mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-10 17:30:37 -10:00
Compare commits
471
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8250d702a2 | ||
|
|
ac75777a3c | ||
|
|
c7f501a744 | ||
|
|
0bb4634250 | ||
|
|
9b4cccff9a | ||
|
|
e03007a7d6 | ||
|
|
e9d3f1515f | ||
|
|
1c5b5aa9f0 | ||
|
|
1b4a48c999 | ||
|
|
e5f47455fb | ||
|
|
808ba4d5e3 | ||
|
|
a1bce7bb64 | ||
|
|
6927f11fdb | ||
|
|
87e31c541e | ||
|
|
212b4523f9 | ||
|
|
904d4155e6 | ||
|
|
ada55f3009 | ||
|
|
cba0a1824a | ||
|
|
50ab9b29aa | ||
|
|
798516bfed | ||
|
|
6547965ee8 | ||
|
|
3c566caa2a | ||
|
|
d1fd7497cf | ||
|
|
f4106b55ac | ||
|
|
61fff0b765 | ||
|
|
bf32eb9d04 | ||
|
|
e3d9896174 | ||
|
|
36b29d568c | ||
|
|
f541f0f989 | ||
|
|
12f3da0f7f | ||
|
|
03add0c889 | ||
|
|
80db609772 | ||
|
|
5aec2ef350 | ||
|
|
7c9d158f4a | ||
|
|
1163fba33a | ||
|
|
d5ca3868d9 | ||
|
|
ebac171a21 | ||
|
|
b2ab9d122b | ||
|
|
6a1f986607 | ||
|
|
1232ad7560 | ||
|
|
fb0ee429a9 | ||
|
|
7deb3b7d65 | ||
|
|
4cb3ce3dc5 | ||
|
|
5fb663a1b1 | ||
|
|
c72576b559 | ||
|
|
4afc1b1390 | ||
|
|
c8399f6dae | ||
|
|
8220e9bb59 | ||
|
|
4be33290a3 | ||
|
|
1e780ac087 | ||
|
|
f623440add | ||
|
|
1cf1bd807f | ||
|
|
6cc82a0899 | ||
|
|
51554eeec0 | ||
|
|
5b01993e0a | ||
|
|
0247dd79f6 | ||
|
|
39b1a8b66d | ||
|
|
8639823415 | ||
|
|
8d55ea6058 | ||
|
|
a36bf2885b | ||
|
|
353e9d2ea5 | ||
|
|
a301b8f35a | ||
|
|
3303f75aea | ||
|
|
924ee31f92 | ||
|
|
0c07c5aa9a | ||
|
|
37c05937b6 | ||
|
|
d4d229dc60 | ||
|
|
990c439434 | ||
|
|
22d4ec4501 | ||
|
|
de405a4021 | ||
|
|
05c03a99ba | ||
|
|
ece38fa4b0 | ||
|
|
e10e33406f | ||
|
|
080e6748fe | ||
|
|
1507652900 | ||
|
|
e6e4803c2f | ||
|
|
f353cb397e | ||
|
|
00cc2551d7 | ||
|
|
d9396e137f | ||
|
|
98986a3501 | ||
|
|
eef701e1a5 | ||
|
|
1244c313ab | ||
|
|
d2115e9d19 | ||
|
|
d0e395e150 | ||
|
|
dba959e2cd | ||
|
|
98772c83d4 | ||
|
|
071a99a8ae | ||
|
|
a676b3f208 | ||
|
|
b939a444ae | ||
|
|
3ef362a2be | ||
|
|
f24c611890 | ||
|
|
f09f0e0aa7 | ||
|
|
df2107142c | ||
|
|
08b9d25f76 | ||
|
|
d11e7b0ded | ||
|
|
3bb0993fbe | ||
|
|
7291bb5c98 | ||
|
|
970a6b58d0 | ||
|
|
1dd6b4113f | ||
|
|
025b12bcd1 | ||
|
|
a5956a6893 | ||
|
|
16bda1cff6 | ||
|
|
c489bd2773 | ||
|
|
50ef77268a | ||
|
|
4e3f166d06 | ||
|
|
c5ff819e47 | ||
|
|
1a65400cd0 | ||
|
|
121b4795a0 | ||
|
|
17edd0014f | ||
|
|
2f6e51d27f | ||
|
|
fb747f2f00 | ||
|
|
fd647ba777 | ||
|
|
0efde04d3b | ||
|
|
c06c297c0f | ||
|
|
fc67a37a27 | ||
|
|
36b9c96762 | ||
|
|
e65f7e8197 | ||
|
|
4d59891a3c | ||
|
|
3b09ada899 | ||
|
|
127f3f40fa | ||
|
|
67725d86eb | ||
|
|
9bf39e226f | ||
|
|
0382525685 | ||
|
|
3027c91083 | ||
|
|
bfe5b238dd | ||
|
|
70b66fbcbc | ||
|
|
041ddab96b | ||
|
|
1aba9924cd | ||
|
|
df25e2d9d6 | ||
|
|
6a0b3bc755 | ||
|
|
fc08bf215e | ||
|
|
52257d8f89 | ||
|
|
2963aeab19 | ||
|
|
34920f5bc0 | ||
|
|
4f16010681 | ||
|
|
8199a0c6b6 | ||
|
|
2d148d0479 | ||
|
|
5c53e891e5 | ||
|
|
be4dcd0729 | ||
|
|
93e263dc43 | ||
|
|
79d97efc49 | ||
|
|
cb570066ab | ||
|
|
781f422694 | ||
|
|
e814804c12 | ||
|
|
342cd85cfa | ||
|
|
74541194c7 | ||
|
|
462885e92e | ||
|
|
d0aac42b62 | ||
|
|
20691af267 | ||
|
|
e17fd5f62c | ||
|
|
98f19efacc | ||
|
|
adca765fb5 | ||
|
|
d673db3468 | ||
|
|
cfa5947d06 | ||
|
|
c624d4cbbd | ||
|
|
50231f6621 | ||
|
|
8b809ef209 | ||
|
|
15053f9ae2 | ||
|
|
09b8e61a77 | ||
|
|
02c3df3764 | ||
|
|
af705c6aa1 | ||
|
|
0ec5224e0d | ||
|
|
4e3428179a | ||
|
|
9a6b930850 | ||
|
|
a4d5059d87 | ||
|
|
52ccbc4a8d | ||
|
|
a15e348774 | ||
|
|
7a4b11dc7c | ||
|
|
76f33e67c6 | ||
|
|
c472505c77 | ||
|
|
25aaceca9b | ||
|
|
bcaa409a94 | ||
|
|
0e0beeaba0 | ||
|
|
5b2c767f78 | ||
|
|
50a58c2b1b | ||
|
|
667a1c7ab6 | ||
|
|
1d29dd990a | ||
|
|
8d0ecf4799 | ||
|
|
a0a99c1ec6 | ||
|
|
ed4dd487c9 | ||
|
|
209b5b7e8e | ||
|
|
7d359d37ea | ||
|
|
88eda46429 | ||
|
|
f700616ec5 | ||
|
|
a4414537b8 | ||
|
|
f411315284 | ||
|
|
ed4f083c74 | ||
|
|
eea6e25352 | ||
|
|
9a41920c3f | ||
|
|
73756aa7ae | ||
|
|
47c093bc55 | ||
|
|
c154833c99 | ||
|
|
60156e704d | ||
|
|
e166a28f4c | ||
|
|
4ede17dfbd | ||
|
|
e6c9c60c38 | ||
|
|
204f753685 | ||
|
|
f1bfaa1dd4 | ||
|
|
dcf0cee2aa | ||
|
|
c8dfb8bb8d | ||
|
|
bf11fd3029 | ||
|
|
e9e67247f6 | ||
|
|
2f0e8c1899 | ||
|
|
ce7263d648 | ||
|
|
7e6ba16882 | ||
|
|
f1ac0298d9 | ||
|
|
03a5f9158b | ||
|
|
2e4ea672b8 | ||
|
|
97ecaf2320 | ||
|
|
72d8544c33 | ||
|
|
cbb5d02d4d | ||
|
|
beea7133c7 | ||
|
|
aa0428474d | ||
|
|
50e3f23898 | ||
|
|
82baa90cac | ||
|
|
e23aa28cd7 | ||
|
|
02139f3052 | ||
|
|
8954094141 | ||
|
|
bb85aef8d4 | ||
|
|
1e1e720b41 | ||
|
|
56811ac0bd | ||
|
|
7075a755a2 | ||
|
|
254eaccdab | ||
|
|
c54235d27f | ||
|
|
7844bcaf02 | ||
|
|
ddad7027a0 | ||
|
|
3d071de8fa | ||
|
|
82ff95db72 | ||
|
|
b606c15806 | ||
|
|
0a32fa79ff | ||
|
|
7bf839736a | ||
|
|
8ca939aafc | ||
|
|
0b86022b73 | ||
|
|
0d425260d6 | ||
|
|
c66f0f93e1 | ||
|
|
877423d303 | ||
|
|
c8266a7299 | ||
|
|
e5c1c3a655 | ||
|
|
68d5884837 | ||
|
|
b1ed90c1bd | ||
|
|
47036178fe | ||
|
|
09ad0d3f5d | ||
|
|
719d615c1a | ||
|
|
9247849087 | ||
|
|
f2f374675e | ||
|
|
cb5d98401e | ||
|
|
b496528acb | ||
|
|
d94b8ce79f | ||
|
|
3f4567edab | ||
|
|
db3e139605 | ||
|
|
76583b21a4 | ||
|
|
2c5a9736c0 | ||
|
|
6ba0de56d4 | ||
|
|
aba207c689 | ||
|
|
9777da5664 | ||
|
|
7ecbe8d4e1 | ||
|
|
a9c1e8a5da | ||
|
|
b56f6dd6d0 | ||
|
|
cdca9c3d11 | ||
|
|
06d2c8914e | ||
|
|
3047856ac7 | ||
|
|
de408360c1 | ||
|
|
2a1ea97946 | ||
|
|
ea0e6ab12d | ||
|
|
fb427b9dec | ||
|
|
352845b215 | ||
|
|
6e874eb9ee | ||
|
|
7ef4df2c22 | ||
|
|
3a27a9d412 | ||
|
|
b88a168a9e | ||
|
|
27b9d801ee | ||
|
|
522cbd42ab | ||
|
|
74057337b6 | ||
|
|
b5ae76b474 | ||
|
|
b66095c2b2 | ||
|
|
53794fbe5a | ||
|
|
00d459dda5 | ||
|
|
dea5d5477e | ||
|
|
2295a1dc58 | ||
|
|
5f5bb4f363 | ||
|
|
6154fbdc9a | ||
|
|
6295d7f6aa | ||
|
|
7f45166b8f | ||
|
|
07775c00f4 | ||
|
|
1a31cb4dd2 | ||
|
|
f46fd24989 | ||
|
|
cd4c6dbe92 | ||
|
|
f17202429f | ||
|
|
929d697107 | ||
|
|
8d8e996d9d | ||
|
|
f29c950790 | ||
|
|
d2048cff8a | ||
|
|
a17990ed3b | ||
|
|
ce5567ab79 | ||
|
|
2a9ad780b5 | ||
|
|
41fc5c90b1 | ||
|
|
ff250f2fdc | ||
|
|
fa97ba530f | ||
|
|
fbb4d5a29a | ||
|
|
a4c18b37b2 | ||
|
|
bd04728936 | ||
|
|
a90c5bb715 | ||
|
|
de243d3086 | ||
|
|
8bb2d272df | ||
|
|
c491827bd7 | ||
|
|
9bcbbca868 | ||
|
|
10739b6108 | ||
|
|
008b84516d | ||
|
|
866b6c5c86 | ||
|
|
5fce62860b | ||
|
|
f7787c509d | ||
|
|
853d480db7 | ||
|
|
f0d2e7c736 | ||
|
|
da1703238e | ||
|
|
0a1809694d | ||
|
|
05d3542f35 | ||
|
|
90a32454a0 | ||
|
|
b73f79e2b2 | ||
|
|
8b35cf7266 | ||
|
|
e5ae69fefd | ||
|
|
de6476d826 | ||
|
|
26ece06051 | ||
|
|
70b3fee4f3 | ||
|
|
8d1867f5db | ||
|
|
8803544c12 | ||
|
|
63c3b583df | ||
|
|
db9619d520 | ||
|
|
8b2cb18112 | ||
|
|
94b2e30c74 | ||
|
|
584b830ff8 | ||
|
|
6883c24da7 | ||
|
|
503424aa09 | ||
|
|
4f4db0fe13 | ||
|
|
d87d1a85cc | ||
|
|
d7910f8980 | ||
|
|
941a2e3149 | ||
|
|
784222d30d | ||
|
|
6b21ce3c86 | ||
|
|
bc5ec05fce | ||
|
|
e5d61983ae | ||
|
|
a4b4b67dae | ||
|
|
e154a5cef1 | ||
|
|
c698838a46 | ||
|
|
a66e1488f1 | ||
|
|
164a67bef2 | ||
|
|
50b17c73d5 | ||
|
|
207ad5f1cb | ||
|
|
2f731b8410 | ||
|
|
58713e0c58 | ||
|
|
30fd7993dc | ||
|
|
eba8fbe4f6 | ||
|
|
236597084f | ||
|
|
4b6a541044 | ||
|
|
7865fd6bac | ||
|
|
f8b8dbe5bf | ||
|
|
9bf770e33f | ||
|
|
8eac96bcd4 | ||
|
|
43f2259ff7 | ||
|
|
ea03680de4 | ||
|
|
275873b507 | ||
|
|
79d06f8db9 | ||
|
|
64f8aa8bc6 | ||
|
|
ad6a522b69 | ||
|
|
a29c78834f | ||
|
|
5048842701 | ||
|
|
0c9e63f10e | ||
|
|
a0177ded00 | ||
|
|
dc4df12fc6 | ||
|
|
233dd16d72 | ||
|
|
79a89d4061 | ||
|
|
257b51b94a | ||
|
|
13d2adf992 | ||
|
|
e7a0b253b0 | ||
|
|
38f4206ed7 | ||
|
|
cb0bf7b943 | ||
|
|
3cd581e3c4 | ||
|
|
d491eca96e | ||
|
|
a0e7e0e196 | ||
|
|
329e2e228d | ||
|
|
16ebb8af07 | ||
|
|
d81456dc3d | ||
|
|
9f9ebe0a51 | ||
|
|
d43b7729f9 | ||
|
|
a2726e8f51 | ||
|
|
08a74ceacb | ||
|
|
1e4b229da0 | ||
|
|
da286abddd | ||
|
|
4f50d12735 | ||
|
|
e7e5192c18 | ||
|
|
1698f4e02a | ||
|
|
f6b2184940 | ||
|
|
76c5f0dbff | ||
|
|
5aec0ef529 | ||
|
|
5f410eeef7 | ||
|
|
2bf9c6606f | ||
|
|
595e9550d1 | ||
|
|
9de91ad43a | ||
|
|
0c13c7c661 | ||
|
|
f8bf875c84 | ||
|
|
1b9f15ee91 | ||
|
|
80f8f9f3b7 | ||
|
|
f7c5a477fa | ||
|
|
63ee7dd793 | ||
|
|
b0ae54ad57 | ||
|
|
0af652dca7 | ||
|
|
a660b47592 | ||
|
|
8752bfe972 | ||
|
|
8865ee6f4f | ||
|
|
97803a8fb3 | ||
|
|
5b00abf712 | ||
|
|
6401fe4be5 | ||
|
|
fbf92f9ce1 | ||
|
|
becce5e932 | ||
|
|
29a9027734 | ||
|
|
f10a19901a | ||
|
|
1fd11b762b | ||
|
|
77bed48ba6 | ||
|
|
6b81d3b6d5 | ||
|
|
adac0dcf51 | ||
|
|
3b7432e41c | ||
|
|
41fce1a5c9 | ||
|
|
82d397156f | ||
|
|
1b96a3b0b8 | ||
|
|
612be91d88 | ||
|
|
2e809917e9 | ||
|
|
ef659f0648 | ||
|
|
fc13c2be3e | ||
|
|
dc57b1e0c1 | ||
|
|
5264b19ae4 | ||
|
|
33547403bf | ||
|
|
d34c7a1aca | ||
|
|
33e6853bc2 | ||
|
|
7f4085e684 | ||
|
|
1aa0c5237e | ||
|
|
cfec550801 | ||
|
|
9ecd28b81c | ||
|
|
76edf9246a | ||
|
|
55e01f2e47 | ||
|
|
27e7fc1ab1 | ||
|
|
1cccc22678 | ||
|
|
168b97fa5f | ||
|
|
12d7faa79d | ||
|
|
fcd621a425 | ||
|
|
cef0dc543a | ||
|
|
ecd8dcc7f0 | ||
|
|
1909f7cf09 | ||
|
|
59f60cb637 | ||
|
|
4b8044816d | ||
|
|
7a12c930b6 | ||
|
|
02dfa8c4a9 | ||
|
|
07f4d35b2a | ||
|
|
e8c8298fc5 | ||
|
|
16b85d9c41 | ||
|
|
f3449bafdd | ||
|
|
777cd5b769 | ||
|
|
15b2526ec2 | ||
|
|
bb0ae3ccd7 | ||
|
|
0db030ce3f | ||
|
|
2adf3c6d84 | ||
|
|
2b4772c785 | ||
|
|
58bd1bb56b | ||
|
|
2b45c8efba | ||
|
|
c21252962e | ||
|
|
c77f5190a1 | ||
|
|
28a2b88a22 | ||
|
|
a3e3d4a0cb | ||
|
|
a28f9b710e | ||
|
|
92a993d37d | ||
|
|
2adf6a10a3 | ||
|
|
7eaf524cc4 | ||
|
|
1483215a45 |
No files matched your search
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
+1327
File diff suppressed because it is too large.
Load diff
@@ -1,42 +1,41 @@
|
||||
# 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
|
||||
OpenRig turns AI coding agents from a pile of terminal sessions into a persistent, organized team. A rig is the team that doesn't go away. If you've got tabs full of agents you're afraid to close, this is the layer you're missing: it's open source, it's local, and you're four commands away.
|
||||
|
||||
```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 conveyor starter rig (4 seats, mixed runtimes)
|
||||
rig up conveyor
|
||||
|
||||
# Open the UI
|
||||
rig up product-team
|
||||
rig ui open
|
||||
```
|
||||
|
||||
After the conveyor rig boots, open the UI and click **intake.lead** in the topology graph. Use **Open CMUX** to jump into a terminal for that node. If cmux is not available, use the tmux attach command shown in the node detail panel instead.
|
||||

|
||||
|
||||
## Launch Walkthrough
|
||||
It runs locally: a daemon, a SQLite database, a CLI, and a dashboard. The agents are ordinary Claude Code and Codex sessions in tmux. `product-team` is the fuller starter, 7 seats, 4 Claude and 3 Codex, with an orchestration HA pair plus development and review pods.
|
||||
|
||||
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.
|
||||
Because 4 Claude seats run at once, single-plan users should expect provider throttling. For the light-footprint path, use `conveyor`, a 4-seat starter with 2 Claude and 2 Codex.
|
||||
|
||||
### 1. Open the conveyor rig
|
||||
```bash
|
||||
# Preview the smaller starter
|
||||
rig specs preview conveyor
|
||||
|
||||
When the UI opens, the explorer is already visible on the left. Click the `conveyor` rig to load its live topology.
|
||||
# Boot the light-footprint path
|
||||
rig up conveyor
|
||||
```
|
||||
|
||||
### 2. Click `CMUX` on `intake.lead`
|
||||
## First Run
|
||||
|
||||
Once the topology loads, go to the `intake.lead` node and click its `CMUX` button. That opens the intake terminal directly.
|
||||
After `product-team` boots, `rig ps` shows the fleet map and `rig ps --nodes --rig product-team` shows the running pods and seats. When you're ready to shut the team down and bring it back, use the same rig name:
|
||||
|
||||
> From here, give the intake agent a packet of work and watch it move through planning, build, review, and closeout.
|
||||
```bash
|
||||
rig ps
|
||||
rig down product-team
|
||||
rig up product-team
|
||||
```
|
||||
|
||||
`rig ps` is a fleet glance. `rig down product-team` snapshots the team and stops it. `rig up product-team` brings it back by name from that snapshot.
|
||||
|
||||
## What It Does
|
||||
|
||||
@@ -54,36 +53,31 @@ Every agent runs in a tmux session you can attach to, inspect, and work with dir
|
||||
|
||||
## Starter Rigs
|
||||
|
||||
OpenRig ships with a conveyor starter rig for learning workflow handoff:
|
||||
|
||||
```bash
|
||||
rig specs preview conveyor --kind rig
|
||||
```
|
||||
|
||||
```
|
||||
conveyor (rig, pod_aware)
|
||||
Starter workflow rig: a station pipeline that can move multiple
|
||||
work packets at once, with queue depth acting as natural backpressure.
|
||||
|
||||
Pod: intake (1 member)
|
||||
lead — claude-code
|
||||
Pod: plan (1 member)
|
||||
planner — codex
|
||||
Pod: build (1 member)
|
||||
builder — claude-code
|
||||
Pod: review (1 member)
|
||||
reviewer — codex
|
||||
```
|
||||
|
||||
For a larger human-operated product-development topology, inspect `product-team`:
|
||||
OpenRig's hero starter is `product-team`, the fuller product-development rig:
|
||||
|
||||
```bash
|
||||
rig specs preview product-team
|
||||
rig up product-team
|
||||
```
|
||||
|
||||
Use it when you want the week-one experience OpenRig is built around: an orchestrator HA pair, development work, review work, and enough moving pieces for the coordination layer to matter.
|
||||
|
||||
For a smaller starter, use `conveyor`:
|
||||
|
||||
```bash
|
||||
rig specs preview conveyor
|
||||
rig up conveyor
|
||||
```
|
||||
|
||||
`conveyor` is the smallest shippable software factory, one command. It keeps the footprint lower for single-plan users while still showing a real handoff path through intake, planning, build, and review.
|
||||
|
||||
Also ships: `implementation-pair`, `adversarial-review`, `research-team`, and `secrets-manager` (HashiCorp Vault managed by a specialist agent).
|
||||
|
||||
Browse the library: `rig specs ls`
|
||||
Browse the library:
|
||||
|
||||
```bash
|
||||
rig specs ls
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ This produces automated proof artifacts in `demo/proof/`:
|
||||
| Artifact | Source | Type |
|
||||
|----------|--------|------|
|
||||
| `up-transcript.txt` | `rig up demo/rig.yaml` output | Automatic |
|
||||
| `ps-nodes.txt` | `rig ps --nodes` after boot | Automatic |
|
||||
| `ps-nodes.txt` | `rig ps --nodes --rig <rig>` after boot | Automatic |
|
||||
| `health-after-boot.json` | `check-demo-health.ts` after boot | Automatic |
|
||||
| `native-resume-after-boot.txt` | immediate native probe after boot | Automatic |
|
||||
| `native-resume-after-boot.json` | immediate native probe machine output | Automatic |
|
||||
@@ -91,7 +91,7 @@ This produces automated proof artifacts in `demo/proof/`:
|
||||
| `down-transcript.txt` | `rig down` output | Automatic |
|
||||
| `tmux-check.txt` | `tmux ls` after teardown | Automatic |
|
||||
| `restore-transcript.txt` | `rig restore <snapshotId> --rig <rigId>` output | Automatic |
|
||||
| `ps-restored.txt` | `rig ps --nodes` after restore | Automatic |
|
||||
| `ps-restored.txt` | `rig ps --nodes --rig <rig>` after restore | Automatic |
|
||||
| `browser-screenshot.png` | Explorer + Graph + Detail Panel | **Manual** |
|
||||
| `resume-test.txt` | Post-restore agent context check | **Manual** |
|
||||
|
||||
|
||||
+3
-2
@@ -35,7 +35,8 @@ echo ""
|
||||
|
||||
# 2. Node status
|
||||
echo "Step 2: Node status after boot..."
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
|
||||
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-nodes.txt"
|
||||
echo ""
|
||||
|
||||
# 3. Health check after boot
|
||||
@@ -95,7 +96,7 @@ echo ""
|
||||
|
||||
# 10. Node status after restore
|
||||
echo "Step 10: Node status after restore..."
|
||||
rig ps --nodes 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
rig ps --nodes --rig "$RIG_ID" 2>&1 | tee "$PROOF_DIR/ps-restored.txt"
|
||||
echo ""
|
||||
|
||||
echo "=== Automated proof artifacts produced ==="
|
||||
|
||||
+3
-2
@@ -32,7 +32,8 @@ fi
|
||||
# Show node status
|
||||
echo ""
|
||||
echo "Node status:"
|
||||
rig ps --nodes
|
||||
# OPR.0.4.4.21: --nodes requires an explicit target outside a managed session.
|
||||
rig ps --nodes --rig "$RIG_ID"
|
||||
|
||||
# Establish a restore-safe baseline for the demo rig.
|
||||
echo ""
|
||||
@@ -55,6 +56,6 @@ echo "=== Demo topology is running ==="
|
||||
echo "Dashboard: http://localhost:5173"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " rig ps --nodes # Check node status"
|
||||
echo " rig ps --nodes --rig '$RIG_ID' # 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"
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Docs — Map of Territory + Module Index
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Starting any technical task that needs the shipped OpenRig system as it
|
||||
actually is. Read this first to learn what the as-built tree contains and
|
||||
which module to open; then go to codemap.md for use-case navigation or
|
||||
straight to the named module.
|
||||
siblings: [codemap.md, cli-reference.md, frontmatter-schema.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# OpenRig As-Built Docs
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system, with a daemon (`@openrig/daemon`), a CLI (`@openrig/cli`), a UI
|
||||
(`@openrig/ui`), and an MCP server all sitting on one SQLite-backed core. This
|
||||
tree is the **source-verified** description of that system as it actually
|
||||
ships — every load-bearing claim is grounded to `packages/*/src` at a named
|
||||
commit, not to memory, chat, or older docs.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages; HEAD carries 6 commits of unreleased 0.3.2 work, no `v0.3.2` tag.
|
||||
|
||||
## How this tree is organized
|
||||
|
||||
The as-built corpus was modularized (slice 08, `context-architecture-v1`) from
|
||||
two monolithic files into a folder of thematic modules, each independently
|
||||
loadable, each frontmatter-tagged for retrieval, each ≤300 lines (author-mode
|
||||
modules with no prior prose may run to ≤400 — see the slice-08 ACK).
|
||||
|
||||
```
|
||||
docs/as-built/
|
||||
├── README.md ← you are here: map-of-territory + index
|
||||
├── codemap.md ← navigation index (use-case lookup, source-root pointers)
|
||||
├── frontmatter-schema.md← the frontmatter convention these docs follow
|
||||
├── cli-reference.md ← full rig CLI surface (kept whole)
|
||||
├── architecture/ ← 14 backend/runtime modules
|
||||
└── ui/ ← 4 operator-surface modules
|
||||
```
|
||||
|
||||
`docs/DESIGN.md` (the canonical visual / brand / design-system spec) **stays
|
||||
at the repo `docs/` root** by design — it is referenced by many existing
|
||||
`docs/DESIGN.md` paths and carries no source drift. This tree points at it
|
||||
(see `ui/library-specs-and-design-system.md`); it is not copied here.
|
||||
|
||||
## Module index
|
||||
|
||||
### `architecture/` — backend, daemon, runtime
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [daemon-core.md](architecture/daemon-core.md) | How the daemon boots, `createDaemon` wiring, the SQLite schema/40-migration set, the route-mount surface. |
|
||||
| [adapters-and-runtimes.md](architecture/adapters-and-runtimes.md) | The five-method RuntimeAdapter contract, the Claude/Codex/Terminal adapters (launch/resume/fork in tmux), and the resume-honesty layer (honest resumed-vs-fresh assessment). |
|
||||
| [coordination-primitive.md](architecture/coordination-primitive.md) | PL-004 Phase A stream/queue/inbox/outbox; the hot-potato closure contract; where queue closure is enforced. |
|
||||
| [workflow-runtime.md](architecture/workflow-runtime.md) | PL-004 Phase D Workflow Runtime — spec cache, instance state, step trails, transactional-scribe projection, watchdog policies. |
|
||||
| [mission-control.md](architecture/mission-control.md) | PL-005 queue-observability surface — seven views, seven write verbs, action audit, bearer-token middleware. |
|
||||
| [agent-spec-and-startup.md](architecture/agent-spec-and-startup.md) | AgentSpec/RigSpec types, profile resolution, additive startup layering, StartupOrchestrator, whoami/materialize/bind/adopt identity. |
|
||||
| [lifecycle-snapshot-restore.md](architecture/lifecycle-snapshot-restore.md) | Snapshot capture, honest restore (resume vs rebuild vs fresh), restore-honesty enforcement, restore-check / restore-packet probes. |
|
||||
| [transport-and-transcripts.md](architecture/transport-and-transcripts.md) | rig send/capture/broadcast over tmux, pipe-pane transcript capture + search, durable SQLite chat, `rig ask`, MCP-name vs tmux-key distinction. |
|
||||
| [workspace-primitive.md](architecture/workspace-primitive.md) | The PL-007 typed workspace declaration (root/repos/defaultRepo/knowledgeRoot), migrations 038/039, per-item repo-scope gating, file-backed missions/slices indexing. |
|
||||
| [content-surfaces.md](architecture/content-surfaces.md) | The operator-allowlisted file browser, atomic conflict-checked writes + JSONL edit audit, PROGRESS.md tree indexer, the one-screen Steering composer. |
|
||||
| [living-notes-review.md](architecture/living-notes-review.md) | **v0.4.4** — the Living Notes review surface: the ONE `ComposedSliceReview` (intent→plan→delivered) projection, pure composer + gatherer, staged-approval locks, C1-bound `verified`, `/api/review/*` routes, freeze export, ranged media serving, and the SDLC on-disk convention it projects. |
|
||||
| [plugin-agent-image-context-pack.md](architecture/plugin-agent-image-context-pack.md) | The filesystem-canonical content layer — plugin discovery, agent images, context packs, the Claude auto-compaction policy enforcer. |
|
||||
| [packaging-bootstrap-bundles.md](architecture/packaging-bootstrap-bundles.md) | Bundle assembly (schema-v2 pod bundles + legacy v1), bundle create/inspect/install + `/api/up`, the staged BootstrapOrchestrator, legacy install seams. |
|
||||
| [architecture-rules-and-event-system.md](architecture/architecture-rules-and-event-system.md) | The cross-cutting invariants — the 25 architecture rules, the RigEvent union + SSE delivery, intentional compatibility limits. |
|
||||
|
||||
### `ui/` — operator surfaces
|
||||
|
||||
| Module | What it covers |
|
||||
|---|---|
|
||||
| [shell-and-routing.md](ui/shell-and-routing.md) | The UI shell (rail / Explorer / center workspace / drawer / preview stack), the actual route tree the shipped UI mounts, the shared detail drawer + event consumption. |
|
||||
| [topology.md](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [project-and-for-you.md](ui/project-and-for-you.md) | The operator destination surfaces — the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, the Dashboard landing on the vellum brand system. |
|
||||
| [library-specs-and-design-system.md](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/skills/plugins/agent-images surfaces, the spec-review + spec-library + live-identity flows, the design-system pointer. |
|
||||
|
||||
### Root docs
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| [codemap.md](codemap.md) | Navigation index — module map, structural relationship diagram, fast-lookup-by-use-case, source-root pointer table. Start here when you know *what* you need but not *which module*. |
|
||||
| [cli-reference.md](cli-reference.md) | The full `rig` CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. Kept as one doc (slice-08 Q4). |
|
||||
| [frontmatter-schema.md](frontmatter-schema.md) | Which frontmatter convention governs every doc in this tree, plus the one as-built-unique field (`last-verified-against-source`). |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (lives at `docs/` root by design; pointer only — not duplicated here). |
|
||||
|
||||
## Source-grounding contract
|
||||
|
||||
Every module declares `last-verified-against-source: <commit-sha>` — the
|
||||
commit its claims were checked against. Load-bearing corrections are recorded
|
||||
inline with an auditable annotation (`> Drift-fix Dx — said X; corrected to Y;
|
||||
slice-00 §z; re-confirmed <file:line> @HEAD`). OPEN items (counts that are
|
||||
definitional or runtime-only) are carried verbatim, never smoothed. The
|
||||
schema is defined in [frontmatter-schema.md](frontmatter-schema.md) and the
|
||||
governing convention is `openrig-work/conventions/frontmatter-for-context/`.
|
||||
+55
-1142
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,228 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Adapters and Runtimes — Claude/Codex/Terminal, tmux/cmux, Resume Honesty
|
||||
status: active
|
||||
topics: [agent-runtime, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need the runtime-adapter contract — how OpenRig launches and resumes a
|
||||
Claude Code, Codex, or terminal harness inside tmux, what the five adapter
|
||||
methods do, or how the daemon honestly assesses whether a harness actually
|
||||
resumed vs fresh-launched (the resume-honesty layer).
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Adapters and Runtimes
|
||||
|
||||
How OpenRig drives the agent harnesses. The daemon never talks to Claude Code,
|
||||
Codex, or a shell directly — it talks to a `RuntimeAdapter`. Three adapters
|
||||
implement one five-method contract; a separate resume-honesty layer answers the
|
||||
question "did this harness *actually* resume, or did it silently fresh-launch?"
|
||||
truthfully rather than optimistically.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). All source in this module is **present at `v0.3.0`**
|
||||
> (`git cat-file -e v0.3.0:<path>` for all 7 files → present) — the adapter
|
||||
> layer is core reboot-era machinery, **not** a 0.3.x feature; do not
|
||||
> back-attribute (§10.8 version-attribution proof technique).
|
||||
|
||||
> Drift-fix (scope) — `architecture.md` §5 "Runtime adapters" carries **no
|
||||
> slice-00 numeric drift** (proposed-structure §4.2: "adapter contract is
|
||||
> current"). The corrections below are *precision* refinements where the source
|
||||
> is more specific than the prose, not stale-count fixes. Each is annotated
|
||||
> inline and re-confirmed at HEAD.
|
||||
|
||||
## 1. The five-method RuntimeAdapter contract
|
||||
|
||||
`RuntimeAdapter` is `packages/daemon/src/domain/runtime-adapter.ts:127`
|
||||
(`interface RuntimeAdapter`). Every adapter declares a `readonly runtime`
|
||||
string and implements exactly five methods (`runtime-adapter.ts:128–153`):
|
||||
|
||||
| Method | Signature (`runtime-adapter.ts`) | Responsibility |
|
||||
|---|---|---|
|
||||
| `listInstalled` | `(binding)` `:131` | List currently installed/projected resources for a node. |
|
||||
| `project` | `(plan, binding)` `:134` | Project resources from a `ProjectionPlan` to the runtime's target locations. |
|
||||
| `deliverStartup` | `(files, binding)` `:137` | Deliver resolved startup files to the runtime. |
|
||||
| `launchHarness` | `(binding, opts)` `:147` | Launch the harness inside the bound tmux session; return a resume token. |
|
||||
| `checkReady` | `(binding)` `:153` | Probe whether the harness is responsive and ready. |
|
||||
|
||||
Startup *action* execution (`slash_command` / `send_text`) is explicitly **not**
|
||||
part of this contract — the contract docstring (`runtime-adapter.ts:121–125`)
|
||||
states actions belong to the `StartupOrchestrator` *after* `checkReady()`. The
|
||||
orchestrator delivery split is in `agent-spec-and-startup.md`.
|
||||
|
||||
### `launchHarness` opts and the fork seam
|
||||
|
||||
`launchHarness` opts is `{ name: string; resumeToken?: string; forkSource?:
|
||||
ForkSource }` (`runtime-adapter.ts:147–150`).
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `launchHarness(binding,
|
||||
> opts: { name, resumeToken? })`. Source adds a third, mutually-exclusive
|
||||
> `forkSource` field. Per the contract docstring (`runtime-adapter.ts:142–146`)
|
||||
> `resumeToken` and `forkSource` are mutually exclusive — if both are provided
|
||||
> the adapter **must refuse** with a clear error, not guess; `forkSource`
|
||||
> triggers a fork and the captured token is the NEW post-fork token, never the
|
||||
> parent. `ForkSource` is `runtime-adapter.ts:116` (`kind: "native_id" |
|
||||
> "artifact_path" | "name" | "last"`; v1 MVP accepts `native_id` only — other
|
||||
> shapes rejected at schema validation, docstring `:106–119`).
|
||||
|
||||
### `HarnessLaunchResult` is a discriminated union with an honest failure arm
|
||||
|
||||
> Drift-fix (precision) — `architecture.md` §4 said `HarnessLaunchResult` is
|
||||
> `{ ok, resumeToken?, resumeType?, error? }` (a single optional-field shape).
|
||||
> Source is a **discriminated union** (`runtime-adapter.ts:81–86`):
|
||||
> `| { ok: true; resumeToken?; resumeType? }`
|
||||
> `| { ok: false; error: string; recovery?: HarnessLaunchRecovery; evidence? }`.
|
||||
> The failure arm carries a typed `recovery` hint
|
||||
> (`HarnessLaunchRecovery = "retry_fresh" | "attention_required"`,
|
||||
> `:79`) and optional `evidence` (last-N pane lines, flowed through to
|
||||
> `RestoreNodeResult.attentionEvidence` for `attention_required` outcomes,
|
||||
> `:83–86`). This is the honest-failure shape, not a smoothed optional `error`.
|
||||
|
||||
## 2. The three adapters
|
||||
|
||||
All three live under `packages/daemon/src/adapters/` and implement
|
||||
`RuntimeAdapter` (Architecture Rule 1: zero Hono in `adapters/`).
|
||||
|
||||
### ClaudeCodeAdapter (`claude-code-adapter.ts:41`)
|
||||
|
||||
- `readonly runtime = "claude-code"` (`:42`).
|
||||
- **Projects** to `.claude/` targets: `guidance_merge` → `<cwd>/CLAUDE.md`
|
||||
(`:127`); `skill_install` → `<cwd>/.claude/skills/<name>/` (`:71,133`);
|
||||
subagents → `.claude/agents`, plugins → `.claude/plugins/<id>`,
|
||||
runtime resources → `.claude/extensions/<id>`, settings fragments merged
|
||||
into `.claude/settings.local.json` (`:388–400`).
|
||||
- **Launches** (`:213–215`): fresh = `claude <permissionMode> --session-id
|
||||
<generatedId> --name <name>`; resume = `claude <permissionMode> --resume
|
||||
<token> --name <name>`; fork = `claude <permissionMode> --resume <parentId>
|
||||
--fork-session --name <seat>` (`:188`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said only "launches via
|
||||
> `claude --name <name>`, resumes via `claude --resume <token>`". Source
|
||||
> shows fresh launch uses an explicit `--session-id` (so a deterministic
|
||||
> resume token exists immediately) and the fork branch exists. Re-confirmed
|
||||
> `claude-code-adapter.ts:188,213–215` @HEAD.
|
||||
- **Readiness** (`checkReady`, `:239`): verifies tmux session alive, captures
|
||||
40 pane lines + pane command, delegates to `assessNativeResumeProbe` (§3);
|
||||
ready only when probe `status === "resumed"`. The resume-launch verification
|
||||
loop `verifyResumeLaunch` retries up to **16 attempts** (`:273`), failing
|
||||
loudly with `recovery: "retry_fresh"` on `no_conversation_found`
|
||||
(`:284–289`) — no silent fresh fallback.
|
||||
|
||||
### CodexRuntimeAdapter (`codex-runtime-adapter.ts:37`)
|
||||
|
||||
- `readonly runtime = "codex"` (`:38`).
|
||||
- **Projects** to `.agents/` targets: `guidance_merge` → `<cwd>/AGENTS.md`
|
||||
(`:139,330`); `skill_install` → `<cwd>/.agents/skills/<name>/` (`:89,145`);
|
||||
skills resolve under `.agents/skills/<id>` (`:377`).
|
||||
- **Launches/resumes** (`:205,225–226`): fresh launch then capture a fresh
|
||||
thread id; resume = `codex<profileArg> resume<queueStateDirArg> <token>`;
|
||||
fork = `codex<profileArg> fork<queueStateDirArg> <parentId>` (`:205`). On
|
||||
success returns `{ ok: true, resumeToken: threadId, resumeType: "codex_id" }`
|
||||
(`:222,245,250`).
|
||||
> Drift-fix (precision) — `architecture.md` §5 said "launches via `codex`,
|
||||
> resumes via `codex resume <threadId>`". Source confirms the `codex resume
|
||||
> <token>` shape (`:226`) and adds the fork branch + the
|
||||
> `resumeType: "codex_id"` tag. Re-confirmed @HEAD.
|
||||
- Refuses `resumeToken` + `forkSource` together with a clear error
|
||||
(`:180–181`) — honors the mutual-exclusivity contract.
|
||||
|
||||
### TerminalAdapter (`terminal-adapter.ts:19`)
|
||||
|
||||
- `readonly runtime = "terminal"` (`:20`).
|
||||
- **All operations are no-ops** — "the shell IS the harness"
|
||||
(`terminal-adapter.ts:15`): no-op `project`/`deliverStartup`/`launchHarness`
|
||||
(`:34`), and `checkReady` returns ready immediately as soon as the tmux
|
||||
session exists (`:47`). Used for infrastructure nodes — servers, log tails,
|
||||
build watchers. (A terminal node cannot fork; the runtime-adapter docstring
|
||||
notes fork-unsupported adapters refuse with a runtime-mismatch error,
|
||||
`runtime-adapter.ts:113–115`.)
|
||||
|
||||
These three are constructed by `createDaemon` step 4 (`startup.ts`; see
|
||||
`daemon-core.md` §4 "Startup sequence").
|
||||
|
||||
## 3. Resume honesty
|
||||
|
||||
The daemon does not assume a harness resumed just because the launch command
|
||||
ran. Three domain files (`packages/daemon/src/domain/`, all present @v0.3.0)
|
||||
make resume assessment honest:
|
||||
|
||||
### `native-resume-probe.ts`
|
||||
|
||||
`assessNativeResumeProbe(input)` (`native-resume-probe.ts:43`) reads pane
|
||||
command + pane content and returns one of four honest statuses
|
||||
(`NativeResumeProbeStatus`, `:6`):
|
||||
|
||||
- `resumed` — runtime-specific indicators confirm a resumed session.
|
||||
- `failed` — terminal failure (e.g. Claude printed "No conversation found" →
|
||||
code `no_conversation_found`, `:51–57`).
|
||||
- `inconclusive` — we don't know yet (e.g. Claude trust gate, code
|
||||
`trust_gate`, `:65–70`).
|
||||
- `attention_required` — alive and recoverable but **needs operator action**
|
||||
(e.g. Claude resume-selection prompt, code `claude_resume_selection_prompt`,
|
||||
`:58–64`). This is the proxy for "an operator must choose the conversation";
|
||||
it is *distinct* from `inconclusive` and `failed` (docstring `:3–5`).
|
||||
|
||||
`buildNativeResumeCommand` (`:27`) builds the resume command per runtime:
|
||||
claude → `claude --resume <token> [--name <name>]` (`:35`); codex → `codex
|
||||
resume <token>` (`:38`); other runtimes → `null` (`:40`).
|
||||
|
||||
This is Architecture Rule 15 in code: a failed resume is FAILED loudly; there
|
||||
is no automatic fresh fallback (the adapter's `verifyResumeLaunch` returns
|
||||
`ok:false` with a `retry_fresh` recovery hint, never a silent relaunch).
|
||||
|
||||
### `resume-metadata-refresher.ts`
|
||||
|
||||
`ResumeMetadataRefresher` (`resume-metadata-refresher.ts:37`). Post-launch
|
||||
resume-token capture: `refresh(sessions)` (`:62`) skips sessions that already
|
||||
have a `resumeToken` (`:65`), and for `claude-code` sessions with a token runs
|
||||
a `probeClaudeResume` returning `"resumable" | "not_resumable" |
|
||||
"inconclusive"` (`:31,74–75`) — a real launch of the resume command in a
|
||||
throwaway probe tmux session (`:106–111`), not a metadata guess.
|
||||
|
||||
### `codex-thread-id.ts`
|
||||
|
||||
Codex thread-id extraction (`codex-thread-id.ts`). Reads the Codex thread id
|
||||
from the Codex *logs* SQLite databases under `~/.codex/`:
|
||||
`readCodexThreadIdFromCandidateHomes(...)` (`:22`) →
|
||||
`readCodexThreadIdFromLogs(...)` (`:49`) → `resolveCodexLogDbPaths(homeDir)`
|
||||
(`:79`) which globs `<homeDir>/.codex/logs_<N>.sqlite` (`:84–89`,
|
||||
regex `^logs_(\d+)\.sqlite$`) and falls back to `logs_1.sqlite` (`:97`).
|
||||
Uses `better-sqlite3` (`:5`). Resolves the home dir by the harness PID
|
||||
(`defaultResolveHomeDirByPid`, `:9`).
|
||||
|
||||
> Precision note — `architecture.md` §5 "Resume honesty" says codex thread IDs
|
||||
> come from "the Codex SQLite database". Source is more specific: the
|
||||
> per-version Codex *logs* DBs `~/.codex/logs_N.sqlite`. Stated precisely here;
|
||||
> re-confirmed `codex-thread-id.ts:79–97` @HEAD.
|
||||
|
||||
## 4. Relevant Architecture Rules (source-verified at HEAD)
|
||||
|
||||
From `architecture.md` §7 (re-confirmed against the source cited inline):
|
||||
|
||||
- **Rule 5** — Runtime is member-authoritative in the pod-aware model.
|
||||
- **Rule 13** — Readiness checking is a retry loop with exponential backoff and
|
||||
a configurable timeout, using adapter-specific probes (Claude TUI indicator,
|
||||
Codex ready message, terminal immediate). Re-confirmed: `checkReady`
|
||||
delegates to `assessNativeResumeProbe`; the retry loop is
|
||||
`claude-code-adapter.ts:273` (16 attempts).
|
||||
- **Rule 14** — Resume states are locked: `resumed` / `rebuilt` / `fresh`;
|
||||
`rebuilt` = new process assembled from artifacts. (The probe layer adds
|
||||
`inconclusive` / `attention_required` as honest *transient* states, not
|
||||
outcomes — see §3.)
|
||||
- **Rule 15** — Restore honesty: failed resume is FAILED loudly; no automatic
|
||||
fresh fallback; fresh launch is an explicit follow-up only. Enforced in code
|
||||
by §2/§3 (`verifyResumeLaunch` returns `ok:false`, never relaunches).
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — where `createDaemon` constructs the three adapters.
|
||||
- `agent-spec-and-startup.md` — the `StartupOrchestrator` that calls these
|
||||
adapters and owns startup-action execution after `checkReady()`.
|
||||
- `lifecycle-snapshot-restore.md` — how persisted resume tokens flow into
|
||||
snapshot/restore (resume vs rebuild vs fresh).
|
||||
- Source roots: `packages/daemon/src/domain/runtime-adapter.ts`,
|
||||
`packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`,
|
||||
`packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts`.
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Agent/Rig Spec, Resolution, Startup, Identity
|
||||
status: active
|
||||
topics: [specification-and-bundles, agent-runtime]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know the canonical AgentSpec/RigSpec/pod-aware reboot types, how
|
||||
profile resolution and additive startup layering work, the StartupOrchestrator
|
||||
pre-launch-vs-interactive delivery split, or how whoami/materialize/bind/adopt
|
||||
resolve and preserve identity.
|
||||
siblings: [daemon-core.md, adapters-and-runtimes.md, lifecycle-snapshot-restore.md, packaging-bootstrap-bundles.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Agent/Rig Spec, Resolution, Startup, Identity
|
||||
|
||||
How the daemon turns authored YAML specs into a resolved, projected, launched,
|
||||
and identity-addressable topology. The spec-and-startup contract: parse →
|
||||
resolve → project → deliver → launch → wait → persist replay context.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version is **0.3.1** across all three
|
||||
> packages (slice-00 §1.1). Source feed located by `architecture.md` headings
|
||||
> (§4 Canonical Reboot Types, §5 Domain Services, §6 Execution Flows, §7
|
||||
> Architecture Rules) per slice-08 §10.1 — line numbers are advisory only.
|
||||
|
||||
## 1. Canonical spec and topology types
|
||||
|
||||
(`architecture.md` §4 "Spec and topology")
|
||||
|
||||
- **AgentSpec** — parsed from `agent.yaml`. Owns imports, defaults, startup,
|
||||
resources, and profiles. Canonical parse/normalize/validate is
|
||||
`domain/agent-manifest.ts`.
|
||||
- **RigSpec** — canonical pod-aware rig topology. Uses `version: "0.2"` and
|
||||
`pods[]`; owns cross-pod `edges[]`, rig-level startup overlays, and
|
||||
`cultureFile`.
|
||||
|
||||
> Drift-fix D-spec-version — `architecture.md` §4 says RigSpec
|
||||
> "Uses `version: "0.2"`". This is the **spec-schema version, NOT the
|
||||
> package version** — a different versioning axis from the 0.3.1 package
|
||||
> version. The value is NOT a code constant: `rigspec-schema.ts:52`
|
||||
> validates `version` only as a "required non-empty string"
|
||||
> (`if (!obj["version"] || typeof obj["version"] !== "string")`); `:163`
|
||||
> carries it through as `raw["version"] as string`. `"0.2"` is the
|
||||
> canonical authored value, correctly described as a schema axis. Do NOT
|
||||
> "correct" it toward 0.3.1 (slice-08 §4.6 binding). Re-confirmed
|
||||
> `rigspec-schema.ts:52,163` @HEAD.
|
||||
|
||||
- **RigServicesSpec** — optional `services` block on a pod-aware RigSpec.
|
||||
Shipped 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, optional continuity policy.
|
||||
- **RigSpecPodMember** — member-level runtime/startup surface: `agentRef`,
|
||||
`profile`, `runtime`, `model?`, `cwd`, `restorePolicy?`, member startup
|
||||
overlays.
|
||||
- **Pod** — persisted DB entity for a pod.
|
||||
- **ContinuityState** — persisted live continuity row keyed by
|
||||
`podId + nodeId`.
|
||||
|
||||
## 2. Execution and projection types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — restore/snapshot members are
|
||||
detailed in `lifecycle-snapshot-restore.md`; the spec/projection members live
|
||||
here)
|
||||
|
||||
- **ResolvedNodeConfig** — output of profile resolution. Carries effective
|
||||
runtime/model/cwd, narrowed restore policy, selected resources, layered
|
||||
startup block, resolved spec identity.
|
||||
- **ProjectionPlan** — runtime projection plan for a node: runtime, cwd,
|
||||
projection entries, startup block, diagnostics, conflict/no-op
|
||||
classifications.
|
||||
- **RuntimeAdapter** — the five-method contract (adapter detail in
|
||||
`adapters-and-runtimes.md`): `listInstalled(binding)`
|
||||
(`runtime-adapter.ts:131`), `project(plan, binding)` (`:134`),
|
||||
`deliverStartup(files, binding)` (`:137`), `launchHarness(binding, opts)`
|
||||
(`:147`), `checkReady(binding)` (`:153`). Re-confirmed @HEAD — the contract
|
||||
is current.
|
||||
- **HarnessLaunchResult** — `{ ok, resumeToken?, resumeType?, error? }`
|
||||
returned by `launchHarness`.
|
||||
- **StartupOrchestrator** — drives the full startup sequence (§4 below).
|
||||
|
||||
## 3. Parsing, validation, resolution pipeline
|
||||
|
||||
(`architecture.md` §5 "Parsing and validation" + "Resolution pipeline"; all
|
||||
under `packages/daemon/src/domain/`, zero Hono imports per Architecture Rule 1)
|
||||
|
||||
**Parse / validate:**
|
||||
|
||||
- `agent-manifest.ts` — canonical AgentSpec parse/normalize/validate.
|
||||
- `rigspec-schema.ts` — dual-format RigSpec validation.
|
||||
- `rigspec-codec.ts` — dual-format YAML codec.
|
||||
- `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, incl. topology preview, provenance state, managed-app
|
||||
services metadata (`waitFor`, `surfaces`, `composePreview`).
|
||||
|
||||
**Resolve:**
|
||||
|
||||
- `agent-resolver.ts` — resolves `agent_ref`, imports, collision metadata.
|
||||
- `agent-preflight.ts` — single-agent resolution/preflight.
|
||||
- `profile-resolver.ts` — applies defaults, profile uses, resource selection,
|
||||
startup layering, restore-policy narrowing.
|
||||
- `startup-resolver.ts` — additive startup layering.
|
||||
- `projection-planner.ts` — runtime resource projection planning.
|
||||
|
||||
All eleven files re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 4. Startup orchestration (the spec-startup contract)
|
||||
|
||||
(`architecture.md` §4 "StartupOrchestrator" + §5 "Startup, runtime, and
|
||||
instantiation" + §7 Architecture Rule 6)
|
||||
|
||||
`StartupOrchestrator` (`domain/startup-orchestrator.ts`) drives:
|
||||
mark pending → project resources → deliver pre-launch files → launch harness
|
||||
→ wait for ready → deliver interactive files → execute actions → persist
|
||||
context → mark ready.
|
||||
|
||||
**Pre-launch vs interactive delivery split** — the load-bearing seam,
|
||||
re-confirmed at source:
|
||||
|
||||
- Pre-launch (filesystem, before harness boot): `guidance_merge`,
|
||||
`skill_install` (`startup-orchestrator.ts:78,167` — "Deliver pre-launch
|
||||
files (guidance_merge, skill_install → filesystem)").
|
||||
- Post-launch (TUI, after harness is ready): `send_text`
|
||||
(`startup-orchestrator.ts:82,160,263` — partition by concrete hint at
|
||||
`:141`, deliver after readiness at `:263`).
|
||||
|
||||
The orchestrator persists replay context including the resume token for future
|
||||
restores (consumed by `lifecycle-snapshot-restore.md`).
|
||||
|
||||
**Architecture Rule 6 — startup layering is additive and ordered** (carried
|
||||
verbatim, `architecture.md` §7): (1) agent base, (2) profile, (3) rig culture
|
||||
file, (4) rig startup, (5) pod startup, (6) member startup, (7) operator debug
|
||||
append. This is the spec-startup contract's invariant; the full 25-rule list
|
||||
lives in `architecture-rules-and-event-system.md`.
|
||||
|
||||
**Startup action constraints** (`architecture.md` §7 "Current startup action
|
||||
constraints"): no shell startup actions; action types are `slash_command` and
|
||||
`send_text` only; non-idempotent actions must not apply on restore; retrying
|
||||
failed startup is handled as restore.
|
||||
|
||||
**Remote import constraints** (§7): the reboot supports `local:...` and
|
||||
`path:/abs/...`. Remote `agent_ref` sources remain unsupported and fail in
|
||||
preflight.
|
||||
|
||||
## 5. Instantiation, preflight, export
|
||||
|
||||
(`architecture.md` §5 "Startup, runtime, and instantiation" + §6 "RigSpec
|
||||
import / validate / preflight / export")
|
||||
|
||||
- `runtime-adapter.ts` — adapter contract + bridge types.
|
||||
- `rigspec-preflight.ts` — dual-stack legacy preflight plus rebooted
|
||||
`rigPreflight(...)`.
|
||||
- `rigspec-instantiator.ts` — dual-stack `RigInstantiator` plus
|
||||
`PodRigInstantiator`.
|
||||
- `rigspec-exporter.ts` — dual-format live rig export to YAML/JSON.
|
||||
- `pod-repository.ts` — pod CRUD plus live continuity-state CRUD.
|
||||
|
||||
`routes/rigspec.ts` is the dual-format seam: validate
|
||||
(pod-aware → `RigSpecSchema.validate`; legacy → `LegacyRigSpecSchema.validate`),
|
||||
preflight (`rigPreflight({ rigSpecYaml, rigRoot, fsOps })` vs
|
||||
`RigSpecPreflight.check(spec)`), import
|
||||
(`podInstantiator.instantiate(yaml, rigRoot)` vs
|
||||
`RigInstantiator.instantiate(spec)`), export (pod-aware exports canonical
|
||||
`version: "0.2"` RigSpec; legacy exports flat-node v1).
|
||||
|
||||
## 6. Identity: whoami, materialize, bind, adopt
|
||||
|
||||
(`architecture.md` §6 "Whoami and adopted-session parity flow" +
|
||||
"Materialize / bind / adopt flow")
|
||||
|
||||
**Whoami resolution** — the daemon owns the truth surface through
|
||||
`/api/whoami` (`routes/whoami.ts`); tmux metadata is an adopted-session
|
||||
anchor, not sovereign truth. `whoami-service.ts:8` declares
|
||||
`resolvedBy: "node_id" | "session_name"`. The route requires `nodeId` or
|
||||
`sessionName` (`routes/whoami.ts:10-15`). Resolution order:
|
||||
explicit `--node-id` → explicit `--session` → env vars → tmux metadata →
|
||||
raw tmux session-name fallback.
|
||||
|
||||
- Managed sessions prefer projected `OPENRIG_NODE_ID` /
|
||||
`OPENRIG_SESSION_NAME`.
|
||||
- Adopted sessions use tmux-owned metadata written at claim/bind time.
|
||||
|
||||
> Drift-nuance D5 (the careful axis — see also `transport-and-transcripts.md`):
|
||||
> the tmux metadata keys `@rigged_node_id` / `@rigged_session_name` /
|
||||
> `@rigged_rig_id` / `@rigged_rig_name` / `@rigged_logical_id` in
|
||||
> `architecture.md` §6 are **CORRECT as-is** and were verified literally at
|
||||
> source — `claim-service.ts:77-81` writes exactly these `@rigged_*` keys;
|
||||
> `claim-service.ts` and `rig-lifecycle-service.ts` read them. This is a
|
||||
> SEPARATE axis from MCP tool names (which are `rig_*`, slice-00 §1.4). Do
|
||||
> NOT blanket-replace `rigged` → `rig` (banked
|
||||
> `feedback_release_prep_three_layer_depersonalization`: the rename was
|
||||
> scoped, not global). The tmux metadata keys were NOT renamed.
|
||||
> Re-confirmed `claim-service.ts:77-81` @HEAD.
|
||||
|
||||
**Materialize / bind / adopt** (`architecture.md` §6): `POST
|
||||
/api/rigs/import/materialize` creates a pod-aware topology without launching
|
||||
sessions; `POST /api/discovery/:id/bind` attaches a discovered live session to
|
||||
an existing logical node; `POST /api/discovery/:id/adopt` is the composite
|
||||
route (bind to existing node, or create a new member in a target pod and bind
|
||||
immediately). CLI mirrors via `rig bind` / `rig adopt`. Authored pod namespace
|
||||
is preserved through adoption so logical ids stay
|
||||
`${podNamespace}.${memberName}` (Architecture Rule 24: adopted-session parity
|
||||
is tmux-metadata parity, not fake env-var parity).
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D-spec-version (resolved-as-correct):** RigSpec `version: "0.2"` is the
|
||||
spec-schema axis, validated only as a non-empty string in source. Documented,
|
||||
not "corrected".
|
||||
- **D5 (careful, resolved-as-correct for this module):** tmux `@rigged_*`
|
||||
metadata keys are literally still `@rigged_*` at HEAD — architecture.md §6 is
|
||||
accurate. The MCP-tool-name `rigged_*` drift lands in
|
||||
`transport-and-transcripts.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — createDaemon wiring; the migration/route surface.
|
||||
- `adapters-and-runtimes.md` — the five-method RuntimeAdapter contract detail.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore consumes persisted replay
|
||||
context.
|
||||
- `architecture-rules-and-event-system.md` — the full 25 architecture rules.
|
||||
- Source roots: `packages/daemon/src/domain/{agent-manifest,rigspec-schema,
|
||||
profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`,
|
||||
`packages/daemon/src/routes/{rigspec,whoami}.ts`.
|
||||
@@ -0,0 +1,216 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Architecture Invariants, Event System, Compatibility Notes
|
||||
status: active
|
||||
topics: [runtime-control, observability, doctrine]
|
||||
domains: [engineering-advisor, operating-advisor, review]
|
||||
applies-when: |
|
||||
Need the cross-cutting architecture invariants the codebase enforces (the
|
||||
25 architecture rules + startup/import constraints), the shape of the
|
||||
RigEvent union and its SSE delivery surfaces, or the intentional
|
||||
compatibility limits that still describe the shipped system.
|
||||
siblings: [daemon-core.md, coordination-primitive.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Architecture Invariants, Event System, Compatibility Notes
|
||||
|
||||
This module collects the cross-cutting invariants that do not belong to any
|
||||
single subsystem: the architecture rules the codebase holds itself to, the
|
||||
event-system shape, and the intentional compatibility limits.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`); package version **0.3.1** across all three packages
|
||||
> (slice-00 §1.1). HEAD carries 6 commits of unreleased 0.3.2 work; no
|
||||
> `v0.3.2` tag exists.
|
||||
|
||||
## 1. Architecture rules
|
||||
|
||||
These are the invariants the codebase is built to preserve (`architecture.md`
|
||||
§7). Rule 6 (startup layering) and rule 7 (restore-policy narrowing) are the
|
||||
spec/startup contract — see `agent-spec-and-startup.md` for their flow detail;
|
||||
they are restated here as system-level invariants.
|
||||
|
||||
1. Zero Hono in `domain/` and `adapters/`.
|
||||
2. Routes depend on the domain; the domain never depends on routes.
|
||||
3. Shared DB-handle invariants are enforced at construction time.
|
||||
4. The reboot is engine-first: domain services land before public-surface
|
||||
rewiring.
|
||||
5. Runtime is member-authoritative in the pod-aware model.
|
||||
6. Startup layering is additive and ordered: agent base → profile → rig
|
||||
culture file → rig startup → pod startup → member startup → operator debug
|
||||
append.
|
||||
7. Restore-policy narrowing is one-way only: `resume_if_possible` →
|
||||
`relaunch_fresh` → `checkpoint_only`.
|
||||
8. Base/import collisions warn; ambiguous import/import unqualified refs fail
|
||||
loudly.
|
||||
9. Bundle assembly and startup-file resolution use containment checks rooted
|
||||
in the owning artifact.
|
||||
10. Restore replay uses classification-free projection intent, not stale
|
||||
startup-time `no_op` / conflict classifications.
|
||||
11. Startup status is explicit session state: `pending`, `ready`, `failed`.
|
||||
12. Session recency depends on monotonic ULIDs: `session-registry.ts` uses
|
||||
`monotonicFactory()`; restore selects the newest session by max ULID.
|
||||
13. Readiness checking is a retry loop with exponential backoff and
|
||||
configurable timeout, using adapter-specific probes (Claude TUI
|
||||
indicator, Codex ready message, terminal immediate).
|
||||
14. Resume states are locked: `resumed` / `rebuilt` / `fresh`. `rebuilt` =
|
||||
new process assembled from artifacts.
|
||||
15. Restore honesty: failed resume is FAILED loudly. No automatic fresh
|
||||
fallback. Fresh launch is explicit follow-up only.
|
||||
16. Post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
17. Session naming: `{pod}-{member}@{rig}` — human-authored,
|
||||
system-validated. No generation, no slugification.
|
||||
18. Communication: tmux is transport, not truth. `send/capture/broadcast`
|
||||
wrap tmux reliably with honest errors.
|
||||
19. Transcripts: raw capture via pipe-pane, ANSI strip on read. `rg`
|
||||
preferred, `grep -E` fallback.
|
||||
20. Config precedence: CLI flag > env var > config file
|
||||
(`~/.openrig/config.json`, with legacy fallback from
|
||||
`~/.rigged/config.json`) > default.
|
||||
21. Semi-deterministic calibration: build what agents use constantly. Agent
|
||||
handles edge cases from error messages.
|
||||
22. `rig ask` is context engineering: gathers evidence, does NOT call an
|
||||
external LLM. The agent IS the LLM.
|
||||
23. Spec library truth is YAML on disk; daemon owns the structured
|
||||
review/index/cache layer.
|
||||
24. Adopted-session parity is tmux-metadata parity, not fake env-var parity.
|
||||
25. Human-readable IDs are UI-only presentation helpers. CLI/API/MCP/backend
|
||||
keep full canonical ids.
|
||||
|
||||
### Startup action constraints
|
||||
|
||||
- No shell startup actions.
|
||||
- Action types are `slash_command` and `send_text` only.
|
||||
- Non-idempotent actions must not apply on restore.
|
||||
- Retrying failed startup is handled as restore.
|
||||
|
||||
### Remote import constraints
|
||||
|
||||
The reboot supports `local:...` and `path:/abs/...` agent refs. Remote
|
||||
`agent_ref` sources remain unsupported and fail in preflight
|
||||
(`architecture.md` §7 "Remote import constraints"; restated in compat note 1).
|
||||
|
||||
## 2. Event system
|
||||
|
||||
The daemon's event surface is the single `RigEvent` discriminated union.
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §8
|
||||
> says "PL-004 Phase A adds 9 coordination events" and (in §3) "Existing 32
|
||||
> PL-004 events" / "Existing 20 PL-004 events" — internally inconsistent
|
||||
> contested PL-004 sub-counts. **Do NOT carry the 9 / 32 / 20 figures.** The
|
||||
> `architecture.md` §8 "Currently emitted in production code" / "Present in
|
||||
> the union but not yet emitted" lists also predate 0.3.x and are stale. The
|
||||
> union shape below is re-derived from source at HEAD, not migrated.
|
||||
|
||||
`RigEvent` is declared at `packages/daemon/src/domain/types.ts:94`
|
||||
(`export type RigEvent =`) and runs through `types.ts:218`. It has **73
|
||||
union members** (slice-00 §1.8, re-confirmed at HEAD:
|
||||
`grep -cE '^\s*\| \{ type:'` over L94–218 = 73). Every one of the 73
|
||||
declared `type:` literals is also constructed somewhere in
|
||||
`packages/daemon/src/{domain,routes}` (re-verified at HEAD: the set of
|
||||
`type: "<x>"` literals in domain+routes exactly equals the 73 union members
|
||||
— zero union-only-but-never-referenced types).
|
||||
|
||||
### Per-prefix event families (grep-verified at HEAD)
|
||||
|
||||
Each count below is a fresh, primary-source-grep-verified, explicitly-labeled
|
||||
per-prefix family count over the union body
|
||||
(`types.ts:94–218`) — NOT the contested PL-004 sub-count (OPEN-4 ruling:
|
||||
labeled grep-verified family counts are ground truth; the `9`/`32`/`20`
|
||||
figures are forbidden).
|
||||
|
||||
| Prefix | Members | Sample / role |
|
||||
|---|---|---|
|
||||
| `node.*` | 7 | `node.added` (`types.ts:97`) … `node.startup_failed` (`:139`) — lifecycle/startup |
|
||||
| `workflow.*` | 6 | PL-004 Phase D workflow runtime (detail in `workflow-runtime.md`) |
|
||||
| `watchdog.*` | 5 | `watchdog.evaluation_fired` (`:187`) … `watchdog.job_stopped` (`:191`) — PL-004 Phase C |
|
||||
| `rig.*` | 5 | `rig.created` / `rig.deleted` / `rig.imported` / `rig.stopped` / `rig.expanded` (`:151`) |
|
||||
| `queue.*` | 5 | PL-004 Phase A queue lifecycle (`:156`–`:159`, `:169`; detail in `coordination-primitive.md`) |
|
||||
| `package.*` | 5 | legacy package/install engine events |
|
||||
| `mission_control.*` | 5 | PL-005 audit/notification (`:212`–`:218`; detail in `mission-control.md`) |
|
||||
| `bootstrap.*` | 5 | legacy bootstrap-run events |
|
||||
| `session.*` | 4 | session discovery / status / detach / vanish |
|
||||
| `classifier.*` | 4 | PL-004 Phase B classifier-lease lifecycle |
|
||||
| `restore.*` | 3 | restore start/complete/reconcile (detail in `lifecycle-snapshot-restore.md`) |
|
||||
| `qitem.*` | 2 | `qitem.fallback_routed` (`:160`), `qitem.closure_overdue` (`:161`) |
|
||||
| `pod.*` | 2 | `pod.created` (`:135`), `pod.deleted` (`:136`) |
|
||||
| `inbox.*` | 2 | `inbox.absorbed` (`:162`), `inbox.denied` (`:163`) |
|
||||
| `continuity.*` | 2 | `continuity.sync` (`:140`), `continuity.degraded` (`:141`) |
|
||||
| singletons | 11 | one member each: `workflow_spec.*`, `view.*`, `stream.*` (`:155`), `snapshot.*`, `seat.*`, `project.*`, `kernel.*`, `chat.*` (`:149`), `bundle.*`, `binding.*`, `agent.*` |
|
||||
|
||||
Family counts sum to 73 (15 multi-member families totalling 62 + 11
|
||||
singletons), re-confirmed at HEAD.
|
||||
|
||||
### Emission and delivery
|
||||
|
||||
Events are constructed and emitted via `eventBus.emit({ type: ... })` across
|
||||
domain services (`stream-store.ts`, `workflow-runtime.ts`,
|
||||
`restore-orchestrator.ts`, `node-launcher.ts`, etc.) and route handlers. The
|
||||
event log is append-only and SQLite-backed.
|
||||
|
||||
Three SSE delivery surfaces (re-confirmed at HEAD):
|
||||
|
||||
- `GET /api/events` — global stream of all events (`server.ts:457`
|
||||
`app.route("/api/events", eventsRoute)`).
|
||||
- `GET /api/stream/watch` — new stream items (`routes/stream.ts:117`).
|
||||
- `GET /api/queue/watch` — queue/inbox coordination events
|
||||
(`routes/queue.ts:357`).
|
||||
- The chat SSE stream `GET /api/rigs/:rigId/chat/watch` delivers
|
||||
`chat.message` for one rig (rig-scoped; see compat note 6).
|
||||
|
||||
> OPEN-4 (carried verbatim, slice-00): the precise PL-004-only vs PL-005-only
|
||||
> sub-partition is NOT asserted here. `architecture.md`'s `32`/`20`/`9`
|
||||
> figures are internally inconsistent and not reconcilable without
|
||||
> classifying all 73 members by introducing slice — flagged, not smoothed.
|
||||
> The grep-verified per-prefix family counts above are the substituted ground
|
||||
> truth.
|
||||
|
||||
## 3. Remaining compatibility notes
|
||||
|
||||
Intentional limits that still describe the shipped system (`architecture.md`
|
||||
§11), verified as still-current at HEAD:
|
||||
|
||||
1. Remote `agent_ref` imports remain unsupported (see §1 remote import
|
||||
constraints).
|
||||
2. Startup actions remain intentionally constrained (`slash_command`,
|
||||
`send_text`).
|
||||
3. Legacy compatibility seams still ship for pre-reboot data and v1
|
||||
artifacts.
|
||||
4. `rig ask` gathers context only — does not call an external LLM (rule 22).
|
||||
5. Transcript search prefers `rg`, falls back to `grep -E`; quality/perf
|
||||
varies by backend.
|
||||
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
|
||||
auto-inject service URLs/tokens into agent prompts beyond authored
|
||||
startup/context files.
|
||||
11. Specialist delegation is conventional, not automatic — addressed by
|
||||
session name or normal communication surfaces.
|
||||
|
||||
## 4. Cross-references
|
||||
|
||||
`architecture.md` §12 names itself the architecture-level source of truth and
|
||||
points at `codemap.md` for file-by-file structure. Under the modular
|
||||
as-built, that role is distributed: this module owns the invariants + event
|
||||
shape; the source-of-truth pointer is the rewritten `../codemap.md`
|
||||
navigation index.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — wiring, DB, migrations, startup; footprint drift-fixes.
|
||||
- `coordination-primitive.md` — PL-004 Phase A queue/stream/inbox/outbox
|
||||
events.
|
||||
- `workflow-runtime.md` — PL-004 Phase D `workflow.*` events.
|
||||
- `mission-control.md` — PL-005 `mission_control.*` events.
|
||||
- Source roots: `packages/daemon/src/domain/types.ts` (RigEvent union),
|
||||
`packages/daemon/src/routes/{stream,queue}.ts` (SSE watch),
|
||||
`packages/daemon/src/server.ts` (`/api/events`).
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
status: active
|
||||
topics: [observability, specification-and-bundles]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-allowlisted file browser enforces path
|
||||
safety, how atomic conflict-checked writes + the JSONL edit audit work,
|
||||
how the workspace PROGRESS.md tree is indexed, or how the one-screen
|
||||
Steering surface (priority stack + roadmap rail + lane rails + health
|
||||
gates) is composed. Author-mode module — no prior architecture.md prose;
|
||||
every load-bearing claim is sourced to file:line at HEAD.
|
||||
siblings: [workspace-primitive.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, workspace-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Content Surfaces — Files Browser, Atomic Write, Progress Tree, Steering Composer
|
||||
|
||||
> **See also (v0.4.4):** the Living Notes review surface — the composed
|
||||
> intent→plan→delivered projection over this same allowlisted content layer —
|
||||
> is documented in [`living-notes-review.md`](living-notes-review.md).
|
||||
|
||||
|
||||
The **content surfaces** are the operator-allowlisted, filesystem-canonical
|
||||
read/write layer that the Project / Steering UI sits on: a fail-closed file
|
||||
browser, an atomic conflict-checked write service with a JSONL edit audit,
|
||||
the recursive PROGRESS.md tree indexer, and the one-screen Steering composer
|
||||
(+ its compact health-summary gates). None adds SQLite state.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative
|
||||
> to `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** This is the content-surfaces half of the
|
||||
> `workspace-and-content-primitives` SPLIT (sibling `workspace-primitive.md`
|
||||
> carries the workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices). See `workspace-primitive.md` § header SPLIT
|
||||
> NOTE for the §10.6 rationale and the transparent-noted-deviation
|
||||
> disclosure.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `domain/files/{path-safety,file-write-service}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/progress/progress-indexer.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/steering/{steering-composer,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{files,progress,steering,health-summary}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` — every listed path
|
||||
> resolves at `v0.3.0` (no failures). All content surfaces are
|
||||
> **≤0.3.0** features (UI Enhancement Pack v0 + Operator Surface
|
||||
> Reconciliation v0; slice-00 0.3.0-GT §1.7 "Files / Markdown /
|
||||
> Progress / Proofs" confirms the cluster shipped 0.3.0). Do **not**
|
||||
> back-attribute any of these to 0.3.1 — the gap is missing
|
||||
> architecture.md prose, not a new feature.
|
||||
|
||||
## 1. Path safety — the fail-closed allowlist (UI Enhancement Pack v0)
|
||||
|
||||
The file browser is gated by a daemon-enforced, **fail-closed** allowlist.
|
||||
Roots are decoded from `OPENRIG_FILES_ALLOWLIST` (env), comma-separated
|
||||
`<name>:<absolute-path>` pairs (legacy fallback `RIGGED_FILES_ALLOWLIST`
|
||||
via `||` so an empty string falls through). Invalid pairs (no colon, empty
|
||||
name, non-absolute path) are **silently skipped**; duplicate names →
|
||||
last-wins. Empty/unset → no roots → routes return an empty list with a
|
||||
structured "configure OPENRIG_FILES_ALLOWLIST" hint. The safe default after
|
||||
a fresh install is **nothing allowlisted**.
|
||||
|
||||
> Source: `domain/files/path-safety.ts:50-51` (env vars),
|
||||
> `:60-93` (`decodeAllowlist` / `readAllowlistFromEnv`; silent-skip
|
||||
> L67-71, last-wins L78, `||` fallback comment L89-91) @HEAD.
|
||||
|
||||
`resolveAllowedPath(allowlist, rootName, relativePath)` is the load-bearing
|
||||
guard. Algorithm: (1) reject unknown root (`root_unknown`); (2) reject
|
||||
expressed-intent `..` segments **before** filesystem resolution
|
||||
(`path_escape`) — segment-split so `foo..bar` is not a false positive;
|
||||
(3) reject an absolute `relativePath` (`path_invalid`); (4) resolve
|
||||
symlinks via `fs.realpathSync` and reject when the realpath does not start
|
||||
with `<canonicalRoot><sep>` (`path_escape`) — a `path.sep` boundary so
|
||||
`/foo/bar` does not match `/foo/bar-other`; (5) `path === ""` resolves to
|
||||
the root itself (so callers can list the root). Symlinks **inside** the
|
||||
tree resolve normally; symlinks pointing **outside** are escape attempts.
|
||||
A non-existent candidate falls back to the unresolved path so the
|
||||
subsequent stat surfaces the absence with a specific code.
|
||||
`resolveAllowedFile` / `resolveAllowedDirectory` add a stat assertion
|
||||
(`not_a_file` / `not_a_directory`).
|
||||
|
||||
> Source: `domain/files/path-safety.ts:95-171` (`resolveAllowedPath`;
|
||||
> `..`-before-fs L124-140, absolute-reject L141-147, sep-boundary
|
||||
> L158-169, empty-path base case in the doc-comment L108-110),
|
||||
> `:173-225` (file/dir convenience asserts) @HEAD.
|
||||
|
||||
## 2. Atomic write + JSONL audit (UI Enhancement Pack v0 item 4)
|
||||
|
||||
`FileWriteService.writeAtomic(req)` is the operator-actionable write surface
|
||||
for `STEERING.md` / `PROGRESS.md` / spec YAML / any allowlisted file.
|
||||
Sequence: (1) resolve target under allowlist (reuses §1 path-safety);
|
||||
(2) re-stat + re-hash the target — if `mtime !== expectedMtime` OR
|
||||
`contentHash !== expectedContentHash`, throw `WriteConflictError` carrying
|
||||
the **current** mtime + hash for the UI to surface (optimistic-concurrency,
|
||||
not a lock); (3) write to a temp file in the **same directory** (PID +
|
||||
random suffix so concurrent writes don't collide); (4) `fsync` the temp fd
|
||||
for durability before the rename; (5) atomic `renameSync` over the target
|
||||
(single inode swap on POSIX — readers see old or new, never partial);
|
||||
(6) recompute mtime + hash + byte delta; (7) append one JSONL audit row.
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:9-25` (documented
|
||||
> atomic-write semantics), `:111-212` (`writeAtomic`; conflict-detect
|
||||
> L129-135, same-dir temp L137-141, fsync L146, atomic rename L161)
|
||||
> @HEAD.
|
||||
|
||||
The audit file is `~/.openrig/file-edit-audit.jsonl` by default
|
||||
(`HOME`/`USERPROFILE` → `/tmp` fallback). **Append-only; never rotates at
|
||||
v0.** A failed audit append throws `FileWriteError("audit_write_failed")`
|
||||
but does **NOT** undo the landed write — the user's edit succeeded; an
|
||||
audit-system flake must not silently revert canon. (The route still
|
||||
surfaces it as a 500 so the failure is honest, not swallowed.)
|
||||
|
||||
> Source: `domain/files/file-write-service.ts:87-91` (default path +
|
||||
> fallback), `:24-25` (never-rotates-at-v0), `:182-204` (audit append;
|
||||
> "does NOT undo the write" comment L183-184) @HEAD.
|
||||
|
||||
## 3. Files routes — browse + read + asset + write
|
||||
|
||||
`filesRoutes()` (mounted `server.ts:507` as `/api/files`). All routes are
|
||||
**literal** (no `/:param` catchall) so order doesn't matter for shadowing
|
||||
(Phase A R1 SSE lesson):
|
||||
|
||||
- `GET /roots` — allowlist root list (or `{roots:[], hint}` when none).
|
||||
- `GET /list?root&path` — directory entries (dirs first, then files,
|
||||
name-sorted; dotfiles always included — allowlisting a root expresses
|
||||
inspection intent).
|
||||
- `GET /read?root&path` — file content + `mtime` + `contentHash` (SHA-256)
|
||||
+ `size`. **Content is capped at `FILE_READ_TRUNCATION_BYTES` (1 MB)** but
|
||||
the hash is computed over the **FULL** file so atomic-write conflict
|
||||
detection stays honest even on a truncated read; response carries
|
||||
`truncated` / `truncatedAtBytes` / `totalBytes`.
|
||||
- `GET /asset?root&path` — raw bytes for embedded images/video/pdf (inferred
|
||||
`Content-Type`, 5-min cache). **v0.4.4 (OPR.0.4.4.20 FR-5):** serves HTTP
|
||||
**byte ranges** — single-range form, `206` + `Content-Range` +
|
||||
`Accept-Ranges: bytes` (malformed/unsatisfiable → `416`) — the
|
||||
iOS-Safari-class media-playback requirement; and `.html` renders as
|
||||
`text/html` ONLY under the explicit `?render=1` opt-in (text/plain stays
|
||||
the default). The slice proof-asset route (`/api/slices/:name/proof-asset/*`)
|
||||
carries the same range semantics (living-notes corrective). See
|
||||
[`living-notes-review.md`](living-notes-review.md) §5.
|
||||
- `POST /write` — atomic write (§2); 503 when no write service
|
||||
(`OPENRIG_FILES_ALLOWLIST` empty); 409 `write_conflict` on stale
|
||||
mtime/hash with the current values for the UI's refresh prompt.
|
||||
|
||||
503 `files_routes_unavailable` when the allowlist dep is unwired.
|
||||
Path-safety errors map to HTTP per code (`root_unknown`/`path_*` → 400,
|
||||
`stat_failed` → 404).
|
||||
|
||||
> Source: `routes/files.ts:51-242` (handlers; route-order comment
|
||||
> L17-19, truncation L139-154, write 503/409 L187-191/L225-231,
|
||||
> path-safety→HTTP `:61-69`); `FILE_READ_TRUNCATION_BYTES = 1_048_576`
|
||||
> `:49`; mounted `server.ts:507`; CLI: no `rig files` verb — files are
|
||||
> a UI-only surface @HEAD.
|
||||
|
||||
> **§10.7 route-reality (source-verified at `packages/ui/src/routes.tsx`):**
|
||||
> `/files` is a **REAL route** (`FilesWorkspace`) the Phase-8.1 survey
|
||||
> MISSED. There is **NO `/markdown` route** — markdown rendering is a
|
||||
> *component* inside file / drawer surfaces, not a navigable destination.
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:192-195` (`path: "/files"` +
|
||||
> `FilesWorkspace`); grep-confirmed no `path: "/markdown"` declaration
|
||||
> anywhere in routes.tsx @HEAD.
|
||||
|
||||
## 4. Progress tree indexer (UI Enhancement Pack v0 item 1B)
|
||||
|
||||
`ProgressIndexer` walks operator-allowlisted scan roots
|
||||
(`OPENRIG_PROGRESS_SCAN_ROOTS`, same `<name>:<abs-path>` shape as the file
|
||||
allowlist; legacy `RIGGED_PROGRESS_SCAN_ROOTS`), finds **`PROGRESS.md` AND
|
||||
`STEERING.md`** files (STEERING.md is anchored as the constraint-frame node
|
||||
per OSR v0 Item 2; the row-machinery is filename-agnostic), and parses each
|
||||
into a checkbox-hierarchy tree. Recursion is depth-bounded (default 6 —
|
||||
enough for mission/lane/slice nesting without descending into
|
||||
`node_modules`/`.git`/`.worktrees`/`dist`/`build`/`.turbo`/`.next` or
|
||||
dotfiles). Checkbox status: `[x]` → `done`, `[~]` → `blocked`, `[ ]` →
|
||||
`active`; headings (`##`–`####`) become hierarchy rows; depth from
|
||||
2-space indent or heading level. Per-file `counts` + a scan-wide
|
||||
`aggregate`. **In-memory walk per request; no caching at v0** (bounded by
|
||||
the operator's allowlist scope).
|
||||
|
||||
> Source: `domain/progress/progress-indexer.ts:79-84` (STEERING anchor +
|
||||
> env vars), `:81` (SKIP_DIRS), `:71` (`DEFAULT_MAX_DEPTH = 6`),
|
||||
> `:218-235` (checkbox status mapping), `:204-215` (heading rows),
|
||||
> `:21-23` (no-caching-at-v0) @HEAD.
|
||||
|
||||
`progressRoutes()` (mounted `server.ts:508` as `/api/progress`):
|
||||
`GET /tree` — the indexed hierarchy; 503 `progress_indexer_unavailable`
|
||||
when unwired; 503 `progress_scan_roots_not_configured` + setup hint when
|
||||
no roots.
|
||||
|
||||
> Source: `routes/progress.ts:18-40` (handler; 503s L29-35); mounted
|
||||
> `server.ts:508` @HEAD.
|
||||
|
||||
> **§10.7 route-reality:** `/progress` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the progress system lives at the daemon
|
||||
> `/api/progress` route, but the UI URL folds into Project tabs (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:464-468` ("folds into Project tabs
|
||||
> (Phase 3) — redirect to /project") @HEAD.
|
||||
|
||||
## 5. Steering composer (Operator Surface Reconciliation v0 item 1)
|
||||
|
||||
`SteeringComposer.compose()` is the **one-screen composed steering surface**.
|
||||
It is deliberately narrow: it composes only the **filesystem-derived**
|
||||
pieces; the UI fetches PL-005 queue views (in-motion / loop-state) and
|
||||
health gates via their own existing endpoints so the composer stays
|
||||
testable. Three sources, all resolved from a single workspace root
|
||||
(`OPENRIG_STEERING_WORKSPACE`; per-piece overrides
|
||||
`OPENRIG_STEERING_PATH` / `OPENRIG_ROADMAP_PATH` /
|
||||
`OPENRIG_DELIVERY_READY_DIR` trump the root-derived defaults; legacy
|
||||
`RIGGED_STEERING_WORKSPACE`):
|
||||
|
||||
- **priorityStack** — verbatim `STEERING.md` content + mtime + byteCount.
|
||||
- **roadmapRail** — `roadmap/PROGRESS.md` checkbox rows; detects `PL-XXX`
|
||||
rail-item codes; marks the **first unchecked** item as `isNextUnchecked`.
|
||||
- **laneRails** — `delivery-ready/mode-{N}/PROGRESS.md` per-lane: reuses
|
||||
`ProgressIndexer` (maxDepth 3) for identical checkbox semantics to the
|
||||
Progress view; "next pull" = first non-done, non-blocked row (Priority
|
||||
Rail Rule — shelf/queue recency does NOT override); top-N (default 3)
|
||||
prefers active+blocked, falls back to done only to fill N.
|
||||
|
||||
Missing sources are reported in `unavailableSources` (each names the env
|
||||
var that would resolve it) rather than failing the whole payload.
|
||||
`isReady()` is true when **at least one** source resolves.
|
||||
|
||||
> Source: `domain/steering/steering-composer.ts:1-25` (narrow-by-design
|
||||
> rationale + 3-source list), `:103-119` (env vars +
|
||||
> `steeringOptsFromEnv`), `:155-169` (`isReady` / `compose`), `:198-250`
|
||||
> (roadmap rail; next-unchecked L231-235), `:282-316` (lane next-pull
|
||||
> L288-289, top-N L293-296), `RAIL_CODE_REGEX` `:346` @HEAD.
|
||||
|
||||
`steeringRoutes()` (mounted `server.ts:510` as `/api/steering`):
|
||||
`GET /` — the composed payload; 503 `steering_composer_unavailable` when
|
||||
unwired; 503 `steering_workspace_not_configured` + a hint pointing at
|
||||
`rig config init-workspace` / `workspace.steering_path` /
|
||||
`OPENRIG_STEERING_PATH` when no source resolves.
|
||||
|
||||
> Source: `routes/steering.ts:18-34` (handler; 503s L23-29); mounted
|
||||
> `server.ts:510` @HEAD.
|
||||
|
||||
> **§10.7 route-reality:** `/steering` is a **`<Navigate to="/project" />`
|
||||
> redirect-stub** — the composer lives at daemon `/api/steering`; the UI
|
||||
> URL folds into the Project workspace overview tab (Phase 3).
|
||||
>
|
||||
> Source: `packages/ui/src/routes.tsx:470-474` ("folds into Project
|
||||
> workspace overview tab (Phase 3) — redirect to /project") @HEAD.
|
||||
|
||||
## 6. Health-summary aggregator (OSR v0 item 1F)
|
||||
|
||||
`computeNodeHealthSummary` / `computeContextHealthSummary` are the compact
|
||||
health gates on the steering surface — **daemon-side aggregation, not a CLI
|
||||
shell-out** (no per-request subprocess). Node summary: cross-rig roll-up of
|
||||
`sessionStatus` + `lifecycleState` via `getNodeInventory` per rig, with an
|
||||
`attentionRequired` tally. Context summary: reads `context_usage` directly
|
||||
(ContextUsageStore exposes per-node accessors only; listing all rows is a
|
||||
steering-surface concern) and buckets by urgency
|
||||
(`usedPercentage` ≥80 critical / ≥60 warning / else low / null unknown) +
|
||||
freshness (`sampledAt` age vs 300 s → fresh/stale/none). A missing
|
||||
`context_usage` table (test harness without the migration) yields an empty
|
||||
summary, not an error.
|
||||
|
||||
> Source: `domain/steering/health-summary.ts:44-46` (thresholds:
|
||||
> `FRESHNESS_THRESHOLD_S=300`, `URGENCY_CRITICAL_PCT=80`,
|
||||
> `URGENCY_WARNING_PCT=60`), `:48-66` (node roll-up), `:75-117`
|
||||
> (context roll-up; table-absent → empty L81-84) @HEAD.
|
||||
|
||||
`healthSummaryRoutes()` (mounted `server.ts:511` as `/api/health-summary`):
|
||||
`GET /nodes`, `GET /context`; 503 `health_summary_unavailable` when the
|
||||
rigRepo dep is unwired.
|
||||
|
||||
> Source: `routes/health-summary.ts:23-44` (handlers; 503 L33,L40);
|
||||
> mounted `server.ts:511` @HEAD.
|
||||
|
||||
## 7. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite:** all four surfaces read/write the
|
||||
filesystem + the JSONL audit; health-summary reads existing
|
||||
`context_usage` only. No content-surface migration
|
||||
(`progress-indexer.ts:21-23`; `health-summary.ts:81-84`) @HEAD.
|
||||
- **Fail-closed, honest errors:** path-safety rejects every escape attempt
|
||||
before fs resolution; write conflicts surface the current mtime/hash for
|
||||
an explicit refresh; missing config returns 503 + a setup hint naming the
|
||||
exact env var — never a silent empty success
|
||||
(`path-safety.ts:124-169`; `file-write-service.ts:129-135`;
|
||||
`routes/steering.ts:25-29`) @HEAD.
|
||||
- **Atomic-or-nothing writes:** same-directory temp + fsync + atomic rename;
|
||||
a rename failure leaves the target untouched (never a partial write)
|
||||
(`file-write-service.ts:160-172`) @HEAD.
|
||||
- **Composer narrowness is deliberate:** the steering composer owns only
|
||||
filesystem-derived pieces; queue/health come from their own endpoints —
|
||||
keeps it testable (`steering-composer.ts:6-16`) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — the JSONL edit audit at `~/.openrig/file-edit-audit.jsonl`
|
||||
**never rotates at v0** by explicit design
|
||||
(`file-write-service.ts:24-25`); unbounded growth is a known accepted v0
|
||||
state (PRD § Item 4 defers rotation), stated here rather than smoothed.
|
||||
- **OPEN-B** — `OPENRIG_FILES_ALLOWLIST` vs `OPENRIG_PROGRESS_SCAN_ROOTS`
|
||||
vs `OPENRIG_STEERING_WORKSPACE` are three independent operator-config
|
||||
surfaces with the same encoding but separate resolution; there is no
|
||||
single "workspace allowlist" that unifies them at v0. Source-real;
|
||||
noted, not reconciled.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim.
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed coordination primitive works — the
|
||||
stream/queue/inbox/outbox tables, the hot-potato closure contract, the
|
||||
transactional handoff guarantee, or where queue closure is enforced.
|
||||
siblings: [workflow-runtime.md, mission-control.md, daemon-core.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Coordination Primitive — Stream/Queue/Inbox/Outbox (PL-004 Phase A)
|
||||
|
||||
The PL-004 Phase A coordination primitive is the SQLite-canonical
|
||||
durable-work layer the daemon exposes through `/api/stream` and `/api/queue`.
|
||||
It replaces the POC filesystem `rigx queue` / `rigx stream` path for
|
||||
daemon-backed work; the POC filesystem path remains untouched and the daemon
|
||||
`rig queue` / `rig stream` write only to SQLite (`architecture.md` §5 L651).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 1. The five host-scoped tables
|
||||
|
||||
Five host-scoped tables back the primitive (`architecture.md` §3 L416–438;
|
||||
migrations re-confirmed at HEAD `packages/daemon/src/db/migrations/`):
|
||||
|
||||
- **`stream_items`** (`023_stream_items.ts`) — L1 append-only intake/audit
|
||||
root. Columns: `stream_item_id` (ULID PK), `ts_emitted`,
|
||||
`stream_sort_key`, `source_session`, `body`, `format` (default `text`),
|
||||
`hint_type`, `hint_urgency`, `hint_destination`, `hint_tags` (JSON),
|
||||
`interrupt`, `archived_at`. Items immutable after emit (only `archived_at`
|
||||
may be set).
|
||||
- **`queue_items`** (`024_queue_items.ts`) — L3 owned-work queue. `qitem_id`
|
||||
is TEXT PK preserving the POC `qitem-YYYYMMDDHHMMSS-<hex>` shape. State
|
||||
enum (8 values): `pending | in-progress | done | blocked | failed |
|
||||
denied | canceled | handed-off`. Carries `closure_reason`,
|
||||
`closure_target`, `closure_required_at`, `chain_of_record` (JSON),
|
||||
`blocked_on`, `handed_off_to`/`handed_off_from`, nudge/heartbeat fields.
|
||||
- **`queue_transitions`** (`025_queue_transitions.ts`) — L3 append-only
|
||||
transition log; authoritative audit trail for state evolution. No
|
||||
UPDATE/DELETE from domain code.
|
||||
- **`inbox_entries`** (`026_inbox_entries.ts`) — mailbox-style asynchronous
|
||||
deposit; idempotent on `inbox_id`. State: `pending | absorbed | denied`.
|
||||
- **`outbox_entries`** (`027_outbox_entries.ts`) — sender-side audit;
|
||||
symmetric to inbox; idempotent on `outbox_id`. Delivery state:
|
||||
`pending | delivered | failed`.
|
||||
|
||||
> Drift-fix D3 — `architecture.md` §3 L243 frames the schema as "27
|
||||
> migrations (22 existing plus 5 added by PL-004 Phase A)". The 5 PL-004
|
||||
> Phase A coordination tables (`023`–`027`) are still correct as a *range*,
|
||||
> but the headline migration count is **40**, not 27 (slice-00 §1.3,
|
||||
> re-confirmed at HEAD: `startup.ts:206` 40-element `migrate()` array; full
|
||||
> drift-fix authority and the correction live in `daemon-core.md`).
|
||||
|
||||
## 2. The six host-scoped services
|
||||
|
||||
Six host-scoped domain services implement the layer (`architecture.md` §5
|
||||
L649–658; files re-confirmed in `packages/daemon/src/domain/` at HEAD).
|
||||
Routes import these; services are Hono-free:
|
||||
|
||||
- **`stream-store.ts`** — L1 stream: idempotent emit (on `stream_item_id`),
|
||||
chronological list with cursor pagination, soft archive.
|
||||
- **`queue-repository.ts`** — L3 queue: create, claim/unclaim, update
|
||||
(general state mutator with hot-potato strict-rejection on `done`),
|
||||
transactional handoff (close source as `handed-off` plus create new owned
|
||||
qitem in a single transaction), pod-fallback rerouting, overdue lookup,
|
||||
nudge/heartbeat tracking. Cross-rig validation hook exposed as
|
||||
`validateRig` constructor option.
|
||||
- **`queue-transition-log.ts`** — append-only state-transition log; used by
|
||||
`queue-repository.ts`, exposed read-only on
|
||||
`queue_repository.transitionLog`.
|
||||
- **`hot-potato-enforcer.ts`** — pure validator for the load-bearing API
|
||||
contract (see §3).
|
||||
- **`inbox-handler.ts`** — mailbox handler: authenticated drop (idempotent
|
||||
on `inbox_id`), absorb (promotes a pending entry to a `queue_item`,
|
||||
idempotent), deny (records reason). Auth check is a pluggable constructor
|
||||
hook.
|
||||
- **`outbox-handler.ts`** — sender-side outbox: idempotent record, mark
|
||||
delivered/failed, list. Emits no event-bus events (pure audit).
|
||||
|
||||
## 3. The hot-potato closure contract (where queue closure is enforced)
|
||||
|
||||
`hot-potato-enforcer.ts` is the pure validator for the load-bearing API
|
||||
contract. Re-confirmed at HEAD (`hot-potato-enforcer.ts:10–24`):
|
||||
|
||||
`state=done` requires `closure_reason ∈ {handed_off_to, blocked_on, denied,
|
||||
canceled, no-follow-on, escalation}`. The reasons
|
||||
`handed_off_to | blocked_on | escalation` additionally require
|
||||
`closure_target`:
|
||||
|
||||
- `handed_off_to` — work continues with a different seat (`closure_target` =
|
||||
new owner).
|
||||
- `blocked_on` — work is parked pending another qitem (`closure_target` =
|
||||
blocker `qitem_id`).
|
||||
- `denied` — receiver rejected the work (`closure_target` = reason text).
|
||||
- `canceled` — sender or receiver withdrew (`closure_target` = note).
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (`closure_target` = escalation
|
||||
target).
|
||||
|
||||
Tier→SLA mapping for `closure_required_at` also lives in
|
||||
`hot-potato-enforcer.ts`. This validator is invoked by
|
||||
`QueueRepository.update()` (and `updateWithinTransaction()`), so closure is
|
||||
enforced at the daemon transaction boundary — the workflow runtime
|
||||
*projects* on closure but does not gate it (see `workflow-runtime.md`).
|
||||
|
||||
## 3b. Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
|
||||
|
||||
The queue's two coordination WRITE verbs — **create + handoff(-and-complete)
|
||||
only** — are host-aware: a write body may carry an out-of-band `hostId`
|
||||
envelope (BR-1 — session strings stay `member@rig` everywhere; the 3-part
|
||||
`agent@rig@host` form is CLI input sugar that never leaves the CLI edge).
|
||||
The mechanism **generalizes the shipped mission-control forward-then-strip
|
||||
WRITE template** (`routes/mission-control.ts` § remote action): one shared
|
||||
route-layer helper (`forwardQueueWrite`, `routes/queue.ts`) resolves the
|
||||
host registry daemon-side (bearers never reach the caller), rejects
|
||||
ssh-declared hosts (`unsupported-transport` — the daemon→daemon path is
|
||||
http-only, because it is what fires the remote nudge), strips `hostId`, and
|
||||
forwards the WHOLE body via `remoteJsonRequest` under a named write-class
|
||||
deadline (`QUEUE_FORWARD_TIMEOUT_MS`). The origin's response returns
|
||||
verbatim; transport failures map to a structured host-named taxonomy
|
||||
(`remote_queue_write_failed`: unknown-host / unsupported-transport /
|
||||
unreachable / auth-failed / remote-error). No local row is ever written on
|
||||
the cross-host path.
|
||||
|
||||
**The model: origin-owns-the-record, at-least-once + idempotent,
|
||||
message-passing closure (never 2PC).**
|
||||
|
||||
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that
|
||||
row is THE record. The target daemon's OWN `maybeNudge` fires on ITS local
|
||||
tmux (the forwarded body includes the `nudge` flag) — the sending daemon
|
||||
never reaches across a host boundary.
|
||||
- **Idempotency (arch Q-a).** The forwarding daemon MINTS the `qitemId`
|
||||
before the first forward, so every retry carries the same id; dedup rides
|
||||
the existing `qitem_id TEXT PRIMARY KEY`. On PK conflict the origin
|
||||
returns the stored row when identity fields match (idempotent absorb) and
|
||||
a structured `qitem_id_reuse` error when they differ
|
||||
(`QueueRepository.create()` catch-path + `isQitemPrimaryKeyConflict`).
|
||||
- **Cross-host handoff choreography (arch Q-c).** The local atomic
|
||||
close+create cannot span two DBs, so the route-layer choreography
|
||||
(`crossHostHandoff`, `routes/queue.ts`) runs: successor-create on the
|
||||
target host FIRST (via the one forward helper), local source-close SECOND
|
||||
(`QueueRepository.closeCrossHostHandoffSource`) — never the reverse. A
|
||||
crash between the two leaves a live duplicate that the idempotent re-drive
|
||||
converges; the reverse order would leave a closed source pointing at a
|
||||
successor that does not exist (a dropped potato — the one forbidden
|
||||
outcome). The successor id is DERIVED, not minted:
|
||||
`deriveCrossHostSuccessorId(source, destination, host)` →
|
||||
`qitem-xh-<sha256[:16]>` — a pure stateless function, so a re-drive
|
||||
re-derives the same id across daemon restarts and absorbs on the target
|
||||
PK. *(Named residual, inherent to the at-least-once/no-2PC fence: a
|
||||
re-drive naming a DIFFERENT destination derives a different id and cannot
|
||||
absorb the earlier successor — that orphan stays visible via the chain +
|
||||
provenance tags; the source-close conflict check surfaces the
|
||||
disagreement.)*
|
||||
- **Closure across the boundary.** The source closes with
|
||||
`closure_reason=handed_off_to` and `closure_target=member@rig@<host>` —
|
||||
the 3-part form is legal THERE because `closure_target` is OPAQUE
|
||||
audit/display metadata, presence-checked and never parsed for routing
|
||||
(arch R1; any PR parsing it as a session string is a spec violation).
|
||||
The exemption spans exactly the `closure_target` column on `queue_items`
|
||||
and its verbatim mirror on `queue_transitions` — nothing else. In
|
||||
particular the transition log's free-text `transition_note` is a durable
|
||||
carrier too: the minted cross-host close note names the 2-part
|
||||
`toSession` only (rev1-r2 B1). `handed_off_to` and every other
|
||||
session-string carrier stay 2-part.
|
||||
Re-drive semantics: already-terminal + MATCHING `closure_target` = absorb;
|
||||
MISMATCH = structured `cross_host_close_conflict` (409). The successor
|
||||
carries `chain_of_record = [...source.chain, source.qitemId]` — A-side ids
|
||||
are opaque lineage identifiers on B (arch R2b; they do not dereference in
|
||||
B's DB) — plus provenance tags `cross-host` + `from-host:<self-declared
|
||||
name>` (honest best-effort, not authenticated identity).
|
||||
- **Boundary discipline.** Claim / update / inbox stay local-by-principle
|
||||
(after a cross-host handoff the successor lives where its worker lives);
|
||||
sender-side ops on a forwarded item are a named follow-up. The local
|
||||
(no-host) paths are byte-identical to pre-MH-3 behavior; the hot-potato
|
||||
validation contract (§3) is unweakened across the boundary.
|
||||
|
||||
## 4. Coordination events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md`
|
||||
> §3 L394 says "Existing 32 PL-004 events untouched" and L410 says "Existing
|
||||
> 20 PL-004 events are unchanged" — these two body claims are internally
|
||||
> inconsistent with each other and predate 0.3.x. **Do NOT carry either
|
||||
> number.** The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 union members** total (slice-00 §1.8, re-confirmed at HEAD:
|
||||
> `grep -cE '^\s*\| \{ type: ' = 73`). A precise PL-004-only vs PL-005-only
|
||||
> sub-partition is not reconcilable from static inspection without
|
||||
> classifying all 73 members by introducing slice; slice-00 OPEN-4 flags
|
||||
> this and it is carried, not smoothed.
|
||||
|
||||
Coordination events emitted by these services (re-confirmed in
|
||||
`domain/types.ts` at HEAD): `stream.emitted` (StreamStore.emit,
|
||||
`types.ts:155`); `queue.created` / `queue.handed_off` / `queue.claimed` /
|
||||
`queue.unclaimed` / `qitem.fallback_routed` / `qitem.closure_overdue`
|
||||
(QueueRepository); `inbox.absorbed` (`types.ts:162`) / `inbox.denied`
|
||||
(InboxHandler). `architecture.md` §8 L987–990 listed these as "9
|
||||
coordination events"; the count of the broader `stream|queue|inbox|qitem`
|
||||
event family at HEAD is 10 (`stream` 1 + `queue` 5 + `inbox` 2 + `qitem` 2)
|
||||
— stated as a re-derived family count, not as a contested PL-004 sub-count
|
||||
(OPEN-4).
|
||||
|
||||
Two SSE surfaces stream coordination events: `/api/stream/watch`
|
||||
(aliased `/api/stream/sse`, `routes/stream.ts:117–118`) for new stream
|
||||
items, and `/api/queue/watch` for queue/inbox events. The event log remains
|
||||
append-only and SQLite-backed.
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
- `/api/stream` (`server.ts:489`) — `POST /emit` (`routes/stream.ts:24`),
|
||||
`GET /list` (`:56`), `GET /watch` + `/sse` SSE (`:117`),
|
||||
`GET /:streamItemId` (`:121`), `POST /:streamItemId/archive` (`:130`).
|
||||
- `/api/queue` (`server.ts:490`) — `POST /create` (`routes/queue.ts:99`),
|
||||
`POST /:qitemId/claim` (`:144`), `POST /:qitemId/unclaim` (`:157`),
|
||||
`POST /:qitemId/update` (`:170`), plus handoff/list/watch surfaces.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — daemon wiring, the 40-migration set, route surface.
|
||||
- `workflow-runtime.md` — PL-004 Phase D runtime that projects on closure.
|
||||
- `mission-control.md` — PL-005 queue observability over `queue_items`.
|
||||
- Source roots: `packages/daemon/src/domain/{stream-store,queue-repository,
|
||||
queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`,
|
||||
`packages/daemon/src/routes/{stream,queue}.ts`.
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Daemon Core — Wiring, DB, Migrations, Startup
|
||||
status: active
|
||||
topics: [runtime-control, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon boots, how createDaemon wires the dependency
|
||||
graph, the SQLite schema/migration set, or the route-mount surface.
|
||||
siblings: [coordination-primitive.md, agent-spec-and-startup.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Daemon Core — Wiring, DB, Migrations, Startup
|
||||
|
||||
OpenRig is a local control plane for multi-agent coding topologies. The daemon
|
||||
(`@openrig/daemon`) is the framework-free SQLite-backed core that the CLI
|
||||
(`@openrig/cli`), UI (`@openrig/ui`), and MCP server all sit on top of.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Current package version is **0.3.1** across all three
|
||||
> packages (`package.json` root `"version": "0.3.1"`; slice-00 §1.1). HEAD
|
||||
> carries 6 commits of unreleased release-0.3.2 work; no `v0.3.2` tag exists.
|
||||
|
||||
## 1. System overview
|
||||
|
||||
The system has six architectural layers (`architecture.md` §1 L23–30):
|
||||
|
||||
1. **AgentSpec / pod-aware core** — spec parsing, resolution, precedence,
|
||||
startup orchestration, snapshot/restore, bundles.
|
||||
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.
|
||||
5. **Rig environment layer** — rig-scoped services records, Compose-backed
|
||||
service orchestration, readiness gates, env snapshot/restore integration.
|
||||
6. **Agent-managed software layer** — managed-app classification, app-focused
|
||||
browse/review/runtime UI, and the canonical `secrets-manager` example.
|
||||
|
||||
Legacy flat-node/package flows remain for backward compatibility.
|
||||
|
||||
### Verified source footprint at HEAD `7eaf524c`
|
||||
|
||||
> Drift-fix D1/D6 — `architecture.md` L6,11 said `OpenRig v0.2.0` /
|
||||
> "Current v0.2.0 release verification" and L7 said `376` source files. Both
|
||||
> are a frozen pre-v0.2.0-era header (slice-00 §1.9 — last edited `72982bb2`,
|
||||
> 2026-03-30). Corrected to the values below.
|
||||
|
||||
| Metric | Value @ HEAD | Source (independently re-confirmed) |
|
||||
|---|---|---|
|
||||
| Package version | **0.3.1** (HEAD +6 unreleased 0.3.2 commits) | slice-00 §1.1; `package.json:version` |
|
||||
| Total source footprint | **601** files | slice-00 §1.6; `find packages/*/src` non-test = 279+87+235 |
|
||||
| Daemon footprint | **279** total / **173** domain / **49** route mounts (46 route files) / **11** adapters / **40** migrations | slice-00 §1.5/§1.6; `find packages/daemon/src` non-test |
|
||||
| CLI footprint | **87** files | slice-00 §1.6; `find packages/cli/src` non-test |
|
||||
| UI footprint | **235** files | slice-00 §1.6; `find packages/ui/src` `.ts`+`.tsx` non-test |
|
||||
|
||||
> Footprint counts use the `find packages/*/src -type f \( -name '*.ts' -o
|
||||
> -name '*.tsx' \) ! -name '*.test.*'` predicate (tests live in separate
|
||||
> `packages/*/test/` dirs). Counts are reproducible with that predicate; a
|
||||
> different counting convention shifts absolute numbers (slice-00 OPEN-5).
|
||||
|
||||
### The stack
|
||||
|
||||
> Drift-fix D2 — `architecture.md` L37,125 said `CLI (53 command groups)`.
|
||||
> Corrected to **58** at HEAD (slice-00 §1.2: 53 at v0.2.0 tag, 56 at v0.3.0,
|
||||
> 57 at v0.3.1, **58 at HEAD** — the 58th is `scopeCommand()` added post-0.3.1
|
||||
> by `0b77cba4`, re-confirmed `index.ts:20,187`).
|
||||
>
|
||||
> Drift-fix D4 — `architecture.md` L40 said `31 route groups` and L76 said
|
||||
> "`createApp()` now mounts 22 route groups". Corrected to **49 `app.route()`
|
||||
> mounts + 4 dedicated handlers** (slice-00 §1.5; re-confirmed `server.ts`
|
||||
> 49×`app.route(` L450–513, plus `app.get("/healthz")` :446,
|
||||
> `handleExportYaml` :461, `handleExportJson` :462, `app.get("*")` :519).
|
||||
> Carry slice-00 **OPEN-2**: "route-group count is definitional" — there is
|
||||
> no single canonical "route group" definition in source; reported as mount
|
||||
> count + dedicated handlers.
|
||||
>
|
||||
> Drift-fix D3 — `architecture.md` L54 said `SQLite state (36 migrations)`.
|
||||
> Corrected to **40** (slice-00 §1.3; re-confirmed below).
|
||||
|
||||
```text
|
||||
CLI (58 command groups) / UI (explorer + workspace + drawer) / MCP (17 tools)
|
||||
|
|
||||
v
|
||||
Hono daemon routes (49 app.route() mounts + 4 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 (managed-app enrichment + compose preview)
|
||||
+-- whoami identity + context-usage route
|
||||
+-- coordination routes (stream / queue / workflow / mission-control)
|
||||
|
|
||||
v
|
||||
Framework-free domain services (173 daemon domain files)
|
||||
|
|
||||
+-- SQLite state (40 migrations)
|
||||
+-- tmux / cmux / resume adapters
|
||||
+-- runtime adapters (Claude Code / Codex / Terminal)
|
||||
+-- RigEnv substrate (compose adapter, readiness, orchestrator)
|
||||
+-- transport / transcript / chat / ask layers
|
||||
+-- whoami identity service
|
||||
```
|
||||
|
||||
The core product loop: `down (auto-snapshot) → up <rig-name> (auto-restore) →
|
||||
handoff → inspect/attach → work → repeat`.
|
||||
|
||||
> MCP tool count is **17** (`rig_*`) — slice-00 §1.4 confirms the count is
|
||||
> correct and the names are `rig_*` (NOT `rigged_*`; the `rigged_*` text in
|
||||
> `architecture.md` §2 is stale — the rename predates v0.2.0). Per-occurrence
|
||||
> verification of any `rigged_*` reference lands in `transport-and-transcripts.md`
|
||||
> (D5); the tmux `@rigged_*` metadata keys are a separate axis, not blanket-replaced.
|
||||
|
||||
## 2. Database schema
|
||||
|
||||
> Drift-fix D3 — `architecture.md` L243 said "27 migrations (22 existing plus
|
||||
> 5 added by PL-004 Phase A)" and L1002 repeated "27 migrations"; the §1 stack
|
||||
> diagram L54 said "36 migrations". All corrected to **40** migrations
|
||||
> (`001`–`040`). Triangulated (slice-00 §1.3, re-confirmed at HEAD): (1)
|
||||
> filesystem `packages/daemon/src/db/migrations/[0-9][0-9][0-9]_*.ts` → 40
|
||||
> files, `001_core_schema.ts` … `040_workflow_specs_diagnostic.ts`; (2)
|
||||
> `startup.ts:206` `migrate(db, [...])` passes a 40-element schema array
|
||||
> (`coreSchema` … `workflowSpecsDiagnosticSchema`); (3) `migrate.ts:13`
|
||||
> applies them sorted by name, tracked in `schema_migrations`.
|
||||
|
||||
### Core state tables (`001_core_schema.ts`)
|
||||
|
||||
`rigs` (topology container, `001_core_schema.ts:7`), `nodes` (logical node
|
||||
identity, `:17`), `edges` (logical topology relationships, `:30`), plus
|
||||
`bindings` (physical tmux/cmux surface attachment), `sessions` (live execution
|
||||
state), `events` (append-only event log), `snapshots` (serialized rig state),
|
||||
`checkpoints` (per-node recovery state). `architecture.md` §3 L247–289.
|
||||
|
||||
### Reboot-era schema
|
||||
|
||||
- `014_agentspec_reboot.ts` — reboot schema shape; adds `pods`,
|
||||
`continuity_state`, and reboot columns on `nodes`/`sessions`/`checkpoints`
|
||||
(`pod_id`, `agent_ref`, `resolved_spec_*`, `startup_status`,
|
||||
`continuity_source`). `architecture.md` §3 L253–289.
|
||||
- `015_startup_context.ts` — persisted startup replay context for restore.
|
||||
- `016_chat_messages.ts` — durable rig-scoped chat (SQLite-backed; transcripts
|
||||
remain filesystem-backed via pipe-pane).
|
||||
- `017_pod_namespace.ts` — first-class authored pod namespace for export/adoption.
|
||||
- `018_context_usage.ts` — per-node context-usage snapshots.
|
||||
- `019_external_cli_attachment.ts` — binding-row extension for external CLI attach.
|
||||
- `020_rig_services.ts` — rig-scoped environment record for service-backed rigs.
|
||||
- `021_seat_handover_observability.ts`, `022_node_codex_config_profile.ts`.
|
||||
|
||||
### Coordination / PL-004 / PL-005 / workspace migrations
|
||||
|
||||
- `023_stream_items.ts` … `027_outbox_entries.ts` — PL-004 Phase A
|
||||
coordination tables (detail in `coordination-primitive.md`).
|
||||
- `028_project_classifications.ts`, `029_classifier_leases.ts`,
|
||||
`030_views_custom.ts` — PL-004 Phase B classifier + view tables. (Note:
|
||||
`architecture.md` L361 said "028 through 030"; the middle migration is
|
||||
literally `029_classifier_leases.ts` — re-confirmed at HEAD.)
|
||||
- `031_watchdog_jobs.ts`, `032_watchdog_history.ts` — PL-004 Phase C watchdog.
|
||||
- `033_workflow_specs.ts`, `034_workflow_instances.ts`,
|
||||
`035_workflow_step_trails.ts` — PL-004 Phase D Workflow Runtime tables
|
||||
(detail in `workflow-runtime.md`). `036_watchdog_policy_enum_extension.ts`
|
||||
is a documenting no-op.
|
||||
- `037_mission_control_actions.ts` — PL-005 Phase A audit table
|
||||
(`037_mission_control_actions.ts:54` `CREATE TABLE … mission_control_actions`;
|
||||
detail in `mission-control.md`).
|
||||
- `038_workspace_primitive.ts`, `039_queue_target_repo.ts` — PL-007 typed
|
||||
workspace primitive (`bab24bf7`).
|
||||
- `040_workflow_specs_diagnostic.ts` — slice-11 (`f68f453a`); an
|
||||
`ALTER TABLE ADD COLUMN` adding parser/validator diagnostic columns to
|
||||
`workflow_specs` (no new table). **Net-new since `architecture.md` was last
|
||||
edited** — slice-00 §1.3 provenance; carried into `workflow-runtime.md`.
|
||||
|
||||
Legacy package/bootstrap/discovery tables (`packages`, `package_installs`,
|
||||
`install_journal`, `bootstrap_runs`, `bootstrap_actions`,
|
||||
`runtime_verifications`, `discovered_sessions`) remain active.
|
||||
|
||||
## 3. Route-mount surface
|
||||
|
||||
`createApp(deps)` (`packages/daemon/src/server.ts:295`) mounts **49**
|
||||
`app.route()` route-group mounts (`server.ts` L450–513) plus 4 dedicated
|
||||
non-route handlers: `GET /healthz` (`:446`), `GET /api/rigs/:rigId/spec`
|
||||
(`handleExportYaml`, `:461`), `GET /api/rigs/:rigId/spec.json`
|
||||
(`handleExportJson`, `:462`), and the static/deep-link `app.get("*")`
|
||||
catch-all (`:519`).
|
||||
|
||||
> OPEN-2 (carried verbatim, slice-00): the route-group "count" is definitional
|
||||
> — "49" is the count of `app.route()` mounts in `server.ts`. The older
|
||||
> "31 route groups" / "createApp now mounts 22 route groups" framing
|
||||
> (`architecture.md` L40,76) used a different, smaller accounting. Independent
|
||||
> corroboration: `packages/daemon/src/routes/` has 46 non-test route `.ts`
|
||||
> files (some groups composed from shared modules; the 49 mounts are the
|
||||
> authoritative count of mounted groups — slice-00 §1.5).
|
||||
|
||||
Mount families include reboot-era rig/session/spec routes plus the coordination
|
||||
routes (`/api/stream` `server.ts:489`, `/api/queue` `:490`,
|
||||
`/api/workflow` `:495`, `missionControlRoutes(...)` `:498`),
|
||||
`/api/health-summary` (`:511`), `/api/rigs/:rigId/env` (`:512`), and
|
||||
`/api/restore-check` (`:513`).
|
||||
|
||||
## 4. Startup sequence (`createDaemon`)
|
||||
|
||||
`createDaemon(opts?)` is `packages/daemon/src/startup.ts:203` (async,
|
||||
returns `DaemonResult`). It returns `{ app, db, deps, contextMonitor }`
|
||||
(`startup.ts:1204`). The sequence (`architecture.md` §9 L1000–1028,
|
||||
corrected against source):
|
||||
|
||||
1. Open SQLite and run **all 40 migrations** (`startup.ts:206`
|
||||
`migrate(db, [coreSchema … workflowSpecsDiagnosticSchema])` — a 40-element
|
||||
array; `architecture.md` L1002 said "27 migrations (22 existing plus the 5
|
||||
PL-004 Phase A coordination tables)" — corrected to 40, drift-fix D3).
|
||||
2. Construct core repositories and legacy services.
|
||||
3. Construct package/bootstrap/discovery services.
|
||||
4. Construct rebooted startup/runtime services: `StartupOrchestrator`,
|
||||
`ClaudeCodeAdapter`, `CodexRuntimeAdapter`, `TerminalAdapter`,
|
||||
`PodRigInstantiator`, `PodBundleSourceResolver`.
|
||||
5. Construct rig environment services: `ComposeServicesAdapter`,
|
||||
`ServiceOrchestrator`.
|
||||
6. Construct operator/transport/history services: `TranscriptStore`,
|
||||
`SessionTransport`, `ChatRepository`, `AskService` (with `HistoryQuery`),
|
||||
`ResumeMetadataRefresher`, `ContextUsageStore`, `ContextMonitor`,
|
||||
`NodeInventory`.
|
||||
7. Construct authoring/identity/managed-app services: `SpecReviewService`,
|
||||
`SpecLibraryService`, `WhoamiService`.
|
||||
8. Construct `BootstrapOrchestrator` with both legacy and rebooted seams.
|
||||
9. Construct PL-004 Phase A coordination services from a shared
|
||||
`QueueRepository` instance (so `InboxHandler.absorb()` and `/api/queue`
|
||||
write to the same repo): `StreamStore`, `QueueRepository`, `InboxHandler`,
|
||||
`OutboxHandler`.
|
||||
10. Build `AppDeps`, enforce shared-DB invariants, and call
|
||||
`createApp(deps)` (`startup.ts:1202`) to mount the full route tree.
|
||||
|
||||
The daemon entrypoint `packages/daemon/src/index.ts:36` calls
|
||||
`createDaemon({ dbPath, bearerToken })`.
|
||||
|
||||
## 5. Test and verification state
|
||||
|
||||
> Drift-fix D7 / OPEN-3 (carried verbatim, slice-00): `architecture.md` L12,13
|
||||
> and §10 L1036–1046 assert daemon `2561/2561` / CLI `794/794` / total
|
||||
> `2422/2422` test pass counts and per-package file counts (`127`/`37`/`37`).
|
||||
> **These are runtime claims and are NOT re-run here** — a read-only static
|
||||
> trace counted `*.test.ts` files only: daemon **255**, cli **234**
|
||||
> (slice-00 §1.7; ui test-file count not separately gathered). Pass tallies
|
||||
> are marked `unverified-runtime-claim`; do not assert them as current. To
|
||||
> assert pass counts, run `pnpm/npm test` and re-verify.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A stream/queue/inbox/outbox.
|
||||
- `agent-spec-and-startup.md` — spec parsing/resolution/startup contract.
|
||||
- `lifecycle-snapshot-restore.md` — snapshot/restore/continuity.
|
||||
- Source roots: `packages/daemon/src/{startup.ts,server.ts,index.ts}`,
|
||||
`packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts`.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Lifecycle — Snapshot, Restore, Continuity
|
||||
status: active
|
||||
topics: [continuity, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig captures a snapshot, restores a rig (resume vs
|
||||
rebuild vs fresh), enforces restore honesty, consults live continuity state,
|
||||
or how the daemon-side restore-check / restore-packet readiness probes work.
|
||||
siblings: [daemon-core.md, agent-spec-and-startup.md, transport-and-transcripts.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Lifecycle — Snapshot, Restore, Continuity
|
||||
|
||||
The durable-state half of the core product loop:
|
||||
`down (auto-snapshot) → up <rig-name> (auto-restore) → handoff`. Snapshot
|
||||
captures serialized rig state; restore replays it honestly (no silent
|
||||
fresh-fallback); restore-check is a separate read-only readiness probe.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§4 Execution and restore, §5
|
||||
> Snapshot/restore/continuity, §6 Snapshot/restore + Auto-snapshot, §7 rules)
|
||||
> per slice-08 §10.1 — line numbers advisory only. This module is a pure
|
||||
> split EXCEPT §6 (restore-check/restore-packet) which is the D15
|
||||
> author-from-source piece — flagged explicitly there.
|
||||
|
||||
## 1. Snapshot / restore / continuity types
|
||||
|
||||
(`architecture.md` §4 "Execution and restore" — spec/projection members live
|
||||
in `agent-spec-and-startup.md`; the snapshot/restore members live here)
|
||||
|
||||
- **NodeRestoreOutcome** — `"resumed" | "rebuilt" | "fresh" | "failed" |
|
||||
"n-a"` — the locked restore vocabulary. Re-confirmed at source:
|
||||
`restore-orchestrator.ts:725-726` sets `baseStatus = "resumed"` on a
|
||||
resumed harness; `:763` sets `"rebuilt"` when a checkpoint is replayed; the
|
||||
outcome union appears at `:944` (`{ kind: "resumed" }` … ).
|
||||
- **SnapshotData** — current serialized snapshot payload. Reboot extensions
|
||||
are optional for older-snapshot compatibility: `pods?`,
|
||||
`continuityStates?`, `nodeStartupContext?`.
|
||||
- **NodeStartupSnapshot** — persisted restore replay input:
|
||||
classification-free projection entries, resolved startup files, startup
|
||||
actions, runtime.
|
||||
- **PersistedProjectionEntry** — the classification-free restore replay seam:
|
||||
persists only entry identity + source metadata, NOT stale `classification`,
|
||||
`conflicts`, or `noOps` (Architecture Rule 10).
|
||||
|
||||
## 2. Snapshot, restore, continuity domain services
|
||||
|
||||
(`architecture.md` §5 "Snapshot, restore, and continuity")
|
||||
|
||||
- `checkpoint-store.ts` — checkpoint persistence with pod/continuity context.
|
||||
- `snapshot-capture.ts` — captures pods, continuity state, startup replay
|
||||
context, and latest env receipt. Re-confirmed: `snapshot-capture.ts:66-77`
|
||||
pulls `pods`, `continuity_state`, and `node_startup_context` rows per rig.
|
||||
- `snapshot-repository.ts` — snapshot CRUD.
|
||||
- `restore-orchestrator.ts` — resume, checkpoint delivery, startup replay,
|
||||
live continuity consultation, topology ordering, and RigEnv boot gating
|
||||
before agent restore.
|
||||
|
||||
## 3. Restore flow and restore honesty
|
||||
|
||||
(`architecture.md` §6 "Snapshot / restore" + §7 rules 7/14/15/16)
|
||||
|
||||
Restore behavior, each point re-confirmed at `restore-orchestrator.ts`:
|
||||
|
||||
- Reads the newest session by **monotonic ULID**, not timestamp alone
|
||||
(`:700` — "Find the NEWEST session for this node. ULIDs are monotonic, so
|
||||
latest = max id").
|
||||
- Consults live `continuity_state`; preserves state when a node is already
|
||||
`restoring` (`:611-615` — `SELECT status FROM continuity_state …`; if
|
||||
`status === "restoring"` the node is skipped with a warning).
|
||||
- Replays restore-safe startup using persisted startup context;
|
||||
prefilters missing optional artifacts into warnings; **hard-fails a node if
|
||||
a required startup file is missing** (`:799` — status `"failed"`, error
|
||||
"Missing required startup files: …").
|
||||
- Writes a **transcript boundary marker before re-launch** (`:652` — "Write
|
||||
transcript boundary marker BEFORE launch (before pipe-pane attaches)").
|
||||
- Refuses to restore over live sessions (`:168` — `rig_not_stopped`: "Rig …
|
||||
has live sessions. Stop the rig with 'rig down' before restoring").
|
||||
- Uses `nativeResumeProbe` to honestly assess whether the harness actually
|
||||
resumed (`:857` continuity-outcome reconciliation).
|
||||
|
||||
**Restore-honesty rules (carried verbatim, `architecture.md` §7):**
|
||||
|
||||
- Rule 7 — restore-policy narrowing is one-way only:
|
||||
`resume_if_possible` → `relaunch_fresh` → `checkpoint_only`.
|
||||
- Rule 14 — resume states are locked: `resumed` / `rebuilt` / `fresh`.
|
||||
`rebuilt` = new process assembled from artifacts.
|
||||
- Rule 15 — failed resume is FAILED loudly. No automatic fresh fallback.
|
||||
Fresh launch is explicit follow-up only.
|
||||
- Rule 16 — post-command handoff required on `up`, `down`, `restore`,
|
||||
`snapshot create`: what happened + current state + next action.
|
||||
|
||||
(The full 25-rule list lives in `architecture-rules-and-event-system.md`.)
|
||||
|
||||
## 4. Auto-snapshot and existing-rig power-on
|
||||
|
||||
(`architecture.md` §6 "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 an existing rig by
|
||||
name and restores from the 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.
|
||||
|
||||
## 5. Daemon-side restore-check / restore-packet — AUTHORED FROM SOURCE (D15)
|
||||
|
||||
> **D15 author bit (flagged per slice-08 §4.7 binding):** `restore-check` and
|
||||
> `restore-packet` have NO `architecture.md` narrative. This section is the
|
||||
> one author-from-source piece in this otherwise pure-split module — authored
|
||||
> at the slice-00 forensic bar from `routes/restore-check.ts` +
|
||||
> `domain/restore-check-service.ts` + `commands/restore-packet.ts`, file:line
|
||||
> cited, version-attribution forensically checked.
|
||||
>
|
||||
> **Version attribution (forensic):** restore-check is NOT a 0.3.1 feature.
|
||||
> `routes/restore-check.ts` first-created `277e279c` ("feat: native rig
|
||||
> restore-check command", 2026-04-23, pre-0.3.0); present at both `v0.3.0`
|
||||
> and `v0.3.1` git trees (`git ls-tree` confirmed). `restore-packet`
|
||||
> first-created `23f2921e` ("Restore-Packet vertical M2a", 2026-05-01),
|
||||
> likewise present at v0.3.0 and v0.3.1. The gap is missing
|
||||
> architecture.md *prose*, not a version-attribution drift.
|
||||
|
||||
### 5.1 `rig restore-check` — readiness probe
|
||||
|
||||
`GET /api/restore-check?rig=<name>&noQueue=<bool>&noHooks=<bool>`
|
||||
(`routes/restore-check.ts:131`). The route assembles a framework-free
|
||||
`RestoreCheckDeps` (`:138-168`) over existing daemon projections —
|
||||
`listRigs` from `rigRepo`, `getNodeInventory` (joining `node_id` by
|
||||
`logical_id`, `:21-26,143-149`), `getStartupContext` (reads
|
||||
`node_startup_context`, parses `projection_entries_json` /
|
||||
`resolved_files_json` / `startup_actions_json`, `:44-128`), `hasSnapshot`
|
||||
/ `getLatestSnapshot` from `snapshotRepo`, and `probeDaemonHealth`
|
||||
(self-evident: "We're inside the daemon — if this route is responding,
|
||||
daemon is healthy", `:160-163`). It then runs
|
||||
`new RestoreCheckService(deps).check(...)` (`:170-171`).
|
||||
|
||||
`RestoreCheckService.check()` (`restore-check-service.ts:244`) layers:
|
||||
|
||||
1. **Host checks** — `checkDaemonReachable` (probe-throw → `verdict:
|
||||
unknown`, NOT `not_restorable`, `:252-259`), `checkStateDirWritable`
|
||||
(`:261`), `checkHostInfraDeclaration` (`:262`, impl `:406`).
|
||||
2. **Rig enumeration** — `listRigs()` throw → `buildUnknown` (`:267-274`);
|
||||
`--rig` filter; unknown rig → red `rig.<name>.exists` (`:276-284`).
|
||||
3. **Per-rig checks** — `checkSnapshot` (`:290`, impl `:675`),
|
||||
`checkSpecPresent` (`:295`).
|
||||
4. **Per-seat checks** — `checkSeatReadiness` (`:311`), `checkStartupContext`
|
||||
(`:315`, impl `:763`; `unknownChecks` → `buildUnknown`),
|
||||
`checkTranscript` (`:325`), `checkResumePath` (`:329`), and unless opted
|
||||
out: `checkQueueFile` (`:334`, gated by `--no-queue`, impl `:870`) and
|
||||
`checkHooks` (`:339`, gated by `--no-hooks`, impl `:896`).
|
||||
5. **Verdict** — `buildResult` aggregates: any red → `not_restorable`; any
|
||||
yellow → `restorable_with_caveats`; else `restorable`; probe-uninspectable
|
||||
→ `unknown` (`:1074-1085`, `:1362-1379`). Plus a `RecoveryPlan`
|
||||
(`buildRecovery`, `:1186`) and a `RepairStep[]` packet
|
||||
(`buildRepairPacket`, `:1392`; `null` when fully restorable).
|
||||
|
||||
The result shape is `RestoreCheckResult` (`restore-check-service.ts:120-130`):
|
||||
`verdict`, `readiness`, `continuity`, `rigs[]`, `hostInfra`, `recovery`,
|
||||
`counts {red,yellow,green}`, `checks[]`, `repairPacket`.
|
||||
|
||||
**Honest-error design (slice-00 bar):** a daemon-probe *exception* produces
|
||||
`verdict: unknown` (uninspectable state), distinct from a daemon
|
||||
definitely-down state which is `red` / `not_restorable`
|
||||
(`restore-check-service.ts:249-259`). The route's catch-all returns the same
|
||||
`unknown`-shaped body with HTTP 500 + a `probe.error` red check
|
||||
(`routes/restore-check.ts:174-222`). `CheckEntry.remediationSafe` defaults to
|
||||
`false` (conservative — unclassified remediations are NOT
|
||||
auto-execution-safe, `restore-check-service.ts:18-24`).
|
||||
|
||||
CLI surface (`cli-reference.md` `### rig restore-check`):
|
||||
`rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`. Exit
|
||||
codes: `0` restorable (or with caveats), `1` not restorable (red), `2`
|
||||
unknown / probe error.
|
||||
|
||||
### 5.2 `rig restore-packet` — cross-runtime restore packet
|
||||
|
||||
CLI-side, no daemon route. `commands/restore-packet.ts` (528 lines)
|
||||
implements three subcommands (`cli-reference.md` `### rig restore-packet`):
|
||||
`write [options]` (generate a packet directory from a source session or JSONL
|
||||
file, with `omitted-records` accounting), `read <packet-dir> [--json]`
|
||||
(render contents; non-mutating), `validate <packet-dir> [--json]` (validate
|
||||
against the v0 schema; non-mutating). Packet shape is the cross-runtime v0
|
||||
standard — Claude Code and Codex transcripts both supported via runtime
|
||||
parsers + redaction.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D15 (author-from-source, completed):** restore-check / restore-packet
|
||||
authored from source above; flagged as the single author bit in this
|
||||
module. Version attribution forensically resolved (both pre-0.3.0; the gap
|
||||
was missing prose, not drift).
|
||||
- No slice-00 numeric drift applies to this module's split content (the
|
||||
footprint/migration/route counts land in `daemon-core.md`).
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — StartupOrchestrator persists the replay
|
||||
context that restore consumes.
|
||||
- `daemon-core.md` — `/api/restore-check` is one of the 49 route mounts
|
||||
(`server.ts:513`).
|
||||
- `transport-and-transcripts.md` — transcript boundary markers written on
|
||||
restore.
|
||||
- Source roots: `packages/daemon/src/domain/{restore-orchestrator,
|
||||
snapshot-capture,snapshot-repository,checkpoint-store,
|
||||
restore-check-service}.ts`, `packages/daemon/src/routes/restore-check.ts`,
|
||||
`packages/cli/src/commands/restore-packet.ts`.
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Living Notes Review Surface — the intent→plan→delivered projection
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability, sdlc]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Working on or consuming the composed slice/mission review surface — the
|
||||
/api/review/* routes, the ComposedSliceReview contract, the on-disk SDLC
|
||||
convention it projects, staged-approval locks, proof artifacts and the C1
|
||||
header, the freeze export, media serving for review evidence, or the
|
||||
cross-host FLEET aggregate (/api/review/fleet, the fleet composer's
|
||||
union/one-count seam, v0.4.6 MH-5).
|
||||
siblings: [content-surfaces.md, workspace-primitive.md, mission-control.md]
|
||||
prerequisite-reads: [workspace-primitive.md]
|
||||
last-verified-against-source: c8341f72
|
||||
last-updated: 2026-07-08
|
||||
---
|
||||
|
||||
# Living Notes review surface (v0.4.4)
|
||||
|
||||
The review surface is a **pure projection of on-disk markdown into one
|
||||
reviewable structure per slice**: INTENT → PLAN → DELIVERED. Agents change
|
||||
files; the daemon re-projects; nothing here writes product state except the
|
||||
two deliberate approval stamps and the freeze export. Shipped across the
|
||||
0.4.4 living-notes slices (19 signal layer, 20 composer/surfaces, 22 rig
|
||||
agents altitude, 23 SDLC operationalization) and the **corrective rebuild**
|
||||
(2026-07-05/06) that collapsed the surface to the one structure described
|
||||
here. Everything below is verified at `bb5ad219`.
|
||||
|
||||
## 1. The ONE contract — `ComposedSliceReview`
|
||||
|
||||
`packages/daemon/src/domain/review/types.ts` defines the read contract every
|
||||
consumer shares (slice Review tab, mission U5 row expansion, For-You
|
||||
expansion — never a second endpoint per consumer):
|
||||
|
||||
- **identity + phase**: `slice`, `sliceId`, `title`, `missionId`, `phase`
|
||||
(five-way, derived read-time: `locked > review > building > spec > intent`,
|
||||
`derivePhase` in `compose.ts`), `laneLabel` (SS14 vocabulary INTENT / PLAN /
|
||||
BUILD / REVIEW / LOCKED).
|
||||
- **`intent`**: `{text, media[], ssotPath, degrade}` — the slice README's
|
||||
`## Intent` section verbatim (`extractSection`), plus any media it embeds.
|
||||
- **`plan`**: `{concise: {text, media[]}, lockedArtifacts[], lock, ssotPath}` —
|
||||
the PRD's pinned `## Mini-requirements` tier + planned mockups; the pinned
|
||||
artifact set is a **frontmatter READ** (`locked-artifacts:` list on the
|
||||
slice README — no new write machinery); `lock` is the spec-scope
|
||||
staged-approval stamp (§3).
|
||||
- **`delivered`**: `{items[], extraProof[], lock, proofDirPath}` — the
|
||||
redesigned join (§4): each `## Proof contract` deliverable paired with its
|
||||
curated proof media and QA's recorded comparison signal. `lock` is the
|
||||
delivery-scope stamp; `proofDirPath` is the drill-in door to the full
|
||||
fix-loop history.
|
||||
- **KEPT orthogonal bands**: `needsYou` (two sources, one queue: agent-routed
|
||||
items ∪ derived ▲ exceptions with inline evidence), `agents`
|
||||
(scope-parameterized `slice:<id> | mission:<id> | rig` — one contract, all
|
||||
scopes), `lineage` (`VerifyLineage`: candidate/merge/tip facts + the four
|
||||
gate `VerdictCell`s rendering recorded tokens verbatim), `defects`
|
||||
(out-of-slice media refs surfaced, never silently dropped), `composedAt`.
|
||||
|
||||
**Deleted by the corrective (2026-07-05, founder-blocked original):** the
|
||||
four parallel renderable structures (`sections`, `acceptance`, `compare`,
|
||||
`join`) and the coequal `green` field. They do not exist in the types, the
|
||||
composer output, the UI hook, or the fixtures; the daemon route tests assert
|
||||
the keys are ABSENT on the wire (`test/review-composer.test.ts`,
|
||||
`test/review-routes.test.ts`). Recorded-verdict green survives in exactly two
|
||||
places: the per-deliverable `verified` signal (§4) and the mission ledger's
|
||||
completion fact (§6) — never a slice-review structure.
|
||||
|
||||
## 2. Composer + gatherer split
|
||||
|
||||
- `compose.ts` is **PURE**: `(gathered inputs) → composed doc`; identical
|
||||
inputs (including caller-supplied `nowIso`/git facts) produce byte-identical
|
||||
output — idempotence holds by construction and is pinned by tests.
|
||||
- `gather.ts` (`ReviewGatherer`) is the impure shell: reads slice docs +
|
||||
`proof/*.md` artifacts from disk, attention/agent rows from SQLite,
|
||||
approval stamps from frontmatter (cross-checked against the audit log, §3),
|
||||
git lineage facts from the workspace default repo. Every unreadable source
|
||||
degrades honestly (nulls / "unknown") — the composer renders the named
|
||||
degrade, never invented content.
|
||||
|
||||
## 3. The two locks = the shipped staged-approval stamps
|
||||
|
||||
`plan.lock` and `delivered.lock` are projections of the **staged-approval
|
||||
verb** (`rig scope slice|mission approve --scope spec|delivery` — see
|
||||
cli-reference "SDLC control plane verbs"): frontmatter stamps
|
||||
(`approved-spec-by/-at`, `approved-by/-at`) written daemon-side together with
|
||||
an append-only `mission_control_actions` audit row (no half-stamp). The
|
||||
gatherer cross-checks each stamp against the pinned scope-approval
|
||||
`audit_notes_json` shape (`approval_scope` + scope identity); a stamp with no
|
||||
matching row projects `auditVerified: false` and renders as an UNVERIFIED
|
||||
stamp — visible, never a block. Approval is freeze/sign-off, **never**
|
||||
proven-green (BR-6).
|
||||
|
||||
## 4. DELIVERED — promised ↔ curated proof ↔ verified
|
||||
|
||||
`composeDelivered` joins the PRD's `## Proof contract` checkbox items
|
||||
(`extractProofContract`; an optional markdown image on the checkbox line
|
||||
becomes the deliverable's `plannedRef` mockup) to the C1-headed proof
|
||||
artifacts in `<slice>/proof/`:
|
||||
|
||||
- An artifact **covers** a deliverable when its C1 `evidences:` list names the
|
||||
item (exact text or 1-based index).
|
||||
- `verified` binds to the shipped C1 fields, never mere presence:
|
||||
**verified** = a covering `qa|adjudication` artifact records the comparison
|
||||
(`self_check`) AND its recorded verdict is passing; **unverified** = some
|
||||
covering artifact without a passing recorded QA comparison (QA's
|
||||
why-kicked-back `note` still surfaces); **missing** = promised, nothing
|
||||
delivered. All three are render states — fail-open by construction.
|
||||
- The curated proof set is the covering artifacts' body media refs
|
||||
(`ProofArtifact.mediaRefs`, populated by `rig proof add --media` or markdown
|
||||
refs in the artifact body), normalized slice-relative; refs escaping the
|
||||
slice dir become `defects` findings and are never served or inlined.
|
||||
Unmapped-artifact media renders bounded as `extraProof`.
|
||||
- The ▲ insufficient-proof NEEDS-YOU exception fires from the delivered
|
||||
MISSING count (`deriveExceptions`).
|
||||
|
||||
## 5. Routes, freeze, media serving
|
||||
|
||||
- `packages/daemon/src/routes/review.ts`, mounted at `/api/review`
|
||||
(`server.ts` route mounts): `GET /slice/:name`, `GET /mission/:name`,
|
||||
`GET /rig` (the OPR.0.4.4.22 rig-agents altitude root), `GET
|
||||
/agents?scope=slice:<id>|mission:<id>|rig`, `POST /freeze`.
|
||||
- **Freeze** (`freeze.ts`): the ONE synchronous compose-and-freeze path,
|
||||
invoked after the delivery stamp + audit row commit. Renders the composed
|
||||
review to a single self-contained HTML file (`REVIEW-<id>-<date>.html` in
|
||||
the slice dir): CSS inline, images data-URI-inlined under slice-dir
|
||||
containment (resolve-prefix + realpath — a traversal/symlink ref renders
|
||||
the muted outside-slice branch, never inlined), video by link + poster.
|
||||
Exclusive-create through the allowlist-governed atomic write service;
|
||||
re-invocation is an idempotent no-op; a failed render never un-approves.
|
||||
The freeze moment also folds the mission brief spine (`brief-spine.ts`)
|
||||
into `MISSION_BRIEF.md`, section-scoped, schema-order-preserving.
|
||||
- **Media serving** for review evidence is ranged on BOTH asset route
|
||||
families (iOS-Safari-class players require `206`): `GET /api/files/asset`
|
||||
(`routes/files.ts` — single-range parse, `206`/`416`, `Accept-Ranges:
|
||||
bytes`; `.html` renders as text/html only under the explicit `?render=1`
|
||||
opt-in) and `GET /api/slices/:name/proof-asset/*` (`routes/slices.ts` —
|
||||
same range semantics via `fileAssetResponse`, added by the corrective QA
|
||||
fixback; immutable-cache posture retained).
|
||||
|
||||
## 6. Mission + rig altitudes
|
||||
|
||||
`composeMissionReview` consumes `{review, green}` entries per slice: the
|
||||
board (stage-specific cells re-bound to the collapsed contract — spec-stamp
|
||||
state, delivered n/m, GREEN·merge pair, stamp), the completion **ledger**
|
||||
(a query over the slice set, never an authored list; `green` here is the
|
||||
recorded-verdict fact from `computeRecordedGreen` — regime 1 = four passing
|
||||
gate verdicts, regime 2 = passing adjudication), `cutComplete` (TRUE only
|
||||
when every in-cut slice is green AND merged AND has zero open needs-human
|
||||
items), and the union NEEDS-YOU (distinct identities — one item seen from N
|
||||
altitudes is one item). `composeRigAgents` serves the standalone rig
|
||||
altitude (roster ∪ recently-holding, park/health/settled from the queue
|
||||
transitions log).
|
||||
|
||||
## 7. The FLEET altitude — the cross-host aggregate SIBLING (v0.4.6, OPR.0.4.6.MH5)
|
||||
|
||||
`GET /api/review/fleet` aggregates every registered host's composed ▲/● set
|
||||
into one manage-by-exception glance. It is a **sibling aggregate endpoint**,
|
||||
never a fourth `AgentsScope` value (arch Q2): the scope grammar stays
|
||||
exactly `slice:* | mission:* | rig`, and the local composer stays a pure
|
||||
function over local state. `domain/review/fleet-compose.ts` is the ONE
|
||||
review-domain module allowed to import hosts transport/registry — a
|
||||
boundary enforced mechanically by the `review-import-audit` static test
|
||||
(the zero-I/O `fanout-contract` types module is the sole allowlisted
|
||||
hosts import elsewhere).
|
||||
|
||||
**Fan-out-the-composed-set (arch Q1):** the ▲ kinds are time-derived on
|
||||
their own host's clock, so the fleet root fans out each host's
|
||||
ALREADY-COMPOSED rig root (`GET /api/review/rig` over `remoteJsonRequest`,
|
||||
concurrent under the named read-class deadline; the local host joins
|
||||
in-process via the same gatherer — zero self-transport) and only **unions +
|
||||
host-dimensions + counts**. It never recomputes exception truth (the
|
||||
composer is clock-free by test pin), so cross-host clock skew can never
|
||||
distort a ▲. Per-host failures degrade to the closed `PerHostStatus`
|
||||
honesty contract; a failed host's counts are ABSENT from its
|
||||
`FleetHostRollup` — absent-not-zero, in the type.
|
||||
|
||||
**One-count across the fleet (arch Q4):** the dedup `Set` lives in the
|
||||
fleet composer — the one place — keyed `hostId|identity` (rendered verbatim
|
||||
on the expanded drawer); within-host multi-altitude visibility collapses to
|
||||
one row with `seenFrom` provenance; an MH-3-forwarded qitem lives only in
|
||||
its origin host's DB, so no double-count by construction.
|
||||
|
||||
**Composition pins the seam (arch Q3, the rule of thumb):** sidedness and
|
||||
caller pick the TRANSPORT seam (MH-4's CLI-direct verbs); when COMPOSITION
|
||||
exists — the fleet union/one-count is a correctness rule — the seam pins
|
||||
daemon-side: ONE composer, one endpoint, every consumer (the `/fleet` route
|
||||
page and the FLEET band today via the shared `useFleet` hook; the TUI/CLI
|
||||
fleet consumers as named follow-ups) reads the same one. UI cadence is the
|
||||
bounded named `FLEET_POLL_INTERVAL_MS` with the feed-cadence-class floor,
|
||||
and the ambient band gates its FETCH on fleet existence (the FS-1 amplifier
|
||||
discipline; enable-at-scale remains the FS-1 release-validation gate). Read
|
||||
+ surface only: acting on a remote host's item rides MH-3/MH-4.
|
||||
|
||||
## 8. The on-disk convention it projects (SDLC control plane)
|
||||
|
||||
The surface projects the markdown-control-plane shapes operationalized by
|
||||
OPR.0.4.4.23 — conventions SSOT `docs/reference/sdlc-conventions.md`:
|
||||
`## Intent` (README) / `## Mini-requirements` + `## Proof contract` (PRD) /
|
||||
`proof/` C1-headed artifacts + `PROOF.md`, scaffolded for every template kind
|
||||
by `rig scope slice create` and checked advisory-only by `rig scope audit`
|
||||
(+ the `rig doctor` SDLC row). The UI half of this surface (the slice Review
|
||||
tab, mission board, For-You approve+chat) is documented in
|
||||
[`../ui/project-and-for-you.md`](../ui/project-and-for-you.md).
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor, orchestrator, human-operator]
|
||||
applies-when: |
|
||||
Need to know how the daemon-backed Mission Control surface works — the
|
||||
seven views, the seven write verbs, the action audit table, the
|
||||
bearer-token middleware, or how queue observability maps to PL-004 sources.
|
||||
siblings: [coordination-primitive.md, workflow-runtime.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Mission Control — Queue Observability + 7-Verb Contract (PL-005)
|
||||
|
||||
Mission Control (PL-005) is the daemon-backed queue-observability surface: an
|
||||
integrated product UI inside the existing shell (a top-level `/mission-control`
|
||||
route, NOT a new managed app) over the PL-004 Phase A coordination primitive.
|
||||
Per the PRD acceptance criteria: seven views, seven verbs, first-class human
|
||||
seats, recent-ships=10, and a daemon-backed action audit table; no
|
||||
old-dashboard migration/cutover (`architecture.md` §3 L368, L390).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`. Mission Control is PL-005 (an
|
||||
> early-0.3.0 graft); plugins and Claude auto-compaction are 0.3.1 features
|
||||
> and are NOT back-attributed here (slice-00 0.3.0-ground-truth seams a/row-7).
|
||||
|
||||
## 1. The seven views
|
||||
|
||||
`MISSION_CONTROL_VIEWS` is the canonical view list (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-read-layer.ts:32–42`):
|
||||
`my-queue`, `human-gate`, `fleet`, `active-work`, `recent-ships`,
|
||||
`recently-active`, `recent-observations`.
|
||||
|
||||
All seven return rows in the load-bearing 9-field phone-friendly content
|
||||
model (rig/mission name, current phase,
|
||||
active|idle|attention|blocked|degraded, next-action,
|
||||
pending-human-decision, read-cost, last-update timestamp,
|
||||
confidence/freshness, evidence link). The model is non-negotiable across all
|
||||
7 views; UI may render compact, JSON preserves all 9 (`architecture.md` §3
|
||||
L370).
|
||||
|
||||
`MissionControlReadLayer` maps each view to its source-of-truth path
|
||||
(`mission-control-read-layer.ts:4–14`, re-confirmed at HEAD):
|
||||
|
||||
- `my-queue` / `human-gate` / `active-work` / `recent-ships` query PL-004
|
||||
Phase A `queue_items` via `QueueRepository`.
|
||||
- `fleet` consumes the per-rig CLI capability cache + queue summary.
|
||||
- `recently-active` delegates to PL-004 Phase B
|
||||
`ViewProjector.show("recently-active")`.
|
||||
- `recent-observations` reads PL-004 Phase A `stream_items` via
|
||||
`StreamStore`.
|
||||
|
||||
Filesystem fallbacks (`~/.openrig/stream/<date>.jsonl`, raw queue file grep)
|
||||
are graceful-degradation aids, NOT the primary path (`architecture.md` §3
|
||||
L378).
|
||||
|
||||
> Scope note (slice-00 0.3.0-ground-truth OPEN-3, carried): the exact
|
||||
> For-You verb subset is an unresolved velocity slice-01 ruling. This module
|
||||
> describes the **system-level** 7-verb vocabulary (proven, slice-00
|
||||
> §1.6/seam-c). It does NOT enumerate a For-You-surface subset — that
|
||||
> surface is described in `../ui/project-and-for-you.md` when authored.
|
||||
|
||||
## 2. The seven verbs (the write contract)
|
||||
|
||||
The seven verbs execute through the load-bearing
|
||||
`MissionControlWriteContract` (re-confirmed at HEAD
|
||||
`packages/daemon/src/domain/mission-control/mission-control-write-contract.ts:27–36`):
|
||||
|
||||
| Verb | Effect |
|
||||
|---|---|
|
||||
| `approve` | `state="done"`, `closure_reason="no-follow-on"` |
|
||||
| `deny` | `state="done"`, `closure_reason="denied"` |
|
||||
| `route` | `state="done"`, `closure_reason="handed_off_to"`, `closure_target`+`handed_off_to`=route target; creates new qitem at the route target (1-hop) |
|
||||
| `annotate` | no queue mutation; audit record only |
|
||||
| `hold` | `state="blocked"`, `closure_reason="blocked_on"` |
|
||||
| `drop` | `state="done"`, `closure_reason="canceled"` |
|
||||
| `handoff` | the 4-step shape (see below) |
|
||||
|
||||
Each verb is one atomic daemon transaction: queue mutation via
|
||||
`QueueRepository.updateWithinTransaction()` (which preserves Phase A
|
||||
hot-potato closure validation — see `coordination-primitive.md` §3) + an
|
||||
audit row in `mission_control_actions` + a persisted
|
||||
`mission_control.action_executed` event, all in one `db.transaction`. The
|
||||
4-step `handoff` shape (source-update + destination-create + opt-in
|
||||
best-effort notify + audit-record append) is verified atomic; notify failure
|
||||
does NOT roll back durable mutations (PRD invariant). Failure-injection rolls
|
||||
back source closure + audit row + new qitem together (`architecture.md` §3
|
||||
L380; `mission-control-write-contract.ts:5,15`).
|
||||
|
||||
## 3. The action audit table
|
||||
|
||||
`mission_control_actions` (`037_mission_control_actions.ts:54` `CREATE TABLE
|
||||
IF NOT EXISTS mission_control_actions`) is append-only at the API surface:
|
||||
it records every operator action through Mission Control with before/after
|
||||
qitem snapshots for forensic reconstruction. Columns include `action_verb`
|
||||
(TEXT, app-layer enum enforcement, `:56`) and `acted_at` (TEXT NOT NULL ISO
|
||||
timestamp); indexes `(acted_at DESC, action_verb)`,
|
||||
`(qitem_id, acted_at DESC)`, `(actor_session, acted_at DESC)`
|
||||
(`037_mission_control_actions.ts:28–44`). Phase B added NO migrations — this
|
||||
Phase A table is the only data source (`architecture.md` §3 L364, L392).
|
||||
|
||||
## 4. PL-005 Phase B — bearer middleware, notifications, audit browse
|
||||
|
||||
Phase B extends the daemon Mission Control surface:
|
||||
|
||||
- **Bearer-token middleware** —
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts` (re-confirmed at
|
||||
HEAD: header `auth-bearer-token.ts:1–9` "PL-005 Phase B"). Constant-time
|
||||
bearer comparison via Node `crypto.timingSafeEqual`; the daemon refuses to
|
||||
start with a non-loopback bind interface AND an empty bearer config
|
||||
(startup-side check). Bearer enforced on the write verbs:
|
||||
`app.post("/action", requireAuth)` (`routes/mission-control.ts:294`) and
|
||||
`app.post("/notifications/test", requireAuth)` (`:295`). v0 is bearer-on-write;
|
||||
no OAuth/SSO/per-user model.
|
||||
- **Notification dispatcher** — two adapters
|
||||
(`notification-adapter-ntfy.ts` default + `notification-adapter-webhook.ts`
|
||||
alternate; selected via `OPENRIG_NOTIFICATIONS_MECHANISM` env) plus
|
||||
`notification-dispatcher.ts` (re-confirmed in
|
||||
`domain/mission-control/`). ntfy.sh recommended for the operator's phone
|
||||
via tailnet.
|
||||
- **Read-only audit-history browse** — `audit-browse.ts` over
|
||||
`mission_control_actions`, exposed at `GET /api/mission-control/audit`
|
||||
(`routes/mission-control.ts:346`) with filters and `(limit, before_id)`
|
||||
pagination cursored on SQLite `rowid` (`architecture.md` §3 L392).
|
||||
|
||||
## 5. Mission Control events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L394 says "Existing 32 PL-004 events untouched" — internally inconsistent
|
||||
> with §3 L410's "20" and predates 0.3.x. **Do NOT carry either number.**
|
||||
> The current `RigEvent` union (`packages/daemon/src/domain/types.ts:94`)
|
||||
> has **73 members** total (slice-00 §1.8, re-confirmed at HEAD); the
|
||||
> additive `mission_control.*` events are described below WITHOUT asserting
|
||||
> a contested PL-004 sub-count.
|
||||
|
||||
Re-confirmed `domain/types.ts:212–218`: Phase A added
|
||||
`mission_control.action_executed`, `mission_control.cli_drift_detected`,
|
||||
`mission_control.view_refreshed`; Phase B added
|
||||
`mission_control.notification_sent`, `mission_control.notification_failed`
|
||||
(5 `mission_control.*` members at HEAD).
|
||||
|
||||
## 6. Route surface
|
||||
|
||||
`missionControlRoutes({ bearerToken })` is mounted at `server.ts:498`. Key
|
||||
routes (`routes/mission-control.ts`): `GET /views` (`:245`),
|
||||
`GET /cli-capabilities` (`:250`), `POST /action` (`:298`, auth-gated),
|
||||
`GET /audit` (`:346`), `POST /notifications/test` (auth-gated), plus an SSE
|
||||
surface. The integrated UI is the `/mission-control` top-level route in
|
||||
`packages/ui/src/routes.tsx` mounting `MissionControlSurface`
|
||||
(`architecture.md` §3 L390); UI detail lands in
|
||||
`../ui/project-and-for-you.md`.
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — the PL-004 Phase A `queue_items`/`stream_items`
|
||||
sources Mission Control reads, and the hot-potato closure contract verbs honor.
|
||||
- `workflow-runtime.md` — PL-004 Phase D transactional-scribe runtime.
|
||||
- `../ui/project-and-for-you.md` — the UI surface counterpart (authored phase).
|
||||
- Source roots: `packages/daemon/src/domain/mission-control/`,
|
||||
`packages/daemon/src/middleware/auth-bearer-token.ts`,
|
||||
`packages/daemon/src/routes/mission-control.ts`,
|
||||
`packages/daemon/src/db/migrations/037_mission_control_actions.ts`.
|
||||
@@ -0,0 +1,143 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
status: active
|
||||
topics: [specification-and-bundles, release-and-versioning]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig/pod bundles are assembled (schema-version-2 vs legacy
|
||||
v1), how bundle create/inspect/install and /api/up route across source kinds,
|
||||
the staged BootstrapOrchestrator plan/apply flow, or which legacy install-
|
||||
engine seams still ship for pre-reboot data.
|
||||
siblings: [agent-spec-and-startup.md, plugin-agent-image-context-pack.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Packaging, Bootstrap, Bundles, Legacy Install Engine
|
||||
|
||||
How OpenRig packages a topology into a shareable bundle and reconstitutes it
|
||||
on another host. Fully dual-format: schema-version-2 pod bundles plus legacy
|
||||
v1 artifacts, routed deterministically by the bootstrap orchestrator.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Bundles/bootstrap/legacy
|
||||
> compatibility, §6 Bundle create/inspect/install + /api/up, §11 Compat
|
||||
> note 3) per slice-08 §10.1 — line numbers advisory only.
|
||||
|
||||
## 1. Bundle, bootstrap, legacy domain services
|
||||
|
||||
(`architecture.md` §5 "Bundles, bootstrap, and legacy compatibility")
|
||||
|
||||
- `pod-bundle-assembler.ts` — schema-version-2 bundle assembler.
|
||||
Re-confirmed: emits `schemaVersion: 2` (`pod-bundle-assembler.ts:167`).
|
||||
- `bundle-types.ts` — v1 and v2 manifest types plus parse/validate/serialize.
|
||||
Re-confirmed: v2 `PodBundleManifest` type carries `schemaVersion: 2`
|
||||
(`:23`); `validatePodBundleManifest` rejects unless `schema_version === 2`
|
||||
(`:38`); `serializePodBundleManifest` writes `schema_version: 2` (`:62`);
|
||||
`parsePodBundleManifest` (`:87`); legacy path
|
||||
`validateLegacyBundleManifest` (`:143`) — both formats live in one file.
|
||||
- `bundle-source-resolver.ts` — `LegacyBundleSourceResolver`
|
||||
(`bundle-source-resolver.ts:25`) plus `PodBundleSourceResolver` (`:132`).
|
||||
- `bootstrap-orchestrator.ts` — staged bootstrap flow with direct pod-aware
|
||||
rig and v2 bundle delegation. `BootstrapMode = "plan" | "apply"`
|
||||
(`bootstrap-orchestrator.ts:26`).
|
||||
- `up-command-router.ts` — spec/bundle source classification for `/api/up`.
|
||||
`SourceKind = "rig_spec" | "rig_bundle" | "rig_name"`
|
||||
(`up-command-router.ts:6`).
|
||||
|
||||
All re-confirmed present in `packages/daemon/src/domain/` @HEAD.
|
||||
|
||||
## 2. Bundle create / inspect / install
|
||||
|
||||
(`architecture.md` §6 "Bundle create / inspect / install")
|
||||
|
||||
`routes/bundles.ts` is fully dual-format:
|
||||
|
||||
- **create** — detects a pod-aware RigSpec and uses `PodBundleAssembler`
|
||||
(accepts an optional `rigRoot`); legacy create still uses
|
||||
`LegacyBundleAssembler`.
|
||||
- **inspect** — safely extracts the archive, detects `schema_version`; v2
|
||||
returns `schemaVersion: 2`, `agents[]`, and integrity data; v1 returns the
|
||||
legacy manifest shape.
|
||||
- **install** — uses the full bootstrap plan/apply; bootstrap peeks the
|
||||
manifest and routes deterministically to `pod_bundle` or `rig_bundle`.
|
||||
Re-confirmed at source: `bootstrap-orchestrator.ts:134-143` — when
|
||||
`sourceKind === "rig_bundle"` it unpacks to a temp peek dir, reads
|
||||
`bundle.yaml`, and calls `parsePodBundleManifest(...)` to detect the
|
||||
schema version before routing.
|
||||
|
||||
## 3. `/api/up` source routing
|
||||
|
||||
(`architecture.md` §6 "`/api/up`")
|
||||
|
||||
`UpCommandRouter` + `BootstrapOrchestrator` own:
|
||||
|
||||
- direct pod-aware rig specs,
|
||||
- legacy rig specs,
|
||||
- v1 bundle installs,
|
||||
- v2 pod-bundle installs.
|
||||
|
||||
Plan mode and apply mode both work across those source kinds.
|
||||
|
||||
> Definitional note (carried, not a numeric drift) — `architecture.md` §6
|
||||
> describes bootstrap routing to `pod_bundle` / `rig_bundle`. The
|
||||
> `UpCommandRouter` *classification* enum is `SourceKind = "rig_spec" |
|
||||
> "rig_bundle" | "rig_name"` (`up-command-router.ts:6`); the
|
||||
> `pod_bundle`-vs-`rig_bundle` distinction is resolved one layer deeper in
|
||||
> `bootstrap-orchestrator.ts` by peeking the manifest schema version
|
||||
> (`:134-143`). Both statements are accurate at different layers; documented
|
||||
> here so the layering is explicit rather than appearing contradictory.
|
||||
|
||||
## 4. Legacy install engine (still ships)
|
||||
|
||||
(`architecture.md` §5 "Legacy systems that still ship" + §11 compat note 3)
|
||||
|
||||
These pre-reboot seams remain active for backward compatibility:
|
||||
|
||||
- package install engine: `package-install-service.ts`,
|
||||
`package-manifest.ts`, `package-repository.ts`, `install-engine.ts`,
|
||||
`conflict-detector.ts`, `role-resolver.ts` (all re-confirmed present
|
||||
@HEAD).
|
||||
- bootstrap and requirement-probe support.
|
||||
- discovery and claim services.
|
||||
- tmux/cmux adapters and resume adapters.
|
||||
|
||||
> Drift-check D-pkg — `architecture.md` §5/§6 carries no slice-00 numeric
|
||||
> drift in this content; the v2 bundle-assembler + dual-format flows were
|
||||
> verified accurate at HEAD (§1–§3 above). The footprint/migration counts
|
||||
> that ARE stale land in `daemon-core.md`, not here.
|
||||
|
||||
Compat note 3 (carried verbatim, `architecture.md` §11): "Legacy
|
||||
compatibility seams still ship for pre-reboot data and v1 artifacts." (The
|
||||
full §11 list lives in `architecture-rules-and-event-system.md`.)
|
||||
|
||||
> Provenance note (carried, not asserted): `bootstrap-orchestrator.ts:3-5`
|
||||
> still imports `LegacyRigSpec` / `LegacyRigSpecCodec` / `LegacyRigSpecSchema`
|
||||
> with in-source `TODO: AS-T08b — migrate to pod-aware RigSpec` markers, and
|
||||
> `:16` `TODO: AS-T12 — migrate to pod-aware bundle source resolver`. The
|
||||
> legacy seam is intentional, in-progress migration scaffolding — recorded
|
||||
> as-is, not smoothed.
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D-pkg (resolved-as-current):** no slice-00 numeric drift in this
|
||||
module's split content; v2 assembler + dual-format flows verified accurate
|
||||
at HEAD. Bundle-routing layering documented explicitly to pre-empt an
|
||||
apparent §6-vs-source contradiction.
|
||||
- Legacy-migration TODOs carried from source verbatim (AS-T08b / AS-T12).
|
||||
|
||||
## See also
|
||||
|
||||
- `agent-spec-and-startup.md` — `RigInstantiator` / `PodRigInstantiator` and
|
||||
the dual-format spec seam that bundles wrap.
|
||||
- `plugin-agent-image-context-pack.md` — the 0.3.0/0.3.1 reusable
|
||||
starter-state / content-provenance cluster (authored separately in 8.4b).
|
||||
- `daemon-core.md` — `/api/up` + `/api/bundles` are among the 49 route
|
||||
mounts; `BootstrapOrchestrator` is constructed in the createDaemon
|
||||
sequence.
|
||||
- Source roots: `packages/daemon/src/domain/{pod-bundle-assembler,
|
||||
bundle-types,bundle-source-resolver,bootstrap-orchestrator,
|
||||
up-command-router}.ts`, `packages/daemon/src/routes/{bundles,up}.ts`.
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
status: active
|
||||
topics: [extension-and-user-workspace, continuity, skill-management]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how OpenRig discovers plugins, captures/forks agent images,
|
||||
assembles/sends context packs, or how the Claude auto-compaction enforcer
|
||||
decides to send /compact. Author-mode module — no prior architecture.md
|
||||
prose; every claim is sourced to file:line at HEAD.
|
||||
siblings: [packaging-bootstrap-bundles.md, agent-spec-and-startup.md]
|
||||
prerequisite-reads: [../README.md, agent-spec-and-startup.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Content Layer — Plugins, Agent Images, Context Packs, Compaction Policy
|
||||
|
||||
Four filesystem-canonical content primitives the daemon discovers and
|
||||
serves: **plugins**, **agent images**, **context packs**, and the **Claude
|
||||
auto-compaction policy enforcer**. None adds SQLite state — all are
|
||||
filesystem-canonical with in-memory daemon caches.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists
|
||||
> for this. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths are
|
||||
> relative to `packages/daemon/src/` unless prefixed `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Context packs | **0.3.0** | `v0.3.0:domain/context-packs/context-pack-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Agent images | **0.3.0** | `v0.3.0:domain/agent-images/agent-image-library-service.ts` → present (slice-00 0.3.0-GT §1.9) |
|
||||
| Plugins | **0.3.1, NOT 0.3.0** | `v0.3.0:domain/plugin-discovery-service.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT seam (a); map row 5) |
|
||||
| Claude auto-compaction | **0.3.1 headline, no 0.3.0 antecedent** | `v0.3.0:domain/claude-compaction-enforcer.ts` → **ABSENT**; `v0.3.1:` → present (slice-00 0.3.0-GT map row 7) |
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/plugin-discovery-service.ts`
|
||||
> and `...claude-compaction-enforcer.ts` both fail ("exists on disk, but
|
||||
> not in 'v0.3.0'"); the two 0.3.0 library services resolve at `v0.3.0`.
|
||||
> Code present on disk at HEAD does NOT make plugins/compaction a 0.3.0
|
||||
> feature — ancestry is truth; the same-calendar-day commit date is the
|
||||
> forensic trap slice-00 0.3.0-GT seam (a) warns about.
|
||||
|
||||
**Do not back-attribute plugins or compaction to 0.3.0.** The CLI
|
||||
reference already encodes this ("## Plugin Inspection (v0.3.1)").
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:943` @HEAD.
|
||||
|
||||
## 1. Context packs (PL-014) — 0.3.0
|
||||
|
||||
A **context pack** is a directory of `manifest.yaml` + included markdown /
|
||||
yaml / txt files: operator-authored, library-discoverable, reviewable,
|
||||
sendable. No SQLite tables; in-memory cache at daemon scope.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-types.ts:1-11` @HEAD.
|
||||
|
||||
`ContextPackLibraryService.scan()` walks roots, parses each
|
||||
`manifest.yaml`, replaces the in-memory index; collisions are
|
||||
**last-wins** in discovery order (workspace > user_file > builtin).
|
||||
Startup wires three roots: builtin (`../context-packs`, first), user-file
|
||||
(`~/.openrig/context-packs`), and workspace-local
|
||||
`<workspaceRoot>/.openrig/context-packs` when present + distinct. Stable
|
||||
id `context-pack:<name>:<version>`.
|
||||
|
||||
> Source: `domain/context-packs/context-pack-library-service.ts:59-94`
|
||||
> (scan; last-wins L79-81), `:31-33` (id); `startup.ts:481-502` (3-root
|
||||
> IIFE; builtin `unshift` L497, workspace L491-493) @HEAD.
|
||||
|
||||
The parser is pure (no fs; caller passes raw YAML). It rejects malformed
|
||||
YAML, missing `name`/`version`, non-array `files`, per-file path traversal
|
||||
(`..`/leading `/`), and unsupported suffixes (`.md .markdown .yaml .yml
|
||||
.txt`); `version` coerced to string. Per-file token estimate is
|
||||
`ceil(bytes/4)`.
|
||||
|
||||
> Source: `domain/context-packs/manifest-parser.ts:11` (suffixes),
|
||||
> `:13-121` (rejects; traversal `:83-89`); token estimate
|
||||
> `context-pack-library-service.ts:46-48` @HEAD.
|
||||
|
||||
`assembleBundle` concatenates files into one paste-ready string framed
|
||||
`# OpenRig Context Pack: <name> v<version>` + optional purpose + `## File:
|
||||
<path> (role: <role>)` headers. Missing files are **skipped and surfaced
|
||||
in `missingFiles`** (operator repair), not a hard fail; a present-but-
|
||||
unreadable file throws `file_read_failed`.
|
||||
|
||||
> Source: `domain/context-packs/bundle-assembler.ts:38-39` (prefixes),
|
||||
> `:73-100` (missing skip L74-76; read-fail throw L81-87) @HEAD.
|
||||
|
||||
`contextPacksRoutes()` (mounted `server.ts:482`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`
|
||||
(assembled, no send), `POST /library/:id/send`
|
||||
(`{ destinationSession, dryRun }`; dry-run returns text without
|
||||
`SessionTransport`; real send → 502 on transport failure). `id==="sync"`
|
||||
→ 404 so it cannot shadow the sync route; 503 when unprovisioned. CLI:
|
||||
`rig context-pack list|show|preview|sync|add|send` (`send --dry-run`).
|
||||
|
||||
> Source: `routes/context-packs.ts:25-138` (table; sync-guard L51; 503
|
||||
> L31; dry-run L121-123; 502 L126-133); `server.ts:482`;
|
||||
> `docs/as-built/cli-reference.md:914-929` @HEAD.
|
||||
|
||||
## 2. Agent images (PL-016) — 0.3.0
|
||||
|
||||
An **agent image** is a snapshot bundle of a productive seat's resumable
|
||||
state: runtime-specific resume token (Claude `resume_token` / Codex
|
||||
`thread_id`), source-seat lineage, optional cwd-deltas + notes. Consumable
|
||||
by AgentSpec `session_source: mode: agent_image`. Filesystem-canonical at
|
||||
`~/.openrig/agent-images/<name>/` + workspace-local; **no SQLite tables**.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-types.ts:1-13` @HEAD.
|
||||
|
||||
`AgentImageLibraryService` mirrors `ContextPackLibraryService` (scan / list
|
||||
/ get / last-wins) with three source-stated differences: `sourceResumeToken`
|
||||
passes through to consumers (instantiator consumes; operator surfaces
|
||||
redact); `stats.json` is a separate mutable file updated atomically on
|
||||
fork-count increment; a `.pinned` sentinel pins from prune. Id
|
||||
`agent-image:<name>:<version>`.
|
||||
|
||||
> Source: `domain/agent-images/agent-image-library-service.ts:5-14`
|
||||
> (3-difference comment), `:38-41` (id) @HEAD.
|
||||
|
||||
`discoverResumeToken(db, sourceSession)` queries `sessions` then `nodes`
|
||||
and returns a typed failure (`session_not_found`/`runtime_unsupported`)
|
||||
rather than fabricating a token; only `claude-code`/`codex` have a native
|
||||
fork primitive. Claude prefers `context_usage.session_id` over persisted
|
||||
`sessions.resume_token`; Codex uses `resume_token`, falling back to a
|
||||
binding's `external_session_name` for `external_cli` seats; returns
|
||||
`nativeId: null` honestly when none.
|
||||
|
||||
> Source: `domain/agent-images/resume-token-discovery.ts:37-85` (honest
|
||||
> null L84; Claude pref L64-69; external_cli L78-83); honesty comment
|
||||
> `:9-10` @HEAD.
|
||||
|
||||
`SnapshotCapturer.capture()` routes through `discoverResumeToken`, throws
|
||||
`AgentImageError` on failure or missing native id (no fabricated token, no
|
||||
auto-fallback to fresh), writes manifest + empty `stats.json` via
|
||||
`install()`, re-scans. Captured `nodeCwd` → manifest `source_cwd` so the
|
||||
Use-as-starter snippet emits `cwd:` (fork starts in the parent's directory
|
||||
where Claude's project-dir-scoped jsonl lives); the daemon does NOT
|
||||
override cwd at fork dispatch.
|
||||
|
||||
> Source: `domain/agent-images/snapshot-capturer.ts:60-104` (throw
|
||||
> L65-79; source_cwd L88-93; install+scan L101-102);
|
||||
> `routes/agent-images.ts:256-265` (no-cwd-override contract) @HEAD.
|
||||
|
||||
**Evidence guard (CATASTROPHIC-bounce; fails closed).**
|
||||
`evaluateProtection()` protects an image from deletion if ANY: pinned;
|
||||
referenced by an active `agent.yaml`; referenced by a rig spec; or a
|
||||
lineage descendant of a protected image (transitive, fixed-point
|
||||
iteration). Spec-root scanning parses YAML structure (not string-grep)
|
||||
and is deliberately conservative — false positives (over-protection)
|
||||
acceptable; false negatives = catastrophic data loss. `--force` /
|
||||
`?force=true` overrides.
|
||||
|
||||
> Source: `domain/agent-images/evidence-guard.ts:1-16` (bounce +
|
||||
> fail-closed), `:53-115` (direct + transitive; fixed point L92-112),
|
||||
> `:117-122` (conservative) @HEAD.
|
||||
|
||||
**Resume-token redaction at route boundary (LOAD-BEARING).**
|
||||
`/api/agent-images` redacts `sourceResumeToken` → `"(redacted)"` on every
|
||||
operator-facing path; tokens are **never returned over the wire**; only
|
||||
the in-process rigspec-instantiator consumes the real token.
|
||||
|
||||
> Source: `routes/agent-images.ts:15-17` + `:44-46` (`redactResumeToken`),
|
||||
> applied `:60`,`:67`,`:94`,`:161`; slice-00 0.3.0-GT §1.9 flags this
|
||||
> LOAD-BEARING @HEAD.
|
||||
|
||||
`agentImagesRoutes()` (mounted `server.ts:483-485`): `GET /library`,
|
||||
`POST /library/sync`, `GET /library/:id`, `GET /library/:id/preview`,
|
||||
`POST /library/:id/pin`, `POST /library/:id/unpin`,
|
||||
`DELETE /library/:id` (evidence-guarded unless `force=true`),
|
||||
`POST /snapshot`, `POST /prune` (**dry-run by default**,
|
||||
`dryRun !== false`); guard `specRoots` injected via
|
||||
`deps.agentImageSpecRoots`. CLI: `rig agent-image
|
||||
list|show|preview|create|delete|pin|unpin|prune|sync`.
|
||||
|
||||
> Source: `routes/agent-images.ts:54-250` (table; prune default L112;
|
||||
> delete-guard L224-239); `server.ts:483-485`;
|
||||
> `docs/as-built/cli-reference.md:895-912` @HEAD.
|
||||
|
||||
## 3. Plugins (plugin-primitive Phase 3a) — **0.3.1, NOT 0.3.0**
|
||||
|
||||
> **Forensic trap (slice-00 0.3.0-GT seam (a)):** the plugin-discovery
|
||||
> file's first-creating commit is dated the same day as the 0.3.0 release
|
||||
> commit but is NOT an ancestor of `v0.3.0`. Plugins are **0.3.1**.
|
||||
> Re-confirmed at HEAD by the §0 tag-presence test.
|
||||
|
||||
`PluginDiscoveryService` is a read-only filesystem aggregator — no SQLite,
|
||||
no mutation routes (SC-29 EXCEPTION #8, declared verbatim in the source
|
||||
header). It scans four source kinds, unioned with provenance labels:
|
||||
vendored (`~/.openrig/plugins/<id>/`), Claude cache
|
||||
(`~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/`), Codex cache
|
||||
(same under `~/.codex`), and rig-cwd (`<cwd>/.claude/plugins/*` +
|
||||
`<cwd>/.codex/plugins/*`). Detection = presence of
|
||||
`.claude-plugin/plugin.json` and/or `.codex-plugin/plugin.json` (declares
|
||||
`runtimes`). Slice 28 added `skillCount` (readdir of `<plugin>/skills/`)
|
||||
to the list response so the index page avoids an N+1 detail fetch
|
||||
(SC-29 EXCEPTION #11, verbatim).
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:1-29` (SC-29 #8 + scan
|
||||
> desc), `:39` (4-kind `PluginSourceKind`), `listPlugins` `:205-276`
|
||||
> (4 scan blocks), `scanCwdBundledPlugins` `:283-307`, `detectPlugin`
|
||||
> `:393-453` (marker rule L399-404; `skillCount` L433-451 + #11
|
||||
> L75-80) @HEAD.
|
||||
|
||||
`getPlugin(id)` returns detail (manifests + skills + hooks + MCP servers);
|
||||
`rig-cwd:` ids are self-resolvable (parses the cwd out of the id prefix
|
||||
and re-scans so `/api/plugins/:id` does not 404 on an id `?cwd=`
|
||||
returned). `findUsedBy(id)` walks `agent.yaml` files parsing YAML
|
||||
structure (not string-grep — comments don't false-positive) to collect
|
||||
`resources.plugins[].id` + referencing profiles.
|
||||
|
||||
> Source: `domain/plugin-discovery-service.ts:309-369` (getPlugin;
|
||||
> rig-cwd self-resolve L310-321), `extractCwdFromRigCwdId` `:466-479`,
|
||||
> `findUsedBy` `:371-389`, `readResourcesPlugins` `:599-615`,
|
||||
> `readProfilesUsingPlugin` `:617-633` (structure-not-grep `:20-23`)
|
||||
> @HEAD.
|
||||
|
||||
`PluginVendorService.ensureLatest()` runs `ensureVendored` (hash-skip
|
||||
idempotent copy from `packages/daemon/assets/plugins/<name>/` to
|
||||
`~/.openrig/plugins/<name>/`) then `attemptAutoFetch`
|
||||
(`github.com/mvschwarz/openrig-plugins`, 5s timeout,
|
||||
**404/network/timeout tolerated silently** — vendored is ALWAYS the
|
||||
fallback; upstream repo intentionally empty at v0 so 404 is normal-state;
|
||||
NO tarball extraction at v0). Wired at startup for `openrig-core`.
|
||||
|
||||
> Source: `domain/plugin-vendor-service.ts:1-24`, `ensureVendored`
|
||||
> `:80-103` (hash-skip L97-99), `attemptAutoFetch` `:111-131`
|
||||
> (404-tolerant L116-117; no-extract L123-125), `ensureLatest`
|
||||
> `:138-141`; `startup.ts:434-472` @HEAD.
|
||||
|
||||
`pluginsRoutes()` (mounted `server.ts:478`, read-only): `GET /` (filters
|
||||
`?runtime=`, `?source=`, `?cwd=`), `GET /:id/used-by`,
|
||||
`GET /:id/files/list?path=`, `GET /:id/files/read?path=` (slice 28
|
||||
docs-browser), `GET /:id`. Literal sub-paths are mounted **before** the
|
||||
bare `/:id` catchall (route-order discipline). The files endpoints reuse
|
||||
the path-safety machinery with the discovered plugin's absolute path as a
|
||||
synthetic single-root allowlist (operator need not declare plugin paths
|
||||
in `OPENRIG_FILES_ALLOWLIST`; plugin folders read-only at v0 so the
|
||||
content hash is informational). CLI: `rig plugin
|
||||
list|show|used-by|validate`; no `install` verb at v0 (deferred to 0.3.2).
|
||||
|
||||
> Source: `routes/plugins.ts:29-39` + `:107-231` (table; before-catchall
|
||||
> L128-130/L141-142; `pluginRootAllowlist` :74-92; read-only-hash
|
||||
> L182-184); `server.ts:478`;
|
||||
> `docs/as-built/cli-reference.md:943-963` @HEAD.
|
||||
|
||||
> **OPEN-A** — `parseSourceFilter` (`routes/plugins.ts:69-72`) accepts
|
||||
> only `vendored|claude-cache|codex-cache`; it does NOT accept `rig-cwd`
|
||||
> though `PluginSourceKind` (`domain/plugin-discovery-service.ts:39`) and
|
||||
> `ListPluginsOpts.sourceFilter` include it. `?source=rig-cwd` is
|
||||
> silently dropped at the route while `?cwd=` still surfaces rig-cwd
|
||||
> entries unfiltered. Stated as-is; intent (rig-cwd reachable only via
|
||||
> `?cwd=`) vs filter-gap is not resolvable from source alone.
|
||||
|
||||
## 4. Claude auto-compaction enforcer (slice 27) — **0.3.1 headline**
|
||||
|
||||
> **No 0.3.0 antecedent (slice-00 0.3.0-GT map row 7).** The enforcer
|
||||
> file is absent at `v0.3.0`, present at `v0.3.1` (§0).
|
||||
|
||||
`ClaudeCompactionEnforcer.maybeAutoCompact()` decides per seat whether
|
||||
`ContextMonitor` should send `/compact`, driven by operator
|
||||
`policies.claude_compaction.*` settings. Decoupled from `ContextMonitor`
|
||||
scheduling; `ContextMonitor` is constructed with the enforcer at startup.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:7-12`, `maybeAutoCompact`
|
||||
> `:205-331`; `startup.ts:1191-1199` (enforcer →
|
||||
> `new ContextMonitor(db, contextUsageStore, claudeAdapter,
|
||||
> compactionEnforcer)`) @HEAD.
|
||||
|
||||
**Defensive contract** (source classifies compaction lifecycle as
|
||||
load-bearing per the banked permission-layer foot-gun rule):
|
||||
|
||||
- **Opt-in, default-off:** `enabled=false` → never triggers.
|
||||
- **Runtime filter:** triggers only when `runtime === "claude-code"`.
|
||||
- **Invalid-policy = disabled:** a hand-edited non-integer / out-of-
|
||||
`[1,100]` `thresholdPercent` is treated as disabled (safer-failure).
|
||||
- **Re-arm via threshold crossing:** session must drop below threshold
|
||||
before another auto-compact fires; window state NOT persisted (restart
|
||||
resets — safer-failure direction).
|
||||
- **Send-failure graceful-degrade:** returns `{ triggered: false,
|
||||
reason: "send_failed" }`, never throws; dedup timestamp set only on
|
||||
successful send so a transient failure retries next tick.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:14-44` (risk-class +
|
||||
> contract), runtime `:206-208`, invalid-policy `:224-232`,
|
||||
> dedup/threshold `:285-300`, send-fail `:312-314`/`:323-325` @HEAD.
|
||||
|
||||
**Active handshake state machine** (Claude hooks can supply context but
|
||||
do not create a new assistant turn): (1) **pre-compact prep** — first
|
||||
eligible above-threshold tick sends a normal user-channel prep prompt;
|
||||
state → `prep_prompt_sent`. (2) **/compact** — next eligible tick sends
|
||||
`/compact <instruction> + trust-channel bridge note`; sets dedup
|
||||
timestamp + `triggeredAboveThreshold`; queues post stage
|
||||
`turn_boundary`. (3) **post-compact, below threshold** — staged sends
|
||||
`turn_boundary` (ack-only) → `restore_prompt` (points at the pending
|
||||
marker `<openrigHome>/compaction/restore-pending/<key>.json`, transcript
|
||||
/ session-id fallbacks) → `compliance_prompt` (read-depth audit); final
|
||||
stage sets post-restore cooldown + clears the above-threshold latch.
|
||||
|
||||
> Source: `domain/claude-compaction-enforcer.ts:71-77` (compact+bridge),
|
||||
> `:79-98` (prep), `:108-145` (restore + marker path), `:147-163`
|
||||
> (compliance), `:165-171` (turn boundary), state machine `:233-330`
|
||||
> (below-threshold L233-283; above-threshold L302-330) @HEAD.
|
||||
|
||||
Policy fields via `SettingsStore.resolveClaudeCompactionPolicy()`:
|
||||
`enabled`, `thresholdPercent`, `preCompactInstruction`,
|
||||
`compactInstruction`, `messageInline`, `messageFilePath`,
|
||||
`postRestoreAuditInstruction` — each backed by a
|
||||
`policies.claude_compaction.*` key + `OPENRIG_POLICIES_CLAUDE_COMPACTION_*`
|
||||
env override. Defaults: dedup `60_000` ms; post-compact restore cooldown
|
||||
`10 * 60_000` ms.
|
||||
|
||||
> Source: `domain/user-settings/settings-store.ts:553-565` (resolver),
|
||||
> keys `:97-103`, env `:145-151`;
|
||||
> `domain/claude-compaction-enforcer.ts:45-46` (default windows) @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Filesystem-canonical, no new SQLite** across all four; daemon caches
|
||||
in-memory; compaction window state intentionally not persisted
|
||||
(context-pack-types.ts:8-11; agent-image-types.ts:12-13;
|
||||
routes/plugins.ts:6; claude-compaction-enforcer.ts:26-29) @HEAD.
|
||||
- **Honest failure, no fabrication** — discovery returns null; capturer
|
||||
throws; enforcer degrades + never throws; evidence guard fails closed
|
||||
(resume-token-discovery.ts:9-10; snapshot-capturer.ts:11-12;
|
||||
claude-compaction-enforcer.ts:30-33; evidence-guard.ts:16) @HEAD.
|
||||
- **503-when-unprovisioned** is the uniform route pattern when the
|
||||
backing service is absent from context (routes/context-packs.ts:31;
|
||||
routes/agent-images.ts:59; routes/plugins.ts:113) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — plugin `?source=` filter omits `rig-cwd` (§3); intent vs
|
||||
filter-gap unresolvable from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts. Slice-00 0.3.0-GT
|
||||
OPEN-3 (For-You verb subset) is out of scope (UI surface, not
|
||||
content-layer).
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Transport, Transcripts, Chat, Ask
|
||||
status: active
|
||||
topics: [coordination, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how rig send/capture/broadcast works, how pipe-pane transcript
|
||||
capture and rg/grep search behave, how durable rig chat (SQLite + SSE) is
|
||||
modeled, what rig ask gathers, or the exact MCP-tool-name vs tmux-metadata-key
|
||||
naming distinction.
|
||||
siblings: [daemon-core.md, lifecycle-snapshot-restore.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Transport, Transcripts, Chat, Ask
|
||||
|
||||
The communication-and-history layer: tmux is transport, not truth. Send/
|
||||
capture/broadcast wrap tmux with honest errors; transcripts are raw pipe-pane
|
||||
captures; chat is daemon-backed SQLite; `rig ask` gathers evidence but never
|
||||
calls an LLM.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c` (`git describe` →
|
||||
> `v0.3.1-6-g7eaf524c`). Package version **0.3.1** (slice-00 §1.1). Source
|
||||
> located by `architecture.md` headings (§5 Transport and communication, §6
|
||||
> Communication/Transcript/Chat flows, §11 Compat notes) per slice-08 §10.1
|
||||
> — line numbers advisory only.
|
||||
|
||||
## 1. The naming axes (D5 — read this first)
|
||||
|
||||
This module carries the D5 drift, which has **two independent axes** that
|
||||
must NOT be conflated or blanket-replaced (banked
|
||||
`feedback_release_prep_three_layer_depersonalization`: the rename was scoped,
|
||||
not global). Per-occurrence source verification:
|
||||
|
||||
**Axis 1 — MCP tool names: `rig_*` (architecture.md is STALE → corrected).**
|
||||
slice-00 §1.4 confirms all 17 MCP tools are `rig_*`. The `rigged_*`
|
||||
references in `architecture.md` are stale text from a rename that predates
|
||||
v0.2.0:
|
||||
|
||||
> Drift-fix D5 (MCP-tool-name axis) — `architecture.md` §5 `node-inventory.ts`
|
||||
> description says MCP `rigged_rig_nodes`; §6 chat flow says MCP
|
||||
> `rigged_chatroom_send` + `rigged_chatroom_watch`. Corrected to **`rig_*`**.
|
||||
> Re-confirmed at source: `mcp-server.ts:288` registers `"rig_rig_nodes"`,
|
||||
> `:305` `"rig_send"`, `:339` `"rig_capture"`, `:364` `"rig_chatroom_send"`,
|
||||
> `"rig_chatroom_watch"` (tool #17). Zero `rigged_chatroom_send` /
|
||||
> `rigged_send` / `rigged_rig_nodes` in non-test product source (grep clean
|
||||
> @HEAD). slice-00 §1.4; the rename landed in `b183c50c` pre-v0.2.0.
|
||||
|
||||
**Axis 2 — tmux metadata keys: `@rigged_*` (architecture.md is CORRECT → do
|
||||
NOT change).** The tmux metadata keys written at claim/bind time are a
|
||||
SEPARATE thing from MCP tool names and were NOT renamed:
|
||||
|
||||
> Drift-nuance D5 (tmux-metadata-key axis) — `architecture.md` §6 "Whoami
|
||||
> and adopted-session parity flow" lists `@rigged_node_id`,
|
||||
> `@rigged_session_name`, `@rigged_rig_id`, `@rigged_rig_name`,
|
||||
> `@rigged_logical_id`. These are **CORRECT as-is** — verified literally at
|
||||
> source: `claim-service.ts:77-81` writes exactly these five `@rigged_*`
|
||||
> keys. Do NOT blanket-sed `rigged` → `rig`; this is a per-occurrence
|
||||
> verify, not a global substitution. (The metadata-key axis is detailed in
|
||||
> `agent-spec-and-startup.md` §6.) Re-confirmed `claim-service.ts:77-81`
|
||||
> @HEAD.
|
||||
|
||||
## 2. Transport and communication domain services
|
||||
|
||||
(`architecture.md` §5 "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.
|
||||
- `history-query.ts` — transcript + chat search. Prefers `rg` when
|
||||
available, falls back to `grep -E`, surfaces which backend was used.
|
||||
Re-confirmed: `history-query.ts:7` `backend: "rg" | "grep" | "none"`;
|
||||
`:103` execs `rg -i --no-filename -e <pattern>`; `:108` returns
|
||||
`backend: "rg"`.
|
||||
- `ask-service.ts` — context-engineering evidence pack: gathers rig summary
|
||||
plus transcript excerpts, chat excerpts, insufficiency state, guidance.
|
||||
Does NOT call an external LLM.
|
||||
- `chat-repository.ts` — durable rig-scoped chat: CRUD for `chat_messages`
|
||||
table, SSE-compatible event emission.
|
||||
|
||||
Routes: `routes/{transport,transcripts,ask,chat,whoami}.ts` — all confirmed
|
||||
present @HEAD.
|
||||
|
||||
## 3. Communication flow
|
||||
|
||||
(`architecture.md` §6 "Communication flow")
|
||||
|
||||
`rig send <session> "message"` → CLI → `POST /api/transport/send` →
|
||||
`SessionTransport`:
|
||||
|
||||
1. Resolve session name (canonical or legacy; by session/rig/pod/global).
|
||||
2. Check mid-work state (unless `--force`) — re-confirmed
|
||||
`session-transport.ts:136-141` (`findPatternEvidence(recentLines,
|
||||
MID_WORK_PATTERNS)`); legacy mid-work check at `:666`.
|
||||
3. Two-step tmux send: `send-keys -l` → ~200ms delay → `C-m`
|
||||
(`session-transport.ts:717` submits `C-m`).
|
||||
4. Optional `--verify`: capture post-send pane, check message visibility
|
||||
(`session-transport.ts:305` `verify?`; `:694` `if (opts?.verify)`).
|
||||
5. Honest result with reason on failure.
|
||||
|
||||
Architecture Rule 18 (carried, `architecture.md` §7): tmux is transport, not
|
||||
truth — `send/capture/broadcast` wrap tmux reliably with honest errors.
|
||||
|
||||
### 3b. Cross-host coordination verbs (v0.4.6 — OPR.0.4.6.MH4)
|
||||
|
||||
`rig send/capture/transcript/broadcast --host <id>` (and the `agent@rig@host`
|
||||
target sugar on the session-target verbs) cross the host boundary with **zero
|
||||
daemon-side changes** — the remote daemon's existing local routes do all the
|
||||
work; the only net-new is the CLI transport branch.
|
||||
|
||||
- **The seam principle (arch-ruled): SIDEDNESS + CALLER pick the seam.**
|
||||
ONE-SIDED remote ops from a BEARER-CAPABLE caller (the CLI resolves the
|
||||
registry bearer locally) go **CLI-DIRECT** via the shipped
|
||||
`runRemoteHttpOp` — one hop, no local-daemon involvement (the
|
||||
`ps --all-hosts` precedent). TWO-SIDED ops (MH-3's queue handoff closes the
|
||||
local source and creates the remote successor — the local daemon owns half
|
||||
the transaction) or BEARER-INCAPABLE callers (the browser: MC-action,
|
||||
MH-2's read-through) use the **daemon-side forward-then-strip**. MH-4's
|
||||
four verbs are pure one-sided remote ops from the CLI.
|
||||
- **Transport is dictated by the host entry (ssh XOR http), never a
|
||||
per-call choice:** ssh hosts keep the shipped shell-out byte-verbatim for
|
||||
send/capture; http hosts (the `pair` front door's kind) take the
|
||||
CLI-direct branch to `POST /api/transport/send|capture|broadcast` /
|
||||
`GET /api/transcripts/*` with the SAME bodies/paths the local CLI builds
|
||||
(one route, two callers = wrap parity by construction). transcript and
|
||||
broadcast are http-only (no ssh path exists for them); a wrong-transport
|
||||
verb dies with the structured requirement error — never a fallback.
|
||||
- **Terminal-bearer posture (named limitation, v0 — `/api/transport/*`
|
||||
ONLY):** the remote's transport routes (send/capture/broadcast) gate on
|
||||
ITS TERMINAL bearer class (`OPENRIG_TERMINAL_BEARER_TOKEN`, default null →
|
||||
pass-through; the tailnet is the auth boundary by design), while the CLI
|
||||
presents the REGISTRY bearer from `hosts.yaml`. A remote enforcing a
|
||||
DIFFERENT terminal bearer surfaces as the structured `permission-gate`
|
||||
step (never a hang, never silent). Remedy: set the remote terminal bearer
|
||||
equal to the paired registry bearer, or rely on the tailnet boundary.
|
||||
**The transcript read is DELIBERATELY outside this class (arch n2):**
|
||||
`/api/transcripts/*` mounts UNGATED (`server.ts` — the shipped open-route
|
||||
posture; daemon-local trust boundary with route-level credential
|
||||
redaction as the protective primitive), so a wrong terminal bearer that
|
||||
permission-gates `send --host` does NOT gate `transcript --host` — the
|
||||
read keeps succeeding, and the proof matrix must not treat the auth-fail
|
||||
class as uniform across the four verbs. A coherent transcript-read auth
|
||||
policy across tail/grep/full is a named future slice per
|
||||
`routes/transcripts.ts`'s own comment (orch approved-option-a).
|
||||
Bearer-class unification is likewise a NAMED follow-up, not v0 (no
|
||||
un-asked auth machinery).
|
||||
- **BR-1 holds:** the 3-part form is CLI-edge sugar only (suffix must match
|
||||
a REGISTERED host id, else passthrough + a loud host hint); every session
|
||||
string that reaches any daemon stays `member@rig`; the host travels
|
||||
out-of-band. Durable cross-host coordination stays MH-3's queue — MH-4
|
||||
adds no queue surface.
|
||||
|
||||
## 4. Transcript flow
|
||||
|
||||
(`architecture.md` §6 "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`.
|
||||
3. `TranscriptStore` owns path convention, ANSI stripping on read, boundary
|
||||
markers, `readTail`, `grep`.
|
||||
4. `rig transcript <session> --tail N / --grep "pattern"` provides
|
||||
agent-facing access.
|
||||
5. On restore: a boundary marker is written before re-launch; pipe-pane
|
||||
reconnects to the same file (append). (Restore-side detail in
|
||||
`lifecycle-snapshot-restore.md` §3.)
|
||||
6. `rig ask` gathers rig summary plus transcript excerpts, chat excerpts,
|
||||
insufficiency state, and guidance.
|
||||
|
||||
Architecture Rule 19 (carried): transcripts are raw capture via pipe-pane,
|
||||
ANSI strip on read; `rg` preferred, `grep -E` fallback. Rule 22: `rig ask`
|
||||
is context engineering — gathers evidence, does NOT call an external LLM;
|
||||
the agent IS the LLM.
|
||||
|
||||
## 5. Chat flow
|
||||
|
||||
(`architecture.md` §6 "Chat flow")
|
||||
|
||||
1. `rig chatroom send <rig> "message"` → `POST
|
||||
/api/rigs/:rigId/chat/send` → `ChatRepository.addMessage()`.
|
||||
2. SSE: `GET /api/rigs/:rigId/chat/watch` delivers real-time messages.
|
||||
3. History: `GET /api/rigs/:rigId/chat/history` returns full channel
|
||||
history; `POST /api/rigs/:rigId/chat/topic` persists topic markers.
|
||||
4. UI: chat-room tab in the rig drawer.
|
||||
5. MCP: **`rig_chatroom_send` + `rig_chatroom_watch`** (D5 axis-1 correction
|
||||
— `architecture.md` §6 said `rigged_*`; source `mcp-server.ts:364` +
|
||||
tool #17).
|
||||
6. Source of truth: daemon-backed SQLite (`chat_messages` table), NOT tmux
|
||||
scrollback.
|
||||
|
||||
**ChatMessage** type (`architecture.md` §4): durable rig-scoped message —
|
||||
`id`, `rigId`, `sender`, `kind`, `body`, `topic`, `createdAt`.
|
||||
|
||||
## 6. Compatibility notes (carried verbatim, `architecture.md` §11)
|
||||
|
||||
The intentional limits in this layer:
|
||||
|
||||
- Note 4 — `rig ask` gathers context only; does not call an external LLM.
|
||||
The agent reasons about the gathered evidence.
|
||||
- Note 5 — transcript search prefers `rg` but falls back to `grep -E`;
|
||||
search quality/performance varies by backend.
|
||||
- Note 6 — chat is rig-scoped only: no cross-rig channels or DMs.
|
||||
- Note 7 — `--verify` on `rig send` checks pane content for message
|
||||
visibility but can produce false positives from pre-existing matching
|
||||
content. Known limitation.
|
||||
|
||||
(The full §11 compatibility-notes list lives in
|
||||
`architecture-rules-and-event-system.md`.)
|
||||
|
||||
## OPEN / carried items
|
||||
|
||||
- **D5 (the careful one — resolved per-occurrence, NOT blanket-replaced):**
|
||||
Axis 1 (MCP tool names) corrected `rigged_*` → `rig_*` (3 sites: §5
|
||||
node-inventory, §6 chat flow ×2). Axis 2 (tmux `@rigged_*` metadata keys)
|
||||
verified literally correct at `claim-service.ts:77-81` and left unchanged.
|
||||
No slice-00 numeric drift applies to this module's split content.
|
||||
|
||||
## See also
|
||||
|
||||
- `daemon-core.md` — transport/transcript/chat/ask are among the 49 route
|
||||
mounts; MCP tool count (17, `rig_*`) is anchored there.
|
||||
- `agent-spec-and-startup.md` §6 — the tmux `@rigged_*` metadata-key axis
|
||||
detail (whoami/adopt).
|
||||
- `lifecycle-snapshot-restore.md` §3 — restore-side transcript boundary
|
||||
markers.
|
||||
- `coordination-primitive.md` §3b — MH-3's cross-host QUEUE routing (the
|
||||
two-sided daemon-forward twin of §3b's one-sided CLI-direct verbs).
|
||||
- `cli-reference.md` § Cross-host execution — the per-verb transport
|
||||
capability table, parse rules, and http failure taxonomy (MH-3 + MH-4).
|
||||
- Source roots: `packages/daemon/src/domain/{session-transport,
|
||||
transcript-store,history-query,ask-service,chat-repository}.ts`,
|
||||
`packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`,
|
||||
`packages/cli/src/mcp-server.ts`,
|
||||
`packages/cli/src/{remote-host-ops,cross-host-target,cross-host-executor}.ts` (v0.4.6 MH-4).
|
||||
@@ -0,0 +1,479 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
status: active
|
||||
topics: [coordination, runtime-control]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the daemon-native Workflow Runtime works — workflow specs
|
||||
cache, instance state, step trails, the transactional-scribe projection
|
||||
contract, or the watchdog policy set including workflow-keepalive.
|
||||
siblings: [coordination-primitive.md, mission-control.md]
|
||||
prerequisite-reads: [../README.md, coordination-primitive.md]
|
||||
last-verified-against-source: slice/opr-0.4.6.wf2-spec-language tip (base d18907ed — WF-1 merged)
|
||||
last-updated: 2026-07-06
|
||||
---
|
||||
|
||||
# Workflow Runtime + Watchdog Policies (PL-004 Phase C/D)
|
||||
|
||||
The daemon-native Workflow Runtime (PL-004 Phase D) turns an intended
|
||||
sequence of work into durable SQLite state: declarative workflow specs, live
|
||||
instance state, append-only step trails, and the load-bearing
|
||||
transactional-scribe contract. The PRD §L4 operating model is
|
||||
owner-as-author semantically plus workflow-as-transactional-scribe
|
||||
mechanically (`architecture.md` §3 L398).
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`.
|
||||
|
||||
## 1. The three Phase D tables (+ the diagnostic addition)
|
||||
|
||||
Re-confirmed at HEAD in `packages/daemon/src/db/migrations/`:
|
||||
|
||||
- **`workflow_specs`** (`033_workflow_specs.ts:33` `CREATE TABLE … workflow_specs`)
|
||||
— a read-through cache of human-authored markdown/YAML spec files. Sources
|
||||
are workspace-surface; the daemon caches by `(name, version)` with a
|
||||
content `source_hash` so valid operator edits to the spec file win at next
|
||||
read (workspace-surface reconciliation). Spec authoring stays
|
||||
markdown-authoritative; the cache exists for fast lookup and runtime
|
||||
resolution (`architecture.md` §3 L400).
|
||||
- **`workflow_instances`** (`034_workflow_instances.ts:45` `CREATE TABLE …
|
||||
workflow_instances`) — live state per running workflow: `status`
|
||||
(`active|waiting|completed|failed`), `current_frontier_json` (active qitem
|
||||
ids), `hop_count` (loop-guard counter), `last_continuation_decision_json`.
|
||||
Instances survive daemon restart from SQLite — no filesystem
|
||||
reconciliation (`architecture.md` §3 L402).
|
||||
- **`workflow_step_trails`** (`035_workflow_step_trails.ts:32` `CREATE TABLE …
|
||||
workflow_step_trails`) — append-only history of meaningful step
|
||||
transitions. Every closure produces one trail row pairing the prior qitem
|
||||
with the next qitem (null on terminal). `WorkflowStepTrailLog.record()` is
|
||||
the only writer (`architecture.md` §3 L404).
|
||||
- **`040_workflow_specs_diagnostic.ts`** — slice-11 (`f68f453a`,
|
||||
slice-00 §1.3 provenance). An `ALTER TABLE ADD COLUMN` adding
|
||||
parser/validator diagnostic columns to `workflow_specs` (**no new table,
|
||||
no constraint changes beyond a DEFAULT**; the cache carries the
|
||||
parse/validation diagnostic for the UI to render — `040_..._diagnostic.ts:5–25`).
|
||||
**This migration is net-new since `architecture.md` was last edited** and
|
||||
is not described in any prior as-built doc (slice-00 §1.3 — `f68f453a`
|
||||
postdates the §3 body) — authored here from source per the slice-08
|
||||
drift-to-fix register.
|
||||
|
||||
`036_watchdog_policy_enum_extension.ts` is a documenting no-op that records
|
||||
the Phase D watchdog enum extension (Phase C uses application-layer
|
||||
enforcement via the `PHASE_D_POLICIES` array, so no DDL is needed —
|
||||
`architecture.md` §3 L363).
|
||||
|
||||
## 2. The transactional-scribe contract
|
||||
|
||||
The load-bearing Phase D guarantee is implemented in
|
||||
`WorkflowProjector.project()` (`packages/daemon/src/domain/workflow-projector.ts`).
|
||||
Re-confirmed at HEAD: the projector header (`workflow-projector.ts:1–20`)
|
||||
declares "transactional-scribe contract" and `project()` runs a single
|
||||
`db.transaction` (`workflow-projector.ts:184` `const txn = this.db.transaction(...)`).
|
||||
Inside that one transaction:
|
||||
|
||||
1. Close the current packet (state mutation on `queue_items`).
|
||||
2. Create the next-step packet (`QueueRepository.createWithinTransaction()`,
|
||||
`workflow-projector.ts:230`).
|
||||
3. Record the trail entry.
|
||||
4. Update the instance frontier + status.
|
||||
5. Persist workflow events.
|
||||
|
||||
Either everything commits or everything rolls back; lost handoffs are
|
||||
impossible by design. Post-commit, subscribers are notified and the next
|
||||
owner is nudged (`architecture.md` §3 L406).
|
||||
|
||||
`WorkflowRuntime` (`packages/daemon/src/domain/workflow-runtime.ts:61`
|
||||
`export class WorkflowRuntime`) is the orchestration class above the
|
||||
projector.
|
||||
|
||||
**Phase D scope boundary** (`architecture.md` §3 L412): excludes multi-hop
|
||||
chaining, gate-return-sweep, and the closure-enforcement path. The daemon's
|
||||
transactional state remains the closure authority via Phase A's hot-potato
|
||||
strict-rejection (see `coordination-primitive.md` §3); the workflow runtime
|
||||
**projects on closure, it does not gate closure**.
|
||||
|
||||
## 3. The workflow-keepalive watchdog policy
|
||||
|
||||
`workflow-keepalive` is the Phase C-deferred watchdog policy, a TypeScript
|
||||
port of the POC `lib/policies/workflow-keepalive.mjs` adapted to read SQLite
|
||||
(`packages/daemon/src/domain/policies/workflow-keepalive.ts:1–5`).
|
||||
Re-confirmed at HEAD (`workflow-keepalive.ts:5–16`):
|
||||
|
||||
- **LOAD-BEARING:** it MUST read `workflow_instances` directly via SQLite —
|
||||
never the markdown source.
|
||||
- Eligibility: `status === "active" || status === "waiting"`. Else
|
||||
`action=terminal, reason="workflow_not_active"`.
|
||||
- Frontier empty + no fallback target: skip with `reason="empty_frontier"`.
|
||||
- Resolves frontier qitem owners by querying `queue_items`; combines with
|
||||
explicit observer/created-by targets; sends to the first resolved target.
|
||||
|
||||
The watchdog supervision tree itself (PL-004 Phase C, `031_watchdog_jobs.ts` /
|
||||
`032_watchdog_history.ts`) records only meaningful evaluations; quiet skip
|
||||
reasons (`not_due`, `no_actionable_artifacts`, `active_wake_not_due`) are
|
||||
NOT recorded and do NOT emit `watchdog.*` events — POC parity so agents are
|
||||
not woken about scheduler polls (`architecture.md` §3 L362). The Phase D
|
||||
policy enum extends Phase C's three values with `workflow-keepalive`.
|
||||
|
||||
## 4. Workflow events
|
||||
|
||||
> Drift-fix D8 / OPEN-4 (carried verbatim, slice-00): `architecture.md` §3
|
||||
> L410 says "Existing 20 PL-004 events are unchanged" — internally
|
||||
> inconsistent with L394's "32 PL-004 events untouched". **Do NOT carry
|
||||
> either number.** The current `RigEvent` union
|
||||
> (`packages/daemon/src/domain/types.ts:94`) has **73 members** total
|
||||
> (slice-00 §1.8, re-confirmed at HEAD). The additive Phase D `workflow.*`
|
||||
> events are described below WITHOUT asserting a contested PL-004 sub-count.
|
||||
|
||||
Phase D extends `RigEvent` with the additive `workflow.*` events
|
||||
(re-confirmed `domain/types.ts:196–201`): `workflow.instantiated`,
|
||||
`workflow.step_closed`, `workflow.next_qitem_projected`,
|
||||
`workflow.completed`, `workflow.failed`, `workflow.routing_table_changed`
|
||||
(6 members; a separate `workflow_spec` event also exists in the union).
|
||||
|
||||
## 5. Route surface
|
||||
|
||||
`/api/workflow` (`server.ts:495`) — `POST /validate`
|
||||
(`routes/workflow.ts:82`), `POST /instantiate` (`:93`,
|
||||
`getRuntime(c).instantiate(...)`), `POST /project` (`:118`,
|
||||
`getRuntime(c).project(...)` — the transactional-scribe entry),
|
||||
`GET /:instance_id/trace` (instance + trail), `POST /:instance_id/continue`
|
||||
(idempotent inspect). Surface enumerated `routes/workflow.ts:21–28`.
|
||||
Cross-ref: the `rig workflow` CLI surface — see `../cli-reference.md`.
|
||||
|
||||
## 6. The WF-1 failure envelope (OPR.0.4.6.WF1)
|
||||
|
||||
Added on top of the kept Phase D core (nothing above was re-specced;
|
||||
FR-1 regression tests pin it):
|
||||
|
||||
- **Step deadlines (FR-2, derived — never stored):**
|
||||
`workflow-deadline.ts` classifies every active|waiting instance's
|
||||
frontier packet by anchor — claimed w/ `closure_required_at` ·
|
||||
claimed w/ NULL deadline (`claimed_at` + threshold; workflow packets
|
||||
ship tier `mode2`, which has no SLA entry) · never-claimed
|
||||
(`created_at` + threshold) · unclaimed-after-claim (`created_at`;
|
||||
unclaim NULLs `claimed_at`). `WORKFLOW_STEP_STUCK_THRESHOLD_SECONDS`
|
||||
(4h, = the routine-tier SLA) is THE single threshold home; WF-5
|
||||
binds to it. Stuck self-clears on normal re-projection.
|
||||
- **Keepalive auto-arm (FR-3):** instantiate + every handoff ensure
|
||||
ONE per-instance `workflow-keepalive` watchdog job INSIDE the scribe
|
||||
transaction; terminal exits disarm it. Auto-armed jobs carry
|
||||
`context.deadline_gated: true` — quiet while healthy, and their
|
||||
overdue send targets the stuck packet's owner with re-project
|
||||
steering. Operator-registered jobs keep exact POC always-send parity.
|
||||
- **Boot sweep (FR-4):** `workflow-boot-sweep.ts` at daemon startup —
|
||||
re-arms missing keepalives, reissues LOST post-commit nudges
|
||||
(pending frontier packet with `last_nudge_attempt` NULL = the
|
||||
commit-then-crash window, detected from the nudge ledger), surfaces
|
||||
stuck instances; one summary log line.
|
||||
- **Real idempotency (FR-5):** waiting-replay ABSORPTION under the
|
||||
full closure-intent identity (exit/packet/step/actor/resultNote/
|
||||
effective-blocker/evidence deep-equal) — exact replay = zero writes;
|
||||
any mismatch = a new decision via the normal path. Migration 049
|
||||
adds `workflow_instances.version`: every guarded advance bumps it
|
||||
`WHERE version = ?`; a stale writer gets structured
|
||||
`instance_version_conflict` and its whole transaction rolls back.
|
||||
- **max_hops enforced (FR-6):** compared at projection via
|
||||
`exceedsMaxHops(hopCount, baseline, maxHops)` (v1 baseline = 0; the
|
||||
baseline is WF-5's resume seam). Exceeding converts the handoff to
|
||||
an honest structured failure (packet closed, instance failed, guard
|
||||
evidence in trail + `workflow.failed`). Migration 050 adds
|
||||
`workflow_specs.spec_json` — before it, `loop_guards`/`invariants`/
|
||||
`closure`/`entry` were silently DROPPED at projection-time
|
||||
rehydration (column-only rebuild); legacy rows self-heal on
|
||||
readThrough and degrade VISIBLY (named once-per-spec advisory).
|
||||
- **Validation (FR-7):** `parseWorkflowSpec` rejects unknown keys loud
|
||||
at every level against EXPORTED closed keysets (WF-2 extends them);
|
||||
the validator walks reachability/cycles over the projector's own
|
||||
exported `resolveNextStep` — unreachable steps fail; a cycle without
|
||||
`max_hops` fails naming the fix; with it, sanctioned.
|
||||
- **`continue` honesty (FR-8):** relabeled to its real read-only
|
||||
inspector semantics everywhere (CLI description/outcome, route
|
||||
comment); `project` remains the sole advance write path.
|
||||
- **The v2 dispositions (FR-9):** every declared-but-unenforced key —
|
||||
`invariants.{continuation_required,preserve_lineage,closure_required}`,
|
||||
`closure.*`, step `gates[]`, role `skill_refs`, `next_hop.mode:
|
||||
prefer`, `loop_guards.spawn_budget` — produces the fail-open
|
||||
`declared_not_enforced_v1` validator advisory (warning; never
|
||||
blocks). `spawn_budget`'s advisory names its WF-2/WF-6
|
||||
parallel-frontier acceptance pointer (arch ruling 2026-07-06).
|
||||
`fallbackSynthesis` (instance column, never written) is dispositioned
|
||||
in the `workflow-types.ts` JSDoc.
|
||||
|
||||
## 7. The WF-2 spec language (OPR.0.4.6.WF2)
|
||||
|
||||
WF-2 grows the language the ratified WF-1 engine speaks. ONE named
|
||||
engine extension (branch execution); everything else is language +
|
||||
compilation onto shipped seams.
|
||||
|
||||
**Conditional-on-outcome branching (FR-1).** A step may declare
|
||||
`next_hop.on: {<exit>: <step-id>}` — branch keys are the recorded exit
|
||||
enum ONLY (`handoff|waiting|done|failed`; closed set, enforced at
|
||||
parse — `spec_branch_key_invalid`). A MAPPED exit routes to its target
|
||||
INSIDE the same scribe transaction: next qitem created in-txn, instance
|
||||
stays ACTIVE bound to the target, hop count + version guard bumped
|
||||
identically to a linear advance, and the taken branch recorded
|
||||
ADDITIVELY (`lastContinuationDecision.branchTaken` + the trail row's
|
||||
`closure_evidence.branch_taken`) — never in `closure_reason` (closed
|
||||
Phase-A enum). An UNMAPPED `failed`/`done` stays terminal exactly as
|
||||
before; unmapped `waiting` stays a park. The `max_hops` guard fires on
|
||||
ANY route (branch routes create the canonical remediation cycles);
|
||||
cycle detection at validation runs over the structural ∪ branch edge
|
||||
union and requires a declared `max_hops` to sanction any cycle.
|
||||
Routing seam: `resolveNextStep(spec, step, recordedExit?)` — one
|
||||
exported function, structural default when no exit supplied (the
|
||||
validator's path).
|
||||
|
||||
**Per-step `harness:` pin (FR-2).** `claude-code | codex` (agent
|
||||
harnesses only — `terminal` rejected at parse with a teaching error; Pi
|
||||
joins in 0.4.7). Owner resolution picks the first `preferred_target`
|
||||
whose node `runtime` column matches (`nodeRuntimeOf`: latest session →
|
||||
node join); no match = structured `harness_pin_unsatisfied` naming the
|
||||
pin + every candidate's runtime. Explicit owner overrides are
|
||||
reconciled too — an override can never silently defeat a pin. Static
|
||||
check at instantiate for every pinned step; re-checked at each route.
|
||||
|
||||
**Per-step `host:` pin (FR-3).** `local`/absent = full execution today.
|
||||
A registry id validates against `~/.openrig/hosts.yaml` (daemon
|
||||
read-only twin; unknown id = `host_not_registered` naming registered
|
||||
ids) but a remote pin fails loud at INSTANTIATE with
|
||||
`host_pin_remote_unsupported` naming the MH-3 boundary + workaround —
|
||||
the queue is local-only until MH-3; no qitem is ever minted into a
|
||||
queue that cannot route it, and there is no silent local fallback.
|
||||
|
||||
**Structured step-level `gate:` (FR-5 — the socket; WF-5 owns
|
||||
semantics).** Singular per step, closed keyset `{target, summary,
|
||||
evidence_ref}`. HUMAN target (the shipped human-seat predicate) →
|
||||
compiles to a human-routed item (tier `human-gate` + summary +
|
||||
evidence_ref — the shipped 0.4.4 write path), resolved by the shipped
|
||||
`resolve` verb; HANDLER-ROLE target → an ordinary agent item to the
|
||||
role's resolved seat. Routing INTO a gated step creates the gate item
|
||||
as the frontier packet and parks the instance `waiting`; resolve/close
|
||||
continues the flow from that step (the WF-1 unpark — no restart). A
|
||||
gated ENTRY step parks from birth.
|
||||
|
||||
**Dispositions (FR-4) — the inert third state is dead.** The legacy
|
||||
step `gates: [...]` string list is REMOVED at parse
|
||||
(`spec_gates_removed`, what/why/fix naming the new `gate:` object);
|
||||
`next_hop.mode: prefer` is REMOVED at parse
|
||||
(`spec_prefer_mode_removed` — it never had distinct behavior).
|
||||
`skill_refs` / `closure.*` / `invariants.{continuation_required,
|
||||
preserve_lineage,closure_required}` keep their WF-1 FR-9 explicitly-v2
|
||||
advisories; `spawn_budget` stays explicitly-v2 (WF-6/parallel-frontier
|
||||
acceptance pointer). Every key is consumed, removed, or
|
||||
machine-readably advisory — zero silently-inert keys.
|
||||
|
||||
**Versioning honesty (FR-6).** New strictness lands at
|
||||
`parseWorkflowSpec` (the only seam that sees raw keys; the WF-1
|
||||
exported closed keysets extended with `harness`/`host`/`gate` +
|
||||
`next_hop.on`) and applies at validate/instantiate/re-parse. A LIVE
|
||||
instance pinned to a pre-WF-2 spec version keeps executing un-failed
|
||||
(project() has no validation gate, by design; stored `spec_json` blobs
|
||||
missing the new optional fields read fine); the same spec FILE
|
||||
re-validated fails under the new rules.
|
||||
|
||||
**Hand-authorability (FR-6).** Three shipped example shapes at
|
||||
`packages/daemon/src/builtins/workflow-specs/`: `linear-build.yaml`
|
||||
(zero WF-2 features — the zero-regression reference),
|
||||
`gated-release.yaml` (human gate + harness pins),
|
||||
`branched-remediation.yaml` (bounded failed-path remediation loop).
|
||||
|
||||
**The composed RSI example (OPR.0.4.6.FAC2).** `factory-rsi.yaml` is the
|
||||
single-rig recursive-self-improvement factory MVP: it composes the branch +
|
||||
gate + guard primitives into the inner loop — `plan → implement → qa_check →
|
||||
review → release_prep` — where `qa_check`/`review` branch `failed` back to
|
||||
`implement` (bounded remediation) and `release_prep` (the release-manager
|
||||
prepares artifacts, un-gated) hands off to a human-gated `release_signoff`.
|
||||
Dogfood is decoupled from this gated loop: the dogfood seat runs out-of-band
|
||||
against the shipped product and feeds its findings into the next plan (the RSI
|
||||
edge, ungated). The remediation loops are sanctioned only by the enforceable
|
||||
`loop_guards.max_hops`; a trip is a WF-5 exception (orchestrator-first via
|
||||
`exception_routing`). It targets the shipped `factory-rsi` launch starter
|
||||
(`specs/rigs/launch/factory-rsi/`) whose seats pin 1:1 to its roles via
|
||||
`preferred_targets` — the v0 hardcode seam, no binding layer.
|
||||
|
||||
## The CLI surface (OPR.0.4.6.WF3)
|
||||
|
||||
WF-3 made the CLI the primary human/agent driving surface. Render-side
|
||||
by rule (BR-2): `run`/`watch` consume the shipped SSE endpoints
|
||||
(snapshot-first-then-stream, priorQitemId dedup, reconnect → announced
|
||||
poll fallback, outcome-as-exit-code: 0 completed / 3 failed);
|
||||
`trace`/`list`/`show` human modes are formatted (argo-shape tree, ps-
|
||||
mechanics columns, ATTN markers) while `--json` stays byte-stable;
|
||||
`status` composes the needs-attention rollup CLI-SIDE from the
|
||||
API-carried `instance.deadline` classification (one threshold home —
|
||||
the CLI never recomputes a class). The two daemon additions:
|
||||
|
||||
- **`route`** (`POST /api/workflow/:id/route`, runtime `route()`):
|
||||
close+recreate+rebind in ONE scribe transaction — honest
|
||||
`handed_off_to` closure with provenance, successor recreate (same
|
||||
step; `current_step_id` unchanged; no hop bump — route is not an
|
||||
advance), frontier rebind under the version guard, keepalive
|
||||
re-target in-txn. Advance-authority revocation is STRUCTURAL: the
|
||||
old packet leaves the frontier in the transaction, so a zombie
|
||||
owner's stale `project` hits the shipped `packet_not_on_frontier`
|
||||
409.
|
||||
- **The frontier close-path guard** (`workflow-frontier-guard.ts` →
|
||||
INJECTED into `QueueRepository` at startup; the queue never imports
|
||||
the workflow domain): terminal closure of a live frontier packet
|
||||
from non-workflow verbs rejects `workflow_frontier_packet` with a
|
||||
what/why/fix naming `rig workflow project` / `route`. Workflow
|
||||
writers pass `viaWorkflowVerb`; non-workflow qitems see zero new
|
||||
behavior.
|
||||
|
||||
## The exception + human-gate model (OPR.0.4.6.WF5)
|
||||
|
||||
The deterministic engine's exception layer: the happy path stays
|
||||
orchestrator-free and PROVEN so; every exception becomes exactly one
|
||||
durable attention item the moment it exists; the responder resolves it
|
||||
and the flow resumes from where it stopped.
|
||||
|
||||
- **The taxonomy** (`workflow-exception.ts`): three closed classes as
|
||||
pure predicates over recorded state — `unmapped_failed` (recorded
|
||||
`status=failed`; a WF-2-mapped `failed` routes to remediation and is
|
||||
NOT an exception), `stuck_overdue` (the WF-1 deadline evaluator's
|
||||
verdict consumed verbatim — the single threshold home), and
|
||||
`human_gate_trip` (WF-2 HUMAN gates; the compiled park IS the item).
|
||||
Handler-role gate-trips are deterministic handoffs, not exceptions —
|
||||
classes (a)/(b) backstop the handler's own step. The occurrence key
|
||||
is the recorded packet id of the episode: re-detections dedupe,
|
||||
resolve+resume closes, a fresh packet is a NEW occurrence.
|
||||
- **The maturity dial** (`workflow-exception-router.ts` + the
|
||||
`exception_routing` spec grammar + the `workflow.exception_routing`
|
||||
settings key): target resolution = spec per-class → spec default →
|
||||
host dynamic key → ORCHESTRATOR-FIRST (the declared orchestrator
|
||||
role via the same `preferred_targets` pick step owners use) →
|
||||
`human@host` never-lost fallback. THE TIER SPLIT: `human-gate` rides
|
||||
ONLY human-routed positions — an orchestrator-routed item carries
|
||||
the ordinary tier so the shipped attention union (which matches on
|
||||
tier regardless of destination) never leaks it into NEEDS-YOU. The
|
||||
shipped attention predicate is untouched.
|
||||
- **Class (a) born-in-txn**: the projector's failed-terminal branch
|
||||
creates the item INSIDE the failing transaction — no window where
|
||||
the instance is failed and no item exists; a gate-rejected routed
|
||||
destination re-creates on `human@host` (never lost, never fails the
|
||||
close). **Class (b) at detection**: the boot sweep and the keepalive
|
||||
evaluation call the injected ensurer (`workflow-exception-
|
||||
escalation.ts`) — occurrence-deduped against OPEN items by tag
|
||||
query; the crash-surviving sweep re-creates a missed item.
|
||||
- **`resume`** (`POST /api/workflow/:id/resume`, runtime `resume()`,
|
||||
`rig workflow resume`): redrive semantics in ONE scribe transaction —
|
||||
failed→active REBOUND to the recorded failed step; the owner is
|
||||
RE-RESOLVED through the projection resolver (never copied from the
|
||||
stale destination — resume is the one sanctioned re-resolution
|
||||
point); `--decision` lands durably in the redrive packet; the
|
||||
occurrence's open items close with provenance; the trail is
|
||||
preserved, never rewritten. THE LIVELOCK RAIL (migration 051):
|
||||
`hops_baseline` re-anchors the max_hops guard at resume so each
|
||||
redrive gets exactly one bounded window; `resume_count` is the
|
||||
recorded redrive fact; re-exceeding raises an honest NEW occurrence.
|
||||
- **The workflow-aware ▲ band** (`review/compose.ts
|
||||
deriveWorkflowExceptions` + the gatherer source): the missing-item
|
||||
backstop row, the stuck row with evaluator evidence, the
|
||||
frontier-non-open ANOMALY row (detection behind WF-3 FR-6's
|
||||
prevention), and THE AWARENESS ROW — an orchestrator-routed
|
||||
exception renders holder + age in the human band (one identity, two
|
||||
projections, count = 1); human-routed items render nothing there
|
||||
(the ● item is the row). Recomposition clears on state-exit.
|
||||
|
||||
## The workflow ↔ rig binding layer (OPR.0.4.6.FAC1)
|
||||
|
||||
Run any workflow on any rig that can support it, without editing the
|
||||
spec — the self-driving factory's binding substrate. Three seams:
|
||||
|
||||
- **A1 — bind at instantiation** (migration 052,
|
||||
`workflow_instances.bound_rig`): the effective binding =
|
||||
`--rig`/`targetRig` override `?? spec.target.rig ?? null`; the spec
|
||||
field stays a DEFAULT (no routing path reads it — display only). The
|
||||
rig NAME persists (the durable operator-space coordinate); name→id
|
||||
re-resolves fresh at each resolution site — a vanished rig fails loud
|
||||
(`bound_rig_not_found`), never silently. NULL = unbound =
|
||||
byte-identical pre-FAC-1 behavior. **Unknown-rig validation SPLITS by
|
||||
PROVENANCE** (arch ruling 2026-07-07, target-rig zero-regression):
|
||||
an explicit operator `--rig`/`targetRig` is AUTHORITATIVE — unknown →
|
||||
`bound_rig_unknown` HARD-FAIL before any mutation; a spec-default
|
||||
`target.rig` is ADVISORY (authored under the pre-FAC-1 regime where the
|
||||
field was ignored at runtime) — unknown → DEGRADE to unbound + a LOUD
|
||||
`advisories` notice on the instantiate result (surfaced by route + CLI
|
||||
stderr), never a hard-fail. This preserves AC-1 zero-regression for
|
||||
shipped/example specs that declare a descriptive `target.rig` AND route
|
||||
via `preferred_targets` (e.g. `conveyor`): they instantiate exactly as
|
||||
pre-FAC-1. A spec that genuinely needs a bound rig still fails loudly,
|
||||
per-step (entry at instantiate, later role-only steps at projection) —
|
||||
the degrade is to honest per-step failure with a heads-up, never to
|
||||
silence.
|
||||
- **A2 — role→seat capability resolution**
|
||||
(`workflow-role-resolver.ts` pure policy +
|
||||
`workflow-role-context.ts` lazy sync snapshot): TIER 3 inside
|
||||
`resolveDefaultOwner`, activating ONLY when a role declares zero
|
||||
`preferred_targets` AND the instance is bound. Tier order is sacred:
|
||||
explicit owner → gate compile → declared `preferred_targets`
|
||||
(byte-identical, never inventory-filtered) → capability → loud null.
|
||||
The CLOSED fact set: role (`nodes.role`, declared per pod member —
|
||||
seat-side, opt-in) · nodeKind · lifecycleState (`running` only) ·
|
||||
runtime (harness-pin aware) · sync `pendingWorkCount` (pending-only
|
||||
backlog) · the derived canonical coordinate. Async
|
||||
`attachAgentActivity`/tmux probes are structurally absent from the
|
||||
transaction. Managed seats only: adopted seats are excluded LOUDLY
|
||||
(`adopted_seat_not_role_resolvable_v1`). Selection = least backlog,
|
||||
plain-codepoint coordinate tiebreak (`driver10@rig < driver2@rig`).
|
||||
Resolution runs ONCE at step-close (after the frontier + absorption
|
||||
guards — replays perform zero inventory reads) and records as the
|
||||
packet destination; the WF-5 resume is the one re-resolution point,
|
||||
now capability-aware. All SIX owner-resolution sites carry the
|
||||
context: projector next-step, human-gate parked-packet owner,
|
||||
handler-role gate destination (no-targets + bound → capability),
|
||||
runtime entry (live + recorded), the eager instantiate loop
|
||||
(STRUCTURAL zero-role-coverage check only — `bound_rig_role_uncovered`;
|
||||
no live resolution of future steps; a warming rig instantiates), and
|
||||
resume. The WF-5 exception dial's orchestrator-role position resolves
|
||||
capability-aware on the bound rig at both homes (in-txn class-(a) +
|
||||
detection-time class-(b)), non-throwing with the human@host
|
||||
never-lost fallback. Failures are loud-with-candidates: structured
|
||||
per-candidate disqualifiers + a named zero-candidate message; never a
|
||||
spawn, never auto-`add_member`, never a dead-seat route. Additive
|
||||
`owner_resolution` trail evidence records `{mode, role, boundRig?,
|
||||
seat}` per routing decision.
|
||||
- **A3 — roles bind to SEATS, never occupants**: the ONE string rule —
|
||||
the derived canonical coordinate `{pod}-{member}@{rig}` is both the
|
||||
tiebreak key and the recorded destination, so an agent handover
|
||||
behind the seat never strands the workflow (raw occupant-era session
|
||||
names are never recorded as role-resolution destinations).
|
||||
|
||||
## The member-exists instantiate advisory (OPR.0.4.6.FAC3 — engine bit)
|
||||
|
||||
The queue transport validates the RIG of a destination, never the member
|
||||
(`topologyValidateRig` is rig-exists-only by design — hardening it would
|
||||
gate every queue write and break legitimate non-managed destinations),
|
||||
so a declared `preferred_target` naming a registered rig but a typo'd or
|
||||
stale MEMBER would mint an orphan packet, visible only later as a WF-5
|
||||
stuck exception. FAC-3 catches it at the earliest knowable moment: at
|
||||
instantiate, for every step-referenced role (each step's `actor_role`
|
||||
plus a handler gate's target role — the same reference set the
|
||||
structural coverage check walks), each declared `preferred_target` that
|
||||
parses CANONICAL and names a rig registered on this daemon is probed
|
||||
for member existence (`rigMemberExists` beside `rigDeclaresRole` —
|
||||
sync SQL, the derived canonical coordinate per the FAC-1 Q5 one-string
|
||||
rule, existence at ANY lifecycle state and node kind; liveness stays
|
||||
projection's business). An unknown member yields ONE aggregated
|
||||
advisory per unique target — naming every declaring step/role pair, the
|
||||
consequence (the work will not be claimed; it will surface as a stuck
|
||||
exception), and the fix hint — pushed into the shipped
|
||||
`InstantiateResult.advisories` list (the FAC-1 `target.rig`-degrade
|
||||
surface: one list, now two producers; rendered by the route body + CLI
|
||||
stderr, zero new surface). ADVISORY-NEVER-DENY: instantiate always
|
||||
proceeds. Skips (in order): human-seat refs (classified BEFORE parse,
|
||||
the queue-gate archetype), non-canonical raw/adopted destinations (the
|
||||
inventory cannot vouch for them), unregistered rigs (the transport
|
||||
already rejects those loudly at queue-write — no double advisory).
|
||||
|
||||
## See also
|
||||
|
||||
- `coordination-primitive.md` — PL-004 Phase A; the closure authority the
|
||||
runtime projects against.
|
||||
- `mission-control.md` — PL-005 queue observability + 7-verb contract.
|
||||
- Source roots: `packages/daemon/src/domain/{workflow-projector,
|
||||
workflow-runtime,workflow-instance-store,workflow-spec-cache,
|
||||
workflow-step-trail-log,workflow-validator}.ts`,
|
||||
`packages/daemon/src/domain/policies/workflow-keepalive.ts`,
|
||||
`packages/daemon/src/domain/{workflow-exception,workflow-exception-router,
|
||||
workflow-exception-escalation}.ts`, `packages/daemon/src/routes/workflow.ts`.
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how a rig declares a typed workspace (workspaceRoot / repos /
|
||||
defaultRepo / knowledgeRoot), how that block is persisted and resolved into
|
||||
whoami / node-inventory, how per-item target_repo scope is validated, or how
|
||||
the file-backed missions/slices tree is indexed and projected into the
|
||||
Project UI. Author-mode module — no prior architecture.md prose; every
|
||||
load-bearing claim is sourced to file:line at HEAD.
|
||||
siblings: [content-surfaces.md, daemon-core.md, ../ui/project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, daemon-core.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# Workspace Primitive — RigSpec.workspace, Migrations 038/039, Missions/Projects/Slices
|
||||
|
||||
The **workspace primitive (PL-007)** is the typed declaration that lets a rig
|
||||
name *where its work lives* — a workspace root, a set of named repos with
|
||||
kinds, an optional default repo, and an optional knowledge root — and have
|
||||
that surface through `whoami` / node-inventory and gate per-item repo scope.
|
||||
Alongside it, a **file-backed missions/slices tree** is indexed read-only and
|
||||
projected into the Project UI's mission / slice surfaces.
|
||||
|
||||
> **AUTHOR-FROM-SOURCE module.** No prior `architecture.md` prose exists for
|
||||
> this. Every load-bearing claim carries `> Source: <file:line> @HEAD`;
|
||||
> ambiguity is declared as an OPEN item, never smoothed. Paths are relative to
|
||||
> `packages/daemon/src/` unless prefixed `packages/` or `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2 commits;
|
||||
> no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
>
|
||||
> **SPLIT NOTE (§10.6).** The honest source-grounded scope of
|
||||
> `workspace-and-content-primitives` (proposed-structure.md §4.9 / D13)
|
||||
> exceeds ~400 lines across two distinct subsystem clusters with distinct
|
||||
> source roots. Per the §10.6 SPLIT DIRECTIVE it is split by subsystem
|
||||
> cluster into this module (workspace primitive + migrations 038/039 +
|
||||
> missions/projects/slices) and the sibling `content-surfaces.md`
|
||||
> (files/markdown/progress/steering). This is a transparent noted deviation
|
||||
> from ratified Q7 "18", a faithful application of the founder's own
|
||||
> ratified Q3 principle ("distinct primitive / distinct source root →
|
||||
> split"); surfaced at the slice-08 review gate, not a mid-execution
|
||||
> interrupt.
|
||||
|
||||
## 0. Release attribution (forensic proof — read first)
|
||||
|
||||
Per §10.8, re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Subsystem | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| `domain/workspace/{workspace-resolver,frontmatter-validator,default-workspace-scaffold}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| migrations `038_workspace_primitive.ts`, `039_queue_target_repo.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/slices/{slice-indexer,slice-detail-projector}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `routes/{workspace,slices,projects}.ts` | **≤0.3.0** | `v0.3.0:<path>` → present |
|
||||
| `domain/workspace/getting-started-narrative.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
| `routes/missions.ts` | **0.3.1** | `v0.3.0:` → **ABSENT**; `v0.3.1:` → present |
|
||||
|
||||
> Source: §10.8 proof re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/daemon/src/domain/workspace/getting-started-narrative.ts`
|
||||
> and `...routes/missions.ts` both fail ("exists on disk, but not in
|
||||
> 'v0.3.0'"); all other listed paths resolve at `v0.3.0`. The workspace
|
||||
> primitive itself is a **0.3.0** feature (slice-00 0.3.0-GT §1.4, PL-007,
|
||||
> migrations 038/039) — do not back-attribute it to 0.3.1. The
|
||||
> `missions` route + the getting-started narrative scaffold are the
|
||||
> **0.3.1** additions layered on top (slice 12 mission scope; slice 21
|
||||
> onboarding-conveyor).
|
||||
|
||||
slice-00 0.3.0-GT §1.4 independently confirms the primitive shipped 0.3.0:
|
||||
workspace-primitive merge `2ce54abc` (PL-007) with migrations
|
||||
`038_workspace_primitive` + `039_queue_target_repo`, "fresh databases apply
|
||||
migrations through `039_queue_target_repo`".
|
||||
|
||||
## 1. The typed workspace declaration (PL-007) — 0.3.0
|
||||
|
||||
`RigSpec.workspace` is an **optional** typed block. Rigs without it stay valid;
|
||||
`whoami` / node-inventory return a null workspace block in that case.
|
||||
|
||||
> Source: `domain/types.ts:762-770` (`WorkspaceSpec`), `:751-758`
|
||||
> (`WorkspaceRepoSpec`), `:780` (`RigSpec.workspace?`) @HEAD.
|
||||
|
||||
| Field | Shape | Notes |
|
||||
|---|---|---|
|
||||
| `workspaceRoot` | string | Verbatim from spec |
|
||||
| `repos[]` | `{ name, path, kind }[]` | `path` resolved to absolute at parse time; authors may declare relative to `workspaceRoot` in YAML |
|
||||
| `defaultRepo?` | string | active repo when no env override / cwd match |
|
||||
| `knowledgeRoot?` | string | treated as `kind=knowledge` when surfaced |
|
||||
|
||||
`WorkspaceKind` is a closed 5-member union: `user`, `project`, `knowledge`,
|
||||
`lab`, `delivery`.
|
||||
|
||||
> Source: `domain/types.ts:748-749` (`WORKSPACE_KINDS` /
|
||||
> `WorkspaceKind`) @HEAD.
|
||||
|
||||
### 1.1 Persistence — migration 038 + RigRepository
|
||||
|
||||
Migration **038** adds `workspace_json TEXT` to the `rigs` table. It holds the
|
||||
typed `RigSpec.workspace` block as JSON when a rig declares one; NULL for rigs
|
||||
without a workspace block.
|
||||
|
||||
> Source: `db/migrations/038_workspace_primitive.ts:16-21`
|
||||
> (`ALTER TABLE rigs ADD COLUMN workspace_json TEXT`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
|
||||
`RigRepository.setRigWorkspace(rigId, workspace)` persists it (UPDATE
|
||||
`workspace_json` + `updated_at`); `getRigWorkspace(rigId)` reads it back,
|
||||
JSON-parsing to `WorkspaceSpec` and returning `null` on parse failure. Both
|
||||
are **defensive no-ops** when the column is absent (`hasRigColumn` probe) —
|
||||
older test fixtures that bypass the canonical migration list don't have the
|
||||
column; the setter's contract is "best-effort persistence".
|
||||
|
||||
> Source: `domain/rig-repository.ts:98-105` (setter, column probe
|
||||
> L99), `:106-117` (getter; parse-fail → null L114-116) @HEAD.
|
||||
|
||||
The instantiator persists the block at rig-create time **only when declared**:
|
||||
|
||||
> Source: `domain/rigspec-instantiator.ts:653-655` (`if
|
||||
> (rigSpec.workspace) … setRigWorkspace(rigId, rigSpec.workspace)`) @HEAD.
|
||||
|
||||
### 1.2 Runtime resolution — workspace-resolver
|
||||
|
||||
`resolveWorkspaceContext({ spec, cwd, envOverride })` (consumed by
|
||||
`whoami-service`) returns the `WhoamiWorkspaceBlock` or `null` when no spec.
|
||||
`activeRepo` resolution: an `envOverride` (non-empty, trimmed) wins
|
||||
**verbatim** — even an unknown repo name is honored, because operators set
|
||||
`OPENRIG_TARGET_REPO` consciously (PL-007 PRD § Item 3); otherwise
|
||||
`defaultRepo` is used **only if it names a declared repo**. `knowledgeKind`
|
||||
is `"knowledge"` when `knowledgeRoot` is declared, else `null`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:26-52` (resolver;
|
||||
> env-override-verbatim L34-43, including the "honored verbatim"
|
||||
> comment L35-36) @HEAD.
|
||||
|
||||
`whoami-service` reads the persisted spec and resolves with the query's
|
||||
`targetRepoOverride`, falling back to `process.env["OPENRIG_TARGET_REPO"]`:
|
||||
|
||||
> Source: `domain/whoami-service.ts:319-324` (`getRigWorkspace` +
|
||||
> `resolveWorkspaceContext`, env fallback L323); `whoami` returns
|
||||
> `workspace` in its payload `:335` @HEAD.
|
||||
|
||||
`resolveNodeWorkspace({ spec, cwd })` (consumed by `node-inventory`) derives a
|
||||
per-node `NodeWorkspaceInfo` by walking the node's `cwd` up the directory
|
||||
tree for the **longest-prefix repo path** that contains it; falls back to
|
||||
`knowledge` when cwd is under `knowledgeRoot`, then to the rig's
|
||||
`defaultRepo` when cwd doesn't resolve. Containment uses a `path.relative`
|
||||
boundary check (not a string `startsWith`) so `/foo/bar` does not match
|
||||
`/foo/bar-other`.
|
||||
|
||||
> Source: `domain/workspace/workspace-resolver.ts:57-95`
|
||||
> (longest-prefix L67-73; knowledge fallback L77-79; default-repo
|
||||
> fallback L82-88), `isInside` `:97-103`;
|
||||
> `domain/node-inventory.ts:397` (`workspace: resolveNodeWorkspace(...)`),
|
||||
> `:434-437` (`NodeWorkspaceInfo`) @HEAD.
|
||||
|
||||
### 1.3 Per-item repo scope — migration 039 + queue validation
|
||||
|
||||
Migration **039** adds `target_repo TEXT` to `queue_items` plus
|
||||
`idx_queue_items_target_repo`. It carries the per-item typed repo scope when
|
||||
an operator passes `--target-repo <name>`; NULL when the qitem is
|
||||
unambiguous against the rig's `default_repo` or no workspace is declared.
|
||||
Mission Control views surface the field for cross-rig handoff clarity.
|
||||
|
||||
> Source: `db/migrations/039_queue_target_repo.ts:16-22`
|
||||
> (`ALTER TABLE queue_items ADD COLUMN target_repo TEXT` +
|
||||
> `CREATE INDEX … idx_queue_items_target_repo`); doc-comment
|
||||
> `:3-14` @HEAD.
|
||||
|
||||
The queue route validates `target_repo` at the route layer against the
|
||||
**source rig's** `RigSpec.workspace.repos[]`: it parses the rig name out of
|
||||
`source_session` (`<member>@<rig>`), looks up the rig, reads
|
||||
`getRigWorkspace`, and rejects an unknown repo with `unknown_target_repo` +
|
||||
the known-repo list. It **fails open** (`{ ok: true }`) when there is no
|
||||
rigRepo, no parseable rig, the rig is unknown, or no workspace is declared —
|
||||
validation only bites when a workspace actively declares repos.
|
||||
|
||||
> Source: `routes/queue.ts:49-57` (fail-open guards), `:55`
|
||||
> (`getRigWorkspace`), `:58-66` (`unknown_target_repo` +
|
||||
> `knownRepos`) @HEAD.
|
||||
|
||||
## 2. Workspace HTTP route — frontmatter validator (0.3.0)
|
||||
|
||||
`workspaceRoutes()` (mounted `server.ts:491` as `/api/workspace`) exposes a
|
||||
**single read-only endpoint** at v0:
|
||||
|
||||
`POST /api/workspace/validate` — body `{ root, workspaceKind?, recursive?,
|
||||
requireFrontmatter?, maxFiles? }`; returns a `FrontmatterValidationReport`.
|
||||
`root` is required (400 `root_required`); an out-of-vocabulary
|
||||
`workspaceKind` is rejected 400 `invalid_workspace_kind`; validator throws
|
||||
→ 500 `validate_failed`. **No filesystem mutation.**
|
||||
|
||||
> Source: `routes/workspace.ts:23-65` (handler; root-required L35-37;
|
||||
> kind-enum L39-47; 500 L59-62); mounted `server.ts:491` @HEAD.
|
||||
|
||||
`validateWorkspaceFrontmatter()` walks a root, parses each `.md` file's
|
||||
YAML frontmatter (delimited `---` on the first line), and emits a structured
|
||||
gap report — **advisory only, never modifies files**. Gap kinds:
|
||||
`missing-required-field`, `unrecognized-status-value`, `parse-error`,
|
||||
`missing-frontmatter`. Per-kind required fields: `user`/`project` →
|
||||
`["doc"]`; `knowledge`/`lab`/`delivery` → `["doc","status","created","owner"]`.
|
||||
Valid `status` enum: `active|draft|archived|superseded`. Default behavior
|
||||
skips files without `---` silently (informal notes) unless
|
||||
`requireFrontmatter`; recurses by default; skips `node_modules` / `.git` /
|
||||
`.worktrees` / `dist` / `build`; hard cap `maxFiles` default 10000.
|
||||
|
||||
> Source: `domain/workspace/frontmatter-validator.ts:53` (status enum),
|
||||
> `:56-62` (per-kind required), `:23-27` (gap kinds), `:90-122` (walk;
|
||||
> skip-dirs L105-111), `:160-172` (missing-frontmatter behavior),
|
||||
> `:201-226` (required + status check) @HEAD.
|
||||
|
||||
CLI surface: `rig workspace validate [root]` — v0 is intentionally narrow
|
||||
(`validate` only); future versions add typed-kind authoring on the same
|
||||
walker.
|
||||
|
||||
> Source: `docs/as-built/cli-reference.md:931-941` @HEAD.
|
||||
|
||||
## 3. Default workspace scaffold (0.3.0 spine; 0.3.1 narrative layer)
|
||||
|
||||
`workspaceScaffoldDirs()` / `workspaceScaffoldFiles()` produce the
|
||||
mission-aware default workspace (`~/.openrig/workspace/` or `--root`). The
|
||||
same scaffold is used **idempotently by daemon startup** so a fresh install
|
||||
has a browsable Project workspace before the operator discovers
|
||||
`rig config init-workspace`. Canonical subdirs: `missions/`, `artifacts/`,
|
||||
`evidence/`, `progress/`, `field-notes/`, `specs/`, `dogfood-evidence/`,
|
||||
plus per-mission `missions/<id>/slices/<slice-id>` folders. Drops
|
||||
`README.md` + `STEERING.md` (placeholder) + per-mission `README.md` /
|
||||
`PROGRESS.md` + per-slice `README.md` / `PROGRESS.md` /
|
||||
`IMPLEMENTATION-PRD.md`.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:44-59`
|
||||
> (`workspaceScaffoldDirs`), `:247-283` (`workspaceScaffoldFiles`),
|
||||
> `WORKSPACE_README` `:82-94`, `STEERING_PLACEHOLDER` `:96-120`;
|
||||
> CLI `packages/cli/src/commands/config-init-workspace.ts:1-16`
|
||||
> (idempotent; `--dry-run`/`--force`; never deletes operator
|
||||
> content L12-16); cli-reference workspace note "See `rig config
|
||||
> init-workspace`" `:939` @HEAD.
|
||||
|
||||
**0.3.1 narrative layer (slice 21 onboarding-conveyor).** The
|
||||
`getting-started` mission's two slices ship rich teaching content from
|
||||
`GETTING_STARTED_NARRATIVE` (the click-through-to-learn surface) including a
|
||||
`timeline.md` the slice Story tab renders via slice-06's
|
||||
`useSliceTimelineMarkdown` hook; other slices keep the boilerplate.
|
||||
|
||||
> Source: `domain/workspace/default-workspace-scaffold.ts:1-4`
|
||||
> (slice-21 header), `:153-190` (narrative branch in `sliceReadme`),
|
||||
> `:192-200` (`sliceTimeline`), `:270-279` (timeline emit); §0 proof:
|
||||
> `getting-started-narrative.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
|
||||
## 4. File-backed missions / slices tree
|
||||
|
||||
### 4.1 SliceIndexer (Slice Story View v0) — 0.3.0
|
||||
|
||||
`SliceIndexer` reads slice folders from a configured filesystem root. The
|
||||
**default workspace contract** is
|
||||
`workspace/missions/<mission>/slices/<slice>`; explicitly configured flat
|
||||
roots (`workspace/slices/<slice>`) remain supported for compatibility.
|
||||
A nested `slices/` child folder makes the parent a mission
|
||||
(`missionId = <mission-folder>`); a bare folder is a flat slice
|
||||
(`missionId = null`). NO new SQLite migration, NO new event type: read-only
|
||||
projection over existing tables (`queue_items`, `queue_transitions`,
|
||||
`mission_control_actions`) + dogfood-evidence directories. Time-bounded
|
||||
listing + detail caches (`invalidate()` drops both). `isReady()` is true
|
||||
when any configured slice root exists on disk.
|
||||
|
||||
> Source: `domain/slices/slice-indexer.ts:1-14` (contract + no-new-state),
|
||||
> `:174-176` (`isReady`), `:179-182` (`invalidate`), `:212-256`
|
||||
> (`readSliceLocations`; nested-vs-flat L233-253), `:62-89`
|
||||
> (`SliceListEntry` shape: `missionId` / `slicePath` / `qitemIds` /
|
||||
> `proofPacket`) @HEAD.
|
||||
|
||||
### 4.2 SliceDetailProjector (Slice Story View v0 + v1) — 0.3.0
|
||||
|
||||
Given a `SliceRecord`, the projector assembles the full per-slice payload
|
||||
across six tabs (Story, Acceptance, Decisions, Docs, Tests/Verification,
|
||||
Topology). Read-only; composes already-shipped tables +
|
||||
`workflow_specs`/`workflow_instances`/`workflow_step_trails` + slice docs on
|
||||
disk + dogfood-evidence. **v1 removed the v0 hardcoded legacy phase enum**
|
||||
(`discovery`/`product-lab`/`delivery`/…): `StoryEvent.phase` is now an
|
||||
open-ended string-or-null — the spec-defined `step.id` when bound to a
|
||||
`workflow_instance`, else `null` (UI groups under "Untagged"). When no
|
||||
workflow runtime is constructed the projector silently degrades to v0
|
||||
behavior (`workflowBinding=null`, `specGraph=null`).
|
||||
|
||||
> Source: `domain/slices/slice-detail-projector.ts:1-21` (six-tab
|
||||
> contract + v1 enrichment), `:18-21` (v0 phase enum REMOVED at v1);
|
||||
> startup degrade-path `startup.ts:1063-1076` (projector built with
|
||||
> `workflowRuntime?.specCache`; comment L1064-1071) @HEAD.
|
||||
|
||||
### 4.3 Slices routes — 0.3.0
|
||||
|
||||
`slicesRoutes()` (mounted `server.ts:501` as `/api/slices`):
|
||||
`GET /` (filter `all|active|done|blocked`, default `all`; `?refresh=1`
|
||||
invalidates; optional `?boundToWorkflow=<name>:<version>` lens narrows to
|
||||
slices bound to a workflow instance), `POST /refresh` (drops both indexer
|
||||
caches — no daemon restart), `GET /:name/proof-asset/*` (path-traversal
|
||||
guarded; immutable 1-day cache), `GET /:name/doc/*` (markdown for the Docs
|
||||
tab; traversal guarded), `GET /:name` (full per-tab payload). 503
|
||||
`slices_indexer_unavailable` when unwired; 503 `slices_root_not_configured`
|
||||
+ setup hint when not ready. **Route-order discipline:** literal `/` /
|
||||
`/refresh` / `/:name/proof-asset/*` / `/:name/doc/*` are registered BEFORE
|
||||
the dynamic `/:name` so they are not shadowed.
|
||||
|
||||
> Source: `routes/slices.ts:31-177` (handlers; route-order L34-35,
|
||||
> L101-102, L109-113, L166-167; 503s L38-44; `boundToWorkflow`
|
||||
> L60-86); mounted `server.ts:501` @HEAD.
|
||||
|
||||
### 4.4 Missions route — **0.3.1** (slice 12 + slice 13 + slice 18)
|
||||
|
||||
`missionsRoutes()` (mounted `server.ts:505` as `/api/missions`) is the
|
||||
mission scope data layer. `GET /:missionId` returns
|
||||
`{ missionId, missionPath, slices, workflow_spec, topology, status }` —
|
||||
slices filtered from the SliceIndexer by `missionId`; `missionPath` derived
|
||||
by going up two levels from any slice's `slicePath`; `workflow_spec` parsed
|
||||
lazily from `<missionPath>/README.md` frontmatter (same `parseWorkflowSpecRef`
|
||||
helper the slice-indexer uses); `topology.specGraph` projected via
|
||||
`projectSpecGraph(spec, null)` when the spec is cached, `{ specGraph: null }`
|
||||
when declared-but-not-cached, `null` when nothing declared; `status` read
|
||||
from README frontmatter. `POST /:missionId/complete` writes
|
||||
`status: complete` to the mission README frontmatter (idempotent; preserves
|
||||
unrelated fields) — the daemon is the audit-trail surface, the UI keeps an
|
||||
optimistic localStorage mirror. 404 `mission_not_found` when no slices match.
|
||||
|
||||
> Source: `routes/missions.ts:39-118` (handlers), `:124-142`
|
||||
> (`writeMissionStatusComplete`), `:148-150` (`computeMissionPath`
|
||||
> up-two-levels), `:171-177` (`readMissionWorkflowSpec`), `:205-215`
|
||||
> (`computeMissionTopology`); mounted `server.ts:505`; §0 proof:
|
||||
> `routes/missions.ts` [email protected] ⇒ **0.3.1** @HEAD.
|
||||
|
||||
### 4.5 Projects route — Coordination L2 classifier (PL-004 Phase B) — 0.3.0
|
||||
|
||||
`projectsRoutes()` (mounted `server.ts:492` as `/api/projects`) backs the
|
||||
`rig project` CLI verb — this is the **PL-004 Phase B project *classifier***
|
||||
(lease lifecycle + idempotent classify + operator-verb reclaim + SSE), NOT
|
||||
the Project *workspace UI* (that is the slices/missions surface above; the
|
||||
naming overlap is a documented seam). Endpoints: `POST /lease/acquire`
|
||||
(optional `evaluateDeadnessFirst` clears stale/dead leases first),
|
||||
`POST /lease/heartbeat`, `POST /reclaim-classifier`, `POST /project`
|
||||
(idempotent on `stream_item_id`), `GET /lease`, `GET /list`,
|
||||
`GET /sse` + `GET /watch` (SSE), `GET /:projectId`. **Route-order
|
||||
discipline:** literal `/lease`, `/list`, `/sse`, `/watch` are registered
|
||||
BEFORE the bare `/:projectId` catchall (Phase A R1 SSE lesson).
|
||||
|
||||
> Source: `routes/projects.ts:18-194` (handlers; route-order note
|
||||
> L15-17, L140-141, L158-159, L185); error-code → HTTP mapping
|
||||
> `:31-52`; mounted `server.ts:492` @HEAD.
|
||||
|
||||
## 5. UI route-reality (§10.7 — source-verified at routes.tsx@HEAD)
|
||||
|
||||
`packages/ui/src/routes.tsx` is **542 lines** and uses TanStack Router
|
||||
(`createRoute({ path, component })` objects), NOT JSX `<Route>`. The
|
||||
Phase-8.1 survey's grep-derived missions/projects expectations are
|
||||
corrected here against routes.tsx reality:
|
||||
|
||||
| Survey expectation | routes.tsx@HEAD reality | routes.tsx:NN |
|
||||
|---|---|---|
|
||||
| `/project` (workspace) | **REAL route** — `WorkspaceScopePage` | `:128-131` |
|
||||
| `/project/mission/$missionId` | **REAL route** — `MissionScopePage` | `:134-137` |
|
||||
| `/project/slice/$sliceId` | **REAL route** — `SliceScopePage` | `:140-143` |
|
||||
| `/files` | **REAL route** — `FilesWorkspace` (see content-surfaces.md) | `:192-195` |
|
||||
| `/mission-control` | **`<Navigate to="/for-you" />`** redirect-stub (DELETED per SC-18; the Mission Control *system* lives at daemon/PL-005, not a UI destination) | `:440-444` |
|
||||
| `/slices` | **`<Navigate to="/project" />`** redirect-stub (DELETED per project-tree.md) | `:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId" />`** redirect-stub | `:453-460` |
|
||||
| `/progress` | **`<Navigate to="/project" />`** redirect-stub (folds into Project tabs, Phase 3) | `:464-468` |
|
||||
| `/steering` | **`<Navigate to="/project" />`** redirect-stub (folds into Project workspace overview tab, Phase 3) | `:470-474` |
|
||||
| `/missions` (top-level) | **NO route** — missions are reached only via `/project/mission/$missionId` | (absent) |
|
||||
| `/markdown` | **NO route** — markdown is a component in file/drawer surfaces, not a destination (see content-surfaces.md §4) | (absent) |
|
||||
|
||||
> Source: `packages/ui/src/routes.tsx:90-195` (real routes),
|
||||
> `:435-474` (redirect-stub block; each `<Navigate>` + DELETED/folds
|
||||
> comment), `:476-490` (route tree) @HEAD. Grep-confirmed absent:
|
||||
> no `path: "/missions"` / `path: "/markdown"` declaration anywhere
|
||||
> in routes.tsx @HEAD.
|
||||
|
||||
Net §10.7 ruling: the daemon `/api/missions` + `/api/slices` +
|
||||
`/api/projects` routes are real and load-bearing; the **operator-facing UI**
|
||||
consumes them through `/project*` destinations only. `/mission-control`,
|
||||
`/slices`, `/progress`, `/steering` are redirect-stubs (the systems exist at
|
||||
the daemon layer, the URLs were collapsed into `/project` / `/for-you`).
|
||||
|
||||
## 6. Cross-cutting properties
|
||||
|
||||
- **Optional + valid-without:** rigs without a `workspace` block stay valid;
|
||||
whoami / node-inventory / queue-target-repo all return null / fail-open
|
||||
(`workspace-resolver.ts:32`; `rig-repository.ts:99,107`;
|
||||
`routes/queue.ts:55`) @HEAD.
|
||||
- **Defensive column probes:** every `workspace_json` / `target_repo` access
|
||||
guards on column presence so partial test fixtures don't crash
|
||||
(`rig-repository.ts:99,107`; migrations 038/039 ship separately so a
|
||||
fixture can apply only the half it needs — `038.ts:11-14`) @HEAD.
|
||||
- **Read-only projection, no new state for the tree:** SliceIndexer /
|
||||
SliceDetailProjector add NO migration / event type; only the workspace
|
||||
declaration (038) + per-item scope (039) touch SQLite
|
||||
(`slice-indexer.ts:12-14`) @HEAD.
|
||||
- **Advisory, never mutates:** the frontmatter validator and the
|
||||
init-workspace scaffold never delete operator content
|
||||
(`frontmatter-validator.ts:12-13`; `config-init-workspace.ts:12-16`) @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN-A** — `resolveWorkspaceContext` honors an `envOverride`
|
||||
**verbatim even when it names no declared repo** (`workspace-resolver.ts:34-43`,
|
||||
by explicit PL-007 PRD § Item 3 design), whereas the queue route
|
||||
**rejects** an unknown `target_repo` against the same `repos[]`
|
||||
(`routes/queue.ts:58-66`). These two surfaces apply opposite policies to
|
||||
an unknown repo name (whoami trusts the operator; queue validates). Stated
|
||||
as-is — the asymmetry is source-real and intentional per the cited
|
||||
comments, but the divergence is not reconciled in source.
|
||||
- **OPEN-B** — the `/api/projects` *classifier* (PL-004 Phase B) and the
|
||||
`/project*` *workspace UI* (slices/missions) share the word "project" but
|
||||
are unrelated subsystems. Documented as a naming seam; no source defect.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration-count / route-group / PL-004-event-count claim (those land in
|
||||
daemon-core / coordination-primitive / architecture-rules).
|
||||
+528
-53
@@ -1,6 +1,21 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig CLI Reference — Full rig Command Surface
|
||||
status: active
|
||||
topics: [runtime-control, agent-runtime]
|
||||
domains: [operating-advisor, engineering-advisor, orchestrator]
|
||||
applies-when: |
|
||||
Need the exact rig CLI surface — command groups, subcommands, flags,
|
||||
JSON output, cross-host, coordination primitives.
|
||||
siblings: [README.md, architecture/daemon-core.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 8d55ea60
|
||||
last-updated: 2026-06-20
|
||||
---
|
||||
|
||||
# OpenRig CLI Reference
|
||||
|
||||
Verified against the shipped CLI on 2026-05-15 (v0.3.1) using:
|
||||
Verified against the shipped CLI on 2026-06-15 (v0.3.4) using:
|
||||
- `packages/cli/src/index.ts`
|
||||
- `packages/cli/src/commands/*.ts`
|
||||
- `packages/cli/src/mcp-server.ts`
|
||||
@@ -11,7 +26,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
## Overview
|
||||
|
||||
- Binary: `rig`
|
||||
- Top-level command groups: `57`
|
||||
- Top-level command groups: `64`
|
||||
- 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`
|
||||
@@ -22,6 +37,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| Command | Description |
|
||||
| --- | --- |
|
||||
| `daemon` | Manage the OpenRig daemon |
|
||||
| `start` | Recovery entrypoint — daemon + kernel + per-rig restore (interactive or headless) |
|
||||
| `status` | Show rig status |
|
||||
| `snapshot` | Manage rig snapshots |
|
||||
| `restore` | Restore a rig from a snapshot |
|
||||
@@ -35,10 +51,15 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `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 |
|
||||
| `reconcile-session` | No-launch, no-input adopt of a hand-resumed session |
|
||||
| `bundle` | Manage rig bundles |
|
||||
| `up` | Bootstrap a rig from a spec or bundle |
|
||||
| `down` | Tear down a rig |
|
||||
| `archive` | Archive a rig (soft + reversible: hides it from the default view, retains all data) |
|
||||
| `unarchive` | Unarchive a rig (reverse of `rig archive`): returns it to the default view |
|
||||
| `add` | Add a member to an existing pod in a running rig (`add_member` converge op) |
|
||||
| `env` | Inspect and control rig environment services |
|
||||
| `file` | Cross-host file movement over ssh/rsync (v0.4.4; one explicit verb: `copy`) |
|
||||
| `ps` | List rigs and their status |
|
||||
| `mcp` | MCP server for agent integration |
|
||||
| `agent` | Manage agent specs |
|
||||
@@ -51,6 +72,7 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `chatroom` | Chat room for rig communication |
|
||||
| `specs` | Browse, preview, and manage the spec library |
|
||||
| `whoami` | Show current managed identity in an OpenRig topology |
|
||||
| `auth` | Manage agent auth profiles per runtime (CLI-local; tokens never printed) |
|
||||
| `config` | Inspect and change OpenRig configuration |
|
||||
| `preflight` | Check system readiness for OpenRig |
|
||||
| `doctor` | Verify OpenRig install health |
|
||||
@@ -78,6 +100,8 @@ This document reflects the current `rig` surface as shipped. Where live help tex
|
||||
| `context-pack` | Browse, preview, send, and install operator-authored context packs |
|
||||
| `workspace` | Workspace primitive — typed-kind tooling (frontmatter validation) |
|
||||
| `plugin` | Inspect plugins (read-only) — list, show, used-by, validate |
|
||||
| `scope` | Scope tree primitive — missions, slices, sub-slices |
|
||||
| `policy` | Operator context-mode bindings (sleep/desk/mobile/away/focus/debug) |
|
||||
|
||||
## Core Daemon and System Commands
|
||||
|
||||
@@ -94,6 +118,7 @@ Subcommands:
|
||||
Notes:
|
||||
- `start` launches the daemon process and accepts runtime overrides for port, host, and DB path.
|
||||
- `logs` reads daemon log output and can follow it.
|
||||
- **Deploy identity (v0.4.4, OPR.0.4.4.11 FR-6/7)**: a PACKAGED build (built via `scripts/build-package.sh`) is stamped with `{semver, commit, dirty, builtAt}`; the daemon's `/healthz` payload carries the four stamp fields additively and `rig --version` renders `<semver> (<commit8>[, dirty])`. A source/dev run has NO stamp and adds NOTHING (never an invented SHA) — `/healthz` keeps its legacy body and `--version` prints the plain semver. This is the 30-second stale-deploy diagnostic: an unstamped or old-commit `/healthz` on a long-running host means you are looking at an older deployed build, not the source tree. Source: `packages/{daemon,cli}/src/build-info.ts` (`stampFields`), `packages/daemon/src/server.ts` (`/healthz`), `packages/cli/src/version.ts`.
|
||||
|
||||
### `rig status`
|
||||
|
||||
@@ -127,6 +152,11 @@ Supported keys:
|
||||
- `transcripts.enabled`
|
||||
- `transcripts.path`
|
||||
- `workspace.root` (and other workspace-rooted paths used by `init-workspace`)
|
||||
- `snapshots.periodic.enabled` (default `true`) — daemon-side periodic snapshot scheduler on/off (v0.3.4)
|
||||
- `snapshots.periodic.interval_seconds` (default `300`) — interval between periodic snapshots
|
||||
- `snapshots.periodic.retention_keep` (default `10`) — number of periodic snapshots to retain per rig
|
||||
- `feed.subscriptions.{action_required|approvals|shipped|progress|audit_log}` (booleans) — the For-You feed's five flat lens toggles (`OPENRIG_FEED_SUBSCRIPTIONS_*` env mapping)
|
||||
- `feed.subscriptions.<hostId>.enabled` (boolean; **v0.4.4, OPR.0.4.4.15**) — ONE registered dynamic key CLASS (not a general dynamic-key mechanism): per-host feed subscription toggles for the aggregated multi-host For-You feed. `hostId` segment charset `[A-Za-z0-9_-]+` (dotted host ids are inexpressible in dotted keys and reject as unknown); the flat toggle tails + `enabled` are RESERVED segments in both spellings, so a host id can never shadow a flat key. No env-var mapping for the dynamic class in v1 — file/API only. The CLI config store carries the same class (parity-pinned against the daemon store).
|
||||
|
||||
Precedence:
|
||||
- CLI flag
|
||||
@@ -137,10 +167,49 @@ Precedence:
|
||||
Notes:
|
||||
- `--with-source` (top-level) and `--show-source` (`get`) report per-key source/default for honest provenance.
|
||||
- `init-workspace` scaffolds the default workspace at `~/.openrig/workspace/` (or the `--root` override) with mission/slice folders. `--force` overwrites scaffolded files (does NOT remove directories); `--dry-run` previews without writing. New in v0.3.0.
|
||||
- `snapshots.periodic.*` (v0.3.4): the daemon-side scheduler takes periodic snapshots per rig at `interval_seconds` and retains the newest `retention_keep`. At restore time, newest-wins between `auto-periodic` and `auto-pre-down` snapshots.
|
||||
|
||||
Legacy env compatibility: the original runtime keys still accept deprecated
|
||||
`RIGGED_*` aliases. New typed config keys use `OPENRIG_*` only.
|
||||
|
||||
### `rig auth`
|
||||
|
||||
Manage agent auth profiles. The command is **CLI-local** — it never touches the daemon, so a token
|
||||
value never enters the daemon queue, stream, database, or logs. The runtime is an orthogonal axis via
|
||||
`--runtime` (MVP: `codex`, also the default), modeled on `gh auth switch/status` and `aws --profile` /
|
||||
`kubectl config use-context`. It is deliberately NOT `rig codex-auth` and NOT a `rig codex` vendor-noun
|
||||
family — the harness is a flag, not a command noun (see `conventions/cli-read-command-grammar`).
|
||||
|
||||
Usage:
|
||||
- `rig auth status [--runtime codex]` — auth-file presence, file mode, saved-profile count, and login
|
||||
state. **No secrets**: login state is derived from the runtime CLI's exit code only, never its output.
|
||||
- `rig auth list [--runtime codex]` — saved profile names.
|
||||
- `rig auth save <profile> [--runtime codex]` — snapshot the active auth file into a named profile (a
|
||||
mode-guarded byte copy; contents are never read into or echoed by the command).
|
||||
- `rig auth switch <profile> [--runtime codex]` — activate a saved profile (copy it onto the active
|
||||
auth file at `0600`).
|
||||
- `rig auth validate <profile> [--runtime codex]` — check a profile's file mode + JSON parseability.
|
||||
This is **not** a live-auth check; a parse failure reports a fixed reason, never the file content.
|
||||
- `rig auth seats list|show <seat>|set …|report [--runtime codex]` — a per-operator seat → profile
|
||||
**metadata** registry.
|
||||
|
||||
Profile storage:
|
||||
- `CODEX_HOME` (default `$HOME/.codex`, env-overridable) holds the active `auth.json`, the
|
||||
`auth-profiles/` directory (profiles `0600`, directory `0700`), and `auth-seat-registry.tsv`.
|
||||
- Ships **empty**: no example or bundled profiles/registry. Profile names use a strict whitelist
|
||||
(alnum-led `[A-Za-z0-9._-]`, ≤64 chars); symlinked or out-of-tree profile paths are refused.
|
||||
|
||||
Secret + honesty invariants:
|
||||
- **No token value is ever printed, logged, queued, streamed, or committed.** `status`/`validate`
|
||||
report presence/mode/parseability/login-state only.
|
||||
- **Seat-registry labels are metadata, not proof of a live account.** A seat labeled with profile "X"
|
||||
does not prove a running session is actually using that account; the command output states this. The
|
||||
registry stores no token/resume secret — its columns are `seat / rig / runtime / cwd / auth_profile /
|
||||
updated_ts`.
|
||||
|
||||
Note: live runtime sessions do not switch accounts in place — restart the affected seats to pick up a
|
||||
newly switched profile.
|
||||
|
||||
### `rig preflight`
|
||||
|
||||
Usage: `rig preflight [--json]`
|
||||
@@ -174,6 +243,21 @@ Notes:
|
||||
- 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 start`
|
||||
|
||||
Usage:
|
||||
- `rig start` (interactive: daemon + kernel + pick-and-restore)
|
||||
- `rig start --last [--json]` (headless: restore rigs that were last running)
|
||||
- `rig start --all [--json]` (headless: restore all rigs with restore-usable snapshots)
|
||||
- `rig start --rigs <name> [<name>...] [--json]` (headless: restore only the named rigs)
|
||||
|
||||
Notes:
|
||||
- Recovery entrypoint introduced in v0.3.4 (slice 01). Sequencing-only: composes daemon start + kernel auto-boot wait + per-rig restore primitives; re-codes nothing.
|
||||
- NOT the getting-started boot hero — that remains `rig up <starter>`. `rig start` is for post-reboot/crash recovery.
|
||||
- TTY interactive flow lists last-running candidates with a readiness summary (`[ready to resume]`, `[will ask before fresh]`, `[fresh start]`, `[mixed]`), then offers restore-all or a spacebar multi-select picker.
|
||||
- Headless modes (`--last`, `--all`, `--rigs`) take zero prompts; if a node returns `awaiting-decision`, the CLI reports it honestly and prints the `rig up --existing <rig> --fresh <logicalId>` command to take action.
|
||||
- Surface source-verified against `packages/cli/src/commands/start.ts` at `03a5f915` (v0.3.4).
|
||||
|
||||
### `rig mcp`
|
||||
|
||||
Usage: `rig mcp serve [--port <port>]`
|
||||
@@ -225,7 +309,7 @@ Notes:
|
||||
|
||||
### `rig up`
|
||||
|
||||
Usage: `rig up <source> [--plan] [--yes] [--target <root>] [--json]`
|
||||
Usage: `rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--fresh <seats...>] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `source`: path to `.yaml` or `.rigbundle`, or a bare name
|
||||
@@ -239,10 +323,18 @@ Actual source resolution:
|
||||
- if both a library spec and existing rig share the same name, exits with an ambiguity error
|
||||
|
||||
Current behavior notes:
|
||||
- `--cwd <path>` overrides launch working directory for all members for this run only. For path-form `rig up <install-internal-spec>` invocations the CLI defaults `cwd` to the caller's directory (slice-22 Bug 3) so library specs match path-form behavior.
|
||||
- `--target <root>` is only for bundle/package installation. It does not override agent working directories.
|
||||
- `--existing` skips the library-spec name resolution and treats `<source>` as an existing rig name directly (disambiguates when a library spec and a stopped rig share the same name).
|
||||
- `--plan` previews the restore without executing (read-only). The preview honors an honest async timeout and reports per-node intended action.
|
||||
- `--fresh <seats...>` deliberately fresh-primes the named seats (logical ids) instead of resuming their original sessions (operation B); reported in the per-node status vocabulary as `fresh-primed`. Repeatable: `--fresh seat-a --fresh seat-b` or `--fresh seat-a seat-b`.
|
||||
- `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.
|
||||
- **`rig up factory-rsi` (OPR.0.4.6.FAC2)** launches the single-rig recursive-self-improvement factory MVP starter — seven seats (`plan`/`build`/`check`/`review`/`dogfood`/`release`/`orch`) that run the `factory-rsi` builtin workflow (inner loop plan→build→check→review→release); the dogfood seat runs out-of-band against the shipped product and feeds its findings back into the next plan. Workspace-agnostic: `rig up factory-rsi --cwd <repo>` points the loop at the repo to improve.
|
||||
- v0.3.2 paper-cut fix-round (slice-22): pre-launch failures now return structured HTTP 4xx (`cycle_error` / `preflight_failed` / `validation_failed` / `service_boot_failed`) instead of bare 500; failed boots no longer leave orphan rig records on disk.
|
||||
- **v0.4.4 (OPR.0.4.4.11) — whole-topology sources**: a `.rigtopology` manifest (or a YAML file whose body declares the topology form) boots MULTIPLE rigs in one staged spin-up. v0 manifest entries are **spec paths only** (a closed-key manifest: `.rigbundle` and bare library-name entries are rejected at parse time with per-entry what/why/fix naming the v0 boundary). Per-entry `host: <id>` is the ONLY placement mechanism for topology entries — `rig up --host <id> <topology>` is REJECTED pre-dispatch (two placement mechanisms must not coexist). The launcher acquires per-rig launch locks route-side and reports a CLOSED per-entry aggregate `{ok | failed | skipped}` (skipped is explicit — a lock conflict or upstream failure never reads as silent success). Source: `packages/cli/src/commands/up.ts` (`.rigtopology` sniff + `--host` rejection), `packages/daemon/src/domain/topology/{topology-manifest,multi-rig-launcher,remote-up-leaf}.ts`, `packages/daemon/src/routes/up.ts`.
|
||||
- v0.3.4: `rig up` is resume-original-by-default for existing rigs. Per-seat opt-in to deliberate fresh-prime is via `--fresh <seats...>`. The five-term restore status vocabulary surfaced per-node is `resumed` / `fresh-primed` / `awaiting-decision` / `attention_required` / `failed`. On TTY, `awaiting-decision` nodes trigger an interactive [y/N] ASK; in headless mode they are reported honestly with the exact `rig up --existing <rig> --fresh <logicalId>` follow-up command.
|
||||
|
||||
Success modes:
|
||||
- fresh boot
|
||||
@@ -251,7 +343,10 @@ Success modes:
|
||||
|
||||
### `rig down`
|
||||
|
||||
Usage: `rig down <rigId> [--delete] [--force] [--snapshot] [--json]`
|
||||
Usage: `rig down <rig> [--delete] [--force] [--snapshot] [--json]`
|
||||
|
||||
`<rig>` accepts a rig **name or id**, symmetric with `rig up`. A name is
|
||||
resolved to its id via the active (non-archived) rig summary before teardown.
|
||||
|
||||
Flags:
|
||||
- `--delete`: delete the rig record after teardown
|
||||
@@ -261,8 +356,36 @@ Flags:
|
||||
Notes:
|
||||
- When `--snapshot` succeeds, human output includes the restore command.
|
||||
- If the rig name is uniquely reusable, the handoff prefers `rig up <rigName>`.
|
||||
- v0.2.0 known issue: `rig down <name> --delete` can return HTTP 404; use
|
||||
`rig ps` to find the rig ID and run `rig down <rigId> --delete`.
|
||||
- Destructive-op safety: if a name matches more than one rig, `rig down`
|
||||
refuses to tear down any of them and lists the matching ids - re-run with
|
||||
`rig down <id>`. An id always resolves directly (ids are never ambiguous).
|
||||
|
||||
### `rig archive`
|
||||
|
||||
Usage: `rig archive <rigId> [--force] [--json]`
|
||||
|
||||
Flags:
|
||||
- `--force`: archive even if the rig is running or degraded
|
||||
- `--json`: JSON output for agents
|
||||
|
||||
Notes:
|
||||
- Soft, reversible archive: hides the rig from the default explorer and `rig ps`, while retaining the rig record, topology, and snapshots.
|
||||
- Different from `rig down --delete` (delete is destructive; archive is recoverable).
|
||||
- Archived rigs are hidden from default `rig ps`; use `rig ps --include-archived` to see them.
|
||||
- Archiving a running or degraded rig requires `--force`; without it the call returns HTTP `409` with a three-part honest error and exits `2`.
|
||||
- Reverse with `rig unarchive <rigId>`.
|
||||
- Emits `rig.archived` SSE event.
|
||||
- Surface source-verified against `packages/cli/src/commands/archive.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### `rig unarchive`
|
||||
|
||||
Usage: `rig unarchive <rigId> [--json]`
|
||||
|
||||
Notes:
|
||||
- Reverse of `rig archive`: clears the `archived_at` flag so the rig returns to the default explorer and `rig ps` view.
|
||||
- Always non-destructive (the row and snapshots were retained while archived); no `--force` and no running-rig guard.
|
||||
- Emits `rig.unarchived` SSE event.
|
||||
- Surface source-verified against `packages/cli/src/commands/unarchive.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### `rig env`
|
||||
|
||||
@@ -281,22 +404,24 @@ Notes:
|
||||
### `rig ps`
|
||||
|
||||
Usage:
|
||||
- `rig ps [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
- `rig ps --nodes [--json] [--full] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
|
||||
- `rig ps [--json] [--full] [--rig <name>] [-A | --all-rigs] [--session <sess>] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--host <id>]`
|
||||
- `rig ps --nodes [--json] [--full] [--rig <name>] [-A | --all-rigs] [--session <sess>] [--limit <n>] [--fields <list>] [--summary] [--filter <key=value>] [--active] [--host <id>]`
|
||||
|
||||
Notes:
|
||||
- `rig ps` lists rig summaries. Default human columns: `RIG`, `NODES`, `RUNNING`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`.
|
||||
- `rig ps --nodes` expands into a cross-rig node inventory. Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
|
||||
- **v0.4.4 — consolidated all-rigs default + disclosure ladder (OPR.0.4.4.21)**: the default is **every ACTIVE rig, one compact row each** — O(rigs), never a fleet node fan-out — plus three load-bearing display elements: the host rollup line ("N rigs · M seats · K need attention"), the archived/stopped count line (history folds to ONE line), and the affordance footer teaching the drill ladder. The v0.4.0 current-rig default is RETIRED (it hid running rigs from the operator's field of view); the session-rig default now applies ONLY to `--nodes`, and only locally — **implicit scope defaults don't cross host boundaries** (remote `--nodes` requires explicit `--rig` or `-A`). `-A`/`--all-rigs` keeps exactly ONE meaning: the `--nodes` fleet widener; bare `-A` is a structured teaching error naming `--include-archived` for history. STATED contract: default `--json` is a bare array of ALL non-archived rigs INCLUDING stopped ones (existing keys preserved; additive `attentionCount`); only the human table folds stopped rigs. Fan-out (`--all-hosts`/`--hosts`) emits the intra-P4 shared `AggregatedPayload` (hostId-stamped `items` + closed-enum per-host `hosts[]` statuses) and is rollup-only by default; the full explicit ladder (`--all-hosts --nodes -A`, `--full` for complete records) fans out per-node with hostId-stamped projected rows. Migration from the old firehose: `rig ps --nodes -A --full`. The default `--json` output is a compact TL;DR projection per node: `session`, `rig` (to disambiguate under `-A`), `activity` (state + reason), `assigned` / `pending` counts, resume summary as `resumeType` + `resumeTokenPresent` (boolean — NOT the token value, per the slice-34 security correction). `--full` returns the complete per-node record (raw byte-equivalent passthrough — preserves the prior shape including `tmuxAttachCommand`, `resumeCommand`, `contextUsage`, `agentActivity` full, `restoreOutcome`, etc.; `resumeToken` value is still part of `--full` for downstream consumers that need it). All-states stays the default (per the orch-lead-grounded ruling: ps surfaces topology/readiness, where stopped/recoverable/attention IS the actionable signal — unlike queue-list which defaults to active items only). `--active` / `--running` is the opt-in active-filter (already existed). Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb.
|
||||
- **Daemon node-list payload trimmed at source (slice 26)**: `recoveryGuidance` is no longer serialized as near-identical templated prose on every node — relocated to a guidance-by-reference map at the top level so the 4 current consumers still resolve it. `contextUsage` is a compact summary in the list payload (full telemetry remains retrievable per-node via `rig whoami` / detail queries). Even `--full` and the UI consumers stop paying for the redundant per-node blobs.
|
||||
- `rig ps` lists rig summaries. Default human columns (v0.4.4): `RIG`, `NODES`, `RUNNING`, `ACTIVE`, `WORK`, `ATTN`, `STATUS`, `LIFECYCLE`, `UPTIME`, `SNAPSHOT`. The `LIFECYCLE` column shows the rig-level fold of per-node lifecycle states with codes `run`/`rec`/`stp`/`deg`/`att`; `ATTN` is the additive attention count.
|
||||
- `rig ps --nodes` expands into the current (or `--rig`-named) rig's node inventory — `--nodes -A` for the cross-rig inventory (v0.4.4 scoping). Default human columns include `STATUS`, `STARTUP`, `LIFECYCLE`, `ACTIVITY`, `RESTORE`, `ERROR` so startup-time and live runtime state can be compared side-by-side without composing a separate diagnostic command.
|
||||
- JSON output for both rig and node tiers includes a `rigName` alias (equal to `name`) for forward compatibility; agent code should prefer `rigName`. Default `--json` is a bare array (back-compat); the envelope shape `{entries, totalRigs|totalNodes, truncated, hint?}` is only used when `--limit`, `--fields`, `--summary`, or `--filter` is set.
|
||||
- Default human output is bounded for context-window safety: rigs truncate at 50 with a footer naming the total + `--full` opt-out, nodes truncate at 100 with the same shape. `--full` disables truncation. `--limit <n>` sets an explicit bound.
|
||||
- `--summary` emits aggregate counts only (`byStatus`, `byLifecycle` for rigs; `bySessionStatus`, `byLifecycle` for nodes); useful for quick fleet checks without per-entry detail. Cross-facet disagreement (e.g. a `running` rig with `attention_required` nodes) is not directly visible in summary mode — narrow with `--filter lifecycleState=attention_required` instead.
|
||||
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `agentActivity`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
|
||||
- `--fields <list>` projects JSON output to a comma-separated allow-list of top-level fields. Unknown keys are rejected before any HTTP call with an error naming the unknown key(s) and the sorted supported list. Exit code on rejection is `1`. Accepted (rig-level): `rigId`, `name`, `rigName`, `nodeCount`, `runningCount`, `activeCount`, `hasWorkCount`, `attentionCount`, `status`, `lifecycleState`, `uptime`, `latestSnapshot`. Accepted (node-level, with `--nodes`): `rigId`, `rigName`, `logicalId`, `podId`, `podNamespace`, `canonicalSessionName`, `nodeKind`, `runtime`, `sessionStatus`, `startupStatus`, `restoreOutcome`, `oriented`, `lifecycleState`, `tmuxAttachCommand`, `resumeCommand`, `latestError`, `terminalActive`, `hasAssignedWork`, `pendingWorkCount`, `agentActivity`, `contextUsage`, `heldReason`. `name` is rig-level only; for node entries use `rigName` (the rejection error includes a hint). Nested fields (e.g. `agentActivity.state`) are not drilled; pass the whole object name (e.g. `agentActivity`) and read the nested value downstream.
|
||||
- `--filter <key=value>` accepts `status`, `lifecycleState`, `name-prefix`, `name`, and `agentActivity.state` (PL-019; node-level — use with `--nodes`). Unknown keys are rejected before any HTTP call with a clear error naming the supported list. For `agentActivity.state`, allowed values are `running`, `needs_input`, `idle`, `unknown`; invalid values fail fast with a three-part error (what failed / what's allowed / what to do).
|
||||
- `--active` (PL-019; node-level) is sugar for `--filter agentActivity.state=running`. Combining `--active` with `--filter` is rejected — pick one explicit form. Output is identical to the explicit-filter form on the same fixture.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Forwards every shaping flag (`--nodes`, `--full`, `--limit`, `--fields`, `--summary`, `--filter`, `--json`) to the remote `rig ps`. The remote rig's output is verbatim passthrough on success; failure is distinguished into `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` per the closed cross-host execution contract.
|
||||
- Exit codes:
|
||||
- `0` success
|
||||
- `1` daemon not running, or invalid `--filter` / `--limit`
|
||||
- `1` daemon not running, or invalid `--filter` / `--limit` / `--fields`
|
||||
- `2` daemon fetch failure
|
||||
|
||||
### `rig snapshot`
|
||||
@@ -321,10 +446,12 @@ Notes:
|
||||
|
||||
### `rig restore-check`
|
||||
|
||||
Usage: `rig restore-check [--rig <name>] [--no-queue] [--no-hooks] [--json]`
|
||||
Usage: `rig restore-check [--rig <name>] [--as <session>] [--full] [--no-queue] [--no-hooks] [--json]`
|
||||
|
||||
Notes:
|
||||
- Checks restore readiness across running rigs (or one rig with `--rig`).
|
||||
- Checks restore readiness across running rigs (or one rig with `--rig` / one seat with `--as`).
|
||||
- **v0.4.0 — summary + not-ready default (slice 29)**: default output is a summary block (total seats / ready / not-ready / error-degraded counts) PLUS only the **not-ready** seats listed compactly (seat + readiness reason). `--full` (or `--json --full`) returns today's complete per-seat readiness across the fleet. The daemon skips per-seat detail assembly for ready seats when compact (computes verdict, omits detail). Closes the largest measured token bomb on the read-command surface (~79,000 tokens → low thousands).
|
||||
- The summary default correctly identifies EVERY not-ready seat (no false-ready omission) — the actionable signal is lossless even though detail is dropped for ready seats.
|
||||
- `--no-queue` skips queue file checks; `--no-hooks` skips hook checks.
|
||||
- Exit codes: `0` restorable (or restorable with caveats), `1` not restorable (red blockers found), `2` unknown / probe error.
|
||||
|
||||
@@ -364,12 +491,15 @@ Notes:
|
||||
Usage: `rig bundle <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--json]`
|
||||
- `inspect <path> [--json]`
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--json]`
|
||||
- `create <spec> -o <path> [--name <name>] [--bundle-version <ver>] [--include-packages <refs...>] [--rig-root <root>] [--notes <text>] [--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]` — pack a rig spec + its declared content into a `.rigbundle`. v0.3.2 slice-05 ships first-class cross-primitive bundling: skills + plugins (hybrid) + workflow_specs + context_packs + agent_images vendor end-to-end with both-sides path containment, symlink escape protection, and integrity hashing.
|
||||
- `inspect <path> [--json]` — inspect a `.rigbundle` manifest. v0.3.2 surfaces the cross-primitive content fields as first-class.
|
||||
- `install <path> [--plan] [--yes] [--target <root>] [--skip-version-check] [--force] [--json]` — install a `.rigbundle`. Routes each declared content kind to its canonical library under `$OPENRIG_HOME`. `--skip-version-check` is an operator-explicit override of the install-time daemon/CLI compatibility gate (NOT recommended). `--force` is an operator-explicit override of the install-time conflict check (NOT recommended; conflicts may produce partial install state).
|
||||
- `history [--rig <name>] [--since <iso>] [--json]` — list bundle install audit records from `~/.openrig/bundle-audit.jsonl`. Filters by target rig name and earliest `installedAt`.
|
||||
|
||||
Important:
|
||||
- `bundle create` requires `-o, --output <path>` by source definition.
|
||||
- v0.3.2 install timeout bumped (was 5s — too short for tmux-session-bootstrapping installs).
|
||||
- Deferred to 0.3.3 (per release packet): agent/port/managed-app collision detection (Item 4.3), broader install-into-existing-rig pathway acceptance (Item 4.4), and the `--target-name` CLI flag (slice-05 Item-3 sub-scopes; design-contingent on CLI surface decision).
|
||||
|
||||
### `rig package` (legacy)
|
||||
|
||||
@@ -465,6 +595,25 @@ Notes:
|
||||
- Materializes the topology first, then resolves/binds discovered sessions.
|
||||
- In JSON mode, emits the materialized nodes plus binding results.
|
||||
|
||||
### `rig reconcile-session`
|
||||
|
||||
Usage:
|
||||
- `rig reconcile-session <session> [--rig <rigId>] [--node <logicalId>] [--no-launch] [--json]`
|
||||
|
||||
Arguments:
|
||||
- `session`: canonical session name (e.g. `dev-impl@my-rig`) of the LIVE session to adopt.
|
||||
|
||||
Flags:
|
||||
- `--rig` and `--node` are paired disambiguators (both required together) when the session resolves ambiguously.
|
||||
- `--no-launch` is the only mode this command has; accepted for explicitness.
|
||||
|
||||
Notes:
|
||||
- No-launch, no-input adopt of a hand-resumed canonical session (slice 03 / v0.3.4). The operator already resumed the session externally (e.g. `claude --resume`, `codex resume`) inside its canonical tmux session; the daemon still shows the seat down.
|
||||
- Binds the live process to its OWN persisted node (same node id, no re-key) and updates the projection so `rig ps` / topology / send / capture / queue routing work again.
|
||||
- NEVER launches, relaunches, kills, replays startup, presses resume menus, compacts, or types into the pane.
|
||||
- Anything that could not be proven is reported as projection drift; conversation continuity is never claimed.
|
||||
- Surface source-verified against `packages/cli/src/commands/reconcile-session.ts` at `03a5f915` (v0.3.4).
|
||||
|
||||
### `rig expand`
|
||||
|
||||
Usage: `rig expand <rig-id> <pod-fragment-path> [--json] [--rig-root <path>]`
|
||||
@@ -474,6 +623,25 @@ Notes:
|
||||
- `--rig-root` controls agent resolution.
|
||||
- Member YAML may carry `session_source` (see "Session source declaration" below) to start the new seat from a prior native conversation (`mode: fork`) or from operator-declared artifacts (`mode: rebuild`).
|
||||
|
||||
### `rig add`
|
||||
|
||||
Usage: `rig add <rig-id> <pod-namespace> <member-fragment-path> [--json] [--rig-root <path>]`
|
||||
|
||||
Arguments:
|
||||
- `<rig-id>`: id of the target rig
|
||||
- `<pod-namespace>`: namespace of the existing pod to add the member to
|
||||
- `<member-fragment-path>`: path to a YAML/JSON member-fragment file (spec snake_case fields)
|
||||
|
||||
Notes:
|
||||
- The `add_member` converge op verb: adds a member to an existing pod in a running rig from a YAML/JSON member-fragment file.
|
||||
- Member fragment accepts both the bare form (top-level member fields) and the wrapper form (`{ member: {...}, edges?: [...] }`). A top-level `edges:` field in the bare form is lifted as pod-local edges and is NOT silently dropped.
|
||||
- **OPR.0.4.6.FAC1**: the fragment accepts an optional `role: <name>` (charset `A-Za-z0-9_.-`; rejected on `runtime: terminal`). A role-declared seat becomes eligible for workflow role→seat capability resolution on this rig — scale-out = add a member under the role (this verb IS the growth path). Role is opt-in per seat: a role-less member stays reachable only via explicit `preferred_targets`; a PROVIDED role is validated, never silently dropped.
|
||||
- A present-but-non-array `edges` field is rejected with an honest error (no silent drop).
|
||||
- `--rig-root <path>` controls agent resolution.
|
||||
- HTTP outcomes: `201` on success (with the new node + persisted edges + optional warnings); `409 member_conflict`; `400 validation_failed` / `preflight_failed`; `404 pod_not_found` (lists existing pods).
|
||||
- Exit code is non-zero if the HTTP call failed OR the new node did not fully launch (`status !== "launched"`).
|
||||
- Surface source-verified against `packages/cli/src/commands/add.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
### Session source declaration (`session_source`)
|
||||
|
||||
Member YAML in a rig spec or `rig expand` payload may declare a launch-time `session_source` to control how the new managed seat derives its starting context. Two modes are supported in v1:
|
||||
@@ -537,11 +705,13 @@ Notes:
|
||||
|
||||
### `rig launch`
|
||||
|
||||
Usage: `rig launch <rigId> <nodeRef> [--json]`
|
||||
Usage: `rig launch <rigId> [nodeRef] [--seats <ids>] [--hold-reason <reason>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Launches or relaunches a node in a running rig.
|
||||
- `nodeRef` can be a logical ID or node ID.
|
||||
- `nodeRef` (optional) can be a logical ID or node ID for the single-target form.
|
||||
- `--seats <ids>` (v0.3.4, slice 11) takes a comma-separated list of logical IDs for node-granular managed partial restore — launch a named subset of seats while holding the rest. Retires the prior `pod_aware_launch_unsupported` dead-end.
|
||||
- `--hold-reason <reason>` records the reason non-target seats are being held; surfaced via observability so the held state is auditable.
|
||||
|
||||
### `rig remove`
|
||||
|
||||
@@ -562,7 +732,7 @@ Notes:
|
||||
|
||||
### `rig whoami`
|
||||
|
||||
Usage: `rig whoami [--node-id <id>] [--session <name>] [--host <id>] [--json]`
|
||||
Usage: `rig whoami [--node-id <id>] [--session <name>] [--host <id>] [--full | --verbose] [--json]`
|
||||
|
||||
Identity resolution order:
|
||||
1. `--node-id`
|
||||
@@ -574,14 +744,18 @@ Identity resolution order:
|
||||
7. raw tmux session name
|
||||
|
||||
Notes:
|
||||
- **v0.4.0 — compact-by-default (slice 27)**: `rig whoami` and `rig whoami --json` default to identity-recovery essentials only — `identity` (rig / pod / member / sessionName / runtime / cwd / logicalId / ids), `peers` (names only: logicalId + sessionName per peer), `edges` (directional `kind` + `to.sessionName`), `transcriptPath`. Daemon skips the `contextUsageStore` lookup and `runtimeContext` build when compact is requested (also saves daemon work). The first command every agent runs on boot + every compaction-restore now costs ~192 tokens instead of ~909.
|
||||
- **`--full` (alias `--verbose`)** returns today's complete payload including `contextUsage`, `commands`, `peersNote`, `runtimeContext` — byte / shape parity with the v0.3.4 default (back-compat for any consumer that reads those fields).
|
||||
- The compact-default is an ALLOWLIST projection (not a denylist) — future payload fields default to `--full` and cannot silently re-bloat the every-boot path.
|
||||
- If the daemon is unreachable but an identity source can still be resolved, `--json` returns a partial result instead of crashing.
|
||||
- Human-readable output includes transcript location and context usage when available.
|
||||
- Human-readable output (compact default) shows identity + peers + edges + transcript path. `--full` adds context usage block, commands list, peersNote prose, runtimeContext.
|
||||
- `peers[]` is this rig's roster excluding self (no edge filter); use `edges{}` for directional relationships and `rig ps --nodes` for node inventory including self + live state.
|
||||
- In Claude Code projects, unattended `rig whoami` on boot may require the local permissions allow list to include `Bash(rig:*)`.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh (CLI-side shell-out; daemon untouched). Identity resolution happens on the REMOTE rig (each host has its own daemon + tmux + identity context); local `--node-id`/`--session`/`--full` flags are forwarded to the remote `rig whoami` invocation. The remote rig's output is verbatim passthrough on success; failure is distinguished into the same `ssh-unreachable` / `permission-gate` / `remote-daemon-unreachable` / `remote-command-failed` enum as `rig ps --host` and `rig send --host`.
|
||||
|
||||
### `rig transcript`
|
||||
|
||||
Usage: `rig transcript <session> [--tail <lines>] [--grep <pattern>] [--json]`
|
||||
Usage: `rig transcript <session> [--tail <lines>] [--grep <pattern>] [--host <id>] [--json]`
|
||||
|
||||
Defaults:
|
||||
- `--tail 50`
|
||||
@@ -589,16 +763,26 @@ Defaults:
|
||||
Notes:
|
||||
- Reads transcript files, not pane scrollback.
|
||||
- `--grep` treats the pattern as regex.
|
||||
- **v0.4.6 (OPR.0.4.6.MH4)** — `--host <id>` / the `agent@rig@host` session form reads the
|
||||
transcript from a remote host, CLI-direct against that daemon's shipped
|
||||
`GET /api/transcripts/:session/tail|grep` routes (http-registered hosts only — an
|
||||
ssh-declared host is a structured transport-requirement error; there is no ssh path for this
|
||||
verb). Output shape is the origin's, verbatim, under the `[via host=…]` banner. Precedence:
|
||||
explicit `--host` > target sugar > the persisted host selection. See "Cross-host execution".
|
||||
|
||||
### `rig send`
|
||||
|
||||
Usage: `rig send <session> <text> [--verify] [--force] [--wait-for-idle <s>] [--host <id>] [--json]`
|
||||
Usage: `rig send <session> <text> [--verify] [--force] [--raw] [--dangerously-interact --reason <text>] [--wait-for-idle <s>] [--host <id>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Uses the two-step send pattern automatically: paste text, wait, submit Enter.
|
||||
- `--verify` requests delivery verification.
|
||||
- `--force` overrides mid-task safety checks.
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below. SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim.
|
||||
- The default path refuses to send when the target is at an interactive prompt / permission block (fail-closed: an unknown or stale activity state also blocks). This closes the footgun where a peer message blindly submits another agent's open prompt.
|
||||
- `--force` overrides the mid-task safety heuristic but does **NOT** bypass the interactive-prompt/permission guard.
|
||||
- `--raw` sends exact text/keystrokes without the From/To messaging envelope; still guarded against interactive prompts.
|
||||
- `--dangerously-interact` is the ONLY override of the prompt/permission guard — it deliberately drives an interactive prompt/permission block (e.g. selects an option). It implies `--raw`, requires `--reason <text>`, and is recorded in the audit log. Cannot be combined with `--wait-for-idle`.
|
||||
- `--reason <text>` records why the prompt is being driven (required with `--dangerously-interact`).
|
||||
- `--host <id>` sends on a remote host declared in `~/.openrig/hosts.yaml`; see "Cross-host execution" below. **v0.4.6 (OPR.0.4.6.MH4)** — the host entry's transport decides the path: ssh hosts keep the single-hop ssh shell-out byte-verbatim (SSH success is NOT verify success — the remote rig's `Verified: yes/no` is what counts and is surfaced verbatim); http hosts (e.g. pair-registered) go CLI-direct to the remote daemon's `POST /api/transport/send` with the same body a local send posts (wrap parity by construction) — `--verify` prints the REMOTE route's `verified`/`outcome` verbatim, never a locally synthesized verdict. The `agent@rig@host` target form is sugar for `--host` when the suffix is a REGISTERED host id (explicit `--host` > sugar > persisted selection; a conflict between `--host` and the sugar is a structured error).
|
||||
|
||||
### `rig capture`
|
||||
|
||||
@@ -611,15 +795,14 @@ Default:
|
||||
- `--lines 20`
|
||||
|
||||
Notes:
|
||||
- `--host <id>` routes the same command to a remote host declared in `~/.openrig/hosts.yaml` via single-hop ssh; see "Cross-host execution" below.
|
||||
- `--host <id>` captures on a remote host declared in `~/.openrig/hosts.yaml`; see "Cross-host execution" below. **v0.4.6 (OPR.0.4.6.MH4)** — ssh hosts keep the shell-out verbatim; http hosts go CLI-direct to the remote daemon's `POST /api/transport/capture` with the local body shape (lines/rig/pod/session), rendering single/multi results exactly as a local capture under the `[via host=…]` banner. The `agent@rig@host` session form is sugar for `--host` when the suffix is a REGISTERED host id (`--rig`/`--pod` values are names, never sugar-parsed).
|
||||
|
||||
### Cross-host execution (`--host <id>`)
|
||||
|
||||
`rig send` and `rig capture` accept a `--host <id>` flag that routes the
|
||||
command to a remote host over single-hop SSH. v0 is CLI-side shell-out
|
||||
only — the local daemon is not involved in routing, and there is no
|
||||
daemon-to-daemon networking. The remote host is expected to have its own
|
||||
managed `rig` available on `$PATH`.
|
||||
Cross-host commands route to a remote host declared by id. SSH-transport
|
||||
commands use single-hop SSH CLI-side shell-out; HTTP-transport commands talk to
|
||||
the remote daemon API. The local daemon is not involved in SSH routing. The
|
||||
remote host is expected to have its own managed `rig` available on `$PATH`.
|
||||
|
||||
Hosts are declared by the operator in `~/.openrig/hosts.yaml`:
|
||||
|
||||
@@ -629,19 +812,20 @@ hosts:
|
||||
transport: ssh
|
||||
target: vm-claude-test.local
|
||||
user: your-username # optional
|
||||
notes: "Tart VM" # optional
|
||||
- id: laptop-b
|
||||
transport: ssh
|
||||
target: laptop-b.tail-scale-net
|
||||
user: your-username
|
||||
notes: "test VM" # optional
|
||||
- id: factory-http
|
||||
transport: http
|
||||
url: http://100.64.1.2:7433
|
||||
bearer_env: FACTORY_HTTP_TOKEN
|
||||
```
|
||||
|
||||
Validation rules:
|
||||
|
||||
- `hosts` is required and must be a non-null array.
|
||||
- Each entry: `id` required (non-empty, unique), `transport` required (v0 supports `ssh` only), `target` required (non-empty — DNS name, SSH config alias, or IP).
|
||||
- `user` and `notes` are optional.
|
||||
- The file is operator-managed; v0 does NOT include any sub-command to add, remove, or list hosts (operators edit the YAML directly).
|
||||
- Each entry: `id` required (non-empty, unique), `transport` required (`ssh` or `http`).
|
||||
- SSH entries require `target` (non-empty — DNS name, SSH config alias, or IP); `user` and `notes` are optional.
|
||||
- HTTP entries require `url` plus exactly one bearer pointer (`bearer_env` or `bearer_file`); pointers are config names/paths, never resolved token values.
|
||||
- `rig host add/list/doctor` covers the standard path; hand-editing remains the path for exotica.
|
||||
- A missing or invalid file returns a clear error pointing at the canonical path.
|
||||
|
||||
The CLI distinguishes four structured failure modes (operators get an
|
||||
@@ -652,14 +836,134 @@ actionable error per mode; JSON output preserves the `failedStep` enum):
|
||||
- `remote-daemon-unreachable` — SSH succeeded but the remote `rig` reported the remote daemon was not reachable. Start it with `ssh <target> rig daemon start`.
|
||||
- `remote-command-failed` — SSH succeeded but the remote `rig` exited non-zero for some other reason; the remote stderr is surfaced.
|
||||
|
||||
Out of scope for v0: non-SSH transports; `--host` on `rig ps` / `rig whoami` / other commands; connection pooling; multi-hop SSH; cross-host queue routing; cross-host seat handover.
|
||||
**The transport posture (OPR.0.4.4.13 FR-4 — DECIDED, pm-ruled: document, no parity).** The
|
||||
partition is the intended posture, not an accident of history: **ssh carries interactive pane
|
||||
ops, http-bearer carries daemon REST ops, `ps`/`whoami` follow the host's DECLARED transport.**
|
||||
There is NO cross-transport fallback, and NO http parity for `send`/`capture` ships in 0.4.4
|
||||
(parity would be new attack surface with no scope-locked need). **v0.4.6 UPDATE (OPR.0.4.6.MH4,
|
||||
pm-RULED IN as fulfilling-confirmed-intent):** `send`/`capture` gain the http branch — the
|
||||
founder's `pair` front door registers HTTP hosts, and without the branch a pair-registered demo
|
||||
host could not receive send/capture at all. The mechanism is CLI-DIRECT via the shipped
|
||||
`runRemoteHttpOp` to the remote daemon's EXISTING transport routes (zero daemon-side changes;
|
||||
the ssh path is kept byte-verbatim for ssh hosts — coverage, not a rewrite, and still no
|
||||
cross-transport fallback: the host entry's declared transport dictates the path). `transcript`
|
||||
and `broadcast` gain their first cross-host affordance the same way (http-only — there is no
|
||||
ssh path for them). Per-command:
|
||||
|
||||
| Command | ssh transport | http transport | Fan-out (`--all-hosts`/`--hosts`) |
|
||||
| --- | --- | --- | --- |
|
||||
| `rig send` | ✓ (shell-out, byte-verbatim) | ✓ (v0.4.6 MH-4 — CLI-direct `POST /api/transport/send`) | ✗ |
|
||||
| `rig capture` | ✓ (shell-out, byte-verbatim) | ✓ (v0.4.6 MH-4 — CLI-direct `POST /api/transport/capture`) | ✗ |
|
||||
| `rig transcript --host` (v0.4.6, OPR.0.4.6.MH4) | ✗ (structured transport error) | ✓ (CLI-direct `GET /api/transcripts/:session/tail\|grep`) | ✗ |
|
||||
| `rig broadcast --host` (v0.4.6, OPR.0.4.6.MH4) | ✗ (structured transport error) | ✓ (CLI-direct `POST /api/transport/broadcast`; remote fan-out, per-target passthrough) | ✗ |
|
||||
| `rig up` / `rig down` / `rig launch` | ✗ | ✓ (http-ONLY) | ✗ |
|
||||
| `rig file copy` (v0.4.4) | ✓ (ssh-ONLY, rsync-over-ssh) | ✗ | ✗ |
|
||||
| `rig ps --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts appear as STRUCTURED `unsupported-transport` statuses in `hosts[]` |
|
||||
| `rig whoami --host` | ✓ (declared) | ✓ (declared) | http-only; non-http hosts are currently SILENTLY FILTERED from the fan-out (a shipped gap, routed for 0.4.5 triage — differs from ps's structured status) |
|
||||
| `rig host doctor` | ✓ | ✓ | n/a (single host) |
|
||||
| `rig queue create/handoff/handoff-and-complete --host` (v0.4.6, OPR.0.4.6.MH3) | ✗ | ✓ (http-ONLY, daemon→daemon forward — an ssh-declared host is a structured `unsupported-transport` error) | ✗ |
|
||||
|
||||
Out of scope in 0.4.4: cross-transport fallback; http parity for `send`/`capture` *(shipped in
|
||||
0.4.6 — OPR.0.4.6.MH4, pm-ruled fulfilling-confirmed-intent; see the v0.4.6 update above)*;
|
||||
connection pooling; multi-hop SSH; cross-host queue routing *(shipped in 0.4.6 — OPR.0.4.6.MH3;
|
||||
see `rig queue` § Cross-host queue routing)*; cross-host seat handover.
|
||||
|
||||
**The http branch's failure taxonomy (v0.4.6 — OPR.0.4.6.MH4).** The http branch names its OWN
|
||||
steps (never the ssh enum, never a generic "failed"): registry-load-failed / unknown-host (the
|
||||
same class across all four verbs) / `permission-gate` (bearer resolution failed locally, or the
|
||||
remote returned 401/403 — including the terminal-bearer posture below) / `remote-daemon-unreachable`
|
||||
(network/timeout) / `remote-command-failed` (remote 4xx/5xx, with the remote route's own error
|
||||
text surfaced beside the step). **Terminal-bearer posture (named, v0 — applies to
|
||||
`/api/transport/*` ONLY, i.e. send/capture/broadcast):** the remote's transport routes gate on
|
||||
ITS terminal bearer class, while the CLI presents the host's REGISTRY bearer. Default (no
|
||||
terminal bearer) + tailnet binds = pass-through by design (the mesh is the auth boundary); a
|
||||
remote enforcing a DIFFERENT terminal bearer surfaces as the structured `permission-gate` step —
|
||||
remedy: set the remote's terminal bearer equal to the paired registry bearer, or rely on the
|
||||
tailnet boundary. **`rig transcript --host` is NOT in this class (arch n2):** the remote's
|
||||
`/api/transcripts/*` routes are the shipped UNGATED transcript-read posture (open route,
|
||||
daemon-local trust boundary, route-level credential redaction as the protective primitive) — a
|
||||
wrong terminal bearer that permission-gates a cross-host send does NOT gate a cross-host
|
||||
transcript read; transcript keeps succeeding. A coherent transcript-read auth policy across
|
||||
tail/grep/full is a named future slice per the route's own comment
|
||||
(`routes/transcripts.ts`, orch decision approved-option-a), out of scope here. No new auth
|
||||
machinery ships with this slice.
|
||||
|
||||
**Destination parse rules — the two-family contract (OPR.0.4.6.MH3, arch-ruled).** The
|
||||
`agent@rig@host` three-part form is INPUT SUGAR at the CLI edge, never grammar: session strings
|
||||
stay `member@rig` everywhere (BR-1), and the host always travels out-of-band. TWO parse rules
|
||||
ship, per verb family, BY DESIGN — one canonical rule would either break adopted targets or
|
||||
degrade queue error honesty:
|
||||
|
||||
| Verb family | 3-part trailing segment | Why |
|
||||
| --- | --- | --- |
|
||||
| Queue coordination writes (`rig queue create/handoff/handoff-and-complete`) | **Always stripped** into the out-of-band `hostId` envelope (after the human-seat classifier) | Queue destinations are canonical-only by construction (the daemon's `validateRig` rejects any non-canonical parse), so the unconditional strip loses nothing — and a mistyped host dies loud as an unknown-HOST error instead of a misleading rig-shaped `unknown_destination_rig`. |
|
||||
| Session-target interactive/observe verbs (`rig send/capture/transcript`) | **Stripped only if the segment matches a REGISTERED host id** | Interactive verbs legitimately target raw/adopted tmux session names that may contain `@`; strip-iff-registered preserves them, with the unregistered-suffix host hint keeping mistypes loud. |
|
||||
| `rig broadcast` | **No sugar** — the positional is MESSAGE TEXT, never parsed as a target | Sugar-parsing a message body would corrupt text containing `@`; cross-host broadcast routes on `--host` or the persisted selection only (v0.4.6 — OPR.0.4.6.MH4). |
|
||||
|
||||
Both families converge on the same outcome: a mistyped host dies loud with the host named. The
|
||||
`RESERVED_HOST_IDS` set (`kernel`, `host`, `local` — rejected at `rig host add`) guarantees no
|
||||
registered host can ever shadow the human-seat `@kernel`/`@host` classification family.
|
||||
|
||||
### `rig file` (v0.4.4 — OPR.0.4.4.18)
|
||||
|
||||
Usage: `rig file copy <src> <dst> [--dry-run] [--json]`
|
||||
|
||||
Cross-host file movement over ssh/rsync — v0 ships ONE explicit verb, `copy`,
|
||||
for a single file.
|
||||
|
||||
Operand grammar (parsed, never guessed):
|
||||
- `<hostId>:<absolute-path>` = remote (the `<hostId>` must resolve in the ssh
|
||||
hosts registry; remote paths must be absolute).
|
||||
- Bare path = local. A LOCAL file whose name contains a colon needs the `./`
|
||||
prefix (`./weird:name.txt`) — the grammar refuses the ambiguous form with a
|
||||
structured error instead of guessing.
|
||||
- Valid shapes: local→remote, remote→local, local→local. remote→remote is not
|
||||
a v0 shape.
|
||||
|
||||
Semantics + safety wall (source: `packages/cli/src/lib/file-transfer.ts`):
|
||||
- An existing destination is **OVERWRITTEN** (v0 copy semantics, stated) —
|
||||
preview with `--dry-run`, which prints the exact planned transfer (src, dst,
|
||||
host, files/bytes) and moves nothing.
|
||||
- **Default-deny wall over live agent/credential state**: paths resolving into
|
||||
the closed deny set `~/.openrig`, `~/.ssh`, `~/.codex`, `~/.claude` — plus
|
||||
the ACTIVE `OPENRIG_HOME` and the active hosts registry file — are refused
|
||||
with a named what/why error (extension of the deny set requires a ruling,
|
||||
never a silent widening).
|
||||
- Traversal is rejected on the RAW operand: a `..` path segment refuses
|
||||
BEFORE any normalization (normalization collapses `..`, so a post-normalize
|
||||
check would be dead code); remote paths are additionally restricted to a
|
||||
shell-inert charset (`A-Za-z0-9._/-` — restriction over escaping, because
|
||||
both rsync implementations in the fleet differ on quoting flags), and every
|
||||
rsync invocation pins operands behind `--` and spawns argv-style with no
|
||||
shell.
|
||||
- Transport is ssh-only (see the transport-posture table above); the remote
|
||||
side uses the same ssh registry entries `rig send`/`capture` use.
|
||||
|
||||
### `rig host`
|
||||
|
||||
Usage: `rig host <add|list|doctor>` — the multi-host registry verbs (OPR.0.4.4.13; capped at
|
||||
exactly these three — no edit/remove/tunnel/bootstrap verbs; hand-editing `hosts.yaml` remains
|
||||
the path for exotica, and the factory bootstrap ships as script + runbook at
|
||||
`docs/reference/product-factory-vps-runbook.md`).
|
||||
|
||||
- `add --id <id> --transport <ssh|http> [--target <t> --user <u> | --url <u> --bearer-env <n>|--bearer-file <p>] [--notes <text>] [--json]` — writes the entry validated by the registry loader's OWN rules (add-time errors are load-time errors, verbatim; duplicate ids refused). Rewrites `hosts.yaml` canonically (hand-authored comments are not preserved).
|
||||
- `list [--json]` — id/transport/target plus AUTH as a config POINTER (`env:NAME` / `file:PATH` / `ssh-key`); never a resolved secret value.
|
||||
- `doctor <id> [--posture product-factory-vps] [--public-addr <ip>] [--json]` — stepwise, honest verification: transport reachability → remote `rig` binary (+version) → remote daemon health → remote identity; each failing step is a DISTINCT actionable error, and unknown host ids surface as the registry error class. `--posture` runs the ONE built-in baseline (`product-factory-vps`): every item reports pass/fail/**unknown** individually with a fix per non-pass — UNKNOWN is never pass; the public `:7433`/`:22` probes need `--public-addr` (outside vantage) and a reachable public daemon port FAILS loudly. Exit `1` on any fail.
|
||||
|
||||
### `rig broadcast`
|
||||
|
||||
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--json]`
|
||||
Usage: `rig broadcast <text> [--rig <name>] [--pod <name>] [--force] [--host <id>] [--json]`
|
||||
|
||||
Notes:
|
||||
- Without `--rig` or `--pod`, broadcasts across all running sessions in all rigs.
|
||||
- **v0.4.6 (OPR.0.4.6.MH4)** — `--host <id>` broadcasts on a remote host, CLI-direct to that
|
||||
daemon's shipped `POST /api/transport/broadcast` (http-registered hosts only; an ssh-declared
|
||||
host is a structured transport-requirement error). The REMOTE daemon resolves `--rig`/`--pod`
|
||||
on ITS topology; its per-target results print verbatim and a partial fan-out exits non-zero
|
||||
exactly as a local one. The remote call carries its own named fan-out deadline
|
||||
(`BROADCAST_REMOTE_TIMEOUT_MS`, 30s — a full per-target loop outlives the 5s read default).
|
||||
The `<text>` positional is message text and is NEVER parsed as a target, so broadcast takes
|
||||
`--host` or the persisted host selection — not the `agent@rig@host` sugar.
|
||||
|
||||
### `rig ask`
|
||||
|
||||
@@ -721,15 +1025,25 @@ Notes:
|
||||
Usage: `rig queue <subcommand>` — L3 owned-work queue plus inbox/outbox.
|
||||
|
||||
Subcommands:
|
||||
- `create --source <session> --destination <session> --body <text> [--priority <p>] [--tier <t>] [--tags <csv>] [--expires-at <iso>] [--id <qitemId>] [--json]`
|
||||
- `create --source <session> --destination <session> (--body <text> | --body-file <path>) [--mission <id>] [--slice <id>] [--priority <p>] [--tier <t>] [--tags <csv>] [--target-repo <name>] [--host <id>] [--no-nudge] [--expires-at <iso>] [--id <qitemId>] [--json]` — v0.3.2 slice-21 FR-4 adds `--body-file <path>` (use `-` for stdin) which kills the backtick-shell-corruption class for multiline bodies, and first-class `--mission <id>` / `--slice <id>` flags that translate to `mission:<id>` / `slice:<id>` tags (compose with `--tags`). Exactly one of `--body` / `--body-file` must be provided. v0.4.6 (OPR.0.4.6.MH3) adds `--host <id>` / the `agent@rig@host` destination form — see § Cross-host queue routing below.
|
||||
- `claim <qitemId> --destination <session> [--json]` — pending → in-progress; computes closure_required_at from tier
|
||||
- `unclaim <qitemId> --destination <session> [--reason <text>] [--json]` — in-progress → pending
|
||||
- `update <qitemId> --actor <session> --state <state> [--closure-reason <r>] [--closure-target <t>] [--note <text>] [--json]`
|
||||
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--json]` — transactional close-as-handed-off + create-new
|
||||
- `handoff <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--host <id>] [--json]` — transactional close-as-handed-off + create-new; v0.4.6 (OPR.0.4.6.MH3) adds `--host <id>` / the `agent@rig@host` `--to` form (§ Cross-host queue routing)
|
||||
- `handoff-and-complete <qitemId> --from <session> --to <session> [--body <text>] [--note <text>] [--priority <p>] [--tier <t>] [--tags <csv>] [--host <id>] [--json]` — variant of `handoff` that closes the source as `done` (terminal) instead of `handed-off`; same atomic close+create, chain_of_record, default-nudge, and cross-host contracts
|
||||
- `fallback <qitemId> --destination <session> [--reason <text>] [--json]` — reroute to fallback seat
|
||||
- `show <qitemId> [--json]`
|
||||
- `transitions <qitemId> [--json]` — append-only transition log
|
||||
- `list [--destination <session>] [--source <session>] [--state <csv>] [--limit <n>] [--json]`
|
||||
- `list [--destination <session>] [--source <session>] [--mine] [--state <csv>] [-a | --all] [-A | --all-rigs] [--full] [-o <json|wide>] [--limit <n>] [--json]` — **v0.4.0 grammar (slices 28 + 32, docker / kubectl-aligned)**:
|
||||
- **`rig queue list`** (no flags) → active states only (`pending` / `in-progress` / `claimed` / `blocked` / `handed-off`; NOT `done` / `canceled`), **current-rig** breadth, compact rows: `qitemId`, `state`, `source→destination` (or `current-owner`), `closure_reason` / `closure_target` (when handed-off / blocked), `mission`, `slice`, `tier` / `priority`, `age` / `updated_at`, short title, capped tags. Excludes: full body, chain_of_record, transition history, proof / artifact blobs.
|
||||
- **`-a` / `--all`** → include closed / done history within the current breadth (docker `-a` axis: history).
|
||||
- **`-A` / `--all-rigs`** → cross-rig breadth (kubectl `-A` axis: breadth). Composable with `-a`, `--mine`, `--full`.
|
||||
- **`--full`** → add body + chain-of-record + full tags + transition history to whichever scope is selected (field-breadth axis).
|
||||
- **`-o json|wide`** → encoding, compact-by-default. `-o json` does NOT imply full body (compact JSON is token-safe + machine-parseable); `--full -o json` returns the full JSON.
|
||||
- **`--mine`** → just the caller's items.
|
||||
- `--destination <s>` / `--source <s>` / `--state <csv>` keep working and compose with the new flags.
|
||||
- The four axes (scope × history × field-breadth × encoding) are orthogonal and composable. The bare unscoped firehose that aggregated cross-rig + full-history (~64,000 tokens on the live host) is retired as a default — opt-in via `-A -a --full`.
|
||||
- Use `rig queue show <qitemId>` for the full single-item view (kubectl `describe` / docker `inspect` pattern).
|
||||
- `overdue [--json]` — in-progress qitems past closure_required_at
|
||||
- `inbox-drop <destinationSession> --sender <session> --body <text> [--tags <csv>] [--urgency <u>] [--audit <pointer>] [--id <inboxId>] [--json]`
|
||||
- `inbox-absorb <inboxId> --receiver <session> [--json]` — promote a pending inbox entry to a queue_item
|
||||
@@ -751,6 +1065,54 @@ Closure-reason semantics:
|
||||
- `no-follow-on` — terminal completion, nothing else needed.
|
||||
- `escalation` — kicked up to a higher tier (target = escalation target).
|
||||
|
||||
### Cross-host queue routing (v0.4.6 — OPR.0.4.6.MH3)
|
||||
|
||||
`rig queue create`, `handoff`, and `handoff-and-complete` can target a destination on ANOTHER
|
||||
registered host: `--host <id>` or the host-qualified destination form `member@rig@<host>` (both
|
||||
resolve to the same out-of-band `hostId` request envelope; naming both with DIFFERENT hosts is a
|
||||
structured ambiguity error). The mechanism generalizes the shipped mission-control
|
||||
forward-then-strip WRITE: the local daemon resolves the host registry server-side (bearers never
|
||||
reach the caller), strips the host at the edge, and forwards the WHOLE body over HTTP to the
|
||||
target daemon — see `docs/as-built/architecture/coordination-primitive.md` § Cross-host queue
|
||||
routing for the full model. Load-bearing contract points:
|
||||
|
||||
- **Explicit-only (no selection follow).** Queue verbs route cross-host ONLY on `--host` / the
|
||||
3-part form — they NEVER consult the persisted `rig host select` selection (a durable write and
|
||||
a hot-potato close must not silently re-home on yesterday's sticky selection). This is a
|
||||
deliberate asymmetry with the observe/interactive verbs, which do follow selection.
|
||||
- **Origin-owns-the-record.** The qitem lives in the TARGET host's DB; that row is THE record.
|
||||
No local ghost row is ever written, and the target daemon's OWN nudge fires on ITS local tmux
|
||||
(the whole body — including the `nudge` flag — is forwarded).
|
||||
- **At-least-once + idempotent, never exactly-once.** The forwarding daemon MINTS the qitem id
|
||||
before the first forward (a retry carries the same id by construction); a cross-host handoff's
|
||||
successor id is DERIVED deterministically from (source qitem, destination, host) — namespaced
|
||||
`qitem-xh-…`, so a re-driven forward absorbs on the target's primary key. Retrying is safe;
|
||||
a same-id create whose identity fields differ is a structured `qitem_id_reuse` error.
|
||||
- **Never-drop ordering.** A cross-host handoff creates the successor on the target host FIRST
|
||||
and closes the local source SECOND. A crash between the two leaves a live duplicate that the
|
||||
idempotent re-drive converges — never a source closed toward a successor that doesn't exist.
|
||||
Re-drives: an already-closed source with the MATCHING `closure_target` absorbs as success; a
|
||||
MISMATCH is a structured `cross_host_close_conflict` (409) — surfaced, never overwritten.
|
||||
*(Named residual, inherent to at-least-once/no-2PC: a re-drive naming a DIFFERENT destination
|
||||
is a NEW handoff decision and can leave the earlier successor live on the target host — visible
|
||||
via the chain + provenance tags, not a dedup bug.)*
|
||||
- **Closure fields.** The cross-host source close records `closure_reason=handed_off_to` and
|
||||
`closure_target=member@rig@<host>` — `closure_target` is OPAQUE audit/display metadata,
|
||||
presence-checked and NEVER parsed for routing (any PR parsing it as a session string is a spec
|
||||
violation). Session-string carriers (`destination_session`, `source_session`, `blocked_on`,
|
||||
`handed_off_to`) stay 2-part `member@rig` (BR-1). The forwarded successor carries the continued
|
||||
`chain_of_record`; those A-side ids are OPAQUE lineage identifiers on the target host (they do
|
||||
not dereference in the target's DB). Provenance: the forwarded item is tagged `cross-host` +
|
||||
`from-host:<sender's self-declared name>` (honest best-effort, not authenticated identity).
|
||||
- **Failure honesty.** Unknown host / ssh-declared host / unreachable / auth-failed each surface
|
||||
as a distinct structured `remote_queue_write_failed` error naming the host — nothing is written
|
||||
on either side. Transport is http-ONLY (the daemon→daemon path is what fires the remote nudge;
|
||||
the `rig send --host` ssh shell-out is a different mechanism, untouched).
|
||||
- **Local zero-regression.** No `--host` (or `local`) = today's local path, byte-identical.
|
||||
Claim / update / inbox verbs stay local-by-principle: after a cross-host handoff the successor
|
||||
lives on the target host where its worker lives. Sender-side operations on an already-forwarded
|
||||
item (cancel/update from the sending host) are a named follow-up, out of v1.
|
||||
|
||||
## Coordination Project / Classifier and View (PL-004 Phase B)
|
||||
|
||||
Two top-level commands extend the coordination layer with L2 (project / classifier) and L5 (views).
|
||||
@@ -811,8 +1173,9 @@ Phase D extends the policy enum with `workflow-keepalive` (the policy deferred f
|
||||
|
||||
### `rig workflow`
|
||||
|
||||
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness).
|
||||
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner.
|
||||
- `validate <specPath>` — validate a workflow spec file; returns structured ok/error report (role resolution, step uniqueness, allowed-exits consistency, optional seat liveness). Spec-only: it takes no rig context (OPR.0.4.6.FAC1 arch ruling — rig-coverage checks happen at instantiate).
|
||||
- `instantiate <specPath> --root-objective <text> --created-by <session>` — create a new instance + entry-step qitem in the same daemon transaction; `--entry-owner <session>` overrides the default entry owner. **OPR.0.4.6.FAC1**: `--rig <name>` binds the instance to a rig (overrides the spec's `target.rig` DEFAULT; persists as `boundRig` on the instance, rendered by `show`/`trace` and carried in `--json`). On a bound instance, a role with **no `preferred_targets`** resolves to a live capable SEAT on that rig by the pure capability policy (running agents declaring the role, managed seats only, required runtime, least pending backlog, deterministic coordinate tiebreak). Unknown rig = structured `bound_rig_unknown`; a bound-rig role no seat structurally declares = `bound_rig_role_uncovered` (existence at any lifecycle state satisfies it — liveness is checked when the step projects). No `--rig` and no spec default = unbound, byte-identical pre-FAC-1 behavior.
|
||||
- `run <specPath> …` — accepts the same `--rig <name>` binding (run instantiates too).
|
||||
- `project --instance <id> --current-packet <qitem-id> --exit <handoff|waiting|done|failed> --actor-session <session>` — close the current packet AND project the next-step packet IN THE SAME daemon transaction (transactional-scribe; lost handoffs impossible by design). `--result-note <text>`, `--blocked-on <ref>`, `--next-owner <session>` modify behavior.
|
||||
- `list [--status <s>]` — list instances; optionally filter by status (`active`/`waiting`/`completed`/`failed`).
|
||||
- `show <instanceId>` — show one instance.
|
||||
@@ -827,10 +1190,11 @@ Read-only inspection commands for context-window state, compaction planning, wor
|
||||
|
||||
### `rig context`
|
||||
|
||||
Usage: `rig context [--rig <name>] [--threshold <pct>] [--refresh] [--json]`
|
||||
Usage: `rig context [--rig <name>] [--threshold <pct>] [--refresh] [--full] [--json]`
|
||||
|
||||
Notes:
|
||||
- Shows context-window usage across running agents.
|
||||
- **v0.4.0 — compact-by-default (slice 30)**: default emits a compact summary (the few fields the common use needs); `--full` (or `--full --json`) returns today's complete current payload. Daemon skips the expensive aggregation when compact. Lower leverage than 28 / 29 but keeps the read-command surface compact-by-default after the upgrade.
|
||||
- `--rig <name>` narrows to a single rig; `--threshold <pct>` filters to seats at or above the percent (plus unknown + stale).
|
||||
- `--refresh` re-samples context usage before displaying instead of reading the latest cached snapshot.
|
||||
|
||||
@@ -859,18 +1223,20 @@ Usage: `rig seat <subcommand>`
|
||||
Subcommands:
|
||||
- `status <seat> [options]` — show read-only seat handover observability status.
|
||||
- `handover <seat> [options]` — plan a safe two-phase seat handover.
|
||||
- `clear-attention <session> [--reason <text>] [--json]` — evidence-gated, operator-attested, audited reconcile of a stuck `attention_required` seat.
|
||||
|
||||
Notes:
|
||||
- `status` reads the seat-handover observability tables (migration `021`); it does not mutate anything.
|
||||
- `handover` plans the two-phase sequence; actual execution happens through the existing seat-launch surfaces under operator gating.
|
||||
- `clear-attention` (v0.3.4) clears a stuck `attention_required` startup status using captured evidence; `--reason <text>` is an operator attestation override that skips the evidence gate (audited). Replaces SQLite hand-edit workarounds.
|
||||
|
||||
## Mission Control / Queue Observability (PL-005 Phase A)
|
||||
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. Per PL-005 PRD line 301: "no new commands at v0; the `rig ps --nodes --json` surface is consumed read-only."
|
||||
Mission Control is an integrated product UI inside the existing shell, NOT a new `rig` command. PL-005 originally named the read-only node surface as `rig ps --nodes --json`; under the v0.4.4 disclosure ladder, the fleet-wide projected node source is `rig ps --nodes -A --json`.
|
||||
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture.md` § Mission Control / Queue Observability. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
Mission Control is reached via the product UI at the `/mission-control` route. The HTTP API surface (`/api/mission-control/*`) is documented in `docs/as-built/architecture/mission-control.md`. The 7 verbs (`approve`, `deny`, `route`, `annotate`, `hold`, `drop`, `handoff`) execute via `POST /api/mission-control/action`; the 7 views are read via `GET /api/mission-control/views/:view-name`.
|
||||
|
||||
Mission Control consumes `rig ps --nodes --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
Mission Control consumes `rig ps --nodes -A --json` for fleet roll-up where the canonical CLI source is preferred. Cross-CLI-version drift is handled per the 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: missing fields surface as honest "field unavailable on this rig's daemon version" placeholders; once-per-session-per-rig logging avoids spam; the fleet view shows a top-level "rigs running stale CLI" indicator.
|
||||
|
||||
## Agent Images, Context Packs, and Workspace (v0.3.0)
|
||||
|
||||
@@ -917,10 +1283,11 @@ Notes:
|
||||
Usage: `rig workspace <subcommand>` — Workspace Primitive (PL-007), v0 typed-kind tooling.
|
||||
|
||||
Subcommands:
|
||||
- `validate [root]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`.
|
||||
- `validate [root] [--kind <kind>] [--no-recursive] [--require-frontmatter] [--max-files <n>] [--json]` — walk a workspace root, parse each `.md` file's YAML frontmatter, and emit a structured gap report. Advisory only — never modifies files. Default root: `cwd`. `--kind` validates against a specific workspace kind (`user | project | knowledge | lab | delivery`). `--max-files` (default `10000`) hard-caps the walk; v0.3.2 slice-01 GA enforces strict-int regex on this flag.
|
||||
- `doctor [--workspace <path>] [--strict] [--json]` — v0.3.2 slice-21 FR-5 (+1 check v0.4.4 slice 23). Run an 8-check workspace-readiness diagnostic against the daemon's resolved workspace (workspace root, missions folder, file allowlist, daemon alignment, daemon reload, optional slice docs, MISSION_NOTES presence, SDLC convention sections — the last advisory-warn per `docs/reference/sdlc-conventions.md`). Read-only. Default exit-code: non-zero only on `fail`; `--strict` makes warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST` from the operator's shell env so doctor reflects the operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
Notes:
|
||||
- v0 surface is intentionally narrow (`validate` only) — read-only frontmatter audit.
|
||||
- v0 surface was intentionally narrow (`validate` only); v0.3.2 added `doctor` as the operator-facing readiness diagnostic.
|
||||
- Future versions will add typed-kind authoring/refactor tooling on the same root walker.
|
||||
- See `rig config init-workspace` to scaffold a fresh default workspace.
|
||||
|
||||
@@ -946,6 +1313,114 @@ Notes:
|
||||
- `openrig-core` ships bundled with the daemon (11 skills). Additional plugins (`gstack` — 45 skills; `obra-superpowers` — 14 skills) ship as substrate references for plugin authors to copy-install per the `OPENRIG-INSTALL.md` workflow inside each plugin's source tree.
|
||||
- A first-class `rig plugin install <substrate-path>` verb is deferred to 0.3.2.
|
||||
|
||||
## Scope Tree Primitive (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`scopeCommand`,
|
||||
`packages/cli/src/index.ts:20,187`; defined in
|
||||
`packages/cli/src/commands/scope.ts`; release-0.3.2 slice 12). Operates
|
||||
the scope tree (missions, slices, sub-slices) per
|
||||
`conventions/scope-and-versioning`.
|
||||
|
||||
### `rig scope`
|
||||
|
||||
Usage: `rig scope <subcommand>` — scope tree primitive: missions, slices, sub-slices.
|
||||
|
||||
Top-level option:
|
||||
- `--workspace <path>` — override workspace root (otherwise inferred from cwd or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
Two subcommand groups: `slice` and `mission`.
|
||||
|
||||
`rig scope slice <subcommand>` — slice-tier commands:
|
||||
- `ls [--mission <name>] [--state <state>] [--json]` — list slices in a mission (or across all missions). `--state` filter: `active | closed | shipped | all` (default `active`).
|
||||
- `show <slice-path> [--mission <name>] [--json]` — inspect a single slice (frontmatter + README + children). `slice-path` is absolute, relative-to-substrate, or `NN-slug`; `--mission` hints the mission when path is just `NN-slug`.
|
||||
- `create <mission> <slug> [--template <kind>] [--title <text>] [--json]` — create a new slice in a mission. `--template` default `placeholder`. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` (the structure the OpenRig PROGRESS UI page parses) plus a convention-correct README with proper frontmatter, for EVERY template. **v0.4.4 (slice 23)**: EVERY template kind additionally emits the SDLC convention sections (`## Intent` / `## Mini-requirements` / `## Proof contract`, kind-specific body below them), a `proof/` dir, `PROOF.md`, and an `IMPLEMENTATION-PRD.md` skeleton carrying the elastic-middle note — the shapes the Living Notes UI projects. Conventions SSOT: `docs/reference/sdlc-conventions.md`.
|
||||
- `progress <slice-path> [--mission <name>] [--status <state>] [--milestone <text>] [--owner <session>] [--note <text>] [--json]` — **v0.4.0 (slice 33)** new verb: append / set / update progress entries deterministically. Writes the canonical structure the OpenRig PROGRESS UI page reads. Replaces hand-editing `PROGRESS.md` with markdown.
|
||||
- `stage <slice-path> <new-stage> [--mission <name>] [--successor <id>] [--json]` — **v0.4.0 (slice 35)** new verb: set the slice's `stage` frontmatter (wip / provisional / established / canonical / superseded / retired) deterministically. `superseded` REQUIRES `--successor <id>` (rejected otherwise + records the successor); `retired` warns "do not use"; invalid stages rejected with the valid set named.
|
||||
- `verified <slice-path> --against "<source>" [--mission <name>] [--json]` — **v0.4.0 (slice 35)** new verb: stamp the slice's `verified` line with `verified: <today> against <source>`. `--against` is MANDATORY (bare timestamps rejected — the anti-stale keystone per `conventions/scope-and-versioning` §2). Overwrites the prior verified line.
|
||||
- `reconcile <slice-path> [--mission <name>] [--json]` — **v0.4.0 (slice 35)** new verb: idempotent repair. Backfills missing `PROGRESS.md`, conforms mandatory frontmatter (`id` / `stage` / `verified`), and repairs id-registration ghosts (`id:null` / doubled-prefix). Safe to re-run.
|
||||
- `ship <slice-path> <release-mission> [--mission <name>] [--json]` — ship a slice to a release mission (preserves git history).
|
||||
- `close <slice-path> [--note <text>] [--mission <name>] [--json]` — close a slice (move to `<mission>/closed/`, update status). `--note` is an optional closure note.
|
||||
- `move <slice-path> <dest-mission> [--mission <name>] [--json]` — move a slice between missions (re-numbers in destination).
|
||||
|
||||
`rig scope mission <subcommand>` — mission-tier commands:
|
||||
- `ls [--json]` — list missions (top-level folders with `README.md`).
|
||||
- `show <mission> [--json]` — inspect a single mission.
|
||||
- `create <name> [--template <kind>] [--id <dot-id>] [--title <text>] [--json]` — create a new mission (mints a stable dot-ID into frontmatter). `--template` auto-selects when name matches `release-X.Y.Z`; `--id` overrides name-pattern inference. **v0.4.0 (slice 33)**: scaffolds a canonical `PROGRESS.md` plus a convention-correct README with proper frontmatter, per `conventions/scope-and-versioning/README.md`. Fixes the v0.3.x bug where newly-created missions were missing `PROGRESS.md` entirely.
|
||||
- `progress <mission> [--status <state>] [--milestone <text>] [--owner <session>] [--note <text>] [--json]` — **v0.4.0 (slice 33)** new verb: append / set / update progress entries on a mission's `PROGRESS.md` deterministically. UI-valid by construction.
|
||||
- `stage <mission> <new-stage> [--successor <id>] [--json]` — **v0.4.0 (slice 35)** new verb: set the mission's `stage` frontmatter deterministically. Same enum + `--successor`-required-for-superseded rules as the slice variant.
|
||||
- `verified <mission> --against "<source>" [--json]` — **v0.4.0 (slice 35)** new verb: stamp the mission's `verified` line. `--against` MANDATORY.
|
||||
- `reconcile <mission> [--json]` — **v0.4.0 (slice 35)** new verb: idempotent mission-tier repair (backfills `PROGRESS.md`, conforms frontmatter, repairs ghosts).
|
||||
|
||||
Convention compliance: `rig scope` together with slice 33 (`PROGRESS.md` + scaffolding) and slice 35 (`stage` / `verified` / `reconcile`) makes `rig scope` the **deterministic enforcer** of `conventions/scope-and-versioning` (§1 dot-IDs, §2 maturity vocabulary). Agents update the convention through commands rather than hand-editing markdown.
|
||||
|
||||
### SDLC control plane verbs (v0.4.4)
|
||||
|
||||
The conventions these verbs operate live in ONE shipped document: `docs/reference/sdlc-conventions.md` (copied into the assembled CLI package); the operating procedure is the packaged `mission-slice-sop` skill.
|
||||
|
||||
- `rig scope slice|mission approve <target> [--scope spec|delivery] [--actor <session>] [--on-behalf-of <human>] [--json]` — **v0.4.4 (slice 19 FR-9)** the two staged-approval locks, one daemon-side write path (frontmatter stamp + append-only audit row land together; no half-stamp). `--scope spec` = the **plan-lock** ("the PRD matches my intent; this artifact set gets built"); `--scope delivery` (default) = the **proof-lock** (terminal sign-off; fires the freeze). Approval is freeze/sign-off — never proven-green (proven-green requires recorded C1 verdicts).
|
||||
- `rig proof add <slice-path> --artifact-type <guard|qa|rev1-r1|rev1-r2|adjudication> --verdict <CLEAR|BLOCKING|CONCERNING|PASS|NOT-CLEAR> --candidate-sha <sha> --money-evidence "<line>" [--file <path>|--body <text>] [--evidences <refs>] [--media <refs>] [--self-check <text>] [--json]` — **v0.4.4 (slice 19 FR-8; `--media` via the corrective §3.4)** drop a proof artifact into `<slice>/proof/` with the machine-readable C1 header, validated at drop time (closed sets above). `--evidences` (item text or 1-based index) joins the drop to the slice's `## Proof contract` items — the pairing the Living Notes DELIVERED section renders; `--media` (proof/-relative refs, containment-checked, never absolute) names the curated media the drop stands behind, projected into the DELIVERED items' proof set. Contract/self-check outputs are advisories (exit 0), never gates.
|
||||
- `rig scope audit <mission> [--json]` — extended **v0.4.4 (slice 19 FR-10 + slice 23)** with C1-header and IMPLEMENTATION-PRD backstops plus the convention-section advisories (missing `## Intent`, missing/malformed `## Mini-requirements`, missing/malformed `## Proof contract`, UI-slice-without-mockup — a mockup ref means a real image ref or plannedRef token, never bare prose). All advisory/fail-open: the exit code flips on HIGH findings only; the convention advisories are low/info by construction.
|
||||
|
||||
Notes:
|
||||
- Surface source-verified against `packages/cli/src/commands/scope.ts` at `51554eee` (v0.4.0 post-slice-35); SDLC control-plane verbs source-verified against `scope.ts` / `proof.ts` / `scope-audit.ts` in OPR.0.4.4.23.
|
||||
|
||||
## Skill Cascade Audit (v0.4.0)
|
||||
|
||||
One top-level command first shipped in v0.4.0 (slice 10 — skill / knowledge lifecycle curation, Hermes-informed). Defined in `packages/cli/src/commands/skill.ts`. Pairs with the daemon-side audit surface at `packages/daemon/src/routes/skills/audit.ts` + `mirror-drift` detection.
|
||||
|
||||
### `rig skill`
|
||||
|
||||
Usage: `rig skill <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `audit [--json] [--include-cache] [--severity <level>] [--rig <name>]` — read-only audit of the skill cascade. Detects provenance + freshness issues across the canonical → product mirror → hub cwd → installed plugin chain.
|
||||
|
||||
Audit categories surfaced:
|
||||
- **`missing`** — a skill location in the cascade lacks a SKILL.md file but a sibling location has one (declares the cascade-relative gap).
|
||||
- **`stale`** — a SKILL.md file exists but is older than the canonical or has a content-hash mismatch.
|
||||
- **`self-referential`** — a SKILL.md provenance pointer references its own location instead of an upstream source.
|
||||
- **`invalid-date`** — frontmatter `last-verified` / `last-updated` is malformed or in the future.
|
||||
- **`mirror-drift`** — a downstream mirror copy diverges from canonical with no documented intentional fork.
|
||||
|
||||
Notes:
|
||||
- **Read-only**: the audit does NOT mutate any skill file. Findings are routed back to the lifecycle (curation-steward) for shaped propagation runs.
|
||||
- **False-green prevention**: when mirror-drift evidence is unavailable for any reason (daemon offline, filesystem inaccessible), the CLI emits a clear `unable-to-audit` outcome with exit code `2` rather than reporting `clean`. This closes the failure mode the v0.3.4 wrap-gate AC-3 almost shipped ("Mirror sync verified via `npm run mirror-skills` clean" — that sync did not touch substrate canonical or hub cwd).
|
||||
- `--include-cache` includes packaged-installer cache copies in the audit (default skips because those are immutable post-ship).
|
||||
- `--severity <level>` filters output: `info` (default; everything), `warn` (stale + mirror-drift only), `error` (invalid-date + self-referential only).
|
||||
- `--rig <name>` narrows the audit to a single rig's embedded skill copies.
|
||||
- `--json` emits structured findings: `{cascade: [...locations...], findings: [{category, path, evidence, suggested-action}]}`.
|
||||
- Exit codes: `0` clean, `1` findings present, `2` unable to audit.
|
||||
|
||||
Composes with the existing `scripts/mirror-skills.mjs` guardrails — the audit detects what `mirror-skills` would also catch, plus the canonical / hub cwd layers that script doesn't touch.
|
||||
|
||||
## Operator Context-Mode Bindings (v0.3.2)
|
||||
|
||||
One top-level command first shipped in v0.3.2 (`rigPolicyCommand`,
|
||||
defined in `packages/cli/src/commands/rig-policy.ts`; release-0.3.2
|
||||
slice 09). Pairs with the daemon's typed-primitive store at
|
||||
`packages/daemon/src/db/migrations/041_rig_policy.ts`. Operates the
|
||||
operator context-mode binding surface (sleep / desk / mobile / away /
|
||||
focus / debug) used by mode-aware agent posture.
|
||||
|
||||
### `rig policy`
|
||||
|
||||
Usage: `rig policy <subcommand>`
|
||||
|
||||
Subcommands:
|
||||
- `set <mode> [--scope <scope>] [--qualifier <id>] [--<field> ...] [--evidence <citation>] [--confirm] [--bearer <token>] [--json]` — propose a binding. Without `--confirm` the CLI echoes the proposed binding and exits with `exit 2` so scripts cannot accidentally apply; `--confirm` is the explicit operator action. `<scope>` is one of `global_host | rig | workstream | qitem` (defaults to the per-mode recommendation). `--qualifier <id>` is required for `rig | workstream | qitem` scopes and rejected for `global_host`. Per-field tuning flags: `--autonomy-scope`, `--heartbeat-cadence`, `--inspection-depth`, `--update-detail`, `--escalation-threshold`, `--concurrency-limit`, `--permission-prompt-posture` (one of `normal | batch_for_human | do_not_prompt_unless_blocked`; `auto_accept` is FORBIDDEN by convention), `--expiry-or-stale-rule`. `--evidence` carries a free-text operator citation (message id, file pointer, chatroom topic, etc.).
|
||||
- `show [--json]` — list all operator-context-mode bindings.
|
||||
- `effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]` — resolve the effective mode for a (rig, workstream, qitem) read context. Surfaces `unknown_posture` when no binding matches.
|
||||
- `cite [--rig <id>] [--workstream <id>] [--qitem <id>]` — emit the short-prose citation line for the effective mode at the read context (per convention §Citation Rules).
|
||||
- `unset <scope> [qualifier] [--bearer <token>] [--json]` — delete one binding (operator-only).
|
||||
- `defaults [--json]` — print the recommended per-mode 6×7 field defaults + default-scope mapping + stale rule.
|
||||
|
||||
Notes:
|
||||
- The 6 modes are `sleep | desk | mobile | away | focus | debug`. Bare-word invocation is accepted (`set desk`); `mode:<word>` is the disambiguated prefix form.
|
||||
- Restate-and-confirm posture (HG-4): `set` is restate-only until `--confirm` is passed. This prevents accidental script application.
|
||||
- `--qualifier` strict reject for `global_host` (HG-7 guard finding): operators who type `--scope global_host --qualifier <id>` get an error and the daemon is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
- Operator-edit mutations (`set --confirm`, `unset`) require an operator bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
- Surface source-verified against `packages/cli/src/commands/rig-policy.ts` at `53794fbe` (v0.3.3).
|
||||
|
||||
## Commands Not Present
|
||||
|
||||
These are not current top-level `rig` commands:
|
||||
|
||||
+171
-399
@@ -1,425 +1,197 @@
|
||||
# OpenRig Source Codemap
|
||||
---
|
||||
kind: as-built
|
||||
title: OpenRig Codemap — Navigation Index / Map of Territory
|
||||
status: active
|
||||
topics: [knowledge-and-context, observability]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
You know what you need to learn about the shipped OpenRig system but not
|
||||
which as-built module holds it. Use the module map, the use-case lookup,
|
||||
or the source-root table to route to the right doc — or jump straight to
|
||||
the source root the codemap points at.
|
||||
siblings: [README.md, cli-reference.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
Source-driven map of every file under `packages/daemon/src`, `packages/cli/src`, and `packages/ui/src` at current `HEAD`.
|
||||
# OpenRig Codemap — Navigation Index
|
||||
|
||||
Current footprint:
|
||||
- daemon: 211 source files (126 domain, 31 routes, 11 adapters, 36 migrations, 7 other)
|
||||
- CLI: 78 source files
|
||||
- UI: 87 source files
|
||||
- total: 376 source files
|
||||
This is a **map of territory**, not a content dump. It tells you which
|
||||
as-built module answers a question and which `packages/*/src` root that
|
||||
module is grounded in. The deep content lives in the modules; the code lives
|
||||
in the source roots; this file points at both.
|
||||
|
||||
How to use this doc:
|
||||
- `Exports` lists the public symbols defined by the file.
|
||||
- `Related` lists the file's primary local outgoing dependencies so you can follow the code path quickly.
|
||||
- The package/area groupings are structural, not ownership metadata.
|
||||
> The old codemap was a 425-line file-by-file `Exports:`/`Related:` flat dump
|
||||
> that drifted fastest (its own header carried a stale v0.2.0 footprint and an
|
||||
> internally-contradictory command-group count) and never fit a context
|
||||
> window. That anti-pattern is retired (slice-08 Q5, founder-ratified). The
|
||||
> genuinely-useful part — the source-root index — is preserved below as the
|
||||
> source-root pointer table; the per-file transcription is not.
|
||||
|
||||
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`
|
||||
- Ask / context packs: `packages/daemon/src/domain/ask-service.ts`, `packages/daemon/src/routes/ask.ts`
|
||||
- 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/*` (53 groups)
|
||||
- CLI infrastructure: `packages/cli/src/config-store.ts`, `packages/cli/src/system-preflight.ts`
|
||||
- UI workspace and navigation: `packages/ui/src/components/Explorer.tsx`, `packages/ui/src/components/WorkspaceHome.tsx`, `packages/ui/src/components/AppShell.tsx`
|
||||
- UI shared drawer: `packages/ui/src/components/SharedDetailDrawer.tsx`, `RigDetailPanel.tsx`, `NodeDetailPanel.tsx`, `DiscoveryPanel.tsx`, `SpecsPanel.tsx`, `RigChatPanel.tsx`, `SystemPanel.tsx`
|
||||
- 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/*`
|
||||
## (a) Product frame
|
||||
|
||||
## Daemon
|
||||
OpenRig is a local control plane for multi-agent coding topologies — a
|
||||
multi-agent harness that manages your Claude Code and Codex sessions as a
|
||||
single system. The daemon (`@openrig/daemon`) is the framework-free
|
||||
SQLite-backed core; the CLI (`@openrig/cli`), UI (`@openrig/ui`), and MCP
|
||||
server all sit on top of it.
|
||||
|
||||
### 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, env status/logs/down, specs review/library, and `whoami`. Exports: AppDeps, createApp. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-orchestrator.js`, `packages/daemon/src/domain/bootstrap-repository.js`, +36 more.
|
||||
- `packages/daemon/src/startup.ts`: Constructs the daemon dependency graph, runs all 36 migrations, wires legacy, reboot, North Star, Post-North-Star, Phase 5 RigEnv, context-usage, Final Stretch 3 managed-app services, and PL-004 coordination services together, and returns `{ app, db, deps }`. Exports: createDaemon. Related: `packages/daemon/src/adapters/claude-resume.js`, `packages/daemon/src/adapters/cmux-transport.js`, `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/codex-resume.js`, +58 more.
|
||||
> Source-verified at HEAD `7eaf524c` (`v0.3.1-6-g7eaf524c`); package version
|
||||
> **0.3.1**, 6 unreleased 0.3.2 commits on HEAD, no `v0.3.2` tag. Frame
|
||||
> sentence triangulated against `architecture/daemon-core.md` §1 +
|
||||
> `openrig-internal/product/positioning.md` ("a multi-agent harness, a local
|
||||
> control plane that manages your Claude Code and Codex sessions as a single
|
||||
> system").
|
||||
|
||||
### 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`.
|
||||
- `packages/daemon/src/adapters/shell-quote.ts`: Adapter/util module for shell quote integration. Exports: shellQuote. Related: none.
|
||||
- `packages/daemon/src/adapters/tmux-exec.ts`: Adapter/util module for tmux exec integration. Exports: execCommand. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/adapters/tmux.ts`: Adapter/util module for tmux integration. Exports: ExecFn, TmuxAdapter, TmuxPane, TmuxResult, TmuxSession, TmuxWindow. Related: none.
|
||||
## (b) Module map
|
||||
|
||||
### DB Core
|
||||
- `packages/daemon/src/db/connection.ts`: Creates the SQLite connection with daemon-wide pragmas. Exports: createDb. Related: none.
|
||||
- `packages/daemon/src/db/migrate.ts`: Migration runner and `Migration` type shared by all schema files. Exports: Migration, migrate. Related: none.
|
||||
One row per module. "Reach for this when…" mirrors each module's actual
|
||||
`applies-when` frontmatter.
|
||||
|
||||
### DB Migrations
|
||||
- `packages/daemon/src/db/migrations/001_core_schema.ts`: Database migration for core schema. Exports: coreSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/002_bindings_sessions.ts`: Database migration for bindings sessions. Exports: bindingsSessionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/003_events.ts`: Database migration for events. Exports: eventsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/004_snapshots.ts`: Database migration for snapshots. Exports: snapshotsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/005_checkpoints.ts`: Database migration for checkpoints. Exports: checkpointsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/006_resume_metadata.ts`: Database migration for resume metadata. Exports: resumeMetadataSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/007_node_spec_fields.ts`: Database migration for node spec fields. Exports: nodeSpecFieldsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/008_packages.ts`: Database migration for packages. Exports: packagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/009_install_journal.ts`: Database migration for install journal. Exports: installJournalSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/010_journal_seq.ts`: Database migration for journal seq. Exports: journalSeqSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/011_bootstrap.ts`: Database migration for bootstrap. Exports: bootstrapSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/012_discovery.ts`: Database migration for discovery. Exports: discoverySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/013_discovery_fk_fix.ts`: Database migration for discovery fk fix. Exports: discoveryFkFix. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/014_agentspec_reboot.ts`: Database migration for agentspec reboot. Exports: agentspecRebootSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/015_startup_context.ts`: Database migration for startup context. Exports: startupContextSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/016_chat_messages.ts`: Database migration for durable rig-scoped chat messages. Exports: chatMessagesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/017_pod_namespace.ts`: Database migration that persists authored pod namespace separately from pod DB id for export, adoption, and identity parity. Exports: podNamespaceSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/018_context_usage.ts`: Database migration for per-node context-usage persistence. Exports: contextUsageSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/019_external_cli_attachment.ts`: Database migration extending bindings for external CLI attachment metadata. Exports: externalCliAttachmentSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/020_rig_services.ts`: Database migration for rig-scoped services records and persisted env receipts. Exports: rigServicesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/021_seat_handover_observability.ts`: Database migration for seat-handover observability fields. Exports: seatHandoverObservabilitySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/022_node_codex_config_profile.ts`: Database migration adding Codex config-profile field on nodes. Exports: nodeCodexConfigProfileSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/023_stream_items.ts`: PL-004 Phase A — L1 stream items table (append-only intake/audit root). Exports: streamItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/024_queue_items.ts`: PL-004 Phase A — L3 queue items table (owned-work queue with closure obligation). Exports: queueItemsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/025_queue_transitions.ts`: PL-004 Phase A — append-only queue state transition log. Exports: queueTransitionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/026_inbox_entries.ts`: PL-004 Phase A — mailbox-style inbox for asynchronous deposit. Exports: inboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/027_outbox_entries.ts`: PL-004 Phase A — sender-side outbox audit. Exports: outboxEntriesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/028_project_classifications.ts`: PL-004 Phase B — L2 project classifications table (idempotent on stream_item_id; FK to stream_items). Exports: projectClassificationsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/029_classifier_leases.ts`: PL-004 Phase B — single-writer classifier lease table (partial UNIQUE INDEX on state='active'). Exports: classifierLeasesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/030_views_custom.ts`: PL-004 Phase B — custom view registry. Exports: viewsCustomSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/031_watchdog_jobs.ts`: PL-004 Phase C — watchdog jobs (canonical scheduler state; policy enum + intervals + lifecycle). Exports: watchdogJobsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/032_watchdog_history.ts`: PL-004 Phase C — append-only watchdog evaluation history (sent/skipped/terminal; not_due polls not recorded). Exports: watchdogHistorySchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/033_workflow_specs.ts`: PL-004 Phase D — workflow specs (cached read-through from markdown/YAML; UNIQUE on name+version; source_hash invalidation). Exports: workflowSpecsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/034_workflow_instances.ts`: PL-004 Phase D — workflow instances (live state; status enum; current_frontier_json; hop_count). Exports: workflowInstancesSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/035_workflow_step_trails.ts`: PL-004 Phase D — append-only workflow step trail history (FK to workflow_instances and queue_items). Exports: workflowStepTrailsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/036_watchdog_policy_enum_extension.ts`: PL-004 Phase D — documenting no-op migration; Phase C uses application-layer enforcement so the enum extension to include workflow-keepalive is implemented in PHASE_D_POLICIES (watchdog-jobs-repository.ts), not in DDL. Exports: watchdogPolicyEnumExtensionSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
- `packages/daemon/src/db/migrations/037_mission_control_actions.ts`: PL-005 Phase A — Mission Control action audit table (append-only; 7-verb enum at app layer; before/after qitem snapshots; FK to queue_items). Exports: missionControlActionsSchema. Related: `packages/daemon/src/db/migrate.js`.
|
||||
### architecture/
|
||||
|
||||
### Domain: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-action-log.ts`: Append-only writer for mission_control_actions. API surface lacks update/delete/remove (contract enforcement). Validates verb-specific required fields (annotate→annotation, hold/drop→reason). Exports: MissionControlActionLog, MissionControlActionLogError, MissionControlVerb, MISSION_CONTROL_VERBS. Related: `packages/daemon/src/db/migrations/037_mission_control_actions.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-write-contract.ts`: LOAD-BEARING. Atomic 7-verb action coordinator. Each verb is one db.transaction: queue mutation via Phase D's QueueRepository.updateWithinTransaction (preserves Phase A hot-potato closure validation; emits queue.updated) + audit row + persisted mission_control.action_executed event. The 4-step handoff shape (source-update + destination-create + opt-in best-effort notify + audit append) is verified atomic; notify failure does NOT roll back durable state. Exports: MissionControlWriteContract, MissionControlWriteContractError, MissionControlActionInput, MissionControlActionResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/mission-control/mission-control-action-log.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-read-layer.ts`: Maps 7 Mission Control views to source-of-truth paths. my-queue/human-gate/active-work/recent-ships query queue_items via QueueRepository; fleet uses fleetCliCapability roll-up; recently-active delegates to PL-004 Phase B ViewProjector.show("recently-active"); recent-observations reads stream_items via StreamStore. All rows return the 9-field phone-friendly content model (PRD non-negotiable). Exports: MissionControlReadLayer, MissionControlViewName, CompactStatusRow, MISSION_CONTROL_VIEWS. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/view-projector.js`, `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.js`.
|
||||
- `packages/daemon/src/domain/mission-control/mission-control-fleet-cli-capability.ts`: Per-rig CLI capability cache + fleet roll-up. Implements 4 sub-clauses of PRD § Runtime/Source Drift Acceptance: per-field availability check, per-rig honesty, once-per-session-per-rig logging, "rigs running stale CLI" indicator. Synthesizes per-rig activity state (active|idle|attention|blocked|degraded) from queue_items aggregation. Exports: MissionControlFleetCliCapability, FleetRollupRow, FleetRollup, MISSION_CONTROL_DESIRED_FIELDS. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/db/migrations/024_queue_items.js`.
|
||||
- `packages/daemon/src/routes/mission-control.ts`: Hono route module for `/api/mission-control` (`GET /views` literal list; `GET /views/:view-name` view fetch; `POST /action` 7-verb atomic execute; `GET /sse` + `GET /watch` SSE; `GET /cli-capabilities` fleet drift cache). SSE/literal paths mounted before /views/:view-name catchall per PL-004 Phase A R1 route-order discipline. Exports: missionControlRoutes. Related: `packages/daemon/src/domain/mission-control/*`, `packages/daemon/src/domain/event-bus.js`.
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `daemon-core.md` | The framework-free SQLite-backed core the CLI/UI/MCP all sit on. | You need to know how the daemon boots, how `createDaemon` wires the dependency graph, the SQLite schema/migration set, or the route-mount surface. |
|
||||
| `adapters-and-runtimes.md` | The five-method runtime-adapter contract + the resume-honesty layer. | You need the runtime-adapter contract, how OpenRig launches/resumes a Claude Code, Codex, or terminal harness inside tmux, or how the daemon honestly assesses whether a harness actually resumed vs fresh-launched. |
|
||||
| `coordination-primitive.md` | PL-004 Phase A: the SQLite-canonical durable-work layer (`/api/stream`, `/api/queue`). | You need the stream/queue/inbox/outbox tables, the hot-potato closure contract, the transactional handoff guarantee, or where queue closure is enforced. |
|
||||
| `workflow-runtime.md` | PL-004 Phase D: turns an intended sequence of work into durable SQLite state. | You need workflow specs cache, instance state, step trails, the transactional-scribe projection contract, or the watchdog policy set including workflow-keepalive. |
|
||||
| `mission-control.md` | PL-005: the daemon-backed queue-observability surface inside the existing shell. | You need the seven views, the seven write verbs, the action audit table, the bearer-token middleware, or how queue observability maps to PL-004 sources. |
|
||||
| `agent-spec-and-startup.md` | How authored YAML specs become a resolved, launched, identity-addressable topology. | You need the AgentSpec/RigSpec/pod-aware reboot types, profile resolution + additive startup layering, the StartupOrchestrator delivery split, or how whoami/materialize/bind/adopt resolve identity. |
|
||||
| `lifecycle-snapshot-restore.md` | The durable-state half of `down → up → handoff`. | You need how OpenRig captures a snapshot, restores a rig (resume vs rebuild vs fresh), enforces restore honesty, consults live continuity, or how restore-check / restore-packet readiness probes work. |
|
||||
| `transport-and-transcripts.md` | The communication-and-history layer; tmux is transport, not truth. | You need how rig send/capture/broadcast works, pipe-pane transcript capture + search, durable SQLite chat, what `rig ask` gathers, or the MCP-tool-name vs tmux-metadata-key distinction. |
|
||||
| `workspace-primitive.md` | PL-007: the typed declaration of where a rig's work lives. | You need how a rig declares a typed workspace (root/repos/defaultRepo/knowledgeRoot), how it persists/resolves into whoami / node-inventory, how per-item `target_repo` scope is validated, or how the file-backed missions/slices tree is indexed and projected into Project. |
|
||||
| `content-surfaces.md` | The operator-allowlisted, filesystem-canonical read/write layer Project/Steering sits on. | You need how the file browser enforces path safety, how atomic conflict-checked writes + the JSONL edit audit work, how the PROGRESS.md tree is indexed, or how the one-screen Steering surface is composed. |
|
||||
| `plugin-agent-image-context-pack.md` | Four filesystem-canonical content primitives the daemon discovers and serves. | You need how OpenRig discovers plugins, captures/forks agent images, assembles/sends context packs, or how the Claude auto-compaction enforcer decides to send `/compact`. |
|
||||
| `packaging-bootstrap-bundles.md` | How a topology is packaged into a shareable bundle and reconstituted elsewhere. | You need how rig/pod bundles are assembled (schema-v2 vs legacy v1), how bundle create/inspect/install + `/api/up` route across source kinds, the staged BootstrapOrchestrator flow, or which legacy install-engine seams still ship. |
|
||||
| `architecture-rules-and-event-system.md` | The cross-cutting invariants that belong to no single subsystem. | You need the 25 architecture rules + startup/import constraints, the shape of the RigEvent union and its SSE delivery, or the intentional compatibility limits that still describe the shipped system. |
|
||||
| `living-notes-review.md` | **v0.4.4** — the one intent→plan→delivered review projection over on-disk SDLC markdown. | You need the `ComposedSliceReview` contract, the `/api/review/*` routes, staged-approval locks (`--scope spec|delivery`), the `## Proof contract` ↔ proof-artifact join and C1-bound `verified`, the freeze export, or ranged media serving for review evidence. |
|
||||
|
||||
### Product UI: Mission Control / Queue Observability (PL-005 Phase A)
|
||||
- `packages/ui/src/components/mission-control/MissionControlSurface.tsx`: Top-level Mission Control surface inside the existing product UI shell. Tab navigation across the 7 view names. Mounted at the `/mission-control` route. Exports: MissionControlSurface.
|
||||
- `packages/ui/src/components/mission-control/views/{MyQueueView,HumanGateView,FleetView,ActiveWorkView,RecentShipsView,RecentlyActiveView,RecentObservationsView}.tsx`: 7 view components. Most wrap a shared GenericListView; FleetView combines CliDriftIndicator + per-row MissingFieldPlaceholder; MyQueueView combines HumanSeatCard + GenericListView with verb actions enabled.
|
||||
- `packages/ui/src/components/mission-control/components/CompactStatusRow.tsx`: 9-field phone-friendly content-model atom (PRD non-negotiable). Renders compact / expanded density modes; UI may collapse but JSON preserves all 9 fields.
|
||||
- `packages/ui/src/components/mission-control/components/VerbActions.tsx`: Shared 7-verb action sub-component. Renders verb buttons + per-verb required-field input (destination for route/handoff, annotation for annotate, reason for hold/drop). Submits to `POST /api/mission-control/action`. Invalidates Mission Control view queries on success.
|
||||
- `packages/ui/src/components/mission-control/components/HumanSeatCard.tsx`: First-class human-seat rendering. Shows session identity, pending load, blocked count, verb capabilities. NOT invisible config-layer convention.
|
||||
- `packages/ui/src/components/mission-control/components/CliDriftIndicator.tsx`: Cross-CLI-version drift indicator (PRD sub-clause 4). Shows fleet-level "rigs running stale CLI" badge + per-row "field unavailable on this rig's daemon version" placeholders via the MissingFieldPlaceholder export.
|
||||
- `packages/ui/src/components/mission-control/hooks/{useMissionControlView,useMissionControlAction,useMissionControlCliCapabilities}.ts`: TanStack Query hooks fetching from `/api/mission-control/{views/:name,action,cli-capabilities}`.
|
||||
### ui/
|
||||
|
||||
### Domain: Coordination Watchdog (PL-004 Phase C; extended in Phase D)
|
||||
- `packages/daemon/src/domain/watchdog-jobs-repository.ts`: Persistence for watchdog_jobs. Phase D extension: enforces four-policy enum (periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive) at register-time. Phase C's policy_deferred_to_phase_d rejection has been replaced with positive registration-accept. Exports: WatchdogJobsRepository, WatchdogJobsError, WatchdogJob, PHASE_D_POLICIES, PHASE_C_POLICIES (alias). Related: `packages/daemon/src/db/migrations/031_watchdog_jobs.js`.
|
||||
- `packages/daemon/src/domain/watchdog-history-log.ts`: Append-only writer for watchdog_history. Records meaningful evaluations only (sent/skipped/terminal). API surface intentionally lacks update/delete. Exports: WatchdogHistoryLog, WatchdogHistoryEntry, WatchdogOutcome. Related: `packages/daemon/src/db/migrations/032_watchdog_history.js`.
|
||||
- `packages/daemon/src/domain/watchdog-policy-engine.ts`: Resolves policy by name, parses spec_yaml context, dispatches policy.evaluate(), records meaningful outcomes via history-log, emits watchdog.* events, calls injected delivery callback for action=send. Exports: WatchdogPolicyEngine, DeliveryFn, DeliveryRequest, DeliveryOutcome. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/watchdog-scheduler.ts`: Tick-driven daemon-native scheduler that joins the supervision tree. Filters listActive() through isDue(), evaluates due jobs sequentially, supports start/stop with graceful in-flight await. Exports: WatchdogScheduler, isDue. Related: `packages/daemon/src/domain/watchdog-policy-engine.js`, `packages/daemon/src/domain/watchdog-jobs-repository.js`.
|
||||
- `packages/daemon/src/domain/policies/types.ts`: Policy contract (Policy, PolicyJob, PolicyEvaluation). Exports: Policy, PolicyJob, PolicyEvaluation. Related: none.
|
||||
- `packages/daemon/src/domain/policies/periodic-reminder.ts`: Policy that returns send {target, message} unconditionally; throws policy_spec_invalid if context.target.session or context.message missing. Translated from POC `lib/policies/periodic-reminder.mjs`. Exports: periodicReminderPolicy. Related: `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-ready.ts`: Policy that scans context.pools and skips with no_actionable_artifacts when empty; otherwise sends a formatted message capped at max_items. Translated from POC `lib/policies/artifact-pool-ready.mjs`. Exports: artifactPoolReadyPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/edge-artifact-required.ts`: Policy that scans source_pools and target_pools, computes source-key per artifact, and sends an edge-repair message for sources lacking matching downstream artifacts. Skips with no_missing_edge_artifacts when all sources matched. Translated from POC `lib/policies/edge-artifact-required.mjs`. Exports: edgeArtifactRequiredPolicy. Related: `packages/daemon/src/domain/policies/artifact-pool-helpers.js`, `packages/daemon/src/domain/policies/types.js`.
|
||||
- `packages/daemon/src/domain/policies/artifact-pool-helpers.ts`: Shared filesystem scanner + formatter for artifact-pool-ready and edge-artifact-required. Translated from POC `lib/policies/artifact-pool.js`. Exports: scanArtifactPools, formatArtifactList, sourceKeyFor, ArtifactPoolSpec, ScannedArtifact. Related: none.
|
||||
- `packages/daemon/src/routes/watchdog.ts`: Hono route module for `/api/watchdog` (`POST /register`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:job_id`, `GET /:job_id/status`, `POST /:job_id/stop`). SSE/literal paths mounted before /:job_id catchall per Phase A R1 route-order discipline. Exports: watchdogRoutes. Related: `packages/daemon/src/domain/watchdog-jobs-repository.js`, `packages/daemon/src/domain/watchdog-history-log.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/policies/workflow-keepalive.ts`: PL-004 Phase D — workflow-keepalive policy (carry-forward from Phase C deferral). Reads workflow_instances directly via SQLite (audit row 18 LOAD-BEARING); requires status active|waiting; resolves frontier qitem owners via queue_items. Translated from POC `lib/policies/workflow-keepalive.mjs`; source-of-truth switched from markdown to SQLite. Exports: makeWorkflowKeepalivePolicy. Related: `packages/daemon/src/domain/policies/types.js`, `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
| Module | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `shell-and-routing.md` | The shell-first, route-first, primitive-driven operator surface. | You need how the UI shell (rail / Explorer / center workspace / drawer / preview stack) is assembled, the actual route tree the shipped UI mounts, or how the shared detail drawer and event consumption work. |
|
||||
| `topology.md` | The operator's live picture of host → rig → pod → seat. | You need how the topology surface is built — the host hybrid graph, the table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, and the navigation/overlay contracts. |
|
||||
| `project-and-for-you.md` | The three operator-facing destination surfaces (For You / Project / Dashboard). | You need how the For-You attention feed (5-card classifier + verb actions), the Project workspace/mission/slice scope pages, and the Dashboard landing on the vellum brand system are built. |
|
||||
| `library-specs-and-design-system.md` | The Library (`/specs`) destination + the design-system pointer. | You need how the Library UI is assembled — the spec/skills/plugins surfaces, the spec-review + spec-library + live-identity flows that feed it — or where the canonical visual/design-system spec lives. |
|
||||
|
||||
### Domain: Workflow Runtime (PL-004 Phase D)
|
||||
- `packages/daemon/src/domain/workflow-types.ts`: Shared types (WorkflowSpec, WorkflowInstance, WorkflowStepTrailEntry, WorkflowExitKind). Reverse-engineered from POC `workflow-runtime-v0/lib/workflow-runtime.rb` + fixture YAMLs. Exports: WorkflowSpec, WorkflowStepSpec, WorkflowRoleSpec, WorkflowInvariants, WorkflowClosureMessages, WorkflowLoopGuards, WorkflowInstance, WorkflowInstanceStatus, WorkflowStepTrailEntry, WorkflowExitKind, WorkflowSpecRow. Related: none.
|
||||
- `packages/daemon/src/domain/workflow-spec-cache.ts`: Read-through cache from markdown/YAML spec files into workflow_specs table. Workspace-surface reconciliation: source_hash invalidates the cache on next read; valid operator edits to spec files win. Uses the `yaml` package for parsing. Exports: WorkflowSpecCache, WorkflowSpecError, parseWorkflowSpec. Related: `packages/daemon/src/db/migrations/033_workflow_specs.js`.
|
||||
- `packages/daemon/src/domain/workflow-validator.ts`: Validates a workflow spec against role resolution, step uniqueness, allowed-exits consistency, and (optional via injected callback) seat liveness. Returns structured ValidationResult with severity-tagged issues. Exports: WorkflowValidator, ValidationIssue, ValidationResult, SeatLivenessCheckFn. Related: `packages/daemon/src/domain/workflow-types.js`.
|
||||
- `packages/daemon/src/domain/workflow-instance-store.ts`: CRUD on workflow_instances + frontier tracking. Survives daemon restart (queries SQLite directly). Exports: WorkflowInstanceStore, WorkflowInstanceError, CreateWorkflowInstanceInput. Related: `packages/daemon/src/db/migrations/034_workflow_instances.js`.
|
||||
- `packages/daemon/src/domain/workflow-step-trail-log.ts`: Append-only writer/reader for workflow_step_trails. API surface lacks update/delete (contract enforcement). Mirrors Phase A's queue-transition-log + Phase C's watchdog-history-log shape. Exports: WorkflowStepTrailLog, WorkflowStepTrailRecordInput. Related: `packages/daemon/src/db/migrations/035_workflow_step_trails.js`.
|
||||
- `packages/daemon/src/domain/workflow-projector.ts`: LOAD-BEARING. Implements the transactional-scribe contract: closing a workflow_instances frontier qitem AND creating the next-step queue_items row AND appending the workflow_step_trails entry AND updating instance frontier+status all happen in the SAME daemon transaction (db.transaction). If any side throws, all roll back. Lost handoffs are impossible by design. Post-commit, fans out persisted events + nudges next owner. Uses Phase A QueueRepository.createWithinTransaction() (Phase D extension point). Exports: WorkflowProjector, WorkflowProjectorError, ProjectStepInput, ProjectStepResult. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/workflow-instance-store.js`, `packages/daemon/src/domain/workflow-step-trail-log.js`.
|
||||
- `packages/daemon/src/domain/workflow-runtime.ts`: Facade over spec-cache + validator + instance-store + projector + step-trail-log. Operations: validate, instantiate (creates instance + entry qitem in same transaction), project (delegates to projector), continue (idempotent inspector). Exports: WorkflowRuntime, WorkflowRuntimeDeps, InstantiateInput, InstantiateResult, WorkflowInstanceError, WorkflowProjectorError, WorkflowSpecError. Related: `packages/daemon/src/domain/workflow-projector.js`, `packages/daemon/src/domain/workflow-spec-cache.js`.
|
||||
- `packages/daemon/src/routes/workflow.ts`: Hono route module for `/api/workflow` (`POST /validate`, `POST /instantiate`, `POST /project`, `GET /list`, `GET /sse`, `GET /watch`, `GET /:instance_id`, `GET /:instance_id/trace`, `POST /:instance_id/continue`). SSE/literal paths mounted before /:instance_id catchall per Phase A R1 route-order discipline. Exports: workflowRoutes. Related: `packages/daemon/src/domain/workflow-runtime.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
### root
|
||||
|
||||
### 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, 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/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`.
|
||||
| Doc | One-sentence summary | Reach for this when… |
|
||||
|---|---|---|
|
||||
| `cli-reference.md` | The full `rig` CLI surface, kept as one doc. | You need the exact rig CLI surface — command groups, subcommands, flags, JSON output, cross-host, coordination primitives. |
|
||||
| `frontmatter-schema.md` | The frontmatter convention these docs follow + the as-built-unique field. | You are authoring or updating a doc under `docs/as-built/`. |
|
||||
| `../DESIGN.md` | The canonical visual / brand / design-system spec (repo `docs/` root). | You need the visual system, brand identity, or design-system tokens. (Stays at root by design; pointer only.) |
|
||||
|
||||
### Domain: Specs, Resolution, Startup
|
||||
- `packages/daemon/src/domain/agent-manifest.ts`: Canonical `agent.yaml` parser, validator, and normalizer for AgentSpec files. Exports: normalizeAgentSpec, parseAgentSpec, validateAgentSpec. 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/agent-preflight.ts`: Lightweight preflight for a single `agent_ref`, used before full rig instantiation. Exports: agentPreflight. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/agent-resolver.ts`: Resolves `agent_ref` roots, imported AgentSpecs, and cross-spec resource-collision metadata. Exports: AgentResolverFsOps, ResolveResult, ResolvedAgentSpec, ResourceCollision, resolveAgentRef, resolveImports. Related: `packages/daemon/src/domain/agent-manifest.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/pod-repository.ts`: SQLite-backed repository for pods and live continuity-state rows. Exports: PodRepository. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/profile-resolver.ts`: Applies AgentSpec defaults, profile uses, startup layering, and restore-policy narrowing to produce a `ResolvedNodeConfig`. Exports: QualifiedResource, ResolutionContext, ResolutionResult, ResolvedNodeConfig, ResolvedResources, resolveNodeConfig. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/startup-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/projection-planner.ts`: Converts resolved node config plus collision data into runtime projection entries, diagnostics, conflicts, and no-ops. Exports: PlanResult, ProjectionClassification, ProjectionEntry, ProjectionFsOps, ProjectionInput, ProjectionPlan, planProjection. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-codec.ts`: YAML codec layer for both legacy flat-node RigSpec and rebooted pod-aware RigSpec shapes. Exports: LegacyRigSpecCodec, RigSpecCodec. Related: `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-exporter.ts`: Exports live rigs back into RigSpec YAML/JSON. Dual-format: detects pod-aware rigs (podId non-null) and exports canonical v0.2 RigSpec with pods/members/edges; legacy rigs export as flat-node v1. Exports: RigSpecExporter. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/pod-repository.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/rigspec-instantiator.ts`: Dual-stack instantiation layer containing both legacy `RigInstantiator` and rebooted `PodRigInstantiator`. Exports: PodRigInstantiator, RigInstantiator. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/pod-repository.js`, +10 more.
|
||||
- `packages/daemon/src/domain/rigspec-preflight.ts`: Dual-stack preflight layer: legacy `RigSpecPreflight` plus rebooted pure `rigPreflight`. Exports: RigPreflightInput, RigSpecPreflight, rigPreflight. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/profile-resolver.js`, `packages/daemon/src/domain/rig-repository.js`, +4 more.
|
||||
- `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, 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`.
|
||||
- `packages/daemon/src/domain/startup-validation.ts`: Shared validation and normalization for startup files and startup actions. Exports: normalizeStartupBlock, validateStartupAction, validateStartupBlock, validateStartupFile. Related: `packages/daemon/src/domain/path-safety.js`, `packages/daemon/src/domain/types.js`.
|
||||
## (c) Structural relationship diagram
|
||||
|
||||
### 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, 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`.
|
||||
Built from each module's `siblings` / `prerequisite-reads` frontmatter.
|
||||
`daemon-core.md` is the spine (prerequisite for almost every architecture
|
||||
module); `shell-and-routing.md` is the UI spine.
|
||||
|
||||
### Domain: Packages And Install Engine
|
||||
- `packages/daemon/src/domain/conflict-detector.ts`: Content-aware conflict classification shared by package installs and rebooted projection planning. Exports: GuidanceConflictMeta, RefinedInstallPlan, classifyResourceProjection, detectConflicts. Related: `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/projection-planner.js`.
|
||||
- `packages/daemon/src/domain/install-engine.ts`: Journaled apply/rollback engine for package installs. Exports: EngineFsOps, InstallEngine, InstallResult, RollbackResult. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, `packages/daemon/src/domain/install-repository.js`.
|
||||
- `packages/daemon/src/domain/install-planner.ts`: Computes classified package install entries and target paths. Exports: ActionClassification, ConflictInfo, InstallPlan, InstallPlanEntry, InstallPlanner, PlanOptions. Related: `packages/daemon/src/domain/package-resolver.js`, `packages/daemon/src/domain/role-resolver.js`.
|
||||
- `packages/daemon/src/domain/install-policy.ts`: Approval-policy gate for package install plans. Exports: PolicyRejection, PolicyResult, applyPolicy. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-planner.js`.
|
||||
- `packages/daemon/src/domain/install-repository.ts`: SQLite-backed repository for package install records and journal entries. Exports: Install, InstallRepository, InstallSummary, JournalEntry. Related: none.
|
||||
- `packages/daemon/src/domain/install-verifier.ts`: Post-apply verification for install records and filesystem targets. Exports: Check, EntryVerification, InstallVerifier, VerificationResult, VerifierFsOps. Related: `packages/daemon/src/domain/install-repository.js`, `packages/daemon/src/domain/package-repository.js`.
|
||||
- `packages/daemon/src/domain/package-install-service.ts`: Top-level package validate/plan/install/rollback service used by routes and bootstrap. Exports: PackageInstallOutcome, PackageInstallService. Related: `packages/daemon/src/domain/conflict-detector.js`, `packages/daemon/src/domain/install-engine.js`, `packages/daemon/src/domain/install-planner.js`, `packages/daemon/src/domain/install-policy.js`, +4 more.
|
||||
- `packages/daemon/src/domain/package-manifest.ts`: Parser and validator for `package.yaml` manifests. Exports: AgentExport, GuidanceExport, HookExport, InstallPolicy, McpExport, PackageExports, PackageManifest, PackageRequirements, RoleDefinition, SkillExport, ValidationResult, VerificationCheck, VerificationConfig, normalizeManifest, parseManifest, serializeManifest, validateManifest. Related: none.
|
||||
- `packages/daemon/src/domain/package-repository.ts`: SQLite-backed repository for package metadata and install summaries. Exports: Package, PackageRepository, PackageSummary. Related: none.
|
||||
- `packages/daemon/src/domain/package-resolve-helper.ts`: Shared helper that wraps package resolution for routes and orchestrators. Exports: ResolveResult, resolvePackage. Related: `packages/daemon/src/domain/package-manifest.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/package-resolver.ts`: Resolves local package sources and computes manifest hashes. Exports: FsOps, PackageResolver, ResolvedPackage. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
- `packages/daemon/src/domain/path-safety.ts`: Shared safe-path validator used by manifests and startup config. Exports: validateSafePath. Related: none.
|
||||
- `packages/daemon/src/domain/role-resolver.ts`: Filters package exports according to selected roles/runtime scope. Exports: DeferredExport, ResolvedExports, resolveExports. Related: `packages/daemon/src/domain/package-manifest.js`.
|
||||
```
|
||||
README.md (entry — prerequisite for all)
|
||||
│
|
||||
┌───────────────┴───────────────┐
|
||||
▼ ▼
|
||||
architecture/ ui/
|
||||
│ │
|
||||
daemon-core.md ◀── (prerequisite spine for the column below)
|
||||
│ │ │ │
|
||||
│ │ │ └──▶ adapters-and-runtimes.md
|
||||
│ │ │ (five-method contract + resume honesty;
|
||||
│ │ │ consumed by agent-spec startup orchestration)
|
||||
│ │ └────────────────────────────┐
|
||||
│ └──────────────┐ │
|
||||
▼ ▼ ▼
|
||||
coordination- agent-spec-and- transport-and-
|
||||
primitive.md startup.md transcripts.md
|
||||
│ │ │ │
|
||||
▼ ▼ │ ▼
|
||||
workflow- mission- │ lifecycle-snapshot-restore.md
|
||||
runtime.md control.md │ ▲ (consumes persisted replay context)
|
||||
│ │ │
|
||||
└────┬─────┘ ├──▶ packaging-bootstrap-bundles.md
|
||||
▼ │ ▲
|
||||
architecture-rules- │ │ (0.3.x reusable starter-state cluster)
|
||||
and-event-system.md │ ▼
|
||||
(consumes all └──▶ plugin-agent-image-context-pack.md
|
||||
PL-004/005 events)
|
||||
|
||||
### Domain: Bootstrap, Discovery, Bundles
|
||||
- `packages/daemon/src/domain/bootstrap-orchestrator.ts`: Staged bootstrap workflow that composes runtime verification, requirement probes, external installs, package installs, legacy rig instantiation, direct pod-aware rig handling, and schema-version-2 pod-bundle delegation. Exports: BootstrapMode, BootstrapOptions, BootstrapOrchestrator, BootstrapResult, BootstrapStageResult. Related: `packages/daemon/src/domain/bootstrap-repository.js`, `packages/daemon/src/domain/bootstrap-types.js`, `packages/daemon/src/domain/bundle-source-resolver.js`, `packages/daemon/src/domain/external-install-executor.js`, +10 more.
|
||||
- `packages/daemon/src/domain/bootstrap-repository.ts`: SQLite-backed repository for bootstrap runs and action journals. Exports: BootstrapRepository. Related: `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/bootstrap-types.ts`: Shared type definitions for bootstrap data. Exports: ActionKind, ActionStatus, BootstrapAction, BootstrapRun, BootstrapStatus, RuntimeStatus, RuntimeVerification. Related: none.
|
||||
- `packages/daemon/src/domain/bundle-archive.ts`: Archive pack/unpack and digest verification for `.rigbundle` files. Exports: pack, unpack, verifyArchiveDigest. Related: `packages/daemon/src/domain/bundle-integrity.js`, `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-assembler.ts`: Legacy rig-bundle assembler for package-based bundles. Exports: AssembleOptions, AssemblerFsOps, LegacyBundleAssembler, PackageInput. Related: `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`.
|
||||
- `packages/daemon/src/domain/bundle-integrity.ts`: Per-file integrity manifest generation and verification for bundles. Exports: IntegrityFsOps, VerifyResult, computeIntegrity, verifyIntegrity, writeIntegrity. Related: `packages/daemon/src/domain/bundle-types.js`.
|
||||
- `packages/daemon/src/domain/bundle-source-resolver.ts`: Dual-stack bundle resolver containing legacy package-bundle and rebooted pod-bundle extraction flows. Exports: BundleResolvedSource, LegacyBundleSourceResolver, PodBundleResolvedSource, PodBundleSourceResolver. Related: `packages/daemon/src/domain/bundle-archive.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/package-resolve-helper.js`, `packages/daemon/src/domain/package-resolver.js`.
|
||||
- `packages/daemon/src/domain/bundle-types.ts`: Bundle manifest types plus parse/validate/serialize helpers for schema v1 and schema v2 bundles. Exports: BundleIntegrity, LegacyBundleManifest, LegacyBundlePackageEntry, PodBundleAgentEntry, PodBundleAgentImportEntry, PodBundleManifest, isRelativeSafePath, normalizeLegacyBundleManifest, parseLegacyBundleManifest, parsePodBundleManifest, serializeLegacyBundleManifest, serializePodBundleManifest, validateLegacyBundleManifest, validatePodBundleManifest. Related: none.
|
||||
- `packages/daemon/src/domain/claim-service.ts`: Claims discovered sessions into managed rig/node state. Exports: ClaimResult, ClaimService. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/domain/discovery-coordinator.ts`: Coordinates tmux scans, fingerprinting, enrichment, persistence, and event emission for discovery. Exports: DiscoveryCoordinator. Related: `packages/daemon/src/domain/discovery-repository.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/session-enricher.js`, +3 more.
|
||||
- `packages/daemon/src/domain/discovery-repository.ts`: SQLite-backed repository for discovered session records. Exports: DiscoveryRepository, UpsertData. Related: `packages/daemon/src/domain/discovery-types.js`.
|
||||
- `packages/daemon/src/domain/discovery-types.ts`: Shared type definitions for discovery data. Exports: Confidence, DiscoveredSession, DiscoveryStatus, RuntimeHint, SessionOrigin. Related: none.
|
||||
- `packages/daemon/src/domain/external-install-executor.ts`: Runs approved external install actions and journals execution results. Exports: ExecutionResult, ExecutionSummary, ExternalInstallExecutor, TaggedAction. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/external-install-planner.js`.
|
||||
- `packages/daemon/src/domain/external-install-planner.ts`: Turns requirement-probe results into auto-approvable or manual external install actions. Exports: ApprovalClassification, ExternalInstallAction, ExternalInstallPlan, ExternalInstallPlanner. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/domain/requirements-probe.js`.
|
||||
- `packages/daemon/src/domain/pod-bundle-assembler.ts`: Rebooted bundle assembler that vendors AgentSpecs, imports, rig startup files, and rewritten `agent_ref`s. Exports: PodAssembleOptions, PodAssembleResult, PodAssemblerFsOps, PodBundleAssembler. Related: `packages/daemon/src/domain/agent-resolver.js`, `packages/daemon/src/domain/bundle-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-schema.js`, +1 more.
|
||||
- `packages/daemon/src/domain/requirements-probe.ts`: Requirement-probe registry and probe result types used during bootstrap. Exports: ProbeResult, ProbeStatus, RequirementSpec, RequirementsProbeRegistry. Related: `packages/daemon/src/adapters/shell-quote.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/runtime-verifier.ts`: Verifies runtime/tool availability and persists verification results. Exports: RuntimeVerifier. Related: `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/bootstrap-types.js`.
|
||||
- `packages/daemon/src/domain/session-enricher.ts`: Adds repository/config hints to discovered sessions based on filesystem inspection. Exports: EnrichmentResult, SessionEnricher. Related: none.
|
||||
- `packages/daemon/src/domain/session-fingerprinter.ts`: Infers runtime/session identity from tmux, cmux, process, and pane evidence. Exports: FingerprintEvidence, FingerprintResult, SessionFingerprinter. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`, `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/tmux-discovery-scanner.js`.
|
||||
- `packages/daemon/src/domain/tmux-discovery-scanner.ts`: Low-level tmux scanner that enumerates panes/sessions for discovery. Exports: ScanResult, ScannedPane, TmuxDiscoveryScanner. Related: `packages/daemon/src/adapters/tmux.js`.
|
||||
workspace-primitive.md ──▶ content-surfaces.md
|
||||
(PL-007 declaration) (filesystem read/write layer on top)
|
||||
│ │
|
||||
└──────────┬───────────────┘
|
||||
▼ (UI counterpart)
|
||||
ui/project-and-for-you.md
|
||||
|
||||
### Domain: North Star Operator Layer
|
||||
- `packages/daemon/src/domain/node-inventory.ts`: Universal node-level projection consumed by CLI, UI, and MCP. Core fields (session name, status, startup status, restore outcome, attach command) plus extended fields (model, agent ref, startup files, installed resources). Exports: NodeInventoryService. Related: `packages/daemon/src/domain/types.js`, `packages/daemon/src/domain/runtime-adapter.js`.
|
||||
- `packages/daemon/src/domain/demo-rig-selector.ts`: Helper for existing-rig power-on — finds the right rig by name from ps summary entries. Exports: selectCurrentRigSummary. Related: none.
|
||||
- `packages/daemon/src/domain/draft-rig-generator.ts`: Synthesizes a candidate RigSpec YAML from discovered sessions — groups by CWD, suggests pods, sanitizes IDs. Exports: generateDraftRig. Related: `packages/daemon/src/domain/discovery-types.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/types.js`.
|
||||
- `packages/daemon/src/domain/native-resume-probe.ts`: Honest assessment of whether a harness actually resumed vs fresh-launched — probes pane command and content for runtime-specific indicators. Exports: NativeResumeProbeResult, assessNativeResumeProbe, buildNativeResumeCommand, isProbeShellReady. Related: `packages/daemon/src/adapters/shell-quote.js`.
|
||||
- `packages/daemon/src/domain/resume-metadata-refresher.ts`: Post-launch resume token capture — reads Claude conversation IDs from state files and Codex thread IDs from SQLite. Exports: ResumeMetadataRefresher. Related: `packages/daemon/src/domain/codex-thread-id.js`, `packages/daemon/src/domain/native-resume-probe.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/codex-thread-id.ts`: Codex-specific thread ID extraction from the Codex SQLite state database. Exports: defaultResolveHomeDirByPid, readCodexThreadIdFromCandidateHomes, ResolveHomeDirByPid. Related: none.
|
||||
shell-and-routing.md ──▶ topology.md
|
||||
│ └──▶ project-and-for-you.md ◀──▶ architecture/mission-control.md
|
||||
└──────────────▶ library-specs-and-design-system.md ──▶ ../DESIGN.md (pointer)
|
||||
```
|
||||
|
||||
### Domain: Post-North-Star Transport and History
|
||||
- `packages/daemon/src/domain/session-transport.ts`: Communication primitives — send/capture/broadcast with session resolution (canonical + legacy names), mid-work detection via pane content heuristics, honest error reporting, pod/rig/global targeting. Exports: SessionTransport, TargetSpec, SendResult, CaptureResult, BroadcastResult, SendOpts. Related: `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/domain/transcript-store.ts`: Pipe-pane transcript management — ANSI stripping on read (preserves raw on disk), boundary markers for session lifecycle events, readTail, grep. Filesystem-backed, not SQLite. Exports: TranscriptStore, TranscriptStoreOpts. Related: none (filesystem-only).
|
||||
- `packages/daemon/src/domain/history-query.ts`: Unified transcript + chat search — prefers `rg` when available, falls back to `grep -E`, surfaces which backend was used. Exports: HistoryQuery, SearchResult, ChatSearchResult, ExecDep. Related: `packages/daemon/src/domain/transcript-store.js`.
|
||||
- `packages/daemon/src/domain/ask-service.ts`: Context engineering evidence pack — gathers topology snapshot, transcript excerpts, chat history excerpts, restore metadata. Returns structured context for the agent to reason about. Does NOT call an external LLM. Exports: AskService, AskDeps. Related: `packages/daemon/src/domain/ps-projection.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/history-query.js`.
|
||||
- `packages/daemon/src/domain/chat-repository.ts`: Durable rig-scoped chat messages — CRUD for chat_messages table, monotonic ULID IDs, rig-scoped queries. Exports: ChatRepository, ChatMessage. Related: none (SQLite-only).
|
||||
Reading-order rule: open a module's `prerequisite-reads` first
|
||||
(`README.md`, then `daemon-core.md` for most architecture modules;
|
||||
`README.md` then `shell-and-routing.md` for most ui modules).
|
||||
|
||||
### Domain: Coordination Primitive (PL-004 Phase A)
|
||||
- `packages/daemon/src/domain/stream-store.ts`: L1 coordination stream — append-only intake/audit root. Idempotent emit on stream_item_id, chronological list with cursor pagination, soft archive. Exports: StreamStore, StreamItem, StreamEmitInput, StreamListOptions. Related: `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/domain/queue-repository.ts`: L3 coordination queue — create, claim/unclaim, update with hot-potato strict-rejection, transactional handoff (close source + create new in one transaction), pod-fallback rerouting, overdue lookup. POC qitem_id format preserved. Exports: QueueRepository, QueueRepositoryError, QueueItem, QueueState, QueuePriority, QueueCreateInput, QueueUpdateInput, QueueHandoffInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-transition-log.js`, `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/queue-transition-log.ts`: Append-only state transition log for queue_items. No UPDATE / DELETE from domain code. Exports: QueueTransitionLog, QueueTransition, QueueTransitionInput. Related: `packages/daemon/src/domain/hot-potato-enforcer.js`.
|
||||
- `packages/daemon/src/domain/hot-potato-enforcer.ts`: Pure validator for the load-bearing hot-potato API contract — state=done requires closure_reason from the 6-value enum. Tier→SLA mapping for closure_required_at. Exports: CLOSURE_REASONS, ClosureReason, validateClosure, isClosureReason, computeClosureRequiredAt. Related: none.
|
||||
- `packages/daemon/src/domain/inbox-handler.ts`: Mailbox handler — authenticated drop (idempotent on inbox_id), absorb (promote pending entry to a queue_item), deny (record reason). Authentication is a pluggable constructor hook. Exports: InboxHandler, InboxHandlerError, InboxEntry, InboxState, InboxDropInput. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/queue-repository.js`.
|
||||
- `packages/daemon/src/domain/outbox-handler.ts`: Sender-side outbox — symmetric to inbox; idempotent record, mark delivered/failed, list. No event-bus events emitted. Exports: OutboxHandler, OutboxHandlerError, OutboxEntry, OutboxDeliveryState, OutboxRecordInput. Related: none (SQLite-only).
|
||||
## (d) Fast lookup by use-case
|
||||
|
||||
### Routes
|
||||
- `packages/daemon/src/routes/adapters.ts`: Hono route module for adapters API endpoints. Exports: adaptersRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/adapters/tmux.js`.
|
||||
- `packages/daemon/src/routes/agents.ts`: Hono route module for AgentSpec validation. POST `/api/agents/validate` using `validateAgentSpecFromYaml`. Exports: agentsRoutes. Related: `packages/daemon/src/domain/spec-validation-service.js`.
|
||||
- `packages/daemon/src/routes/bootstrap.ts`: Hono route module for bootstrap API endpoints. Exports: bootstrapRoutes. 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/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`.
|
||||
- `packages/daemon/src/routes/rigs.ts`: Hono route module for rig CRUD, graph projection, summary, delete, and existing-rig power-on by rig id. Exports: rigsRoutes. Related: `packages/daemon/src/domain/event-bus.js`, `packages/daemon/src/domain/graph-projection.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/rigspec.ts`: Hono route module for dual-format RigSpec import, validate, preflight, materialize, and export. Pod-aware import/preflight/materialize require `X-Rig-Root`; export emits v0.2 pod-aware YAML/JSON when the live rig has pods. Exports: handleExportJson, handleExportYaml, rigspecImportRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/rigspec-codec.js`, `packages/daemon/src/domain/rigspec-exporter.js`, `packages/daemon/src/domain/rigspec-instantiator.js`, +2 more.
|
||||
- `packages/daemon/src/routes/sessions.ts`: Hono route module for rig sessions and node inventory/detail/launch/focus. `GET /api/rigs/:rigId/nodes/:logicalId` is the backend contract for the live node drawer and full-details workspace. Exports: nodesRoutes, sessionsRoutes. Related: `packages/daemon/src/adapters/cmux.js`, `packages/daemon/src/domain/node-inventory.js`, `packages/daemon/src/domain/node-launcher.js`, `packages/daemon/src/domain/rig-repository.js`, `packages/daemon/src/domain/session-registry.js`.
|
||||
- `packages/daemon/src/routes/snapshots.ts`: Hono route module for snapshots API endpoints. Exports: restoreRoutes, snapshotsRoutes. Related: `packages/daemon/src/domain/errors.js`, `packages/daemon/src/domain/restore-orchestrator.js`, `packages/daemon/src/domain/snapshot-capture.js`, `packages/daemon/src/domain/snapshot-repository.js`.
|
||||
- `packages/daemon/src/routes/transport.ts`: Hono route module for communication primitives (`POST /send`, `/capture`, `/broadcast`). Resolves session targets, delegates to SessionTransport. Exports: transportRoutes. Related: `packages/daemon/src/domain/session-transport.js`.
|
||||
- `packages/daemon/src/routes/transcripts.ts`: Hono route module for transcript access (`GET /:session/tail`, `/:session/grep`). Resolves session to rig/node, delegates to TranscriptStore. Exports: transcriptRoutes. Related: `packages/daemon/src/domain/transcript-store.js`, `packages/daemon/src/domain/rig-repository.js`.
|
||||
- `packages/daemon/src/routes/ask.ts`: Hono route module for context evidence packs (`POST /`). Delegates to AskService. Exports: askRoutes. Related: `packages/daemon/src/domain/ask-service.js`.
|
||||
- `packages/daemon/src/routes/chat.ts`: Hono route module for durable rig chat (`GET /watch` SSE stream, `POST /send`, `GET /history`, `POST /topic`). Delegates to ChatRepository, streams via SSE. Exports: chatRoutes. Related: `packages/daemon/src/domain/chat-repository.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/stream.ts`: Hono route module for the PL-004 Phase A coordination L1 stream (`POST /emit`, `GET /list`, `GET /:id`, `POST /:id/archive`, `GET /watch` SSE). Host-scoped (no rigId). Exports: streamRoutes. Related: `packages/daemon/src/domain/stream-store.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/queue.ts`: Hono route module for the PL-004 Phase A coordination L3 queue plus inbox/outbox (`POST /create`, `POST /:id/{claim,unclaim,update,handoff,fallback}`, `GET /list`, `GET /overdue`, `GET /:id`, `GET /:id/transitions`, inbox/outbox subroutes, `GET /watch` SSE). Surfaces hot-potato strict-rejection structured errors with validReasons enum. Exports: queueRoutes. Related: `packages/daemon/src/domain/queue-repository.js`, `packages/daemon/src/domain/inbox-handler.js`, `packages/daemon/src/domain/outbox-handler.js`, `packages/daemon/src/domain/event-bus.js`.
|
||||
- `packages/daemon/src/routes/spec-review.ts`: Hono route module for structured review over raw RigSpec / AgentSpec YAML. Accepts `sourceState` and returns review models or validation errors. Exports: specReviewRoutes. Related: `packages/daemon/src/domain/spec-review-service.js`.
|
||||
- `packages/daemon/src/routes/spec-library.ts`: Hono route module for the filesystem-backed spec library (`list`, `get`, `review`, `sync`). 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`.
|
||||
| I need to know… | → see |
|
||||
|---|---|
|
||||
| How the daemon boots / `createDaemon` wiring / migration set / route mounts | `architecture/daemon-core.md` |
|
||||
| The runtime-adapter contract / how a harness is launched or resumed in tmux | `architecture/adapters-and-runtimes.md` |
|
||||
| Where Claude resume honesty lives / resumed-vs-fresh assessment | `architecture/adapters-and-runtimes.md` |
|
||||
| Where queue closure is enforced / hot-potato contract / durable handoff | `architecture/coordination-primitive.md` |
|
||||
| How workflow specs project on closure / transactional-scribe / watchdog policies | `architecture/workflow-runtime.md` |
|
||||
| The Mission Control views/verbs / queue-observability / action audit | `architecture/mission-control.md` |
|
||||
| AgentSpec/RigSpec types, profile resolution, startup layering, whoami/bind/adopt | `architecture/agent-spec-and-startup.md` |
|
||||
| Snapshot/restore, restore honesty, restore-check / restore-packet probes | `architecture/lifecycle-snapshot-restore.md` |
|
||||
| rig send/capture/broadcast, transcripts, durable chat, `rig ask`, MCP-name vs tmux-key | `architecture/transport-and-transcripts.md` |
|
||||
| The workspace primitive (root/repos), `target_repo` scope gating, missions/slices indexing | `architecture/workspace-primitive.md` |
|
||||
| The file browser path safety, atomic writes + edit audit, PROGRESS tree, Steering composer | `architecture/content-surfaces.md` |
|
||||
| The composed slice/mission review (`/api/review/*`), staged-approval locks, proof artifacts + `verified`, freeze export, ranged media | `architecture/living-notes-review.md` |
|
||||
| Plugin discovery / agent images / context packs / Claude auto-compaction enforcer | `architecture/plugin-agent-image-context-pack.md` |
|
||||
| Bundle assembly, bundle install / `/api/up`, BootstrapOrchestrator, legacy install seams | `architecture/packaging-bootstrap-bundles.md` |
|
||||
| The 25 architecture rules / RigEvent union / SSE delivery / compatibility limits | `architecture/architecture-rules-and-event-system.md` |
|
||||
| The UI shell, the real route tree, the shared detail drawer | `ui/shell-and-routing.md` |
|
||||
| The topology graph/table/terminal views, activity-ring / hot-potato visuals | `ui/topology.md` |
|
||||
| The For-You attention feed, Project scope pages, the Dashboard landing | `ui/project-and-for-you.md` |
|
||||
| The vellum brand system on the destination surfaces (0.3.1 brand identity) | `ui/project-and-for-you.md` (vellum brand = **0.3.1** per slice-00 0.3.0-GT seam (b); slice-00 §2 row 1 = vellum-primitives → brand-identity, row 2 = destination-model → polished-destinations) |
|
||||
| The Library `/specs` UI + spec-review/spec-library/live-identity flows | `ui/library-specs-and-design-system.md` |
|
||||
| The full `rig` CLI surface | `cli-reference.md` |
|
||||
| The visual / brand / design-system spec | `../DESIGN.md` (repo `docs/` root) |
|
||||
| Which frontmatter these docs use + the as-built-unique field | `frontmatter-schema.md` |
|
||||
|
||||
## CLI
|
||||
## (e) Source-root pointer table
|
||||
|
||||
### 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 (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`.
|
||||
Module → primary `packages/*/src/...` roots. This replaces the old per-file
|
||||
`Exports:`/`Related:` dump: it points at the code, it does not transcribe it.
|
||||
Source-grounding for each claim lives inside the module with file:line cites.
|
||||
|
||||
### Commands
|
||||
- `packages/cli/src/commands/agent.ts`: Commander command module for `agent`. Subcommands: `validate` (POST `/api/agents/validate`). Supports `--json`. Exports: AgentDeps, agentCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bootstrap.ts`: Commander command module for `bootstrap`. Supports library-name resolution before falling back to raw source refs. Exports: bootstrapCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/commands/daemon.js`, `packages/cli/src/commands/specs.js`, `packages/cli/src/commands/status.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/bind.ts`: Commander command module for `bind` — bind a discovered session to an existing logical node. Exports: bindCommand. 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/bundle.ts`: Commander command module for `bundle`. Supports dual-format bundle create/inspect/install, `--json`, and pod-aware `--rig-root` on create. Exports: bundleCommand. 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/claim.ts`: Commander command module for `claim`. Exports: claimCommand. 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/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.
|
||||
- `packages/cli/src/commands/package.ts`: Commander command module for the legacy package-management surface. Exports: packageCommand. 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/ps.ts`: Commander command module for `ps`. Exports: psCommand. 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/requirements.ts`: Commander command module for `requirements`. Exports: requirementsCommand. 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/restore.ts`: Commander command module for `restore`. Exports: restoreCommand. 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/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`. `add` accepts single YAML files and full spec directories containing `rig.yaml` or `agent.yaml`. Help/examples and not-found guidance explicitly call out managed apps. Exports: resolveLibrarySpec, specsCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/ui.ts`: Commander command module for `ui`. Exports: UiDeps, uiCommand. Related: `packages/cli/src/commands/daemon.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/up.ts`: Commander command module for `up`. Supports spec paths, bundle paths, rig-name restore, and library-name resolution; fails loudly on rig-name vs library-name ambiguity. 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`.
|
||||
| Module | Primary source roots |
|
||||
|---|---|
|
||||
| `architecture/daemon-core.md` | `packages/daemon/src/{startup.ts,server.ts,index.ts}`, `packages/daemon/src/db/migrations/`, `packages/cli/src/index.ts` |
|
||||
| `architecture/adapters-and-runtimes.md` | `packages/daemon/src/domain/runtime-adapter.ts`, `packages/daemon/src/adapters/{claude-code-adapter,codex-runtime-adapter,terminal-adapter}.ts`, `packages/daemon/src/domain/{native-resume-probe,resume-metadata-refresher,codex-thread-id}.ts` |
|
||||
| `architecture/coordination-primitive.md` | `packages/daemon/src/domain/{stream-store,queue-repository,queue-transition-log,hot-potato-enforcer,inbox-handler,outbox-handler}.ts`, `packages/daemon/src/routes/{stream,queue}.ts` |
|
||||
| `architecture/workflow-runtime.md` | `packages/daemon/src/domain/{workflow-projector,workflow-runtime,workflow-instance-store,workflow-spec-cache,workflow-step-trail-log,workflow-validator}.ts`, `packages/daemon/src/domain/policies/workflow-keepalive.ts`, `packages/daemon/src/routes/workflow.ts` |
|
||||
| `architecture/mission-control.md` | `packages/daemon/src/domain/mission-control/`, `packages/daemon/src/middleware/auth-bearer-token.ts`, `packages/daemon/src/routes/mission-control.ts`, `packages/daemon/src/db/migrations/037_mission_control_actions.ts` |
|
||||
| `architecture/agent-spec-and-startup.md` | `packages/daemon/src/domain/{agent-manifest,rigspec-schema,profile-resolver,startup-orchestrator,whoami-service,claim-service}.ts`, `packages/daemon/src/routes/{rigspec,whoami}.ts` |
|
||||
| `architecture/lifecycle-snapshot-restore.md` | `packages/daemon/src/domain/{restore-orchestrator,snapshot-capture,snapshot-repository,checkpoint-store}.ts`, `packages/daemon/src/routes/restore-check.ts`, `packages/cli/src/commands/restore-packet.ts` |
|
||||
| `architecture/transport-and-transcripts.md` | `packages/daemon/src/domain/{session-transport,transcript-store,history-query,ask-service,chat-repository}.ts`, `packages/daemon/src/routes/{transport,transcripts,ask,chat}.ts`, `packages/cli/src/mcp-server.ts` |
|
||||
| `architecture/workspace-primitive.md` | `packages/daemon/src/domain/workspace/`, `packages/cli/src/commands/config-init-workspace.ts`, `packages/daemon/src/db/migrations/{038,039}*`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/content-surfaces.md` | `packages/daemon/src/domain/{files,progress,steering}*`, `packages/daemon/src/routes/{files,progress,steering}.ts`, `packages/ui/src/routes.tsx` |
|
||||
| `architecture/living-notes-review.md` | `packages/daemon/src/domain/review/{types,compose,gather,freeze,brief-spine}.ts`, `packages/daemon/src/routes/{review,files,slices}.ts`, `packages/cli/src/commands/{scope,proof}.ts`, `packages/ui/src/components/review/` |
|
||||
| `architecture/plugin-agent-image-context-pack.md` | `packages/daemon/src/domain/plugin-discovery-service.ts`, `packages/daemon/src/domain/{agent-images,context-packs}/`, `packages/daemon/src/domain/claude-compaction-enforcer.ts` |
|
||||
| `architecture/packaging-bootstrap-bundles.md` | `packages/daemon/src/domain/{pod-bundle-assembler,bootstrap-orchestrator,bundle-*,package-*,install-*}.ts`, `packages/daemon/src/routes/{bundles,up}.ts` |
|
||||
| `architecture/architecture-rules-and-event-system.md` | `packages/daemon/src/domain/types.ts` (RigEvent union), `packages/daemon/src/routes/{stream,queue}.ts` (SSE watch), `packages/daemon/src/server.ts` (`/api/events`) |
|
||||
| `ui/shell-and-routing.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/AppShell.tsx` |
|
||||
| `ui/topology.md` | `packages/ui/src/components/topology/`, `packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts` |
|
||||
| `ui/project-and-for-you.md` | `packages/ui/src/routes.tsx`, `packages/ui/src/components/dashboard/vellum/` |
|
||||
| `ui/library-specs-and-design-system.md` | `packages/ui/src/components/specs/`, `packages/daemon/src/domain/{spec-review-service,spec-library-service}.ts`, `../DESIGN.md` (pointer) |
|
||||
| `cli-reference.md` | `packages/cli/src/index.ts`, `packages/cli/src/commands/*` |
|
||||
|
||||
### Commands: Post-North-Star Additions
|
||||
- `packages/cli/src/commands/send.ts`: Commander command module for `send` — send message to agent terminal via reliable tmux two-step. Supports `--verify` and `--force`. Exports: sendCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/capture.ts`: Commander command module for `capture` — capture agent pane content. Supports `--lines N` and `--json`. Exports: captureCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/broadcast.ts`: Commander command module for `broadcast` — send to all agents in a rig or pod. Reports per-session delivery status. Exports: broadcastCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/transcript.ts`: Commander command module for `transcript` — composable transcript access via `--tail N` and `--grep "pattern"`. Exports: transcriptCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/stream.ts`: Commander command module for `rig stream` (PL-004 Phase A) — coordination L1 intake stream. Subcommands: `emit`, `list`, `show`, `archive`. Speaks only to the daemon HTTP API; does not touch POC filesystem state. Exports: streamCommand, StreamDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/queue.ts`: Commander command module for `rig queue` (PL-004 Phase A) — coordination L3 queue plus inbox/outbox. Subcommands: `create`, `claim`, `unclaim`, `update`, `handoff`, `fallback`, `show`, `transitions`, `list`, `overdue`, `inbox-drop`, `inbox-absorb`, `inbox-deny`, `inbox-pending`, `outbox-record`, `outbox-list`. Hot-potato strict-rejection enforced at daemon. Exports: queueCommand, QueueDeps. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/config.ts`: Commander command module for `config` — read/write/reset config keys. Supports `--json`. Exports: configCommand. Related: `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/preflight.ts`: Commander command module for `preflight` — system readiness checks. Supports `--json`. Exports: preflightCommand. Related: `packages/cli/src/system-preflight.js`, `packages/cli/src/config-store.js`.
|
||||
- `packages/cli/src/commands/ask.ts`: Commander command module for `ask` — context evidence pack over transcripts + topology + chat. Supports `--json`. Exports: askCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
- `packages/cli/src/commands/chatroom.ts`: Commander command module for `chatroom` — durable group chat with subcommands `send`, `watch`, `history`, and `topic`. Exports: chatroomCommand. Related: `packages/cli/src/client.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
### CLI Infrastructure: Post-North-Star Additions
|
||||
- `packages/cli/src/config-store.ts`: Config file management at `~/.openrig/config.json` with legacy fallback from `~/.rigged/config.json`. 5 locked keys with env var overrides. Precedence: CLI flag > env var > config file > default. Exports: ConfigStore and config helpers. Related: none (filesystem-only).
|
||||
- `packages/cli/src/system-preflight.ts`: System readiness checks: Node.js version, tmux availability, writable directories, port availability. Every error follows the 3-part pattern (what + why + fix). Exports: SystemPreflight, PreflightCheck, PreflightResult. Related: `packages/cli/src/config-store.js`, `packages/cli/src/daemon-lifecycle.js`.
|
||||
|
||||
## UI
|
||||
|
||||
### App Entry And Routing
|
||||
- `packages/ui/src/App.tsx`: Thin app wrapper that renders the TanStack Router provider. Exports: App. Related: `packages/ui/src/routes.js`.
|
||||
- `packages/ui/src/globals.css`: Tailwind/theme tokens and global UI styling. Exports: none. Related: none.
|
||||
- `packages/ui/src/main.tsx`: Bootstraps the React app, fonts, and global styles. Exports: none. Related: `packages/ui/src/App.js`, `packages/ui/src/globals.css`.
|
||||
- `packages/ui/src/routes.tsx`: Declares the explorer-first route tree and root shell composition, including specs review/library routes, discovery/specs drawer bridges, and live-node full-details route `/rigs/$rigId/nodes/$logicalId`. Exports: router. Related: `packages/ui/src/components/AppShell.js`, `packages/ui/src/components/BootstrapWizard.js`, `packages/ui/src/components/BundleInspector.js`, `packages/ui/src/components/BundleInstallFlow.js`, +13 more.
|
||||
|
||||
### Components: Application Surfaces
|
||||
- `packages/ui/src/components/ActivityFeed.tsx`: Rolling global activity/event overlay driven by SSE. Exports: ActivityFeed. Related: `packages/ui/src/hooks/useActivityFeed.js`.
|
||||
- `packages/ui/src/components/AppShell.tsx`: Persistent explorer/workspace/drawer shell. Owns drawer selection context, discovery placement state, specs workspace provider, explorer visibility, and top-level discovery/specs/system toggles. Exports: DrawerSelectionContext, DrawerSelectionContextValue, DiscoveryPlacementContext, DiscoveryPlacementContextValue, ExplorerVisibilityContext, ExplorerVisibilityContextValue, NodeSelectionContext, AppShell, useDiscoveryPlacement, useDrawerSelection, useExplorerVisibility, useNodeSelection. Related: `packages/ui/src/components/ActivityFeed.js`, `packages/ui/src/components/Explorer.js`, `packages/ui/src/components/SharedDetailDrawer.js`, `packages/ui/src/components/SpecsWorkspace.js`, `packages/ui/src/hooks/useActivityFeed.js`, `packages/ui/src/hooks/useGlobalEvents.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/BootstrapWizard.tsx`: Multi-step bootstrap planning/apply flow UI for both rig specs and `.rigbundle` archives. Includes the spec-or-bundle entry field and bundle-inspector handoff link. Exports: BootstrapWizard. Related: `packages/ui/src/components/RequirementsPanel.js`, `packages/ui/src/hooks/useBootstrap.js`.
|
||||
- `packages/ui/src/components/BundleInspector.tsx`: Bundle inspection screen for manifest and integrity results. Renders `agents[]` plus schema-version badge for v2 pod bundles and legacy package content for v1 bundles. Exports: BundleInspector. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/BundleInstallFlow.tsx`: Bundle install plan/apply screen. Labels the install as pod-aware or legacy from bootstrap `resolve_spec.detail.source`. Exports: BundleInstallFlow. Related: `packages/ui/src/hooks/useBundles.js`.
|
||||
- `packages/ui/src/components/Dashboard.tsx`: Home dashboard that renders rig cards and high-level rig actions. Exports: Dashboard. Related: `packages/ui/src/components/RigCard.js`, `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/usePsEntries.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/DiscoveryOverlay.tsx`: Session discovery and claim surface. Exports: DiscoveryOverlay. Related: `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigSummary.js`.
|
||||
- `packages/ui/src/components/ImportFlow.tsx`: RigSpec validate/preflight/instantiate flow UI. Supports pod-aware `X-Rig-Root`, structured preflight diagnostics, and rebooted RigSpec terminology. Exports: ImportFlow. Related: `packages/ui/src/hooks/mutations.js`.
|
||||
- `packages/ui/src/components/AgentSpecValidateFlow.tsx`: Standalone validate surface for one AgentSpec YAML, backed by `/api/agents/validate`. Exports: AgentSpecValidateFlow. Related: none.
|
||||
- `packages/ui/src/components/WorkflowScaffold.tsx`: Shared page-shell primitives for multi-step/spec-review flows (`WorkflowHeader`, `WorkflowStepIndicator`, `WorkflowSection`, summary cards, code preview). Exports: WorkflowHeader, WorkflowStepIndicator, WorkflowSection, WorkflowSummaryGrid, WorkflowSummaryCard, WorkflowCodePreview. Related: none.
|
||||
- `packages/ui/src/components/WorkspacePage.tsx`: Shared center-workspace page shell used by specs/library/live-details surfaces. Exports: WorkspacePage. Related: none.
|
||||
- `packages/ui/src/components/SpecsWorkspace.tsx`: Local persisted workspace state for spec authoring/review tasks, drafts, and active task list. Exports: SpecsDraft, SpecsTask, SPECS_WORKSPACE_STORAGE_KEYS, SpecsWorkspaceProvider, useSpecsWorkspace. Related: none.
|
||||
- `packages/ui/src/components/PackageDetail.tsx`: Package detail screen with install history, journal, and rollback actions. Exports: PackageDetail. Related: `packages/ui/src/hooks/usePackageDetail.js`.
|
||||
- `packages/ui/src/components/PackageInstallFlow.tsx`: Legacy package validate/configure/plan/apply flow UI, explicitly labeled `(Legacy)`. Exports: PackageInstallFlow. Related: none.
|
||||
- `packages/ui/src/components/PackageList.tsx`: Legacy package summary list/grid screen presented under the rebooted `SPECS` navigation, with an empty-state handoff into RigSpec import. Exports: PackageList. Related: `packages/ui/src/hooks/usePackages.js`.
|
||||
- `packages/ui/src/components/RequirementsPanel.tsx`: Visualizes requirement probe results inside bootstrap flows. Exports: RequirementResult, RequirementsPanel. Related: none.
|
||||
- `packages/ui/src/components/RigCard.tsx`: Dashboard card for one rig with counts, status, and primary actions. Exports: RigCard, RigSummary. Related: `packages/ui/src/hooks/useCountUp.js`, `packages/ui/src/hooks/usePsEntries.js`.
|
||||
- `packages/ui/src/components/RigGraph.tsx`: Rig graph view that renders topology, discovery overlays, and focus interactions. Exports: RigGraph. Related: `packages/ui/src/components/RigNode.js`, `packages/ui/src/hooks/useDiscovery.js`, `packages/ui/src/hooks/useRigEvents.js`, `packages/ui/src/hooks/useRigGraph.js`.
|
||||
- `packages/ui/src/components/RigNode.tsx`: Custom graph-node renderer for React Flow topology cards. Exports: RigNode. Related: none.
|
||||
- `packages/ui/src/components/Sidebar.tsx`: App navigation sidebar. Exports: Sidebar. Related: none.
|
||||
- `packages/ui/src/components/SnapshotPanel.tsx`: Snapshot list/create/restore panel shown beside rig graphs. Exports: SnapshotPanel. Related: `packages/ui/src/hooks/mutations.js`, `packages/ui/src/hooks/useSnapshots.js`.
|
||||
|
||||
### 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/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. 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. 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. 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`.
|
||||
|
||||
### Components: UI Primitives
|
||||
- `packages/ui/src/components/ui/alert.tsx`: Wrapped/customized shadcn-style UI primitive for alert. Exports: Alert, AlertDescription, AlertTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/badge.tsx`: Wrapped/customized shadcn-style UI primitive for badge. Exports: Badge, BadgeProps, badgeVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/button.tsx`: Wrapped/customized shadcn-style UI primitive for button. Exports: Button, ButtonProps, buttonVariants. Related: none.
|
||||
- `packages/ui/src/components/ui/card.tsx`: Wrapped/customized shadcn-style UI primitive for card. Exports: Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle. Related: none.
|
||||
- `packages/ui/src/components/ui/dialog.tsx`: Wrapped/customized shadcn-style UI primitive for dialog. Exports: Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/input.tsx`: Wrapped/customized shadcn-style UI primitive for input. Exports: Input. Related: none.
|
||||
- `packages/ui/src/components/ui/separator.tsx`: Wrapped/customized shadcn-style UI primitive for separator. Exports: Separator. Related: none.
|
||||
- `packages/ui/src/components/ui/table.tsx`: Wrapped/customized shadcn-style UI primitive for table. Exports: Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow. Related: none.
|
||||
- `packages/ui/src/components/ui/tabs.tsx`: Wrapped/customized shadcn-style UI primitive for tabs. Exports: Tabs, TabsContent, TabsList, TabsTrigger. Related: none.
|
||||
- `packages/ui/src/components/ui/textarea.tsx`: Wrapped/customized shadcn-style UI primitive for textarea. Exports: Textarea. Related: none.
|
||||
- `packages/ui/src/components/ui/tooltip.tsx`: Wrapped/customized shadcn-style UI primitive for tooltip. Exports: Tooltip, TooltipContent, TooltipProvider, TooltipTrigger. Related: none.
|
||||
|
||||
### Hooks
|
||||
- `packages/ui/src/hooks/mutations.ts`: Shared mutation hooks for rig import, teardown, snapshot creation, and restore. `useImportRig` supports optional `rigRoot` and sends `X-Rig-Root` for pod-aware import. Exports: ImportError, useCreateSnapshot, useImportRig, useRestoreSnapshot, useTeardownRig. Related: none.
|
||||
- `packages/ui/src/hooks/useActivityFeed.ts`: SSE-backed activity-feed model and event-formatting helpers. Exports: ActivityEvent, UseActivityFeedResult, eventColor, eventRoute, eventSummary, formatRelativeTime, useActivityFeed. Related: none.
|
||||
- `packages/ui/src/hooks/useBootstrap.ts`: Bootstrap plan/apply hooks. Exports: BootstrapApplyResult, BootstrapPlanResult, useBootstrapApply, useBootstrapPlan. Related: none.
|
||||
- `packages/ui/src/hooks/useBundles.ts`: Bundle inspect/install hooks. Typed for v2 inspect responses (`schemaVersion`, `agents`, integrity) and install-stage `detail.source` labeling. Exports: BundleInstallResult, InspectResult, useBundleInspect, useBundleInstall. Related: none.
|
||||
- `packages/ui/src/hooks/useCountUp.ts`: Small animation helper for count-up telemetry. Exports: useCountUp. Related: none.
|
||||
- `packages/ui/src/hooks/useDiscovery.ts`: Discovery polling plus claim/bind/adopt mutations and draft-rig generation helpers. Exports: DiscoveredSession, useAdoptSession, useBindSession, useClaimSession, useDiscoveredSessions, useDiscoveredSessionsConditional, useDiscoveryPoll, useDiscoveryScan. Related: none.
|
||||
- `packages/ui/src/hooks/usePackageDetail.ts`: Package detail, journal, history, and rollback hooks. Exports: InstallSummary, JournalEntry, PackageInfo, useInstallHistory, useJournalEntries, usePackageInfo, useRollbackInstall. Related: none.
|
||||
- `packages/ui/src/hooks/usePackages.ts`: Package summary list hook. Exports: PackageSummary, usePackages. Related: none.
|
||||
- `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. 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 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.
|
||||
- `packages/ui/src/lib/graph-layout.ts`: Tree-layout helper for rig graph positioning. Exports: applyTreeLayout. Related: none.
|
||||
- `packages/ui/src/lib/instantiate-status-colors.ts`: Color-class lookup for instantiation states. Exports: getInstantiateStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/query-client.ts`: Shared TanStack Query client singleton. Exports: queryClient. Related: none.
|
||||
- `packages/ui/src/lib/restore-status-colors.ts`: Color-class lookup for restore states. Exports: getRestoreStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/status-colors.ts`: Generic rig/session status color mapping. Exports: getStatusColorClass. Related: none.
|
||||
- `packages/ui/src/lib/utils.ts`: Shared UI utility helpers, currently `cn` class merging. Exports: cn. Related: none.
|
||||
|
||||
### 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.
|
||||
> No per-file flat dump. File-level detail belongs in the code; this codemap
|
||||
> points at the source root and the module that explains it. The per-file
|
||||
> index anti-pattern (old codemap) is retired, not relocated.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
status: active
|
||||
topics: [knowledge-and-context, frontmatter]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Authoring or updating a doc under docs/as-built/. Tells you which
|
||||
frontmatter convention governs these docs and the one field that is
|
||||
unique to as-built docs.
|
||||
siblings: [README.md]
|
||||
prerequisite-reads: [README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# As-Built Frontmatter — Pointer + As-Built-Unique Field
|
||||
|
||||
Every doc under `docs/as-built/` carries YAML frontmatter. The shape is
|
||||
**not** defined here. It is the substrate-wide context-frontmatter
|
||||
convention; this doc only points to it and documents the one field that
|
||||
is unique to as-built docs.
|
||||
|
||||
## Canonical convention (do not duplicate — read it)
|
||||
|
||||
`openrig-work/conventions/frontmatter-for-context/README.md` is the
|
||||
authoritative schema: required floor (`kind` + `title` + `status` +
|
||||
`applies-when`), recommended fields (`topics`, `domains`, `siblings`,
|
||||
`prerequisite-reads`), and the controlled vocabularies for `kind:`,
|
||||
`topics:`, and `domains:`. Author against that convention. This doc adds
|
||||
nothing to it except the field below.
|
||||
|
||||
## `kind: as-built`
|
||||
|
||||
These docs use `kind: as-built`. That value is in the convention's
|
||||
controlled `kind:` vocabulary (first row of its "Controlled vocabulary —
|
||||
`kind:`" table, source `openrig/docs/as-built/*`). Using it is conformant,
|
||||
not an extension.
|
||||
|
||||
## `last-verified-against-source: <sha>` (as-built-unique)
|
||||
|
||||
As-built docs describe the running system. They drift when the source
|
||||
drifts. The as-built-unique field records the exact commit the doc was
|
||||
last verified against:
|
||||
|
||||
```yaml
|
||||
last-verified-against-source: 7eaf524c
|
||||
```
|
||||
|
||||
- **Value:** the short SHA of the source HEAD the doc was verified
|
||||
against at its last edit. Pair with `last-updated: <iso-date>`.
|
||||
- **Why it is here and not in the convention:** the convention is
|
||||
substrate-wide; most context docs do not track a source SHA. This
|
||||
field is meaningful only for docs that mirror code, i.e. as-built
|
||||
docs. It is the per-module drift-detection anchor the
|
||||
context-architecture-v1 mission dogfoods on its own production.
|
||||
- **Discipline:** when re-verifying a module against a newer HEAD,
|
||||
bump this field (and `last-updated`) and carry an inline
|
||||
`re-confirmed <file:line> @HEAD` annotation on each load-bearing
|
||||
claim that was re-checked.
|
||||
|
||||
## See also
|
||||
|
||||
- `openrig-work/conventions/frontmatter-for-context/README.md` — the canonical schema
|
||||
- `README.md` — the as-built corpus map of territory
|
||||
+36
-230
@@ -1,238 +1,44 @@
|
||||
# OpenRig UI As-Built
|
||||
---
|
||||
kind: as-built
|
||||
title: ui.md — Reorganized into the Modular As-Built Tree (Redirect Stub)
|
||||
status: superseded
|
||||
topics: [knowledge-and-context]
|
||||
domains: [engineering-advisor, operating-advisor, product-advisor]
|
||||
applies-when: |
|
||||
You followed an old reference to docs/as-built/ui.md. The monolith was
|
||||
reorganized (slice-08, context-architecture-v1) into the ui/ module folder.
|
||||
Go to README.md (map of territory) or codemap.md (use-case lookup), then the
|
||||
named ui/ module.
|
||||
siblings: [README.md, codemap.md, architecture.md]
|
||||
prerequisite-reads: []
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
This document describes the current UI architecture and component system. The
|
||||
brand/design rules live in `docs/DESIGN.md`; this file focuses on how the shipped UI
|
||||
is assembled.
|
||||
# ui.md was reorganized into the modular as-built tree
|
||||
|
||||
## Package Shape
|
||||
This single-file UI monolith was split into a folder of independently-loadable,
|
||||
frontmatter-tagged thematic modules (slice-08, `context-architecture-v1`). The
|
||||
canonical as-built description of the shipped operator UI now lives in `ui/`.
|
||||
This stub is a forwarding pointer so old references still resolve.
|
||||
|
||||
UI package: `packages/ui`
|
||||
**Start here:**
|
||||
|
||||
Primary implementation areas:
|
||||
- [`./README.md`](./README.md) — the map of territory and full module index.
|
||||
- [`./codemap.md`](./codemap.md) — navigation index: use-case lookup table and
|
||||
source-root pointers. Use this when you know *what* you need but not *which*
|
||||
module.
|
||||
|
||||
- `src/routes.tsx` - TanStack Router route tree
|
||||
- `src/components/AppShell.tsx` - global shell, rail, Explorer, drawer, preview stack
|
||||
- `src/components/ui/` - reusable vellum and base UI primitives
|
||||
- `src/components/graphics/` - runtime, tool, and actor marks
|
||||
- `src/components/topology/` - host/rig/pod/seat graph, table, terminal, activity
|
||||
- `src/components/project/` - workspace/mission/slice observability and project metadata primitives
|
||||
- `src/components/for-you/` - attention feed and actionable queue cards
|
||||
- `src/components/specs/` - Library and skill file viewing
|
||||
- `src/components/preview/` - terminal/session preview panes
|
||||
- `src/hooks/` - route data hooks and event consumption
|
||||
- `src/lib/` - classifiers, formatters, layout helpers, brand helpers, activity logic
|
||||
## Where the major content went
|
||||
|
||||
## Shell Model
|
||||
The UI content is now the **4 modules under `ui/`**:
|
||||
|
||||
`AppShell` wraps every route with:
|
||||
| Module | What moved here |
|
||||
|---|---|
|
||||
| [`ui/shell-and-routing.md`](ui/shell-and-routing.md) | Package shape, the `AppShell` shell model (rail / Explorer / center workspace / drawer / preview stack), the route tree, design primitives, the shared detail-drawer/viewer system, event/activity consumption. |
|
||||
| [`ui/topology.md`](ui/topology.md) | The topology surface — host hybrid graph, table/terminal views, the activity-ring / hot-potato visual language, terminal-preview popovers, navigation/overlay contracts. |
|
||||
| [`ui/project-and-for-you.md`](ui/project-and-for-you.md) | The operator destination surfaces — Project observability (workspace/mission/slice scope tabs), the For-You attention feed (5-card classifier + queue actions), the Dashboard landing on the vellum brand system. |
|
||||
| [`ui/library-specs-and-design-system.md`](ui/library-specs-and-design-system.md) | The Library (`/specs`) UI — specs/applications/skills surfaces, the graphics layer, the current design constraints, and the design-system pointer to `../DESIGN.md`. |
|
||||
|
||||
- Desktop rail
|
||||
- Route-aware Explorer sidebar
|
||||
- Center workspace
|
||||
- Shared detail drawer
|
||||
- Preview stack
|
||||
- Topology overlay state provider
|
||||
- Shared drawer selection context
|
||||
- Shared discovery placement context
|
||||
|
||||
The rail destinations are:
|
||||
|
||||
- Dashboard: `/`
|
||||
- Topology: `/topology`
|
||||
- For You: `/for-you`
|
||||
- Project: `/project`
|
||||
- Library: `/specs`
|
||||
- Settings: `/settings`
|
||||
- Advisor and Operator placeholders/settings links
|
||||
|
||||
The Explorer is contextual. It changes its tree based on the current destination
|
||||
rather than behaving as a generic file browser.
|
||||
|
||||
## Route Model
|
||||
|
||||
Main routes:
|
||||
|
||||
- `/` - Dashboard
|
||||
- `/topology` - host topology scope
|
||||
- `/topology/rig/$rigId` - rig scope
|
||||
- `/topology/pod/$rigId/$podName` - pod scope
|
||||
- `/topology/seat/$rigId/$logicalId` - seat scope with live node details
|
||||
- `/for-you` - attention feed
|
||||
- `/project` - workspace project scope
|
||||
- `/project/mission/$missionId` - mission scope
|
||||
- `/project/slice/$sliceId` - slice scope
|
||||
- `/specs` - Library
|
||||
- `/specs/applications` - application section
|
||||
- `/specs/skills/$skillToken` - skill viewer defaulting to `skill.md`
|
||||
- `/specs/skills/$skillToken/file/$fileToken` - specific skill file viewer
|
||||
- `/settings` - settings center
|
||||
- `/search` - audit/history view
|
||||
- `/lab/project-graphics-preview` - graphics preview lab route
|
||||
|
||||
Legacy routes remain for compatibility where needed, including package flows,
|
||||
bootstrap flows, validation flows, and `/rigs/$rigId`.
|
||||
|
||||
## Design Primitives
|
||||
|
||||
The UI moved from route-specific one-off panels toward reusable primitives.
|
||||
|
||||
Base primitives:
|
||||
|
||||
- `VellumCard` - paper card with optional dark header, hard shadow, registration marks
|
||||
- `VellumSheet` - side sheet/drawer with vellum-heavy surface and registration marks
|
||||
- `RegistrationMarks` - corner registration marks
|
||||
- `StatusPip` - semantic status only
|
||||
- `SectionHeader`, `EmptyState`, `Button`, `Tabs`, `Table`, and form controls
|
||||
|
||||
Graphics primitives:
|
||||
|
||||
- `RuntimeMark` and `RuntimeBadge`
|
||||
- `ToolMark` and `ToolBadge`
|
||||
- `ActorMark` and `OperatorMoodMark`
|
||||
- Runtime and tool normalization in `runtime-brand.ts` and `tool-brand.ts`
|
||||
|
||||
Project metadata primitives:
|
||||
|
||||
- `EventBadge`
|
||||
- `QueueStateBadge`
|
||||
- `TagPill`
|
||||
- `ActorChip`
|
||||
- `DateChip`
|
||||
- `FlowChips`
|
||||
- `ProofThumbnailGrid`
|
||||
- `ProofPacketHeader`
|
||||
|
||||
These primitives centralize visual language for runtime identity, queue state,
|
||||
actor flow, event labels, dates, tags, and proof images.
|
||||
|
||||
## Topology
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views.
|
||||
|
||||
Current topology pieces:
|
||||
|
||||
- `HostMultiRigGraph` renders the host-level hybrid React Flow graph.
|
||||
- `HybridAgentNode` renders compact agent cards with runtime badges, context
|
||||
percentage, token totals, activity state, terminal preview, and CMUX actions.
|
||||
- `HybridPodGroupNode` renders soft dashed pod frames.
|
||||
- `RigGroupNode` renders soft rig frames with registration marks and aggregate
|
||||
activity.
|
||||
- `ActivityRing` and card activity classes show active, needs-input, and blocked
|
||||
states.
|
||||
- `HotPotatoEdge` visualizes directional queue movement through the graph.
|
||||
- `TopologyTableView` mirrors topology data in a dense table.
|
||||
- `TopologyTerminalView` shows terminal-oriented topology state.
|
||||
- `TerminalPreviewPopover` provides black-glass quick terminal preview above the
|
||||
graph via a portal.
|
||||
|
||||
Important topology contracts:
|
||||
|
||||
- Graph/table/tree navigation resolves to seat URLs.
|
||||
- Host-level graph uses cross-rig node ID prefixing.
|
||||
- Expanded rig state is managed by `TopologyOverlayProvider`.
|
||||
- Graph data is lazy-fetched for expanded rigs.
|
||||
- Terminal preview actions are hover/focus visible.
|
||||
- The terminal popover must escape React Flow stacking contexts and stay within
|
||||
the viewport.
|
||||
|
||||
## Project Observability
|
||||
|
||||
Project is organized as a hierarchy:
|
||||
|
||||
- Workspace
|
||||
- Mission
|
||||
- Slice
|
||||
|
||||
The current route scope acts as a filter. Higher scopes show broader rollups;
|
||||
lower scopes show more specific story, queue, proof, artifact, test, and topology
|
||||
data.
|
||||
|
||||
Current project tabs:
|
||||
|
||||
- Story - narrative progress and queue body content
|
||||
- Progress - status and activity rollup
|
||||
- Artifacts - files, commits, proof, and outputs
|
||||
- Tests - proof material, screenshots, and verification detail
|
||||
- Queue - operational qitems with actions and drawer detail
|
||||
- Topology - workflow/agent diagram where available
|
||||
|
||||
Project primitives translate raw queue and event data into readable badges,
|
||||
chips, dates, flows, proof thumbnails, and action outcome panels.
|
||||
|
||||
## For You
|
||||
|
||||
For You is the attention-routing surface. It uses live event feed data plus queue
|
||||
details to classify cards into:
|
||||
|
||||
- Action Required
|
||||
- Approval
|
||||
- Shipped
|
||||
- Progress
|
||||
- Observation
|
||||
|
||||
The card renderer prioritizes readable body content, proof thumbnails, actor
|
||||
flow, friendly dates, and action affordances. Queue actions reuse existing
|
||||
mission-control action endpoints.
|
||||
|
||||
## Library And Skills
|
||||
|
||||
`/specs` is the Library route. It contains specs, applications, context packs,
|
||||
agent specs, agent images, and skills.
|
||||
|
||||
Skills are folder-based. The skill tree in Explorer treats each skill as a
|
||||
folder. Clicking a skill opens a skill detail route that defaults to `skill.md`
|
||||
case-insensitively. The skill detail page uses the existing `FileViewer` and
|
||||
does not add a second in-page file browser; file navigation belongs in the
|
||||
Explorer tree.
|
||||
|
||||
## Drawer And Viewer System
|
||||
|
||||
The shared detail drawer is used for transient focused detail:
|
||||
|
||||
- Queue item viewer
|
||||
- File viewer
|
||||
- Subspec preview
|
||||
- Other detail selections
|
||||
|
||||
Drawer triggers carry root/path provenance so the viewer can fetch and render the
|
||||
correct content. The drawer supports outside-click dismissal.
|
||||
|
||||
Image proof viewing is handled by `ProofImageViewer`, which respects the shell
|
||||
layout and uses black-glass styling.
|
||||
|
||||
## Event And Activity Consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app avoids
|
||||
spawning duplicate SSE connections for related surfaces.
|
||||
|
||||
Current activity consumers include:
|
||||
|
||||
- For You feed hydration
|
||||
- Topology activity rings and hot-potato movement
|
||||
- Activity feed/system surfaces
|
||||
- Rig event surfaces where appropriate
|
||||
|
||||
Reduced-motion preference is honored for pulse and packet animations.
|
||||
|
||||
## Graphics Layer
|
||||
|
||||
The graphics package is intentionally centralized:
|
||||
|
||||
- Runtime identity: Claude, Codex, Terminal, Unknown
|
||||
- Tool identity: CMUX, tmux, VS Code, Terminal, file, markdown, config, code,
|
||||
screenshot, proof, transcript, commit, folder, skill, video, trace
|
||||
- Actor identity: human/operator, runtime agents, terminal fallback
|
||||
|
||||
The goal is to make dense operator surfaces scannable without adding more raw
|
||||
metadata text.
|
||||
|
||||
## Current Design Constraints
|
||||
|
||||
- Use the shared primitives before creating new card styles.
|
||||
- Keep vellum and black-glass separate.
|
||||
- Keep status semantics separate from activity animation.
|
||||
- Do not duplicate runtime/tool brand detection outside the central helpers.
|
||||
- Do not add new daemon shape for UI polish unless the slice explicitly declares
|
||||
and verifies that boundary.
|
||||
- Do not make page-level content look like a raw terminal log unless the content
|
||||
is actually terminal output.
|
||||
The brand/design rules still live at `docs/DESIGN.md` (repo `docs/` root, by
|
||||
design — see `ui/library-specs-and-design-system.md`).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Library/Specs Surfaces + Design-System Pointer
|
||||
status: active
|
||||
topics: [specification-and-bundles, observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the Library (`/specs`) UI is assembled — the spec/skills/
|
||||
plugins surfaces, the spec-review + spec-library + live-identity flows that
|
||||
feed it — or where the canonical visual/design-system spec lives.
|
||||
siblings: [shell-and-routing.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Library/Specs Surfaces + Design-System Pointer
|
||||
|
||||
The Library destination keeps `/specs` as the route and presents the product
|
||||
label "Library": specs, applications, context packs, agent specs, agent
|
||||
images, plugins, and skills.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). Component/primitive names below are reconciled against
|
||||
> `packages/ui/src/components/` at HEAD — NOT taken on trust from `ui.md`'s
|
||||
> or `DESIGN.md`'s lists.
|
||||
|
||||
## 1. Library / specs surfaces
|
||||
|
||||
Components in `packages/ui/src/components/specs/` (re-confirmed at HEAD):
|
||||
|
||||
- `SpecsLibraryPage.tsx` — the `/specs` Library page (unified list:
|
||||
All / Apps / Rigs / Agents; service-backed entries render as `APP`).
|
||||
- `SpecsTable.tsx`, `SpecsTreeView.tsx` — list + Explorer tree.
|
||||
- `SkillsIndexPage.tsx`, `SkillDetailPage.tsx` — folder-based skills.
|
||||
The skill tree treats each skill as a folder; clicking a skill opens a
|
||||
detail route defaulting to `skill.md` case-insensitively, rendered via the
|
||||
shared `FileViewer` in the center workspace (no second in-page file
|
||||
browser — file navigation belongs in the Explorer tree).
|
||||
- `PluginsIndexPage.tsx`, `PluginDetailPage.tsx`, `AgentPluginsList.tsx` —
|
||||
the plugin surfaces.
|
||||
|
||||
> Drift-note (slice-00 0.3.0-GT seam a): the plugin primitive is a **0.3.1**
|
||||
> feature (git-ancestry proven; absent at `v0.3.0`, present at `v0.3.1`). Do
|
||||
> NOT back-attribute the `/specs/plugins`, `/plugins/$pluginId` routes or
|
||||
> these plugin components to 0.3.0. `ui.md` (which predates 0.3.x) has **no
|
||||
> narrative for plugins at all** — these surfaces are authored from source +
|
||||
> the slice-00 0.3.0-GT seam, not migrated.
|
||||
|
||||
## 2. Spec review / library / live-identity flows
|
||||
|
||||
These daemon-backed flows feed the Library UI (`architecture.md` §6
|
||||
"Spec review and spec library flow", "Live identity / specs UI flow",
|
||||
re-confirmed accurate at HEAD):
|
||||
|
||||
- **Raw YAML preview:** UI/CLI posts YAML to `/api/specs/review/rig` or
|
||||
`/api/specs/review/agent`; `SpecReviewService` parses/validates/returns
|
||||
structured review models; UI renders them through `RigSpecDisplay` /
|
||||
`AgentSpecDisplay` (the same primitives reused for draft preview, library
|
||||
review, and live full-details).
|
||||
- **Filesystem-backed library:** `SpecLibraryService` scans builtin + user
|
||||
roots (`packages/daemon/specs`, `~/.openrig/specs`, legacy fallback
|
||||
`~/.rigged/specs`); each YAML is classified via structured review;
|
||||
service-backed rigs are marked `hasServices`; `/api/specs/library` serves
|
||||
list/get/review/sync.
|
||||
- **CLI mirror:** `rig specs ls/show/preview/add/sync`; `rig specs add`
|
||||
installs a single YAML spec or a full spec directory; `rig up` /
|
||||
`rig bootstrap` resolve library names before other source kinds.
|
||||
- **Live identity:** Explorer/graph selection opens the shared right drawer
|
||||
(runtime-first node detail: live identity, peers, directional edges,
|
||||
transcript helpers, compact spec summary); `Open Full Details` navigates to
|
||||
`/rigs/$rigId/nodes/$logicalId` in the center workspace.
|
||||
|
||||
## 3. UI primitive inventory (reconciled against source)
|
||||
|
||||
> Drift-fix — `ui.md` "Design Primitives" (L75–107) omits `ProjectPill`.
|
||||
> `docs/DESIGN.md` L195 lists it; re-confirmed at HEAD it is a real export
|
||||
> (`packages/ui/src/components/project/ProjectMetaPrimitives.tsx:196`
|
||||
> `export function ProjectPill`). Reconciled inventory below is from source,
|
||||
> not from either doc's list.
|
||||
|
||||
- **Vellum/base** (`components/ui/`): `VellumCard`, `VellumSheet`,
|
||||
`RegistrationMarks`, `StatusPip`, `SectionHeader`, `EmptyState`,
|
||||
`Button`, `Tabs`, `Table`, form controls, plus `rig-stamp.tsx`
|
||||
(`RigStamp`) — present at HEAD; not in ui.md's list.
|
||||
- **Graphics** (`components/graphics/RuntimeMark.tsx`, exports re-confirmed):
|
||||
`RuntimeMark`, `RuntimeBadge`, `ToolMark`, `ToolBadge`, `ActorMark`,
|
||||
`OperatorMoodMark`; normalization in `lib/runtime-brand.ts` +
|
||||
`lib/tool-brand.ts`.
|
||||
- **Project metadata** (`components/project/ProjectMetaPrimitives.tsx`,
|
||||
exports re-confirmed): `ProjectPill` (`:196`), `EventBadge` (`:224`),
|
||||
`QueueStateBadge` (`:228`), `TagPill` (`:274`), `ActorChip` (`:283`),
|
||||
`DateChip` (`:303`), `FlowChips` (`:315`), `ProofThumbnailGrid` (`:334`),
|
||||
`ProofPacketHeader` (`:380`) — plus `QueueCountIcon` / `StatusDot` (in
|
||||
source, not in ui.md/DESIGN.md primitive lists).
|
||||
- **Viewers**: `FileViewer` (`components/drawer-viewers/FileViewer.tsx`),
|
||||
`MarkdownViewer` (`components/markdown/MarkdownViewer.tsx`),
|
||||
`ProofImageViewer`, `SessionPreviewPane` (`components/preview/`).
|
||||
|
||||
## 4. Design-system pointer (NOT a copy — Q1)
|
||||
|
||||
The canonical OpenRig visual/design system is **`docs/DESIGN.md`** (at the
|
||||
repo `docs/` root, NOT under `docs/as-built/`). Q1 is ratified: DESIGN.md
|
||||
stays at root, byte-identical, and this module **points to it rather than
|
||||
copying it**. DESIGN.md is the source for: Vellum-paper vs Black-glass
|
||||
surface languages, colour tokens (`packages/ui/src/globals.css` +
|
||||
`tailwind.config.ts`), typography (`font-body` Inter / `font-headline` Space
|
||||
Grotesk / `font-mono` JetBrains Mono), layout (48px rail + Explorer + center
|
||||
+ drawer + preview stack), the core-primitive list, the graphics system, and
|
||||
the interaction/motion/accessibility/do-and-do-not rules.
|
||||
|
||||
> Reconciliation note: DESIGN.md's primitive lists were independently
|
||||
> verified against `packages/ui/src/components/` at HEAD — every primitive
|
||||
> DESIGN.md names (Vellum, Graphics, Project-metadata, Topology, Preview)
|
||||
> resolves to a real export at HEAD. DESIGN.md is accurate and carries no
|
||||
> slice-00 numeric drift, so it is referenced as-is. Do not duplicate its
|
||||
> content here; read `docs/DESIGN.md` directly for the visual spec.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/DESIGN.md` — the canonical visual/design-system spec (pointer; do
|
||||
not copy).
|
||||
- `shell-and-routing.md` — the `/specs` route family + shell + drawer.
|
||||
- `../architecture/agent-spec-and-startup.md` — the spec parsing/resolution
|
||||
contract behind the review flows.
|
||||
- `../architecture/plugin-agent-image-context-pack.md` — the 0.3.1 content
|
||||
layer behind the plugins/agent-image/context-pack Library surfaces.
|
||||
- Source roots: `packages/ui/src/components/specs/`,
|
||||
`packages/daemon/src/domain/{spec-review-service,spec-library-service}*`.
|
||||
@@ -0,0 +1,433 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Project Observability, For You, Dashboard
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, product-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the operator-facing destination surfaces are built —
|
||||
the For-You attention feed (5-card classifier + verb actions), the
|
||||
Project workspace/mission/slice scope pages (tabbed rollups), and the
|
||||
Dashboard landing surface on the vellum brand system. Author-mode
|
||||
module — ui.md prose for these surfaces predates 0.3.x; every claim is
|
||||
sourced to file:line at HEAD.
|
||||
siblings: [shell-and-routing.md, ../architecture/mission-control.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-07-06
|
||||
---
|
||||
|
||||
# UI Project Observability, For You, Dashboard
|
||||
|
||||
The three operator-facing destination surfaces: **For You** (`/for-you`,
|
||||
the attention feed), **Project** (`/project*`, the workspace/mission/slice
|
||||
scope pages), and the **Dashboard** (`/`, the landing surface on the
|
||||
vellum brand system). All read live daemon state; none adds UI-local
|
||||
persistence beyond per-event soft-dismiss.
|
||||
|
||||
> **AUTHOR-HEAVY module.** `ui.md`'s Project Observability / For You /
|
||||
> Graphics-Layer sections are thin and predate the 0.3.1 dashboard/vellum
|
||||
> brand refresh. Every load-bearing claim carries `> Source: <file:line>
|
||||
> @HEAD`; ambiguity is declared as an OPEN item, never smoothed. Paths
|
||||
> are relative to `packages/ui/src/` unless prefixed `docs/`.
|
||||
>
|
||||
> Verified at HEAD `7eaf524c` (`git describe` → `v0.3.1-6-g7eaf524c`).
|
||||
> Package version **0.3.1**; HEAD carries 6 unreleased release-0.3.2
|
||||
> commits; no `v0.3.2` tag (daemon-core.md; slice-00 §1.1).
|
||||
|
||||
## 0. Release attribution (forensic seam — read first)
|
||||
|
||||
Re-verified at HEAD via `git cat-file -e <tag>:<path>`:
|
||||
|
||||
| Layer | Release | Forensic proof @HEAD |
|
||||
|---|---|---|
|
||||
| Project-observability foundation (For-You feed, feed-classifier, project scope pages, the Mission Control 7-verb vocabulary) | **0.3.0** | `v0.3.0:components/for-you/Feed.tsx`, `v0.3.0:lib/feed-classifier.ts`, `v0.3.0:components/project/ScopePages.tsx`, `v0.3.0:components/mission-control/components/VerbActions.tsx` all resolve at `v0.3.0` (slice-00 0.3.0-GT §1.5/§1.6/§1.7) |
|
||||
| Vellum **brand-identity system** the Dashboard/For-You now render on (`dashboard/vellum/*`: CornerBracket, VellumDestinationCard, marks, graphics, single-source-of-truth barrel) | **0.3.1, NOT 0.3.0** | `git cat-file -e v0.3.0:components/dashboard/vellum/index.ts` → **ABSENT** ("exists on disk, but not in 'v0.3.0'"); `v0.3.1:` → present (slice-00 0.3.0-GT seam (b); §2 rows 1/2) |
|
||||
|
||||
> Source: re-run at HEAD `7eaf524c` —
|
||||
> `git cat-file -e v0.3.0:packages/ui/src/components/dashboard/vellum/index.ts`
|
||||
> fails; `v0.3.0:.../for-you/Feed.tsx`, `.../lib/feed-classifier.ts`,
|
||||
> `.../project/ScopePages.tsx`, `.../mission-control/components/VerbActions.tsx`
|
||||
> all resolve at `v0.3.0`. Vellum surface *primitives*
|
||||
> (`components/ui/vellum-*.tsx`) shipped 0.3.0; the coherent vellum
|
||||
> *brand system* under `dashboard/vellum/` is 0.3.1 — slice-00 0.3.0-GT
|
||||
> seam (b) is the exact split. **Do not back-attribute the vellum brand
|
||||
> system to 0.3.0.**
|
||||
|
||||
## 1. Route reality (source-verified at `routes.tsx`@HEAD — BINDING)
|
||||
|
||||
Before narrating any surface, each route was verified at
|
||||
`packages/ui/src/routes.tsx`@HEAD (542 lines). The Phase-8.1 survey's
|
||||
"missing route" expectations are grep-derived and partially wrong; this
|
||||
table is authored from `routes.tsx` reality, not the survey:
|
||||
|
||||
| Path | Reality @HEAD | Component | Source |
|
||||
|---|---|---|---|
|
||||
| `/for-you` | **REAL destination route** | `Feed` | `routes.tsx:122-126` |
|
||||
| `/project` | **REAL destination route** | `WorkspaceScopePage` | `routes.tsx:128-132` |
|
||||
| `/project/mission/$missionId` | **REAL destination route** | `MissionScopePage` | `routes.tsx:134-138` |
|
||||
| `/project/slice/$sliceId` | **REAL destination route** | `SliceScopePage` | `routes.tsx:140-144` |
|
||||
| `/` (index) | **REAL destination route** | `Dashboard` | `routes.tsx:88-92` |
|
||||
| `/mission-control` | **DELETED `<Navigate to="/for-you">` redirect-stub** | `() => <Navigate to="/for-you">` | `routes.tsx:440-444` |
|
||||
| `/progress` | **`<Navigate to="/project">` redirect-stub** (folds into Project tabs) | `() => <Navigate to="/project">` | `routes.tsx:463-467` |
|
||||
| `/slices` | **DELETED `<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:447-451` |
|
||||
| `/slices/$name` | **`<Navigate to="/project/slice/$sliceId">` redirect-stub** | `useParams` → `<Navigate>` | `routes.tsx:453-460` |
|
||||
| `/steering` | **`<Navigate to="/project">` redirect-stub** | `() => <Navigate to="/project">` | `routes.tsx:470-474` |
|
||||
| `/markdown` | **NOT a route** — `MarkdownViewer` is a component used inside `MissionScopePage` | (no route) | grep `routes.tsx`@HEAD: zero `path: "/markdown"` |
|
||||
|
||||
> Source: `routes.tsx:88-92`,`:122-144`,`:440-474` @HEAD; component
|
||||
> imports `routes.tsx:40-41` (`Dashboard`, `Feed`), `:60-63`
|
||||
> (`WorkspaceScopePage`/`MissionScopePage`/`SliceScopePage` from
|
||||
> `components/project/ScopePages.js`) @HEAD.
|
||||
|
||||
> **Route-reality contract (slice-08 §10.7):** the **Mission Control
|
||||
> *system*** lives at daemon/PL-005 (see `../architecture/mission-control.md`)
|
||||
> — `/mission-control` is **NOT a UI destination**; it is a deleted
|
||||
> redirect-stub (SC-18). The 7-verb *action vocabulary* surfaces in this
|
||||
> module via `VerbActions` embedded in For-You cards, but the system
|
||||
> itself is not this module's subject. `/progress`, `/slices`,
|
||||
> `/steering` all fold into `/project` tabs — they are redirect-stubs,
|
||||
> not surfaces to narrate.
|
||||
|
||||
## 2. For You — the attention feed (`/for-you` → `Feed`)
|
||||
|
||||
`Feed` is the operator's attention surface: a chronologically-sorted,
|
||||
subscription-filtered, soft-dismissable card stream over the live
|
||||
activity feed. Header reads `Attention` / `For You`; max width 720px.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:364-369` (`data-testid="for-you-feed"`,
|
||||
> max-w-720 + `For You` header), feed-centerpiece design note `:3-12`
|
||||
> (PRIMARY UX = the feed; subscriptions NOT dominating — LOAD-BEARING
|
||||
> SC-16) @HEAD.
|
||||
|
||||
### 2.1 The 5-card classifier (0.3.0 spine)
|
||||
|
||||
`classifyFeed(events)` maps every `ActivityEvent` to exactly one of five
|
||||
`FeedCardKind` values — `action-required`, `approval`, `shipped`,
|
||||
`progress`, `observation` — then sorts by `receivedAt` descending.
|
||||
**Nothing is silently dropped**: any unmatched event type falls through
|
||||
to `observation`. Queue-visibility events are sub-classified by
|
||||
`queueKind(type, state)` (e.g. `*.closed` → shipped; `human-gate` /
|
||||
`pending-approval` → action-required; `closeout-pending-ratify` →
|
||||
approval); a human-seat destination forces `action-required`.
|
||||
|
||||
> Source: `lib/feed-classifier.ts:9-14` (`FeedCardKind` union),
|
||||
> `classifyEvent` `:145-218` (default-observation fallthrough L216-217),
|
||||
> `queueKind` `:93-110`, `classifyFeed` `:220-223` (receivedAt-desc sort),
|
||||
> `isHumanSeat` `:141-144` @HEAD.
|
||||
|
||||
The feed pipeline (`Feed.tsx`): `classifyFeed` → cap at
|
||||
`HISTORY_LIMIT = 50` → hydrate each card's kind from the live queue-item
|
||||
map + action-audit (`hydratedCardKind`: a recorded outcome on an
|
||||
action/approval card → `approval`; a `done|closed|completed` qitem →
|
||||
`shipped`) → **subscription filter** (`isCardKindSubscribed` against the
|
||||
5 `feed.subscriptions.*` config keys; `action-required` is always
|
||||
visible, `observation` only when the audit subscription is on) → the
|
||||
transient **lens-chip** filter (All / Action req / Approvals / Shipped /
|
||||
Progress / Audit; not persisted) → per-event-seq soft-dismiss filter.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:63` (`HISTORY_LIMIT = 50`),
|
||||
> `:238` (`classifyFeed(events).slice(0, HISTORY_LIMIT)`),
|
||||
> `hydratedCardKind` `:156-180`, subscription+lens+dismiss pipeline
|
||||
> `:269-285` (subscription FIRST L280-282; lens L283; dismiss L284),
|
||||
> `LENS_CHIPS` `:54-61` @HEAD;
|
||||
> subscription forced/default rules `Feed.tsx:3-12` (action_required
|
||||
> forced ON L9; observation gated on audit_log L10) @HEAD.
|
||||
|
||||
### 2.2 Queue-item hydration + proof previews
|
||||
|
||||
For cards carrying a `qitemId` payload, `useQueueItemMap` hydrates the
|
||||
full queue-item body; for `shipped` cards `sliceForCard` matches a slice
|
||||
by tag/text and `proofPreviewForSlice` pulls the first proof packet with
|
||||
screenshots so the card renders an inline `ProofThumbnailGrid` →
|
||||
`ProofImageViewer`. This is the slice-00 §1.5 "queue cards hydrate qitem
|
||||
bodies + proof previews" foundation, live-wired.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:263` (`useQueueItemMap`),
|
||||
> `sliceForCard` `:182-201`, `proofPreviewForSlice` `:203-213`,
|
||||
> per-card proof wiring `:421-427`; `FeedCard.tsx` proof block
|
||||
> `:453-471` (`ProofPacketHeader`+`ProofThumbnailGrid`),
|
||||
> `ProofImageViewer` `:521` @HEAD.
|
||||
|
||||
### 2.3 Verb actions on action/approval cards (the 7-verb system; OPEN-3)
|
||||
|
||||
An actionable card (`action-required` or `approval`, no recorded
|
||||
outcome, non-terminal qitem) embeds `VerbActions`. The **canonical
|
||||
Mission Control action vocabulary is the 7-verb system** —
|
||||
`MISSION_CONTROL_VERBS = [approve, deny, route, annotate, hold, drop,
|
||||
handoff]` — defined at the Mission Control system level (slice-00
|
||||
0.3.0-GT §1.6/seam (c): the vocabulary *originates in 0.3.0 source*).
|
||||
|
||||
> Source: `components/mission-control/hooks/useMissionControlAction.ts:5-15`
|
||||
> (`MISSION_CONTROL_VERBS` 7-element const `:5-13` + `MissionControlVerb`
|
||||
> type `:15`), `components/mission-control/components/VerbActions.tsx:82`
|
||||
> (defaults to all 7 via `enabledVerbs = [...MISSION_CONTROL_VERBS]`),
|
||||
> per-verb input needs `:97-99` (route/handoff→destination L97;
|
||||
> annotate→annotation L98; hold/drop→reason L99) @HEAD; slice-00
|
||||
> 0.3.0-GT §1.6 + seam (c).
|
||||
|
||||
> **OPEN-3 RESOLVED (v0.4.4 living-notes corrective, founder ruling
|
||||
> N-1, 2026-07-05).** The For-You actionable-card surface is **bare
|
||||
> one-tap APPROVE + CHAT — nothing else**. `FeedCard` passes
|
||||
> `enabledVerbs={["approve"]}` + `oneClickVerbs={["approve"]}` + the
|
||||
> v0.4.4 `bare` prop (JSX-only: skips the "Choose response" header
|
||||
> chrome; the mutation/receipt/error paths are byte-identical — pinned
|
||||
> by a raw-body test), beside a CHAT button that opens the **shared
|
||||
> `ProgressiveTerminal`** seeded via `buildChatPreamble` (terminal,
|
||||
> never a chat panel — BR-12). Deny/route are RETIRED from this
|
||||
> surface (including the action-required lens's empty-state copy:
|
||||
> "one-tap approve and chat with the owning agent"); the card-level
|
||||
> kind tag is the status label "Action required", never instruction
|
||||
> chrome. The 7-verb vocabulary above remains the MISSION-CONTROL
|
||||
> system-level vocabulary; For-You offers the two-action subset.
|
||||
>
|
||||
> Source @`bb5ad219`: `FeedCard.tsx:547-593` (bare `VerbActions`
|
||||
> `enabledVerbs=["approve"]` :561-566 + chat button + inline
|
||||
> `ProgressiveTerminal`), `:87-94` (`resolveCardTerminalSession` —
|
||||
> human-action cards chat with the SENDER), `Feed.tsx:76-82`
|
||||
> (`EMPTY_COPY["action-required"]`), `VerbActions.tsx` (`bare` prop),
|
||||
> `review/chat.ts:21` (`buildChatPreamble`); tests
|
||||
> `test/foryou-bare-approve-chat.test.tsx` (byte-identical mutation +
|
||||
> chatBtns:1/denyRoute:0 + retired-copy source-scan).
|
||||
|
||||
On mutation success `VerbActions` fires `onOptimisticOutcome`; `Feed`
|
||||
keeps an optimistic-outcome map keyed by `qitemId` so the
|
||||
`ActionOutcomePanel` receipt renders instantly without waiting for the
|
||||
audit-log re-fetch (the audit re-fetch eventually surfaces the same
|
||||
shape). A terminal qitem with no recorded outcome derives a fallback
|
||||
receipt from its closure reason.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:224-236` (optimistic-outcome
|
||||
> map + `setOptimisticOutcome`), `:426-428` (optimistic-first, audit
|
||||
> fallback: `optimisticOutcomes.get ?? actionOutcomes.get ?? null`);
|
||||
> `FeedCard.tsx:473-500` (`isActionableCard` gate L473 + `VerbActions`
|
||||
> wiring L489-499), `isActionableCard` `:166-175`,
|
||||
> `fallbackOutcomeFromQueueItem` `:177-198`, `ActionOutcomePanel`
|
||||
> `:271-310`; `VerbActions.tsx:138-145` (`onOptimisticOutcome` on
|
||||
> mutation `onSuccess`) @HEAD.
|
||||
|
||||
### 2.4 Card surface — vellum-coherent (0.3.1 brand layer)
|
||||
|
||||
`FeedCard` renders on the **0.3.1 vellum brand system**: a
|
||||
`bg-stone-100/45 backdrop-blur` surface with a 3-stop ambient box-shadow
|
||||
(no border), four `CornerBracket` marks registering the bounds through
|
||||
the vellum, a mono-uppercase kind tag + colored dot, and runtime
|
||||
graphics marks (`ActorMark` for sessions) — the slice-00 §1.7 "shared
|
||||
graphics marks across drawers / proof rows / queue refs / story rows"
|
||||
foundation, now expressed in the matured vellum vocabulary. Cards are
|
||||
keyboard-dismissable (Backspace/Delete) and swipe-dismissable, with an
|
||||
`UndoToast`.
|
||||
|
||||
> Source: `components/for-you/FeedCard.tsx:72-73` (`CARD_SURFACE_CLASS`),
|
||||
> `:74-80` (`CARD_SHADOW_STYLE` 3-stop box-shadow), corner brackets
|
||||
> `:401-404` (4× `<CornerBracket position=…>`), `KIND_DOT` `:33-39`,
|
||||
> kind tag span `:410-414`, `ActorMark` import `:25` + usage `:229`;
|
||||
> `CornerBracket` from the 0.3.1 brand barrel
|
||||
> `dashboard/vellum/index.ts:17`; slice-00 0.3.0-GT seam (b) + §1.7 @HEAD.
|
||||
|
||||
### 2.5 Storytelling preview band
|
||||
|
||||
Above the legacy card list `Feed` renders a `StorytellingFeed` preview
|
||||
built by `buildStorytellingFeedItems` from discovered missions
|
||||
(`useMissionDiscovery` → `ProgressCard`, first 2) + slices
|
||||
(`useSlices` → `ShippedCard` for shipped/done, `IncidentCard`
|
||||
otherwise, capped at 3). Mission rows carry a daemon-derived
|
||||
`status` so the "Getting Started"-style complete-and-hide filters
|
||||
durably (`status === "complete"`), with an optimistic local hide +
|
||||
best-effort `POST /api/missions/:id/complete` audit write that swallows
|
||||
network errors so a partial-air-gapped daemon does not block the hide.
|
||||
|
||||
> Source: `components/for-you/Feed.tsx:319-343` (mission/slice adapters:
|
||||
> `useMissionDiscovery` L319, `missionsWithStatus` L329-335,
|
||||
> `buildStorytellingFeedItems` L336-343; adapter comment L305-318),
|
||||
> `handleMarkMissionComplete` `:351-361` (best-effort `void fetch` L354,
|
||||
> error-swallowed L357), preview render `:399-409`;
|
||||
> `components/feed/cards/storytelling-cards.tsx:36` (`CardKind`),
|
||||
> `ShippedCard` `:184`, `IncidentCard` `:235`, `ProgressCard` `:289`
|
||||
> @HEAD.
|
||||
|
||||
## 3. Project — workspace / mission / slice scope pages (0.3.0 spine)
|
||||
|
||||
`/project*` mounts three scope pages, all built on a shared
|
||||
`ScopeShell` + tabbed-rollup pattern (slice-00 §1.5 project
|
||||
observability foundation). A `SHARED_TABS` set —
|
||||
`overview / story / progress / artifacts / tests / queue / topology` —
|
||||
drives `WorkspaceScopePage` and `MissionScopePage`; `SLICE_TABS`
|
||||
reorders `story` first for `SliceScopePage` but `SliceScopePage`'s
|
||||
`useState` default is `overview` (README + readiness first, not a
|
||||
metric grid).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:75-76` (`SharedTab` /
|
||||
> `SliceTab` types), `SHARED_TABS` `:78-86`, `SLICE_TABS` `:88-96`,
|
||||
> `ScopeShell` `:137-166`, `TabNav` `:98-135` @HEAD.
|
||||
|
||||
### 3.1 WorkspaceScopePage (`/project`)
|
||||
|
||||
Reads the live workspace name (`useWorkspaceName`); honest empty-state
|
||||
(`NO WORKSPACE CONNECTED` with an Open-settings action) when unset. The
|
||||
`overview` tab renders `WorkspaceOverviewPanel`: slices grouped into
|
||||
missions, then `partitionProjectMissions` splits them into a
|
||||
two-column **Current Work / Archive** layout (slice-00 §1.5
|
||||
"current/archive grouping"). Each mission card lists its slices as
|
||||
`Link`s to `/project/slice/$sliceId` with a `QueueCountIcon` +
|
||||
`StatusDot`. The other tabs are the scope rollups (§3.4).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:676-749` (`WorkspaceScopePage`;
|
||||
> no-workspace empty-state guard `:682-693`, `workspace-scope-no-workspace`
|
||||
> `:689`), `WorkspaceOverviewPanel` `:534-674` (mission bucketing
|
||||
> `:536-551`, `partitionProjectMissions` `:552`, two-column panel `:631`,
|
||||
> Current section `:632-651`, Archive section `:652-671`) @HEAD.
|
||||
|
||||
### 3.2 MissionScopePage (`/project/mission/$missionId`)
|
||||
|
||||
Same `ScopeShell`/`SHARED_TABS`. `overview` renders the mission README
|
||||
(`useScopeMarkdown(missionPath, "README.md")` via `MarkdownViewer`)
|
||||
above a slice rail; `progress` renders a `MissionProgressHeatmap` + the
|
||||
mission `PROGRESS.md` + the shared `ScopeProgressRollup`. The `topology`
|
||||
tab renders the **projected workflow spec graph** via `TopologyTab`
|
||||
when the mission's README frontmatter declares a `workflow_spec` that is
|
||||
in the `WorkflowSpecCache` (daemon returns `topology.specGraph`),
|
||||
falling back to session-name aggregation otherwise.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:751-895` (`MissionScopePage`;
|
||||
> `useMission` `:759`, `missionPath` `:760-761`, README/PROGRESS
|
||||
> markdown `:762-763`, overview README section `:775`, progress
|
||||
> heatmap `:832`, `mission-progress-readme` `:838`, spec-graph topology
|
||||
> branch `:875-891` — `missionTopology` `:875`, `specGraph` check
|
||||
> `:879`, session-name fallback `:890`) @HEAD.
|
||||
|
||||
### 3.3 SliceScopePage (`/project/slice/$sliceId`)
|
||||
|
||||
Default tab `overview` (`SliceOverviewTab` — README + current step +
|
||||
readiness). Loading/error states are honest empty-states (the error
|
||||
state names `rig config get workspace.slices_root` as the likely
|
||||
misconfiguration). `progress` folds in `AcceptanceTab` (acceptance is
|
||||
the canonical slice-scope progress proof); `story` renders `TimelineTab`
|
||||
with curated `timeline.md` (`useSliceTimelineMarkdown`) above the
|
||||
auto-captured event feed; `topology` renders the slice's
|
||||
workflow-instance-aware `TopologyTab`; **`review` (v0.4.4) renders the
|
||||
Living Notes `SliceReviewTab`** (§3.5).
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:1187-1297` (`SliceScopePage`;
|
||||
> default `overview` `:1192`, timeline md `:1204`, loading-state
|
||||
> guard `:1206`, error-state guard `:1225` (slices_root remediation
|
||||
> hint `:1239`), story=`TimelineTab` `:1258-1265`,
|
||||
> progress=`AcceptanceTab` `:1269-1273`, topology `:1294`) @HEAD.
|
||||
|
||||
### 3.4 Shared scope rollups
|
||||
|
||||
`ScopeStoryRollup`, `ScopeProgressRollup`, `ScopeArtifactsRollup`,
|
||||
`ScopeTestsRollup`, `ScopeQueueRollup`, `ScopeTopologyRollup` (the
|
||||
non-overview tabs) all read from one `useProjectScopeRollup(missionId,
|
||||
loadDetails)` hook so workspace and mission scopes share identical
|
||||
rollup behavior over their respective slice sets — the slice-00 §1.5
|
||||
"workspace/mission rollups" foundation. Detail fetching is gated on the
|
||||
active tab (`active !== "overview"`) so the overview path stays cheap.
|
||||
|
||||
> Source: `components/project/ScopePages.tsx:198-217`
|
||||
> (`useProjectScopeRollup`; `rowsForScope` `:193-196`),
|
||||
> rollup components `:219-532`; gate `:679`,`:754` (`active !== "overview"`)
|
||||
> @HEAD.
|
||||
|
||||
### 3.5 Review tabs (v0.4.4 — the Living Notes surface)
|
||||
|
||||
Both the slice and mission scope pages mount a `Review` tab
|
||||
(`SLICE_TABS`/mission tabs, `ScopePages.tsx:99-113`). The slice tab
|
||||
(`review/SliceReviewTab.tsx`) renders the ONE reviewable structure per
|
||||
slice — bands in order **NEEDS YOU → AGENTS → INTENT / PLAN / DELIVERED →
|
||||
verify-lineage → SETTLED** — from `GET /api/review/slice/:name`
|
||||
(`hooks/useReview.ts`):
|
||||
|
||||
- **DELIVERED** pairs each planned deliverable with its curated proof down
|
||||
the column (planned mockup above delivered artifact), media inline at text
|
||||
height expanding on tap one-at-a-time (`?item=` deep link); `verified`
|
||||
renders QA's recorded comparison in plain words (✓ QA-verified · ◇
|
||||
unverified · ✗ missing + QA's note) — visible, never blocking. "See all
|
||||
proof" drills into `proof/`.
|
||||
- **Media actually plays**: video renders inline with native controls
|
||||
(`?seek`/`?play` deep links for capture verification); images open the
|
||||
shipped `Lightbox`; evidence and the "full PRD →" door open the shipped
|
||||
`FileViewer` in the shared **right-edge** drawer (the v0.4.4 corrective
|
||||
reverted the FR-11.1 left flip at its three tokens).
|
||||
- **Two locks** (§4 of the architecture module) render as deliberate stamps:
|
||||
plan-lock in PLAN, both stamps in SETTLED; an unaudited stamp renders
|
||||
loudly as UNVERIFIED.
|
||||
- Review cards ride ONE vellum recipe (`review/vellum.ts` —
|
||||
background-matched translucency + backdrop blur, token-driven for both
|
||||
themes); DELIVERED rows stack vertically below the `sm` breakpoint
|
||||
(phone-first single-column scan).
|
||||
- The mission tab (`review/MissionReviewTab.tsx`) is board-first: stage
|
||||
cells from the collapsed contract, the completion ledger, cut-complete,
|
||||
and a U5 row expansion reading the SAME contract (intent verbatim + a
|
||||
bounded per-item verified summary; the full pairing lives on the slice
|
||||
page). The rig altitude (`review/RigAgentsPage.tsx`) reads
|
||||
`GET /api/review/rig`.
|
||||
|
||||
Backend contract + composition: see
|
||||
[`../architecture/living-notes-review.md`](../architecture/living-notes-review.md).
|
||||
|
||||
> Source @`bb5ad219`: `components/review/{SliceReviewTab,MissionReviewTab,
|
||||
> NeedsYouAccordion,AgentsBandView,VerifyLineageCard,EvidenceOpener,
|
||||
> RigAgentsPage}.tsx`, `review/vellum.ts`, `hooks/useReview.ts`,
|
||||
> `SharedDetailDrawer.tsx` (right-edge anchor + pre-flip z);
|
||||
> tests `test/{fileviewer-resolvable-target,drawer-primitives}.test.tsx`.
|
||||
> The rejected three-column compare and the separate item-join table are
|
||||
> DELETED files (corrective delete-not-demote; zero references remain).
|
||||
|
||||
## 4. Dashboard — the landing surface (`/` → `Dashboard`)
|
||||
|
||||
`Dashboard` is a thin composition over the **0.3.1 vellum brand
|
||||
system**: it imports `MidLayerContent`, `TopLayerContent`,
|
||||
`DestinationsLayer` from `dashboard/vellum/index.js` — the SAME barrel
|
||||
`/lab/vellum-lab` imports, so the production dashboard tracks the design
|
||||
lab exactly (single source of truth). Real-data wiring: `useRigSummary`
|
||||
→ totalRigs/totalAgents, `usePsEntries` → activeAgents, `useSpecLibrary`
|
||||
→ librarySize, `window.location.hostname` → classification eyebrow. Per
|
||||
a 2026-05-15 founder dispatch the heavy `BackLayerContent` /
|
||||
`BackVellumSheet` back-layer was removed so the dashboard sits on the
|
||||
page-level cream paper-grid; the barrel still exports them (used by the
|
||||
lab).
|
||||
|
||||
> Source: `components/dashboard/Dashboard.tsx:1-52` (vellum-barrel
|
||||
> import `:12-16`, single-source-of-truth note `:2-5`, real-data hooks
|
||||
> `:29-39`, back-layer-removal note `:21-27`, render `:40-50`);
|
||||
> `components/dashboard/vellum/index.ts:1-26` (barrel; `BackLayerContent`
|
||||
> `:5` / `BackVellumSheet` `:6` still exported); slice-00 0.3.0-GT
|
||||
> seam (b) + §2 rows 1/2 @HEAD.
|
||||
|
||||
## 5. Cross-cutting properties
|
||||
|
||||
- **Live daemon state, no UI-local persistence beyond soft-dismiss** —
|
||||
every surface reads daemon hooks; the only UI-local state is the
|
||||
per-event-seq dismiss set + transient lens chips + optimistic-outcome
|
||||
map (`Feed.tsx:217` lens, `:224` optimistic map, `:240-241` dismiss
|
||||
set; `ScopePages.tsx` `useState` tab state `:677`,`:753`,`:1192`)
|
||||
@HEAD.
|
||||
- **Honest empty/error states** — `WorkspaceScopePage` no-workspace
|
||||
(`ScopePages.tsx:682-693`), `WorkspaceOverviewPanel` index-unavailable
|
||||
(`:565-573`, label `:568`), `SliceScopePage` not-available
|
||||
(`:1225-1245`, label `:1235`) all surface the cause + a remediation
|
||||
pointer, never a blank screen @HEAD.
|
||||
- **0.3.0 observability spine on the 0.3.1 vellum brand layer** — the
|
||||
classifier / verb-action / scope-rollup *logic* is the 0.3.0
|
||||
foundation (§0); the *visual surface* (vellum cards, corner brackets,
|
||||
graphics marks, dashboard) is the 0.3.1 brand maturation. The split is
|
||||
slice-00 0.3.0-GT seam (b); do not collapse either direction @HEAD.
|
||||
|
||||
## OPEN items (carried, not smoothed)
|
||||
|
||||
- **OPEN — For-You verb subset (slice-00 0.3.0-GT OPEN-3).** The exact
|
||||
shipped For-You-surface verb subset is an unresolved velocity slice-01
|
||||
ruling (CHANGELOG `[0.3.1]` "all 7" vs source-material §6 "subset"
|
||||
discrepancy). This module describes the **7-verb system-level
|
||||
vocabulary only** and reports the literal current `enabledVerbs`
|
||||
prop value (§2.3) as source state, NOT as a resolution. Defer
|
||||
For-You-subset enumeration to the pending velocity ruling.
|
||||
- **OPEN — `/project` redirect-stub consolidation.** `/progress`,
|
||||
`/slices`, `/steering` are `<Navigate to="/project">` stubs and
|
||||
`/slices/$name` redirects into `/project/slice/$sliceId` (§1). The
|
||||
former standalone surfaces fold into Project tabs; whether the stubs
|
||||
are permanent or transitional is a product decision not resolvable
|
||||
from source.
|
||||
- No slice-00 numeric-drift OPEN (1–5) applies — this module carries no
|
||||
migration / route-group / PL-004-event counts.
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Shell, Routing, Drawer System
|
||||
status: active
|
||||
topics: [observability]
|
||||
domains: [engineering-advisor, product-advisor]
|
||||
applies-when: |
|
||||
Need to know how the UI shell (AppShell rail / Explorer / center workspace /
|
||||
drawer / preview stack) is assembled, the actual route tree the shipped UI
|
||||
mounts, or how the shared detail drawer and event consumption work.
|
||||
siblings: [topology.md, project-and-for-you.md, library-specs-and-design-system.md]
|
||||
prerequisite-reads: [../README.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Shell, Routing, Drawer System
|
||||
|
||||
The `@openrig/ui` package is the shell-first, route-first, primitive-driven
|
||||
operator surface. Brand/visual rules live in `docs/DESIGN.md` (pointer from
|
||||
`library-specs-and-design-system.md`); this module is how the shell is
|
||||
assembled and what routes it mounts.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). UI footprint is **235** source files
|
||||
> (`packages/ui/src`, `.ts`+`.tsx`, non-test; slice-00 §1.6 / D6).
|
||||
|
||||
## 1. Package shape
|
||||
|
||||
UI package: `packages/ui`. Primary implementation areas:
|
||||
|
||||
- `src/routes.tsx` — TanStack Router route tree.
|
||||
- `src/components/AppShell.tsx` — global shell: rail, Explorer, drawer,
|
||||
preview stack.
|
||||
- `src/components/ui/` — reusable vellum + base UI primitives.
|
||||
- `src/components/graphics/RuntimeMark.tsx` — runtime/tool/actor marks.
|
||||
- `src/components/topology/` — graph/table/terminal topology surfaces.
|
||||
- `src/components/project/`, `for-you/`, `dashboard/`, `feed/` —
|
||||
observability surfaces.
|
||||
- `src/components/specs/` — Library / skills / plugins surfaces.
|
||||
- `src/components/{preview,markdown,drawer-viewers}/` — viewers.
|
||||
- `src/hooks/`, `src/lib/` — route data hooks, classifiers, formatters,
|
||||
layout/brand helpers.
|
||||
|
||||
## 2. Shell model
|
||||
|
||||
`AppShell` wraps every route. `AppShell.tsx` lays a 48px icon rail + a
|
||||
route-aware Explorer sidebar + center workspace + shared right drawer +
|
||||
preview stack + topology overlay provider + shared drawer-selection /
|
||||
discovery-placement contexts.
|
||||
|
||||
The rail destinations (`AppShell.tsx` `RAIL_ICONS`, re-confirmed at HEAD)
|
||||
are exactly six destination icons plus Advisor/Operator entries:
|
||||
|
||||
- Dashboard `/` (`rail-dashboard`)
|
||||
- Topology `/topology` (`rail-topology`)
|
||||
- For You `/for-you` (`rail-for-you`)
|
||||
- Project `/project` (`rail-project`)
|
||||
- Library `/specs` (`rail-specs`)
|
||||
- Settings `/settings` (`rail-settings`)
|
||||
|
||||
The Explorer is contextual: its tree changes with the current destination
|
||||
rather than acting as a generic file browser.
|
||||
|
||||
## 3. Route model
|
||||
|
||||
> Drift-fix D16 — `ui.md` "Route Model" (the L52–73 list) and
|
||||
> `architecture.md` §2 "UI architecture" predate 0.3.x. The route table
|
||||
> below is **re-derived from `packages/ui/src/routes.tsx` at HEAD** (542
|
||||
> lines). The route tree is assembled at `routes.tsx:480`
|
||||
> (`rootRoute.addChildren([...])`).
|
||||
>
|
||||
> Correction to the D16 expectation itself (source-verified at HEAD): the
|
||||
> survey expected `/mission-control`, `/progress`, `/markdown` as net-new
|
||||
> *real* routes. At HEAD `/mission-control` and `/progress` are **redirect
|
||||
> stubs**, not destination routes, and there is **no `/markdown` route**.
|
||||
> There IS a real `/files` route (markdown is rendered by the `MarkdownViewer`
|
||||
> component inside file/drawer surfaces, not via a `/markdown` route). The
|
||||
> route table is reported as source says, not as the survey predicted.
|
||||
|
||||
### Primary destination routes
|
||||
|
||||
| Path | Component | Notes |
|
||||
|---|---|---|
|
||||
| `/` | Dashboard (`indexRoute` `routes.tsx:88`) | rail destination |
|
||||
| `/topology` | host topology (`:98`) | + rig/pod/seat scopes (see `topology.md`) |
|
||||
| `/topology/rig/$rigId` | rig scope (`:104`) | |
|
||||
| `/topology/pod/$rigId/$podName` | pod scope (`:110`) | |
|
||||
| `/topology/seat/$rigId/$logicalId` | seat scope (`:116`) | live node details |
|
||||
| `/for-you` | attention feed (`:122`) | rail destination |
|
||||
| `/project` | workspace project scope (`:128`) | rail destination |
|
||||
| `/project/mission/$missionId` | mission scope (`:134`) | |
|
||||
| `/project/slice/$sliceId` | slice scope (`:140`) | |
|
||||
| `/specs` | Library (`:146`) | rail destination |
|
||||
| `/specs/applications` | applications section (`:152`) | |
|
||||
| `/specs/skills` | skills index (`:160`) | |
|
||||
| `/specs/skills/$skillToken` | skill viewer (`:166`) | defaults to `skill.md` |
|
||||
| `/specs/skills/$skillToken/file/$fileToken` | skill file viewer (`:175`) | |
|
||||
| `/specs/plugins` | plugins index (`:186`) | 0.3.1 (see `library-specs…`) |
|
||||
| `/plugins/$pluginId` | plugin detail (`:201`) | 0.3.1 |
|
||||
| `/specs/$specKind/$specName` | generic spec → library redirect (`:213`) | |
|
||||
| `/files` | Files workspace (`:192`) | net-new vs ui.md/D16 |
|
||||
| `/settings` | settings center (`:222`) | rail destination |
|
||||
| `/settings/policies` | Policies (`:232`) | slice 27 Claude-compaction form |
|
||||
| `/settings/log` | Log (`:237`) | slice 26 4-item Settings explorer |
|
||||
| `/settings/status` | Status (`:242`) | |
|
||||
| `/search` | audit/history view (`:248`) | |
|
||||
|
||||
### Lab routes (design experiments)
|
||||
|
||||
`/lab/project-graphics-preview` (`:254`), `/lab/card-previews` (`:262`),
|
||||
`/lab/vellum-lab` (`:272`), `/lab/vellum-bg/{a-large,b-small,c-allover}`
|
||||
(`:283`–`:293`). The vellum-lab + vellum-bg routes are the 0.3.1 vellum
|
||||
brand-system experiment surface (slice-00 0.3.0-GT seam b: vellum brand
|
||||
identity matured in 0.3.1; the `dashboard/vellum/*` system is 0.3.1).
|
||||
|
||||
### Legacy / compatibility routes
|
||||
|
||||
`/rigs/$rigId` (`:318`), `/rigs/$rigId/nodes/$logicalId` (`:324`),
|
||||
`/import` (`:333`), `/packages`, `/packages/install`, `/packages/$packageId`
|
||||
(`:339`–`:351`), `/bootstrap` (`:357`), `/agents/validate` (`:363`),
|
||||
`/specs/rig` + `/specs/agent` review (`:372`/`:378`),
|
||||
`/specs/library/$entryId` (`:384`), `/discovery` + `/discovery/inventory`
|
||||
(`:395`/`:410`), `/bundles/inspect` + `/bundles/install` (`:416`/`:422`).
|
||||
|
||||
### Redirect stubs (deleted routes)
|
||||
|
||||
`routes.tsx:432`–`:474` mount `<Navigate>` redirects for removed routes —
|
||||
NOT destination pages: `/context` → `/topology`; `/mission-control` →
|
||||
`/for-you` (SC-18: `/mission-control` deleted, For-You replaces it);
|
||||
`/slices` + `/slices/$name` → `/project`; `/progress` → `/project`;
|
||||
`/steering` → `/project`. The Mission Control *system* still exists at the
|
||||
daemon/PL-005 layer (`../architecture/mission-control.md`); only the old UI
|
||||
*route* was retired in favour of For-You.
|
||||
|
||||
## 4. Drawer and viewer system
|
||||
|
||||
`SharedDetailDrawer` is the shared transient detail surface for queue-item
|
||||
viewer, file viewer, subspec preview, and other detail selections. Drawer
|
||||
triggers carry root/path provenance so the viewer fetches/render the correct
|
||||
content; the drawer supports outside-click dismissal. Image proof viewing is
|
||||
handled by `ProofImageViewer` with black-glass styling honouring the shell
|
||||
layout. Skill detail uses Library Explorer navigation + `FileViewer`
|
||||
(`components/drawer-viewers/FileViewer.tsx`) in the center workspace, not a
|
||||
nested three-column browser.
|
||||
|
||||
## 5. Event and activity consumption
|
||||
|
||||
UI event consumption is centralized through shared event hooks so the app
|
||||
avoids duplicate SSE connections for related surfaces. Current consumers: For
|
||||
You feed hydration, topology activity rings + hot-potato movement, the
|
||||
activity feed/system surfaces, and rig event surfaces. Reduced-motion
|
||||
preference is honoured for pulse/packet animation. (Daemon SSE surfaces:
|
||||
`/api/events`, `/api/stream/watch`, `/api/queue/watch` —
|
||||
`architecture-rules-and-event-system.md` §2.4.)
|
||||
|
||||
## See also
|
||||
|
||||
- `topology.md` — graph/table/terminal topology surface.
|
||||
- `project-and-for-you.md` — project observability + For-You feed.
|
||||
- `library-specs-and-design-system.md` — Library/specs + DESIGN.md pointer.
|
||||
- Source roots: `packages/ui/src/routes.tsx`,
|
||||
`packages/ui/src/components/AppShell.tsx`.
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
kind: as-built
|
||||
title: UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
status: active
|
||||
topics: [observability, coordination]
|
||||
domains: [engineering-advisor, operating-advisor]
|
||||
applies-when: |
|
||||
Need to know how the topology surface is built — the host hybrid graph,
|
||||
the table/terminal views, the activity-ring / hot-potato visual language,
|
||||
terminal-preview popovers, and the topology navigation/overlay contracts.
|
||||
siblings: [shell-and-routing.md, project-and-for-you.md]
|
||||
prerequisite-reads: [../README.md, shell-and-routing.md]
|
||||
last-verified-against-source: 7eaf524c
|
||||
last-updated: 2026-05-16
|
||||
---
|
||||
|
||||
# UI Topology — Graph/Table/Terminal, HotPotato, ActivityRing
|
||||
|
||||
Topology is a scoped workspace with graph, table, and terminal views at the
|
||||
`/topology` route family (`shell-and-routing.md` §3). It is the operator's
|
||||
live picture of host → rig → pod → seat.
|
||||
|
||||
> Verified against source at HEAD `7eaf524c`; package version **0.3.1**
|
||||
> (slice-00 §1.1). All component names below are re-confirmed against
|
||||
> `packages/ui/src/components/topology/` at HEAD — `ui.md` and `DESIGN.md`
|
||||
> name *exported symbols*; the source file that defines the hybrid nodes is
|
||||
> `HybridTopologyNodes.tsx` (DESIGN.md L344 implementation reference).
|
||||
|
||||
## 1. Topology pieces
|
||||
|
||||
> Drift-fix — `ui.md` "Topology" (L108–137) lists `HybridAgentNode` /
|
||||
> `HybridPodGroupNode` as if top-level files. Re-confirmed at HEAD: both are
|
||||
> exports inside `packages/ui/src/components/topology/HybridTopologyNodes.tsx`
|
||||
> (`HybridPodGroupNode` is `memo(...)` at `HybridTopologyNodes.tsx:94`;
|
||||
> `HybridAgentNode` is `memo(HybridAgentNodeInner, ...)` at `:272`). The
|
||||
> component names are accurate; their file location is consolidated.
|
||||
|
||||
Components in `packages/ui/src/components/topology/` (re-confirmed at HEAD):
|
||||
|
||||
- `HostMultiRigGraph.tsx` — host-level hybrid React Flow graph (multi-rig
|
||||
single canvas).
|
||||
- `HybridTopologyNodes.tsx` — exports `HybridAgentNode` (compact agent cards:
|
||||
runtime badges, context %, token totals, activity state, terminal preview,
|
||||
CMUX actions) and `HybridPodGroupNode` (soft dashed pod frames).
|
||||
- `RigGroupNode.tsx` — soft rig frames with registration marks + aggregate
|
||||
activity.
|
||||
- `ActivityRing.tsx` — active / needs-input / blocked activity ring; card
|
||||
activity classes in `activity-card-visuals.ts`.
|
||||
- `HotPotatoEdge.tsx` — directional queue-movement edge animation.
|
||||
- `TopologyTableView.tsx` — dense table mirror of topology data.
|
||||
- `TopologyTerminalView.tsx` — terminal-oriented topology state.
|
||||
- `TopologyTreeView.tsx` — tree navigation view.
|
||||
- `TopologyViewModeTabs.tsx` — graph/table/terminal/tree mode switch.
|
||||
- `TerminalPreviewPopover.tsx` — black-glass quick terminal preview, portaled
|
||||
above the graph.
|
||||
- `LaunchCmuxButton.tsx` — hover/focus CMUX launch action.
|
||||
- `ScopePages.tsx` — host/rig/pod/seat scope page wrappers.
|
||||
- `topology-overlay-context.tsx` — `TopologyOverlayProvider` (expanded-rig
|
||||
state).
|
||||
|
||||
> Note (vs `DESIGN.md` "Topology" L205–216): DESIGN.md lists the same
|
||||
> exported primitives (`HostMultiRigGraph`, `HybridAgentNode`,
|
||||
> `HybridPodGroupNode`, `RigGroupNode`, `ActivityRing`, `HotPotatoEdge`,
|
||||
> `TerminalPreviewPopover`, `TopologyTableView`, `TopologyTerminalView`) —
|
||||
> reconciled against source: all present at HEAD. `TopologyTreeView` /
|
||||
> `TopologyViewModeTabs` / `LaunchCmuxButton` exist in source but are not in
|
||||
> DESIGN.md's primitive list (DESIGN.md is the brand primitive list, not an
|
||||
> exhaustive component inventory; not a drift, a scope difference). DESIGN.md
|
||||
> stays byte-identical (Q1).
|
||||
|
||||
## 2. Topology contracts
|
||||
|
||||
From `architecture.md` §2 "Current topology UI" + `ui.md` "Important
|
||||
topology contracts", re-confirmed against source behaviour at HEAD:
|
||||
|
||||
- Graph / table / tree navigation all resolve to seat detail URLs
|
||||
(`/topology/seat/$rigId/$logicalId`).
|
||||
- The host-level graph uses cross-rig node-ID prefixing for multi-rig single
|
||||
canvas.
|
||||
- Expanded-rig state is owned by `TopologyOverlayProvider`
|
||||
(`topology-overlay-context.tsx`); graph data is lazy-fetched for expanded
|
||||
rigs only.
|
||||
- Compact agent cards show context percentage, token totals, and activity
|
||||
card tint; `ActivityRing` + activity classes surface active / needs-input /
|
||||
blocked.
|
||||
- `HotPotatoEdge` animates directional queue movement at graph zoom levels;
|
||||
reduced-motion preference removes pulse/travel animation and keeps static
|
||||
state signals.
|
||||
- Terminal-preview actions are hover/focus-visible; the
|
||||
`TerminalPreviewPopover` must escape React Flow stacking contexts (portaled
|
||||
above the canvas) and stay within the viewport.
|
||||
|
||||
## 3. Layout helpers
|
||||
|
||||
Topology layout is computed by `src/lib/` helpers (re-confirmed at HEAD):
|
||||
`graph-layout.ts`, `hybrid-layout.ts`, `multi-rig-layout.ts`,
|
||||
`topology-activity.ts`, `activity-visuals.ts`. Runtime/tool identity on the
|
||||
cards comes from the central `runtime-brand.ts` / `tool-brand.ts` +
|
||||
`RuntimeMark.tsx` (do not duplicate brand logic — DESIGN.md "Do not";
|
||||
restated here as the topology-card brand-source contract).
|
||||
|
||||
## See also
|
||||
|
||||
- `shell-and-routing.md` — the `/topology` route family and shell.
|
||||
- `project-and-for-you.md` — sibling observability surface.
|
||||
- `../architecture/coordination-primitive.md` — the queue movement the
|
||||
hot-potato edge visualizes.
|
||||
- Source root: `packages/ui/src/components/topology/`,
|
||||
`packages/ui/src/lib/{graph,hybrid,multi-rig}-layout.ts`.
|
||||
@@ -311,7 +311,7 @@ This prompts the agent to actually invoke the skills, not just have them as pass
|
||||
## System Check
|
||||
|
||||
After identity recovery, verify:
|
||||
1. `rig ps --nodes` shows your rig running
|
||||
1. `rig ps --nodes` shows your rig running (scoped to your session's rig by default; outside a managed session name it explicitly: `rig ps --nodes --rig <name>`)
|
||||
2. `rig env status` shows services healthy (if applicable)
|
||||
3. Your working directory is correct
|
||||
4. Required tools are available (node, npm, git, etc.)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Getting started: the guided golden path
|
||||
|
||||
This is the ordered new-operator path from a fresh machine to a running rig with
|
||||
a workspace and a workflow. It uses the **existing** `rig` verbs in order - there
|
||||
is no magic one-command onboarding; each step is a real, inspectable verb.
|
||||
|
||||
> Everything below reports **what is currently true**, never a guarantee that
|
||||
> downstream work will succeed. "Daemon up" does not mean every agent is healthy;
|
||||
> "kernel ready" does not mean every kernel agent is healthy; a workspace root
|
||||
> being *live* does not mean it is the *right* one for your project.
|
||||
|
||||
## The ordered path
|
||||
|
||||
1. **`rig setup`** - install and verify the local runtime (tmux, cmux, Claude
|
||||
Code / Codex, runtime config). Run once per machine. `rig setup` does NOT boot
|
||||
the kernel (see "Kernel framing" below).
|
||||
2. **`rig up <rig-spec>`** - launch a rig. This **auto-starts the daemon** if it
|
||||
is not already running (the four daemon-dependent verbs below do not auto-start
|
||||
it - they tell you to run `rig up` or `rig daemon start`).
|
||||
3. **`rig status`** - see what is currently true: daemon up/down + port, kernel
|
||||
readiness (distinct from daemon health), and your effective workspace root
|
||||
(default vs override).
|
||||
4. **`rig workspace doctor`** - check that your workspace is ready; it reports
|
||||
state + fix-hints. The effective workspace root defaults to
|
||||
`~/.openrig/workspace/` unless you override `workspace.root` (env or config).
|
||||
5. **`rig workflow instantiate <name>`** - start a workflow runtime instance by
|
||||
its **discovered name** (e.g. `conveyor`, `basic-loop`) - no hidden file path
|
||||
needed. `rig workflow specs` shows the seeded built-ins. Instantiating creates a
|
||||
workflow instance and its first **entry qitem**.
|
||||
6. **`rig scope ...`** - browse and manage the durable mission/slice artifacts the
|
||||
workflow coordinates (see the scope <-> workflow bridge below).
|
||||
|
||||
`rig setup` prints this ordered sequence as its next-steps output; `rig status`
|
||||
points back here. This doc is the durable reference.
|
||||
|
||||
## Kernel framing (what `rig setup` does and does not do)
|
||||
|
||||
The kernel rig **auto-boots on daemon-start** - it is daemon-start behavior, NOT
|
||||
a `rig setup` step and NOT something you boot by hand. So:
|
||||
|
||||
- `rig setup` installs/verifies the runtime; it does not start the daemon or the
|
||||
kernel.
|
||||
- Starting the daemon (`rig daemon start`, or implicitly via `rig up`) is what
|
||||
boots the kernel rig in the background.
|
||||
- **Kernel readiness is a distinct signal from daemon health.** The daemon's HTTP
|
||||
health binds early; the kernel can still be booting or a kernel agent can be
|
||||
unhealthy while the daemon is up. `rig status` surfaces kernel readiness
|
||||
separately (via `/api/kernel/status`); `rig daemon start --wait-for-kernel`
|
||||
polls it.
|
||||
|
||||
## The scope <-> workflow bridge
|
||||
|
||||
Two related primitives, often confused by new operators:
|
||||
|
||||
- **`rig scope`** manages **durable, on-disk artifacts** - missions and slices
|
||||
(markdown/YAML files in your workspace). These are the persistent record of
|
||||
*what work exists*.
|
||||
- **`rig workflow`** manages a **runtime instance** - when you
|
||||
`rig workflow instantiate <name>`, the daemon creates a workflow instance plus
|
||||
an **entry qitem** that routes the first step to an owner. This is the live
|
||||
coordination of *who does the next step*.
|
||||
|
||||
How they relate: a scope slice is the durable description of a unit of work; a
|
||||
workflow instance + its qitems are the live machinery that moves that work
|
||||
through owners (hot-potato handoffs). They are not auto-bridged - there is no
|
||||
auto-instantiate-from-scope - you instantiate a workflow by name when you want to
|
||||
run one, and you reference your scope artifacts as the work it coordinates. A
|
||||
typical loop: author/track the unit in `rig scope`, then
|
||||
`rig workflow instantiate <name>` to start the runtime that drives it.
|
||||
@@ -0,0 +1,132 @@
|
||||
# Product-Factory VPS Runbook (product-factory-vps)
|
||||
|
||||
OPR.0.4.4.13 — the productized form of the 2026-06-23 OVH smoke test: a fresh
|
||||
Ubuntu VPS becomes an OpenRig product factory managed from a home base, with
|
||||
the proven security posture as the encoded default. The bootstrap is a
|
||||
SCRIPT + THIS RUNBOOK by design — host verbs are capped at `add`/`list`/
|
||||
`doctor`, and the acceptance anchor is the observable end state: home-base
|
||||
`rig host doctor <id>` all green.
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
- A fresh Ubuntu 22.04/24.04 VPS (any provider; the lived reference was OVH).
|
||||
- A Tailscale tailnet you administer; the home base already on it.
|
||||
- A home-base SSH keypair for the factory (`ssh-keygen -t ed25519 -f ~/.ssh/openrig-factory`).
|
||||
- The published OpenRig artifact (`@openrig/cli@<version>`) or the packaged
|
||||
tarball a release branch builds (`npm pack` output) for pre-release proof.
|
||||
- Dedicated Claude/Codex accounts for THIS factory. Never copy home-base
|
||||
credentials (`~/.claude`, `~/.codex`, `~/.openrig`) onto a VPS.
|
||||
|
||||
## 2. Tailscale tag + ACL (one-time per tailnet)
|
||||
|
||||
In the Tailscale admin console (the UI's words, mapped):
|
||||
- **tag name**: `openrig-vps` (enter as `tag:openrig-vps` in ACL JSON).
|
||||
- **tag owner**: your tailnet admin group/user (`"tagOwners": {"tag:openrig-vps": ["autogroup:admin"]}`).
|
||||
- ACL: home base may reach `tag:openrig-vps` on 22/7433 over the tailnet;
|
||||
`tag:openrig-vps` gets NO grants toward the home base (the reverse path is
|
||||
the explicit recipe in §6, never standing policy).
|
||||
- Auth keys for `tailscale up` must be EPHEMERAL or revoked after use.
|
||||
|
||||
## 3. Bootstrap (fresh VPS → factory-ready)
|
||||
|
||||
On the VPS, as root:
|
||||
|
||||
```bash
|
||||
curl -fsSLO <your-copy-of>/scripts/bootstrap-product-factory-vps.sh
|
||||
chmod +x bootstrap-product-factory-vps.sh
|
||||
./bootstrap-product-factory-vps.sh \
|
||||
--artifact @openrig/cli@<version> \
|
||||
--authorized-key "$(cat ~/.ssh/openrig-factory.pub)" \
|
||||
--ts-authkey tskey-... # ephemeral
|
||||
```
|
||||
|
||||
The script is stepwise and loud: non-root `openrig` user (key-only) → sshd
|
||||
hardening drop-in (`PermitRootLogin no`, `PasswordAuthentication no`,
|
||||
`KbdInteractiveAuthentication no`, `X11Forwarding no`) → UFW default-deny +
|
||||
tailnet ingress (public SSH stays open as a BOOTSTRAP ESCAPE HATCH only) →
|
||||
Tailscale with `tag:openrig-vps`, no routes/exit-node/Tailscale SSH → Node 22 →
|
||||
`npm install -g` of the artifact (must succeed with NO manual package
|
||||
patching) → daemon + kernel.
|
||||
|
||||
On the home base:
|
||||
|
||||
```bash
|
||||
rig host add --id ovh-01 --transport ssh --target <tailnet-alias> --user openrig
|
||||
rig host doctor ovh-01 # expect 4/4 green
|
||||
rig host doctor ovh-01 --posture product-factory-vps --public-addr <public-ip>
|
||||
```
|
||||
|
||||
Then close the escape hatch on the VPS: `sudo ufw delete allow OpenSSH` and
|
||||
make sure Tailscale SSH is off: `tailscale set --ssh=false`. Re-run the
|
||||
posture check. UNKNOWN posture items are NOT passes — each carries the command
|
||||
that verifies it from the right vantage.
|
||||
|
||||
## 4. Transport posture (the decided partition)
|
||||
|
||||
See the per-command transport table in `docs/as-built/cli-reference.md`
|
||||
(§Cross-host execution). The partition IS the intended posture: ssh carries
|
||||
interactive pane ops (`send`/`capture`); http-bearer carries daemon REST ops
|
||||
(`up`/`down`/`launch`); `ps`/`whoami` follow the host's DECLARED transport;
|
||||
fan-out (`--all-hosts`) is http-only. There is NO cross-transport fallback and
|
||||
NO http parity for send/capture in 0.4.4 (parity = new attack surface with no
|
||||
scope-locked need).
|
||||
|
||||
## 5. Safe UI access (recipe, not a verb)
|
||||
|
||||
The daemon binds loopback/tailnet — never public. View the factory UI through
|
||||
a local tunnel (the smoke test's exact form):
|
||||
|
||||
```bash
|
||||
ssh -N -L 127.0.0.1:17433:127.0.0.1:7433 <tailnet-alias>
|
||||
# then open http://127.0.0.1:17433
|
||||
```
|
||||
|
||||
Tear the tunnel down when done. Do not add public allow rules for :7433.
|
||||
|
||||
## 6. Restricted reverse path (supported recipe — NOT always-on)
|
||||
|
||||
Boundary first: home-base-initiated ONLY; no broad VPS→home SSH; the tunnel is
|
||||
torn down after use. The factory cannot freely reach into the home base.
|
||||
|
||||
1. On the home base, create a dedicated key the FACTORY will use through the
|
||||
reverse tunnel, and gate it with a forced command in
|
||||
`~/.ssh/authorized_keys`:
|
||||
|
||||
```text
|
||||
command="$HOME/.openrig/bin/openrig-reverse-gate",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA...factory-reverse-key
|
||||
```
|
||||
|
||||
2. The gate script (generatable template — allowlists EXACTLY the four verbs
|
||||
the smoke test proved; everything else is refused, fail-closed):
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# openrig-reverse-gate — forced-command allowlist for the reverse path.
|
||||
set -euo pipefail
|
||||
case "${SSH_ORIGINAL_COMMAND:-}" in
|
||||
"rig send "*|"rig capture "*|"rig ps"*|"rig whoami"*)
|
||||
exec $SSH_ORIGINAL_COMMAND ;;
|
||||
*)
|
||||
echo "refused: only rig send/capture/ps/whoami are allowed on this key" >&2
|
||||
exit 77 ;;
|
||||
esac
|
||||
```
|
||||
|
||||
3. Open the reverse tunnel FROM the home base when you want the channel:
|
||||
|
||||
```bash
|
||||
ssh -N -R 127.0.0.1:2222:127.0.0.1:22 <tailnet-alias> # home-base initiated
|
||||
```
|
||||
|
||||
4. On the factory, agents reach home through it:
|
||||
`ssh -p 2222 -i <factory-reverse-key> [email protected] rig send <session> "..."`.
|
||||
5. Kill the tunnel after use. The REFUSAL of any out-of-allowlist verb is the
|
||||
proof artifact that the gate works — test it on purpose.
|
||||
|
||||
## 7. Credential + teardown discipline
|
||||
|
||||
- Runtime credentials (Claude/Codex) live ON the VPS, provisioned for it —
|
||||
never copied from the home host.
|
||||
- Snapshot/backup expectations and clean teardown follow the provider's
|
||||
tooling; `rig down --snapshot` before destroying a factory.
|
||||
- Revoke the Tailscale node + one-time keys when a factory is retired.
|
||||
@@ -0,0 +1,187 @@
|
||||
# SDLC conventions — the markdown control plane
|
||||
|
||||
> **Succession rule (source of truth):** this document was derived from the
|
||||
> corrective review-surface redesign spec
|
||||
> (`CORRECTIVE-REDESIGN-review-surface-2026-07-05`, DRAFT v0.2, §2–§6,
|
||||
> grounded against openrig main `c27f2aff`, 2026-07-05/06). **Once shipped,
|
||||
> THIS repo document is the living SSOT for the SDLC conventions going
|
||||
> forward**; the corrective spec is the historical design record. Scaffold
|
||||
> templates, the advisory scope audit, the `mission-slice-sop` skill, and the
|
||||
> shipped bootstrap overlay all point HERE — they must not restate this
|
||||
> document in full (restatement is how drift is born).
|
||||
|
||||
OpenRig is a software factory the human steers from a high altitude. The human
|
||||
records intent; agents turn intent into a plan, the plan into a build, the
|
||||
build into proof. The Living Notes UI is a **plain projection of well-formed
|
||||
markdown on disk** — agents change the files, the UI re-projects. These
|
||||
conventions define "well-formed." Everything here is **advisory / fail-open
|
||||
for agents**: nothing below blocks a write; the audit records and advises.
|
||||
|
||||
## 1. The flow in one pass
|
||||
|
||||
```
|
||||
intent → mini-requirements + proof contract → (UI slices: mockups)
|
||||
→ plan-lock (rig scope slice approve --scope spec)
|
||||
→ build the LOCKED set
|
||||
→ QA: mockup ↔ delivered VISUAL compare
|
||||
→ proof drops (rig proof add <slice> …)
|
||||
→ proof-lock (rig scope slice approve --scope delivery)
|
||||
```
|
||||
|
||||
The human should almost never see something that doesn't match what was
|
||||
planned — QA catches mismatches and kicks them back. When the human looks,
|
||||
they map intent → plan → delivered at a glance, mostly by scanning screenshots
|
||||
down a single column, and give the final 1% approval.
|
||||
|
||||
## 2. Slice artifact conventions (what the UI projects)
|
||||
|
||||
Each slice directory carries:
|
||||
|
||||
- **`README.md`** (or the slice body) opening with the three convention
|
||||
sections, in this order and with these exact headings:
|
||||
- **`## Intent`** — the recorded intent, verbatim. The UI projects this
|
||||
text as the INTENT section.
|
||||
- **`## Mini-requirements`** — the concise, one-glance requirement tier
|
||||
(numbered list). This is the founder's first structured catchpoint;
|
||||
approval starts here.
|
||||
- **`## Proof contract`** — a checkbox list of promised deliverables (see
|
||||
§3). The UI's DELIVERED section pairs each item with its proof.
|
||||
- **`IMPLEMENTATION-PRD.md`** — the full PRD. It OPENS with the
|
||||
mini-requirements; everything between intent and proof is **elastic**
|
||||
(see §7 — for a small slice the mini-requirements may BE the whole PRD).
|
||||
- **`proof/`** — the proof-artifact directory. Curated canonical evidence
|
||||
lands here via `rig proof` with valid C1 headers (§5).
|
||||
|
||||
The three sections project into the UI's one review structure: a vertical
|
||||
stack of **INTENT → PLAN → DELIVERED**. A slice missing a section still
|
||||
renders (the projection degrades to a muted "—", never invents content) —
|
||||
but it does not carry its weight in review.
|
||||
|
||||
## 3. The proof contract format
|
||||
|
||||
`## Proof contract` is a markdown checkbox list; each item is one promised
|
||||
deliverable, written as an observable outcome:
|
||||
|
||||
```markdown
|
||||
## Proof contract
|
||||
|
||||
- [ ] The consolidated `rig ps` default renders all rigs with a rollup footer — captured.
|
||||
- [ ] UI: the slice review tab renders the three-section stack — screenshot vs the locked mockup.
|
||||
```
|
||||
|
||||
- Each item is joined (by item text or 1-based index) to the proof artifacts
|
||||
that evidence it — that pairing is what the DELIVERED section renders, so
|
||||
the human never hunts through dozens of artifacts to find which one proves
|
||||
what.
|
||||
- **UI deliverables carry a planned mockup** (`plannedRef`): the planning
|
||||
agent produces the mockup and attaches it to the locked set. A UI slice
|
||||
with no mockup in its locked set is an incomplete plan. Non-UI slices
|
||||
(backend, skills, markdown) have no mockup and no `plannedRef` — that is
|
||||
not a gap and not a gate.
|
||||
- A deliverable QA did not actually verify shows as `unverified`/`missing`
|
||||
in the UI — visible, never hard-blocking.
|
||||
|
||||
## 4. The two locks (shipped verb — not new machinery)
|
||||
|
||||
Two deliberate stamps, both written by the SAME shipped verb
|
||||
(`rig scope slice approve`, one daemon-side write path: frontmatter stamp +
|
||||
append-only audit row land together):
|
||||
|
||||
- **Plan-lock:** `rig scope slice approve <slice> --scope spec` — "the PRD
|
||||
matches my intent; THIS artifact set is what gets built." Pins the locked
|
||||
artifact set (spec/PRD/mockups) out of everything else in the folder.
|
||||
- **Proof-lock:** `rig scope slice approve <slice> --scope delivery`
|
||||
(the default scope) — the terminal "this is done" sign-off; fires the
|
||||
freeze.
|
||||
|
||||
Approval is freeze/sign-off — **never** proven-green. Proven-green requires a
|
||||
recorded verdict (a C1 proof artifact, §5); presence of an approval stamp does
|
||||
not assert the work was proven. `--on-behalf-of` records delegation honestly
|
||||
(the actor stays the real invoking session).
|
||||
|
||||
## 5. Proof drops and the C1 header (closed sets)
|
||||
|
||||
Proof artifacts land in `proof/` via the shipped verb:
|
||||
|
||||
```bash
|
||||
rig proof add <slice> \
|
||||
--artifact-type qa \
|
||||
--verdict PASS \
|
||||
--candidate-sha <the-proven-tip> \
|
||||
--money-evidence "one line of money evidence" \
|
||||
--file <artifact.md> \
|
||||
--evidences "1,3" \
|
||||
--media "walk.webm,panel.png" \
|
||||
--self-check "I looked at the captures; they show the claim"
|
||||
```
|
||||
|
||||
The C1 header's five required fields: `slice`, `candidate_sha`,
|
||||
`artifact_type`, `verdict`, `money_evidence`. Two **ratified closed sets**
|
||||
(extending either is a convention change, not a local edit):
|
||||
|
||||
- `artifact_type`: `guard | qa | rev1-r1 | rev1-r2 | adjudication`
|
||||
- `verdict`: `CLEAR | BLOCKING | CONCERNING | PASS | NOT-CLEAR`
|
||||
|
||||
`candidate_sha` is the join key: the proven candidate tip this artifact
|
||||
judges. `--evidences` names which proof-contract deliverable(s) the drop
|
||||
covers (item text or 1-based index) — that reference populates the
|
||||
planned↔delivered pairing. `--self-check` is the agent's recorded assertion
|
||||
that it LOOKED at the evidence. `--media` names the curated media files
|
||||
(relative to the slice's `proof/` dir — co-located, never absolute) this
|
||||
drop stands behind; the composer projects them into the DELIVERED items'
|
||||
proof set. Validation happens at drop time; the audit
|
||||
(§6) backstops artifacts that arrived by other paths. **Hand-placing files
|
||||
in `proof/` without a drop is the anti-pattern**: the deliverable stays
|
||||
unpaired and `unverified` in the DELIVERED view — always attach media via
|
||||
`--media` on a drop.
|
||||
|
||||
## 6. Role contracts (what makes the structure self-enforcing)
|
||||
|
||||
- **Planning agent:** authors intent verbatim, the mini-requirements, and the
|
||||
`## Proof contract` (each UI deliverable with its mockup `plannedRef`);
|
||||
produces the mockups; locks the plan (`--scope spec`).
|
||||
- **Build agent:** builds against the LOCKED set only; looks at the mockups,
|
||||
not just the spec text.
|
||||
- **QA agent (owns the compare):** for each deliverable — load the locked
|
||||
`plannedRef`, produce the real artifact in a test/demo environment,
|
||||
**visually compare**, record the verdict + note via a proof drop, and
|
||||
**curate** the canonical proof set. On mismatch beyond minutiae: fix and
|
||||
have another agent review, or kick back with the reason — never escalate a
|
||||
raw mismatch to the human. A proof drop with no recorded comparison leaves
|
||||
the deliverable `unverified` — visibly.
|
||||
|
||||
**Curation rule:** the primary proof set is the curated canonical "this is
|
||||
what it looks like now" evidence the agent stands behind — bounded, mapped
|
||||
1:1 (or few:1) to deliverables. The fix-loop's full artifact history stays in
|
||||
`proof/`, one drill-in down, NEVER in the primary view. The anti-pattern:
|
||||
an append-only pile where the human can't tell final from superseded.
|
||||
|
||||
## 7. The elastic middle (proportionality — no minted ceremony)
|
||||
|
||||
The SDLC has exactly three fixed capture points: **intent** → a
|
||||
**proportional structured requirement** → **proof**. Everything between is
|
||||
elastic. For a small slice (a bug fix, a research note), the
|
||||
mini-requirements may BE the whole PRD — the convention sections must be
|
||||
present so the slice projects, but their contents scale to the work. Gates
|
||||
are losslessness checks on the decompression from intent to delivery, not
|
||||
paperwork. Scaffolding emits the sections; it must never mint ceremony.
|
||||
|
||||
## 8. The audit (advisory, fail-open — always)
|
||||
|
||||
`rig scope audit` (and the advisory rows in `rig workspace validate` /
|
||||
`doctor`) checks these conventions: the section headings present, the proof
|
||||
contract well-formed, `proof/` artifacts carrying valid C1 headers, UI slices
|
||||
referencing a mockup. Every finding **records and advises — it never blocks a
|
||||
write path and never changes exit semantics into a gate**. Unknown is
|
||||
reported as unknown, not failure.
|
||||
|
||||
## 9. Where the knowledge lives (the four pointers)
|
||||
|
||||
- **This document** — the SSOT.
|
||||
- **Scaffold**: `rig scope slice create` emits the convention sections +
|
||||
`proof/` + an `IMPLEMENTATION-PRD.md` skeleton for every template kind;
|
||||
`rig scope mission create` emits the convention pointer.
|
||||
- **Skill**: `mission-slice-sop` teaches the full flow (shipped in the
|
||||
product skill tree and the bundled plugin, mechanically mirrored).
|
||||
- **Bootstrap**: the shipped agent overlay points fresh seats at the skill
|
||||
and this document at boot.
|
||||
@@ -0,0 +1,362 @@
|
||||
# OpenRig v0.3.2
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.2 ships Rigbundles as a first-class cross-primitive bundling
|
||||
mechanism, GAs the workspace + workflow operator surface, lands a
|
||||
4-bug `rig up <starter>` paper-cut fix-round, ships a 5-fix
|
||||
coordination + first-user-setup round (slice 21), scrubs internal
|
||||
substrate paths from daemon source + tests, wires `ConceptCard` to a
|
||||
shaped data source, and adds server-side priority windowing on For You.
|
||||
|
||||
No schema-breaking migrations. Existing v0.3.1 databases upgrade on first
|
||||
`rig daemon start` (adds migration `041_rig_policy` for the new
|
||||
operator-context-mode bindings table — `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime).
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Rigbundles First-Class (slice 05)** — cross-primitive bundling for
|
||||
five content kinds (skills + plugins HYBRID + workflow_specs +
|
||||
context_packs + agent_images) routed end-to-end. `rig bundle create`
|
||||
auto-detects an author `bundle.yaml`, vendors declared content with
|
||||
both-sides path containment and symlink-escape protection, and
|
||||
emits an integrity-hashed manifest. `rig bundle install` routes
|
||||
each content kind to its canonical library at `$OPENRIG_HOME`
|
||||
with consumer-scan visibility. `rig bundle inspect` surfaces the
|
||||
cross-primitive content fields as first-class. CLI install timeout
|
||||
bumped (the 5-second cap was too short for tmux-session-bootstrapping
|
||||
installs).
|
||||
- **Workspace + Workflow GA (slice 01)** — operator-facing surface
|
||||
hardened. `rig workspace validate --max-files` strict-int regex;
|
||||
`rig workflow project --exit` enum guard; 3-part error returned
|
||||
before `client.post` for early-fail clarity; 14 new discriminator
|
||||
tests on the validator paths.
|
||||
- **`rig up <starter>` Paper-Cut Fix-Round (slice 22)** — unblocks
|
||||
the homepage quick-start on fresh 0.3.1→0.3.2 installs. Four bounded
|
||||
fixes:
|
||||
- HTTP 4xx surface for pre-launch failures
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) — was bare 500.
|
||||
- No orphan rig record on pre-launch failure — instantiator
|
||||
re-ordered + rollback path + compose teardown order tightened.
|
||||
- Path-form `rig up <install-internal-spec>` defaults `cwd` to the
|
||||
caller directory (closes the divergence between path-form and
|
||||
bare-form invocation).
|
||||
- `walkYamlFiles` skips standard noise dirs (`.worktrees`,
|
||||
`node_modules`, `.git`, `dist`, `.turbo`, `.next`) + stale
|
||||
`workflow_specs` rows prune at startup with an install-root
|
||||
preservation guard (so shipped built-in specs survive the prune).
|
||||
- **Coordination + First-User Setup Fix-Round (slice 21)** — five
|
||||
bounded follow-rounds:
|
||||
- FR-1: Coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md` (`rig send` vs `rig queue` vs
|
||||
`rig queue handoff` + §1b doctrine).
|
||||
- FR-2: First-user workspace + workflow setup teaching content
|
||||
(skill + docs).
|
||||
- FR-3: MISSION_NOTES durable-pattern hardening (template +
|
||||
codemap convention + scope-primitive auto-scaffold via
|
||||
`rig scope mission create`).
|
||||
- FR-4: Queue ergonomics — `rig queue create --body-file <path>`
|
||||
(use `-` for stdin) kills the backtick-shell-corruption class for
|
||||
multiline bodies; first-class `--mission` / `--slice` flags
|
||||
compose into tags; `lastNudgeResult` wording fix;
|
||||
closure-vs-acceptance documented.
|
||||
- FR-5: `rig workspace doctor` subcommand — 7-check workspace
|
||||
readiness diagnostic (workspace root, missions folder, file
|
||||
allowlist, daemon alignment, daemon reload, optional slice
|
||||
docs, MISSION_NOTES presence).
|
||||
- **Daemon Test Substrate-Path Scrub (slice 14)** — internal-team
|
||||
substrate path shape scrubbed from 4 daemon source sites + 10 test
|
||||
files. Hook-constants de-duplicated. Privacy class closed for
|
||||
tracked source/test surface.
|
||||
- **`ConceptCard` Data Source (slice 17)** — wired to shaped backlog
|
||||
candidates; storytelling-adapter completion deferred from 0.3.1
|
||||
is now complete.
|
||||
- **For-You Priority Windowing (slice 20)** — server-side attention
|
||||
query (Option 3): SQL predicate pushdown, exact-match attention
|
||||
regex, dismissal as string-keyed for queue-derived cards.
|
||||
- **Operator Context-Mode Bindings (slice 09)** — new `rig policy`
|
||||
command surface paired with a daemon-side typed-primitive store
|
||||
(migration `041_rig_policy`). Six modes
|
||||
(`sleep | desk | mobile | away | focus | debug`) bind to four
|
||||
scope tiers (`global_host | rig | workstream | qitem`) with a
|
||||
restate-and-confirm posture so scripts cannot silently apply a
|
||||
binding without explicit `--confirm`. Citation, effective-mode
|
||||
resolve, and `defaults` commands round out the surface.
|
||||
- **Scope Tree Primitive (slice 12)** — new `rig scope` command for
|
||||
operating the substrate scope tree (missions, slices, sub-slices)
|
||||
per `conventions/scope-and-versioning`. Mission-tier
|
||||
(`ls` / `show` / `create`) + slice-tier
|
||||
(`ls` / `show` / `create` / `ship` / `close` / `move`) commands.
|
||||
Auto-mints stable dot-IDs into mission frontmatter; mission
|
||||
templates include `release` / `placeholder` / `mission-notes`.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### Rigbundles First-Class
|
||||
|
||||
`rig bundle` now ships first-class cross-primitive bundling:
|
||||
|
||||
```
|
||||
rig bundle create <spec> -o <path>
|
||||
[--name <n>] [--bundle-version <v>] [--include-packages <refs...>]
|
||||
[--rig-root <r>] [--notes <text>]
|
||||
[--min-daemon-version <ver>] [--min-cli-version <ver>] [--json]
|
||||
rig bundle inspect <path> [--json]
|
||||
rig bundle install <path>
|
||||
[--plan] [--yes] [--target <root>]
|
||||
[--skip-version-check] [--force] [--json]
|
||||
rig bundle history [--rig <name>] [--since <iso>] [--json]
|
||||
```
|
||||
|
||||
Content kinds routed at install: `skills`, `plugins` (hybrid), `workflow_specs`,
|
||||
`context_packs`, `agent_images`. Each kind lands in its canonical
|
||||
library under `$OPENRIG_HOME`; consumer-scan visibility is preserved.
|
||||
|
||||
`bundle.yaml` author manifests are auto-detected when present.
|
||||
Vendoring uses both-sides path containment (source and destination)
|
||||
and symlink-escape protection. Integrity hashing is captured in the
|
||||
bundle manifest.
|
||||
|
||||
`--skip-version-check` and `--force` on `install` are operator-explicit
|
||||
overrides for the install-time daemon/CLI compatibility gate and the
|
||||
install-time conflict gate respectively (NOT recommended for routine
|
||||
use; the overrides exist for known-good operator scenarios).
|
||||
|
||||
`rig bundle history` reads `~/.openrig/bundle-audit.jsonl` — the
|
||||
audit ledger of every install — with optional filters on
|
||||
target rig name and earliest install time.
|
||||
|
||||
### Workspace Doctor (`rig workspace doctor`)
|
||||
|
||||
7-check workspace-readiness diagnostic against the daemon's resolved
|
||||
workspace:
|
||||
|
||||
```
|
||||
rig workspace doctor [--workspace <path>] [--strict] [--json]
|
||||
```
|
||||
|
||||
Checks: workspace root, missions folder, file allowlist, daemon
|
||||
alignment, daemon reload, optional slice docs, MISSION_NOTES presence.
|
||||
|
||||
Default exit-code: non-zero only on `fail`. `--strict` makes
|
||||
warn-or-fail non-zero. The CLI overlays `OPENRIG_FILES_ALLOWLIST`
|
||||
from the operator's shell env so the diagnostic reflects the
|
||||
operator's intended allowlist even when the daemon's env is unchanged.
|
||||
|
||||
### Queue Ergonomics
|
||||
|
||||
`rig queue create` accepts a body from a file or stdin and exposes
|
||||
first-class mission/slice scope:
|
||||
|
||||
```
|
||||
rig queue create --source <s> --destination <d> \
|
||||
(--body <text> | --body-file <path>) \
|
||||
[--mission <id>] [--slice <id>] \
|
||||
[--priority <p>] [--tier <t>] [--tags <csv>] \
|
||||
[--target-repo <name>] [--no-nudge] \
|
||||
[--expires-at <iso>] [--id <qitemId>] [--json]
|
||||
```
|
||||
|
||||
`--body-file -` reads from stdin. `--mission <id>` / `--slice <id>`
|
||||
translate to `mission:<id>` / `slice:<id>` tags (compose with
|
||||
`--tags`).
|
||||
|
||||
### Operator Context-Mode Bindings (`rig policy`)
|
||||
|
||||
```
|
||||
rig policy set <mode> [--scope <scope>] [--qualifier <id>] \
|
||||
[--<field> ...] [--evidence <citation>] \
|
||||
[--confirm] [--bearer <token>] [--json]
|
||||
rig policy show [--json]
|
||||
rig policy effective [--rig <id>] [--workstream <id>] [--qitem <id>] [--json]
|
||||
rig policy cite [--rig <id>] [--workstream <id>] [--qitem <id>]
|
||||
rig policy unset <scope> [qualifier] [--bearer <token>] [--json]
|
||||
rig policy defaults [--json]
|
||||
```
|
||||
|
||||
`set` is restate-only without `--confirm` (the CLI echoes the proposed
|
||||
binding and exits with `exit 2` so scripts cannot silently apply).
|
||||
|
||||
Six modes: `sleep | desk | mobile | away | focus | debug`. Four
|
||||
scopes: `global_host | rig | workstream | qitem`. `--qualifier` is
|
||||
required for `rig | workstream | qitem` scopes and explicitly
|
||||
rejected for `global_host`.
|
||||
|
||||
Operator-edit verbs (`set --confirm`, `unset`) require an operator
|
||||
bearer token (`--bearer` or `OPENRIG_AUTH_BEARER_TOKEN` env).
|
||||
|
||||
### Scope Tree Primitive (`rig scope`)
|
||||
|
||||
```
|
||||
rig scope slice ls [--mission <m>] [--state <s>] [--json]
|
||||
rig scope slice show <slice-path> [--mission <m>] [--json]
|
||||
rig scope slice create <mission> <slug> [--template <k>] [--title <t>] [--json]
|
||||
rig scope slice ship <slice-path> <release-mission> [--mission <m>] [--json]
|
||||
rig scope slice close <slice-path> --reason <r> [--note <t>] [--mission <m>] [--json]
|
||||
rig scope slice move <slice-path> <dest-mission> [--mission <m>] [--json]
|
||||
rig scope mission ls [--json]
|
||||
rig scope mission show <mission> [--json]
|
||||
rig scope mission create <name> [--template <k>] [--id <dot-id>] [--title <t>] [--json]
|
||||
```
|
||||
|
||||
Top-level option `--workspace <path>` overrides workspace root
|
||||
(otherwise inferred from `cwd` or `$OPENRIG_WORK_ROOT`).
|
||||
|
||||
### `rig up` Paper-Cut Fixes
|
||||
|
||||
```
|
||||
rig up <source> [--plan] [--yes] [--cwd <path>] [--target <root>] [--existing] [--json]
|
||||
```
|
||||
|
||||
- Pre-launch failures now return structured HTTP 4xx
|
||||
(`cycle_error` / `preflight_failed` / `validation_failed` /
|
||||
`service_boot_failed`) instead of bare 500.
|
||||
- Failed boots no longer leave orphan rig records on disk.
|
||||
- Path-form invocations default `cwd` to the caller directory.
|
||||
- `--existing` skips library-spec name resolution and treats
|
||||
`<source>` as an existing rig name directly.
|
||||
|
||||
### MISSION_NOTES Durable Pattern (slice 21 FR-3)
|
||||
|
||||
`rig scope mission create` auto-scaffolds `MISSION_NOTES.md` from
|
||||
`conventions/mission-notes/TEMPLATE.md` by default; `--no-mission-notes`
|
||||
opts out (rare). The convention codifies the durable-pattern model
|
||||
documented in workspace skills + canonical templates so missions
|
||||
ship with a uniform top-of-mind notes surface.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.3.2
|
||||
rig --version # expect 0.3.2
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `041_rig_policy` applies automatically on first post-upgrade
|
||||
daemon start. It is a non-destructive `CREATE TABLE` with no impact on
|
||||
existing data; binding rows are operator-authored at runtime through
|
||||
`rig policy set --confirm`.
|
||||
|
||||
For the new `rig policy` surface: opt-in default-none. With no
|
||||
bindings set, no behavior change. Use `rig policy defaults` to inspect
|
||||
the recommended per-mode 6×7 + default-scope mapping before authoring.
|
||||
|
||||
For the new `rig scope` surface: read-only against workspace by
|
||||
default (`ls`/`show`); authoring verbs (`create`/`ship`/`close`/`move`)
|
||||
operate on the substrate scope tree as advertised. Use `--workspace
|
||||
<path>` to override the resolved workspace root.
|
||||
|
||||
## Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-05 Item-3 sub-scopes deferred to 0.3.3**: agent/port/managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Design-contingent on the CLI surface
|
||||
decision (A-breaking rename vs A-additive new flag).
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema deferred to 0.3.3**:
|
||||
the new `accepted` queue state is a schema model change; carried
|
||||
forward per the banked release-triage philosophy.
|
||||
- **Onboarding journey + battle-hardening deferred to 0.3.3**: slices
|
||||
04 (new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission tree
|
||||
at wrap.
|
||||
- **Workspace symlink alignment**: the daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots is a follow-up
|
||||
surfaced by slice-21 FR-5 QA; non-blocking for 0.3.2.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental` only;
|
||||
not split-deferred to 0.3.3 (stays held).
|
||||
- **`rig down <name>` still returns HTTP 404** — name-based teardown
|
||||
still doesn't resolve through the daemon down route at v0.3.2; use
|
||||
`rig down <rigId> --delete` instead. Carries forward as known issue.
|
||||
- **`rig view list/show --json` flag inconsistency** — flag scaffolding
|
||||
on the wrapper-layer routing path remains; the daemon's
|
||||
`view show <name>` route returns JSON correctly when invoked directly
|
||||
(per v0.3.1 cutover evidence). Workaround: use human-readable output
|
||||
for now. Wrapper-layer fix is the documented target.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- `npm run test:repo` green.
|
||||
- Daemon test suite: 3996 PASS at slice-22 close.
|
||||
- TypeScript checks clean on every touched file at per-commit guard.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + orch heavy pre-merge verify on
|
||||
each merge.
|
||||
- Post-QA-fix deltas (when present) routed back to guard before
|
||||
merge per the banked qa-fix-lands-post-guard pattern.
|
||||
- Review-lead continuous-parallel-review ADVISORY-CLEAN on slice-22
|
||||
close with five POSITIVE observations.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.1 → v0.3.2 delta is 143 commits / 203 files /
|
||||
+29,028 insertions / −2,081 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Rigbundles First-Class
|
||||
- `feat(daemon,cli)`: cross-primitive bundle routing — five content
|
||||
kinds plumbed end-to-end with both-sides path containment +
|
||||
symlink-escape protection + integrity hashing.
|
||||
- `feat(cli)`: `rig bundle` surface — `create` / `inspect` /
|
||||
`install` / `history` subcommands with operator-explicit override
|
||||
flags on install.
|
||||
|
||||
### Workspace + Workflow GA
|
||||
- `feat(daemon,cli)`: workspace `validate --max-files` strict-int
|
||||
regex; workflow `project --exit` enum guard; 3-part error before
|
||||
`client.post`.
|
||||
- `feat(cli)`: 14 new discriminator tests on the validator paths.
|
||||
|
||||
### `rig up <starter>` Paper-Cut Fix-Round
|
||||
- `fix(daemon)`: HTTP 4xx surface for pre-launch failures.
|
||||
- `fix(daemon)`: no orphan rig record on pre-launch failure.
|
||||
- `fix(cli)`: path-form `rig up` defaults `cwd` to caller dir.
|
||||
- `fix(daemon)`: `walkYamlFiles` skips standard noise dirs + stale
|
||||
`workflow_specs` row prune with install-root preservation.
|
||||
|
||||
### Coordination + First-User Setup Fix-Round (slice 21)
|
||||
- `feat(skill)`: coordination-model boot instinct surfaced in
|
||||
`core/openrig-user/SKILL.md`.
|
||||
- `feat(docs)`: first-user workspace + workflow setup teaching.
|
||||
- `feat(cli)`: MISSION_NOTES auto-scaffold via
|
||||
`rig scope mission create`.
|
||||
- `feat(cli)`: `rig queue create --body-file` + first-class
|
||||
`--mission` / `--slice` flags.
|
||||
- `feat(daemon,cli)`: `rig workspace doctor` 7-check diagnostic.
|
||||
|
||||
### Daemon Test Substrate-Path Scrub
|
||||
- `chore(privacy)`: substrate path shape scrubbed from daemon source
|
||||
+ test surface; hook-constants de-duplicated.
|
||||
|
||||
### ConceptCard Data Source
|
||||
- `feat(ui,daemon)`: wire `ConceptCard` to shaped backlog candidates.
|
||||
|
||||
### For-You Priority Windowing
|
||||
- `feat(daemon)`: server-side attention query with SQL predicate
|
||||
pushdown.
|
||||
- `feat(ui)`: dismissal as string-keyed for queue-derived cards.
|
||||
|
||||
### Operator Context-Mode Bindings
|
||||
- `feat(daemon)`: migration `041_rig_policy` + typed-primitive store.
|
||||
- `feat(cli)`: `rig policy` command surface
|
||||
(`set` / `show` / `effective` / `cite` / `unset` / `defaults`).
|
||||
|
||||
### Scope Tree Primitive
|
||||
- `feat(cli)`: `rig scope` command surface — mission + slice tiers.
|
||||
- `feat(cli)`: scope templates (release, placeholder, mission-notes,
|
||||
bug-fix, research, etc.) ship under `dist/lib/scope-templates/`.
|
||||
|
||||
### Privacy and Release Prep
|
||||
- `chore(privacy)`: pre-release scrub of residual founder-attribution
|
||||
comments + an internal-shape substrate-path JSDoc example.
|
||||
|
||||
See `CHANGELOG.md` for the full agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
@@ -0,0 +1,596 @@
|
||||
# OpenRig v0.3.3
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.3 is a usability + honesty release. Two new top-level CLI
|
||||
verbs land (`rig archive`/`rig unarchive` and `rig add`/`rig add-member`),
|
||||
several long-standing paper cuts are retired (large-payload startup
|
||||
delivery, dash-prefixed `send_text`, cmux 0.64.x compatibility, `rig down`
|
||||
by name, golden-path discovery), and three honesty surfaces are tightened
|
||||
to stop misclassifying ambiguous outcomes as success (`rig send --verify`
|
||||
delivery outcomes, Codex restore gates, `rig whoami` peers contract). The
|
||||
For-You feed gets a manage-by-exception pass (drill-to-terminal,
|
||||
decision-band sort, one-click approve). Slice 13's deterministic
|
||||
release-surface parser + skill-binding index ship as product code in
|
||||
service of the auto-feature-rollout system.
|
||||
|
||||
One new migration in 0.3.3: `042_rig_archive`
|
||||
(`ALTER TABLE rigs ADD COLUMN archived_at TEXT` + `idx_rigs_archived`).
|
||||
Append-only, non-destructive, no rigs-row restructure. Existing v0.3.2
|
||||
databases upgrade on first `rig daemon start`.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **Large Startup-File Transport (slice 16)** — `TmuxAdapter.sendText`
|
||||
payloads over 100KB now travel via a per-call tmux buffer
|
||||
(`load-buffer` + `paste-buffer -d -r`) instead of being embedded in
|
||||
a single `send-keys -l` argv. Closes the >128KB silent-launch-fail
|
||||
class on Linux (`MAX_ARG_STRLEN`) for large per-seat startup packs.
|
||||
`-r` preserves raw LF so multi-line packs do not early-submit on
|
||||
every newline; the single trailing C-m stays the caller's separate
|
||||
submit. Small payloads keep the exact inline `send-keys -l` command.
|
||||
Temp file + buffer cleanup on success AND paste-error paths; unique
|
||||
names per call so parallel `rig up` seats do not collide.
|
||||
- **`send_text` Inline Dash Sentinel (slice 17)** — inline
|
||||
`tmux send-keys -t <t> -l <text>` now carries a `--` end-of-options
|
||||
sentinel so any startup payload whose content begins with `-`
|
||||
(notably `---` YAML frontmatter — the norm for per-seat packs) is
|
||||
not parsed by tmux as flags. The slice-16 large/buffer path is
|
||||
already immune (content travels via a file, never argv) and is
|
||||
untouched. Inert for non-dash content.
|
||||
- **cmux 0.64.x Compatibility (slice 18)** — cmux 0.64.x renamed the
|
||||
surface identifier (`list-panels --json` rows carry `ref`, not
|
||||
`id`). The transport's `surface.list` normalizer remapped the array
|
||||
key but left row keys untouched, so `surface.sendText` silently
|
||||
failed under the new cmux line. Fixed via a `normalizeSurfaceRow`
|
||||
helper that resolves the handle from `ref ?? surface_ref ??
|
||||
surface_id ?? id`, normalizing every row regardless of array key.
|
||||
One resolution order serves both cmux 0.64.x and 0.63.x — no
|
||||
version-negotiation shim. Adaptation via the same pattern the code
|
||||
already uses for workspace rows and create/split handles.
|
||||
- **`rig archive` Affordance (slice 19)** — new top-level commands
|
||||
`rig archive <rigId>` + `rig unarchive <rigId>`. Archive is
|
||||
non-destructive: rig row is preserved, `archived_at` is set, and a
|
||||
`rig.archived` event fires. Default reads exclude archived (no
|
||||
surprise listings); `rig ps --include-archived`, `?includeArchived=
|
||||
true`, and `?archived=only` opt them back in. The UI gains a lazy
|
||||
collapsible "Archive" section under the localhost host node, fed
|
||||
by a separate archived-only query. `rig up <name>` against an
|
||||
archived-only name is refused honestly with an error pointing at
|
||||
`rig unarchive` — no silent restore, no silent fall-through. The
|
||||
running/degraded archive guard surfaces a 3-part fact / consequence
|
||||
/ action error and accepts `--force` to override. UI query
|
||||
invalidation consumes `rig.archived` / `rig.unarchived` events
|
||||
end-to-end so a CLI archive in one window reactively refreshes
|
||||
another mounted UI.
|
||||
- **`rig add` / `rig add-member` (slice 24)** — new top-level command
|
||||
for the `add_member` converge op. Adds a member to an existing pod
|
||||
in a live rig from a YAML/JSON member fragment file. Built on the
|
||||
new converge spine (`Op` union + differ + `convergeOp`), with
|
||||
imperative CLI sugar over the converge interface (not a bypass).
|
||||
Daemon route `POST /api/rigs/:rigId/pods/:podNamespace/members`
|
||||
maps `AddMemberOutcome` codes to HTTP (404 / 409 / 400 / 201).
|
||||
MCP tool `rig_add` ships in lockstep. Edge validation tightened in
|
||||
the same slice: non-array `edges` is rejected (not silently
|
||||
dropped); edge kinds are validated against the canonical
|
||||
`VALID_EDGE_KINDS` set exported from `rigspec-schema.ts` and
|
||||
reused (not duplicated), so `rig add` edges share the existing
|
||||
graph contract.
|
||||
- **`rig down` Accepts Name-or-Id (slice 22)** — the v0.2.0-era
|
||||
`rig down <name>` HTTP 404 caveat is gone. The resolver now fetches
|
||||
`/api/rigs/summary?includeArchived=true` and id-matches across ALL
|
||||
rigs (archived ids still reach teardown), then name-matches ACTIVE
|
||||
rigs only. Active+archived same-name pair resolves the active rig;
|
||||
archived-only name does not resolve by name. The packaged
|
||||
`openrig-user` skill (specs source + assets/plugins copy +
|
||||
`_canonical` mirror) and the `down.ts` resolver comment are
|
||||
corrected to reflect the real behavior. Historical CHANGELOG /
|
||||
v0.3.1 / v0.3.2 release notes are left intact — the bug genuinely
|
||||
existed at those releases.
|
||||
- **Honest Codex Restore Gate (slice 21 FR-2)** — `verifyResumeLaunch`
|
||||
no longer returns `ok:true` unless the native-resume-probe proves
|
||||
the seat actually resumed. Unresolved operator-action gates (an
|
||||
update that cannot auto-dismiss, trust, model-selection) and a
|
||||
bounded poll that never reaches `resumed` now return `ok:false`
|
||||
with `recovery: attention_required` and last-12-line pane evidence,
|
||||
routed into the existing
|
||||
`HarnessLaunchResult` -> startup-orchestrator
|
||||
`startupStatus: attention_required` -> `ps`/`status` projection —
|
||||
no new state machinery. The working auto-dismiss path is preserved
|
||||
(a skippable update gate still auto-dismisses and continues to
|
||||
success); only UNRESOLVED gates fail loudly. Resolves the 04.3
|
||||
clean-VM `kernel_state bootstrap_failed` failure-mode the herder
|
||||
surfaced.
|
||||
- **`rig send --verify` Honest Delivery Outcomes (slice 99.0.6.3)** —
|
||||
the prior `Verified: no` collapsed two materially different states
|
||||
into one: a message that LANDED (text pasted + Enter submitted,
|
||||
both succeeded) but whose post-send capture raced a TUI redraw,
|
||||
and a message that genuinely did not land. Careful agents paid a
|
||||
manual `rig capture` roundtrip on every send; careless agents read
|
||||
no as failure and double-sent. The three outcomes are now named in
|
||||
the response:
|
||||
- `delivered` — verified succeeded.
|
||||
- `rendered-unconfirmed` — text + Enter both succeeded but the
|
||||
capture could not re-confirm (redraw race, or capture threw).
|
||||
Landed-but-unconfirmable, NOT failure.
|
||||
- `failed` — the transport itself failed.
|
||||
The legacy `Verified: yes/no` line is preserved verbatim
|
||||
(parsers); a new `Delivery:` line carries the named outcome plus,
|
||||
for the middle case, a `rig capture <session>` confirmation
|
||||
pointer. `--json` carries the field through automatically.
|
||||
- **`rig whoami` Peers Contract (slice 99.0.6.1)** — the `peers[]`
|
||||
field was readable as a directionally-edged subset or as host
|
||||
inventory; it is neither. The grounded contract is now stated
|
||||
in-band: `peers[]` is this rig's roster EXCLUDING self (built from
|
||||
`rig.nodes` with only a self-skip — no edge/status/binding
|
||||
filter), `edges{}` is the directional graph, and
|
||||
`rig ps --nodes` is the inventory including self + live state.
|
||||
Additive clarification only — no behavior or shape change.
|
||||
`WhoamiResult` gains a required `peersNote` field carrying the
|
||||
three pointers; the CLI Peers header keeps the literal `Peers:`
|
||||
prefix verbatim (shipped tests grep on it) and adds the in-band
|
||||
clarifier.
|
||||
- **Workflow Instantiate By Name (slice 04.1)** — `rig workflow
|
||||
instantiate <built-in-name>` (e.g. `conveyor`, `basic-loop`)
|
||||
used to fail with `spec_file_missing` because instantiate fed the
|
||||
bare name straight to `WorkflowSpecCache.readThrough` (which
|
||||
treats its argument as a literal file path). Only a raw
|
||||
sourcePath worked — a hidden path that violated the
|
||||
fresh-operator goal: discovery advertised names you then could
|
||||
not use. `WorkflowRuntime.instantiate` now resolves the passed
|
||||
identifier against the seeded spec cache BY NAME first, falling
|
||||
back to literal-sourcePath only when no named spec matches. The
|
||||
cache returns the STORED path verbatim (no source-tree
|
||||
re-derivation), preserving the `dist/builtins` production layout.
|
||||
- **Guided Golden Path State Clarity (slice 04.2)** — the
|
||||
new-operator golden path said `rig workflow list` shows the
|
||||
seeded built-ins, but `rig workflow list` lists workflow
|
||||
INSTANCES; the built-in / spec discovery surface is
|
||||
`rig workflow specs` (built-ins tagged `(built-in)`). A fresh
|
||||
operator following the ordered path before `workflow instantiate
|
||||
<name>` was sent to the wrong command. Corrected in
|
||||
`docs/reference/getting-started.md` and in the CLI's
|
||||
`goldenPathNextSteps()` step 4.
|
||||
- **For-You Manage-By-Exception (slice 20)** — feed-interaction UX
|
||||
on existing signals only (the 0.4.0 attention-detection layer is
|
||||
untouched):
|
||||
- **Drill-to-terminal** — new `FeedCardTerminalDrill` opens the
|
||||
resolved source/author session's terminal PREVIEW directly
|
||||
from a card, by reusing `TerminalPreviewPopover` over
|
||||
`GET /api/sessions/:sessionName/preview`. Honest framing —
|
||||
"terminal preview" / "captured snapshot, not live", never
|
||||
claims live state; when no session resolves, the drill renders
|
||||
DISABLED with an honest title rather than an empty terminal.
|
||||
- **Decision-band sort** — a stable two-band partition lifts
|
||||
action-required + approval cards above progress / observation /
|
||||
shipped within the existing newest-first comparator. NOT a
|
||||
ranking engine (no scores, no card mutation). Applied at the
|
||||
single Feed merge-consumption seam.
|
||||
- **One-click approve (approve-only)** — `VerbActions` gains an
|
||||
additive `oneClickVerbs` prop. APPROVE submits directly on
|
||||
click; deny/route stay select+confirm. Defense in depth: the
|
||||
prop type narrows to `Extract<MissionControlVerb, "approve">`
|
||||
so misuse is a compile error, and a runtime
|
||||
`ONE_CLICK_SAFE_VERBS` allowlist rejects cast-forced
|
||||
`"deny"`/`"route"` at the call site. Reuses the same
|
||||
`performSubmit` mutation path (identical optimistic receipt
|
||||
and held-error behavior).
|
||||
- **CLI Release-Surface Parser (slice 13.1)** — deterministic TypeScript
|
||||
Compiler API extractor that walks the Commander registrations in
|
||||
`packages/cli/src/commands/*.ts` at two git refs and emits a
|
||||
structured release-surface-diff of added commands, added flags, and
|
||||
removed/renamed surface. Resolves both Commander idioms (chained
|
||||
inline subcommands and factory indirection). Emits the
|
||||
REGISTRATION name (`rig-policy.ts` surfaces as `policy`); option
|
||||
name-tokens are taken from `.option()` arg[0] only, so
|
||||
template-literal descriptions never drop an option. Reads batched
|
||||
via one `git cat-file --batch` per ref (fast, offline,
|
||||
deterministic). 3-part honest failure shape; never a silent empty
|
||||
diff. Not registered as a `rig` verb at 0.3.3 (a separate decision)
|
||||
— the module is product code with hermetic test fixtures
|
||||
(including a v0.3.1..v0.3.2 worked example).
|
||||
- **Skill <-> CLI-Surface Binding Index (slice 13.2)** — deterministic
|
||||
offline lookup that joins a release surface-diff to "which skills
|
||||
are affected by this release". Composes with the slice 13.1 parser;
|
||||
invocation-agnostic. Implements the ratified join grammar:
|
||||
component-wise prefix match in either direction (so `up` does not
|
||||
match `update`); conservative over-include bias for the
|
||||
no-false-negative floor. Drops `--version` from the binding index
|
||||
(the canonical grammar is command paths only, not global flags);
|
||||
the skill body still documents `rig --version` as prose. Ships
|
||||
with the v0.3.2-affected-skills regression fixture derived from
|
||||
the corpus.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### `rig archive` and `rig unarchive`
|
||||
|
||||
New top-level commands. Archive is non-destructive: the rig row is
|
||||
preserved, an `archived_at` timestamp is set, and a `rig.archived`
|
||||
event fires.
|
||||
|
||||
```
|
||||
rig archive <rigId> [--force] [--json]
|
||||
rig unarchive <rigId> [--json]
|
||||
```
|
||||
|
||||
`rig unarchive` is always safe (no `--force`, no guard). `rig
|
||||
archive` against a running or degraded rig surfaces a 3-part
|
||||
fact / consequence / action error and accepts `--force` to
|
||||
override.
|
||||
|
||||
Default reads exclude archived rigs:
|
||||
|
||||
```
|
||||
rig ps [--include-archived]
|
||||
GET /api/rigs/summary?includeArchived=true
|
||||
GET /api/rigs/summary?archived=only
|
||||
GET /api/ps?includeArchived=true
|
||||
```
|
||||
|
||||
`rig ps` marks archived rows with `*` and renders a legend. The
|
||||
`--include-archived` flag propagates through cross-host argv.
|
||||
|
||||
`rig up <name>` against an archived-only name is refused honestly
|
||||
with an error pointing at `rig unarchive` (no silent restore, no
|
||||
silent fall-through); `--json` form emits `rig_archived`.
|
||||
|
||||
UI surfaces a lazy collapsible "Archive" section under the
|
||||
localhost host node. The section is fed by a separate archived-only
|
||||
query (`useArchivedRigs`, `?archived=only`), not by client-side
|
||||
filtering of the default list. `rig.archived` / `rig.unarchived`
|
||||
events drive global query invalidation
|
||||
(`["rigs","summary"]` + `["rigs","summary","archived"]` + `["ps"]`)
|
||||
so a CLI archive reactively refreshes other mounted UIs.
|
||||
|
||||
### `rig add` and `rig add-member`
|
||||
|
||||
New top-level command for the `add_member` converge op. Adds a
|
||||
member to an existing pod in a live rig from a YAML/JSON member
|
||||
fragment file:
|
||||
|
||||
```
|
||||
rig add <rigId> <podNamespace> <member-fragment-path> \
|
||||
[--json] [--rig-root <root>]
|
||||
```
|
||||
|
||||
The member fragment tolerates a bare member or a `{ member }`
|
||||
wrapper. Field names follow the spec snake_case shape (`id`,
|
||||
`runtime`, `agent_ref`, `profile`, `cwd`, ...). Optional
|
||||
pod-local `edges` (array of `{from, to, kind}`) is preserved
|
||||
through to the converge op:
|
||||
|
||||
- Present-but-non-array `edges` is REJECTED (not silently dropped):
|
||||
CLI prints a clear error and exits 1 before POST; route returns
|
||||
400 `validation_failed`; domain also returns `validation_failed`
|
||||
as defense in depth.
|
||||
- Edge `kind` is validated against the canonical `VALID_EDGE_KINDS`
|
||||
set exported from `rigspec-schema.ts` and reused (not duplicated).
|
||||
`kind: nonsense` returns 400 `validation_failed` naming the valid
|
||||
set; no edge is persisted.
|
||||
|
||||
Daemon route: `POST /api/rigs/:rigId/pods/:podNamespace/members`.
|
||||
Outcome -> HTTP mapping: `rig_not_found` / `pod_not_found` -> 404;
|
||||
`member_conflict` -> 409; `validation_failed` /
|
||||
`preflight_failed` -> 400; success -> 201 (with per-node launch
|
||||
status in the body). MCP tool `rig_add` ships in lockstep.
|
||||
|
||||
### `rig down` Accepts Name-or-Id
|
||||
|
||||
`rig down <name>` now works. The v0.2.0-era `rig down <name>` HTTP
|
||||
404 caveat is gone.
|
||||
|
||||
```
|
||||
rig down <rigId-or-name> [--delete] [--json]
|
||||
```
|
||||
|
||||
Resolver semantics:
|
||||
|
||||
- `/api/rigs/summary?includeArchived=true` is fetched, then:
|
||||
- id-match across ALL rigs (archived ids still reach teardown);
|
||||
- name-match ACTIVE rigs only (`archivedAt == null`).
|
||||
|
||||
Same-name active+archived pair resolves the ACTIVE rig (not
|
||||
ambiguous). Archived-only name does not resolve by name (use the
|
||||
id, or `rig unarchive` first).
|
||||
|
||||
### Send Text Inline Dash Sentinel (slice 17)
|
||||
|
||||
The internal tmux `send_text` path now carries an explicit `--`
|
||||
end-of-options sentinel before user-supplied content, so tmux no
|
||||
longer parses content beginning with `-` or `--` as its own flag
|
||||
arguments. Companion to the slice 16 large-payload transport fix:
|
||||
|
||||
```
|
||||
rig send <session> "content with -- dashes or - hyphens"
|
||||
```
|
||||
|
||||
delivers literally without the agent having to escape or work
|
||||
around dash-prefixed leading characters (notably `---` YAML
|
||||
frontmatter in per-seat startup payloads). This is a daemon-side
|
||||
adapter fix in `packages/daemon/src/adapters/tmux.ts`; the
|
||||
`rig send` CLI surface itself is unchanged.
|
||||
|
||||
### `rig send --verify` Delivery Outcomes
|
||||
|
||||
`--verify` responses now name the outcome explicitly:
|
||||
|
||||
- `delivered` — verified succeeded.
|
||||
- `rendered-unconfirmed` — text + Enter both succeeded but the
|
||||
post-send capture could not re-confirm (TUI redraw race, or
|
||||
capture threw). The message LANDED; the render is not
|
||||
re-confirmable.
|
||||
- `failed` — the transport itself failed.
|
||||
|
||||
The legacy `Verified: yes/no` line is preserved verbatim
|
||||
(parsers); a new `Delivery:` line carries the named outcome plus,
|
||||
for `rendered-unconfirmed`, a `rig capture <session>`
|
||||
confirmation pointer. `--json` carries the `outcome` field
|
||||
through.
|
||||
|
||||
`exit` codes: `delivered` and `rendered-unconfirmed` exit 0; the
|
||||
middle is not dressed as success or failure. `failed` exits
|
||||
non-zero with HTTP mapping unchanged. Mid-work / wait-for-idle
|
||||
REFUSALS deliberately carry no outcome (nothing was sent; a
|
||||
refusal is not a failed delivery).
|
||||
|
||||
### `rig whoami --json` Peers Contract
|
||||
|
||||
`peers[]` is this rig's roster EXCLUDING self. It is NOT a
|
||||
directionally-edged subset, and it is NOT host inventory.
|
||||
|
||||
- For the directional graph, read `edges{}`.
|
||||
- For the inventory including self + live state, use
|
||||
`rig ps --nodes`.
|
||||
|
||||
`WhoamiResult.peersNote` carries the contract in-band. The CLI
|
||||
Peers header keeps the literal `Peers:` prefix verbatim (shipped
|
||||
parsers grep on it) and adds the clarifier pointing at `edges`
|
||||
and `rig ps --nodes`. `peers[]` name and shape are untouched;
|
||||
this is an additive clarification only.
|
||||
|
||||
### Workflow Instantiate By Name
|
||||
|
||||
`rig workflow instantiate <name>` now resolves built-in names
|
||||
end-to-end:
|
||||
|
||||
```
|
||||
rig workflow instantiate <name-or-source-path> [--cwd <path>] [--json]
|
||||
```
|
||||
|
||||
Built-in names (`conveyor`, `basic-loop`, ...) are resolved
|
||||
against the seeded spec cache BY NAME first; literal source paths
|
||||
remain a fallback for unmatched names (honest 404 if neither
|
||||
matches). Closes the discovery / use divergence where
|
||||
`rig workflow specs` advertised names that `rig workflow
|
||||
instantiate` could not consume.
|
||||
|
||||
### Guided Golden Path Discovery Surface
|
||||
|
||||
The new-operator golden path now points at the correct discovery
|
||||
verb:
|
||||
|
||||
- `rig workflow specs` lists built-in / seeded workflow specs
|
||||
(`(built-in)` tagged). USE THIS to discover names before
|
||||
`rig workflow instantiate <name>`.
|
||||
- `rig workflow list` lists workflow INSTANCES (created from
|
||||
specs). Empty for a fresh operator.
|
||||
|
||||
Corrected in `docs/reference/getting-started.md` and in the
|
||||
CLI's `setup` golden-path text.
|
||||
|
||||
### For-You Manage-By-Exception
|
||||
|
||||
Feed-interaction UX on existing signals (the 0.4.0
|
||||
attention-detection layer is untouched):
|
||||
|
||||
- **Drill to terminal preview** — each card's footer carries a
|
||||
drill that opens the resolved source/author session's terminal
|
||||
PREVIEW via `GET /api/sessions/:sessionName/preview`. Labelled
|
||||
"terminal preview" / "captured snapshot, not live" — never
|
||||
claims live state. Disabled with an honest title when no
|
||||
session resolves.
|
||||
- **Decision-band sort** — action-required + approval cards lift
|
||||
above progress / observation / shipped within the existing
|
||||
newest-first comparator. Stable two-band partition; not a
|
||||
ranking engine.
|
||||
- **One-click approve** — APPROVE submits directly on click; deny
|
||||
and route stay select+confirm. Compile-time + runtime guards
|
||||
prevent the one-click path from being applied to non-approve
|
||||
verbs (`ONE_CLICK_SAFE_VERBS = {"approve"}`).
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.3.3
|
||||
rig --version # expect 0.3.3
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
Migration `042_rig_archive` applies automatically on first
|
||||
post-upgrade daemon start. It is `ALTER TABLE rigs ADD COLUMN
|
||||
archived_at TEXT` plus `idx_rigs_archived` — append-only,
|
||||
non-destructive, no rigs-row restructure / rebuild / drop.
|
||||
Existing rows are unaffected.
|
||||
|
||||
For the new `rig archive` surface: default reads still exclude
|
||||
archived rigs at the daemon layer. With no rigs archived, no
|
||||
behavior change. `rig ps --include-archived`, the
|
||||
`?includeArchived=true` / `?archived=only` query params, and the
|
||||
UI's lazy Archive section opt archived rigs back in.
|
||||
|
||||
For the new `rig add` surface: the member fragment file is a YAML
|
||||
or JSON document using the rig spec's snake_case field names
|
||||
(same authoring format as a `RigSpec` pod member). The MCP
|
||||
`rig_add` tool accepts the same fragment shape.
|
||||
|
||||
For `rig down <name>`: previously-documented agent workarounds
|
||||
that resolved the rig id by hand before calling `rig down` no
|
||||
longer need to do that step. The id-based form continues to work
|
||||
unchanged.
|
||||
|
||||
## Known Limitations / 0.3.3 Deferrals
|
||||
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after the build-time forensic. FR-2 (honest restore
|
||||
gate) ships alone in 0.3.3.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: the slice
|
||||
13 vertical's dispatcher / rollout-invocation layer is the
|
||||
skill-layer landing for this release, not product code. The
|
||||
product-code halves (13.1 parser + 13.2 binding index) ship in
|
||||
0.3.3; invocation is intentionally out of the shipped runtime
|
||||
bundle.
|
||||
- **Slice-05 sub-scopes from 0.3.2**: agent / port / managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-rig
|
||||
pathway acceptance (Item 4.4); the `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Carried forward; CLI-surface
|
||||
decision (A-breaking rename vs A-additive flag) still pending.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; new `accepted` queue state is a schema model change.
|
||||
Carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) physically moved to the `release-0.3.3` mission
|
||||
tree at 0.3.2 wrap; the 0.3.3 cut focused on usability + honesty
|
||||
surfaces ahead of the onboarding journey. Carried forward to a
|
||||
later release.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.3.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-layer
|
||||
routing path remains; the daemon's `view show <name>` route
|
||||
returns JSON correctly when invoked directly. Workaround:
|
||||
human-readable output for now. Wrapper-layer fix is the
|
||||
documented target.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- TypeScript checks clean on every touched file at per-commit
|
||||
guard across every shipped slice.
|
||||
- Per-slice focused-suite tests green:
|
||||
- Slice 16: tmux-adapter buffer path (small + large) +
|
||||
Claude/Codex adapters large-payload delivery.
|
||||
- Slice 17: tmux-adapter dash-prefix discriminator
|
||||
(`-l -- '<text>'`); 77/77 in the touched suite.
|
||||
- Slice 18: cmux 130/130 unit tests; VM e2e 0.64.x launch +
|
||||
0.63.x non-regression.
|
||||
- Slice 19: archive route / repo / projection + UI
|
||||
`topology-archive-section` + `global-events` (archived /
|
||||
unarchived invalidation); AC-4 host-scoped no-cross-host-leak;
|
||||
AC-8 archive-vs-down-delete non-destruction discriminator.
|
||||
- Slice 22: down 22/22 (id-exact-first; archived-id reaches
|
||||
teardown; active+archived same-name -> active; archived-only
|
||||
name halts not_found; AC-3 active-active discriminator).
|
||||
- Slice 21 FR-2: 45/45 in the verifyResumeLaunch suite
|
||||
including the unresolved-gate discriminator, trust-gate,
|
||||
model-selection-gate, and poll-exhaustion coverage.
|
||||
- Slice 24: add-member-route 8/8; add (CLI) 8/8; edge
|
||||
validation +6 (domain + route + CLI); cli index/expand/MCP +
|
||||
daemon rig-expansion green (41 cli + 49 daemon).
|
||||
- Slice 99.0.6.3: session-transport + transport-routes 76/76;
|
||||
cli send 12/12.
|
||||
- Slice 99.0.6.1: whoami-service 15/15; cli whoami 20/20.
|
||||
- Slice 04.1: workflow-spec-cache + workflow-routes 44/44.
|
||||
- Slice 04.2: setup AC-1 asserts the path references
|
||||
`rig workflow specs` and does not use `rig workflow list`.
|
||||
- Slice 20: feed-classifier 17, attention-feed 17,
|
||||
verb-actions-one-click 6, verb-actions-optimistic-error 7,
|
||||
feed-card-terminal-drill 4, feed-decision-band-sort 5,
|
||||
feed-card-dismiss 12, activity-feed 25, session-preview-pane
|
||||
3 — 102/102 touched + adjacent.
|
||||
- Slice 13.1: release-surface 10/10; sample byte-identical
|
||||
across two runs (determinism).
|
||||
- Slice 13.2: affected-skills 12/12.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + dual rev1 CLEAR on each
|
||||
merge.
|
||||
- The cumulative-workspace test runs surface the documented
|
||||
pre-existing baseline (cmux / preflight / archived_at-harness /
|
||||
@tanstack dep cascade); ZERO regressions versus the v0.3.2 base
|
||||
on every shipped slice.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.2 -> v0.3.3 delta is 56 commits / 93 files /
|
||||
+6,669 insertions / -238 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Startup Transport + Cmux Compatibility
|
||||
- `fix(daemon)`: deliver large startup `send_text` via tmux
|
||||
buffer, not argv (slice 16).
|
||||
- `fix(daemon)`: add `--` end-of-options sentinel to inline
|
||||
`send_text` (slice 17).
|
||||
- `fix(daemon)`: cmux 0.64.x surface-listing `ref` / `id`
|
||||
normalization (slice 18).
|
||||
|
||||
### `rig archive` Affordance
|
||||
- `feat(daemon)`: archive vertical — migration `042_rig_archive`,
|
||||
repository filter, projection filter, routes
|
||||
`/api/rigs/:id/archive` + `/unarchive`,
|
||||
`?includeArchived=true` / `?archived=only` query params.
|
||||
- `feat(cli,ui)`: `rig archive` / `rig unarchive` commands,
|
||||
`rig ps --include-archived`, `rig up` archived-name refusal,
|
||||
UI archived-only hook + lazy "Archive" section under localhost
|
||||
host node.
|
||||
- `fix(ui)`: wire `rig.archived` / `rig.unarchived` into global
|
||||
query invalidation.
|
||||
|
||||
### `rig add` / `rig add-member`
|
||||
- `feat(daemon)`: `add_member` converge op on
|
||||
`PodRigInstantiator`; topology-converge spine
|
||||
(`Op` union + differ + `convergeOp`) scaffold.
|
||||
- `feat(cli,daemon)`: `rig add` HTTP route + CLI command for the
|
||||
`add_member` converge op.
|
||||
- `feat(cli)`: `rig_add` MCP tool.
|
||||
- `fix(daemon,cli)`: honest edge validation — reject non-array
|
||||
edges + non-canonical kinds (shared `VALID_EDGE_KINDS` with
|
||||
rigspec schema).
|
||||
|
||||
### `rig down` Name-or-Id
|
||||
- `fix(cli,docs)`: `rig down` resolves by name-or-id;
|
||||
`?includeArchived=true` summary fetch; archived-id reaches
|
||||
teardown; name-match active only; packaged-skill caveat
|
||||
retired across specs source + assets/plugins copy +
|
||||
`_canonical` mirror.
|
||||
|
||||
### Honest Outcomes
|
||||
- `feat(daemon)`: honest Codex restore gate — unresolved gates
|
||||
are `attention_required`, not launch success (slice 21 FR-2).
|
||||
- `feat(daemon,cli)`: `rig send --verify` honest delivery
|
||||
outcomes — `delivered` / `rendered-unconfirmed` / `failed`.
|
||||
- `feat(daemon,cli,docs)`: `rig whoami` peers contract stated
|
||||
in-band — `peersNote` field + CLI clarifier; `peers[]` shape
|
||||
unchanged.
|
||||
|
||||
### Workflow Discovery + Instantiate
|
||||
- `fix(daemon)`: resolve workflow instantiate by discovered name
|
||||
(slice 04.1 AC-3).
|
||||
- `fix(cli,docs)`: golden path points to `rig workflow specs`
|
||||
for built-in discovery (slice 04.2 rev1-r2).
|
||||
|
||||
### For-You Manage-By-Exception
|
||||
- `feat(ui)`: For-You drill-to-terminal + decision-band sort +
|
||||
one-click approve (slice 20).
|
||||
- `fix(ui)`: enforce approve-only one-click in code — narrowed
|
||||
type + runtime `ONE_CLICK_SAFE_VERBS` allowlist.
|
||||
|
||||
### Auto-Feature-Rollout (product-code halves)
|
||||
- `feat(cli)`: CLI release-surface parser POC (slice 13.1) —
|
||||
deterministic Commander-registration extractor; sample diff
|
||||
fixture; v0.3.1..v0.3.2 worked example.
|
||||
- `feat(cli)`: skill <-> CLI-surface binding-index lookup
|
||||
(slice 13.2 component 2) — composes with 13.1; ratified join
|
||||
grammar; v0.3.2-affected-skills regression fixture.
|
||||
- `fix(cli)`: drop `--version` flag token from the binding
|
||||
index (slice 13.2 guard B1).
|
||||
|
||||
See `CHANGELOG.md` for the agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
@@ -0,0 +1,593 @@
|
||||
# OpenRig v0.3.4
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.3.4 is a recovery + resilience release. Two new top-level CLI
|
||||
verbs land (`rig start` and `rig reconcile-session`), `rig up` flips
|
||||
from fresh-by-default to resume-original-by-default with `--fresh
|
||||
<seats...>` as the explicit per-seat opt-in, the restore status surface
|
||||
collapses every honest outcome into a named five-term vocabulary
|
||||
(`resumed` / `fresh-primed` / `awaiting-decision` / `attention_required`
|
||||
/ `failed`) across `rig up` / `rig restore` / `rig ps`, and a periodic
|
||||
snapshot scheduler ships as the crash-insurance floor under
|
||||
event-driven and teardown snapshots. A handful of long-standing
|
||||
recovery paper cuts are retired in the same cut: pod-aware narrow
|
||||
launch leaves dead-end-land for a node-granular managed partial
|
||||
restore through `rig launch`, a stuck `attention_required` seat now
|
||||
clears through an evidence-gated `rig seat clear-attention` rather
|
||||
than hand-edited SQLite, the Codex profile-v2 preflight stops
|
||||
misclassifying invalid TOML as a legacy-profile reason, the cmux
|
||||
launch-readiness gate surfaces honest partial workspace state, and a
|
||||
slow Codex resume no longer gets mis-categorized.
|
||||
|
||||
No new schema-bumping migrations in 0.3.4. Existing v0.3.3 databases
|
||||
upgrade by running `rig daemon start` on the new daemon.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
- **`rig start` (slice 01)** — new top-level recovery entry point.
|
||||
One command that sequences daemon-start, kernel readiness wait,
|
||||
restore-usable candidate listing, picker or headless flag
|
||||
selection, and per-rig restore + reconcile. Re-codes nothing
|
||||
underneath: it sequences existing primitives. Flags `--last`,
|
||||
`--all`, `--rigs <names...>`, `--json`. Candidate listing and
|
||||
apply are both id-grounded against `/api/rigs/:id/up` (the
|
||||
same-name-rigs class never reaches the name-based `/api/up`
|
||||
route).
|
||||
- **`rig up` Resume-By-Default (slice 02)** — `rig up <existing-rig>`
|
||||
now resumes original sessions by default. `--fresh <seats...>`
|
||||
becomes the explicit per-seat opt-in for operation B (deliberate
|
||||
fresh-prime). Previously the default was fresh-prime; agents had
|
||||
to know to ask for resume out-of-band. The seat-level outcome
|
||||
rides the five-term vocabulary below; `awaiting-decision` is the
|
||||
honest zero-session state when the daemon cannot resume and the
|
||||
operator has not opted into fresh.
|
||||
- **`rig reconcile-session` (slice 03)** — new top-level command.
|
||||
Adopts a LIVE, hand-resumed canonical session back into its
|
||||
persisted node without launching, relaunching, killing, replaying
|
||||
startup, pressing resume menus, compacting, or typing into the
|
||||
pane. The repair for "I manually resumed the seat; the daemon
|
||||
still shows it down." Same node id, no re-key; reports projection
|
||||
drift honestly; conversation continuity is never claimed.
|
||||
- **`rig up --plan` Read-Only Restore Preview (slice 04)** —
|
||||
`rig up <existing-rig> --plan` returns a read-only restore
|
||||
preview keyed by the five-term vocabulary and an honest async
|
||||
timeout. No mutation; no silent claims of resumability when the
|
||||
daemon cannot prove it.
|
||||
- **Five-Term Restore Status Vocabulary (slice 06)** — the prior
|
||||
2-3 term collapsed model is replaced across `rig up`,
|
||||
`rig restore`, and `rig ps`:
|
||||
- `resumed` — original session continuity proven.
|
||||
- `fresh-primed` — deliberate fresh-prime (operation B); a brand
|
||||
new session was started instead of resumed.
|
||||
- `awaiting-decision` — daemon could not resume and operator has
|
||||
not opted into fresh; ZERO session started. The honest
|
||||
zero-session state, not a silent failure.
|
||||
- `attention_required` — seat is live or parked but needs
|
||||
operator action (auth gate, model-selection menu, trust
|
||||
prompt, stuck recovery). NEVER `failed`.
|
||||
- `failed` — the launch transport itself failed.
|
||||
- **Codex Profile-V2 Preflight (slice 07)** — profile-load check
|
||||
on profile-bearing launch and restore surfaces. Legacy-profile
|
||||
detection narrowed to legacy-specific patterns only (`contains
|
||||
legacy`, `[profiles.<name>]`, `cannot be used while.*legacy`,
|
||||
`legacy profile selector`); the prior generic match against
|
||||
`failed to load configuration` was also catching invalid-TOML
|
||||
stderr and giving the wrong migration hint. Invalid TOML now
|
||||
surfaces the actual parse reason (up to 3 stderr lines) with a
|
||||
TOML-validity hint.
|
||||
- **cmux Launch Readiness (slice 08)** — launch path no longer
|
||||
silently produces a partial cmux workspace. When cmux comes up
|
||||
with a subset of expected windows / panes, the seat surfaces an
|
||||
honest partial-workspace state with a one-click open-missing
|
||||
affordance instead of being reported as launched-clean.
|
||||
- **Periodic Snapshots (slice 09)** — `snapshots.periodic` config
|
||||
family plus a daemon-side `PeriodicSnapshotScheduler`. Every
|
||||
enabled rig captures an `auto-periodic` snapshot on interval and
|
||||
the repository prunes by retention-keep. Provides a true
|
||||
crash-insurance floor — `auto-periodic` and `auto-pre-down`
|
||||
compete on freshness in the same restore selector (the newer
|
||||
wins), so a daemon crash between teardown snapshots no longer
|
||||
costs an arbitrary amount of lifecycle state. Last-snapshot /
|
||||
floor surfaced in `rig ps` / status.
|
||||
- **`rig seat clear-attention` (slice 10)** — new subcommand on
|
||||
`rig seat`. Evidence-gated, operator-attested, audited reconcile
|
||||
of a stuck `attention_required` seat back to `ready`. Replaces
|
||||
the prior hand-edit-SQLite / fake-clear pattern that agents
|
||||
reached for when a seat got stuck. The audit row records who
|
||||
cleared, from-state, the evidence path, and any operator
|
||||
attestation; `--reason <text>` is the operator-attestation
|
||||
override for the evidence gate.
|
||||
- **Node-Granular Managed Partial Restore (slice 11)** — `rig
|
||||
launch <rigId> <nodeRef>` and `rig launch <rigId> --seats <a,b>`
|
||||
relaunch a seat or subset by logical id through the
|
||||
orchestration layer. Retires the v0.3.x-era
|
||||
`pod_aware_launch_unsupported` dead-end and the
|
||||
"use rig up instead" workaround that lost per-seat control.
|
||||
Held seats carry a `held-reason`; failed targets surface as
|
||||
`failed (liveness unknown)`; already-running seats are reported,
|
||||
not relaunched.
|
||||
- **Slow Codex Resume Classification Fix (slice 13)** — internal
|
||||
classification tweak for slow-resume Codex sessions: a seat that
|
||||
was genuinely resuming but slowly was previously mis-categorized
|
||||
by `verifyResumeLaunch`. Now correctly classified through the
|
||||
same five-term vocabulary.
|
||||
- **Pod-Aware Claude Resume-Selection Menu (slice 05 rev1)** —
|
||||
follow-on to 0.3.3 slice 21 FR-2: `ClaudeCodeAdapter.verifyResumeLaunch`
|
||||
now treats a Claude resume-selection menu the same way it treats
|
||||
the Codex unresolved-gate case — returns immediately with
|
||||
`recovery: attention_required` and last-12-line pane evidence,
|
||||
never sends a numeric selection keystroke, and never lets the
|
||||
loop time out to a generic failure.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### `rig start` — Recovery Entry Point
|
||||
|
||||
New top-level command. Starts the daemon, waits for the kernel, lists
|
||||
restore-usable rigs, and applies restore + reconcile through the
|
||||
existing primitives:
|
||||
|
||||
```
|
||||
rig start Interactive: daemon + kernel + pick-and-restore
|
||||
rig start --last Headless: restore all rigs that were last running
|
||||
rig start --all Headless: restore all rigs with restore-usable snapshots
|
||||
rig start --rigs <name> [<name>...] Headless: restore only the named rigs
|
||||
rig start --json JSON output for agents
|
||||
```
|
||||
|
||||
Listing and apply are both id-grounded against
|
||||
`POST /api/rigs/:id/up`. Same-name rigs surface as separate
|
||||
candidates; selection carries the candidate's `rigId` end-to-end so
|
||||
the name-based `/api/up` route is never consulted (no
|
||||
`ambiguous_name` 409 in the recovery path).
|
||||
|
||||
`rig start` is sequencing-only; it does not invent recovery
|
||||
semantics. The per-rig restore call is the same `/api/rigs/:id/up`
|
||||
that `rig up <existing>` uses, and seat-level outcomes carry the
|
||||
same five-term vocabulary.
|
||||
|
||||
### `rig up` — Resume Original By Default
|
||||
|
||||
`rig up <existing-rig>` resumes original sessions by default.
|
||||
`--fresh <seats...>` is the per-seat opt-in for deliberate
|
||||
fresh-prime (operation B):
|
||||
|
||||
```
|
||||
rig up <existing-rig> Resume original sessions (default)
|
||||
rig up --existing <name> --fresh dev-impl Deliberately fresh-prime dev-impl only
|
||||
rig up <existing-rig> --plan Read-only restore preview (no mutation)
|
||||
```
|
||||
|
||||
- Seats whose original session resumes report `resumed`.
|
||||
- Seats opted into `--fresh` report `fresh-primed`.
|
||||
- Seats the daemon cannot resume and the operator has not opted
|
||||
into fresh report `awaiting-decision` — ZERO session started.
|
||||
Headless callers see the explicit hint `rig up --existing
|
||||
<source> --fresh <logical-id>`; TTY callers are offered a
|
||||
per-seat `[y/N]` prompt before any fresh-prime is started.
|
||||
- Seats live or parked but needing operator action (auth gate,
|
||||
model selection, trust prompt, stuck recovery) report
|
||||
`attention_required` — never silently re-classified as `failed`.
|
||||
- Only transport-level failure reports `failed`.
|
||||
|
||||
The prior fresh-by-default behavior is gone. If a workflow relied on
|
||||
implicit fresh, name `--fresh <seats...>` explicitly.
|
||||
|
||||
### `rig up --plan` — Read-Only Restore Preview
|
||||
|
||||
```
|
||||
rig up <existing-rig> --plan [--json]
|
||||
```
|
||||
|
||||
Returns the restore plan without mutating: per-node intended action
|
||||
(resume / fresh-prime / awaiting-decision), the snapshot the plan
|
||||
would consume, and whether the current state would be captured before
|
||||
mutation. The plan path carries an honest async timeout — when the
|
||||
preview cannot complete within bound, the response says so rather
|
||||
than claiming a clean plan.
|
||||
|
||||
### `rig reconcile-session` — Adopt a Hand-Resumed Session
|
||||
|
||||
New top-level command. Use after manually resuming a seat (e.g.
|
||||
`claude --resume <id>` / `codex resume <id>`) inside its canonical
|
||||
tmux session: the daemon still shows the seat down. Reconcile binds
|
||||
the live process to its OWN persisted node and updates the
|
||||
projection so `rig ps` / topology / `rig send` / `rig capture` /
|
||||
queue routing work again. It NEVER launches, relaunches, kills,
|
||||
replays startup, presses resume menus, compacts, or types into the
|
||||
pane.
|
||||
|
||||
```
|
||||
rig reconcile-session <session> Auto-resolve target node
|
||||
rig reconcile-session <session> --rig <rigId> --node <logicalId> Disambiguate
|
||||
rig reconcile-session <session> --no-launch Explicit (default mode)
|
||||
rig reconcile-session <session> --json JSON output for agents
|
||||
```
|
||||
|
||||
`--rig` and `--node` must be provided together (disambiguation).
|
||||
Same node id, no re-key. Anything the reconcile could not prove is
|
||||
reported as projection drift (a list of unproven metadata fields);
|
||||
conversation continuity is reported as a status string, never
|
||||
claimed as proven.
|
||||
|
||||
### Five-Term Restore Status Vocabulary
|
||||
|
||||
The seat-level status field on `rig up`, `rig restore`, and `rig ps`
|
||||
now carries one of five values:
|
||||
|
||||
| Status | Meaning |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `resumed` | Original session continuity proven by the native-resume probe. |
|
||||
| `fresh-primed` | Operator opted into a deliberate fresh-prime (operation B); a brand new session started. |
|
||||
| `awaiting-decision` | Daemon could not resume and operator has not opted into fresh. ZERO session started. |
|
||||
| `attention_required` | Seat is live or parked but needs operator action (auth, model selection, trust, stuck recovery). |
|
||||
| `failed` | The launch transport itself failed. |
|
||||
|
||||
`awaiting-decision` is new and replaces the prior pattern where a
|
||||
zero-session outcome was either misreported as success or as
|
||||
failure. `attention_required` covers the broader live-but-stuck
|
||||
class; a live or parked seat is NEVER reported as `failed`.
|
||||
|
||||
### `rig seat clear-attention` — Audited Stuck-Seat Recovery
|
||||
|
||||
Subcommand on `rig seat`. Evidence-gated, operator-attested, audited
|
||||
reconcile of a stuck `attention_required` seat back to `ready`:
|
||||
|
||||
```
|
||||
rig seat clear-attention <session>
|
||||
rig seat clear-attention <session> --reason "<operator attestation>"
|
||||
rig seat clear-attention <session> --json
|
||||
```
|
||||
|
||||
- Without `--reason`, the clear requires evidence the seat is
|
||||
actually back to a clean state (the daemon-side gate).
|
||||
- `--reason <text>` is the operator-attestation override; the
|
||||
audit row records the attestation string verbatim.
|
||||
- Output: `Cleared <session>: <from> -> ready (<clearedBy>)`. The
|
||||
audit log captures session, from-state, gate-or-attestation, and
|
||||
operator address.
|
||||
|
||||
Replaces the prior hand-edit-SQLite / fake-clear pattern. The seat's
|
||||
node id is unchanged.
|
||||
|
||||
### Node-Granular Managed Partial Restore
|
||||
|
||||
`rig launch` works for single-seat and subset relaunch on a running
|
||||
rig through the orchestration layer:
|
||||
|
||||
```
|
||||
rig launch <rigId> <nodeRef> Single seat (logical id or node id)
|
||||
rig launch <rigId> --seats <a,b,c> Subset (comma-separated logical ids)
|
||||
rig launch <rigId> --seats <a,b> --hold-reason "<text>" Reason for held non-targets
|
||||
rig launch <rigId> --seats <a,b> --json
|
||||
```
|
||||
|
||||
- Single-target: `POST /api/rigs/:rigId/nodes/:nodeRef/launch`.
|
||||
- Subset: `POST /api/rigs/:rigId/nodes/launch-subset`. Reports
|
||||
`launched`, `held` (with reason), `alreadyRunning`, and
|
||||
`failedTargets` (liveness unknown) separately — partial outcomes
|
||||
are never collapsed.
|
||||
|
||||
Retires the `pod_aware_launch_unsupported` dead-end and the
|
||||
"use rig up instead" workaround. Use `rig launch` for per-seat or
|
||||
per-subset relaunch on a running rig; `rig up <existing>` remains
|
||||
the whole-rig restore entry.
|
||||
|
||||
### Periodic Snapshots
|
||||
|
||||
The crash-insurance floor under event-driven and teardown
|
||||
snapshots. New `snapshots.periodic` settings family:
|
||||
|
||||
| Key | Default | Meaning |
|
||||
| -------------------------------------- | ------- | ------------------------------------------ |
|
||||
| `snapshots.periodic.enabled` | `true` | Master switch |
|
||||
| `snapshots.periodic.interval_seconds` | `300` | Snapshot interval per enabled rig |
|
||||
| `snapshots.periodic.retention_keep` | `10` | Per-rig `auto-periodic` retention count |
|
||||
|
||||
Snapshots are captured with kind `auto-periodic`; the repository
|
||||
prunes by `retention_keep` per rig. Restore selection treats
|
||||
`auto-periodic` and `auto-pre-down` symmetrically — the newer of
|
||||
the two wins, so a daemon crash between teardown snapshots no
|
||||
longer loses arbitrary lifecycle state. Last-snapshot / floor is
|
||||
surfaced in `rig ps` / status so an operator can see the floor
|
||||
at a glance.
|
||||
|
||||
Tune via the settings store (`rig config set ...`) or rely on
|
||||
defaults; the scheduler reads at daemon startup and respects
|
||||
`snapshots.periodic.enabled = false` as a master switch.
|
||||
|
||||
### Codex Profile-V2 Preflight
|
||||
|
||||
The profile-load check on profile-bearing launch and restore
|
||||
surfaces is now honest about WHICH failure it sees:
|
||||
|
||||
- **Legacy-profile detection narrowed**: matches `contains legacy`,
|
||||
`[profiles.<name>]`, `cannot be used while.*legacy`, and `legacy
|
||||
profile selector` only. The prior generic match against `failed
|
||||
to load configuration` was also catching invalid-TOML stderr.
|
||||
- **Invalid TOML surfaces the parse reason**: up to 3 stderr lines
|
||||
(e.g. `expected newline`), with a TOML-validity hint instead of
|
||||
a wrong migration hint pointing at `[profiles.<profile>]`.
|
||||
|
||||
The auth-refusal projection path is unchanged; this is a
|
||||
classification fix in the preflight discriminator.
|
||||
|
||||
### cmux Launch Readiness
|
||||
|
||||
The cmux launch path no longer silently produces a partial
|
||||
workspace. When cmux comes up with a subset of expected windows or
|
||||
panes, the seat surfaces an honest partial-workspace state with a
|
||||
one-click open-missing affordance instead of being projected as
|
||||
launched-clean.
|
||||
|
||||
### Slow Codex Resume Classification
|
||||
|
||||
Internal classification tweak in `verifyResumeLaunch` for slow but
|
||||
genuinely-resuming Codex sessions. Previously the slow path got
|
||||
mis-categorized through the unresolved-gate branch; now it
|
||||
correctly classifies through the same five-term vocabulary and
|
||||
reaches `resumed` when the readiness probe confirms it.
|
||||
|
||||
### Pod-Aware Claude Resume-Selection Menu
|
||||
|
||||
Follow-on to 0.3.3 slice 21 FR-2. `ClaudeCodeAdapter.verifyResumeLaunch`
|
||||
now treats a Claude resume-selection menu the same way it treats
|
||||
the Codex unresolved-gate case: returns immediately with
|
||||
`recovery: attention_required` and last-12-line pane evidence on
|
||||
both the inner poll loop and the final-probe exit path. Zero
|
||||
numeric selection keystrokes are ever sent.
|
||||
|
||||
## What to STOP Using
|
||||
|
||||
These patterns are retired by 0.3.4. Stop reaching for them; the
|
||||
named replacement is the supported path:
|
||||
|
||||
- **STOP** treating `rig up <existing-rig>` as fresh-by-default.
|
||||
Resume-original is now default. Use `--fresh <seats...>` when a
|
||||
deliberate fresh-prime is the goal (operation B).
|
||||
- **STOP** reading a stuck or parked seat as `failed`. A live or
|
||||
parked seat carries `attention_required`; only the transport
|
||||
itself failing is `failed`. The honest zero-session state is
|
||||
`awaiting-decision`, not `failed`.
|
||||
- **STOP** reaching for `pod_aware_launch_unsupported` workarounds
|
||||
or "use `rig up` instead" for per-seat relaunch. Use `rig launch
|
||||
<rigId> <nodeRef>` or `rig launch <rigId> --seats <a,b>`.
|
||||
- **STOP** hand-editing SQLite or fake-clearing a stuck
|
||||
`attention_required` seat. Use `rig seat clear-attention
|
||||
<session>` (evidence-gated) or `rig seat clear-attention
|
||||
<session> --reason "<attestation>"` (operator-attested,
|
||||
audited).
|
||||
- **STOP** relying only on event-driven or teardown snapshots for
|
||||
crash safety. The periodic snapshot scheduler is the floor;
|
||||
tune `snapshots.periodic.interval_seconds` /
|
||||
`snapshots.periodic.retention_keep` to taste.
|
||||
- **STOP** classifying invalid TOML as a legacy-profile problem.
|
||||
The Codex profile-v2 preflight now surfaces the actual TOML
|
||||
parse reason; legacy detection requires legacy-specific
|
||||
patterns.
|
||||
- **STOP** restoring a hand-resumed session by relaunching the
|
||||
whole rig. Use `rig reconcile-session <session> --no-launch` —
|
||||
no relaunch, no input written, same node id.
|
||||
- **STOP** projecting a partial cmux workspace as launched-clean.
|
||||
The launch-readiness gate surfaces partial state honestly with
|
||||
a one-click open-missing path.
|
||||
|
||||
## Operator / Setup Notes
|
||||
|
||||
After upgrading the published CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @openrig/cli@0.3.4
|
||||
rig --version # expect 0.3.4
|
||||
rig daemon stop
|
||||
rig daemon start
|
||||
```
|
||||
|
||||
No new schema-bumping migrations in 0.3.4. The
|
||||
`snapshots.periodic.*` settings have built-in defaults
|
||||
(`enabled=true`, `interval_seconds=300`, `retention_keep=10`); no
|
||||
operator action is required to pick up the crash-insurance floor.
|
||||
|
||||
Operators installing v0.3.4 fresh from npm have full access to the
|
||||
new surfaces immediately. Operators upgrading an existing host
|
||||
follow the standard daemon-restart flow above; the 0.3.4 host-
|
||||
upgrade flow is held for separate sequencing and is NOT a
|
||||
prerequisite for fresh-install access.
|
||||
|
||||
For the new `rig start` surface: it sequences existing primitives
|
||||
(daemon + kernel + per-rig `/api/rigs/:id/up`). The picker is TTY-
|
||||
interactive by default; the headless flags `--last`, `--all`, and
|
||||
`--rigs <names...>` cover scripted recovery.
|
||||
|
||||
For `rig up` resume-original-by-default: any agent or script that
|
||||
implicitly depended on fresh-prime as the default must now name
|
||||
`--fresh <seats...>` explicitly. The five-term vocabulary applies
|
||||
on the response; treat `awaiting-decision` as the headless
|
||||
"operator must decide" outcome, not an error.
|
||||
|
||||
For `rig seat clear-attention`: prefer evidence-gated clear when
|
||||
the seat is genuinely back. The `--reason <text>` override is the
|
||||
audited operator-attestation path; the attestation string lands in
|
||||
the audit row verbatim.
|
||||
|
||||
For periodic snapshots: tune `snapshots.periodic.interval_seconds`
|
||||
down for higher-churn rigs and `snapshots.periodic.retention_keep`
|
||||
up for longer recovery windows. The default 5-minute interval +
|
||||
10-snapshot retention is a balanced floor.
|
||||
|
||||
## Known Limitations / 0.3.4 Carry-forwards
|
||||
|
||||
- **`rig view list/show --json` flag inconsistency** — wrapper-
|
||||
layer routing path remains; the daemon's `view show <name>`
|
||||
route returns JSON correctly when invoked directly. Workaround:
|
||||
human-readable output for now. Wrapper-layer fix is the
|
||||
documented target.
|
||||
- **`docs/DESIGN.md` docs-guard ledger** — the docs-guard ledger
|
||||
for design-doc updates carries forward; no behavior impact on
|
||||
the shipped runtime surface.
|
||||
- **Slice-21 FR-1 (native Codex session-id hook)**: scope-tipped
|
||||
to 0.4.0 after build-time forensic. Carried forward from 0.3.3.
|
||||
- **Slice-13 component 3 (auto-rollout dispatcher)**: skill-layer
|
||||
landing remains; product-code dispatcher / invocation is
|
||||
intentionally out of the shipped runtime bundle. Carried
|
||||
forward from 0.3.3.
|
||||
- **Slice-05 sub-scopes from 0.3.2**: agent / port / managed-app
|
||||
collision detection (Item 4.3); broader install-into-existing-
|
||||
rig pathway acceptance (Item 4.4); `--target-name` CLI flag for
|
||||
install-time rig-name overrides. Carried forward.
|
||||
- **Slice-21 FR-4(d) accepted-queue-state schema**: deferred from
|
||||
0.3.2; new `accepted` queue state remains a schema model
|
||||
change. Carried forward.
|
||||
- **Onboarding journey + battle-hardening**: slices 04
|
||||
(new-user-journey), 08 (hooks-elite), 10 (rig-self), 11
|
||||
(personal-rig) carried forward to a later release. The 0.3.4
|
||||
cut focused on recovery + resilience surfaces ahead of the
|
||||
onboarding journey.
|
||||
- **Workspace symlink alignment**: daemon-side workspace-resolver
|
||||
alignment with operator symlinked workspace roots remains a
|
||||
follow-up; non-blocking for 0.3.4.
|
||||
- **Slice-13 permission-block-routing-architecture remains held**:
|
||||
big-green-light gated by operator review; `rigx-experimental`
|
||||
only; not split-deferred (stays held).
|
||||
- **0.3.4 host-upgrade flow held**: operators installing v0.3.4
|
||||
fresh from npm have full access immediately; upgrading an
|
||||
existing host follows the standard daemon-restart flow above.
|
||||
The named host-upgrade flow is held for separate sequencing.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- `npm run lint` clean.
|
||||
- `npm run build` clean across all workspaces.
|
||||
- TypeScript checks clean on every touched file at per-commit
|
||||
guard across every shipped slice.
|
||||
- Per-slice focused-suite tests green:
|
||||
- Slice 01 (`rig start`): pod-aware id-grounded preview + apply
|
||||
(rev1 BLOCKING b4c6ada4); same-name disambiguation; `/api/up`
|
||||
never reached in the recovery path; `--last` / `--all` /
|
||||
`--rigs <names...>` headless paths.
|
||||
- Slice 02 (`rig up` resume-original-by-default): seat-level
|
||||
outcomes drive `resumed` / `fresh-primed` / `awaiting-
|
||||
decision`; per-seat TTY [y/N] prompt; headless explicit-hint
|
||||
path for `awaiting-decision`.
|
||||
- Slice 03 (`rig reconcile-session`): adopt a hand-resumed
|
||||
session with same node id; projection-drift reporting; never
|
||||
launches, never writes to the pane.
|
||||
- Slice 04 (`rig up --plan`): read-only restore preview; honest
|
||||
async timeout; no mutation; same five-term vocabulary on the
|
||||
per-node `intendedAction`.
|
||||
- Slice 05 rev1 (pod-aware Claude resume-selection menu):
|
||||
`ClaudeCodeAdapter.verifyResumeLaunch` returns
|
||||
`recovery: attention_required` with last-12-line evidence;
|
||||
zero numeric keystrokes sent.
|
||||
- Slice 06 (five-term vocabulary): `rig up` / `rig restore` /
|
||||
`rig ps` projections carry the named outcome on every
|
||||
representative seat-state path.
|
||||
- Slice 07 (Codex profile-v2 preflight): narrowed legacy
|
||||
detection regex; invalid-TOML stderr surfaces the parse
|
||||
reason and a TOML-validity hint (rev1 BLOCKING).
|
||||
- Slice 08 (cmux launch readiness): partial-workspace state
|
||||
surfaces honestly with the open-missing affordance; no
|
||||
silent launched-clean projection.
|
||||
- Slice 09 (periodic snapshots): `PeriodicSnapshotScheduler`
|
||||
captures + prunes by retention; `auto-periodic` vs
|
||||
`auto-pre-down` newest-wins in the restore selector;
|
||||
settings-store integration for the `snapshots.periodic.*`
|
||||
family.
|
||||
- Slice 10 (`rig seat clear-attention`): evidence-gated clear;
|
||||
operator-attestation override via `--reason`; audit row
|
||||
captures session / from-state / cleared-by.
|
||||
- Slice 11 (node-granular launch): single-seat
|
||||
`/api/rigs/:rigId/nodes/:nodeRef/launch`; subset
|
||||
`/api/rigs/:rigId/nodes/launch-subset`; partial outcomes
|
||||
(launched / held / alreadyRunning / failedTargets) never
|
||||
collapsed.
|
||||
- Slice 13 (slow Codex resume classification): slow-resume
|
||||
Codex path classifies through the five-term vocabulary and
|
||||
reaches `resumed` when the readiness probe confirms.
|
||||
- Per-commit guard CLEAR across every shipped slice;
|
||||
velocity-qa adversarial dogfood + dual rev1 CLEAR on each
|
||||
merge.
|
||||
- VM end-to-end suite green against the 0.3.4 cut; recovery
|
||||
paths (`rig start` + `rig up` resume-by-default +
|
||||
`rig reconcile-session` + `rig seat clear-attention`)
|
||||
exercised against a real macOS VM through the vm-e2e harness.
|
||||
- Zero regressions versus the v0.3.3 base on every shipped
|
||||
slice.
|
||||
|
||||
## Commits Included (highlights)
|
||||
|
||||
The full v0.3.3 -> v0.3.4 delta is 67 commits / 69 files /
|
||||
+7,193 insertions / -267 deletions. The list below groups
|
||||
load-bearing commits by theme:
|
||||
|
||||
### Recovery Entry Points
|
||||
|
||||
- `feat(cli)`: `rig start` one-command recovery orchestrator —
|
||||
daemon-start, kernel wait, candidate listing, picker / flags,
|
||||
per-rig restore + reconcile (slice 01).
|
||||
- `fix(cli,daemon)`: `rig start` id-grounded preview + apply —
|
||||
same-name rigs no longer hit `ambiguous_name` 409 (slice 01
|
||||
rev1 BLOCKING).
|
||||
- `feat(cli)`: `rig reconcile-session` — adopt a live, hand-
|
||||
resumed canonical session into its persisted node without
|
||||
launch / relaunch / kill / startup-replay / resume-menu /
|
||||
pane-input (slice 03).
|
||||
|
||||
### Resume Original By Default
|
||||
|
||||
- `feat(cli,daemon)`: `rig up` resume-original-by-default;
|
||||
`--fresh <seats...>` becomes the per-seat opt-in for
|
||||
operation B; per-seat TTY [y/N] for `awaiting-decision`;
|
||||
headless explicit-hint path (slice 02).
|
||||
- `feat(cli,daemon)`: `rig up --plan` read-only restore preview
|
||||
with honest async timeout (slice 04).
|
||||
|
||||
### Honest Restore Vocabulary
|
||||
|
||||
- `feat(daemon,cli)`: five-term restore status vocabulary across
|
||||
`rig up` / `rig restore` / `rig ps` — `resumed` /
|
||||
`fresh-primed` / `awaiting-decision` / `attention_required` /
|
||||
`failed` (slice 06).
|
||||
- `fix(daemon)`: pod-aware Claude resume-selection menu surfaces
|
||||
`attention_required` instead of timeout (slice 05 rev1
|
||||
BLOCKING).
|
||||
- `fix(daemon)`: slow Codex resume classification — slow but
|
||||
genuinely-resuming sessions no longer mis-categorize through
|
||||
the unresolved-gate branch (slice 13).
|
||||
|
||||
### Preflight + Readiness Honesty
|
||||
|
||||
- `fix(daemon)`: Codex profile-v2 preflight narrows legacy
|
||||
detection regex; invalid TOML surfaces parse reason + TOML-
|
||||
validity hint (slice 07 rev1 BLOCKING).
|
||||
- `fix(daemon,ui)`: cmux launch readiness — partial workspace
|
||||
surfaces honestly; one-click open-missing affordance instead
|
||||
of silent launched-clean (slice 08).
|
||||
|
||||
### Crash-Insurance Floor
|
||||
|
||||
- `feat(daemon)`: `PeriodicSnapshotScheduler` + `snapshots.
|
||||
periodic` settings family; `auto-periodic` snapshot kind;
|
||||
restore selector treats `auto-periodic` and `auto-pre-down`
|
||||
symmetrically (newest-wins); last-snapshot / floor in
|
||||
`rig ps` (slice 09).
|
||||
|
||||
### Stuck-Seat Recovery
|
||||
|
||||
- `feat(cli,daemon)`: `rig seat clear-attention` — evidence-
|
||||
gated, operator-attested, audited reconcile of a stuck
|
||||
`attention_required` seat; audit row captures session / from-
|
||||
state / cleared-by (slice 10).
|
||||
|
||||
### Node-Granular Managed Partial Restore
|
||||
|
||||
- `feat(cli,daemon)`: node-granular launch through orchestration
|
||||
— `rig launch <rigId> <nodeRef>` single-seat;
|
||||
`rig launch <rigId> --seats <a,b>` subset; partial outcomes
|
||||
(launched / held / alreadyRunning / failedTargets) reported
|
||||
separately; retires `pod_aware_launch_unsupported` dead-end
|
||||
(slice 11).
|
||||
|
||||
See `CHANGELOG.md` for the agent-readable changelog with quick
|
||||
verification commands and operator notes.
|
||||
@@ -0,0 +1,431 @@
|
||||
# OpenRig v0.4.0
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.4.0 is a token-efficient-defaults + recovery-capstone release.
|
||||
Five read-commands every agent runs constantly (`rig ps`, `rig whoami`,
|
||||
`rig queue list`, `rig restore-check`, `rig context`) flip from
|
||||
firehose-by-default to compact-by-default with an explicit `--full` /
|
||||
`--all` opt-in; `rig queue list` adopts the docker/kubectl-aligned
|
||||
read-command grammar (`-a` for history, `-A` for cross-rig breadth,
|
||||
`-o json|wide` for encoding, `--mine` / `--source` / `--destination`
|
||||
for scope). `rig seat clear-attention` extends to cover the
|
||||
restoreOutcome-derived projection staleness class that v0.3.4 shipped
|
||||
but couldn't reach. `rig scope mission|slice create` now scaffolds
|
||||
`PROGRESS.md` reliably and ships a `progress` update verb so agents
|
||||
update progress through the command rather than hand-editing markdown.
|
||||
|
||||
Behind those user-visible defaults: the daemon-side payload
|
||||
source-dedup (`rig ps` `recoveryGuidance` no longer duplicated
|
||||
per-node, `contextUsage` compact in the list payload) ships at slice 26
|
||||
so even `--full` and the UI consumers stop paying for it; live agent
|
||||
terminals replace the read-only 3-second snapshot view (slice 01);
|
||||
multi-host dogfooding hardens the `--host` flag's cross-host story
|
||||
(slice 02); the Agent Images library gains a Fork-now affordance + row
|
||||
metadata (slice 07); attention/activity detection consumes
|
||||
`agentActivity` truth more richly across the UI (slice 09);
|
||||
skill-cascade provenance + freshness gets a new read-only `rig skill
|
||||
audit` surface (slice 10); the identity refactor foundation lands the
|
||||
common identity types both the daemon and CLI consume (slice 11);
|
||||
`rig scope` progress rails reconnect the mission/slice tree to
|
||||
`PROGRESS.md` to the Project UI (slice 15); Codex resume preserves the
|
||||
launching seat's approval posture so resumed seats can run lifecycle
|
||||
commands without hitting implicit-deny (slice 17); the active/idle
|
||||
node-state ring becomes reliable (slice 18); a diagonal ghost in the
|
||||
topology graph view is fixed at source (slice 21); convergence-fix
|
||||
P1 (For-You drill captured/live contract) + P2 (daemon test-harness
|
||||
WebSocket registration) land as the wrap-blocking bug-fix slice 24.
|
||||
|
||||
Existing v0.3.4 databases upgrade by running `rig daemon start` on the
|
||||
new daemon. Migrations are additive.
|
||||
|
||||
## Included In This Release
|
||||
|
||||
### New top-level CLI verbs + new subcommands
|
||||
|
||||
- **`rig skill audit` (slice 10)** — new read-only command + daemon
|
||||
surface (`/api/skills/audit`, discovery, mirror-drift detection,
|
||||
skills route). Reports skill-cascade provenance findings —
|
||||
missing / stale / self-referential / invalid-date / mirror-drift —
|
||||
across the canonical → product mirror → hub cwd → installed plugin
|
||||
cascade. Prevents false-green CLI success when mirror-drift evidence
|
||||
is unavailable (the failure mode the v0.3.4 wrap-gate AC-3 almost
|
||||
shipped). Composes with the existing `scripts/mirror-skills.mjs`
|
||||
guardrails.
|
||||
- **`rig scope mission progress` / `rig scope slice progress` (slice 33)** —
|
||||
new `progress` verbs that append/set/update entries in `PROGRESS.md`
|
||||
deterministically through the command surface rather than hand-edited
|
||||
markdown. Writes the canonical structure the OpenRig PROGRESS UI page
|
||||
reads.
|
||||
|
||||
### Compact-by-default read commands (the token-efficiency family)
|
||||
|
||||
- **`rig ps` — compact default + `--full` + current-rig default + `-A`/`--all-rigs` (slices 25 + 34)** — default
|
||||
emits a compact TL;DR per node (session, rig, activity state +
|
||||
reason, assigned/pending counts; identity-recovery essentials only).
|
||||
`--full` returns the complete record (raw byte-equivalent). **Slice 34 — breadth default flipped to CURRENT-RIG** (derived from `OPENRIG_SESSION_NAME`'s `@<rig>` suffix) to match the docker/kubectl-aligned `rig queue list` grammar: `-A`/`--all-rigs` for fleet breadth; `--rig <name>` overrides the current-rig default; `--session <sess>` narrows further. All-states stays the default (per orch-lead's ruling: ps surfaces topology/readiness, where stopped/recoverable/attention is the actionable signal — unlike queue-list where the actionable set is active items). `--active` / `--running` remains the opt-in active-state filter. Closes a ~77,000-token status-glance incident at root + a fleet-scale unbounded-default-output bomb. **Resume-token security**: `--full` JSON now emits `resumeTokenPresent` (boolean) instead of the resume token value itself, so an orch glance never accidentally leaks token material.
|
||||
- **`rig ps` payload source dedup (slice 26)** — daemon node-list
|
||||
payload trims at source: `recoveryGuidance` is no longer serialized
|
||||
as near-identical templated prose on every node (relocated to a
|
||||
guidance-by-reference map at the top level so the 4 current
|
||||
consumers still resolve it); `contextUsage` is a compact summary in
|
||||
the list payload (full telemetry remains retrievable per-node via
|
||||
whoami / detail query). The ~7 contextUsage UI consumers + 4
|
||||
recoveryGuidance consumers migrated without regression. Even
|
||||
`--full` and the UI Project view stop paying for the redundant
|
||||
per-node blobs.
|
||||
- **`rig whoami` — compact default + `--full` (slice 27)** — `rig
|
||||
whoami` and `rig whoami --json` default to identity-recovery
|
||||
essentials only (`identity` / `peers` names + sessionNames / `edges`
|
||||
directional kind + to.sessionName / `transcriptPath`). `--full`
|
||||
(alias `--verbose`) returns today's complete payload including
|
||||
`contextUsage`, `commands`, `peersNote`, `runtimeContext`. Daemon
|
||||
skips the `contextUsageStore` lookup and `runtimeContext` build when
|
||||
compact is requested. The first command every agent runs on boot +
|
||||
every compaction-restore now costs ~192 tokens instead of ~909.
|
||||
- **`rig queue list` — active-frontier compact default + read-command
|
||||
grammar (slices 28 + 32)** — `rig queue list` (no flags) returns
|
||||
active states only (`pending` / `in-progress` / `claimed` /
|
||||
`blocked` / `handed-off`; not `done` / `canceled`), current-rig
|
||||
breadth, compact rows (id, state, source→destination /
|
||||
current-owner, closure_reason / closure_target when handed-off /
|
||||
blocked, mission, slice, tier / priority, age / updated_at, short
|
||||
title, capped tags). The four orthogonal axes are composable:
|
||||
- **`-a` / `--all`** — include closed/done history within the
|
||||
current breadth (the docker `-a` axis: history).
|
||||
- **`-A` / `--all-rigs`** — widen to cross-rig breadth (the
|
||||
kubectl `-A` axis: breadth).
|
||||
- **`--full`** — add body / chain-of-record / full-tags / transition
|
||||
history to whichever scope is selected (the field-breadth axis).
|
||||
- **`-o json|wide`** — encoding, compact-by-default. `-o json`
|
||||
does NOT imply full body (compact JSON is token-safe + machine-
|
||||
parseable); `--full -o json` returns the full JSON.
|
||||
- `--mine` returns just the caller's items; `--destination <s>` /
|
||||
`--source <s>` for explicit scoping. The bare unscoped firehose
|
||||
that aggregated cross-rig + full-history (~64,000 tokens on the
|
||||
live host) is retired as a default — opt-in via `-A -a --full`.
|
||||
- **`rig restore-check` — summary + not-ready default + `--full`
|
||||
(slice 29)** — default emits summary counts (total / ready / not-
|
||||
ready / error-degraded) plus the not-ready seats only (each compact:
|
||||
seat + readiness reason). `--full` / `--json` returns today's
|
||||
complete per-seat readiness across the fleet. Daemon skips
|
||||
per-seat-detail assembly for ready seats when compact (computes
|
||||
verdict, omits detail). `--rig <name>` / `--as <me>` narrow.
|
||||
Closes the largest measured bomb (~79,000 tokens) on the
|
||||
read-command surface.
|
||||
- **`rig context` — compact summary default + `--full` (slice 30)** —
|
||||
default emits a compact context summary (the few fields the common
|
||||
use needs); `--full` / `--json` returns the complete current
|
||||
payload. Daemon skips the expensive aggregation when compact. Lower
|
||||
leverage than 28/29 but keeps the read-command surface coherent
|
||||
compact-by-default after the upgrade.
|
||||
|
||||
### Recovery + resilience capstone
|
||||
|
||||
- **`rig seat clear-attention` extends to derived projection staleness
|
||||
(slice 16)** — v0.3.4 shipped `clear-attention` gating on
|
||||
`session.startupStatus` only. First real production use (the host
|
||||
cutover's residual `advisor-lead@kernel` projection) found the gap:
|
||||
when a seat is `startupStatus=ready` + `sessionStatus=running` but
|
||||
carries `restoreOutcome=failed` / `continuityOutcome=failed`, the
|
||||
daemon derives `lifecycleState=attention_required` and the verb
|
||||
refused with `{ok:false, code:not_in_attention}`. v0.4.0
|
||||
`clear-attention` now reaches both classes — the
|
||||
startupStatus-driven attention (already shipped) AND the
|
||||
restoreOutcome-derived projection — with the same evidence-gated
|
||||
audit row and the same operator-attested `--reason <text>` override.
|
||||
- **Codex Resume Preserves Approval Posture (slice 17)** — a
|
||||
resumed Codex seat now comes back with the same permission /
|
||||
approval posture as a fresh Codex launch. Previously a resumed
|
||||
seat hit "approval required but approvals disabled" implicit-deny
|
||||
with no path when running shipped lifecycle commands. Bounded
|
||||
bug-fix grounded against the 0.4.0 resilience set; surfaced
|
||||
during the GS1 Studio capture.
|
||||
|
||||
### UI + topology surfaces
|
||||
|
||||
- **Real (Interactive) Terminals (slice 01)** — the per-seat
|
||||
terminals in the UI become interactive. Click into a seat, type at
|
||||
the agent directly (shared-pane; "hands near the wheel"); the
|
||||
read-only 3-second snapshot view is retired for local-host seats.
|
||||
Performance constraint met for the rig-altitude "all terminals"
|
||||
view (~20–30 panes open simultaneously) via a **global
|
||||
`LiveTerminalRegistry`** that caps concurrently-live terminals
|
||||
(`ui.terminal.max_live_terminals` config key, env
|
||||
`OPENRIG_UI_TERMINAL_MAX_LIVE_TERMINALS`) and a **progressive
|
||||
multi-live** interaction model: live where the user is looking
|
||||
(focused popover / in-place grid expansion / node-detail panel),
|
||||
static smoked-glass thumbnails everywhere else, coordinated by a
|
||||
`LiveTerminalProvider`. In-place multi-live in the topology grid +
|
||||
shared smoked-glass styling across focused / popover / grid /
|
||||
node-detail surfaces. Remote-seat terminals ride 0.4.1 (need
|
||||
daemon-to-daemon transport).
|
||||
- **Real-Terminal Session Broker (slice 38)** — release-critical
|
||||
reliability fix grounded in founder live dogfood 2026-06-21. The
|
||||
prior architecture opened its own WebSocket per view, and each
|
||||
WebSocket started its own tmux `pipe-pane` against the same pane;
|
||||
tmux allows ONE pipe-pane per pane, so opening a second live view
|
||||
of the same session would **steal output from the first** —
|
||||
typing in one visible terminal sent echo to a different tab. The
|
||||
v0.4.0 architecture: a daemon-owned
|
||||
**`TerminalSessionBroker`** keyed by canonical `sessionName` —
|
||||
one tmux pipe-pane per session, N WebSocket subscribers, output
|
||||
fanout, session-level seed + shared scrollback ring for late
|
||||
subscribers, explicit input-owner semantics (the focused view is
|
||||
eligible to send input), and honest cleanup when the last
|
||||
subscriber leaves or the session dies. `terminal-ws` is now a
|
||||
thin broker subscriber; it no longer owns pipe-pane per
|
||||
connection. **Resize policy = canonical fixed geometry**: the
|
||||
broker pins the tmux pane at the optimal Claude/Codex TUI grid
|
||||
(120×40); subscribers fit/scroll their container to it (CSS),
|
||||
they never send tmux resize. One stable source of truth, zero
|
||||
resize-fighting, TUI always renders at its optimal width.
|
||||
- **Unified Static / Live Terminal Component (slice 39)** —
|
||||
the static thumbnail and live xterm now share one
|
||||
`StaticTerminalPlate` component + one mirror geometry (90×27 for
|
||||
thumbnails / mirror; the broker's live stream is 120×40 — the
|
||||
thumbnail is a fit-to-container projection of that live stream).
|
||||
Opaque mirror render + static line-return fix + grid in-place
|
||||
multi-live + graph/table popover. Live-terminal selection on a
|
||||
scaled view now uses fontSize scaling (not CSS transform), so
|
||||
text selection lands on the correct character cell. Geometry
|
||||
comment sweep is behavior-neutral but eliminates stale comments
|
||||
that confused subsequent edits.
|
||||
- **Agent Images Library Polish (slice 07)** — the Agent Images
|
||||
library UI gains a `Fork now` action for cloneable images
|
||||
(composes the existing add-member endpoint with
|
||||
`session_source.mode: "agent_image"`), lightweight row metadata
|
||||
(version, token, fork-count, last-used), and nested-failure
|
||||
rendering (nested `failed` / `attention_required` outcomes render
|
||||
as red failure rather than green success). Fork-now is hidden /
|
||||
disabled when the image lacks `sourceCwd`; the confirm path
|
||||
requires a runtime-matched sibling carrying `agentRef` + `profile`;
|
||||
no fallback cwd is invented.
|
||||
- **Attention / Activity Detection — Elite Tier (slice 09)** —
|
||||
richer consumption of the `agentActivity` truth landed in 0.3.4
|
||||
across UI + operator surfaces. Needs-input prominence,
|
||||
time-in-state, activity rollups POST-18.
|
||||
- **Topology Graph-View Ghost Render Fix (slice 21)** — diagonal
|
||||
ghost artifact behind the nodes in the dashboard topology /
|
||||
graph view is fixed at source. Was present in every graph-view
|
||||
capture across the launch-video reviewer kit; the table view was
|
||||
already ghost-free. Hero surface across launch videos +
|
||||
dashboard.
|
||||
- **Reliable Active/Idle Node State (slice 18)** — the lit/dark
|
||||
activity ring already shipped; this slice closes the
|
||||
`DOT→terminalActive` fallback and removes the dead
|
||||
`pane_silence_flag` derivation. Flagship video F1's signal layer.
|
||||
|
||||
### Multi-host + identity foundations
|
||||
|
||||
- **Multi-Host Dogfood (slice 02)** — the `--host` CLI flag was
|
||||
shipped pre-0.4.0; this slice closes the cross-machine integration
|
||||
story (multi-machine dogfood + external QA). HTTP-primary
|
||||
transport; cross-host identity rides 0.4.1.
|
||||
- **Native Codex Session Identity (slice 11)** — landing the common
|
||||
identity types both the daemon and CLI consume — native Codex
|
||||
session-identity capture + the foundation for the slice-12
|
||||
identity-refactor that rides 0.4.1.
|
||||
|
||||
### Scope automation
|
||||
|
||||
- **`rig scope` PROGRESS.md + README scaffolding (slice 33)** —
|
||||
`rig scope mission create` and `rig scope slice create` now
|
||||
scaffold a canonical `PROGRESS.md` (the structure the OpenRig
|
||||
PROGRESS UI page parses) plus a convention-correct README with
|
||||
proper frontmatter, per
|
||||
`conventions/scope-and-versioning/README.md`. Fixes the bug where
|
||||
newly-created missions + slices were missing `PROGRESS.md`
|
||||
entirely. A `rig scope ... progress` update verb appends / sets /
|
||||
records progress entries deterministically — agents update
|
||||
progress through the command (UI-valid by construction) rather
|
||||
than hand-editing markdown and drifting.
|
||||
- **`rig scope` stage / verified / reconcile verbs (slice 35)** —
|
||||
the maturity vocabulary from `conventions/scope-and-versioning`
|
||||
§2 (`stage` + `verified`) becomes enforced through commands
|
||||
instead of hand-editing markdown. Composes with slice 33's
|
||||
PROGRESS.md + scaffolding to make `rig scope` the deterministic
|
||||
convention-enforcer.
|
||||
- `rig scope <slice|mission> stage <id> <new-stage>` — set
|
||||
`stage` (wip / provisional / established / canonical /
|
||||
superseded / retired) deterministically. `superseded` REQUIRES
|
||||
`--successor <id>` (rejected otherwise); `retired` warns
|
||||
"do not use"; invalid stages rejected with the valid set
|
||||
named.
|
||||
- `rig scope <slice|mission> verified <id> --against
|
||||
"<source>"` — stamp `verified: <today> against <source>` in
|
||||
frontmatter. `--against` is MANDATORY — bare timestamps
|
||||
rejected (the anti-stale keystone: a bare timestamp is
|
||||
exactly what lets stale trackers lie while looking fresh).
|
||||
Overwrites the prior verified line.
|
||||
- `rig scope <slice|mission> reconcile <id>` — idempotent repair
|
||||
verb that backfills missing `PROGRESS.md`, conforms mandatory
|
||||
frontmatter (`id` / `stage` / `verified`), and repairs
|
||||
id-registration ghosts (`id:null` / doubled-prefix artifacts).
|
||||
Safe to re-run.
|
||||
- `rig scope <slice|mission> create` now writes the MANDATORY
|
||||
`stage` (default `wip`) + a `verified` placeholder, so no new
|
||||
artifact ships missing them.
|
||||
- `rig scope <slice|mission> show` derives a read-time
|
||||
effective-reliability projection from (stage × verified): a
|
||||
stale-`verified` `canonical` is reported as effectively
|
||||
`provisional` (derivation, not stored). Self-correcting signal
|
||||
for the UI + agents.
|
||||
- **Scope-Backed Progress Rails (slice 15)** — connects the scope
|
||||
tree, startup skills, `PROGRESS.md` rails, and Project UI
|
||||
warnings into one coherent workflow. From the 0.3.4 release
|
||||
audit (the mission/slice tree existed + queue traffic was rich,
|
||||
but `PROGRESS.md` surfaces the dashboard depends on were missing
|
||||
or under-used). Pairs with 33 — 33 ships the command; 15 ships
|
||||
the workflow that uses it.
|
||||
|
||||
### Bug-fix wrap
|
||||
|
||||
- **Wrap Convergence Fixes (slice 24)** — the wrap-blocking
|
||||
bug-fix slice from the 0.4.0 convergence deep-review. Two narrow
|
||||
real-terminals integration defects: **P1** For-You terminal-drill
|
||||
mixed captured / live contract (cross-slice 01↔09 seam); **P2**
|
||||
daemon test-harness broken by unconditional Node WebSocket
|
||||
registration (51→411 suite failures). Both pre-wrap blockers per
|
||||
founder ("real convergence bugs land as 0.4.0 bug-fix slices
|
||||
before wrap"). Reviewers found NO broader cross-slice coherence
|
||||
defect — the convergence is clean.
|
||||
|
||||
## User-Facing Changes
|
||||
|
||||
### New surfaces (use these)
|
||||
|
||||
- **Read-commands compact by default**: `rig ps`, `rig whoami`,
|
||||
`rig queue list`, `rig restore-check`, `rig context`. Same
|
||||
breadth, much smaller default payload. Use `--full` (or `-A -a
|
||||
--full` for queue list) when you need the firehose.
|
||||
- **`rig queue list` grammar (docker / kubectl aligned)**:
|
||||
```
|
||||
rig queue list # active, compact, current-rig
|
||||
rig queue list -a # + closed/done history
|
||||
rig queue list -A # cross-rig breadth (same as -A in kubectl)
|
||||
rig queue list --full # add body + chain-of-record
|
||||
rig queue list -o json # compact JSON (token-safe)
|
||||
rig queue list --full -o json # full JSON
|
||||
rig queue list --mine # caller's items only
|
||||
rig queue list --destination <s> # destined to <s>
|
||||
rig queue list --source <s> # sourced by <s>
|
||||
rig queue show <qitemId> # full single item (kubectl describe)
|
||||
```
|
||||
- **`rig skill audit`** — read-only audit of the skill cascade.
|
||||
Detects missing / stale / self-referential / invalid-date /
|
||||
mirror-drift across the canonical → product mirror → hub cwd
|
||||
→ installed plugin chain. Prevents false-green CLI success when
|
||||
mirror-drift evidence is unavailable.
|
||||
- **`rig scope ... progress`** — deterministic `PROGRESS.md`
|
||||
updates. Append entries, set status, record milestones through
|
||||
the command surface; the UI reads the same canonical structure
|
||||
the command writes.
|
||||
- **`rig seat clear-attention` for restoreOutcome-derived
|
||||
projection** — the v0.3.4 verb now reaches the second class of
|
||||
projection staleness. Same evidence-gated audit row; same
|
||||
`--reason <text>` operator-attestation override.
|
||||
|
||||
### What to STOP using
|
||||
|
||||
- **Stop assuming `rig ps --nodes --json` is the casual status
|
||||
check.** Compact default is the casual check; the firehose is
|
||||
`--full`. Per-node `recoveryGuidance` near-identical-prose
|
||||
duplication is retired; per-node full `contextUsage` is retired
|
||||
from the list payload (relocate to single-node / whoami / detail
|
||||
queries).
|
||||
- **Stop using bare `rig queue list` as the cross-rig firehose.**
|
||||
Default is now active + compact + current-rig. The cross-rig
|
||||
ambient-frontier view is `-A`; cross-rig + history is `-A -a`;
|
||||
the full firehose is `-A -a --full -o json`.
|
||||
- **Stop using `rig whoami --json` to get the heavy payload on
|
||||
boot.** Default is compact identity-essentials; `--full` (or
|
||||
`--verbose`) returns today's complete payload.
|
||||
- **Stop using `rig restore-check` as a per-seat-detail fleet
|
||||
scan.** Default is summary + not-ready only; `--full` for the
|
||||
per-seat detail when actually needed.
|
||||
- **Stop hand-editing `PROGRESS.md` with markdown to record
|
||||
progress.** Use `rig scope ... progress` — UI-valid by
|
||||
construction, deterministic, no drift.
|
||||
- **Stop hand-creating `PROGRESS.md` for new missions / slices.**
|
||||
`rig scope mission create` + `rig scope slice create` scaffold
|
||||
it now.
|
||||
- **Stop applying the token-efficiency-boot-guardrail's
|
||||
CLI-command prohibitions on hosts running 0.4.0** (the
|
||||
prohibitions were host-version workarounds for the bloated
|
||||
defaults this release closes). The pack's bounded-local-search
|
||||
rule + scope/over-flag discipline GRADUATE to a standing
|
||||
convention (`conventions/bounded-local-search-and-flag-scope`)
|
||||
and continue to apply host-independently. The
|
||||
CLI-command-prohibitions half of the pack RETIRES at
|
||||
host-upgrade-to-0.4.0.
|
||||
|
||||
### Operator / Setup Notes
|
||||
|
||||
- **No new schema-bumping migrations.** Existing v0.3.4 databases
|
||||
upgrade by running `rig daemon start` on the new daemon.
|
||||
- **`rig scope` backfill on existing trees**: missions and slices
|
||||
created before 0.4.0 that are missing `PROGRESS.md` can be
|
||||
backfilled by running the `rig scope` repair / doctor step (see
|
||||
slice 33 IMPLEMENTATION-PRD for the exact verb).
|
||||
- **Compact JSON is machine-parseable**. The compact-row schema
|
||||
for `rig queue list -o json` is stable; downstream parsers
|
||||
reading the compact rows don't need migration. Parsers that
|
||||
read body / chain-of-record must use `--full` explicitly.
|
||||
- **`rig skill audit` on the host** — first real production run
|
||||
surfaces the current cascade state (missing / stale /
|
||||
mirror-drift findings). The lifecycle's cascade-propagation
|
||||
pass (canonical `openrig-work/skills/` → product mirror → hub
|
||||
cwd → installed plugin) coordinates with the audit's findings
|
||||
going forward.
|
||||
|
||||
### Known Limitations + carry-forward
|
||||
|
||||
- **Plugin-Lineage Drift in `openrig-core`**: the openrig-core
|
||||
plugin skill lineage is divergent / stale (see OPR.0.4.1.4 —
|
||||
the full plugin re-sync, rides 0.4.1). v0.4.0 ships with this
|
||||
drift known; the cascade-sweep this release ran verified the
|
||||
canonical + config-mirror layers agents boot from are correct
|
||||
/ current at the release SHA, and `rig skill audit` (slice 10)
|
||||
is the new runtime mechanism to detect future drift. The full
|
||||
openrig-core plugin re-sync lands in 0.4.1.
|
||||
- All earlier "PUSHED to 0.4.1" carry-forwards were RESTORED to
|
||||
0.4.0 during the wrap: slice 34 (`rig ps` current-rig default
|
||||
+ `-A`/`--all-rigs` + `resumeTokenPresent` security) landed —
|
||||
see the rig ps entry under Compact-by-default read commands;
|
||||
slice 35 (`rig scope` stage / verified / reconcile verbs)
|
||||
landed — see Scope automation section. Nothing of substance
|
||||
carries forward to 0.4.1 from the original wrap-gate set.
|
||||
- **Real-terminal-related slices** (38 + 39) shipped via founder
|
||||
live-dogfood forward-fix authorization 2026-06-21 (the
|
||||
TerminalSessionBroker reliability fix + the unified
|
||||
static/live terminal component). Both are in 0.4.0; no
|
||||
carry-forward.
|
||||
|
||||
## Verification Performed
|
||||
|
||||
- npm + GitHub release surfaces: published at the lifecycle wrap step (founder-authorized).
|
||||
- Wrap worktree clean on `8d55ea60` (the wrap-gate target SHA).
|
||||
- `rig daemon start` upgrades 0.3.4 → 0.4.0 databases additively (no schema-breaking migrations).
|
||||
- CLI help surfaces for every changed command (`rig ps`, `rig whoami`,
|
||||
`rig queue list`, `rig restore-check`, `rig context`, `rig scope`)
|
||||
source-verified against the command source at the release SHA per
|
||||
`feedback_release_docs_source_verify_each_cli_surface_dont_carry_forward`.
|
||||
- CLI→skill cascade sweep — canonical
|
||||
`openrig-work/skills/openrig-user/SKILL.md` → product mirror at
|
||||
`packages/daemon/specs/agents/shared/skills/core/openrig-user/SKILL.md`
|
||||
→ hub cwd `.claude/skills/openrig-user/SKILL.md` +
|
||||
`.agents/skills/openrig-user/SKILL.md` updated for the six changed
|
||||
commands and the new `--full` / `-a` / `-A` / `-o` grammar; obsolete
|
||||
caveats retired (the `--notify`-required footgun footnote where it
|
||||
appeared, any "this command dumps everything" warnings made false
|
||||
by the compact defaults).
|
||||
- AC-6 wrap-gate self-check PASS — see the durability-close ledger
|
||||
evidence for the cascade-sweep grep evidence + cli-reference diff.
|
||||
|
||||
## Commits Included
|
||||
|
||||
`git log v0.3.4..7e6ba168` against the wrap worktree at the release
|
||||
SHA. The release commits are the version bump + the durability-close
|
||||
sweep + the lifecycle wrap commits per the
|
||||
`conventions/release-durability-close` convention.
|
||||
@@ -0,0 +1,269 @@
|
||||
# OpenRig v0.4.1
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.4.1 is an observability + operator-UI overhaul: the workspace
|
||||
becomes a developer's mission cockpit. Across the Project UI, altitude
|
||||
projection (workspace → mission → slice) and a coherent tab set replace
|
||||
the partially-built Project pages from v0.4.0. The Mission landing tab
|
||||
shows steering + the human-facing mission brief. The Story tab
|
||||
reconstructs what actually happened on a mission as a scrollable
|
||||
git-graph derived from queue lineage. The Workflow tab visualizes the
|
||||
configured workflow spec. The Artifacts navigator + Proof tab + Progress
|
||||
heat-map give workspace altitude its own native surfaces. The Dashboard
|
||||
route gets a clean cosmetic refresh as the launcher home.
|
||||
|
||||
Underneath: a UI digital twin renders all six real Project surfaces
|
||||
1:1 daemon-free from typed fixtures so visual intent can be approved
|
||||
before any UI slice is built, then proven against a real screenshot at
|
||||
the end. A topology edge-flow animation surfaces real queue/handoff
|
||||
movement on the topology graph (signal-driven, never decorative). The
|
||||
For-You feed picks up a phone-friendly restyle with real-data fidelity.
|
||||
|
||||
New CLI: `rig auth` lands as a product-native, CLI-local Codex
|
||||
auth-profile manager with a hardened secret boundary that refuses
|
||||
symlink + hardlink escapes out of CODEX_HOME. `rig send` ships a 3-layer
|
||||
interactive-prompt guard (default-blocked at a target pane's prompt;
|
||||
`--raw` keystrokes still guarded; `--dangerously-interact --reason`
|
||||
audited override) so a peer message can no longer accidentally select
|
||||
an option in another agent's terminal.
|
||||
|
||||
Reliability fixes: a fresh `npm install -g @openrig/cli` followed by
|
||||
`rig daemon start` now resolves cleanly (a missing `@hono/node-ws`
|
||||
runtime dep was masked by monorepo hoisting on the dev host but failed
|
||||
on a fresh install; the CLI now mirrors the daemon's full runtime
|
||||
dep set, gated by a packaging-completeness test). Table-view + hybrid
|
||||
terminal-action errors surface as visible alerts instead of silent
|
||||
failures, and the table terminal opens. Queue items take a
|
||||
human-readable `summary` field (additive migration 044; phased
|
||||
WARN-on-author).
|
||||
|
||||
Existing v0.4.0 databases upgrade by running `rig daemon start` on the
|
||||
new daemon. Migrations are additive (044 adds a nullable
|
||||
`queue_items.summary` column; pre-0.4.1 items degrade cleanly to a
|
||||
body-fallback label).
|
||||
|
||||
## Included In This Release
|
||||
|
||||
### New top-level CLI surface
|
||||
|
||||
- **`rig auth`** — product-native, CLI-local Codex auth-profile
|
||||
management. List / show / switch / capture / forget Codex profiles
|
||||
under a CLI-owned `~/.openrig/codex-auth/`, never reaching outside
|
||||
CODEX_HOME. The adversarial path is closed: a symlinked active auth
|
||||
is refused, hardlinks and symlinked parents cannot escape CODEX_HOME,
|
||||
and the secret boundary is integration-tested. Replaces the prior
|
||||
ad-hoc out-of-product Codex auth swap with a single first-class
|
||||
surface.
|
||||
|
||||
### `rig send` — interactive-prompt guard
|
||||
|
||||
A peer message can no longer accidentally pick an option or approve a
|
||||
permission in another agent's terminal. Three layers:
|
||||
|
||||
- **L0 (default)** — `rig send` is **blocked** when the target pane is
|
||||
at an interactive prompt or permission block. Detection is
|
||||
hook-primary (Codex `PermissionRequest`) with a hardened capture-pane
|
||||
fallback (12-line trailing-content scan + 15-second send-readiness
|
||||
freshness).
|
||||
- **L1 — `--raw`** — exact keystrokes; the L0 guard still applies.
|
||||
- **L2 — `--dangerously-interact --reason "..."`** — explicit override,
|
||||
implies `--raw`, requires `--reason`, writes an audit row.
|
||||
|
||||
This closes the long-standing "stand-down message submitted an
|
||||
authorize-publish default" footgun.
|
||||
|
||||
### Workspace observability tabs (the headline)
|
||||
|
||||
- **Steering tab (mission landing)** — Panel 1 projects `STEERING.md`
|
||||
(what agents are currently being told to do); Panel 2 projects the
|
||||
mission's human-facing `MISSION_BRIEF.md` derived from the new locked
|
||||
7-section schema. The landing tab when you click into a mission;
|
||||
Overview stays available.
|
||||
- **Mission brief doc-type + projection** — a real canonical
|
||||
human-facing doc-type alongside `README.md` / `PROGRESS.md` /
|
||||
`MISSION_NOTES.md`. `rig scope init-workspace` /
|
||||
`rig scope mission create` emit a root `MISSION_BRIEF.md` from the
|
||||
7-section template (Brief / What & why / Building / Progress / Proven
|
||||
/ Needs you / Pointers); backfill the same template into existing
|
||||
missions on demand. The Mission Steering tab renders it
|
||||
markdown → UI.
|
||||
- **Story tab — queue-item DAG git-graph** — reconstructs a forest of
|
||||
DAGs from `chain_of_record` + handoff lineage as a scrollable,
|
||||
upward-growing git-graph. Single-parent edges only (no invented merge
|
||||
state); one-line rows over a curved gutter; full-width expanded row;
|
||||
a Tier-3 drawer reuses the queue-item viewer for the full agent-speak
|
||||
body + chain. Both mission and slice altitudes; client-side from the
|
||||
existing `/api/queue` read API (no new surface). The Queue tab folds
|
||||
into Story.
|
||||
- **Workflow tab — spec visualizer** — finishes the half-built Project
|
||||
Topology tab into a real read-only visualizer of the configured
|
||||
workflow spec: dotted-grid canvas, dark-header step cards, per-step
|
||||
state dot, dagre LR layout, amber reject → rework loop-back. Label
|
||||
rename Topology → Workflow (test-id stays `project-tab-topology`,
|
||||
id-stable). Both mission and slice altitudes. Workspace-only; the
|
||||
standalone Topology page (NOC) is unchanged.
|
||||
- **Proof tab** — per-slice proof galleries + empty-state for
|
||||
scaffolded-but-unpopulated slices. Reads from the slice's
|
||||
`proof/` directory + a `PROOF.md` summary if present.
|
||||
- **Progress heat-map** — Project Progress tab consolidates to the
|
||||
heat-map (per-slice rollup cards retired); the heat-map carries
|
||||
status at a glance without the duplicate-card noise.
|
||||
- **Artifacts navigator** — slice-altitude `ArtifactsNavigator`
|
||||
replaces the prior `SliceArtifactsTab` card wall; files and docs
|
||||
resolve through the same `/api/files/*` surface; the Proof tab owns
|
||||
proof artifacts; the Decisions surface routes through Story
|
||||
(decision-of-record items are already Story nodes).
|
||||
- **Workspace portfolio** — workspace parent altitude renders a
|
||||
cross-mission portfolio panel: mission list with steering glance on
|
||||
expand, lazy `MISSION_BRIEF.md` fetch, rollup metric counts
|
||||
*proven* (from `hasProofPacket`) rather than `done`.
|
||||
|
||||
### UI digital twin + visual intent → proof convention
|
||||
|
||||
- **UI digital twin (harness)** — a derive-from-source twin renders all
|
||||
six real Project UI surfaces 1:1 daemon-free from typed fixtures
|
||||
(cache-seed + a thin fetch override + a seeded SSE stream; **not**
|
||||
MSW). One self-contained `intent.html` per surface; tsc
|
||||
compile-time drift-guard (a real interface change breaks the twin
|
||||
build); ~2-step per-slice authoring loop; production-isolated
|
||||
dev-only target. Build any surface with
|
||||
`TWIN_ROUTE=<route> npm run twin:build`.
|
||||
- **`rig scope` intent-visual slot** — `rig scope slice create`
|
||||
scaffolds a slice `README.md` that includes an
|
||||
`## Intent visual` slot with `[change.diff]` + the
|
||||
`TWIN_ROUTE=<route> npm run twin:build` rebuild command. A non-visual
|
||||
slice gets an explicit `N/A` line, so the slot is *present-or-N/A*
|
||||
per slice.
|
||||
- **Visual intent → proof convention** — adopted alongside the
|
||||
harness: visual intent is approved as a twin mockup before build,
|
||||
then proven against a real screenshot at the end. The convention's
|
||||
twin-data-freshness condition (every brand-new UI surface has a twin
|
||||
fixture; data shapes match real) is folded into the release-close
|
||||
gate.
|
||||
|
||||
### Topology + For-You
|
||||
|
||||
- **Topology edge-flow animation** — when a real queue handoff or
|
||||
`rig send` travels an edge (source → destination), the edge animates
|
||||
in the handoff direction, brightens while live, then settles static
|
||||
after TTL. Honesty-contract held: animates **only** on a real
|
||||
queue / send signal (no workflow DAG, no ambient motion);
|
||||
un-signaled edges stay static; reduced-motion omits the flow.
|
||||
Topology-graph surface only.
|
||||
- **For-You phone restyle + real-data fidelity** — the Dashboard
|
||||
restyle reaches For-You as a phone-friendly cards layout with an
|
||||
altitude-dial level filter (All activity → Highlights → Needs you,
|
||||
card set changes per level). The internal `source.type` wrapper
|
||||
string no longer renders in feed cards, and the vestigial
|
||||
storytelling-preview band above the filtered feed is removed (it
|
||||
ignored the level filters and visually masked the restyle).
|
||||
|
||||
### Reliability + correctness
|
||||
|
||||
- **Fresh-install daemon-start fix** — `@hono/node-ws` is now declared
|
||||
in the CLI package (the CLI vendors the built daemon and does not
|
||||
depend on `@openrig/daemon`, so its `package.json` must mirror the
|
||||
daemon's runtime deps for a global `npm install -g @openrig/cli` to
|
||||
resolve them). A monorepo-hoisting blind spot meant the dev host
|
||||
worked while a fresh install failed; a packaging-completeness gate
|
||||
now fails the test suite if a daemon runtime dep is not mirrored in
|
||||
the CLI.
|
||||
- **Table / hybrid terminal-action reliability** — terminal-action
|
||||
errors surface as a visible cmux/auth alert instead of silent
|
||||
failure on the topology Table view + the hybrid surface; the Table
|
||||
terminal now opens cleanly.
|
||||
- **Queue-item human-readable summary** — additive nullable `summary`
|
||||
field on queue items (migration 044). `rig queue create` +
|
||||
`rig queue handoff` gain `--summary` with WARN-on-author (warns if
|
||||
missing; does **not** hard-break existing callers — phased
|
||||
enforcement). The agent-speak body remains the source of truth and
|
||||
is inspectable in the queue-item drawer. The Story-row label prefers
|
||||
the authored summary and degrades cleanly to the body fallback for
|
||||
pre-0.4.1 items.
|
||||
- **Seat-scoped post-compaction restore** — Claude Code seats get a
|
||||
scoped, idempotent restore packet derived from the JSONL transcript;
|
||||
the post-compact agent rebuilds its working mental model from
|
||||
durable evidence rather than fuzzy recall.
|
||||
- **Table-view crash fix** — null-safe TanStack filter + an
|
||||
`ErrorBoundary` on all three topology Table mounts (host / rig /
|
||||
pod) so a single bad row no longer takes down the page; regression
|
||||
test added.
|
||||
- **Dashboard route refresh** — cosmetic visual overhaul of the
|
||||
welcome / home launcher route (paper-draft launcher grid + six
|
||||
cards + Field Environment + drafting footer). Pure cosmetic; the
|
||||
six launcher routes + card test-ids preserved.
|
||||
|
||||
### Conventions adopted
|
||||
|
||||
- **Visual intent → proof** — the digital-twin convention is adopted
|
||||
with three ratified parts: derive-from-source twin (no MSW), one
|
||||
`intent.html` per surface, and twin-data-freshness as the residual
|
||||
release-close check.
|
||||
- **Release-durability close — AC-9 (skill-cascade integrity)** —
|
||||
`rig skill audit` clean for the shipping set, every drift edge
|
||||
green, and bundled plugin reconciled to canonical modulo declared
|
||||
transform. Condition 2 (drift edges) remains advisory until the
|
||||
shipping cascade checker lands; conditions 1 and 3 are hard from
|
||||
this release.
|
||||
- **Release-durability close — AC-10 (twin-data-freshness)** — the
|
||||
twin's component look is 1:1 by construction; the gate covers only
|
||||
the two residuals construction does not — dummy dataset
|
||||
representativeness (caught mechanically by twin build) and
|
||||
net-new-route fixture coverage.
|
||||
|
||||
## Behavior + Compatibility
|
||||
|
||||
- **Migrations** — additive only. Migration 044 adds a nullable
|
||||
`queue_items.summary` column; pre-0.4.1 items degrade to the
|
||||
body-fallback label. Pre-0.4.1 daemons that omit 044 also degrade
|
||||
cleanly via a `detectQueueColumn`-guarded read path. Existing v0.4.0
|
||||
databases upgrade by running `rig daemon start` on the new daemon.
|
||||
- **`rig queue create` / `rig queue handoff` — `--summary`** —
|
||||
optional; missing summary emits a WARN, never a hard error
|
||||
(graceful migration). Hard-require is a future hardening.
|
||||
- **`rig send` default** — now refuses sends when the target pane is
|
||||
at a prompt. `--dangerously-interact --reason "..."` is the explicit
|
||||
override path.
|
||||
- **Cosmetic Project tab renames** — Topology → Workflow (test-id
|
||||
stable); Queue folds into Story.
|
||||
- **For-You feed** — the storytelling-preview band on
|
||||
`/for-you` is removed (its mission-progress rollup remains on
|
||||
Dashboard / Project).
|
||||
|
||||
## Known Limitations + Follow-ons
|
||||
|
||||
- **Skill-layer cut depth (slice 04 family)** — the canonical
|
||||
skill-layer cut still awaits a decision on the load-bearing skill
|
||||
set; the cut does not execute in this release. The 04.3 shipping
|
||||
cascade-drift checker is not in this cut.
|
||||
- **Convention drift control (slice 28)** — partially delivered;
|
||||
convention ratified + folded into the release-close gate, but the
|
||||
shipping product skill + audit-code are still in flight.
|
||||
- **Twin capture tooling (slice 11.2)** — the twin harness ships; the
|
||||
one-command capture wrapper is in flight.
|
||||
- **Workspace UX architecture (slice 15)** — the design-discovery
|
||||
scope is approved; the remaining mockups are in flight (they gate
|
||||
future UI slices, not 0.4.1 shipped surfaces).
|
||||
- **For-You terminal phone-UX (slice 27)** — the For-You restyle and
|
||||
real-data fidelity are shipped; the embedded terminal's phone-UX
|
||||
(keyboard / touch / width / performance) is a non-gating polish
|
||||
check, not yet phone-verified.
|
||||
- **`rig send` interactive-prompt guard — hook-primary path** — the
|
||||
fallback capture-pane guard is fully proven; the hook-primary
|
||||
`PermissionRequest` path lands with this release and rides its full
|
||||
proof on the next iteration.
|
||||
- **`rig auth` follow-on (B1 secret-boundary)** — the adversarial
|
||||
hardening already merged into `rig auth` closes the symlink +
|
||||
hardlink escapes; a deeper structured follow-on is tracked
|
||||
separately, not in this cut.
|
||||
- **Inherited cascade-metadata hygiene** — the 0.4.0 release
|
||||
carry-forward (skill-layer `missing_provenance` +
|
||||
`missing_verified` audit findings) remains the documented residue;
|
||||
bulk fix lives in a separate post-0.4.0 skill-hygiene work item.
|
||||
|
||||
## See Also
|
||||
|
||||
- `CHANGELOG.md` — the standard changelog entry for 0.4.1.
|
||||
- `docs/as-built/cli-reference.md` — the shipped CLI surface.
|
||||
- `docs/releases/v0.4.0.md` — the prior release.
|
||||
@@ -0,0 +1,57 @@
|
||||
# OpenRig v0.4.2
|
||||
|
||||
## Summary
|
||||
|
||||
OpenRig v0.4.2 is a targeted hotfix for the CLI `daemon-status` probe's
|
||||
false-negative window right after a `rig daemon start` / restart. On the
|
||||
prior 0.4.1 build, the probe issued a single `/healthz` fetch per
|
||||
`getDaemonStatus` branch; if the daemon process was up but the HTTP
|
||||
listener had not yet begun accepting, the single probe lost the race and
|
||||
`rig daemon status` reported a false "Daemon not running" /
|
||||
"healthz failed" even though `/healthz` was returning `200`.
|
||||
|
||||
The fix wraps the three `getDaemonStatus` `/healthz` branches in a
|
||||
status-probe-local bounded settle: max 5 attempts, 200ms backoff,
|
||||
hard-bounded by construction. A genuine-down still fails all attempts
|
||||
and is reported stopped / unhealthy after the budget — never masked,
|
||||
never an unbounded wait. `fetchDaemonProbe` and start / stop / checkPid
|
||||
timing are unchanged.
|
||||
|
||||
## What Changed
|
||||
|
||||
- **`rig daemon status` no longer false-negatives during the daemon's
|
||||
post-start listener-bind window.** The probe re-checks up to 5 times
|
||||
with a short backoff before it reports stopped, so a healthy daemon
|
||||
that is still binding its listener resolves as running / healthy
|
||||
instead of reporting a false down state.
|
||||
- **Genuine-down reporting is unchanged.** If the daemon really is not
|
||||
running, all attempts fail and the probe reports stopped or unhealthy
|
||||
after the bounded budget — no hang, no mask.
|
||||
|
||||
## Files Touched
|
||||
|
||||
- `packages/cli/src/daemon-lifecycle.ts` — three probe branches
|
||||
(pid-alive `/healthz`, no-state `/healthz`, and the injected-probe
|
||||
wrapper) now go through `probeHealthzWithSettle`.
|
||||
- `packages/cli/test/daemon-lifecycle.test.ts` — focused probe-layer
|
||||
tests: transient-then-healthy settles to running / healthy;
|
||||
pid-alive-but-never-answers stays `healthy: false`; genuine-down is
|
||||
reported stopped after the hard-bounded budget.
|
||||
|
||||
## Behavior + Compatibility
|
||||
|
||||
- **No CLI surface changes.** All commands, flags, and arguments are
|
||||
identical to `0.4.1`.
|
||||
- **No daemon / DB / migration changes.** Migration state is unchanged
|
||||
from `0.4.1`; no new schema migrations ship in this release.
|
||||
- **No dependency changes.** `package-lock.json` is refreshed for the
|
||||
version bumps only.
|
||||
|
||||
Existing v0.4.1 databases are unaffected. Upgrading is simply
|
||||
`npm install -g @openrig/[email protected]` (or a package refresh) and a normal
|
||||
`rig daemon` restart if the daemon is running.
|
||||
|
||||
## See Also
|
||||
|
||||
- `CHANGELOG.md` — the standard changelog entry for `0.4.2`.
|
||||
- `docs/releases/v0.4.1.md` — the prior release.
|
||||
@@ -0,0 +1,171 @@
|
||||
# OpenRig v0.4.3
|
||||
|
||||
## Summary — "rigs that survive"
|
||||
|
||||
OpenRig v0.4.3 makes rigs actually survive. Crash the daemon or the whole
|
||||
machine — turn the power off, not a graceful stop — and on reboot the
|
||||
seats come back to their **original** sessions, with their pre-crash
|
||||
markers recalled, zero fresh-prime. The proof is a real hard power-off
|
||||
capstone (hard power-off → reboot → `rig start` → both Claude
|
||||
and Codex seats resumed to their originating conversations). This is the
|
||||
load-bearing "rigs that survive" claim.
|
||||
|
||||
Alongside the survival backbone, the release ships the **deny-by-default
|
||||
`rig send` reversal**: `needs_input` (an actual interactive picker) is
|
||||
now the only hard send-refuse; unknown / stale / missing / busy all
|
||||
advise-and-send. The 0.4.1 3-layer guard was too strict in practice —
|
||||
0.4.2 hotfixed the daemon-status probe, and 0.4.3 corrects the send-guard
|
||||
posture so peer coordination no longer needs a `--dangerously-interact`
|
||||
bridge for routine sends. The genuine prompt block still stands.
|
||||
|
||||
Wave-2 UI lands rig-status + launch-control on the workspace, a
|
||||
switch-client view-retarget, and a real theming system (Vellum Dark
|
||||
opt-in; light stays byte-identical). Seat-handover full-cycle + seat-
|
||||
forking closeout complete the recovery story. Doctrine ships too:
|
||||
mission-slice-sop skill.
|
||||
|
||||
Existing v0.4.2 databases upgrade by running `rig daemon start` on the
|
||||
new daemon. Migrations 045, 046, and 047 are additive.
|
||||
|
||||
## Survival — the load-bearing set
|
||||
|
||||
- **Crash-restore resume-token ledger (FR-3 → FR-7)** — the seat's
|
||||
resume marker is captured on adoption (FR-3), refreshed on snapshot
|
||||
(FR-4), pinned to a restore target (FR-5), freshness-gated with a
|
||||
1-hour advisory threshold + `--fresh` re-verify (FR-6 + FR-6.1
|
||||
periodic re-stamp), and — critically — the restore path never
|
||||
silently fresh-primes a seat that had a resume marker (FR-7). Prior
|
||||
to FR-7, a restore that couldn't reach the token would degrade to a
|
||||
fresh-prime, losing the original session identity; that silent
|
||||
degrade is now eliminated.
|
||||
- **Fixture-home isolation guard** — a fresh-prime that would have
|
||||
clobbered a live rig's home dir is refused loudly, not silently
|
||||
swallowed.
|
||||
- **Liveness PID-verify** — the daemon distinguishes a live seat from a
|
||||
dead-PID ghost by verifying the process actually exists before
|
||||
labeling the seat running.
|
||||
- **Daemon event-loop health + terminal broker resilience** — the
|
||||
daemon publishes an event-loop-latency health signal; the
|
||||
`TerminalSessionBroker` recovers cleanly across daemon restarts.
|
||||
- **Startup-proof** — a seat proves it actually read its orientation
|
||||
material rather than skipping past it; a challenge-verified read
|
||||
path.
|
||||
- **Seat-handover full-cycle** — an outgoing seat can hand its captured
|
||||
context to a fresh successor; the resume marker carries automatically
|
||||
via FR-3; the loud-unwind path fires on both lanes when handoff
|
||||
fails.
|
||||
- **Seat-forking closeout** — the fork lifecycle terminates cleanly on
|
||||
the shared checkpoint; predecessors and successors are named + wired.
|
||||
|
||||
## The deny-by-default reversal (`rig send`)
|
||||
|
||||
The 0.4.1 3-layer guard was too aggressive at the fleet level: unknown /
|
||||
stale / missing / busy activity signals all default-blocked, which
|
||||
required constant `--dangerously-interact --reason "..."` overrides for
|
||||
routine peer coordination. 0.4.3 corrects this:
|
||||
|
||||
- **`needs_input` (a real interactive picker rendered on the target
|
||||
pane) is the only hard send-refuse.** The guard here is unchanged
|
||||
behavior at the actual footgun-point.
|
||||
- **Unknown / stale / missing / busy** now **advise-and-send** rather
|
||||
than block. Peer sends land; the operator sees the advisory in the
|
||||
send output.
|
||||
- **`--dangerously-interact --reason "..."`** override still exists for
|
||||
intentionally driving a prompted seat, unchanged.
|
||||
|
||||
Also improved: **Codex activity-signal hardening** — an idle Codex seat
|
||||
now takes normal sends; the genuine prompt block still holds; hook-trust
|
||||
autoclear lands as a fast-follow so a restored Codex seat clears its
|
||||
attention-gate on restore.
|
||||
|
||||
## `rig send` unified targeting
|
||||
|
||||
`rig send` grows explicit multi-recipient targeting — a recipient list +
|
||||
`--pod` + `--rig` — reusing the existing broadcast fan-out + per-
|
||||
recipient guard. Backward-compatible: single-recipient send is
|
||||
unchanged.
|
||||
|
||||
## Wave-2 UI + theming
|
||||
|
||||
- **Rig-status + launch-control UI** — the workspace surface for
|
||||
starting / stopping / recovering a rig lands. Combines the topology
|
||||
view with a launch modal for mixed-plan rigs.
|
||||
- **Switch-client view-retarget** — clicking through a switch-client
|
||||
action lands the operator on the correct target view without a stale
|
||||
breadcrumb.
|
||||
- **Dashboard theming — Vellum Dark opt-in** — a real theming
|
||||
primitive: `light` is the existing `:root` theme (byte-identical),
|
||||
`dark` is the first shipped alternate, and the substrate scales to
|
||||
N themes via the token-block + registry entry. Uses Tailwind
|
||||
`darkMode: 'selector'`. Light is unchanged.
|
||||
|
||||
## Recovery + operator-UX
|
||||
|
||||
- **Idle-gate watchdog** — an idle seat holding a claimable gate qitem
|
||||
gets woken with a bounded skip-recording so the queue doesn't back up
|
||||
behind a dormant seat.
|
||||
- **Session-admin mutation auth-guard** — the admin mutation surface is
|
||||
no longer reachable without the correct auth posture.
|
||||
- **`rig queue show` bounded body preview** — long queue-item bodies
|
||||
are truncated in the show view; the full body remains inspectable via
|
||||
the queue-item drawer.
|
||||
- **Manual configurable compaction trigger + same-seat guard** —
|
||||
Claude Code seats can be prompted to compact from outside the seat;
|
||||
the trigger refuses a same-seat mutation.
|
||||
- **Secret-boundary B1 hardening (`rig auth`)** — the `rig auth`
|
||||
secret boundary now opens the fd first + operates on the fd never
|
||||
re-resolving the path, closing the check-then-use gap for
|
||||
interruption / concurrent-legitimate-rig-process scenarios (the
|
||||
TOCTOU class is closed as a consequence). The `rig auth` file is a
|
||||
real credential; leak-hunt is real security, not theater.
|
||||
|
||||
## Doctrine + fast-follow
|
||||
|
||||
- **`mission-slice-sop` skill** ships — the per-file rules for
|
||||
`PROGRESS.md` / `PROOF.md` / `MISSION_NOTES.md` / `MISSION_BRIEF.md` /
|
||||
`README.md`, the SCAFFOLD/POPULATE/PROJECT/VERIFY lifecycle,
|
||||
hot-potato queue handoffs, and the `rig scope audit` backstop.
|
||||
- **Hook-trust autoclear (fast-follow)** — a restored Codex seat that
|
||||
hits its attention_required VM-local hook-trust gate now auto-clears
|
||||
the attention on the restore path, mirroring slice-10, for
|
||||
fully-unattended Codex restore.
|
||||
|
||||
## Behavior + Compatibility
|
||||
|
||||
- **Migrations** — additive only. `045_resume_verification`,
|
||||
`046_seat_identity_verdicts`, `047_events_node_type_index` land in
|
||||
this release. Existing v0.4.2 databases upgrade by running
|
||||
`rig daemon start` on the new daemon.
|
||||
- **`rig send` default posture change** — unknown / stale / missing /
|
||||
busy activity signals no longer default-block; only `needs_input`
|
||||
does. If you were relying on the 0.4.1 default-block posture, add an
|
||||
explicit guard on your call sites; the intent-signaling for peer
|
||||
coordination now lives in the fleet-standard advise-and-send path.
|
||||
- **`rig send` grammar** — multi-recipient list + `--pod` + `--rig`
|
||||
are additive; single-recipient calls are unchanged.
|
||||
- **Cosmetic Project surface** — Dashboard theming is opt-in via the
|
||||
UI toggle; light theme is byte-identical.
|
||||
|
||||
## Known Follow-ons
|
||||
|
||||
- **Slice-04 real-provider handover VM-marker** — code proven at
|
||||
runtime (unit + integration + loud-unwind both lanes); the real-
|
||||
provider end-to-end VM-round-trip is a **fast-follow proof capture**
|
||||
(VM-env limitation on prior provider auth). Not a claim gap; the
|
||||
load-bearing survival proof is the crash-restore capstone.
|
||||
- **`rig ps` consolidated-default + progressive disclosure** — captured
|
||||
for 0.4.4 (token-efficient-defaults family evolution).
|
||||
- **Deploy-identity: git SHA via `/healthz` + `rig --version`** — the
|
||||
version-truth-vs-commit-truth observability gap (captured for 0.4.4
|
||||
Discovery).
|
||||
- **Managed-hooks-via-`requirements.toml`** — the robust structural
|
||||
hook-trust re-arch (later fast-follow).
|
||||
- **Skill-layer cut depth (slice-25)** — awaits a decision on the
|
||||
load-bearing skill set; deferred.
|
||||
- **Dashboard glyph swap (slice-24)** — awaits icon pick; deferred.
|
||||
|
||||
## See Also
|
||||
|
||||
- `CHANGELOG.md` — the standard changelog entry for `0.4.3`.
|
||||
- `docs/releases/v0.4.2.md` — the prior release (daemon-status hotfix).
|
||||
- `docs/releases/v0.4.1.md` — the workspace observability overhaul.
|
||||
@@ -0,0 +1,131 @@
|
||||
# OpenRig v0.4.4
|
||||
|
||||
## Summary — Multi-host + Living Notes
|
||||
|
||||
OpenRig v0.4.4 lands the multi-host foundation: a shared rig topology
|
||||
can now span multiple hosts, with a staged spin-up flow, real cross-
|
||||
host file movement, and a consolidated For-You activity feed that
|
||||
merges signal across every host in the topology. The VPS product-
|
||||
factory story hardens for real multi-host operation.
|
||||
|
||||
Alongside the multi-host foundation, this release ships the **Living
|
||||
Notes signal layer** — a durable, agent-authored notes surface that
|
||||
threads INTENT → PLAN → DELIVERED down the mission tree, with a
|
||||
one-structure review contract that reads left-to-right as a single
|
||||
vertical stack (INTENT above, PLAN in the middle, DELIVERED at the
|
||||
bottom; delete-not-demote when a plan changes). Composer surfaces make
|
||||
authoring cheap; the review surface makes reading intent-versus-build
|
||||
one-glance.
|
||||
|
||||
Operator UX picks up **`rig ps` consolidated default** (the token-
|
||||
efficient-defaults family gains progressive disclosure that keeps the
|
||||
default view glanceable without hiding the fleet), an **agent altitude
|
||||
coordination panel** that surfaces multi-agent state at the right
|
||||
altitude for the operator, and the **operationalize-SDLC control
|
||||
plane** which pulls the release / delivery / lifecycle levers into a
|
||||
single control surface. As-built docs closeout catches everything up.
|
||||
|
||||
Migrations, if any, are additive. Existing v0.4.3 databases upgrade by
|
||||
running `rig daemon start` on the new daemon.
|
||||
|
||||
## What Shipped
|
||||
|
||||
### Multi-host foundation
|
||||
|
||||
- **Shareable whole-topology staged spin-up** — a rig topology can be
|
||||
brought up in stages across multiple hosts; the spec + the daemon
|
||||
coordinate to reach a green whole-fleet state without requiring a
|
||||
single-host bring-up. Prior to this release, a whole-topology spin-
|
||||
up assumed a single host.
|
||||
- **VPS product-factory multi-host hardening** — the shipped
|
||||
`openrig-user`/`openrig-architect` operator flows work correctly
|
||||
across VPS/self-hosted product-factory scenarios; multi-host
|
||||
handshake + registration hardens.
|
||||
- **Multi-host consolidated For-You feed** — the For-You feed
|
||||
aggregates activity across all registered hosts in the topology, not
|
||||
just the local daemon. The stream classifier + real-data fidelity
|
||||
from 0.4.3 apply per-host and merge upstream. A real-host feed-
|
||||
subscription capture ships as belt-and-suspenders proof (see
|
||||
Known Follow-ons).
|
||||
- **`rig file` cross-host movement** — files move across the
|
||||
topology's registered hosts via the `rig file` surface. Real
|
||||
registered-host two-host end-to-end round-trip proves the flow on
|
||||
top of the VM stand-ins already merged.
|
||||
|
||||
### Living Notes
|
||||
|
||||
- **Signal layer** — a durable, agent-authored notes surface at
|
||||
mission and slice altitude; INTENT / PLAN / DELIVERED entries thread
|
||||
down the mission tree with agent authorship + timestamps.
|
||||
- **Composer surfaces** — cheap authoring surfaces make Living Notes
|
||||
the first-class place agents record decisions, plans, and delivered
|
||||
work.
|
||||
- **One-structure review contract (§3.1) — corrective rebuild** — the
|
||||
Living Notes review surface reads left-to-right as a **single
|
||||
vertical stack** (INTENT above, PLAN + mockup in the middle,
|
||||
DELIVERED with paired proof at the bottom); a plan change **deletes**
|
||||
the old plan and writes a new one — never demotes / stacks multiple
|
||||
competing plans. The corrective rebuild replaced an earlier
|
||||
three-column layout after human review.
|
||||
|
||||
### Operator UX
|
||||
|
||||
- **`rig ps` consolidated default + progressive disclosure** — the
|
||||
default `rig ps` now consolidates the fleet's readable rows into a
|
||||
single glanceable default; progressive disclosure via `--full` /
|
||||
`-A` / per-scope filters keeps the rich per-node record retrievable.
|
||||
Follows the token-efficient-defaults family established at 0.4.0.
|
||||
- **Agent altitude coordination panel** — a coordination surface
|
||||
scoped to the right altitude (workspace / mission / slice) so the
|
||||
operator sees the actual coordination state without drowning in
|
||||
per-seat detail. Composes with the workspace observability tabs
|
||||
shipped at 0.4.1.
|
||||
|
||||
### Doctrine
|
||||
|
||||
- **Operationalize the SDLC control plane** — release / delivery /
|
||||
lifecycle levers pull into a single surface. The lanes that shipped
|
||||
0.4.1 → 0.4.3 (release-cut squash + bounded-diff, sidecar-operator
|
||||
host cutover, curation pass) are now first-class control-plane
|
||||
actions rather than lifecycle-tribal-wisdom.
|
||||
- **As-built docs closeout** — the as-built documentation family
|
||||
(`docs/as-built/architecture.md`, `docs/as-built/cli-reference.md`,
|
||||
codemaps) is caught up to what shipped through 0.4.4.
|
||||
|
||||
## Behavior + Compatibility
|
||||
|
||||
- **Migrations** — additive only. Existing v0.4.3 databases upgrade
|
||||
by running `rig daemon start` on the new daemon.
|
||||
- **Multi-host registration** — the multi-host handshake is
|
||||
backward-compatible with single-host deployments; single-host
|
||||
operation is unchanged.
|
||||
- **`rig ps` default view** — the consolidated default is a
|
||||
compact readable view; `--full` returns the v0.4.3 default shape.
|
||||
- **For-You feed** — multi-host feed aggregation is opt-in via
|
||||
registered-host membership; a single-host topology sees no
|
||||
behavior change.
|
||||
|
||||
## Known Follow-ons
|
||||
|
||||
- **R2 — iOS-Safari S20 (Living Notes composer)** — deferred to
|
||||
0.4.5 for iOS Safari verification.
|
||||
- **R4 — S18 symlink footgun** — routed to the 0.4.5+ backlog.
|
||||
- **`openrig-user` stale plugin copy + `whoami --all-hosts` silent
|
||||
host filter** — 0.4.5 candidates.
|
||||
- **Wider mission-template prose sweep** — post-cut sequencing.
|
||||
- **Belt-and-suspenders real-host proofs** — R13-1 (published-npm
|
||||
Linux install smoke + daemon-start / `/healthz` / WebSocket
|
||||
handshake), R1 (real registered-host two-host e2e for S13 + S18 on
|
||||
top of the merged VM stand-ins), and R3 (S15 real-host feed-
|
||||
subscription capture) are sequenced as post-publish lifecycle
|
||||
validation lanes. The shipped code was already proven via VM/SSH
|
||||
stand-ins per PM rulings; the real-host lanes are defense-in-depth
|
||||
belt-and-suspenders proofs, not release gates.
|
||||
|
||||
## See Also
|
||||
|
||||
- `CHANGELOG.md` — the standard changelog entry for `0.4.4`.
|
||||
- `docs/releases/v0.4.3.md` — the prior release ("rigs that
|
||||
survive").
|
||||
- `docs/releases/v0.4.2.md` — the daemon-status hotfix.
|
||||
- `docs/releases/v0.4.1.md` — the workspace observability overhaul.
|
||||
@@ -0,0 +1,198 @@
|
||||
# OpenRig v0.4.6
|
||||
|
||||
## Summary — Workflows + multi-host coordination + factory foundations
|
||||
|
||||
OpenRig v0.4.6 lands the deterministic workflow engine and the
|
||||
multi-host coordination model that runs on top of it. Workflows are
|
||||
now a first-class product surface: a rock-solid engine, a full-
|
||||
featured spec language, a workflow CLI, a workflow web UI, and an
|
||||
exception + human-gate maturity model that keeps a healthy run
|
||||
zero-interruption. On top of that engine, the self-driving factory
|
||||
starter ships as an MVP shipped starter (`rig up factory-rsi`) —
|
||||
one rig, seven seats, a self-driving `plan → implement → qa_check
|
||||
→ review → release` inner loop with dogfood decoupled and running
|
||||
out-of-band against the shipped product, feeding recorded findings
|
||||
into the next plan.
|
||||
|
||||
Multi-host coordination lands the full happy path. Hosts register
|
||||
and select through dashboard-visible cards; a remote host's
|
||||
workspace surfaces are readable from the local operator; queue
|
||||
items and hot-potato handoffs cross hosts explicitly through the
|
||||
registered transport (never a silent selection re-home); the direct
|
||||
coordination verbs (`rig send`, `rig capture`, `rig transcript`,
|
||||
`rig broadcast`) cross hosts CLI-direct via the shipped http
|
||||
transport branch with byte-verbatim ssh behavior preserved; a
|
||||
fleet-altitude attention band rolls up needs-attention across the
|
||||
whole registered fleet.
|
||||
|
||||
Foundations round out the release: a new agent runtime adapter (Pi)
|
||||
joining Claude/Codex as a first-class runtime, terminal provider
|
||||
first-class treatment across the terminal-facing surfaces, and
|
||||
daemon read-path hardening for the multi-host + workflow load with
|
||||
no functional behavior change.
|
||||
|
||||
Two new bundled skills ship canonical + plugin with byte-parity
|
||||
guard: `openrig-herdr` (rig terminal open/views/status model) and
|
||||
`openrig-cmux` (provider-agnostic delta).
|
||||
|
||||
Migrations are additive only: `049_workflow_instance_version`,
|
||||
`050_workflow_spec_json`, `051_workflow_resume`,
|
||||
`052_workflow_instance_bound_rig`, `053_sessions_node_id_index`,
|
||||
`054_queue_transitions_archive`. Existing v0.4.4 databases upgrade
|
||||
by running `rig daemon start` on the new daemon. (v0.4.5 was
|
||||
skipped; there is no cut between v0.4.4 and v0.4.6.)
|
||||
|
||||
## What Shipped
|
||||
|
||||
### Workflows
|
||||
|
||||
- **The rock-solid deterministic engine** — strict spec validation
|
||||
(unknown keys reject; unreachable steps and unguarded cycles
|
||||
fail); `loop_guards.max_hops` enforced at projection; waiting-
|
||||
exit replay absorption; per-instance keepalive watchdogs auto-
|
||||
armed in the routing transaction; startup sweep re-arms and
|
||||
surfaces stuck instances; `rig workflow continue` relabeled to
|
||||
its read-only inspector semantics.
|
||||
- **The full-featured spec language** — steps with role /
|
||||
target.rig / preferred_targets / next_hop.on / invariants /
|
||||
loop_guards / exception_routing / closure / gates / skill_refs /
|
||||
spawn_budget; strict-keyset validation; shipped-spec compat
|
||||
pinned by fixture round-trip.
|
||||
- **The workflow CLI** — new verbs `rig workflow run` (instantiate
|
||||
+ follow to terminal; exit 3 = workflow-failed distinct from
|
||||
transport 1/2), `rig workflow watch` (mid-flight attach),
|
||||
`rig workflow status` (needs-attention rollup), `rig workflow
|
||||
route` (frontier re-target in one transaction). Formatted
|
||||
`trace` / `list` / `show` human output; `--json` byte-identical
|
||||
to before. New guard rejects out-of-band terminal closure of a
|
||||
live workflow-frontier packet.
|
||||
- **The workflow web UI** — new
|
||||
Workflows/Instance/InstancesBand/TopologyGraph/InstanceTrailTimeline
|
||||
pages; SSE stream unscoped by rig; pure `buildLauncherViews` +
|
||||
`suggestLayout` + edge-handle assignment exports. UI acceptance
|
||||
is carried by per-cut real screenshots + the two-host
|
||||
integration proof; twin fixture backfill is routed as a
|
||||
non-blocking 0.4.7 hygiene fast-follow.
|
||||
- **The exception + human-gate model** — `rig workflow resume`
|
||||
verb (redrive a failed instance from its failed step, rebound,
|
||||
fresh capability-aware re-resolution, one fresh `max_hops`
|
||||
window); exceptions become durable attention items in the same
|
||||
transaction as the failure; maturity dial `orchestrator-first |
|
||||
human_only` per class / per workflow / per host; the attention
|
||||
band is workflow-aware; the happy-path guarantee is unchanged.
|
||||
|
||||
### Multi-host coordination
|
||||
|
||||
- **Add / select hosts + dashboard** — `HostConfigCard` +
|
||||
`HostIndicator` surfaces on the dashboard; `rig host select`
|
||||
persists a sticky selection consumed by the observe/interactive
|
||||
verbs; new `useHosts` / `useFleet` hooks.
|
||||
- **View remote workspace** — workspace observability tabs gain
|
||||
`--host <id>` scope; registry + bearer fans out per-host reads.
|
||||
Read-only.
|
||||
- **Cross-host queue routing** — `rig queue create / handoff /
|
||||
handoff-and-complete` gain `--host <id>` + host-qualified
|
||||
destination form `member@rig@<host>`. Explicit-only routing.
|
||||
At-least-once + idempotent via deterministic successor id;
|
||||
handoff creates successor FIRST + closes local SECOND; retries
|
||||
absorb on primary key. Failure honesty per host outcome.
|
||||
- **Cross-host agent coordination** — `rig send` + `rig capture`
|
||||
gain an http transport branch reaching the remote daemon's
|
||||
existing `/api/transport/send|capture` routes CLI-direct;
|
||||
`rig transcript` + `rig broadcast` gain their first cross-host
|
||||
affordance the same way. The `agent@rig@host` target form is
|
||||
CLI-edge sugar for session-target verbs. Byte-verbatim ssh
|
||||
behavior preserved for ssh-registered hosts; explicit `--host`
|
||||
> target sugar > selection; `send --verify` over http prints
|
||||
the REMOTE route's verdict verbatim.
|
||||
- **Fleet-attention altitude** — fleet-wide attention rollup via
|
||||
`FleetBand` + `FleetPage`; per-host status + drill-down.
|
||||
|
||||
### Factory
|
||||
|
||||
- **The workflow-to-rig binding layer** — bind a workflow to a rig
|
||||
at instantiation via `--rig`; roles resolve to seats by
|
||||
capability; capable-seat resolution honors managed-only,
|
||||
harness-pin-aware runtime match, least pending backlog,
|
||||
deterministic tiebreak; resolve-once, record-in-the-packet;
|
||||
roles bind to the stable seat coordinate `{pod}-{member}@{rig}`
|
||||
(agent handover behind the seat never strands the workflow).
|
||||
- **The self-driving factory starter (`factory-rsi`)** — new
|
||||
`rig up factory-rsi` starter; new `factory-rsi` builtin
|
||||
workflow with a `plan → implement → qa_check → review →
|
||||
release` inner loop sanctioned by `loop_guards.max_hops`;
|
||||
dogfood decoupled + running out-of-band against the shipped
|
||||
product with its findings feeding the next plan (the RSI edge,
|
||||
ungated in the MVP — refined in a later release); publish stays
|
||||
a human act via `release_signoff` gate.
|
||||
- **The member-exists advisory + cross-rig wrapping** —
|
||||
mis-routed workflow destinations caught loudly at instantiate;
|
||||
cross-rig wrapping workflows travel via the cross-host queue
|
||||
routing path.
|
||||
|
||||
### Runtime + terminal + read-path
|
||||
|
||||
- **Pi agent runtime adapter** — a new first-class runtime
|
||||
joining Claude/Codex; participates in all workflow / role /
|
||||
queue / send / capture surfaces exactly like the shipped
|
||||
runtimes.
|
||||
- **Terminal provider first-class treatment** — terminal provider
|
||||
first-class treatment across the terminal launcher,
|
||||
send/capture/broadcast, and the new `openrig-cmux` skill's
|
||||
`--provider cmux` seam with best-effort fallback.
|
||||
- **Daemon read-path hardening** — SSE stream backpressure
|
||||
guards, projection caches, additive indices for read-side query
|
||||
performance. No functional behavior change; the same reads
|
||||
return the same results, faster.
|
||||
|
||||
### New bundled skills
|
||||
|
||||
- **`openrig-herdr`** — the full rig terminal open/views/status
|
||||
model (verbs, view grammar, honest-partial reading, read-only
|
||||
policy, terminal-views.yaml schema). AGPL clean-room rail — no
|
||||
herdr source text vendored.
|
||||
- **`openrig-cmux`** — the provider-agnostic vs cmux-specific
|
||||
delta with shipped-integration open-or-focus rule.
|
||||
|
||||
Both ship canonical + bundled openrig-core plugin, byte-parity
|
||||
guarded.
|
||||
|
||||
## Migrations
|
||||
|
||||
Additive only:
|
||||
|
||||
- `049_workflow_instance_version` — optimistic concurrency on
|
||||
`workflow_instances`.
|
||||
- `050_workflow_spec_json` — full parsed spec cached on
|
||||
`workflow_specs`; legacy rows self-heal on next read-through.
|
||||
- `051_workflow_resume` — `resume_count` + `hops_baseline` on
|
||||
`workflow_instances`.
|
||||
- `052_workflow_instance_bound_rig` — nullable `bound_rig` TEXT
|
||||
on `workflow_instances`.
|
||||
- `053_sessions_node_id_index` — additive index.
|
||||
- `054_queue_transitions_archive` — archive table for read-side
|
||||
query performance.
|
||||
|
||||
Existing v0.4.4 databases upgrade by running `rig daemon start` on
|
||||
the new daemon.
|
||||
|
||||
## Known Follow-ons
|
||||
|
||||
- **Save-verb** did not land in 0.4.6; rides 0.4.7.
|
||||
- **Twin fixture backfill** for the new workflow + multi-host UI
|
||||
routes routed as a non-blocking 0.4.7 hygiene fast-follow; UI
|
||||
acceptance for the cut carried by per-cut real screenshots + the
|
||||
two-host integration proof.
|
||||
- **Continuous out-of-band dogfood runtime** — the factory-rsi
|
||||
RSI edge ships ungated in the MVP; the continuous mechanism is
|
||||
refined in a later release.
|
||||
- **Carry-forwards from 0.4.4**: `openrig-user` bundled plugin
|
||||
stale-copy sweep, `whoami --all-hosts` silent host filter,
|
||||
managed-stop SIGTERM-escalation brittleness, post-cutover
|
||||
reconcile-settle-visibility signal on `/healthz`.
|
||||
- **iOS Safari** (Living Notes composer verification) —
|
||||
carry-forward from 0.4.4.
|
||||
- **Wider mission-template prose sweep** — post-cut sequencing.
|
||||
|
||||
Full technical detail lives in
|
||||
[CHANGELOG.md](../../CHANGELOG.md#046---2026-07-09).
|
||||
Generated
+40
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/daemon",
|
||||
@@ -1009,6 +1009,22 @@
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-ws": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-ws/-/node-ws-1.3.1.tgz",
|
||||
"integrity": "sha512-vo/MwCnpJAVHBkGzWjCJ28wF45fYHAfbPZcH2rodZODHtch2GHA94KtMfusmVycTUtsLAsaNsHhtY6P8X3RQsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"hono": "^4.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
@@ -2768,6 +2784,21 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"addons/*"
|
||||
]
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz",
|
||||
@@ -7314,7 +7345,6 @@
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -7419,11 +7449,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
@@ -7450,10 +7481,11 @@
|
||||
},
|
||||
"packages/daemon": {
|
||||
"name": "@openrig/daemon",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"better-sqlite3": "^11.8.0",
|
||||
"hono": "^4.7.0",
|
||||
"tar": "^7.5.13",
|
||||
@@ -7474,7 +7506,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@openrig/ui",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
@@ -7488,6 +7520,8 @@
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openrig",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openrig/cli",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.6",
|
||||
"type": "module",
|
||||
"description": "Local control plane for multi-agent coding topologies",
|
||||
"keywords": [
|
||||
@@ -43,6 +43,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"@hono/node-ws": "^1.3.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// OPR.0.4.4.11 FR-6 — the CLI's build-identity module.
|
||||
//
|
||||
// COMMITTED DEV STUB: unstamped runs report absent identity — never an
|
||||
// invented SHA. scripts/build-package.sh computes the stamp ONCE and
|
||||
// overwrites the compiled counterpart (dist/build-info.js) at package time;
|
||||
// this is the CLI's own generated twin of the daemon module (arch ruling 6 —
|
||||
// per-package modules, no runtime cross-package import).
|
||||
|
||||
export interface BuildInfo {
|
||||
semver: string | null;
|
||||
commit: string | null;
|
||||
dirty: boolean | null;
|
||||
builtAt: string | null;
|
||||
}
|
||||
|
||||
export const BUILD_INFO: BuildInfo = {
|
||||
semver: null,
|
||||
commit: null,
|
||||
dirty: null,
|
||||
builtAt: null,
|
||||
};
|
||||
@@ -1,7 +1,27 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ConfigStore } from "./config-store.js";
|
||||
import { readOpenRigEnv } from "./openrig-compat.js";
|
||||
import { fetchWithTimeout } from "./fetch-with-timeout.js";
|
||||
|
||||
export function terminalAuthHeaders(): Record<string, string> {
|
||||
const token = resolveTerminalToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
function resolveTerminalToken(): string | null {
|
||||
const env = process.env.OPENRIG_TERMINAL_BEARER_TOKEN?.trim();
|
||||
if (env) return env;
|
||||
try {
|
||||
const homeDir = process.env.OPENRIG_HOME ?? path.join(process.env.HOME ?? "", ".openrig");
|
||||
const tokenPath = path.join(homeDir, "terminal-token");
|
||||
const token = fs.readFileSync(tokenPath, "utf-8").trim();
|
||||
return token || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class DaemonConnectionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -16,6 +36,8 @@ export interface DaemonResponse<T = unknown> {
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
@@ -79,11 +101,27 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
return this.requestJson<T>(path, {
|
||||
method: "DELETE",
|
||||
headers: options?.headers,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const baseHeaders: Record<string, string> = body !== undefined ? { "Content-Type": "application/json" } : {};
|
||||
const headers = { ...baseHeaders, ...(options?.headers ?? {}) };
|
||||
return this.requestJson<T>(path, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
||||
if (options?.headers) {
|
||||
init = { ...init, headers: { ...(init.headers as Record<string, string> ?? {}), ...options.headers } };
|
||||
}
|
||||
try {
|
||||
return await fetchWithTimeout(
|
||||
this.fetchImpl,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Command } from "commander";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AddMemberResponse {
|
||||
ok: boolean;
|
||||
result?: {
|
||||
podId: string;
|
||||
podNamespace: string;
|
||||
node: { logicalId: string; nodeId: string; status: string; error?: string; sessionName?: string };
|
||||
edges?: Array<{ from: string; to: string; kind: string }>;
|
||||
warnings?: string[];
|
||||
};
|
||||
code?: string;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function addMemberCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("add").description("Add a member to an existing pod in a running rig");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rig-id>", "ID of the target rig")
|
||||
.argument("<pod-namespace>", "Namespace of the existing pod to add the member to")
|
||||
.argument("<member-fragment-path>", "Path to YAML/JSON member fragment file (spec snake_case fields)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--rig-root <path>", "Root directory for agent resolution")
|
||||
.action(async (rigId: string, podNamespace: string, fragmentPath: string, opts: { json?: boolean; rigRoot?: string }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let fileContent: string;
|
||||
try {
|
||||
fileContent = readFileSync(fragmentPath, "utf-8");
|
||||
} catch {
|
||||
console.error(`Cannot read file: ${fragmentPath}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let member: Record<string, unknown>;
|
||||
let edges: unknown;
|
||||
try {
|
||||
// Dynamic import to avoid bundling yaml at module load (matches expand).
|
||||
const { parse } = await import("yaml");
|
||||
const parsed = (parse(fileContent) ?? {}) as Record<string, unknown>;
|
||||
if (parsed["member"] && typeof parsed["member"] === "object" && !Array.isArray(parsed["member"])) {
|
||||
// Wrapper form: { member: {...}, edges?: [...] }.
|
||||
member = parsed["member"] as Record<string, unknown>;
|
||||
edges = parsed["edges"];
|
||||
} else {
|
||||
// Bare member form. Lift any top-level `edges:` out as pod-local edges
|
||||
// so they are NOT silently dropped (the schema ignores unknown member
|
||||
// fields). The rest is the member.
|
||||
const { edges: bareEdges, ...rest } = parsed;
|
||||
member = rest;
|
||||
edges = bareEdges;
|
||||
}
|
||||
} catch {
|
||||
console.error("Invalid YAML/JSON in member fragment file");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// A PRESENT-but-non-array edges field is an honest error, never silently
|
||||
// omitted (governance FM2 no-silent-drop).
|
||||
if (edges !== undefined && edges !== null && !Array.isArray(edges)) {
|
||||
console.error("Invalid member fragment: 'edges' must be an array of { from, to, kind }.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body: Record<string, unknown> = { member };
|
||||
if (Array.isArray(edges)) body["edges"] = edges;
|
||||
if (opts.rigRoot) body["rigRoot"] = opts.rigRoot;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<AddMemberResponse>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/pods/${encodeURIComponent(podNamespace)}/members`,
|
||||
body,
|
||||
);
|
||||
const data = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
// Non-zero if the HTTP failed OR the new node did not fully launch.
|
||||
if (res.status >= 400 || (data.ok && data.result !== undefined && data.result.node.status !== "launched")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400 || !data.ok) {
|
||||
// Honest 3-part error: the daemon's message already says what failed /
|
||||
// why / what to do (pod_not_found lists pods; member_conflict suggests a
|
||||
// new id); validation/preflight surface the specific field errors.
|
||||
const msg = data.message
|
||||
?? (data.errors && data.errors.length > 0 ? data.errors.join("; ") : data.error)
|
||||
?? `Add member failed (HTTP ${res.status})`;
|
||||
console.error(msg);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const node = data.result!.node;
|
||||
const icon = node.status === "launched" ? "OK" : "FAIL";
|
||||
const session = node.sessionName ? ` (${node.sessionName})` : "";
|
||||
const error = node.error ? ` - ${node.error}` : "";
|
||||
console.log(`Added member to rig ${rigId}`);
|
||||
console.log(` Pod: ${data.result!.podNamespace}`);
|
||||
console.log(` Member: [${icon}] ${node.logicalId}${session}${error}`);
|
||||
|
||||
const persistedEdges = data.result!.edges ?? [];
|
||||
if (persistedEdges.length > 0) {
|
||||
console.log(" Edges:");
|
||||
for (const e of persistedEdges) console.log(` ${e.from} ${e.kind} ${e.to}`);
|
||||
}
|
||||
|
||||
if (data.result!.warnings && data.result!.warnings.length > 0) {
|
||||
console.log("");
|
||||
for (const w of data.result!.warnings) console.log(` Warning: ${w}`);
|
||||
}
|
||||
|
||||
if (node.status !== "launched") {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ArchiveResult {
|
||||
ok: boolean;
|
||||
rigId: string;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
interface ThreePartErrorBody {
|
||||
error:
|
||||
| { fact: string; consequence: string; action: string }
|
||||
| string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig archive <rigId>` - OPR.0.3.3.19. Soft, REVERSIBLE archive: hides a rig
|
||||
* from the default explorer + `rig ps`, while RETAINING the rigs row, topology,
|
||||
* and snapshots. This is NOT `rig down --delete` (which is destructive). Reverse
|
||||
* with `rig unarchive <rigId>`. Running/degraded rigs require `--force`.
|
||||
*/
|
||||
export function archiveCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("archive").description(
|
||||
"Archive a rig (soft + reversible: hides it from the default view, retains all data). NOT a delete; reverse with 'rig unarchive'.",
|
||||
);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to archive")
|
||||
.option("--force", "Archive even if the rig is running or degraded")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<ArchiveResult | ThreePartErrorBody>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/archive`,
|
||||
{ force: opts.force ?? false },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = res.status === 409 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Rig not found: ${rigId}. Check the id with: rig ps`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// AC-6: running/degraded without --force returns a 3-part honest error.
|
||||
if (res.status === 409) {
|
||||
const err = (res.data as ThreePartErrorBody).error;
|
||||
if (err && typeof err === "object") {
|
||||
process.stderr.write(`Error: ${err.fact}\n${err.consequence}\n${err.action}\n`);
|
||||
} else {
|
||||
process.stderr.write(`Error: ${String(err)}\n`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`Archive failed (HTTP ${res.status}).`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = res.data as ArchiveResult;
|
||||
if (r.archived) {
|
||||
console.log(`Rig ${rigId} archived (reversible). It is hidden from the default view.`);
|
||||
console.log(` See it with: rig ps --include-archived`);
|
||||
console.log(` Bring it back: rig unarchive ${rigId}`);
|
||||
} else {
|
||||
console.log(`Rig ${rigId} was already archived.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// OPR.0.4.1.29 — `rig auth <verb> [--runtime codex]`: CLI-LOCAL / daemon-free auth-profile management.
|
||||
// Capability-grouped with runtime as an orthogonal flag (NOT `rig codex-auth`, NOT a `rig codex` noun)
|
||||
// per conventions/cli-read-command-grammar. Every line printed here is built ONLY from the structured,
|
||||
// non-secret result fields of the secret-safe lib — a token value can never reach stdout/stderr.
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
resolveCodexHome,
|
||||
authStatus,
|
||||
authList,
|
||||
authSave,
|
||||
authSwitch,
|
||||
authValidate,
|
||||
authSeatSet,
|
||||
authSeatsList,
|
||||
authSeatShow,
|
||||
authSeatsReport,
|
||||
SEAT_REGISTRY_DISCLAIMER,
|
||||
type CodexAuthPaths,
|
||||
} from "../lib/codex-auth.js";
|
||||
|
||||
export interface AuthCommandDeps {
|
||||
/** env source (default process.env) — tests point CODEX_HOME at a fixture. */
|
||||
env?: NodeJS.ProcessEnv;
|
||||
/** login-state probe (default: real `codex login status`, exit-code-only). Injectable for tests. */
|
||||
loginStatus?: (codexHome: string) => "logged_in" | "not_logged_in" | "unavailable";
|
||||
/** clock for seats set updated_ts (default new Date().toISOString()). */
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
function fail(reason: string): void {
|
||||
console.error(`rig auth: ${reason}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
// MVP supports only --runtime codex. Other runtimes are the SAME surface with a different flag
|
||||
// (future); reject non-codex now with a clear message rather than silently treating it as codex.
|
||||
function ensureRuntime(runtime: string): boolean {
|
||||
if (runtime !== "codex") {
|
||||
fail(`unsupported_runtime ${runtime} (MVP supports: codex)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function authCommand(depsOverride?: AuthCommandDeps): Command {
|
||||
const env = depsOverride?.env ?? process.env;
|
||||
const paths = (): CodexAuthPaths => resolveCodexHome(env);
|
||||
const withRuntime = (c: Command): Command => c.option("--runtime <runtime>", "Runtime axis (MVP: codex)", "codex");
|
||||
|
||||
const auth = new Command("auth").description(
|
||||
"Manage agent auth profiles (CLI-local; runtime via --runtime). Tokens are never printed, logged, or stored.",
|
||||
);
|
||||
|
||||
withRuntime(auth.command("status"))
|
||||
.description("Auth-file presence + login state (no secrets).")
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authStatus(paths(), { loginStatus: depsOverride?.loginStatus });
|
||||
console.log(`codex_home: ${r.codexHome}`);
|
||||
console.log(`codex_home_present: ${r.codexHomePresent ? "yes" : "no"}`);
|
||||
console.log(`active_auth_present: ${r.activeAuthPresent ? "yes" : "no"}`);
|
||||
console.log(`active_auth_mode: ${r.activeAuthMode ?? "n/a"}`);
|
||||
console.log(`active_auth_mode_safe: ${r.activeAuthModeSafe}`);
|
||||
console.log(`saved_profiles: ${r.profileCount}`);
|
||||
console.log(`login_status: ${r.loginStatus}`);
|
||||
});
|
||||
|
||||
withRuntime(auth.command("list"))
|
||||
.description("List saved profiles by name.")
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
for (const name of authList(paths())) console.log(name);
|
||||
});
|
||||
|
||||
withRuntime(auth.command("save <profile>"))
|
||||
.description("Snapshot the active auth state into a named profile (file copy; contents never echoed).")
|
||||
.action((profile: string, opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authSave(paths(), profile);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`saved_profile: ${r.name}`);
|
||||
console.log(`profile_path: ${r.path}`);
|
||||
console.log(`profile_mode: ${r.mode}`);
|
||||
});
|
||||
|
||||
withRuntime(auth.command("switch <profile>"))
|
||||
.description("Activate a saved profile.")
|
||||
.action((profile: string, opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authSwitch(paths(), profile);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`activated_profile: ${r.name}`);
|
||||
console.log(`active_auth: ${r.activePath}`);
|
||||
console.log(`active_mode: ${r.mode}`);
|
||||
console.log(`note: ${r.note}`);
|
||||
});
|
||||
|
||||
withRuntime(auth.command("validate <profile>"))
|
||||
.description("Check a profile's file mode + JSON parseability (NOT live-auth).")
|
||||
.action((profile: string, opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authValidate(paths(), profile);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`profile_valid: ${r.name}`);
|
||||
console.log(`profile_path: ${r.path}`);
|
||||
console.log(`profile_mode: ${r.mode}`);
|
||||
console.log("note: checks file mode + JSON parseability only, NOT live account state.");
|
||||
});
|
||||
|
||||
const seats = auth.command("seats").description("Seat -> profile registry (metadata only; not proof of a live account).");
|
||||
|
||||
withRuntime(seats.command("list"))
|
||||
.description("List seat -> profile mappings.")
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
for (const row of authSeatsList(paths())) console.log(`${row.seat}\t${row.authProfile}\t${row.updatedTs}`);
|
||||
console.log(`# ${SEAT_REGISTRY_DISCLAIMER}`);
|
||||
});
|
||||
|
||||
withRuntime(seats.command("show <seat>"))
|
||||
.description("Show the registry row for one seat.")
|
||||
.action((seat: string, opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authSeatShow(paths(), seat);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`seat: ${r.row.seat}`);
|
||||
console.log(`rig: ${r.row.rig}`);
|
||||
console.log(`runtime: ${r.row.runtime}`);
|
||||
console.log(`cwd: ${r.row.cwd}`);
|
||||
console.log(`auth_profile: ${r.row.authProfile}`);
|
||||
console.log(`updated_ts: ${r.row.updatedTs}`);
|
||||
console.log(`note: ${SEAT_REGISTRY_DISCLAIMER}`);
|
||||
});
|
||||
|
||||
withRuntime(seats.command("set"))
|
||||
.description("Upsert a seat -> profile metadata row.")
|
||||
.requiredOption("--seat <seat>", "Seat session name")
|
||||
.requiredOption("--rig <rig>", "Rig name")
|
||||
.option("--cwd <cwd>", "Working dir (metadata)")
|
||||
.option("--profile <profile>", "Auth profile label (or omit for unknown)")
|
||||
.action((opts: { runtime: string; seat?: string; rig?: string; cwd?: string; profile?: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authSeatSet(
|
||||
paths(),
|
||||
{ seat: opts.seat ?? "", rig: opts.rig ?? "", runtime: "codex", cwd: opts.cwd, authProfile: opts.profile },
|
||||
depsOverride?.now,
|
||||
);
|
||||
if (!r.ok) return fail(r.reason);
|
||||
console.log(`seat_upserted: ${r.seat}`);
|
||||
console.log(`registry_path: ${r.registryPath}`);
|
||||
console.log(`registry_mode: ${r.mode}`);
|
||||
console.log(`note: ${r.disclaimer}`);
|
||||
});
|
||||
|
||||
withRuntime(seats.command("report"))
|
||||
.description("Counts: total / known / unknown / malformed.")
|
||||
.action((opts: { runtime: string }) => {
|
||||
if (!ensureRuntime(opts.runtime)) return;
|
||||
const r = authSeatsReport(paths());
|
||||
console.log(`registry_present: ${r.registryPresent ? "yes" : "no"}`);
|
||||
console.log(`registry_mode: ${r.registryMode ?? "n/a"}`);
|
||||
console.log(`registry_mode_safe: ${r.registryModeSafe}`);
|
||||
console.log(`total_seats: ${r.total}`);
|
||||
console.log(`known_profiles: ${r.known}`);
|
||||
console.log(`unknown_profiles: ${r.unknown}`);
|
||||
console.log(`malformed_rows: ${r.malformed}`);
|
||||
console.log(`# ${SEAT_REGISTRY_DISCLAIMER}`);
|
||||
});
|
||||
|
||||
return auth;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import nodePath from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -30,7 +30,7 @@ export function bootstrapCommand(depsOverride?: StatusDeps): Command {
|
||||
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");
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
|
||||
import { emitCrossHostError, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
|
||||
export function broadcastCommand(depsOverride?: StatusDeps): Command {
|
||||
/**
|
||||
* OPR.0.4.6.MH4 C3 — the cross-host broadcast deadline, named at the call
|
||||
* site (arch delivery-lane note (a)): the remote route responds only after
|
||||
* the ORIGIN daemon's per-target fan-out loop completes, so a many-seat rig
|
||||
* needs more than the 5s read-class default.
|
||||
*/
|
||||
const BROADCAST_REMOTE_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface BroadcastDeps extends StatusDeps {
|
||||
/** Test seam: inject a registry loader so no real ~/.openrig is touched. */
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
}
|
||||
|
||||
export function broadcastCommand(depsOverride?: BroadcastDeps): Command {
|
||||
const cmd = new Command("broadcast").description("Send a message to multiple agent sessions");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
const getDeps = (): BroadcastDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
@@ -16,6 +33,7 @@ export function broadcastCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--rig <name>", "Broadcast to all sessions in a rig")
|
||||
.option("--pod <name>", "Broadcast to all sessions in a pod")
|
||||
.option("--force", "Send even if targets appear mid-task")
|
||||
.option("--host <id>", "Broadcast on a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's fan-out)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -23,11 +41,34 @@ Examples:
|
||||
rig broadcast --pod dev "New task spec at docs/planning/next-task.md"
|
||||
rig broadcast "System maintenance in 5 minutes."
|
||||
rig broadcast --rig my-rig "message" --json
|
||||
rig broadcast --host vps-b --rig remote-rig "coordinate the remote factory"
|
||||
|
||||
Without --rig or --pod, broadcasts to ALL running tmux sessions plus any attached external_cli nodes across all rigs.
|
||||
Unsupported external_cli targets are returned as explicit per-target failures in the result.`)
|
||||
.action(async (text: string, opts: { rig?: string; pod?: string; force?: boolean; json?: boolean }) => {
|
||||
Unsupported external_cli targets are returned as explicit per-target failures in the result.
|
||||
|
||||
--host broadcasts on a remote host declared in ~/.openrig/hosts.yaml,
|
||||
CLI-direct against that daemon's shipped broadcast fan-out (http-registered
|
||||
hosts, e.g. pair-registered; an ssh-registered host fails with the structured
|
||||
transport-requirement error). The REMOTE daemon resolves --rig/--pod on ITS
|
||||
topology and its per-target results print verbatim — a partial fan-out exits
|
||||
non-zero exactly as a local one does. The positional is message text (never
|
||||
parsed as a target), so broadcast takes --host or the persisted host
|
||||
selection, not the agent@rig@host sugar.`)
|
||||
.action(async (text: string, opts: { rig?: string; pod?: string; force?: boolean; host?: string; json?: boolean }) => {
|
||||
// OPR.0.4.6.MH4 C3 — explicit --host > persisted selection. Broadcast
|
||||
// has NO session-target operand (the positional is message text, which
|
||||
// must never be sugar-parsed), so the §4 sugar does not apply here.
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host path (CLI-direct POST to the remote daemon's shipped
|
||||
// /api/transport/broadcast; its own fan-out engine resolves the
|
||||
// TargetSpec on ITS topology; daemon untouched). ---
|
||||
if (opts.host) {
|
||||
await runCrossHostBroadcast(opts.host, text, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
@@ -57,21 +98,83 @@ Unsupported external_cli targets are returned as explicit per-target failures in
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data as Record<string, unknown>;
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string }>) ?? [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
}
|
||||
console.log(`${data["sent"]}/${data["total"]} delivered`);
|
||||
|
||||
if ((data["failed"] as number) > 0 || results.some((r) => !r.ok)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
renderBroadcastResults(res.data as Record<string, unknown>);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 C3 — cross-host broadcast over http, CLI-DIRECT to the
|
||||
* remote daemon's shipped POST /api/transport/broadcast (zero daemon-side
|
||||
* changes). The body is the LOCAL shape verbatim ({text, force, rig?,
|
||||
* pod?}); the ORIGIN daemon's own fan-out engine resolves the TargetSpec on
|
||||
* ITS topology and returns per-target results, printed VERBATIM — per-target
|
||||
* honesty is passthrough, never summarized; a partial fan-out exits non-zero
|
||||
* exactly as the local path does. Deadline: BROADCAST_REMOTE_TIMEOUT_MS
|
||||
* (named — a full fan-out outlives the read-class default).
|
||||
*/
|
||||
async function runCrossHostBroadcast(
|
||||
hostId: string,
|
||||
text: string,
|
||||
opts: { rig?: string; pod?: string; force?: boolean; json?: boolean },
|
||||
deps: BroadcastDeps,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
const body: Record<string, unknown> = { text, force: opts.force };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
|
||||
const result = await runRemoteHttpOp(hostId, "POST", "/api/transport/broadcast", body, deps, {
|
||||
timeoutMs: BROADCAST_REMOTE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
|
||||
result,
|
||||
}));
|
||||
const results = ((result.data as Record<string, unknown> | undefined)?.["results"] as Array<{ ok: boolean }> | undefined) ?? [];
|
||||
if (!result.ok || results.some((r) => !r.ok)) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
renderBroadcastResults((result.data ?? {}) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** One renderer, two callers — the remote route returns the same shape the
|
||||
* local route does, so per-target honesty prints identically. A partial
|
||||
* fan-out sets a non-zero exit either way. */
|
||||
function renderBroadcastResults(data: Record<string, unknown>): void {
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string }>) ?? [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
}
|
||||
console.log(`${data["sent"]}/${data["total"]} delivered`);
|
||||
|
||||
if ((data["failed"] as number) > 0 || results.some((r) => !r.ok)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,46 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import nodePath from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Read the CLI's own package.json version at call time (Item 1 / slice-05).
|
||||
* Function-level read on purpose: module-level constants would mask test
|
||||
* isolation per the audit-every-layer discipline.
|
||||
*/
|
||||
function getCliVersion(): string {
|
||||
try {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const pkgPath = nodePath.join(nodePath.dirname(here), "..", "..", "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: unknown };
|
||||
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the provenance block the CLI sends to /api/bundles/create. Reads
|
||||
* hostname, session name (from canonical OPENRIG_SESSION_NAME env), and
|
||||
* CLI version at call time. Operator notes come from the --notes flag.
|
||||
* Daemon adds daemonVersion server-side.
|
||||
*/
|
||||
function buildClientProvenance(notes: string | undefined): Record<string, string> {
|
||||
const out: Record<string, string> = {
|
||||
sourceHost: os.hostname(),
|
||||
cliVersion: getCliVersion(),
|
||||
};
|
||||
const session = process.env.OPENRIG_SESSION_NAME;
|
||||
if (typeof session === "string" && session.length > 0) out.authorSession = session;
|
||||
if (typeof notes === "string" && notes.length > 0) out.notes = notes;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("bundle").description("Manage rig bundles");
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -26,17 +62,38 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--bundle-version <ver>", "Bundle version", "0.1.0")
|
||||
.option("--include-packages <refs...>", "Package refs to include (default: all from spec)")
|
||||
.option("--rig-root <root>", "Root directory for pod-aware resolution")
|
||||
.option("--notes <text>", "Operator notes captured in bundle provenance metadata")
|
||||
.option("--min-daemon-version <ver>", "Minimum daemon version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--min-cli-version <ver>", "Minimum CLI version required to install this bundle (Item 2 compatibility)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; json?: boolean }) => {
|
||||
.action(async (spec: string, opts: { output: string; name: string; bundleVersion: string; includePackages?: string[]; rigRoot?: string; notes?: string; minDaemonVersion?: string; minCliVersion?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// Item 2 / slice-05: build compatibility from operator flags. Only included
|
||||
// in the request body when at least one of the two flags is set.
|
||||
const compatibility: Record<string, string> = {};
|
||||
if (opts.minDaemonVersion) compatibility.minDaemonVersion = opts.minDaemonVersion;
|
||||
if (opts.minCliVersion) compatibility.minCliVersion = opts.minCliVersion;
|
||||
const hasCompatibility = Object.keys(compatibility).length > 0;
|
||||
|
||||
// QA-20260601 A1 repair (banked banked-create-install-long-running):
|
||||
// /create can take seconds-to-minutes (pod-aware assembler walk +
|
||||
// Item-6 cross-primitive vendoring + integrity-over-all-content +
|
||||
// tar pack). Default 5000ms CLI timeout (client.ts:31) is too short
|
||||
// for real-world bundles; daemon completes work but CLI reports
|
||||
// failure. Per-call 120s upper bound covers reasonable real-world
|
||||
// bundle sizes; if a bundle author hits this, the daemon-side
|
||||
// operation is async-completable in a future /bundles/jobs/<id>
|
||||
// surface (not in slice-05 scope).
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/create", {
|
||||
specPath: spec, bundleName: opts.name, bundleVersion: opts.bundleVersion, outputPath: opts.output,
|
||||
includePackages: opts.includePackages,
|
||||
rigRoot: opts.rigRoot ? nodePath.resolve(opts.rigRoot) : undefined,
|
||||
});
|
||||
provenance: buildClientProvenance(opts.notes),
|
||||
...(hasCompatibility ? { compatibility } : {}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -92,15 +149,31 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
.option("--plan", "Plan mode")
|
||||
.option("--yes", "Auto-approve")
|
||||
.option("--target <root>", "Target root directory")
|
||||
.option("--skip-version-check", "Operator-explicit override of the Item-2 install-time compatibility check (NOT recommended for routine use)")
|
||||
.option("--force", "Operator-explicit override of the Item-3 install-time conflict check (NOT recommended; conflicts may produce partial install state)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; json?: boolean }) => {
|
||||
.action(async (bundlePath: string, opts: { plan?: boolean; yes?: boolean; target?: string; skipVersionCheck?: boolean; force?: boolean; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
// QA-20260601 A1 repair: /install completes a full bootstrap run
|
||||
// (resolve → vendor sibling primitives → boot rig sessions) which
|
||||
// can take many seconds for real bundles. Default 5000ms CLI
|
||||
// timeout (client.ts:31) caused CLI to report failure while
|
||||
// daemon completed the mutating install — operator-unsafe retry
|
||||
// path (rig_name_collision on second attempt). Bumped to 120s.
|
||||
const res = await client.post<Record<string, unknown>>("/api/bundles/install", {
|
||||
bundlePath, plan: opts.plan ?? false, autoApprove: opts.yes ?? false, targetRoot: opts.target,
|
||||
});
|
||||
// Item 2 / slice-05 Checkpoint 3.3: send CLI version + skip flag for the
|
||||
// daemon-side install-time compatibility check. CLI version read at call
|
||||
// time (no module-level constant) via the existing getCliVersion helper.
|
||||
cliVersion: getCliVersion(),
|
||||
skipVersionCheck: opts.skipVersionCheck ?? false,
|
||||
// Item 3 / slice-05 Checkpoint 4.2: send force flag for the daemon-side
|
||||
// install-time conflict check. Operator-explicit override only.
|
||||
force: opts.force ?? false,
|
||||
}, { timeoutMs: 120_000 });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -118,5 +191,48 @@ export function bundleCommand(depsOverride?: StatusDeps): Command {
|
||||
if (res.data["rigId"]) console.log(`Rig: ${res.data["rigId"]}`);
|
||||
});
|
||||
|
||||
// rig bundle history — Item 4 / slice-05 Checkpoint 5.2
|
||||
cmd.command("history")
|
||||
.description("List bundle install audit records from ~/.openrig/bundle-audit.jsonl")
|
||||
.option("--rig <name>", "Filter to records whose targetRigName matches")
|
||||
.option("--since <iso>", "Filter to records installedAt >= this ISO timestamp")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { rig?: string; since?: string; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const client = await getClient(deps);
|
||||
if (!client) { process.exitCode = 1; return; }
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.since) qs.set("since", opts.since);
|
||||
const query = qs.toString();
|
||||
const path = query.length > 0 ? `/api/bundles/history?${query}` : "/api/bundles/history";
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(path);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(res.data["error"] ?? "History fetch failed");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const records = Array.isArray(res.data["records"]) ? res.data["records"] as Array<Record<string, unknown>> : [];
|
||||
if (records.length === 0) {
|
||||
console.log("No bundle install audit records found.");
|
||||
return;
|
||||
}
|
||||
console.log(`Bundle install history (${records.length} record${records.length === 1 ? "" : "s"}):`);
|
||||
for (const r of records) {
|
||||
const at = r["installedAt"] ?? "?";
|
||||
const rig = r["targetRigName"] ?? "?";
|
||||
const outcome = r["outcome"] ?? "?";
|
||||
const bundle = r["bundlePath"] ?? "?";
|
||||
console.log(` ${at} ${outcome.toString().padEnd(8)} rig=${rig} ${bundle}`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
|
||||
export interface CaptureDeps extends StatusDeps {
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
@@ -28,7 +31,7 @@ export function captureCommand(depsOverride?: CaptureDeps): Command {
|
||||
.option("--rig <name>", "Capture all sessions in a rig")
|
||||
.option("--pod <name>", "Capture all sessions in a pod")
|
||||
.option("--lines <n>", "Number of lines to capture (default: 20)", "20")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--host <id>", "Capture on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
@@ -42,14 +45,40 @@ Examples:
|
||||
Supported notes:
|
||||
- Multi-target capture reports unsupported external_cli nodes as explicit per-target failures.
|
||||
- For outbound-only external_cli nodes, use rig whoami/rig ps instead of rig capture.
|
||||
- --host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
|
||||
via single-hop ssh. The remote rig is authoritative on what it can capture.`)
|
||||
- --host captures on a remote host declared in ~/.openrig/hosts.yaml. The host
|
||||
entry's transport decides the path: ssh hosts via single-hop ssh; http hosts
|
||||
(e.g. pair-registered) CLI-direct to the remote daemon's capture route. The
|
||||
remote is authoritative on what it can capture either way. A session of the
|
||||
form agent@rig@host is sugar for --host when the suffix is a REGISTERED
|
||||
host id (explicit --host > sugar > persisted selection).`)
|
||||
.action(async (session: string | undefined, opts: { rig?: string; pod?: string; lines?: string; host?: string; json?: boolean }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly. OPR.0.4.6.MH4 §4: the raw flag is kept
|
||||
// so the target sugar slots BETWEEN explicit and selection.
|
||||
const explicitHost = opts.host;
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
// OPR.0.4.6.MH4 §4 — `agent@rig@host` target sugar (session operand
|
||||
// only; --rig/--pod values are names, never sugar-parsed). Suffix must
|
||||
// match a REGISTERED host id, else passthrough + loud-failure hint.
|
||||
let crossHostHint: string | undefined;
|
||||
if (session !== undefined) {
|
||||
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
|
||||
if (!targetResolution.ok) {
|
||||
console.error(targetResolution.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
session = targetResolution.target;
|
||||
crossHostHint = targetResolution.hint;
|
||||
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
|
||||
}
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side; ssh shell-out or the MH-4 http branch; daemon untouched) ---
|
||||
if (opts.host) {
|
||||
await runCrossHostCapture(opts.host, session, opts, deps);
|
||||
await runCrossHostCapture(opts.host, session, opts, deps, crossHostHint);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,7 +98,7 @@ Supported notes:
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body);
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/capture", body, { headers: terminalAuthHeaders() });
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -80,6 +109,8 @@ Supported notes:
|
||||
if (res.status >= 400) {
|
||||
const error = (res.data as Record<string, unknown>)["error"] as string | undefined;
|
||||
console.error(error ?? `Capture failed (HTTP ${res.status})`);
|
||||
// MH-4 §4 loud-failure hint: 3-part-shaped target, unregistered suffix.
|
||||
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -113,6 +144,7 @@ async function runCrossHostCapture(
|
||||
session: string | undefined,
|
||||
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
|
||||
deps: CaptureDeps,
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
@@ -124,11 +156,19 @@ async function runCrossHostCapture(
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// OPR.0.4.6.MH4 — the http transport branch: CLI-direct POST to the
|
||||
// remote daemon's shipped /api/transport/capture with the SAME body the
|
||||
// local path posts. ssh hosts fall through to the shell-out verbatim.
|
||||
if (host.transport === "http") {
|
||||
await runHttpHostCapture(host, session, opts, deps, hint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct argv. Order: positional first, then flags.
|
||||
const argv: string[] = ["rig", "capture"];
|
||||
if (session) argv.push(session);
|
||||
@@ -141,19 +181,80 @@ async function runCrossHostCapture(
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: host.target },
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host) },
|
||||
result,
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, opts.json);
|
||||
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, opts.json);
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 C1 — cross-host capture over http, CLI-DIRECT to the remote
|
||||
* daemon's shipped POST /api/transport/capture (zero daemon-side changes).
|
||||
* Body parity with the local path (lines/rig/pod/session); the remote's
|
||||
* single/multi result renders exactly as a local capture does, under the
|
||||
* `[via host=…]` banner. Read-class deadline (the client default).
|
||||
*/
|
||||
async function runHttpHostCapture(
|
||||
host: HttpHostEntry,
|
||||
session: string | undefined,
|
||||
opts: { rig?: string; pod?: string; lines?: string; json?: boolean },
|
||||
deps: CaptureDeps,
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const lines = parseInt(opts.lines ?? "20", 10);
|
||||
const body: Record<string, unknown> = { lines: isNaN(lines) ? 20 : lines };
|
||||
if (opts.rig) body.rig = opts.rig;
|
||||
if (opts.pod) body.pod = opts.pod;
|
||||
if (session) body.session = session;
|
||||
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/capture", body, deps, {});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
|
||||
result,
|
||||
...(!result.ok && hint ? { hint } : {}),
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
const data = (result.data ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Multi-target result — rendered exactly as the local path renders it.
|
||||
const results = data["results"] as Array<{ sessionName: string; content?: string; ok: boolean; error?: string }> | undefined;
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
console.log(`--- ${r.sessionName} ---`);
|
||||
if (r.ok && r.content) {
|
||||
console.log(r.content);
|
||||
} else {
|
||||
console.log(` (error: ${r.error ?? "no content"})`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Single target result
|
||||
const content = data["content"] as string | undefined;
|
||||
if (content) {
|
||||
console.log(content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
// The trigger is two-phase (prep → wait-for-idle → /compact); the wait-for-idle
|
||||
// half can take up to the daemon's manual-prep ceiling (~120s). Give the HTTP
|
||||
// call generous headroom so the client does not time out mid-sequence.
|
||||
const MANUAL_COMPACT_REQUEST_TIMEOUT_MS = 180_000;
|
||||
|
||||
export interface CompactDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.14 — `rig compact <session>`: manually run the guided compaction
|
||||
* lifecycle (prep → /compact → restore → audit) for one Claude seat, on demand,
|
||||
* independent of the auto threshold. Distinct from the read-only `rig
|
||||
* compact-plan` triage command.
|
||||
*/
|
||||
export function compactCommand(depsOverride?: CompactDeps): Command {
|
||||
const cmd = new Command("compact")
|
||||
.description("Manually run the guided compaction sequence (prep → /compact → restore → audit) for one Claude seat")
|
||||
.argument("<session>", "Target Claude session name (e.g. dev-impl@my-rig)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig compact dev-impl@my-rig Manually compact one Claude seat now
|
||||
rig compact dev-impl@my-rig --json JSON output for agents
|
||||
|
||||
Runs the SAME guided lifecycle the auto-compaction policy runs (pre-compact
|
||||
prep → /compact with the trust-bridge → restore-from-marker → read-depth audit)
|
||||
on demand, for ONE Claude seat, without waiting for the context threshold. It is
|
||||
NOT a bare /compact and NOT the read-only 'rig compact-plan' triage. Non-Claude
|
||||
seats are rejected. The /compact is sent only AFTER the prep turn completes.`);
|
||||
|
||||
const getDepsF = (): CompactDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd.action(async (session: string, opts: { json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
"/api/compaction/trigger",
|
||||
{ session },
|
||||
{ headers: terminalAuthHeaders(), timeoutMs: MANUAL_COMPACT_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const error = res.data["error"] as string | undefined;
|
||||
console.error(error ?? `Manual compaction failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const stage = res.data["stage"] as string | undefined;
|
||||
console.log(`Manual compaction triggered for ${session}${stage ? ` (stage: ${stage})` : ""}.`);
|
||||
console.log("The restore + read-depth audit prompts follow automatically as the seat drains below threshold.");
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { renderMissionBriefTemplate, renderMissionNotesTemplate, renderSliceProofTemplate } from "../lib/scope/templates.js";
|
||||
|
||||
export interface InitWorkspaceOpts {
|
||||
root?: string;
|
||||
@@ -39,6 +40,11 @@ export interface InitWorkspaceResult {
|
||||
|
||||
type DefaultSlice = {
|
||||
id: string;
|
||||
/** §1 dot-ID per conventions/scope-and-versioning (release-0.3.2
|
||||
* slice 01 GA-coherence pass). Mirror of the daemon constant in
|
||||
* packages/daemon/src/domain/workspace/default-workspace-scaffold.ts
|
||||
* — keep in lockstep for the slice-21 parity test. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
@@ -46,27 +52,36 @@ type DefaultSlice = {
|
||||
|
||||
type DefaultMission = {
|
||||
id: string;
|
||||
/** §1 mission dot-ID — escape band for non-release missions. */
|
||||
dotId: string;
|
||||
title: string;
|
||||
status: "active" | "draft";
|
||||
objective: string;
|
||||
slices: DefaultSlice[];
|
||||
};
|
||||
|
||||
// Release-0.3.2 slice 01 (OPR.0.3.2.1) — seeded missions/slices now
|
||||
// carry §1 dot-IDs in frontmatter so the scaffold coheres with
|
||||
// `conventions/scope-and-versioning`. Mirror of the daemon constant;
|
||||
// the slice-21 parity test asserts both sides emit identical bodies.
|
||||
const DEFAULT_MISSIONS: DefaultMission[] = [
|
||||
{
|
||||
id: "getting-started",
|
||||
dotId: "OPR.99.0.1",
|
||||
title: "Getting Started",
|
||||
status: "active",
|
||||
objective: "Launch the conveyor starter and learn how OpenRig moves work through queue-backed slices.",
|
||||
slices: [
|
||||
{
|
||||
id: "first-conveyor-run",
|
||||
dotId: "OPR.99.0.1.1",
|
||||
title: "First Conveyor Run",
|
||||
status: "active",
|
||||
objective: "Move one small packet through intake, planning, build, review, and close on the conveyor starter.",
|
||||
},
|
||||
{
|
||||
id: "inspect-project-evidence",
|
||||
dotId: "OPR.99.0.1.2",
|
||||
title: "Inspect Project Evidence",
|
||||
status: "draft",
|
||||
objective: "Open Project, Queue, Story, and Tests to inspect the evidence created by the first conveyor run.",
|
||||
@@ -86,14 +101,17 @@ const WORKSPACE_DIRS = [
|
||||
...DEFAULT_MISSIONS.flatMap((mission) => [
|
||||
`missions/${mission.id}`,
|
||||
`missions/${mission.id}/slices`,
|
||||
...mission.slices.map((slice) => `missions/${mission.id}/slices/${slice.id}`),
|
||||
...mission.slices.flatMap((slice) => [
|
||||
`missions/${mission.id}/slices/${slice.id}`,
|
||||
`missions/${mission.id}/slices/${slice.id}/proof`,
|
||||
]),
|
||||
]),
|
||||
] as const;
|
||||
|
||||
function subdirReadmeContent(subdir: string): string {
|
||||
switch (subdir) {
|
||||
case "missions":
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-id>/README.md\nmissions/<mission-id>/PROGRESS.md\nmissions/<mission-id>/slices/<slice-id>/README.md\n```\n";
|
||||
return "# missions\n\nProject missions live here. Each mission folder maps to one Project mission in the UI and owns a `slices/` child folder.\n\nExpected shape:\n\n```text\nmissions/<mission-name>/README.md\nmissions/<mission-name>/PROGRESS.md\nmissions/<mission-name>/slices/<slice-name>/README.md\n```\n\nEvery mission and slice README carries a stable dot-ID in its frontmatter (`id: OPR.<ver>[.<n>]`) per the scope-and-versioning convention. Use `rig scope mission create <name>` and `rig scope slice create <mission> <slug>` to mint conformant artifacts; the CLI handles the dot-ID + auto-numbering for you. The folder name is the operator-facing slug; the `id:` is the stable handle that survives renames.\n";
|
||||
case "artifacts":
|
||||
return "# artifacts\n\nWork products live here: plans, drafts, generated outputs, and other files that a slice may reference before closure.\n";
|
||||
case "evidence":
|
||||
@@ -122,7 +140,7 @@ This workspace is file-backed. The Project UI mirrors this structure:
|
||||
- \`evidence/\` is for proof notes and verification summaries.
|
||||
- \`dogfood-evidence/<proof-packet-id>\` becomes Tests proof when the packet id contains the slice id tokens.
|
||||
|
||||
Use stable kebab-case ids for mission and slice folders. Keep slice ids unique inside the workspace.
|
||||
Use stable kebab-case names for mission and slice folders. Every mission/slice README also carries a stable dot-ID in its frontmatter (\`id: OPR.<ver>[.<n>]\`) per the \`scope-and-versioning\` convention; mint conformant artifacts via \`rig scope mission create <name>\` and \`rig scope slice create <mission> <slug>\`. Folder names are operator-facing slugs; the \`id:\` is the rename-proof handle.
|
||||
`;
|
||||
|
||||
const STEERING_PLACEHOLDER = `---
|
||||
@@ -153,6 +171,7 @@ alongside your Project workspace.
|
||||
|
||||
function missionReadme(mission: DefaultMission): string {
|
||||
return `---
|
||||
id: ${mission.dotId}
|
||||
title: ${mission.title}
|
||||
status: ${mission.status}
|
||||
mission: ${mission.id}
|
||||
@@ -423,6 +442,7 @@ function sliceReadme(mission: DefaultMission, slice: DefaultSlice): string {
|
||||
const narrative = GETTING_STARTED_NARRATIVE[slice.id];
|
||||
if (narrative) {
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -433,6 +453,7 @@ slice: ${slice.id}
|
||||
${narrative.readme}`;
|
||||
}
|
||||
return `---
|
||||
id: ${slice.dotId}
|
||||
title: ${slice.title}
|
||||
status: ${slice.status}
|
||||
mission: ${mission.id}
|
||||
@@ -442,8 +463,18 @@ slice: ${slice.id}
|
||||
|
||||
# ${slice.title}
|
||||
|
||||
## Intent
|
||||
|
||||
${slice.objective}
|
||||
|
||||
## Mini-requirements
|
||||
|
||||
1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`.
|
||||
|
||||
## Proof contract
|
||||
|
||||
- [ ] The work is visible in the Project slice — captured (see IMPLEMENTATION-PRD.md).
|
||||
|
||||
## Queue Mapping
|
||||
|
||||
Queue items for this slice should mention or tag:
|
||||
@@ -490,15 +521,21 @@ slice: ${slice.id}
|
||||
|
||||
# ${slice.title} Implementation Notes
|
||||
|
||||
## Goal
|
||||
## Intent
|
||||
|
||||
${slice.objective}
|
||||
|
||||
## Acceptance
|
||||
## Mini-requirements
|
||||
|
||||
- [ ] The work is visible in the Project slice.
|
||||
1. The work is visible in the Project slice and its queue items link back to \`${slice.id}\`.
|
||||
|
||||
## Proof contract
|
||||
|
||||
- [ ] The work is visible in the Project slice — captured.
|
||||
- [ ] Queue items include enough body or tag context to link back to \`${slice.id}\`.
|
||||
- [ ] Any proof artifacts are referenced from the slice before closure.
|
||||
- [ ] Proof artifacts are referenced from the slice before closure (drop via \`rig proof add\`).
|
||||
|
||||
> Conventions SSOT: \`docs/reference/sdlc-conventions.md\` — section names, proof-contract format, the two locks, C1 headers. For a small slice the mini-requirements may BE the whole PRD.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -522,11 +559,27 @@ export function workspaceScaffoldFiles(): Array<{ relPath: string; content: stri
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/README.md`, content: missionReadme(mission) },
|
||||
{ relPath: `missions/${mission.id}/PROGRESS.md`, content: missionProgress(mission) },
|
||||
{ relPath: `missions/${mission.id}/MISSION_BRIEF.md`, content: renderMissionBriefTemplate(mission.title) },
|
||||
// FR-5e A1 — reuse the FR-3 MISSION_NOTES scaffold helper so a
|
||||
// fresh `rig config init-workspace` produces a getting-started
|
||||
// mission that doctor check #7 (mission_notes_presence) marks
|
||||
// ok. Same env-var-pivot (OPENRIG_MISSION_NOTES_TEMPLATE_PATH >
|
||||
// built-in bundled template) FR-3's `rig scope mission create`
|
||||
// honors.
|
||||
{
|
||||
relPath: `missions/${mission.id}/MISSION_NOTES.md`,
|
||||
content: renderMissionNotesTemplate({
|
||||
mission_id: mission.dotId,
|
||||
mission_name: mission.title,
|
||||
created_date: new Date().toISOString().slice(0, 10),
|
||||
}).rendered,
|
||||
},
|
||||
);
|
||||
for (const slice of mission.slices) {
|
||||
files.push(
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/README.md`, content: sliceReadme(mission, slice) },
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROGRESS.md`, content: sliceProgress(mission, slice) },
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/PROOF.md`, content: renderSliceProofTemplate({ id: slice.dotId, title: slice.title }) },
|
||||
{ relPath: `missions/${mission.id}/slices/${slice.id}/IMPLEMENTATION-PRD.md`, content: slicePrd(mission, slice) },
|
||||
);
|
||||
// V0.3.1 slice 21: getting-started slices ship timeline.md.
|
||||
@@ -580,6 +633,17 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
const dryRun = !!opts.dryRun;
|
||||
const force = !!opts.force;
|
||||
|
||||
// FR-5e BLOCKER-1 — precompute the full scaffold file list BEFORE
|
||||
// any filesystem mutation. workspaceScaffoldFiles() now invokes
|
||||
// renderMissionNotesTemplate(), which throws on invalid
|
||||
// OPENRIG_MISSION_NOTES_TEMPLATE_PATH. If we mkdir first then
|
||||
// throw on render, the operator is left with a half-created
|
||||
// workspace that the next `rig workspace doctor` will misread as
|
||||
// partially-initialized. Verify-first-then-write (banked from
|
||||
// FR-3 self-lesson) — fail before any side effect.
|
||||
const scaffoldFiles = workspaceScaffoldFiles();
|
||||
const scaffoldDirs = workspaceScaffoldDirs();
|
||||
|
||||
const rootExists = existsSync(root);
|
||||
const result: InitWorkspaceResult = {
|
||||
root,
|
||||
@@ -591,14 +655,14 @@ export function runInitWorkspace(opts: InitWorkspaceOpts & { configPath?: string
|
||||
|
||||
if (!rootExists && !dryRun) mkdirSync(root, { recursive: true });
|
||||
|
||||
for (const sub of workspaceScaffoldDirs()) {
|
||||
for (const sub of scaffoldDirs) {
|
||||
const subPath = join(root, sub);
|
||||
const subExists = existsSync(subPath);
|
||||
if (!subExists && !dryRun) mkdirSync(subPath, { recursive: true });
|
||||
result.subdirs.push({ name: sub, path: subPath, created: !subExists });
|
||||
}
|
||||
|
||||
for (const file of workspaceScaffoldFiles()) {
|
||||
for (const file of scaffoldFiles) {
|
||||
const absPath = join(root, file.relPath);
|
||||
const exists = existsSync(absPath);
|
||||
if (exists && !force) {
|
||||
|
||||
@@ -43,9 +43,14 @@ Keys:
|
||||
agents.* advisor_session, operator_session
|
||||
feed.subscriptions.* action_required, approvals, shipped, progress, audit_log
|
||||
runtime.codex.* hooks_enabled
|
||||
workflow.* exception_routing (orchestrator | human_only — the maturity-dial host default)
|
||||
policies.claude_compaction.*
|
||||
enabled, threshold_percent, compact_instruction,
|
||||
message_inline, message_file_path
|
||||
snapshots.periodic.* enabled, interval_seconds, retention_keep
|
||||
retention.* enabled, transitions_days, watchdog_days,
|
||||
watchdog_keep_per_job, batch_size
|
||||
terminal.status_bar show the inner tmux status bar on launch (default off)
|
||||
|
||||
Precedence: CLI flag > environment variable > config file > default`)
|
||||
.action((opts: { json?: boolean; withSource?: boolean }) => {
|
||||
|
||||
@@ -151,16 +151,19 @@ Examples:
|
||||
rig context --rig openrig-pm Show one rig
|
||||
rig context --threshold 80 Show seats at or above 80% (plus unknown + stale)
|
||||
rig context --refresh Re-sample context before displaying
|
||||
rig context --json JSON output for agents`);
|
||||
rig context --json Compact JSON (slim per-seat projection, non-pretty)
|
||||
rig context --full --json Complete per-seat payload (all fields; lossless)
|
||||
rig context --full Widened human table (adds remaining/source/freshness)`);
|
||||
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--json", "Compact JSON for agents (use --full --json for the complete payload)")
|
||||
.option("--full", "Include every field: --full --json is the complete Seat[]; --full widens the human table")
|
||||
.option("--rig <name>", "Show one rig only")
|
||||
.option("--threshold <pct>", "Show seats at or above this percentage")
|
||||
.option("--refresh", "Re-sample context usage before displaying")
|
||||
.action(async (opts: { json?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
|
||||
.action(async (opts: { json?: boolean; full?: boolean; rig?: string; threshold?: string; refresh?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// Strict threshold validation — reject non-integer input
|
||||
@@ -245,22 +248,49 @@ Examples:
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ seats, summary }, null, 2));
|
||||
if (opts.full) {
|
||||
// --full --json: the complete Seat[] + summary. Lossless parity
|
||||
// with the pre-0.4.0.30 --json output (pretty-printed). This is
|
||||
// the ONLY full-payload surface.
|
||||
console.log(JSON.stringify({ seats, summary }, null, 2));
|
||||
} else {
|
||||
// Compact default (OPR.0.4.0.30): a slim, non-pretty actionable
|
||||
// projection — exactly the columns the human table shows. The
|
||||
// other Seat fields move to --full --json.
|
||||
const compactSeats = seats.map((s) => ({
|
||||
session: s.session,
|
||||
rig: s.rig,
|
||||
runtime: s.runtime,
|
||||
usedPercentage: s.usedPercentage,
|
||||
contextWindowSize: s.contextWindowSize,
|
||||
staleness: s.staleness,
|
||||
displayStatus: s.displayStatus,
|
||||
}));
|
||||
console.log(JSON.stringify({ seats: compactSeats, summary }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Human-readable output
|
||||
const title = opts.rig ? `CONTEXT USAGE - ${opts.rig}` : "CONTEXT USAGE - all rigs";
|
||||
console.log(title);
|
||||
console.log(
|
||||
`${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} status`
|
||||
);
|
||||
// Default table is unchanged; --full appends remaining/source/freshness
|
||||
// columns (a defined additive widening, not multi-line).
|
||||
const baseHeader = `${"seat".padEnd(42)} ${"runtime".padEnd(12)} ${"context".padStart(7)} ${"window".padStart(8)} ${"sampled".padStart(10)} ${opts.full ? "status".padEnd(16) : "status"}`;
|
||||
console.log(opts.full
|
||||
? `${baseHeader} ${"remaining".padStart(9)} ${"source".padEnd(22)} freshness`
|
||||
: baseHeader);
|
||||
for (const seat of seats) {
|
||||
const context = seat.usedPercentage == null ? "?" : `${seat.usedPercentage}%`;
|
||||
const window = seat.contextWindowSize == null ? "?" : String(seat.contextWindowSize);
|
||||
console.log(
|
||||
`${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${seat.displayStatus}`
|
||||
);
|
||||
const baseRow = `${seat.session.slice(0, 42).padEnd(42)} ${seat.runtime.slice(0, 12).padEnd(12)} ${context.padStart(7)} ${window.padStart(8)} ${seat.staleness.padStart(10)} ${opts.full ? seat.displayStatus.padEnd(16) : seat.displayStatus}`;
|
||||
if (opts.full) {
|
||||
const remaining = seat.remainingPercentage == null ? "?" : `${seat.remainingPercentage}%`;
|
||||
const source = seat.source ?? "\u2014";
|
||||
console.log(`${baseRow} ${remaining.padStart(9)} ${source.padEnd(22)} ${seat.freshness}`);
|
||||
} else {
|
||||
console.log(baseRow);
|
||||
}
|
||||
}
|
||||
console.log();
|
||||
console.log(
|
||||
|
||||
@@ -54,7 +54,9 @@ export function realDeps(): LifecycleDeps {
|
||||
timeoutMs: 1_500,
|
||||
timeoutMessage: `Daemon health probe timed out for ${url}`,
|
||||
});
|
||||
return { ok: res.ok };
|
||||
// OPR.0.4.3.21 — expose json() so getDaemonStatus can read the enriched
|
||||
// /healthz event-loop evidence. Bound to this Response instance.
|
||||
return { ok: res.ok, json: () => res.json() };
|
||||
},
|
||||
kill: (pid, signal) => { process.kill(pid, signal as NodeJS.Signals); return true; },
|
||||
readFile: (p) => { try { return fs.readFileSync(p, "utf-8"); } catch { return null; } },
|
||||
@@ -206,7 +208,23 @@ export function daemonCommand(depsOverride?: LifecycleDeps): Command {
|
||||
switch (status.state) {
|
||||
case "running":
|
||||
if (status.healthy === false) {
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix} — healthz failed`);
|
||||
// OPR.0.4.3.21 — process-present but unhealthy: name the real
|
||||
// cause (wedged control plane), not a bare "healthz failed", and
|
||||
// attach the event-loop evidence + seat-preserving recovery hint.
|
||||
const cause = status.reason === "unresponsive"
|
||||
? "unresponsive (event loop may be starved — /healthz timed out)"
|
||||
: status.reason === "event-loop-starved"
|
||||
? "event-loop starved"
|
||||
: "healthz failed";
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix} — process present but UNHEALTHY: ${cause}`);
|
||||
if (status.eventLoop) {
|
||||
const el = status.eventLoop;
|
||||
console.log(
|
||||
` event-loop: lag mean ${el.lagMeanMs.toFixed(1)}ms, p99 ${el.lagP99Ms.toFixed(1)}ms, `
|
||||
+ `utilization ${(el.utilization * 100).toFixed(0)}%, last-tick age ${el.lastTickAgeMs.toFixed(0)}ms`,
|
||||
);
|
||||
}
|
||||
console.log(" Recovery: restart the daemon only (`rig daemon stop && rig daemon start`) — this preserves the tmux seats.");
|
||||
} else {
|
||||
console.log(`Daemon running on port ${status.port}${pidSuffix}`);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -11,7 +11,7 @@ export function discoverCommand(depsOverride?: StatusDeps): Command {
|
||||
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");
|
||||
printDaemonNotRunning();
|
||||
return null;
|
||||
}
|
||||
return deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
@@ -192,13 +192,14 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
});
|
||||
|
||||
// 7. Port availability (async) — daemon already running on that port counts as OK
|
||||
const fetchFn = deps.fetch ?? globalThis.fetch;
|
||||
const portCheck = deps.checkPort(DEFAULT_PORT).then(async (available): Promise<DoctorCheck> => {
|
||||
if (available) {
|
||||
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} available.` };
|
||||
}
|
||||
// Port in use — check if it's our daemon via healthz
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
|
||||
const res = await fetchFn(`http://127.0.0.1:${DEFAULT_PORT}/healthz`);
|
||||
if (res.ok) {
|
||||
return { name: "port", status: "pass", message: `Port ${DEFAULT_PORT} in use by OpenRig daemon.` };
|
||||
}
|
||||
@@ -215,7 +216,6 @@ export function runDoctorChecks(deps: DoctorDeps): { checks: DoctorCheck[]; port
|
||||
// 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`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -16,8 +17,82 @@ interface TeardownResult {
|
||||
|
||||
const LONG_RUNNING_TIMEOUT_MS = 45_000;
|
||||
|
||||
interface RigSummaryEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
archivedAt?: string | null;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig down <rigId>` — tear down a rig.
|
||||
* Outcome of resolving a `rig down <rig>` handle (name OR id) to a concrete id.
|
||||
* The destructive teardown only ever runs on a `resolved`/`passthrough` id;
|
||||
* `ambiguous`/`not_found` halt BEFORE any `/api/down` POST.
|
||||
*/
|
||||
type HandleResolution =
|
||||
| { kind: "resolved"; id: string }
|
||||
| { kind: "ambiguous"; name: string; ids: string[] }
|
||||
| { kind: "not_found"; handle: string }
|
||||
// Summary unavailable (non-200 / fetch error): fall back to today's id-only
|
||||
// behavior - POST the raw handle as the id and let the daemon resolve it by
|
||||
// exact id (404 if absent). Safe: the daemon matches a single exact id, so a
|
||||
// name posted this way cannot tear down the wrong rig.
|
||||
| { kind: "passthrough"; handle: string };
|
||||
|
||||
/**
|
||||
* Resolve a `rig down` handle (rig name OR id) to a concrete rig id, mirroring
|
||||
* the `/api/rigs/summary` path `rig up` uses. Resolution is a PRE-STEP: the
|
||||
* existing teardown + guards downstream are unchanged; this only maps the
|
||||
* handle to an id.
|
||||
*
|
||||
* Safety order (destructive-op):
|
||||
* 1. id-exact-match FIRST, across ALL rigs incl. archived - an id is unique, so
|
||||
* it is never ambiguous, and an archived rig's id must still reach the
|
||||
* canonical teardown id path (AC-2 unchanged).
|
||||
* 2. else name-filter over ACTIVE (non-archived) rigs only:
|
||||
* - exactly 1 active match -> resolve to that id;
|
||||
* - >1 active matches -> AMBIGUOUS: halt, never guess (load-bearing AC-3);
|
||||
* - 0 active matches -> NOT_FOUND: halt, honest error (AC-4).
|
||||
*
|
||||
* `/api/rigs/summary` defaults to ACTIVE-only and exposes `archivedAt`; we fetch
|
||||
* with `includeArchived=true` so an archived id still id-matches, then filter
|
||||
* names to active. So an active+archived same-name pair is NOT ambiguous (only
|
||||
* the active candidate counts), and an archived-only name does not resolve by
|
||||
* name (use the id, or the archive path).
|
||||
*/
|
||||
async function resolveRigHandle(client: DaemonClient, handle: string): Promise<HandleResolution> {
|
||||
let summaries: RigSummaryEntry[];
|
||||
try {
|
||||
// includeArchived=true so an archived rig's id still id-matches below
|
||||
// (preserving today's `rig down <id>` path); names are filtered to active.
|
||||
const res = await client.get<RigSummaryEntry[]>("/api/rigs/summary?includeArchived=true");
|
||||
if (res.status !== 200 || !Array.isArray(res.data)) {
|
||||
return { kind: "passthrough", handle };
|
||||
}
|
||||
summaries = res.data;
|
||||
} catch {
|
||||
return { kind: "passthrough", handle };
|
||||
}
|
||||
|
||||
// 1. id-exact-match first, across ALL rigs incl. archived (AC-2: down by id,
|
||||
// unchanged; ids are never ambiguous; archived ids still reach teardown).
|
||||
if (summaries.some((r) => r.id === handle)) {
|
||||
return { kind: "resolved", id: handle };
|
||||
}
|
||||
|
||||
// 2. name-filter over ACTIVE (non-archived) rigs only, symmetric with `up`.
|
||||
const activeNameMatches = summaries.filter((r) => r.name === handle && r.archivedAt == null);
|
||||
if (activeNameMatches.length === 1) {
|
||||
return { kind: "resolved", id: activeNameMatches[0]!.id };
|
||||
}
|
||||
if (activeNameMatches.length > 1) {
|
||||
return { kind: "ambiguous", name: handle, ids: activeNameMatches.map((r) => r.id) };
|
||||
}
|
||||
return { kind: "not_found", handle };
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig down <rig>` - tear down a rig by name or id.
|
||||
* @param depsOverride - injectable deps for testing
|
||||
* @returns Commander command
|
||||
*/
|
||||
@@ -26,14 +101,41 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to tear down")
|
||||
.argument("<rig>", "Rig name or id to tear down")
|
||||
.option("--delete", "Delete rig record after stopping")
|
||||
.option("--force", "Kill sessions immediately")
|
||||
.option("--snapshot", "Take snapshot before teardown")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean }) => {
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (rigHandle: string, opts: { delete?: boolean; force?: boolean; snapshot?: boolean; json?: boolean; host?: string }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly.
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDepsF();
|
||||
|
||||
if (opts.host) {
|
||||
const { runRemoteHttpOp, resolveRemoteRigId } = await import("../remote-host-ops.js");
|
||||
const rigIdResult = await resolveRemoteRigId(opts.host, rigHandle, deps);
|
||||
if (!rigIdResult.ok) {
|
||||
if (opts.json) console.log(JSON.stringify(rigIdResult));
|
||||
else console.error(`Error: ${rigIdResult.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const result = await runRemoteHttpOp(opts.host, "POST", `/api/down`, { rigId: rigIdResult.rigId, delete: opts.delete, force: opts.force, snapshot: opts.snapshot }, deps, opts);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} else if (result.ok) {
|
||||
console.log(JSON.stringify(result.data, null, 2));
|
||||
} else {
|
||||
console.error(`Error on host ${opts.host}: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
@@ -43,6 +145,46 @@ export function downCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// Resolve the handle (name OR id) to a concrete id BEFORE the teardown POST.
|
||||
// Ambiguous/not-found halt here and never reach `/api/down` - for a
|
||||
// destructive op, ambiguity must stop, never guess (AC-3 load-bearing).
|
||||
const resolution = await resolveRigHandle(client, rigHandle);
|
||||
|
||||
if (resolution.kind === "ambiguous") {
|
||||
const fact = `'${resolution.name}' matches ${resolution.ids.length} rigs.`;
|
||||
const consequence = "Refusing to tear down: an ambiguous name could destroy the wrong rig.";
|
||||
const action = `Re-run with the specific id, e.g. ${resolution.ids.map((id) => `rig down ${id}`).join(" | ")}`;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ error: { fact, consequence, action, candidates: resolution.ids } }));
|
||||
} else {
|
||||
console.error(`Error: ${fact}`);
|
||||
console.error(` ${consequence}`);
|
||||
console.error(` ${action}`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolution.kind === "not_found") {
|
||||
const fact = `No rig found matching '${resolution.handle}'.`;
|
||||
const consequence = "Nothing was torn down.";
|
||||
const action = "List rigs with: rig ps";
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ error: { fact, consequence, action } }));
|
||||
} else {
|
||||
console.error(`Error: ${fact}`);
|
||||
console.error(` ${consequence}`);
|
||||
console.error(` ${action}`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
// resolved -> the looked-up id; passthrough -> the raw handle (summary
|
||||
// unavailable; daemon resolves by exact id, 404s if absent). Either way
|
||||
// the SAME existing teardown path + guards run below - no forked path.
|
||||
const rigId = resolution.kind === "resolved" ? resolution.id : resolution.handle;
|
||||
|
||||
const res = await client.post<TeardownResult | { error: string }>("/api/down", {
|
||||
rigId,
|
||||
delete: opts.delete ?? false,
|
||||
|
||||
@@ -110,7 +110,7 @@ export function expandCommand(depsOverride?: StatusDeps): Command {
|
||||
console.log("");
|
||||
console.log(" Failed nodes cannot be relaunched directly because pod-aware launch");
|
||||
console.log(" bypasses startup orchestration. Recover by:");
|
||||
console.log(` 1. Verify other seats are healthy: rig ps --filter rigName=${rigId} --nodes`);
|
||||
console.log(` 1. Verify other seats are healthy: rig ps --nodes --rig ${rigId}`);
|
||||
if (data.podNamespace) {
|
||||
console.log(` 2. Shrink the failed new pod: rig shrink ${rigId} ${data.podNamespace}`);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// OPR.0.4.4.18 — `rig file` (v0: ONE explicit verb, copy).
|
||||
//
|
||||
// Every transfer names its source, destination, and policy: <hostId>:<path>
|
||||
// is remote (resolving the registry's ssh entries), a bare path is local,
|
||||
// and NOTHING is inferred from cwd/env/session (FR-2). Existing destination
|
||||
// files are OVERWRITTEN — v0 copy semantics, stated (arch R18-2); --dry-run
|
||||
// is the guard rail. The security wall lives in lib/file-transfer.ts.
|
||||
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
planFileCopy,
|
||||
buildRsyncArgv,
|
||||
runFileCopy,
|
||||
type CopyPlan,
|
||||
type PlanDeps,
|
||||
} from "../lib/file-transfer.js";
|
||||
|
||||
export interface FileCommandDeps extends PlanDeps {
|
||||
run?: typeof runFileCopy;
|
||||
}
|
||||
|
||||
function operandLabel(side: CopyPlan["src"]): string {
|
||||
return side.kind === "remote" ? `${side.host!.id}:${side.path}` : side.path;
|
||||
}
|
||||
|
||||
export function fileCommand(deps: FileCommandDeps = {}): Command {
|
||||
const file = new Command("file").description(
|
||||
"Cross-host file movement over ssh/rsync (v0: one explicit verb — copy)",
|
||||
);
|
||||
|
||||
file
|
||||
.command("copy <src> <dst>")
|
||||
.description(
|
||||
"Copy one file. <hostId>:<absolute-path> = remote (ssh registry entry); bare path = local; local files with a colon need the ./ prefix. Valid shapes: local→remote, remote→local, local→local. An existing destination is OVERWRITTEN — preview with --dry-run.",
|
||||
)
|
||||
.option("--dry-run", "Print the exact planned transfer (src, dst, host, files/bytes) and move nothing")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (src: string, dst: string, opts: { dryRun?: boolean; json?: boolean }) => {
|
||||
const planned = planFileCopy(src, dst, { dryRun: opts.dryRun, registryLoader: deps.registryLoader });
|
||||
if (!planned.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, code: planned.code, error: planned.error }));
|
||||
} else {
|
||||
console.error(planned.error);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const plan = planned.plan;
|
||||
const srcLabel = operandLabel(plan.src);
|
||||
const dstLabel = operandLabel(plan.dst);
|
||||
const run = deps.run ?? runFileCopy;
|
||||
const result = await run(plan);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
ok: result.ok,
|
||||
dryRun: plan.dryRun,
|
||||
src: srcLabel,
|
||||
dst: dstLabel,
|
||||
failedStep: result.failedStep,
|
||||
bytesTransferred: result.bytesTransferred ?? null,
|
||||
filesTransferred: result.filesTransferred ?? null,
|
||||
exitCode: result.exitCode,
|
||||
error: result.ok ? null : result.stderr.trim() || `rsync failed (${result.failedStep})`,
|
||||
hint: result.hint ?? null,
|
||||
rsyncArgv: buildRsyncArgv(plan), // transparency for agents; the builder is closed
|
||||
}),
|
||||
);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.ok) {
|
||||
if (plan.dryRun) {
|
||||
console.log(`DRY RUN — nothing moved. Planned transfer:`);
|
||||
console.log(` ${srcLabel} → ${dstLabel}`);
|
||||
if (result.filesTransferred !== undefined) console.log(` files: ${result.filesTransferred}${result.bytesTransferred !== undefined ? `, bytes: ${result.bytesTransferred}` : ""}`);
|
||||
const itemized = result.stdout.split("\n").filter((l) => /^[<>ch.*][fdLDS]/.test(l));
|
||||
for (const line of itemized) console.log(` ${line}`);
|
||||
} else {
|
||||
console.log(`Copied ${srcLabel} → ${dstLabel}${result.bytesTransferred !== undefined ? ` (${result.bytesTransferred} bytes)` : ""}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(`file copy failed [${result.failedStep}]: ${result.stderr.trim() || `exit ${result.exitCode}`}`);
|
||||
if (result.hint) console.error(`Hint: ${result.hint}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
return file;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// OPR.0.4.3.05 seat-forking closeout — the top-level `rig fork` verb.
|
||||
//
|
||||
// One discoverable command that branches a live seat's context into a new
|
||||
// seat. It is a THIN client over the shipped agent-image fork foundation: it
|
||||
// POSTs to the narrow daemon fork composer (/api/agent-images/fork), which
|
||||
// resolves the native resume id server-side and composes add_member. The
|
||||
// native id is redacted at every wire boundary and never reaches the CLI.
|
||||
//
|
||||
// default one-shot fork, no library growth (mode: fork, native_id)
|
||||
// --keep-image also capture a durable, PINNED (prune-protected) image
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ForkResponse {
|
||||
ok?: boolean;
|
||||
code?: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
result?: {
|
||||
podNamespace?: string;
|
||||
node?: { logicalId: string; status: string; error?: string; sessionName?: string };
|
||||
warnings?: string[];
|
||||
};
|
||||
image?: { id: string; name: string; version: string; pinned: boolean };
|
||||
}
|
||||
|
||||
export function forkCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("fork")
|
||||
.description("Fork a live seat's context into a new seat (composes the shipped agent-image fork path)")
|
||||
.argument("<source-session>", "Source session canonical name (e.g., dev-impl@openrig-delivery)")
|
||||
.requiredOption("--rig <rig-id>", "Target rig id")
|
||||
.requiredOption("--pod <pod-namespace>", "Target existing pod namespace")
|
||||
.requiredOption("--member <member-id>", "New member id for the forked successor")
|
||||
.option("--keep-image", "Also capture a durable, pinned agent image (default forks one-shot with no library growth)")
|
||||
.option("--image-name <name>", "Name for the kept image (with --keep-image; default fork-<member>)")
|
||||
.option("--image-version <version>", "Version for the kept image (default 1)")
|
||||
.option("--rig-root <path>", "Root directory for agent resolution")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig fork dev-impl@openrig-delivery --rig openrig-delivery --pod dev --member dev-impl-fork
|
||||
rig fork dev-impl@openrig-delivery --rig openrig-delivery --pod dev --member dev-impl-2 --keep-image --image-name impl-primed
|
||||
`)
|
||||
.action(async (sourceSession: string, opts: {
|
||||
rig: string;
|
||||
pod: string;
|
||||
member: string;
|
||||
keepImage?: boolean;
|
||||
imageName?: string;
|
||||
imageVersion?: string;
|
||||
rigRoot?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const deps = depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
sourceSession,
|
||||
rigId: opts.rig,
|
||||
pod: opts.pod,
|
||||
member: opts.member,
|
||||
};
|
||||
if (opts.keepImage) body["keepImage"] = true;
|
||||
if (opts.imageName) body["imageName"] = opts.imageName;
|
||||
if (opts.imageVersion) body["imageVersion"] = opts.imageVersion;
|
||||
if (opts.rigRoot) body["rigRoot"] = opts.rigRoot;
|
||||
|
||||
const res = await client.post<ForkResponse>("/api/agent-images/fork", body);
|
||||
const data = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
if (res.status >= 400 || !data.ok || (data.result?.node && data.result.node.status !== "launched")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400 || !data.ok) {
|
||||
const msg = data.message
|
||||
?? (data.errors && data.errors.length > 0 ? data.errors.join("; ") : data.error)
|
||||
?? `Fork failed (HTTP ${res.status})`;
|
||||
console.error(msg);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const node = data.result?.node;
|
||||
const icon = node?.status === "launched" ? "OK" : "FAIL";
|
||||
const session = node?.sessionName ? ` (${node.sessionName})` : "";
|
||||
const error = node?.error ? ` - ${node.error}` : "";
|
||||
console.log(`Forked ${sourceSession} into rig ${opts.rig}`);
|
||||
console.log(` Pod: ${data.result?.podNamespace ?? opts.pod}`);
|
||||
console.log(` New seat: [${icon}] ${node?.logicalId ?? opts.member}${session}${error}`);
|
||||
if (data.image) {
|
||||
console.log(` Kept image: ${data.image.name} v${data.image.version} (pinned, protected from prune)`);
|
||||
} else {
|
||||
console.log(" One-shot fork (no image retained)");
|
||||
}
|
||||
for (const w of data.result?.warnings ?? []) console.log(` Warning: ${w}`);
|
||||
|
||||
if (node && node.status !== "launched") process.exitCode = 1;
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
// OPR.0.4.4.13 — the host-registry verbs (add / list / doctor — arch
|
||||
// R13-2a: no edit/remove/tunnel/bootstrap verbs; hand-editing hosts.yaml
|
||||
// remains the path for exotica; the bootstrap ships as script + runbook).
|
||||
// OPR.0.4.6.MH1 FR-1 extends the family with `select` — the persisted
|
||||
// host-selection pointer (kubectl current-context shape). The pointer
|
||||
// lives in the config twins as `host.selected`; this verb is a THIN
|
||||
// CLIENT of the daemon config write (one write path for CLI+UI
|
||||
// convergence, arch ruling); reads resolve from the local ConfigStore
|
||||
// (env > file > default — zero new daemon lookups on read paths).
|
||||
//
|
||||
// Secret hygiene (FR-1): every render carries bearer POINTERS (env var /
|
||||
// file NAMES) only — no code path in this file resolves a bearer value
|
||||
// for display.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { hostname as osHostname, userInfo } from "node:os";
|
||||
import { connect } from "node:net";
|
||||
import { getOpenRigHome } from "../openrig-compat.js";
|
||||
import { addHostEntry, defaultHostRegistryPath, loadHostRegistry, validateHostRegistry, hostDisplayTarget, resolveHost, resolveRemoteBearer, type HostEntry, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type CrossHostResult } from "../cross-host-executor.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { readOwnHostName, readSelectedHost } from "../host-selection.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rig host doctor — stepwise, honest, three-valued (FR-1 + FR-2).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CheckStatus = "pass" | "fail" | "unknown";
|
||||
|
||||
export interface CheckRow {
|
||||
step: string;
|
||||
status: CheckStatus;
|
||||
detail: string;
|
||||
/** Actionable next step; REQUIRED for fail, encouraged for unknown. */
|
||||
fix?: string;
|
||||
}
|
||||
|
||||
export interface DoctorDeps {
|
||||
run: (host: HostEntry, argv: readonly string[]) => Promise<CrossHostResult>;
|
||||
httpGet: (url: string, headers?: Record<string, string>) => Promise<{ status: number; body: string }>;
|
||||
/** OPR.0.4.6.MH1 FR-6: bounded JSON POST for the pair handshake.
|
||||
* Optional so existing DoctorDeps fixtures keep compiling; the pair
|
||||
* verb falls back to plain fetch when absent. */
|
||||
httpPost?: (url: string, body: unknown) => Promise<{ status: number; body: string }>;
|
||||
/** TCP connect probe: "open" (connected) | "closed" (refused/filtered/timeout) | "unknown" (probe itself failed). */
|
||||
tcpProbe: (target: string, port: number, timeoutMs: number) => Promise<"open" | "closed" | "unknown">;
|
||||
}
|
||||
|
||||
function nestedBoolean(obj: unknown, path: readonly string[]): boolean | undefined {
|
||||
let cur: unknown = obj;
|
||||
for (const part of path) {
|
||||
if (typeof cur !== "object" || cur === null) return undefined;
|
||||
cur = (cur as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof cur === "boolean" ? cur : undefined;
|
||||
}
|
||||
|
||||
function tailscaleRunSshFromPrefs(text: string): boolean | undefined {
|
||||
if (!text.trim().startsWith("{")) return undefined;
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
return nestedBoolean(parsed, ["RunSSH"])
|
||||
?? nestedBoolean(parsed, ["Prefs", "RunSSH"])
|
||||
?? nestedBoolean(parsed, ["CurrentProfile", "RunSSH"]);
|
||||
}
|
||||
|
||||
function defaultDoctorDeps(): DoctorDeps {
|
||||
return {
|
||||
run: (host, argv) => runCrossHostCommand(host, argv),
|
||||
httpGet: async (url, headers) => {
|
||||
const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
|
||||
return { status: res.status, body: await res.text() };
|
||||
},
|
||||
httpPost: async (url, body) => {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
},
|
||||
tcpProbe: (target, port, timeoutMs) =>
|
||||
new Promise((resolve) => {
|
||||
const sock = connect({ host: target, port, timeout: timeoutMs });
|
||||
sock.on("connect", () => { sock.destroy(); resolve("open"); });
|
||||
sock.on("timeout", () => { sock.destroy(); resolve("closed"); });
|
||||
sock.on("error", () => { sock.destroy(); resolve("closed"); });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** FR-1 doctor legs — each failing step maps to a DISTINCT actionable error:
|
||||
* "SSH works but daemon is down" ≠ "daemon works but registry is wrong" ≠
|
||||
* "remote rig binary missing/old". */
|
||||
/** OPR.0.4.6.MH1 FR-3 — the coarse ls status word: a BOUNDED tcp dial
|
||||
* against the host's transport endpoint (ssh: target:22; http: the
|
||||
* URL's host:port). Coarse by design (the kubectl STATUS / docker
|
||||
* context ls precedent): reachable | unreachable | unknown — never a
|
||||
* hang (hard timeout), never a guess (dial failure = unreachable,
|
||||
* probe error/unparseable endpoint = unknown). */
|
||||
export async function probeHostStatus(
|
||||
host: HostEntry,
|
||||
tcpProbe: DoctorDeps["tcpProbe"],
|
||||
timeoutMs = 1500,
|
||||
): Promise<"reachable" | "unreachable" | "unknown"> {
|
||||
try {
|
||||
let target: string;
|
||||
let port: number;
|
||||
if (host.transport === "ssh") {
|
||||
target = host.target;
|
||||
port = 22;
|
||||
} else {
|
||||
const u = new URL(host.url);
|
||||
target = u.hostname;
|
||||
port = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80;
|
||||
}
|
||||
const r = await tcpProbe(target, port, timeoutMs);
|
||||
return r === "open" ? "reachable" : r === "closed" ? "unreachable" : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export async function doctorLegs(host: HostEntry, deps: DoctorDeps): Promise<CheckRow[]> {
|
||||
const rows: CheckRow[] = [];
|
||||
|
||||
if (host.transport === "ssh") {
|
||||
const reach = await deps.run(host, ["true"]);
|
||||
if (!reach.ok && (reach.failedStep === "ssh-unreachable" || reach.failedStep === "permission-gate")) {
|
||||
rows.push({
|
||||
step: "transport-reachability",
|
||||
status: "fail",
|
||||
detail: `ssh to ${host.target} failed (${reach.failedStep}): ${reach.sshStderr.trim()}`,
|
||||
fix: reach.failedStep === "permission-gate"
|
||||
? "SSH auth/permission gate — check the key, agent, and remote authorized_keys"
|
||||
: "verify the ssh target/alias, tailnet connectivity, and that sshd is running",
|
||||
});
|
||||
return rows; // later legs are meaningless without the shell
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `ssh to ${host.target} ok` });
|
||||
|
||||
const version = await deps.run(host, ["rig", "--version"]);
|
||||
if (!version.ok) {
|
||||
rows.push({
|
||||
step: "remote-rig-binary",
|
||||
status: "fail",
|
||||
detail: "reached the remote shell, but `rig --version` failed — remote rig binary missing or broken",
|
||||
fix: "install the published artifact on the host: npm install -g @openrig/cli (see the product-factory runbook)",
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
rows.push({ step: "remote-rig-binary", status: "pass", detail: `remote rig ${version.stdout.trim() || "(version unreadable)"}` });
|
||||
|
||||
const daemon = await deps.run(host, ["rig", "daemon", "status"]);
|
||||
const daemonUp = daemon.ok && /running/i.test(daemon.stdout);
|
||||
rows.push(daemonUp
|
||||
? { step: "remote-daemon-health", status: "pass", detail: "remote daemon reports running" }
|
||||
: {
|
||||
step: "remote-daemon-health",
|
||||
status: "fail",
|
||||
detail: "SSH works and rig is installed, but the remote daemon is not running",
|
||||
fix: "on the host: rig daemon start (then re-run doctor)",
|
||||
});
|
||||
if (!daemonUp) return rows;
|
||||
|
||||
const whoami = await deps.run(host, ["rig", "ps", "--json", "--limit", "5"]);
|
||||
let identityOk = false;
|
||||
if (whoami.ok) {
|
||||
try { identityOk = typeof JSON.parse(whoami.stdout) === "object"; } catch { identityOk = false; }
|
||||
}
|
||||
rows.push(identityOk
|
||||
? { step: "remote-identity", status: "pass", detail: "remote daemon identity/list resolves (rig ps --json parses)" }
|
||||
: {
|
||||
step: "remote-identity",
|
||||
status: "fail",
|
||||
detail: "daemon is up but remote identity/list did not resolve",
|
||||
fix: "on the host: check `rig ps --json` directly; the daemon API may be unhealthy even though status says running",
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// http transport
|
||||
const bearer = resolveRemoteBearer(host as HttpHostEntry);
|
||||
if (!bearer.ok) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: bearer.error, fix: "set the bearer env var / file the registry entry points at (pointer names: rig host list)" });
|
||||
return rows;
|
||||
}
|
||||
try {
|
||||
const health = await deps.httpGet(`${(host as HttpHostEntry).url}/healthz`, { Authorization: `Bearer ${bearer.token}` });
|
||||
if (health.status === 401 || health.status === 403) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon reachable but rejected the bearer (HTTP ${health.status})`, fix: "registry is reachable but the token is wrong — rotate/set the bearer the entry points at" });
|
||||
return rows;
|
||||
}
|
||||
if (health.status < 200 || health.status >= 300) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `healthz returned HTTP ${health.status}`, fix: "the daemon answered abnormally — check remote daemon logs" });
|
||||
return rows;
|
||||
}
|
||||
rows.push({ step: "transport-reachability", status: "pass", detail: `healthz ok at ${(host as HttpHostEntry).url}` });
|
||||
rows.push({ step: "remote-daemon-health", status: "pass", detail: "healthz IS the daemon health check on http transport" });
|
||||
} catch (err) {
|
||||
rows.push({ step: "transport-reachability", status: "fail", detail: `daemon unreachable at ${(host as HttpHostEntry).url}: ${(err as Error).message}`, fix: "check tailnet connectivity and that the remote daemon is running (rig daemon start on the host)" });
|
||||
return rows;
|
||||
}
|
||||
rows.push({
|
||||
step: "remote-rig-binary",
|
||||
status: "unknown",
|
||||
detail: "not determinable over http transport (no shell)",
|
||||
fix: "verify via an ssh-transport entry for this host, or on the host directly: rig --version",
|
||||
});
|
||||
try {
|
||||
const ps = await deps.httpGet(`${(host as HttpHostEntry).url}/api/ps`, { Authorization: `Bearer ${bearer.token}` });
|
||||
rows.push(ps.status >= 200 && ps.status < 300
|
||||
? { step: "remote-identity", status: "pass", detail: "authenticated daemon API answers (/api/ps)" }
|
||||
: { step: "remote-identity", status: "fail", detail: `/api/ps returned HTTP ${ps.status}`, fix: "daemon healthz is up but the API is unhealthy — check remote daemon logs" });
|
||||
} catch (err) {
|
||||
rows.push({ step: "remote-identity", status: "fail", detail: `/api/ps unreachable: ${(err as Error).message}`, fix: "check remote daemon logs" });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** FR-2 — THE one built-in posture profile (arch R13-2b: a named constant
|
||||
* table, no framework). Every item is three-valued; UNKNOWN is never
|
||||
* smoothed to pass, and every non-pass carries its fix. */
|
||||
export const POSTURE_PROFILE_ID = "product-factory-vps";
|
||||
|
||||
export async function postureCheck(
|
||||
host: HostEntry,
|
||||
deps: DoctorDeps,
|
||||
opts: { publicAddr?: string } = {},
|
||||
): Promise<CheckRow[]> {
|
||||
if (host.transport !== "ssh") {
|
||||
// Posture needs a shell. Report every item unknown — honestly.
|
||||
return POSTURE_ITEM_IDS.map((id) => ({
|
||||
step: id,
|
||||
status: "unknown" as const,
|
||||
detail: "posture checks need shell access; this host is registered with http transport",
|
||||
fix: "register an ssh-transport entry for this host (or run the checks on the host per the runbook)",
|
||||
}));
|
||||
}
|
||||
|
||||
const rows: CheckRow[] = [];
|
||||
const sh = async (cmd: string) => deps.run(host, ["sh", "-c", cmd]);
|
||||
|
||||
// 1. non-root openrig user
|
||||
const idOut = await sh("id -u openrig 2>/dev/null");
|
||||
if (idOut.ok && idOut.stdout.trim() !== "") {
|
||||
rows.push(idOut.stdout.trim() === "0"
|
||||
? { step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' resolves to uid 0", fix: "create a non-root openrig user per the runbook" }
|
||||
: { step: "nonroot-openrig-user", status: "pass", detail: `user 'openrig' uid ${idOut.stdout.trim()}` });
|
||||
} else {
|
||||
rows.push({ step: "nonroot-openrig-user", status: "fail", detail: "user 'openrig' does not exist", fix: "adduser openrig + key-only SSH per the runbook" });
|
||||
}
|
||||
|
||||
// 2+3. sshd effective config (root SSH off, password auth off)
|
||||
const sshd = await sh("sudo -n sshd -T 2>/dev/null || sshd -T 2>/dev/null || true");
|
||||
const sshdOut = sshd.ok ? sshd.stdout.toLowerCase() : "";
|
||||
const sshdItem = (step: string, key: string, want: string): CheckRow => {
|
||||
if (!sshdOut.includes(key)) {
|
||||
return { step, status: "unknown", detail: "effective sshd config not readable from this vantage (sshd -T needs root)", fix: "on the host: sudo sshd -T | grep " + key };
|
||||
}
|
||||
return sshdOut.includes(`${key} ${want}`)
|
||||
? { step, status: "pass", detail: `${key} ${want}` }
|
||||
: { step, status: "fail", detail: `sshd -T reports ${key} != ${want}`, fix: `set ${key} ${want} in /etc/ssh/sshd_config.d/99-openrig-hardening.conf and reload sshd` };
|
||||
};
|
||||
rows.push(sshdItem("root-ssh-disabled", "permitrootlogin", "no"));
|
||||
rows.push(sshdItem("key-only-ssh", "passwordauthentication", "no"));
|
||||
|
||||
// 4+5. UFW default-deny + tailnet ingress
|
||||
const ufw = await sh("sudo -n ufw status verbose 2>/dev/null || true");
|
||||
const ufwOut = ufw.ok ? ufw.stdout.toLowerCase() : "";
|
||||
if (ufwOut.trim() === "") {
|
||||
const unknownUfw = (step: string): CheckRow => ({ step, status: "unknown", detail: "ufw status not readable from this vantage (needs sudo)", fix: "on the host: sudo ufw status verbose" });
|
||||
rows.push(unknownUfw("ufw-default-deny-incoming"));
|
||||
rows.push(unknownUfw("tailnet-ingress-allowed"));
|
||||
} else {
|
||||
rows.push(/default:\s*deny\s*\(incoming/.test(ufwOut)
|
||||
? { step: "ufw-default-deny-incoming", status: "pass", detail: "ufw default deny (incoming)" }
|
||||
: { step: "ufw-default-deny-incoming", status: "fail", detail: "ufw incoming default is not deny", fix: "sudo ufw default deny incoming" });
|
||||
// R2-B1: mentioning tailscale0 is NOT enough — a DENY on tailscale0
|
||||
// must never pass. Pass requires an ALLOW IN rule on the interface.
|
||||
const tailnetAllowIn = ufwOut.split("\n").some((line) => line.includes("tailscale0") && /allow\s+in/.test(line));
|
||||
if (tailnetAllowIn) {
|
||||
rows.push({ step: "tailnet-ingress-allowed", status: "pass", detail: "ufw has an ALLOW IN rule on tailscale0" });
|
||||
} else if (ufwOut.includes("tailscale0")) {
|
||||
rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "tailscale0 appears in ufw rules but with no ALLOW IN rule (deny/other only)", fix: "sudo ufw allow in on tailscale0" });
|
||||
} else {
|
||||
rows.push({ step: "tailnet-ingress-allowed", status: "fail", detail: "no tailscale0 allow rule found", fix: "sudo ufw allow in on tailscale0" });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. tailscale flags (no subnet routes / exit node / ts-SSH)
|
||||
const ts = await sh("tailscale status --json 2>/dev/null || true");
|
||||
const prefs = await sh("tailscale debug prefs --json 2>/dev/null || true");
|
||||
let routes: number | undefined;
|
||||
let exitNode: boolean | undefined;
|
||||
let runSsh: boolean | undefined;
|
||||
const unknownReasons: string[] = [];
|
||||
try {
|
||||
if (ts.ok && ts.stdout.trim().startsWith("{")) {
|
||||
const parsed = JSON.parse(ts.stdout) as { Self?: { PrimaryRoutes?: unknown[]; ExitNodeOption?: boolean }; ExitNodeStatus?: unknown };
|
||||
routes = parsed.Self?.PrimaryRoutes?.length ?? 0;
|
||||
exitNode = (parsed.ExitNodeStatus !== undefined && parsed.ExitNodeStatus !== null) || parsed.Self?.ExitNodeOption === true;
|
||||
} else {
|
||||
unknownReasons.push("tailscale status unreadable");
|
||||
}
|
||||
} catch {
|
||||
unknownReasons.push("tailscale status did not parse");
|
||||
}
|
||||
try {
|
||||
runSsh = prefs.ok ? tailscaleRunSshFromPrefs(prefs.stdout) : undefined;
|
||||
if (runSsh === undefined) unknownReasons.push("tailscale debug prefs unreadable");
|
||||
} catch {
|
||||
unknownReasons.push("tailscale debug prefs did not parse");
|
||||
}
|
||||
if ((routes ?? 0) > 0 || exitNode === true || runSsh === true) {
|
||||
rows.push({
|
||||
step: "tailscale-minimal-trust",
|
||||
status: "fail",
|
||||
detail: `subnet routes: ${routes ?? "unknown"}; exit node in use: ${exitNode ?? "unknown"}; Tailscale SSH enabled: ${runSsh ?? "unknown"}`,
|
||||
fix: "remove advertised routes / exit-node use (tailscale set) and disable Tailscale SSH: tailscale set --ssh=false",
|
||||
});
|
||||
} else if (routes === 0 && exitNode === false && runSsh === false) {
|
||||
rows.push({ step: "tailscale-minimal-trust", status: "pass", detail: "no advertised subnet routes; no exit-node use; Tailscale SSH disabled" });
|
||||
} else {
|
||||
rows.push({
|
||||
step: "tailscale-minimal-trust",
|
||||
status: "unknown",
|
||||
detail: `tailscale posture not fully determined (${unknownReasons.join("; ")})`,
|
||||
fix: "on the host: tailscale status --json && tailscale debug prefs --json; disable Tailscale SSH with tailscale set --ssh=false",
|
||||
});
|
||||
}
|
||||
|
||||
// 7. daemon bound loopback/tailnet — never public. R2-B1: a SPECIFIC
|
||||
// public IP bind must fail exactly like a wildcard — pass is an
|
||||
// ALLOWLIST (loopback or the tailnet CGNAT range 100.64.0.0/10), never
|
||||
// "not wildcard".
|
||||
const ss = await sh("ss -tln 2>/dev/null | grep ':7433' || true");
|
||||
const ssOut = ss.ok ? ss.stdout : "";
|
||||
const listenAddrs = ssOut
|
||||
.split("\n")
|
||||
.map((line) => line.match(/(\S+):7433\b/)?.[1])
|
||||
.filter((a): a is string => a !== undefined);
|
||||
if (listenAddrs.length === 0) {
|
||||
rows.push({ step: "daemon-bind-not-public", status: "unknown", detail: "no listener on :7433 observed (daemon may be down)", fix: "start the daemon, then re-check: ss -tln | grep 7433" });
|
||||
} else {
|
||||
const isLoopbackOrTailnet = (addr: string): boolean => {
|
||||
const a = addr.replace(/^\[|\]$/g, "");
|
||||
if (a.startsWith("127.") || a === "::1") return true;
|
||||
const m = a.match(/^100\.(\d+)\./);
|
||||
return m !== null && Number(m[1]) >= 64 && Number(m[1]) <= 127;
|
||||
};
|
||||
const offending = listenAddrs.filter((a) => !isLoopbackOrTailnet(a));
|
||||
rows.push(offending.length === 0
|
||||
? { step: "daemon-bind-not-public", status: "pass", detail: `listener(s) bound loopback/tailnet only: ${listenAddrs.join(", ")}` }
|
||||
: { step: "daemon-bind-not-public", status: "fail", detail: `daemon listens on a non-loopback/non-tailnet address: ${offending.join(", ")}`, fix: "bind the daemon to loopback/tailnet only (OPENRIG daemon host config); never expose :7433 publicly" });
|
||||
}
|
||||
|
||||
// 8+9. public reachability probes — only meaningful with a known PUBLIC address.
|
||||
if (opts.publicAddr) {
|
||||
for (const [step, port] of [["public-daemon-port-unreachable", 7433], ["public-ssh-unreachable-or-accepted", 22]] as const) {
|
||||
const probe = await deps.tcpProbe(opts.publicAddr, port, 5000);
|
||||
rows.push(probe === "open"
|
||||
? { step, status: "fail", detail: `${opts.publicAddr}:${port} is publicly reachable`, fix: "remove the public allow rule / firewall the port (the smoke-test hardening bar: public :22/:7433 time out)" }
|
||||
: { step, status: "pass", detail: `${opts.publicAddr}:${port} not reachable (${probe})` });
|
||||
}
|
||||
} else {
|
||||
const unknownProbe = (step: string, port: number): CheckRow => ({
|
||||
step, status: "unknown",
|
||||
detail: `public reachability of :${port} not probed (no public address known — the registry target may be a tailnet alias)`,
|
||||
fix: `re-run with --public-addr <ip> to probe from this vantage`,
|
||||
});
|
||||
rows.push(unknownProbe("public-daemon-port-unreachable", 7433));
|
||||
rows.push(unknownProbe("public-ssh-unreachable-or-accepted", 22));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const POSTURE_ITEM_IDS = [
|
||||
"nonroot-openrig-user",
|
||||
"root-ssh-disabled",
|
||||
"key-only-ssh",
|
||||
"ufw-default-deny-incoming",
|
||||
"tailnet-ingress-allowed",
|
||||
"tailscale-minimal-trust",
|
||||
"daemon-bind-not-public",
|
||||
"public-daemon-port-unreachable",
|
||||
"public-ssh-unreachable-or-accepted",
|
||||
] as const;
|
||||
|
||||
function renderRows(rows: CheckRow[], json: boolean | undefined): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(rows));
|
||||
return;
|
||||
}
|
||||
const glyph = { pass: "✓", fail: "✗", unknown: "?" } as const;
|
||||
for (const r of rows) {
|
||||
console.log(`${glyph[r.status]} ${r.step}: ${r.detail}${r.fix ? `\n fix: ${r.fix}` : ""}`);
|
||||
}
|
||||
}
|
||||
|
||||
function authPointer(h: HostEntry): string {
|
||||
if (h.transport === "ssh") return "ssh-key";
|
||||
return h.bearer_env ? `env:${h.bearer_env}` : h.bearer_file ? `file:${h.bearer_file}` : "—";
|
||||
}
|
||||
|
||||
export function hostCommand(doctorDepsOverride?: DoctorDeps): Command {
|
||||
const cmd = new Command("host").description("Manage the multi-host registry (~/.openrig/hosts.yaml)");
|
||||
|
||||
cmd
|
||||
.command("add")
|
||||
.description("Add a host entry (validated with the registry loader's own rules)")
|
||||
.requiredOption("--id <id>", "Unique host id")
|
||||
.requiredOption("--transport <transport>", "ssh or http")
|
||||
.option("--target <target>", "SSH target (DNS name, ssh-config alias, or IP) — ssh transport")
|
||||
.option("--user <user>", "SSH user — ssh transport")
|
||||
.option("--url <url>", "Remote daemon base URL — http transport")
|
||||
.option("--bearer-env <name>", "Env var NAME holding the bearer token — http transport (pointer, never a value)")
|
||||
.option("--bearer-file <path>", "File PATH holding the bearer token — http transport (pointer, never a value)")
|
||||
.option("--notes <text>", "Free-form operator note")
|
||||
.option("--json", "JSON output")
|
||||
.action((opts: { id: string; transport: string; target?: string; user?: string; url?: string; bearerEnv?: string; bearerFile?: string; notes?: string; json?: boolean }) => {
|
||||
const rawEntry: Record<string, unknown> = { id: opts.id, transport: opts.transport };
|
||||
if (opts.target !== undefined) rawEntry["target"] = opts.target;
|
||||
if (opts.user !== undefined) rawEntry["user"] = opts.user;
|
||||
if (opts.url !== undefined) rawEntry["url"] = opts.url;
|
||||
if (opts.bearerEnv !== undefined) rawEntry["bearer_env"] = opts.bearerEnv;
|
||||
if (opts.bearerFile !== undefined) rawEntry["bearer_file"] = opts.bearerFile;
|
||||
if (opts.notes !== undefined) rawEntry["notes"] = opts.notes;
|
||||
|
||||
const res = addHostEntry(rawEntry);
|
||||
if (!res.ok) {
|
||||
console.error(res.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, path: res.path, entry: res.entry }));
|
||||
return;
|
||||
}
|
||||
console.log(`Added host '${res.entry.id}' (${res.entry.transport}) to ${res.path}`);
|
||||
console.log(`Verify it: rig host doctor ${res.entry.id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("select")
|
||||
.description("Select which host you are viewing/acting on (persisted; 'local' returns to this host)")
|
||||
.argument("<id>", "A registered host id, or 'local'")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (id: string, opts: { json?: boolean }) => {
|
||||
// Validate BEFORE persisting: 'local' is always legal; anything
|
||||
// else must be a registered host id.
|
||||
if (id !== "local") {
|
||||
const registryPath = defaultHostRegistryPath();
|
||||
const loaded = existsSync(registryPath) ? loadHostRegistry(registryPath) : null;
|
||||
if (!loaded || !loaded.ok) {
|
||||
console.error(
|
||||
loaded && !loaded.ok
|
||||
? loaded.error
|
||||
: `cannot select '${id}': no hosts registered (no registry at ${registryPath}). Add one first: rig host add --id <id> --transport <ssh|http> ...`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ids = loaded.registry.hosts.map((h) => h.id);
|
||||
if (!ids.includes(id)) {
|
||||
console.error(
|
||||
`cannot select '${id}': not a registered host id. Registered: ${ids.length > 0 ? ids.join(", ") : "(none)"}. Add it first: rig host add --id ${id} --transport <ssh|http> ... (or select 'local').`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// ONE write path: the daemon config write (the CLI is a thin
|
||||
// client — arch FR-1 ruling). Daemon-down surfaces the existing
|
||||
// structured connection error (trade named in the PRD).
|
||||
const client = new DaemonClient();
|
||||
let res: { status: number; data: { ok?: boolean; error?: string } };
|
||||
try {
|
||||
res = await client.post<{ ok?: boolean; error?: string }>(
|
||||
`/api/config/${encodeURIComponent("host.selected")}`,
|
||||
{ value: id },
|
||||
);
|
||||
} catch (err) {
|
||||
// Daemon down: the existing structured connection error, named
|
||||
// trade in the PRD (selection writes need the local daemon up).
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status !== 200 || res.data?.error) {
|
||||
console.error(res.data?.error ?? `daemon config write failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, selected: id }));
|
||||
return;
|
||||
}
|
||||
console.log(id === "local" ? "Selected host: local (this host)" : `Selected host: ${id}`);
|
||||
console.log(id === "local" ? "Commands run against this host." : `Flagless commands that support --host now run against '${id}'. Return with: rig host select local`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("rename")
|
||||
.description("Rename THIS host's display name (renders in dashboard, explorer, ls, whoami)")
|
||||
.argument("<name>", 'The new display name, e.g. "Mac mini 2"')
|
||||
.option("--json", "JSON output")
|
||||
.action(async (name: string, opts: { json?: boolean }) => {
|
||||
// OPR.0.4.6.MH1 FR-4 — one stored name (the settings twins, arch
|
||||
// Ruling 1), one write path (the daemon config write, same as
|
||||
// select). The name is free text; only emptiness is rejected.
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
console.error("host name must not be empty");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = new DaemonClient();
|
||||
let res: { status: number; data: { ok?: boolean; error?: string } };
|
||||
try {
|
||||
res = await client.post<{ ok?: boolean; error?: string }>(
|
||||
`/api/config/${encodeURIComponent("host.name")}`,
|
||||
{ value: trimmed },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error((err as Error).message);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status !== 200 || res.data?.error) {
|
||||
console.error(res.data?.error ?? `daemon config write failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, name: trimmed }));
|
||||
return;
|
||||
}
|
||||
console.log(`This host is now named: ${trimmed}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("pair")
|
||||
.description("Pair with a remote host from one pasted address — one approval on the target, done (FR-6)")
|
||||
.argument("<url>", "The target daemon's address (http[s]://host:port, or host:port)")
|
||||
.option("--id <id>", "Registry id for the new host (default: derived from the hostname)")
|
||||
.option("--timeout <seconds>", "How long to wait for the target-side approval", "600")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rawUrl: string, opts: { id?: string; timeout?: string; json?: boolean }) => {
|
||||
// OPR.0.4.6.MH1 FR-6 — THE founder-simple add path: one pasted
|
||||
// address, one approval ON the target, done. The flag-heavy
|
||||
// `rig host add` stays as the unchanged advanced path. B1: the CLI
|
||||
// keeps its direct-fs write (addHostEntry); the browser surface
|
||||
// pairs through its local daemon — both converge on the ONE write
|
||||
// contract.
|
||||
let target: URL;
|
||||
try {
|
||||
target = new URL(/^https?:\/\//.test(rawUrl.trim()) ? rawUrl.trim() : `http://${rawUrl.trim()}`);
|
||||
} catch {
|
||||
console.error(`'${rawUrl}' is not a usable address. Paste the target daemon's URL, e.g. http://vps-a:7433`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const targetBase = target.origin;
|
||||
const deps = doctorDepsOverride ?? defaultDoctorDeps();
|
||||
|
||||
// B1 fixback (guard code-review 2026-07-07): PREFLIGHT before any
|
||||
// network call or credential mutation. The candidate entry runs the
|
||||
// SAME validation contract the add will use (duplicate ids, reserved
|
||||
// ids, invalid existing registry all fail HERE — before the target
|
||||
// is asked for approval), and a pre-existing token file is
|
||||
// pre-existing CREDENTIAL STATE: rejected, never overwritten, and
|
||||
// never deleted by this request's cleanup.
|
||||
const id = (opts.id ?? "").trim() || target.hostname.toLowerCase().replace(/[^a-z0-9.-]/g, "-").replace(/\./g, "-").replace(/^-+|-+$/g, "") || "paired-host";
|
||||
const secretsDir = join(getOpenRigHome(), "secrets");
|
||||
const tokenPath = join(secretsDir, `host-${id}.token`);
|
||||
{
|
||||
const registryPath = defaultHostRegistryPath();
|
||||
let existing: HostEntry[] = [];
|
||||
if (existsSync(registryPath)) {
|
||||
const loaded = loadHostRegistry(registryPath);
|
||||
if (!loaded.ok) {
|
||||
console.error(`cannot pair: ${loaded.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
existing = loaded.registry.hosts;
|
||||
}
|
||||
const preflight = validateHostRegistry(
|
||||
{ hosts: [...existing, { id, transport: "http", url: targetBase, bearer_file: tokenPath }] },
|
||||
registryPath,
|
||||
);
|
||||
if (!preflight.ok) {
|
||||
console.error(`cannot pair as '${id}': ${preflight.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (existsSync(tokenPath)) {
|
||||
console.error(`a credential file already exists at ${tokenPath} — pre-existing credential state is never overwritten. Pass --id <different-id>, or remove the file yourself if you know it is stale.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const httpPost = deps.httpPost ?? (async (url: string, payload: unknown) => {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return { status: res.status, body: await res.text() };
|
||||
});
|
||||
const parseJson = (s: string): Record<string, unknown> => {
|
||||
try { return JSON.parse(s) as Record<string, unknown>; } catch { return {}; }
|
||||
};
|
||||
|
||||
let issued: { pairId?: string; code?: string; approvalQitemId?: string; error?: string; message?: string };
|
||||
try {
|
||||
const res = await httpPost(`${targetBase}/api/hosts/pair-request`, {
|
||||
requester: `${userInfo().username}@${osHostname()}`,
|
||||
});
|
||||
issued = parseJson(res.body) as typeof issued;
|
||||
if (res.status !== 200 || !issued.pairId || !issued.code) {
|
||||
console.error(issued.message ?? `pairing refused: target responded HTTP ${res.status}${issued.error ? ` (${issued.error})` : ""}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`could not reach ${targetBase}: ${(err as Error).message}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(`Pairing code: ${issued.code}`);
|
||||
console.log(`Waiting for approval on the target (its attention queue carries item ${issued.approvalQitemId ?? "?"}).`);
|
||||
console.log(`On the target: rig queue update ${issued.approvalQitemId ?? "<qitem-id>"} --state done --closure-reason no-follow-on`);
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(1, Number(opts.timeout ?? "600") || 600) * 1000;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let outcome: { status?: string; token?: string } = {};
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const res = await deps.httpGet(`${targetBase}/api/hosts/pair-request/${issued.pairId}`);
|
||||
outcome = parseJson(res.body) as typeof outcome;
|
||||
} catch {
|
||||
continue; // transient poll failure: keep waiting until the deadline
|
||||
}
|
||||
if (outcome.status && outcome.status !== "pending") break;
|
||||
}
|
||||
|
||||
if (outcome.status !== "approved" || !outcome.token) {
|
||||
const why = outcome.status === "denied" ? "the target DENIED the pairing"
|
||||
: outcome.status === "expired" ? "the pairing EXPIRED on the target"
|
||||
: "no approval arrived before the timeout";
|
||||
console.error(`pairing failed: ${why}. Nothing was persisted (no registry entry, no token file).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Approved: token → EXCLUSIVE-CREATE 0600 file (open flag "wx" —
|
||||
// rev1-r2 B3: check-then-rename had a window where a concurrent
|
||||
// same-id pair could clobber the winner's file and then delete it
|
||||
// on its own add failure; "wx" is atomic at the filesystem, so
|
||||
// creation SUCCESS is the proof of ownership the cleanup relies
|
||||
// on), then the registry entry via the ONE shipped write path.
|
||||
// addHostEntry re-validates authoritatively (the registry may have
|
||||
// changed since preflight).
|
||||
try {
|
||||
mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
||||
writeFileSync(tokenPath, `${outcome.token}\n`, { mode: 0o600, flag: "wx" });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
||||
console.error(`a credential file appeared at ${tokenPath} during pairing — refusing to overwrite it. Nothing was persisted by this request.`);
|
||||
} else {
|
||||
console.error(`failed to store the pairing token: ${(err as Error).message}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const added = addHostEntry({
|
||||
id,
|
||||
transport: "http",
|
||||
url: targetBase,
|
||||
bearer_file: tokenPath,
|
||||
notes: `paired ${new Date().toISOString().slice(0, 10)}`,
|
||||
});
|
||||
if (!added.ok) {
|
||||
rmSync(tokenPath, { force: true });
|
||||
console.error(`pairing approved but the registry write failed: ${added.error}. The token file (created by this request) was removed — nothing persisted.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, entry: added.entry }));
|
||||
return;
|
||||
}
|
||||
console.log(`Paired. Host '${id}' registered (${targetBase}, bearer_file ${tokenPath}).`);
|
||||
console.log(`Verify it: rig host doctor ${id}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
// OPR.0.4.6.MH1 QA fixback: the PRD/proof contract spells the verb
|
||||
// `rig host ls` (FR-3); `list` stays the canonical name, `ls` is the
|
||||
// contract-required alias (same action, same --json).
|
||||
.alias("ls")
|
||||
.description("List registered hosts w/ status + selected marker (config pointers only — never secret values)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const registryPath = defaultHostRegistryPath();
|
||||
if (!existsSync(registryPath)) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify([]));
|
||||
return;
|
||||
}
|
||||
console.log(`No hosts registered in ${registryPath}. Pair one from a single address: rig host pair <url> (advanced/manual path: rig host add --id <id> --transport <ssh|http> ...)`);
|
||||
return;
|
||||
}
|
||||
const loaded = loadHostRegistry(registryPath);
|
||||
if (!loaded.ok) {
|
||||
console.error(loaded.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.6.MH1 FR-1/FR-3: selected marker (local ConfigStore read —
|
||||
// env > file > default; zero daemon dependency) + a bounded status
|
||||
// probe per host (parallel; hard per-host timeout — never a hang).
|
||||
const selected = readSelectedHost();
|
||||
const probeDeps: DoctorDeps = doctorDepsOverride ?? defaultDoctorDeps();
|
||||
const statuses = await Promise.all(
|
||||
loaded.registry.hosts.map((h) => probeHostStatus(h, probeDeps.tcpProbe)),
|
||||
);
|
||||
if (opts.json) {
|
||||
// Entries as validated: bearer fields are NAMES by construction.
|
||||
// ADDITIVE ONLY (the shipped bare-array shape is a contract —
|
||||
// pre-MH1 consumers keep working): each row gains `selected` +
|
||||
// `status`; the scriptable selection pointer is the true row (or
|
||||
// `rig config get host.selected`).
|
||||
console.log(JSON.stringify(loaded.registry.hosts.map((h, i) => ({ ...h, selected: h.id === selected, status: statuses[i] }))));
|
||||
return;
|
||||
}
|
||||
if (loaded.registry.hosts.length === 0) {
|
||||
console.log(`No hosts registered in ${registryPath}. Pair one from a single address: rig host pair <url> (advanced/manual path: rig host add --id <id> --transport <ssh|http> ...)`);
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.6.MH1 FR-4: the own-host name renders here when RENAMED;
|
||||
// unnamed (default "localhost") keeps today's output byte-identical
|
||||
// (the FR-4 zero-regression AC). Scriptable read: rig config get
|
||||
// host.name — the --json array stays registry rows only (shipped
|
||||
// bare-array contract).
|
||||
const ownName = readOwnHostName();
|
||||
if (ownName !== "localhost") {
|
||||
console.log(`This host: ${ownName}\n`);
|
||||
}
|
||||
const pad = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + "…" : s.padEnd(n));
|
||||
console.log(`${pad("", 2)}${pad("ID", 20)} ${pad("TRANSPORT", 10)} ${pad("TARGET", 30)} ${pad("STATUS", 12)} ${pad("AUTH", 24)} NOTES`);
|
||||
for (let i = 0; i < loaded.registry.hosts.length; i++) {
|
||||
const h = loaded.registry.hosts[i]!;
|
||||
const marker = h.id === selected ? "* " : " ";
|
||||
console.log(`${marker}${pad(h.id, 20)} ${pad(h.transport, 10)} ${pad(hostDisplayTarget(h), 30)} ${pad(statuses[i]!, 12)} ${pad(authPointer(h), 24)} ${h.notes ?? "—"}`);
|
||||
}
|
||||
if (selected !== "local") {
|
||||
console.log(`\nSelected host: ${selected} (return with: rig host select local)`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("doctor")
|
||||
.description("Stepwise host verification (+ --posture for the product-factory-vps baseline)")
|
||||
.argument("<id>", "Registered host id")
|
||||
.option("--posture <profile>", `Run the posture baseline (only built-in profile: ${POSTURE_PROFILE_ID})`)
|
||||
.option("--public-addr <ip>", "Public address for the outside-vantage reachability probes (posture items 8-9)")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (id: string, opts: { posture?: string; publicAddr?: string; json?: boolean }) => {
|
||||
const deps: DoctorDeps = doctorDepsOverride ?? defaultDoctorDeps();
|
||||
const loaded = loadHostRegistry();
|
||||
if (!loaded.ok) {
|
||||
console.error(loaded.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(loaded.registry, id);
|
||||
if (!resolved.ok) {
|
||||
// The DISTINCT "registry is wrong" error class.
|
||||
console.error(`registry: ${resolved.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.posture !== undefined && opts.posture !== POSTURE_PROFILE_ID) {
|
||||
console.error(`unknown posture profile '${opts.posture}'. The only built-in profile is: ${POSTURE_PROFILE_ID}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await doctorLegs(resolved.host, deps);
|
||||
if (opts.posture) {
|
||||
rows.push(...await postureCheck(resolved.host, deps, { publicAddr: opts.publicAddr }));
|
||||
}
|
||||
renderRows(rows, opts.json);
|
||||
if (rows.some((r) => r.status === "fail")) process.exitCode = 1;
|
||||
const unknowns = rows.filter((r) => r.status === "unknown").length;
|
||||
if (!opts.json && unknowns > 0) {
|
||||
console.log(`${unknowns} item${unknowns === 1 ? "" : "s"} UNKNOWN — unknown is not pass; see the fix lines to verify.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
@@ -11,8 +12,24 @@ type LaunchResponse = {
|
||||
logicalId?: string;
|
||||
sessionName?: string;
|
||||
error?: string;
|
||||
code?: string;
|
||||
launched?: Array<{ nodeId: string; logicalId: string; status: string; error?: string }>;
|
||||
held?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
alreadyRunning?: Array<{ nodeId: string; logicalId: string }>;
|
||||
failedTargets?: Array<{ nodeId: string; logicalId: string; reason: string }>;
|
||||
// OPR.0.4.3.28 correction — non-blocking launch warnings (e.g. liveness_probe_unknown: launched
|
||||
// despite a failed tmux liveness probe). Surfaced as human output WITHOUT a non-zero exit.
|
||||
warnings?: string[];
|
||||
};
|
||||
|
||||
// OPR.0.4.3.20 FR-7 — a launched-entry status that means NO session is running (the
|
||||
// operator must act). These are NOT successful launches — never print "Launched" for
|
||||
// them; exit non-zero. Mirrors the daemon's NON_RUNNING_LAUNCH_STATUSES.
|
||||
const NON_RUNNING_LAUNCH_STATUSES = new Set(["awaiting-decision", "attention_required", "failed"]);
|
||||
function launchStatusRunning(status: string): boolean {
|
||||
return !NON_RUNNING_LAUNCH_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("launch").description("Launch or relaunch a node in a running rig");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
@@ -28,17 +45,108 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Target rig ID")
|
||||
.argument("<nodeRef>", "Node logical ID or node ID")
|
||||
.argument("[nodeRef]", "Node logical ID or node ID (single target)")
|
||||
.option("--seats <ids>", "Comma-separated logical IDs for subset launch")
|
||||
.option("--hold-reason <reason>", "Reason for holding non-target seats")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (rigId: string, nodeRef: string, opts: { json?: boolean }) => {
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (rigId: string, nodeRef: string | undefined, opts: { json?: boolean; holdReason?: string; seats?: string; host?: string }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly.
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDeps();
|
||||
|
||||
if (opts.host) {
|
||||
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
|
||||
const seatList = opts.seats ? opts.seats.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
let apiPath: string;
|
||||
let body: unknown;
|
||||
if (seatList.length > 0) {
|
||||
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`;
|
||||
body = { seats: seatList, ...(opts.holdReason ? { holdReason: opts.holdReason } : {}) };
|
||||
} else if (nodeRef) {
|
||||
apiPath = `/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`;
|
||||
body = opts.holdReason ? { holdReason: opts.holdReason } : {};
|
||||
} else {
|
||||
console.error("Either a node reference or --seats is required for launch --host");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const result = await runRemoteHttpOp(opts.host, "POST", apiPath, body, deps, opts);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} else if (result.ok) {
|
||||
console.log(JSON.stringify(result.data, null, 2));
|
||||
} else {
|
||||
console.error(`Error on host ${opts.host}: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const client = await getClient(deps);
|
||||
if (!client) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`, {});
|
||||
const seatList = opts.seats ? opts.seats.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
|
||||
if (seatList.length > 0) {
|
||||
const body: { seats: string[]; holdReason?: string } = { seats: seatList };
|
||||
if (opts.holdReason) body.holdReason = opts.holdReason;
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/launch-subset`, body);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Hard failure (no per-seat result to render — e.g. rig_not_found): error + exit.
|
||||
if (!res.data.launched && !res.data.held && !res.data.alreadyRunning) {
|
||||
console.error(res.data.error ?? `Launch failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.3.20 FR-7 — only actually-running restore outcomes are "Launched".
|
||||
// A seat that landed awaiting-decision / attention_required / failed is NOT
|
||||
// launched (no session running) — print it honestly + exit non-zero.
|
||||
const launchedAll = res.data.launched ?? [];
|
||||
const running = launchedAll.filter((n) => launchStatusRunning(n.status));
|
||||
const needsDecision = launchedAll.filter((n) => !launchStatusRunning(n.status));
|
||||
const heldIds = (res.data.held ?? []).map((n) => `${n.logicalId} (${n.reason})`).join(", ");
|
||||
if (running.length) console.log(`Launched: ${running.map((n) => n.logicalId).join(", ")}`);
|
||||
if (heldIds) console.log(`Held: ${heldIds}`);
|
||||
if (res.data.alreadyRunning?.length) console.log(`Already running: ${res.data.alreadyRunning.map((n) => n.logicalId).join(", ")}`);
|
||||
// OPR.0.4.3.28 correction — proceed-with-warning: print non-blocking launch warnings (e.g.
|
||||
// liveness_probe_unknown) WITHOUT setting a non-zero exit.
|
||||
for (const w of res.data.warnings ?? []) console.warn(`Warning: ${w}`);
|
||||
for (const n of needsDecision) {
|
||||
console.error(` ${n.logicalId}: ${n.status}${n.error ? ` — ${n.error}` : ""}`);
|
||||
}
|
||||
if (needsDecision.length > 0) process.exitCode = 1;
|
||||
if (res.data.failedTargets?.length) {
|
||||
console.error(`Failed (liveness unknown): ${res.data.failedTargets.map((n) => n.logicalId).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if ((res.data as Record<string, unknown>).unmatchedIds && ((res.data as Record<string, unknown>).unmatchedIds as string[]).length > 0) {
|
||||
console.error(`Unmatched seats (not found): ${((res.data as Record<string, unknown>).unmatchedIds as string[]).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeRef) {
|
||||
console.error("Provide a node logical ID or use --seats <a,b> for subset launch");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {};
|
||||
if (opts.holdReason) body.holdReason = opts.holdReason;
|
||||
|
||||
const res = await client.post<LaunchResponse>(`/api/rigs/${encodeURIComponent(rigId)}/nodes/${encodeURIComponent(nodeRef)}/launch`, body);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
@@ -52,8 +160,19 @@ export function launchCommand(depsOverride?: StatusDeps): Command {
|
||||
}
|
||||
|
||||
const logicalId = res.data.logicalId ?? nodeRef;
|
||||
// OPR.0.4.3.28 correction — proceed-with-warning: print non-blocking launch warnings (e.g.
|
||||
// liveness_probe_unknown) WITHOUT setting a non-zero exit.
|
||||
const printLaunchWarnings = () => {
|
||||
for (const w of res.data.warnings ?? []) console.warn(`Warning: ${w}`);
|
||||
};
|
||||
if (res.data.code === "already_running" || (res.data.alreadyRunning && res.data.alreadyRunning.length > 0)) {
|
||||
console.log(`Node ${logicalId} is already running in rig ${rigId} (not relaunched)`);
|
||||
printLaunchWarnings();
|
||||
return;
|
||||
}
|
||||
const sessionSuffix = res.data.sessionName ? ` (${res.data.sessionName})` : "";
|
||||
console.log(`Launched node ${logicalId} in rig ${rigId}${sessionSuffix}`);
|
||||
printLaunchWarnings();
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import YAML from "yaml";
|
||||
import { findSlice, resolveMissionsRoot } from "../lib/scope/scope-fs.js";
|
||||
import { ScopeCliError } from "../lib/scope/types.js";
|
||||
|
||||
/**
|
||||
* `rig proof` — the proof-drop write path (OPR.0.4.4.19 FR-8 + FR-11;
|
||||
* conventions C1 + C2 + C8, D2 attestation).
|
||||
*
|
||||
* CLI-side filesystem only (plan-review + arch-lead confirmed): the drop
|
||||
* validates the C1 header AT THE MOMENT THE EVIDENCE IS IN-HAND, writes the
|
||||
* artifact into the slice's proof/ dir, and echoes the parsed header (the
|
||||
* seat sees what the composer will see). No daemon involvement, no synthetic
|
||||
* qitems, no DB writes.
|
||||
*
|
||||
* LOAD-BEARING boundaries:
|
||||
* - Validation applies ONLY to drops made through this path. An artifact
|
||||
* written by any other means (raw file write, existing workflows) is
|
||||
* NEVER blocked at write time — the backstop is `rig scope audit`
|
||||
* (FR-10), not a write-path gate on ordinary file I/O.
|
||||
* - The D2 proof contract + self_check are AGENT JUDGMENT recorded here;
|
||||
* the drop path ADVISES (exit 0) and never blocks on them. There is no
|
||||
* configuration that makes any advisory blocking (BR-7).
|
||||
*/
|
||||
|
||||
/** C1 ratified closed sets (BR-4 — extending them is a convention change
|
||||
* owned by pm-lead, not a code decision). */
|
||||
export const C1_ARTIFACT_TYPES = ["guard", "qa", "rev1-r1", "rev1-r2", "adjudication"] as const;
|
||||
export const C1_VERDICTS = ["CLEAR", "BLOCKING", "CONCERNING", "PASS", "NOT-CLEAR"] as const;
|
||||
|
||||
/** Video extensions for the C8 UX advisory (screencast evidence). */
|
||||
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv"]);
|
||||
|
||||
export interface C1Header {
|
||||
slice: string;
|
||||
candidate_sha: string;
|
||||
artifact_type: string;
|
||||
verdict: string;
|
||||
money_evidence: string;
|
||||
/** D2 optional attestation fields — advise-never-block. */
|
||||
evidences?: string[];
|
||||
self_check?: string;
|
||||
}
|
||||
|
||||
export interface C1ValidationResult {
|
||||
ok: boolean;
|
||||
missing: string[];
|
||||
invalid: Array<{ field: string; value: string; allowed: readonly string[] }>;
|
||||
}
|
||||
|
||||
/** Validate the five required C1 fields + closed sets. Pure. */
|
||||
export function validateC1Header(header: Partial<C1Header>): C1ValidationResult {
|
||||
const missing: string[] = [];
|
||||
for (const field of ["slice", "candidate_sha", "artifact_type", "verdict", "money_evidence"] as const) {
|
||||
const value = header[field];
|
||||
if (typeof value !== "string" || value.trim().length === 0) missing.push(field);
|
||||
}
|
||||
const invalid: C1ValidationResult["invalid"] = [];
|
||||
if (header.artifact_type && !(C1_ARTIFACT_TYPES as readonly string[]).includes(header.artifact_type)) {
|
||||
invalid.push({ field: "artifact_type", value: header.artifact_type, allowed: C1_ARTIFACT_TYPES });
|
||||
}
|
||||
if (header.verdict && !(C1_VERDICTS as readonly string[]).includes(header.verdict)) {
|
||||
invalid.push({ field: "verdict", value: header.verdict, allowed: C1_VERDICTS });
|
||||
}
|
||||
return { ok: missing.length === 0 && invalid.length === 0, missing, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the pinned `## Proof contract` section out of a slice's
|
||||
* IMPLEMENTATION-PRD.md (the C7 pinned name; PM-lane authored). Returns the
|
||||
* promised items (checkbox-item form, one promised item per line), or null
|
||||
* when the slice declares no contract (tier-1 degrade — zero noise).
|
||||
*/
|
||||
export function parseProofContract(prdContent: string): string[] | null {
|
||||
const lines = prdContent.split("\n");
|
||||
const start = lines.findIndex((l) => /^##\s+Proof contract\s*$/i.test(l.trim()));
|
||||
if (start === -1) return null;
|
||||
const items: string[] = [];
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (/^##\s/.test(line)) break; // next section
|
||||
const m = line.match(/^\s*-\s*(?:\[[ xX]\]\s*)?(.+\S)\s*$/);
|
||||
if (m) items.push(m[1]!);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function isVideoFile(filePath: string): boolean {
|
||||
return VIDEO_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
export function proofCommand(): Command {
|
||||
const cmd = new Command("proof").description(
|
||||
"Proof artifacts — drop gate/proof evidence into a slice's proof/ dir with the machine-readable C1 header, validated at the natural moment (OPR.0.4.4.19 FR-8). The drop is the SDLC's proof leg: --evidences joins it to the slice's ## Proof contract items (what the Living Notes DELIVERED section pairs + renders); proof-lock afterwards via rig scope slice approve --scope delivery. Conventions SSOT: docs/reference/sdlc-conventions.md."
|
||||
);
|
||||
cmd.option("--workspace <path>", "Override workspace root (else cwd walk or $OPENRIG_WORK_ROOT)");
|
||||
|
||||
cmd
|
||||
.command("add <slice-path>")
|
||||
.description("Drop a proof artifact: authors the C1 frontmatter from flags, writes <slice>/proof/<name>, echoes the parsed header. Contract/self-check/C8 outputs are advisories (exit 0) — never gates.")
|
||||
.option("--mission <name>", "Hint mission when slice-path is just NN-slug")
|
||||
.requiredOption("--artifact-type <type>", `C1 artifact_type, one of: ${C1_ARTIFACT_TYPES.join(" | ")}`)
|
||||
.requiredOption("--verdict <verdict>", `C1 verdict, one of: ${C1_VERDICTS.join(" | ")}`)
|
||||
.requiredOption("--candidate-sha <sha>", "C1 candidate_sha — the join key (convention C2): the proven candidate tip this artifact judges")
|
||||
.requiredOption("--money-evidence <line>", "C1 money_evidence — the one line of money evidence")
|
||||
.option("--slice-id <dot-id>", "C1 slice dot-ID (defaults to the slice frontmatter id)")
|
||||
.option("--file <path>", "Artifact body from a file (mutually exclusive with --body)")
|
||||
.option("--body <text>", "Artifact body inline (mutually exclusive with --file)")
|
||||
.option("--name <filename>", "Artifact filename in proof/ (defaults to the --file basename, else <artifact-type>-<verdict>-<UTC>.md)")
|
||||
.option("--evidences <refs>", "D2 attestation: comma-separated proof-contract item refs this artifact covers (item text or 1-based index)")
|
||||
.option("--self-check <text>", "D2 attestation: the agent's assertion that it LOOKED at the evidence and confirmed it shows the claim")
|
||||
.option("--media <refs>", "Corrective §3.4: comma-separated media refs (relative to the slice proof/ dir) this drop stands behind — appended to the artifact body as markdown refs so the composer curates them into delivered.items[].proof")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (slicePath: string, opts: {
|
||||
mission?: string;
|
||||
artifactType: string;
|
||||
verdict: string;
|
||||
candidateSha: string;
|
||||
moneyEvidence: string;
|
||||
sliceId?: string;
|
||||
file?: string;
|
||||
body?: string;
|
||||
name?: string;
|
||||
evidences?: string;
|
||||
selfCheck?: string;
|
||||
media?: string;
|
||||
json?: boolean;
|
||||
}, command: Command) => {
|
||||
const json = Boolean(opts.json);
|
||||
const advisories: string[] = [];
|
||||
const warns: string[] = [];
|
||||
try {
|
||||
if (opts.file && opts.body) {
|
||||
throw new ScopeCliError({
|
||||
fact: "Both --file and --body were provided.",
|
||||
consequence: "The artifact body is ambiguous.",
|
||||
action: "Pass exactly one of --file <path> or --body <text>.",
|
||||
});
|
||||
}
|
||||
const parentOpts = (command.parent?.opts() ?? {}) as { workspace?: string };
|
||||
const missionsRoot = resolveMissionsRoot({ override: parentOpts.workspace });
|
||||
const slice = findSlice(missionsRoot, slicePath, opts.mission ?? null);
|
||||
|
||||
// Resolve the artifact body.
|
||||
let body = "";
|
||||
if (opts.file) {
|
||||
if (!fs.existsSync(opts.file)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--file ${opts.file} does not exist.`,
|
||||
consequence: "No artifact body to drop.",
|
||||
action: "Point --file at the evidence file, or use --body.",
|
||||
});
|
||||
}
|
||||
body = fs.readFileSync(opts.file, "utf8");
|
||||
} else if (opts.body) {
|
||||
body = opts.body;
|
||||
}
|
||||
|
||||
// Corrective §3.4 — attach curated media refs (proof/-relative) to
|
||||
// the artifact body as markdown refs; the composer projects them
|
||||
// into delivered.items[].proof for the covered deliverables. Same
|
||||
// containment discipline as --name: nothing outside the slice dir.
|
||||
const sliceProofDir = path.join(slice.absPath, "proof");
|
||||
const mediaRefs = opts.media
|
||||
? opts.media.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
const mediaLines: string[] = [];
|
||||
for (const ref of mediaRefs) {
|
||||
if (path.isAbsolute(ref)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--media ref '${ref}' is absolute.`,
|
||||
consequence: "The artifact was NOT dropped — proof media is co-located slice content (FR-5), referenced relative to the slice's proof/ dir.",
|
||||
action: "Copy the media into the slice's proof/ dir and pass the relative name.",
|
||||
});
|
||||
}
|
||||
const resolved = path.resolve(sliceProofDir, ref);
|
||||
if (!resolved.startsWith(path.resolve(slice.absPath) + path.sep)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--media ref '${ref}' resolves outside the slice dir.`,
|
||||
consequence: "The artifact was NOT dropped — out-of-slice media can never be served or frozen with the review (FR-5).",
|
||||
action: "Move the media under the slice dir (proof/ is the natural home) and re-run.",
|
||||
});
|
||||
}
|
||||
if (!fs.existsSync(resolved)) {
|
||||
warns.push(`--media ref '${ref}' does not exist yet (${resolved}) — the review will show it as unavailable until the file lands`);
|
||||
}
|
||||
const ext = path.extname(ref).toLowerCase();
|
||||
if (VIDEO_EXTENSIONS.has(ext)) mediaLines.push(`<video src="${ref}"></video>`);
|
||||
else mediaLines.push(``);
|
||||
}
|
||||
if (mediaLines.length > 0) {
|
||||
body = `${body.trimEnd()}\n\n## Media\n\n${mediaLines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// Author the C1 header from flags + slice identity.
|
||||
const header: Partial<C1Header> = {
|
||||
slice: opts.sliceId ?? (typeof slice.id === "string" ? slice.id : undefined),
|
||||
candidate_sha: opts.candidateSha,
|
||||
artifact_type: opts.artifactType,
|
||||
verdict: opts.verdict,
|
||||
money_evidence: opts.moneyEvidence,
|
||||
};
|
||||
const evidences = opts.evidences
|
||||
? opts.evidences.split(",").map((s) => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
if (evidences && evidences.length > 0) header.evidences = evidences;
|
||||
if (opts.selfCheck) header.self_check = opts.selfCheck;
|
||||
|
||||
// Validate the closed sets AT DROP TIME (while the evidence is
|
||||
// in-hand) — the one REJECTING validation this path performs.
|
||||
const validation = validateC1Header(header);
|
||||
if (!validation.ok) {
|
||||
const parts: string[] = [];
|
||||
if (validation.missing.length > 0) parts.push(`missing required C1 field(s): ${validation.missing.join(", ")}`);
|
||||
for (const inv of validation.invalid) {
|
||||
parts.push(`${inv.field}='${inv.value}' is not in the ratified closed set (${inv.allowed.join(" | ")})`);
|
||||
}
|
||||
throw new ScopeCliError({
|
||||
fact: `C1 header invalid — ${parts.join("; ")}.`,
|
||||
consequence: "The artifact was NOT dropped (post-hoc reconstruction is the failure mode this fights).",
|
||||
action: "Provide the named fields with allowed values and re-run while the evidence is in-hand. Extending the closed sets is a pm-lead convention change (BR-4).",
|
||||
});
|
||||
}
|
||||
|
||||
// D2 — validate evidences refs against the slice's declared proof
|
||||
// contract (unknown refs = a named WARN, never a rejection), and
|
||||
// emit the coverage/self_check ADVISORY when a contract exists.
|
||||
const prdPath = path.join(slice.absPath, "IMPLEMENTATION-PRD.md");
|
||||
const contractItems = fs.existsSync(prdPath)
|
||||
? parseProofContract(fs.readFileSync(prdPath, "utf8"))
|
||||
: null;
|
||||
let coveredItems: string[] = [];
|
||||
if (contractItems && contractItems.length > 0) {
|
||||
if (evidences && evidences.length > 0) {
|
||||
for (const ref of evidences) {
|
||||
const byIndex = /^\d+$/.test(ref) ? contractItems[Number.parseInt(ref, 10) - 1] : undefined;
|
||||
const byText = contractItems.find((item) => item === ref);
|
||||
const match = byText ?? byIndex;
|
||||
if (match) coveredItems.push(match);
|
||||
else warns.push(`evidences ref '${ref}' matches no declared proof-contract item (known items: ${contractItems.map((_, i) => i + 1).join(", ")} or exact text)`);
|
||||
}
|
||||
}
|
||||
if (coveredItems.length === 0 || !header.self_check) {
|
||||
const uncovered = contractItems.filter((item) => !coveredItems.includes(item));
|
||||
const reasons: string[] = [];
|
||||
if (coveredItems.length === 0) reasons.push("this drop covers no declared contract item");
|
||||
if (!header.self_check) reasons.push("self_check attestation omitted");
|
||||
advisories.push(
|
||||
`ADVISORY (D2, advise-never-block): ${reasons.join(" and ")}. ` +
|
||||
`Uncovered contract item(s): ${uncovered.map((u) => `"${u}"`).join(", ")}. ` +
|
||||
`The Packet-2 promised→delivered join will show these as MISSING (the ▲ insufficient-proof signal).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// FR-11 / C8 — the UX-slice video advisory (SHOULD/steer, exit 0,
|
||||
// no configuration can make it blocking). Trigger: slice frontmatter
|
||||
// ux-change: true (spec-time flag; never a qitem tag, never
|
||||
// diff-inference). Satisfied when this drop is a video or the
|
||||
// proof/ dir already holds one.
|
||||
const uxChange = slice.frontmatter["ux-change"] === true;
|
||||
if (uxChange) {
|
||||
const proofDir = path.join(slice.absPath, "proof");
|
||||
const existingVideo = fs.existsSync(proofDir)
|
||||
&& fs.readdirSync(proofDir).some((f) => isVideoFile(f));
|
||||
const droppingVideo = (opts.file ? isVideoFile(opts.file) : false) || mediaRefs.some((r) => isVideoFile(r));
|
||||
if (!existingVideo && !droppingVideo) {
|
||||
advisories.push(
|
||||
"ADVISORY (C8, SHOULD/steer): this slice is UX-tagged (ux-change: true) and its proof set has no video. " +
|
||||
"UX-change slices SHOULD produce screenshot + video together — capture a screencast via the agent-browser-screencast method " +
|
||||
"and hold it to the money-shot-edit bar. This never blocks a drop."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write the artifact: YAML frontmatter + body into proof/.
|
||||
const proofDir = path.join(slice.absPath, "proof");
|
||||
const defaultName = `${opts.artifactType}-${opts.verdict}-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
|
||||
const fileName = opts.name ?? (opts.file ? path.basename(opts.file) : defaultName);
|
||||
// rev1-r2 BLOCKING fix (a7dedd93 review): --name is a FILENAME, never
|
||||
// a path. Reject separators / dot-dot / absolute shapes BEFORE any
|
||||
// filesystem effect, so the drop can only land inside proof/ (the
|
||||
// FR-8 contract) — a traversal name like ../README.md must not reach
|
||||
// slice control files.
|
||||
if (fileName.includes("/") || fileName.includes("\\") || fileName.startsWith("..") || path.isAbsolute(fileName)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--name '${fileName}' is not a plain filename (path separators, '..', and absolute paths are rejected).`,
|
||||
consequence: "The artifact was NOT dropped — proof drops land inside the slice proof/ dir only (FR-8).",
|
||||
action: "Pass a bare filename like qa-clear.md; the drop path owns the directory.",
|
||||
});
|
||||
}
|
||||
const target = path.resolve(proofDir, fileName);
|
||||
// Defense-in-depth: even a name that slips the shape check must
|
||||
// resolve INSIDE proof/ (same containment discipline as
|
||||
// scope-approve's path-escape guard).
|
||||
if (!target.startsWith(path.resolve(proofDir) + path.sep)) {
|
||||
throw new ScopeCliError({
|
||||
fact: `--name '${fileName}' resolves outside the slice proof/ dir.`,
|
||||
consequence: "The artifact was NOT dropped.",
|
||||
action: "Pass a bare filename; the drop path owns the directory.",
|
||||
});
|
||||
}
|
||||
fs.mkdirSync(proofDir, { recursive: true });
|
||||
const frontmatter = YAML.stringify(header).trimEnd();
|
||||
fs.writeFileSync(target, `---\n${frontmatter}\n---\n\n${body}`, "utf8");
|
||||
|
||||
// Echo the parsed header — the seat sees what the composer will see.
|
||||
const echo = {
|
||||
dropped: path.relative(process.cwd(), target),
|
||||
header: header as C1Header,
|
||||
contractItemsDeclared: contractItems?.length ?? 0,
|
||||
contractItemsCovered: coveredItems,
|
||||
mediaRefs,
|
||||
warnings: warns,
|
||||
advisories,
|
||||
};
|
||||
if (json) {
|
||||
console.log(JSON.stringify(echo, null, 2));
|
||||
} else {
|
||||
console.log(`Dropped: ${echo.dropped}`);
|
||||
console.log(`Parsed C1 header:\n${frontmatter}`);
|
||||
if (contractItems) console.log(`Proof contract: ${coveredItems.length}/${contractItems.length} item(s) covered by this drop.`);
|
||||
for (const w of warns) console.error(`warning: ${w}`);
|
||||
for (const a of advisories) console.error(a);
|
||||
}
|
||||
// Advisories + warns NEVER change the exit code (BR-7).
|
||||
} catch (err) {
|
||||
if (err instanceof ScopeCliError) {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact: err.fact, consequence: err.consequence, action: err.action } }, null, 2));
|
||||
} else {
|
||||
console.error(`${err.fact}\n${err.consequence}\n${err.action}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
+833
-134
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { sessionRigOf, isHumanSeatSessionRef } from "../session-name.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
@@ -41,6 +43,139 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// OPR.0.4.3.03 — `rig queue show` body preview.
|
||||
//
|
||||
// Default `show` renders a BOUNDED body preview instead of dumping the whole
|
||||
// qitem body into the agent's context; `--full` opts back into the complete
|
||||
// body. The bound is a CODE-POINT count (delivery-set, adjustable) per
|
||||
// IMPL-SPEC §2.3-2.4.
|
||||
const SHOW_BODY_PREVIEW_MAX_CODEPOINTS = 512;
|
||||
|
||||
export interface BodyPreview {
|
||||
preview: string;
|
||||
bodyBytes: number;
|
||||
bodyTruncated: boolean;
|
||||
}
|
||||
|
||||
// Multibyte-SAFE bounded preview (IMPL-SPEC §2.3-2.4). The preview is the first
|
||||
// N CODE POINTS: `Array.from(body)` splits by code point (never a surrogate
|
||||
// pair / multibyte char), so the slice is inherently multibyte-safe and never
|
||||
// emits a partial/invalid UTF-8 sequence. `bodyTruncated` is CODE-POINT-count
|
||||
// based (codePointCount > N). `bodyBytes` is the honest TRUE total UTF-8 byte
|
||||
// length of the FULL body (never the truncated size).
|
||||
export function previewBody(
|
||||
body: string,
|
||||
maxCodePoints = SHOW_BODY_PREVIEW_MAX_CODEPOINTS
|
||||
): BodyPreview {
|
||||
const bodyBytes = Buffer.byteLength(body, "utf8");
|
||||
const codePoints = Array.from(body);
|
||||
if (codePoints.length <= maxCodePoints) {
|
||||
return { preview: body, bodyBytes, bodyTruncated: false };
|
||||
}
|
||||
return {
|
||||
preview: codePoints.slice(0, maxCodePoints).join(""),
|
||||
bodyBytes,
|
||||
bodyTruncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecordWithStringBody(v: unknown): v is Record<string, unknown> & { body: string } {
|
||||
return (
|
||||
typeof v === "object" &&
|
||||
v !== null &&
|
||||
typeof (v as Record<string, unknown>).body === "string"
|
||||
);
|
||||
}
|
||||
|
||||
// OPR.0.3.2.21.FR-4(a) — body input resolution. Three accepted shapes:
|
||||
// --body "<text>" inline (legacy; backtick-prone for raw
|
||||
// multiline content)
|
||||
// --body-file <path> read body content from a file path
|
||||
// (kills the backtick-corruption class)
|
||||
// --body-file - or --body - read body from stdin (pipeline-friendly)
|
||||
//
|
||||
// Exactly one of --body / --body-file must be provided; the resolver throws
|
||||
// a 3-part fact/consequence/action error otherwise.
|
||||
//
|
||||
// stdinReader is dependency-injected so tests can swap it without touching
|
||||
// process.stdin. Default reads UTF-8 from process.stdin until EOF.
|
||||
export interface ResolveBodyOpts {
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
}
|
||||
|
||||
export async function defaultStdinReader(): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
if (process.stdin.isTTY) {
|
||||
// No pipe is connected to stdin; resolve immediately to empty
|
||||
// rather than blocking forever waiting for data on a TTY. The
|
||||
// empty body then flows through to the daemon's content
|
||||
// validation (queue-repository owns the body contract); the CLI
|
||||
// does not error locally on empty stdin.
|
||||
resolve("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveQueueBody(
|
||||
opts: ResolveBodyOpts,
|
||||
stdinReader: () => Promise<string> = defaultStdinReader,
|
||||
): Promise<string> {
|
||||
const hasInline = opts.body !== undefined && opts.body !== "";
|
||||
const hasFile = opts.bodyFile !== undefined && opts.bodyFile !== "";
|
||||
if (hasInline && hasFile) {
|
||||
const err = new Error("--body and --body-file are mutually exclusive.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Both --body and --body-file were passed; the body source is ambiguous.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass exactly one of --body or --body-file.";
|
||||
throw err;
|
||||
}
|
||||
if (!hasInline && !hasFile) {
|
||||
const err = new Error("Missing required body input.") as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = "Neither --body nor --body-file was provided.";
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass the qitem body via --body \"<text>\" or --body-file <path> (use - for stdin).";
|
||||
throw err;
|
||||
}
|
||||
if (hasInline) {
|
||||
if (opts.body === "-") return stdinReader();
|
||||
return opts.body!;
|
||||
}
|
||||
// hasFile path
|
||||
if (opts.bodyFile === "-") return stdinReader();
|
||||
const absPath = opts.bodyFile!;
|
||||
if (!fs.existsSync(absPath)) {
|
||||
const err = new Error(`--body-file path does not exist: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path does not exist: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Check the path; pass an absolute path; or use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
const stat = fs.statSync(absPath);
|
||||
if (!stat.isFile()) {
|
||||
const err = new Error(`--body-file path is not a regular file: ${absPath}`) as Error & { fact?: string; consequence?: string; action?: string };
|
||||
err.fact = `--body-file path is not a regular file: ${absPath}`;
|
||||
err.consequence = "rig queue create did not run; daemon was not contacted.";
|
||||
err.action = "Pass a path to a readable file (not a directory, symlink-to-directory, or block device). Use --body-file - to read from stdin.";
|
||||
throw err;
|
||||
}
|
||||
return fs.readFileSync(absPath, "utf8");
|
||||
}
|
||||
|
||||
function emitBodyResolveError(err: Error & { fact?: string; consequence?: string; action?: string }, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact: err.fact ?? err.message, consequence: err.consequence ?? "", action: err.action ?? "" } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.fact ?? err.message}\n${err.consequence ?? ""}\n${err.action ?? ""}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function resolveCurrentSession(explicit: string | undefined, optionName: string): string | undefined {
|
||||
const session = explicit ?? readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
if (session) return session;
|
||||
@@ -50,6 +185,85 @@ function resolveCurrentSession(explicit: string | undefined, optionName: string)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractRigName(sessionName: string): string | undefined {
|
||||
// OPR.0.4.6.MH1 FR-8: the shared parse contract (greedy first-@ rig).
|
||||
return sessionRigOf(sessionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH3 D-3 (C3): resolve a queue DESTINATION operand + optional
|
||||
* explicit `--host` into the out-of-band request envelope (BR-1 — the session
|
||||
* string that leaves the CLI stays 2-part `member@rig`; the host rides
|
||||
* `hostId`; the 3-part string NEVER leaves the CLI edge).
|
||||
*
|
||||
* The queue parse rule (arch-ruled — deliberately DIFFERENT from the
|
||||
* interactive verbs' strip-iff-registered rule): queue destinations are
|
||||
* CANONICAL-ONLY by construction (the daemon's validateRig rejects any
|
||||
* non-canonical parse), so the strip is UNCONDITIONAL after the human-seat
|
||||
* classifier — a mistyped host dies loud with the HOST named (unknown-host)
|
||||
* instead of a misleading rig-shaped `unknown_destination_rig`:
|
||||
*
|
||||
* 1. human-seat classifier FIRST (the shipped archetype): a human-seat ref
|
||||
* is never captured. `RESERVED_HOST_IDS` (kernel/host/local) guarantees
|
||||
* no REGISTERED host can shadow the human-seat `@kernel`/`@host` family.
|
||||
* 2. fewer than two `@` → plain 2-part session, pass through untouched.
|
||||
* 3. two or more `@` → split on the LAST `@`; the trailing segment is the
|
||||
* host qualifier, stripped into `hostId`; the remainder is the
|
||||
* destination session.
|
||||
*
|
||||
* D-2 (explicit-only): queue verbs NEVER consult the persisted host
|
||||
* selection — cross-host routing happens only via `--host <id>` or the
|
||||
* host-qualified destination form. Naming BOTH with different hosts is a
|
||||
* structured ambiguity error, never a silent precedence pick.
|
||||
*/
|
||||
export type QueueHostResolution =
|
||||
| { ok: true; destination: string; hostId?: string }
|
||||
| { ok: false; error: string; message: string };
|
||||
|
||||
export function resolveQueueHostDestination(
|
||||
destination: string,
|
||||
explicitHost?: string,
|
||||
): QueueHostResolution {
|
||||
if (isHumanSeatSessionRef(destination)) {
|
||||
return { ok: true, destination, hostId: explicitHost };
|
||||
}
|
||||
const atCount = destination.split("@").length - 1;
|
||||
if (atCount < 2) {
|
||||
return { ok: true, destination, hostId: explicitHost };
|
||||
}
|
||||
const lastAt = destination.lastIndexOf("@");
|
||||
const head = destination.slice(0, lastAt);
|
||||
const tail = destination.slice(lastAt + 1);
|
||||
if (!tail) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_host_qualified_destination",
|
||||
message: `destination '${destination}' ends with an empty host segment — use member@rig@<host> (or drop the trailing '@')`,
|
||||
};
|
||||
}
|
||||
if (explicitHost !== undefined && explicitHost !== tail) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "host_qualifier_conflict",
|
||||
message: `--host ${explicitHost} conflicts with the host-qualified destination '${destination}' (host '${tail}') — name ONE host (drop the flag or the qualifier)`,
|
||||
};
|
||||
}
|
||||
return { ok: true, destination: head, hostId: tail };
|
||||
}
|
||||
|
||||
/** Emit a D-3 resolution error (local, pre-daemon) in the house 3-part style. */
|
||||
function emitHostResolutionError(res: { error: string; message: string }, json: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ error: res.error, message: res.message }));
|
||||
} else {
|
||||
console.error(res.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const QUEUE_HOST_OPTION_HELP =
|
||||
"OPR.0.4.6.MH3: route this queue write to a REGISTERED remote host (see rig host ls). EXPLICIT-ONLY — queue verbs never follow the persisted 'rig host select' selection. Equivalent to the host-qualified destination form member@rig@<host>.";
|
||||
|
||||
export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
const cmd = new Command("queue").description("Coordination L3 — owned-work queue + inbox/outbox");
|
||||
const getDeps = (): QueueDeps => depsOverride ?? {
|
||||
@@ -62,42 +276,103 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.description("Create a new qitem")
|
||||
.requiredOption("--source <session>", "Source session")
|
||||
.requiredOption("--destination <session>", "Destination session (the seat that owns the work)")
|
||||
.requiredOption("--body <text>", "Qitem body")
|
||||
.option("--body <text>", "Qitem body inline (use - to read from stdin; mutually exclusive with --body-file)")
|
||||
.option("--body-file <path>", "Read qitem body from a file path (use - for stdin; mutually exclusive with --body). Kills the backtick-shell-corruption class for multiline bodies.")
|
||||
.option("--mission <id>", "First-class mission scope; translated to a mission:<id> tag (composes with --tags)")
|
||||
.option("--slice <id>", "First-class slice scope; translated to a slice:<id> tag (composes with --tags)")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark this as a gate qitem; translated to a gate:<role> tag (role e.g. guard | spec-review | pm-lead | qa | human). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--priority <priority>", "Priority: routine | urgent | critical", "routine")
|
||||
.option("--tier <tier>", "Tier (e.g. fast, routine, deep, critical) — drives SLA")
|
||||
.option("--tags <tags>", "Comma-separated tags")
|
||||
.option("--tags <tags>", "Comma-separated tags (composes with --mission and --slice)")
|
||||
.option("--expires-at <iso>", "ISO timestamp at which the qitem expires")
|
||||
.option("--id <qitemId>", "Idempotent qitem_id (skip if not provided)")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope (must match a repo in the source rig's RigSpec.workspace.repos[])")
|
||||
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary of the work (feeds the Story node label; the agent-speak --body stays the source of truth). Warned-if-missing; pre-18 qitems exempt.")
|
||||
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: pointer to the durable artifact a human judges (e.g. a PROOF.md path). Required by the daemon when the item is human-routed; optional otherwise.")
|
||||
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
|
||||
.option("--no-nudge", "Suppress the default destination nudge (cold-queue)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: {
|
||||
source: string;
|
||||
destination: string;
|
||||
body: string;
|
||||
body?: string;
|
||||
bodyFile?: string;
|
||||
mission?: string;
|
||||
slice?: string;
|
||||
gate?: string;
|
||||
priority: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
expiresAt?: string;
|
||||
id?: string;
|
||||
targetRepo?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
host?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// OPR.0.4.6.MH3 D-3 (C3): resolve the host qualifier at the CLI edge —
|
||||
// the 3-part form never leaves the CLI; the request carries the 2-part
|
||||
// destination + the out-of-band hostId envelope (BR-1).
|
||||
const hostResolved = resolveQueueHostDestination(opts.destination, opts.host);
|
||||
if (!hostResolved.ok) {
|
||||
emitHostResolutionError(hostResolved, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// OPR.0.3.2.21.FR-4(a) — resolve body BEFORE contacting the daemon
|
||||
// so a missing/ambiguous body fails fast and locally.
|
||||
let resolvedBody: string;
|
||||
try {
|
||||
resolvedBody = await resolveQueueBody({ body: opts.body, bodyFile: opts.bodyFile });
|
||||
} catch (err) {
|
||||
emitBodyResolveError(err as Error & { fact?: string; consequence?: string; action?: string }, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.1.18 (FR-7, warn-then-require grace): a summary SHOULD accompany
|
||||
// every new qitem (it feeds the Story node + helps humans skim). Warn — to
|
||||
// stderr so --json stdout stays clean — but do NOT hard-break existing
|
||||
// callers that omit it; hard-require is a future hardening.
|
||||
if (!opts.summary) {
|
||||
process.stderr.write(
|
||||
"warning: rig queue create called without --summary. New qitems should carry a short human-readable summary; the Story node degrades to a body truncation without it. Proceeding (pre-18 callers exempt).\n"
|
||||
);
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.3.2.21.FR-4(b) — first-class --mission / --slice flags
|
||||
// translate to canonical mission:<id> / slice:<id> tags. Composes
|
||||
// with --tags (any flag-derived tags prepend; explicit --tags
|
||||
// append). De-duplicates so passing both --mission X and
|
||||
// --tags mission:X yields one mission:X tag.
|
||||
const fromTagsArg = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const fromFlags: string[] = [];
|
||||
if (opts.mission) fromFlags.push(`mission:${opts.mission}`);
|
||||
if (opts.slice) fromFlags.push(`slice:${opts.slice}`);
|
||||
// OPR.0.4.3.16 — first-class --gate <role> stamps a gate:<role> tag
|
||||
// (the queue-gate-predicate the idle-gate watchdog reads). Same
|
||||
// formalization + de-dup as --mission/--slice.
|
||||
if (opts.gate) fromFlags.push(`gate:${opts.gate}`);
|
||||
const merged = [...fromFlags, ...fromTagsArg];
|
||||
const seen = new Set<string>();
|
||||
const dedupedTags = merged.filter((t) => { if (seen.has(t)) return false; seen.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<Record<string, unknown>>("/api/queue/create", {
|
||||
qitemId: opts.id,
|
||||
sourceSession: opts.source,
|
||||
destinationSession: opts.destination,
|
||||
body: opts.body,
|
||||
destinationSession: hostResolved.destination,
|
||||
body: resolvedBody,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
expiresAt: opts.expiresAt,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
// OPR.0.4.6.MH3 FR-1: the out-of-band host envelope (omitted for
|
||||
// plain local writes — the local path stays byte-identical).
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
@@ -141,11 +416,14 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("update <qitemId>")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation)")
|
||||
.description("Mutate qitem state. state=done REQUIRES --closure-reason (one of: handed_off_to, blocked_on, denied, canceled, no-follow-on, escalation). Closure ≠ acceptance: handed_off_to records delivery to the next stage; acceptance is the next stage's verdict on its own qitem, not this closure.")
|
||||
.option("--actor <session>", "Actor session performing the transition (defaults to OPENRIG_SESSION_NAME)")
|
||||
.requiredOption("--state <state>", "New state: pending | in-progress | done | blocked | failed | denied | canceled | handed-off")
|
||||
.option("--closure-reason <reason>", "Required for state=done")
|
||||
.option("--closure-target <target>", "Required for handed_off_to, blocked_on, escalation")
|
||||
.option("--blocked-on <blocker>", "For state=blocked: the blocker (a qitem id, or a human seat for the OPR.0.4.4.19 FR-6 park — human parks require summary + evidence_ref on the item)")
|
||||
.option("--summary <text>", "OPR.0.4.4.19 FR-6: park-time summary persisted onto the item (human-seat parks only)")
|
||||
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-6: park-time durable-artifact pointer persisted onto the item (human-seat parks only)")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -153,6 +431,9 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
state: string;
|
||||
closureReason?: string;
|
||||
closureTarget?: string;
|
||||
blockedOn?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -165,12 +446,96 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
state: opts.state,
|
||||
closureReason: opts.closureReason,
|
||||
closureTarget: opts.closureTarget,
|
||||
blockedOn: opts.blockedOn,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.4.19 FR-6 — the first-class park affordance (C5 leg 1). One verb,
|
||||
// both blocker kinds: --on takes a qitem id (today's shipped blocked-on
|
||||
// usage, nothing new required) or a human-seat session (the leg-1 park —
|
||||
// summary + evidence_ref enforced by the daemon validator). A THIN client
|
||||
// of the same update write path — enforcement lives in the daemon domain
|
||||
// layer, never verb-only. The park is NON-TERMINAL: the owner keeps the
|
||||
// potato and no closure_reason is involved.
|
||||
cmd
|
||||
.command("block <qitemId>")
|
||||
.description("Park a qitem as state=blocked on a blocker (qitem id, or a human seat for a needs-human park). Non-terminal: the owner keeps the item; resolution returns to it (rig queue resolve).")
|
||||
.requiredOption("--on <blocker>", "The blocker: a qitem id, or a human-seat session (e.g. human-review@kernel) for the needs-human park")
|
||||
.option("--actor <session>", "Acting session (defaults to OPENRIG_SESSION_NAME)")
|
||||
.option("--summary <text>", "Plain-language summary of the decision owed (required for human-seat parks unless already on the item)")
|
||||
.option("--evidence-ref <path>", "Durable artifact the human judges (required for human-seat parks unless already on the item)")
|
||||
.option("--note <text>", "Transition note for the audit log")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
on: string;
|
||||
actor?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
note?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const actor = resolveCurrentSession(opts.actor, "actor");
|
||||
if (!actor) return;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/update`, {
|
||||
actorSession: actor,
|
||||
state: "blocked",
|
||||
blockedOn: opts.on,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note ?? `parked on ${opts.on}`,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.4.19 FR-7 — the resolve verb's CLI wrapper: a THIN client of the
|
||||
// ONE write path (POST /api/mission-control/action, verb=resolve). Exists
|
||||
// so proof walks and the relay session are scriptable before the Packet-2
|
||||
// surface ships; the founder's path is the surface/feed card invoking the
|
||||
// same endpoint. Resolution returns to the PARKED OWNER (blocked →
|
||||
// in-progress on the SAME item) — never a closure, never a new owner.
|
||||
cmd
|
||||
.command("resolve <qitemId>")
|
||||
.description("Resolve a leg-1 parked qitem (state=blocked on a human seat): records the decision text durably in queue_transitions, unparks blocked -> in-progress, and nudges the owner. Non-closure.")
|
||||
.requiredOption("--decision <text>", "The human's decision text (non-empty; lands in transition_note + the audit row)")
|
||||
.option("--actor <session>", "Resolving session (defaults to OPENRIG_SESSION_NAME)")
|
||||
.option("--bearer <token>", "Operator bearer token for the mission-control write gate (or set OPENRIG_AUTH_BEARER_TOKEN; loopback daemons without a configured bearer need none)")
|
||||
.option("--no-notify", "Skip the best-effort owner nudge (the unpark still commits)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
decision: string;
|
||||
actor?: string;
|
||||
bearer?: string;
|
||||
notify?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const actor = resolveCurrentSession(opts.actor, "actor");
|
||||
if (!actor) return;
|
||||
const deps = getDeps();
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(
|
||||
"/api/mission-control/action",
|
||||
{
|
||||
verb: "resolve",
|
||||
qitemId,
|
||||
actorSession: actor,
|
||||
decision: opts.decision,
|
||||
notify: opts.notify,
|
||||
},
|
||||
bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined,
|
||||
);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("handoff <qitemId>")
|
||||
.description("Transactional handoff: closes source as handed-off + creates new qitem owned by --to")
|
||||
@@ -181,7 +546,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
|
||||
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
|
||||
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -192,25 +561,54 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
gate?: string;
|
||||
targetRepo?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
host?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const from = resolveCurrentSession(opts.from, "from");
|
||||
if (!from) return;
|
||||
// OPR.0.4.6.MH3 D-3 (C3): the host qualifier resolves at the CLI edge
|
||||
// (applies to the DESTINATION --to only; --from stays as given).
|
||||
const hostResolved = resolveQueueHostDestination(opts.to, opts.host);
|
||||
if (!hostResolved.ok) {
|
||||
emitHostResolutionError(hostResolved, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.1.18 (FR-7): warn-on-author — a handoff authors a NEW qitem, so
|
||||
// it should carry its own summary. Warn to stderr; do not hard-break.
|
||||
if (!opts.summary) {
|
||||
process.stderr.write(
|
||||
"warning: rig queue handoff called without --summary. The new qitem should carry a short human-readable summary; the Story node degrades to a body truncation without it. Proceeding.\n"
|
||||
);
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.4.3.16 — --gate <role> stamps a gate:<role> tag (composes with
|
||||
// --tags, de-duplicated). Guard code-review + spec-review handoffs use
|
||||
// this so the idle-gate watchdog's predicate has a producer.
|
||||
const explicitTags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const gateTags = opts.gate ? [`gate:${opts.gate}`] : [];
|
||||
const mergedTags = [...gateTags, ...explicitTags];
|
||||
const seenTags = new Set<string>();
|
||||
const dedupedTags = mergedTags.filter((t) => { if (seenTags.has(t)) return false; seenTags.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff`, {
|
||||
fromSession: from,
|
||||
toSession: opts.to,
|
||||
toSession: hostResolved.destination,
|
||||
body: opts.body,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
@@ -228,7 +626,11 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
.option("--priority <priority>", "Override priority for the new qitem")
|
||||
.option("--tier <tier>", "Override tier for the new qitem")
|
||||
.option("--tags <tags>", "Comma-separated tags for the new qitem")
|
||||
.option("--gate <role>", "OPR.0.4.3.16: mark the new qitem as gate work; translated to a gate:<role> tag (e.g. guard | spec-review). The idle-gate watchdog reads this predicate. Composes with --tags.")
|
||||
.option("--target-repo <name>", "PL-007: typed repo scope for the new qitem")
|
||||
.option("--summary <text>", "OPR.0.4.1.18: short human-readable 1-2 sentence summary for the new qitem (feeds the Story node; --body stays source of truth). Warned-if-missing.")
|
||||
.option("--evidence-ref <path>", "OPR.0.4.4.19 FR-5: durable-artifact pointer for the new qitem. Required by the daemon when the new qitem is human-routed; optional otherwise.")
|
||||
.option("--host <id>", QUEUE_HOST_OPTION_HELP)
|
||||
.option("--no-nudge", "Suppress the default nudge to the new destination")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: {
|
||||
@@ -239,25 +641,53 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
priority?: string;
|
||||
tier?: string;
|
||||
tags?: string;
|
||||
gate?: string;
|
||||
targetRepo?: string;
|
||||
summary?: string;
|
||||
evidenceRef?: string;
|
||||
host?: string;
|
||||
nudge?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const from = resolveCurrentSession(opts.from, "from");
|
||||
if (!from) return;
|
||||
// OPR.0.4.6.MH3 D-3 (C3): same edge resolution as handoff.
|
||||
const hostResolved = resolveQueueHostDestination(opts.to, opts.host);
|
||||
if (!hostResolved.ok) {
|
||||
emitHostResolutionError(hostResolved, opts.json ?? false);
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.1.18 (FR-7): warn-on-author — a handoff authors a NEW qitem, so
|
||||
// it should carry its own summary. Warn to stderr; do not hard-break.
|
||||
if (!opts.summary) {
|
||||
process.stderr.write(
|
||||
"warning: rig queue handoff called without --summary. The new qitem should carry a short human-readable summary; the Story node degrades to a body truncation without it. Proceeding.\n"
|
||||
);
|
||||
}
|
||||
const deps = getDeps();
|
||||
const tags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
||||
// OPR.0.4.3.16 — --gate <role> stamps a gate:<role> tag (composes with
|
||||
// --tags, de-duplicated). Guard code-review + spec-review handoffs use
|
||||
// this so the idle-gate watchdog's predicate has a producer.
|
||||
const explicitTags = opts.tags ? opts.tags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
const gateTags = opts.gate ? [`gate:${opts.gate}`] : [];
|
||||
const mergedTags = [...gateTags, ...explicitTags];
|
||||
const seenTags = new Set<string>();
|
||||
const dedupedTags = mergedTags.filter((t) => { if (seenTags.has(t)) return false; seenTags.add(t); return true; });
|
||||
const tags = dedupedTags.length > 0 ? dedupedTags : undefined;
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/queue/${encodeURIComponent(qitemId)}/handoff-and-complete`, {
|
||||
fromSession: from,
|
||||
toSession: opts.to,
|
||||
toSession: hostResolved.destination,
|
||||
body: opts.body,
|
||||
summary: opts.summary,
|
||||
evidenceRef: opts.evidenceRef,
|
||||
transitionNote: opts.note,
|
||||
priority: opts.priority,
|
||||
tier: opts.tier,
|
||||
tags,
|
||||
targetRepo: opts.targetRepo,
|
||||
nudge: opts.nudge,
|
||||
...(hostResolved.hostId !== undefined ? { hostId: hostResolved.hostId } : {}),
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
@@ -302,13 +732,31 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("show <qitemId>")
|
||||
.description("Show one qitem")
|
||||
.description("Show one qitem (bounded body preview by default; --full for the complete body)")
|
||||
.option("--full", "Show the complete body (no preview truncation) + chain fields")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (qitemId: string, opts: { json?: boolean }) => {
|
||||
.action(async (qitemId: string, opts: { full?: boolean; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/${encodeURIComponent(qitemId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const json = opts.json ?? false;
|
||||
const item = res.data;
|
||||
// --full is a pure passthrough of today's COMPLETE item shape (the
|
||||
// compatibility contract — body byte-identical to pre-0.4.3.03). Also
|
||||
// passthrough on error responses / non-object payloads, where there is
|
||||
// no string body to preview.
|
||||
if (opts.full || res.status >= 400 || !isRecordWithStringBody(item)) {
|
||||
printResult(json, item, res.status);
|
||||
return;
|
||||
}
|
||||
const { preview, bodyBytes, bodyTruncated } = previewBody(item.body);
|
||||
// Append-only additions: keep `body` in place (now the preview) and add
|
||||
// the honest size + truncation flag. Object otherwise unchanged.
|
||||
const transformed = { ...item, body: preview, bodyBytes, bodyTruncated };
|
||||
printResult(json, transformed, res.status);
|
||||
if (!json && bodyTruncated) {
|
||||
console.log(`… (truncated — ${bodyBytes} bytes total; --full for complete body)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,14 +774,52 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
|
||||
cmd
|
||||
.command("list")
|
||||
.description("List qitems with filters")
|
||||
.description("List qitems (default: active + compact + current-rig; like 'docker ps')")
|
||||
.option("-a, --all", "Include closed/done history (like 'docker ps -a')")
|
||||
.option("-A, --all-rigs", "Cross-rig breadth (like 'kubectl get --all-namespaces')")
|
||||
.option("--full", "Show complete per-item fields (body, chain-of-record)")
|
||||
.option("--mine", "Scope to items where you are destination or source")
|
||||
.option("-o <format>", "Output format: json")
|
||||
.option("--destination <session>", "Filter by destination session")
|
||||
.option("--source <session>", "Filter by source session")
|
||||
.option("--state <state>", "Filter by state (comma-separated for multiple)")
|
||||
.option("--target-repo <name>", "PL-007: filter qitems by target_repo (exact match)")
|
||||
.option("--limit <n>", "Result limit", "100")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--json", "JSON output (compact; use --full --json for complete fields)")
|
||||
.addHelpText("after", `
|
||||
Default: active items in your current rig, compact summary (like 'docker ps').
|
||||
Current rig is derived from OPENRIG_SESSION_NAME's @<rig> suffix.
|
||||
|
||||
Four orthogonal axes (docker/kubectl pattern):
|
||||
-a, --all Include closed/done history (state axis)
|
||||
-A, --all-rigs Cross-rig breadth (scope axis)
|
||||
--full Include body + chain-of-record (field axis)
|
||||
-o json JSON output (compact; --full -o json for complete)
|
||||
|
||||
Active states: pending, in-progress, blocked.
|
||||
History (-a adds): done, canceled, handed-off, failed, denied.
|
||||
Use --state <states> to select specific states explicitly.
|
||||
|
||||
Depth: use 'rig queue show <qitemId>' for a single item in full.
|
||||
Frontier source: 'rig queue list' is the default status surface.
|
||||
|
||||
Examples:
|
||||
rig queue list Active items in your rig (compact)
|
||||
rig queue list -a Include closed history in your rig
|
||||
rig queue list -A Active items across ALL rigs
|
||||
rig queue list -a -A Everything across all rigs
|
||||
rig queue list --full Active items with body/chain
|
||||
rig queue list -o json Compact JSON (same as --json)
|
||||
rig queue list --full -o json Complete JSON (with body/chain)
|
||||
rig queue list --mine Items where you are source or destination
|
||||
rig queue list --state pending Only pending items in your rig
|
||||
rig queue list --full --all --all-rigs Full firehose (pre-0.4.0 default)`)
|
||||
.action(async (opts: {
|
||||
all?: boolean;
|
||||
allRigs?: boolean;
|
||||
full?: boolean;
|
||||
mine?: boolean;
|
||||
o?: string;
|
||||
destination?: string;
|
||||
source?: string;
|
||||
state?: string;
|
||||
@@ -343,6 +829,24 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
const sessionName = readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
const hasExplicitScope = !!(opts.destination || opts.source);
|
||||
|
||||
if (opts.mine && sessionName) {
|
||||
params.set("as", sessionName);
|
||||
} else if (!opts.allRigs && !hasExplicitScope) {
|
||||
const rigName = sessionName ? extractRigName(sessionName) : undefined;
|
||||
if (rigName) {
|
||||
params.set("rig", rigName);
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts.all) {
|
||||
params.set("activeOnly", "1");
|
||||
}
|
||||
if (!opts.full) {
|
||||
params.set("compact", "1");
|
||||
}
|
||||
if (opts.destination) params.set("destinationSession", opts.destination);
|
||||
if (opts.source) params.set("sourceSession", opts.source);
|
||||
if (opts.state) params.set("state", opts.state);
|
||||
@@ -350,7 +854,8 @@ export function queueCommand(depsOverride?: QueueDeps): Command {
|
||||
if (opts.limit) params.set("limit", opts.limit);
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/queue/list?${params.toString()}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const useJson = opts.json || opts.o === "json";
|
||||
printResult(useJson, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// OPR.0.3.4.3 — rig reconcile-session: adopt a LIVE, hand-resumed canonical
|
||||
// session back into its persisted node WITHOUT launch/relaunch/kill/startup
|
||||
// replay/resume menus/compaction, and WITHOUT writing any input into the pane.
|
||||
// The repair for "I manually resumed the seat; make the daemon see it again."
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface ReconcileResponse {
|
||||
ok: boolean;
|
||||
result?: {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
nodeId: string;
|
||||
logicalId: string;
|
||||
sessionName: string;
|
||||
sessionId: string;
|
||||
projectionDrift: string[];
|
||||
continuity: string;
|
||||
};
|
||||
code?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function reconcileSessionCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("reconcile-session")
|
||||
.description("Adopt a live, hand-resumed session back into its persisted node (no launch, no input)");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig) of the LIVE session to adopt")
|
||||
.option("--rig <rigId>", "Disambiguate: target rig id (requires --node)")
|
||||
.option("--node <logicalId>", "Disambiguate: target node logical id (requires --rig)")
|
||||
.option("--no-launch", "Never launch/relaunch (the only mode this command has; accepted for explicitness)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig reconcile-session dev-impl@my-rig --no-launch
|
||||
rig reconcile-session dev-impl@my-rig --rig <rig-id> --node dev.impl --no-launch
|
||||
rig reconcile-session dev-impl@my-rig --json
|
||||
|
||||
Use after manually resuming a seat (claude --resume / codex resume) inside its
|
||||
canonical tmux session: the daemon still shows the seat down. Reconcile binds
|
||||
the live process to its OWN persisted node (same node id - no re-key) and
|
||||
updates the projection so rig ps / topology / send / capture / queue routing
|
||||
work again. It NEVER launches, relaunches, kills, replays startup, presses
|
||||
resume menus, compacts, or types into the pane. Anything it could not prove is
|
||||
reported as projection drift; conversation continuity is never claimed.`)
|
||||
.action(async (session: string, opts: { rig?: string; node?: string; launch?: boolean; json?: boolean }) => {
|
||||
if ((opts.rig && !opts.node) || (!opts.rig && opts.node)) {
|
||||
console.error("--rig and --node must be provided together.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (opts.rig) body["rigId"] = opts.rig;
|
||||
if (opts.node) body["logicalId"] = opts.node;
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<ReconcileResponse>(
|
||||
`/api/sessions/${encodeURIComponent(session)}/reconcile`,
|
||||
body,
|
||||
{ headers: terminalAuthHeaders() },
|
||||
);
|
||||
const data = res.data;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
if (res.status >= 400 || !data.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400 || !data.ok) {
|
||||
console.error(data.message ?? data.error ?? `Reconcile failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = data.result!;
|
||||
console.log(`Reconciled ${r.sessionName} into rig ${r.rigName}`);
|
||||
console.log(` Node: ${r.logicalId} (node id unchanged - no relaunch, no input sent)`);
|
||||
if (r.projectionDrift.length > 0) {
|
||||
console.log(" Projection drift (unproven metadata):");
|
||||
for (const d of r.projectionDrift) console.log(` - ${d}`);
|
||||
} else {
|
||||
console.log(" Projection drift: none detected");
|
||||
}
|
||||
console.log(` Conversation continuity: ${r.continuity} (reconcile reconnects the projection; it does not verify the conversation)`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,8 +1,94 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { getOpenRigHome, isFixtureScopedHome, readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
|
||||
// The daemon.port default (DEFAULT_CONFIG in config-store.ts). A fixture
|
||||
// target "diverges" when it is resolved from file/env AND is not this value.
|
||||
const DEFAULT_DAEMON_PORT = 7433;
|
||||
|
||||
interface FixtureLeak {
|
||||
fixtureHome: string;
|
||||
/** The divergent daemon target (port number, or the leaked OPENRIG_URL). */
|
||||
target: string | number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.12 — Detect a leaked fixture-scoped home with a divergent daemon
|
||||
* target. Composes the PATH-ONLY `isFixtureScopedHome` predicate (from
|
||||
* openrig-compat, no ConfigStore dependency) with a ConfigStore
|
||||
* divergent-target confirmation HERE (restore-check is downstream of both, so
|
||||
* no import cycle). Returns null unless BOTH hold, so a legit non-default port
|
||||
* under the real home never trips the guard.
|
||||
*/
|
||||
function detectFixtureLeak(): FixtureLeak | null {
|
||||
const fixtureHome = getOpenRigHome();
|
||||
if (!isFixtureScopedHome(fixtureHome)) return null;
|
||||
|
||||
// A leaked OPENRIG_URL points the status probe at the fixture directly.
|
||||
const leakedUrl = readOpenRigEnv("OPENRIG_URL", "RIGGED_URL");
|
||||
if (leakedUrl) {
|
||||
return { fixtureHome, target: leakedUrl, source: "env (OPENRIG_URL)" };
|
||||
}
|
||||
|
||||
// Otherwise confirm a divergent daemon.port resolved from the fixture's
|
||||
// config.json / OPENRIG_PORT (source !== "default" AND value !== 7433).
|
||||
let resolved: { value: string | number | boolean; source: string } | undefined;
|
||||
try {
|
||||
resolved = new ConfigStore().resolveWithSource("daemon.port");
|
||||
} catch {
|
||||
resolved = undefined;
|
||||
}
|
||||
if (resolved && resolved.source !== "default" && resolved.value !== DEFAULT_DAEMON_PORT) {
|
||||
return { fixtureHome, target: resolved.value as number, source: resolved.source };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the honest, NON-MUTATING both-homes result for a detected fixture
|
||||
* leak — instead of the blocking `rig daemon start` / `scope:"host"` packet.
|
||||
* Names both the fixture home (+ its divergent target/source) and the real
|
||||
* default home (+ default port), with a non-mutating remediation.
|
||||
*/
|
||||
function fixtureLeakResult(leak: FixtureLeak): RestoreCheckResult {
|
||||
const realHome = join(homedir(), ".openrig");
|
||||
const evidence =
|
||||
`restore-check ran under a fixture-scoped OPENRIG_HOME (${leak.fixtureHome}) whose daemon ` +
|
||||
`target diverges (${leak.source} = ${leak.target}). The real default home is ${realHome} ` +
|
||||
`(daemon.port ${DEFAULT_DAEMON_PORT}). The real kernel was NOT probed — this is a leaked ` +
|
||||
`test fixture, not a host-down condition.`;
|
||||
const remediation =
|
||||
`Unset the leaked OPENRIG_HOME / OPENRIG_PORT / OPENRIG_URL (or re-target to ${realHome}), ` +
|
||||
`then re-run: rig restore-check`;
|
||||
return localRestoreResult({
|
||||
verdict: "unknown",
|
||||
checks: [{ check: "home.fixture-scoped", status: "yellow", evidence, remediation }],
|
||||
repairPacket: [{
|
||||
step: 1,
|
||||
command: remediation,
|
||||
rationale: "Fixture-scoped OPENRIG_HOME leaked into a real-kernel recovery command",
|
||||
safe: true, // non-mutating: unsetting env / re-targeting, not starting a daemon
|
||||
blocking: false,
|
||||
}],
|
||||
recovery: {
|
||||
status: "unknown",
|
||||
summary:
|
||||
`Recovery state was not inspected: this invocation used a fixture-scoped home ` +
|
||||
`(${leak.fixtureHome}), not the real default home (${realHome}). Clear the leaked ` +
|
||||
`OpenRig home/target env and re-run to inspect the real kernel.`,
|
||||
actions: [],
|
||||
blocked: [],
|
||||
unknown: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RestoreCheckDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
clientFactory: (url: string) => DaemonClient;
|
||||
@@ -90,6 +176,14 @@ interface RestoreCheckResult {
|
||||
hostInfra: HostInfraAssertion;
|
||||
recovery: RecoveryPlan;
|
||||
counts: { red: number; yellow: number; green: number };
|
||||
// OPR.0.4.0.29 FR-8 — ready-confidence breakdown by the 5 real-enum classes.
|
||||
classCounts?: {
|
||||
ready: number;
|
||||
ready_with_caveats: number;
|
||||
not_ready: number;
|
||||
attention_required: number;
|
||||
unknown: number;
|
||||
};
|
||||
checks: CheckEntry[];
|
||||
repairPacket: RepairStep[] | null;
|
||||
}
|
||||
@@ -125,14 +219,53 @@ Exit codes:
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--json", "JSON output (compact summary by default; use --full --json for complete detail)")
|
||||
.option("--full", "Show complete per-seat detail (today's default)")
|
||||
.option("--ready", "Show ready-seat detail in addition to not-ready")
|
||||
.option("--rig <name>", "Check one rig only")
|
||||
.option("--no-queue", "Skip queue file checks")
|
||||
.option("--no-hooks", "Skip hook checks")
|
||||
.action(async (opts: { json?: boolean; rig?: string; queue?: boolean; hooks?: boolean }) => {
|
||||
.addHelpText("after", `
|
||||
Default: compact summary with per-rig readiness counts and NOT-READY seats only.
|
||||
Ready seats are counted but their detail is omitted to save tokens.
|
||||
|
||||
Use --full for today's complete per-seat detail (all checks, repair packet, etc.).
|
||||
Use --ready to include ready-seat detail alongside not-ready.
|
||||
Use --full --json for the complete JSON firehose.
|
||||
|
||||
Readiness classes: ready, ready_with_caveats, not_ready, attention_required, unknown.
|
||||
|
||||
Examples:
|
||||
rig restore-check Compact summary (counts + not-ready)
|
||||
rig restore-check --json Compact JSON summary
|
||||
rig restore-check --full Complete per-seat detail
|
||||
rig restore-check --full --json Complete JSON (the firehose)
|
||||
rig restore-check --ready Include ready seats in output
|
||||
rig restore-check --rig my-rig Check one rig only`)
|
||||
.action(async (opts: { json?: boolean; full?: boolean; ready?: boolean; rig?: string; queue?: boolean; hooks?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
// OPR.0.4.3.12 — fixture-home isolation guard. A leaked fixture
|
||||
// OPENRIG_HOME with a divergent daemon target makes getDaemonStatus
|
||||
// probe the WRONG port (connection-refused) and report the real
|
||||
// kernel as down. Detect that precise leak and respond honestly
|
||||
// (both homes named, non-mutating) instead of the blocking
|
||||
// `rig daemon start` / `scope:"host"` host-repair packet. Fires ONLY
|
||||
// under a detected fixture-scoped home + divergent target, so the
|
||||
// genuine daemon-down contract below is preserved for real homes.
|
||||
const leak = detectFixtureLeak();
|
||||
if (leak) {
|
||||
const result = fixtureLeakResult(leak);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printHuman(result);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
// Daemon down — produce a structured not_restorable result
|
||||
const result = localRestoreResult({
|
||||
verdict: "not_restorable",
|
||||
@@ -174,6 +307,11 @@ Exit codes:
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
// Compact by default; --full opts out. --ready stays COMPACT and adds
|
||||
// ready-seat detail (ready=1) IN ADDITION to the not-ready rows — it is
|
||||
// NOT the full firehose (OPR.0.4.0.29 FR-2 / forward-fix corrective).
|
||||
if (!opts.full) params.set("compact", "1");
|
||||
if (opts.ready) params.set("ready", "1");
|
||||
if (opts.rig) params.set("rig", opts.rig);
|
||||
if (opts.queue === false) params.set("noQueue", "true");
|
||||
if (opts.hooks === false) params.set("noHooks", "true");
|
||||
@@ -222,8 +360,10 @@ Exit codes:
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
} else if (opts.full) {
|
||||
printHuman(result);
|
||||
} else {
|
||||
printCompact(result);
|
||||
}
|
||||
|
||||
// Exit codes: 0 for restorable/restorable_with_caveats; 1 for not_restorable; 2 for unknown
|
||||
@@ -301,6 +441,64 @@ function printHuman(result: RestoreCheckResult): void {
|
||||
}
|
||||
}
|
||||
|
||||
function printCompact(result: RestoreCheckResult): void {
|
||||
const verdictLabel = result.verdict.replace(/_/g, " ").toUpperCase();
|
||||
console.log(`RESTORE CHECK — ${verdictLabel}`);
|
||||
console.log(`${result.counts.green} green | ${result.counts.yellow} yellow | ${result.counts.red} red`);
|
||||
const readinessLabel = result.readiness.status.replace(/_/g, " ");
|
||||
console.log(`READINESS: ${readinessLabel}`);
|
||||
// FR-8: ready-confidence breakdown by class (the 5 real-enum classes).
|
||||
if (result.classCounts) {
|
||||
const cc = result.classCounts;
|
||||
console.log(
|
||||
`CLASSES: ${cc.ready} ready | ${cc.ready_with_caveats} ready_with_caveats | ` +
|
||||
`${cc.not_ready} not_ready | ${cc.attention_required} attention_required | ${cc.unknown} unknown`
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rigs && result.rigs.length > 0) {
|
||||
console.log();
|
||||
console.log(`${"RIG".padEnd(24)} ${"STATUS".padEnd(18)} ${"READY".padEnd(9)} ${"BLOCKED".padEnd(8)} CAVEATS`);
|
||||
for (const rig of result.rigs) {
|
||||
console.log(
|
||||
`${rig.rigName.slice(0, 24).padEnd(24)} ${rig.status.padEnd(18)} ` +
|
||||
`${`${rig.runningReadyNodes}/${rig.expectedNodes}`.padEnd(9)} ${String(rig.blockedNodes).padEnd(8)} ${rig.caveatNodes}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The route already scopes `checks` to the right set: not-ready only by
|
||||
// default, or all seats (including ready detail) when --ready (ready=1).
|
||||
// Render exactly what it returned — do not re-filter here.
|
||||
if (result.checks.length > 0) {
|
||||
console.log();
|
||||
for (const check of result.checks) {
|
||||
const sym = STATUS_SYMBOLS[check.status] ?? "?";
|
||||
console.log(` ${sym} ${check.check}: ${check.evidence}`);
|
||||
if (check.remediation) {
|
||||
console.log(` Fix: ${check.remediation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.recovery && result.recovery.status !== "not_needed") {
|
||||
console.log();
|
||||
console.log(`RECOVERY: ${result.recovery.status.replace(/_/g, " ").toUpperCase()}`);
|
||||
console.log(result.recovery.summary);
|
||||
for (const action of result.recovery.actions) {
|
||||
console.log(` Action: ${action.command}`);
|
||||
}
|
||||
for (const issue of result.recovery.blocked) {
|
||||
console.log(` Blocked: ${issue.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.counts.red > 0) {
|
||||
console.log();
|
||||
console.log("Use --full for complete detail. Use --ready to include ready-seat info.");
|
||||
}
|
||||
}
|
||||
|
||||
function localRestoreResult(input: {
|
||||
verdict: RestoreCheckResult["verdict"];
|
||||
checks: CheckEntry[];
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { detectRuntime } from "../restore-packet/runtime-detect.js";
|
||||
import { parseSessionName } from "../session-name.js";
|
||||
import { parseCodexJsonl } from "../restore-packet/codex-jsonl-parser.js";
|
||||
import { parseClaudeTranscript } from "../restore-packet/claude-transcript-parser.js";
|
||||
import { writePacket, type WritePacketOptions } from "../restore-packet/packet-writer.js";
|
||||
@@ -104,8 +105,18 @@ function deriveProvenance(
|
||||
if (typeof opts.sourceRigOverride === "string" && opts.sourceRigOverride.length > 0) {
|
||||
sourceRig = opts.sourceRigOverride;
|
||||
} else {
|
||||
const m = /^[^@]+@([^@]+)$/.exec(sourceSessionId);
|
||||
const captured = m?.[1];
|
||||
// OPR.0.4.6.MH1 FR-8 + rev1-r2 B2: the shared parse contract, with
|
||||
// this site's OWN strictness on top. The contract's greedy rig is the
|
||||
// QUEUE GATE's shape — where an in-band host fails the registry
|
||||
// lookup loudly. THIS site persists provenance with no lookup, so a
|
||||
// multi-@ rig would be silently recorded (BR-1 keeps host out of the
|
||||
// session string; the honest-provenance policy forbids silent
|
||||
// acceptance). Canonical member@rig with a PLAIN rig derives; anything
|
||||
// else throws the explicit override error below.
|
||||
const parsedSource = parseSessionName(sourceSessionId);
|
||||
const captured = parsedSource.kind === "canonical" && !parsedSource.rig.includes("@")
|
||||
? parsedSource.rig
|
||||
: undefined;
|
||||
if (!captured) {
|
||||
throw new Error(
|
||||
`could not derive source_rig from session id '${sourceSessionId}' (does not match <seat>@<rig> shape); supply --source-rig-override <rig> explicitly. Silent fallback to source_rig:"unknown" is not allowed (M2c-CLI R2 honest-provenance policy).`,
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI: operator-context-mode
|
||||
// surface. Pairs with the daemon's typed-primitive store.
|
||||
//
|
||||
// Subcommands:
|
||||
// rig policy set <mode> [--scope ...] [--qualifier ...] [--<field> ...]
|
||||
// [--evidence ...] [--confirm]
|
||||
// → restate-and-confirm; PUT only when --confirm
|
||||
// rig policy show → list all bindings (defaults to JSON-when-piped)
|
||||
// rig policy effective → resolve effective for a read context
|
||||
// rig policy cite → emit the citation line per convention §Component 5
|
||||
// rig policy unset <scope> [qualifier]
|
||||
// → DELETE one binding (operator-only)
|
||||
// rig policy defaults → recommended 6×7 + per-mode scope + stale rule
|
||||
//
|
||||
// HG-4 / HG-7 anchored here:
|
||||
// - `set` never silently applies. Without `--confirm` it echoes the
|
||||
// proposed binding (mode + scope + key settings) and exits with
|
||||
// `exit 2` so scripts cannot accidentally apply. `--confirm` is the
|
||||
// explicit operator action.
|
||||
// - Mode invocation: bare word OR `mode:<mode>` prefix; both normalize
|
||||
// via disambiguateModeInvocation().
|
||||
// - Citation format: short-prose per convention §Component 5 +
|
||||
// §Citation Rules.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface RigPolicyDeps extends StatusDeps {}
|
||||
|
||||
// Mirror of daemon enums + structure. We keep these inline so the CLI
|
||||
// doesn't grow a build-time dep on the daemon package; the validator
|
||||
// at the daemon edge is the source of truth, and the CLI sends the
|
||||
// record through unchanged.
|
||||
const MODES = ["sleep", "desk", "mobile", "away", "focus", "debug"] as const;
|
||||
type Mode = (typeof MODES)[number];
|
||||
|
||||
const SCOPES = ["global_host", "rig", "workstream", "qitem"] as const;
|
||||
type Scope = (typeof SCOPES)[number];
|
||||
|
||||
// Convention §Component 4 — bare-word disambiguation. A bare reserved
|
||||
// mode word, or `mode:<word>`, becomes an invocation; embedded-in-
|
||||
// sentence does not (the latter is a CLI input error here since the
|
||||
// CLI takes a single-positional <mode> argument).
|
||||
function disambiguateModeInvocation(raw: string): Mode | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const stripped = trimmed.startsWith("mode:") ? trimmed.slice("mode:".length).trim() : trimmed;
|
||||
if (stripped.split(/\s+/).length !== 1) return null;
|
||||
return (MODES as readonly string[]).includes(stripped) ? (stripped as Mode) : null;
|
||||
}
|
||||
|
||||
interface RecommendedDefaultsResponse {
|
||||
recommendedModeDefaults: Record<Mode, {
|
||||
autonomy_scope: string;
|
||||
heartbeat_cadence: string;
|
||||
inspection_depth: string;
|
||||
update_detail: string;
|
||||
escalation_threshold: string;
|
||||
concurrency_limit: string;
|
||||
permission_prompt_posture: string;
|
||||
}>;
|
||||
recommendedDefaultScope: Record<Mode, Scope>;
|
||||
defaultStaleRule: string;
|
||||
}
|
||||
|
||||
interface BindingResponse {
|
||||
binding: {
|
||||
id: string;
|
||||
mode: Mode;
|
||||
record: Record<string, string>;
|
||||
qualifier: string | null;
|
||||
setAt: string;
|
||||
setBy: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
bindings: Array<BindingResponse["binding"]>;
|
||||
}
|
||||
|
||||
interface EffectiveResponse {
|
||||
effective: { binding: BindingResponse["binding"]; resolvedScope: Scope } | null;
|
||||
posture: "known" | "unknown_posture";
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: RigPolicyDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function emitRecord(label: string, record: Record<string, string>): void {
|
||||
console.log(label);
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the daemon route's parseScopeAndQualifier semantics on the
|
||||
* CLI side. The CLI is the operator's authoring surface; the convention's
|
||||
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
|
||||
*
|
||||
* Reject:
|
||||
* - explicit qualifier with global_host (BLOCKING re-verify finding from
|
||||
* guard qitem-20260518044650): operators who type
|
||||
* `--scope global_host --qualifier <id>` get an error and the daemon
|
||||
* is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
* - missing qualifier for any non-global scope.
|
||||
*/
|
||||
type NormalizedScope = { ok: true; qualifier: string | null } | { ok: false; message: string };
|
||||
function normalizeScopeQualifier(scope: Scope, explicitQualifier: string | undefined): NormalizedScope {
|
||||
if (scope === "global_host") {
|
||||
if (explicitQualifier !== undefined && explicitQualifier !== "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Global-host bindings cannot carry a qualifier (got "${explicitQualifier}"). Either drop --qualifier OR change --scope to rig / workstream / qitem.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: null };
|
||||
}
|
||||
if (explicitQualifier === undefined || explicitQualifier === "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Scope ${scope} requires a qualifier (rigId / workstreamId / qitemId). Pass --qualifier <id>.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: explicitQualifier };
|
||||
}
|
||||
|
||||
function formatCitation(b: BindingResponse["binding"]): string {
|
||||
const qualifierPart = b.qualifier ? `:${b.qualifier}` : "";
|
||||
const scope = b.record.scope as Scope;
|
||||
return `Operating in \`${b.mode}\` mode at \`${scope}${qualifierPart}\` per operator (set_at ${b.setAt})`;
|
||||
}
|
||||
|
||||
export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
|
||||
const cmd = new Command("policy").description(
|
||||
"Slice 09 — operator-context-mode bindings (sleep/desk/mobile/away/focus/debug × global_host/rig/workstream/qitem).",
|
||||
);
|
||||
|
||||
const getDeps = (): RigPolicyDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// -- set ---------------------------------------------------------------
|
||||
cmd
|
||||
.command("set <mode>")
|
||||
.description(
|
||||
"Propose a mode binding. Without --confirm, restates the proposed binding and exits 2 (no daemon write). With --confirm, sets it.",
|
||||
)
|
||||
.option("--scope <scope>", `Scope: ${SCOPES.join(" | ")} (default: per-mode recommendation)`)
|
||||
.option("--qualifier <id>", "Required for rig / workstream / qitem scopes; omit for global_host")
|
||||
.option("--autonomy-scope <v>")
|
||||
.option("--heartbeat-cadence <v>")
|
||||
.option("--inspection-depth <v>")
|
||||
.option("--update-detail <v>")
|
||||
.option("--escalation-threshold <v>")
|
||||
.option("--concurrency-limit <v>")
|
||||
.option("--permission-prompt-posture <v>", "One of: normal | batch_for_human | do_not_prompt_unless_blocked (auto_accept is FORBIDDEN by convention).")
|
||||
.option("--expiry-or-stale-rule <v>")
|
||||
.option("--evidence <citation>", "Free-text citation (operator message id, file pointer, chatroom topic, etc.).")
|
||||
.option("--confirm", "Confirm the proposed binding and apply it. Without this flag, set is restate-only.")
|
||||
.option("--bearer <token>", "Operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN env).")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
modeArg: string,
|
||||
opts: {
|
||||
scope?: string;
|
||||
qualifier?: string;
|
||||
autonomyScope?: string;
|
||||
heartbeatCadence?: string;
|
||||
inspectionDepth?: string;
|
||||
updateDetail?: string;
|
||||
escalationThreshold?: string;
|
||||
concurrencyLimit?: string;
|
||||
permissionPromptPosture?: string;
|
||||
expiryOrStaleRule?: string;
|
||||
evidence?: string;
|
||||
confirm?: boolean;
|
||||
bearer?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const mode = disambiguateModeInvocation(modeArg);
|
||||
if (!mode) {
|
||||
console.error(`Unknown mode '${modeArg}'. Allowed: ${MODES.join(", ")} (or 'mode:<name>' prefix).`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
|
||||
// explicit, validate scope + qualifier LOCALLY before touching
|
||||
// the daemon. Otherwise an invalid operator invocation
|
||||
// (e.g. `--scope global_host --qualifier X`) surfaces as
|
||||
// "Daemon not running" when the daemon is down, instead of the
|
||||
// proper local input error. Convention's scope rule applies at
|
||||
// operator invocation, not at daemon HTTP parsing.
|
||||
let explicitScope: Scope | null = null;
|
||||
if (opts.scope !== undefined) {
|
||||
if (!(SCOPES as readonly string[]).includes(opts.scope)) {
|
||||
console.error(`Unknown scope '${opts.scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
explicitScope = opts.scope as Scope;
|
||||
const preflight = normalizeScopeQualifier(explicitScope, opts.qualifier);
|
||||
if (!preflight.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: preflight.message }, null, 2));
|
||||
} else {
|
||||
console.error(preflight.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const defaultsRes = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (defaultsRes.status >= 400) {
|
||||
console.error(JSON.stringify(defaultsRes.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const defaults = defaultsRes.data;
|
||||
const scope = explicitScope ?? defaults.recommendedDefaultScope[mode];
|
||||
// Defensive — the defaults map is from the daemon, so its scope
|
||||
// values are trusted; keep the assertion shape lean.
|
||||
if (!(SCOPES as readonly string[]).includes(scope)) {
|
||||
console.error(`Unknown scope '${scope}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// Re-run normalization for the implicit-default-scope case.
|
||||
// (Explicit scope was already preflighted above; this branch is
|
||||
// a no-op for explicit-scope inputs but is needed when the
|
||||
// default scope is e.g. `qitem`/`workstream` and the operator
|
||||
// forgot --qualifier.)
|
||||
const normalized = normalizeScopeQualifier(scope, opts.qualifier);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const perMode = defaults.recommendedModeDefaults[mode];
|
||||
// Component 3 — exactly the 10 settings fields. `mode` is the
|
||||
// binding's identity (Component 2) and lives at the top level
|
||||
// of the PUT body, NOT inside the record.
|
||||
const record = {
|
||||
autonomy_scope: opts.autonomyScope ?? perMode.autonomy_scope,
|
||||
heartbeat_cadence: opts.heartbeatCadence ?? perMode.heartbeat_cadence,
|
||||
inspection_depth: opts.inspectionDepth ?? perMode.inspection_depth,
|
||||
update_detail: opts.updateDetail ?? perMode.update_detail,
|
||||
escalation_threshold: opts.escalationThreshold ?? perMode.escalation_threshold,
|
||||
concurrency_limit: opts.concurrencyLimit ?? perMode.concurrency_limit,
|
||||
permission_prompt_posture: opts.permissionPromptPosture ?? perMode.permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule: opts.expiryOrStaleRule ?? defaults.defaultStaleRule,
|
||||
evidence_citation: opts.evidence ?? `operator confirmed ${mode}`,
|
||||
};
|
||||
|
||||
if (!opts.confirm) {
|
||||
// Restate-and-confirm (HG-7). No daemon write.
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, proposed: { mode, scope, qualifier, record }, confirm_required: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Proposed binding (restate-and-confirm — NOT applied):`);
|
||||
console.log(` mode: ${mode}`);
|
||||
console.log(` scope: ${scope}${qualifier ? ` (${qualifier})` : ""}`);
|
||||
emitRecord(` record:`, record);
|
||||
console.log(`\nRe-run with --confirm to apply.`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const res = await client.put<BindingResponse | { error: string; errors?: string[] }>(
|
||||
`/api/rig-policy/bindings/${scope}${qualifierPath}`,
|
||||
{ mode, record },
|
||||
{ headers },
|
||||
);
|
||||
if (res.status === 401) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "unauthorized", hint: "Daemon requires operator bearer. Pass --bearer <token> or set OPENRIG_AUTH_BEARER_TOKEN." }, null, 2));
|
||||
} else {
|
||||
console.error("Unauthorized. Daemon requires an operator bearer token.");
|
||||
console.error("Pass --bearer <token> or export OPENRIG_AUTH_BEARER_TOKEN before re-running.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(`Error setting binding (HTTP ${res.status}):`);
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body = res.data as BindingResponse;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, binding: body.binding }, null, 2));
|
||||
} else {
|
||||
console.log(`Set: ${body.binding.id}`);
|
||||
console.log(` mode: ${body.binding.mode}`);
|
||||
emitRecord(` record:`, body.binding.record);
|
||||
console.log(` set_by: ${body.binding.setBy}`);
|
||||
console.log(` set_at: ${body.binding.setAt}`);
|
||||
console.log(`\n${formatCitation(body.binding)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- show --------------------------------------------------------------
|
||||
cmd
|
||||
.command("show")
|
||||
.description("List all operator-context-mode bindings.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<ListResponse>("/api/rig-policy/bindings");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.bindings.length === 0) {
|
||||
console.log("No operator-context-mode bindings set.");
|
||||
return;
|
||||
}
|
||||
for (const b of res.data.bindings) {
|
||||
console.log(`${b.id} [${b.mode}] set_at=${b.setAt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- effective ---------------------------------------------------------
|
||||
cmd
|
||||
.command("effective")
|
||||
.description("Resolve the effective mode for a (rig, workstream, qitem) read context. Q6 unknown_posture surfaced when no binding matches.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.posture === "unknown_posture" || !res.data.effective) {
|
||||
console.log("unknown_posture: no binding matches this read context.");
|
||||
if (res.data.hint) console.log(res.data.hint);
|
||||
return;
|
||||
}
|
||||
const b = res.data.effective.binding;
|
||||
console.log(`Effective: ${b.mode} (resolved scope: ${res.data.effective.resolvedScope})`);
|
||||
emitRecord(` record:`, b.record);
|
||||
console.log(` set_by: ${b.setBy}`);
|
||||
console.log(` set_at: ${b.setAt}`);
|
||||
});
|
||||
});
|
||||
|
||||
// -- cite --------------------------------------------------------------
|
||||
cmd
|
||||
.command("cite")
|
||||
.description("Emit a citation line for the effective mode at a read context. Per convention §Citation Rules.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400 || !res.data.effective) {
|
||||
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
|
||||
return;
|
||||
}
|
||||
console.log(formatCitation(res.data.effective.binding));
|
||||
});
|
||||
});
|
||||
|
||||
// -- unset -------------------------------------------------------------
|
||||
cmd
|
||||
.command("unset <scope> [qualifier]")
|
||||
.description("Delete one binding (operator-only). Scope: global_host | rig | workstream | qitem.")
|
||||
.option("--bearer <token>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
scopeArg: string,
|
||||
qualifierArg: string | undefined,
|
||||
opts: { bearer?: string; json?: boolean },
|
||||
) => {
|
||||
if (!(SCOPES as readonly string[]).includes(scopeArg)) {
|
||||
console.error(`Unknown scope '${scopeArg}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const scope = scopeArg as Scope;
|
||||
// BLOCKING re-verify (qitem-20260518044650): never silently drop
|
||||
// an explicit operator-supplied qualifier on global_host on unset.
|
||||
// Same hazard class as set; same shared helper.
|
||||
const normalized = normalizeScopeQualifier(scope, qualifierArg);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const res = await client.delete<{ removed: boolean }>(`/api/rig-policy/bindings/${scope}${qualifierPath}`, { headers });
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, removed: res.data.removed }, null, 2));
|
||||
} else {
|
||||
console.log(res.data.removed ? `Removed ${scope}${qualifier ? `:${qualifier}` : ""}` : "Nothing to remove.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- defaults ----------------------------------------------------------
|
||||
cmd
|
||||
.command("defaults")
|
||||
.description("Print the recommended per-mode 6×7 + default-scope + stale rule.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const mode of MODES) {
|
||||
console.log(`${mode} (default scope: ${res.data.recommendedDefaultScope[mode]})`);
|
||||
emitRecord(" ", res.data.recommendedModeDefaults[mode] as unknown as Record<string, string>);
|
||||
}
|
||||
console.log(`\ndefault stale rule: ${res.data.defaultStaleRule}`);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-export for unit-testability of pure helpers.
|
||||
export const __test__ = { disambiguateModeInvocation, formatCitation, normalizeScopeQualifier };
|
||||
+1055
-13
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,19 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export type SeatDeps = StatusDeps;
|
||||
|
||||
/** Read all of STDIN to EOF as a UTF-8 string. Used by set-resume-token so the
|
||||
* credential never appears in argv / shell history / ps. Injectable for tests. */
|
||||
async function defaultReadStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
|
||||
interface SeatStatusResponse {
|
||||
seat_ref: string;
|
||||
rig_id: string;
|
||||
@@ -32,6 +40,7 @@ interface SeatStatusError {
|
||||
error?: string;
|
||||
guidance?: string;
|
||||
matches?: Array<{ rig_name: string; logical_id: string; current_occupant: string | null }>;
|
||||
clients?: Array<{ name: string; session: string }>;
|
||||
}
|
||||
|
||||
interface SeatHandoverPlan {
|
||||
@@ -81,10 +90,10 @@ interface SeatHandoverMutationResult {
|
||||
continuityTransferred: false;
|
||||
seat: SeatHandoverPlan["seat"];
|
||||
source: {
|
||||
mode: "discovered";
|
||||
ref: string;
|
||||
mode: "fresh" | "rebuild" | "fork" | "discovered";
|
||||
ref: string | null;
|
||||
raw: string;
|
||||
defaulted: false;
|
||||
defaulted: boolean;
|
||||
};
|
||||
reason: string;
|
||||
operator: string | null;
|
||||
@@ -103,11 +112,21 @@ interface SeatHandoverMutationResult {
|
||||
eventSeq: number;
|
||||
sideEffects: {
|
||||
departingSessionKilled: false;
|
||||
startupContextDelivered: false;
|
||||
startupContextDelivered: boolean;
|
||||
provenanceRecordWritten: false;
|
||||
};
|
||||
}
|
||||
|
||||
interface SeatSwitchClientResponse {
|
||||
seat_ref: string;
|
||||
session: string;
|
||||
window: number;
|
||||
target: string;
|
||||
client: string;
|
||||
mutated: false;
|
||||
retargeted: true;
|
||||
}
|
||||
|
||||
function display(value: string | null | undefined, empty = "none"): string {
|
||||
return value ?? empty;
|
||||
}
|
||||
@@ -148,14 +167,28 @@ function printHumanHandoverResult(result: SeatHandoverMutationResult): void {
|
||||
console.log(`Seat handover complete: ${result.seat.ref}`);
|
||||
console.log(`Rig: ${result.seat.rigName}`);
|
||||
console.log(`Logical ID: ${result.seat.logicalId}`);
|
||||
console.log(`Source: discovered:${result.discovery.id}`);
|
||||
console.log(`Source: ${result.source.mode}${result.source.ref ? `:${result.source.ref}` : ""}`);
|
||||
console.log(`Reason: ${result.reason}`);
|
||||
console.log(`Operator: ${display(result.operator)}`);
|
||||
console.log(`Previous occupant: ${result.previousOccupant}`);
|
||||
console.log(`Current occupant: ${result.currentOccupant}`);
|
||||
console.log(`Handover result: ${display(result.currentStatus.handoverResult)}`);
|
||||
console.log("Seat binding and inventory provenance were updated.");
|
||||
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
if (result.sideEffects.startupContextDelivered) {
|
||||
// fresh handover: the captured restore packet was delivered to the launched
|
||||
// live successor agent.
|
||||
console.log("The captured startup context (restore packet) was delivered to the successor.");
|
||||
console.log("No conversation continuity, provenance markdown, or session stop was performed.");
|
||||
} else {
|
||||
// discovered handover: the operator-prepared successor is already live, so no
|
||||
// separate context delivery is performed (v0 live modes are fresh + discovered).
|
||||
console.log("No conversation continuity, startup context delivery, provenance markdown, or session stop was performed.");
|
||||
}
|
||||
}
|
||||
|
||||
function printHumanSwitchClient(r: SeatSwitchClientResponse): void {
|
||||
console.log(`Retargeted client ${r.client} -> ${r.target} (seat ${r.seat_ref})`);
|
||||
console.log("View only: no routing, queue address, transcript, or seat binding was changed.");
|
||||
}
|
||||
|
||||
function printSeatError(error: SeatStatusError, fallback: string): void {
|
||||
@@ -168,15 +201,22 @@ function printSeatError(error: SeatStatusError, fallback: string): void {
|
||||
console.error(` ${match.logical_id}@${match.rig_name} (${display(match.current_occupant)})`);
|
||||
}
|
||||
}
|
||||
if (error.clients?.length) {
|
||||
console.error("Attached clients:");
|
||||
for (const cl of error.clients) {
|
||||
console.error(` ${cl.name} (viewing ${display(cl.session)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function seatCommand(depsOverride?: SeatDeps): Command {
|
||||
export function seatCommand(depsOverride?: SeatDeps & { readStdin?: () => Promise<string> }): Command {
|
||||
const cmd = new Command("seat")
|
||||
.description("Inspect OpenRig seat observability state");
|
||||
const getDeps = (): SeatDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
const readStdin = depsOverride?.readStdin ?? defaultReadStdin;
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
@@ -219,7 +259,7 @@ Examples:
|
||||
cmd
|
||||
.command("handover")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--source <source>", "Source: discovered:<id> for live MVP; fresh, rebuild, or fork:<id> for dry-run planning")
|
||||
.option("--source <source>", "Source. Live: fresh (launches a new agent) or discovered:<id> (operator-prepared). fork:<id> and rebuild are dry-run-plan only in v0.")
|
||||
.option("--reason <reason>", "Why the handover is happening")
|
||||
.option("--operator <address>", "Operator initiating the handover")
|
||||
.option("--dry-run", "Plan the handover without changing topology")
|
||||
@@ -231,21 +271,45 @@ Examples:
|
||||
rig seat handover spec-writer@openrig-pm --source rebuild --reason context-wall --dry-run --json
|
||||
rig seat handover spec-writer@openrig-pm --source fork:0b0165d7 --reason successor-test --operator orch-lead@openrig-pm --dry-run
|
||||
rig seat handover spec-writer@openrig-pm --source discovered:01H... --reason mvp-proof --json`)
|
||||
.action(async (seat: string, opts: { source?: string; reason?: string; operator?: string; dryRun?: boolean; json?: boolean }) => {
|
||||
if (!opts.reason?.trim()) {
|
||||
const error: SeatStatusError = {
|
||||
ok: false,
|
||||
code: "missing_reason",
|
||||
message: "Missing required option: --reason <reason>",
|
||||
guidance: "Provide an explicit handover reason, for example: --reason context-wall",
|
||||
};
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
} else {
|
||||
printSeatError(error, "Missing required option: --reason <reason>");
|
||||
.action((seat: string, opts: HandoverActionOpts) => runSeatHandover(seat, opts, getDeps()));
|
||||
|
||||
// OPR.0.4.3.26 — seat-recovery VIEW retarget. Points an already-attached tmux
|
||||
// client at the seat's canonical session/window. VIEW-ONLY: never mutates
|
||||
// routing/queue/transcript/identity, never launches an agent, never kills a
|
||||
// session. Composes AFTER reconcile-session / handover as a distinct step.
|
||||
cmd
|
||||
.command("switch-client")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--to-window <n>", "Target window index (default: 0, the canonical seat window)")
|
||||
.option("--client <id>", "Target a specific attached tmux client (required when multiple are attached)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Retarget an attached tmux client's view to the seat's canonical session (view-only)")
|
||||
.addHelpText("after", `
|
||||
Retargets what a client SEES; it never changes OpenRig routing, queue addresses,
|
||||
transcripts, or seat bindings. Repair routing first with rig reconcile-session /
|
||||
rig seat handover, THEN retarget the view. Examples:
|
||||
rig seat switch-client dev-impl@my-rig
|
||||
rig seat switch-client dev-impl@my-rig --to-window 1
|
||||
rig seat switch-client dev-impl@my-rig --client /dev/ttys003 --json`)
|
||||
.action(async (seat: string, opts: { toWindow?: string; client?: string; json?: boolean }) => {
|
||||
let toWindow: number | undefined;
|
||||
if (opts.toWindow != null) {
|
||||
const n = Number(opts.toWindow);
|
||||
if (!Number.isInteger(n) || n < 0) {
|
||||
const error: SeatStatusError = {
|
||||
ok: false,
|
||||
code: "invalid_window",
|
||||
message: `Invalid --to-window "${opts.toWindow}": must be a non-negative integer.`,
|
||||
};
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
} else {
|
||||
printSeatError(error, "Invalid --to-window");
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
toWindow = n;
|
||||
}
|
||||
|
||||
const deps = getDeps();
|
||||
@@ -257,12 +321,10 @@ Examples:
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
});
|
||||
const res = await client.post<SeatSwitchClientResponse | SeatStatusError>(
|
||||
`/api/seat/switch-client/${encodeURIComponent(seat)}`,
|
||||
{ client: opts.client, toWindow },
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
@@ -271,18 +333,205 @@ Examples:
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
printSeatError(res.data as SeatStatusError, `Seat handover failed (HTTP ${res.status})`);
|
||||
printSeatError(res.data as SeatStatusError, `Seat switch-client failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data as SeatHandoverPlan | SeatHandoverMutationResult;
|
||||
if (data.dryRun) {
|
||||
printHumanHandoverPlan(data);
|
||||
} else {
|
||||
printHumanHandoverResult(data);
|
||||
printHumanSwitchClient(res.data as SeatSwitchClientResponse);
|
||||
});
|
||||
|
||||
// OPR.0.3.4.10 — clear stuck attention_required / failed startup_status.
|
||||
cmd
|
||||
.command("clear-attention")
|
||||
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
|
||||
.option("--reason <text>", "Operator attestation override (skip evidence gate)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Clear stuck attention_required startup status with evidence or operator attestation")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig seat clear-attention dev-impl@my-rig
|
||||
rig seat clear-attention dev-impl@my-rig --reason "founder re-authed, confirmed live"
|
||||
rig seat clear-attention dev-impl@my-rig --json
|
||||
`)
|
||||
.action(async (session: string, opts: { reason?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
console.error("Daemon not running.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/sessions/${encodeURIComponent(session)}/clear-attention`,
|
||||
opts.reason ? { reason: opts.reason } : {},
|
||||
{ headers: terminalAuthHeaders() },
|
||||
);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
const detail = res.data["detail"] as string | undefined;
|
||||
console.error(`${code ?? "error"}: ${detail ?? String(res.data["error"] ?? "unknown")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const clearedBy = res.data["clearedBy"] as string | undefined;
|
||||
const from = res.data["from"] as string | undefined;
|
||||
console.log(`Cleared ${session}: ${from} -> ready (${clearedBy})`);
|
||||
});
|
||||
|
||||
// OPR.0.4.0.22 — set a managed seat's durable resume token (attested + audited).
|
||||
// The token is read from STDIN ONLY (never a positional argv, which would leak
|
||||
// via shell history + argv/ps) and is NEVER echoed back.
|
||||
cmd
|
||||
.command("set-resume-token")
|
||||
.argument("<session>", "Canonical session name (e.g. dev-impl@my-rig)")
|
||||
.option("--token-stdin", "Read the resume token from STDIN (the only supported input path)")
|
||||
.requiredOption("--reason <text>", "Operator attestation recorded in the append-only audit event")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Set a managed seat's durable resume token (token read from stdin; attested + audited)")
|
||||
.addHelpText("after", `
|
||||
The token is read from STDIN only (never an argument). Examples:
|
||||
printf '%s' "$RESUME_TOKEN" | rig seat set-resume-token dev-impl@my-rig --token-stdin --reason "founder re-authed"
|
||||
pbpaste | rig seat set-resume-token dev-qa@my-rig --token-stdin --reason "manual codex thread id" --json
|
||||
`)
|
||||
.action(async (session: string, opts: { tokenStdin?: boolean; reason: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
if (!opts.tokenStdin) {
|
||||
console.error("set-resume-token requires --token-stdin: the token is read from stdin, never passed as an argument (it would leak via shell history / ps). Pipe it in, e.g. printf '%s' \"$TOKEN\" | rig seat set-resume-token <session> --token-stdin --reason \"...\".");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const token = (await readStdin()).trim();
|
||||
if (!token) {
|
||||
console.error("No resume token received on stdin.");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
console.error("Daemon not running.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/sessions/${encodeURIComponent(session)}/resume-token`,
|
||||
{ token, reason: opts.reason },
|
||||
{ headers: terminalAuthHeaders() },
|
||||
);
|
||||
// The token is NEVER echoed in either output mode (the daemon response is
|
||||
// already redacted).
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`error: ${String(res.data["message"] ?? res.data["error"] ?? "unknown")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`Set resume token for ${session}: ${String(res.data["resumeType"] ?? "")} (provenance: operator). Token redacted.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
interface HandoverActionOpts {
|
||||
source?: string;
|
||||
reason?: string;
|
||||
operator?: string;
|
||||
dryRun?: boolean;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
/** Shared handover action for both `rig seat handover` and the top-level
|
||||
* `rig handover` verb (OPR.0.4.3.04). Posts to the same daemon route. */
|
||||
export async function runSeatHandover(seat: string, opts: HandoverActionOpts, deps: SeatDeps): Promise<void> {
|
||||
if (!opts.reason?.trim()) {
|
||||
const error: SeatStatusError = {
|
||||
ok: false,
|
||||
code: "missing_reason",
|
||||
message: "Missing required option: --reason <reason>",
|
||||
guidance: "Provide an explicit handover reason, for example: --reason context-wall",
|
||||
};
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(error, null, 2));
|
||||
} else {
|
||||
printSeatError(error, "Missing required option: --reason <reason>");
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const daemon = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (daemon.state !== "running" || daemon.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(daemon));
|
||||
const res = await client.post<SeatHandoverPlan | SeatHandoverMutationResult | SeatStatusError>(`/api/seat/handover/${encodeURIComponent(seat)}`, {
|
||||
source: opts.source,
|
||||
reason: opts.reason,
|
||||
operator: opts.operator,
|
||||
dryRun: opts.dryRun === true,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
printSeatError(res.data as SeatStatusError, `Seat handover failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data as SeatHandoverPlan | SeatHandoverMutationResult;
|
||||
if (data.dryRun) {
|
||||
printHumanHandoverPlan(data);
|
||||
} else {
|
||||
printHumanHandoverResult(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.04 — top-level `rig handover <seat>` verb: the operator-facing
|
||||
* surface for the full-cycle handover composer. Same route + behavior as
|
||||
* `rig seat handover`; hoisted to top-level for discoverability.
|
||||
*/
|
||||
export function handoverCommand(depsOverride?: SeatDeps): Command {
|
||||
const getDeps = (): SeatDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
return new Command("handover")
|
||||
.argument("<seat>", "Canonical session name or logical seat ref")
|
||||
.option("--source <source>", "Successor source. Live: fresh (default, launches a new agent) or discovered:<id> (operator-prepared). fork:<id> and rebuild are dry-run-plan only in v0.")
|
||||
.option("--reason <reason>", "Why the handover is happening")
|
||||
.option("--operator <address>", "Operator initiating the handover")
|
||||
.option("--dry-run", "Plan the handover without changing topology")
|
||||
.option("--json", "JSON output for agents")
|
||||
.description("Hand a seat to a successor: create -> deliver context -> verify continuity -> rebind")
|
||||
.addHelpText("after", `
|
||||
v0 live handover supports --source fresh and --source discovered:<id>.
|
||||
fork and rebuild are rejected for a live handover (planning only) until their
|
||||
follow-ons ship; a fork/rebuild handover is never silently completed.
|
||||
|
||||
Examples:
|
||||
rig handover spec-writer@openrig-pm --reason context-wall --dry-run
|
||||
rig handover spec-writer@openrig-pm --source fresh --reason context-wall
|
||||
rig handover spec-writer@openrig-pm --source fork:0b0165d7 --reason plan-only --dry-run
|
||||
rig handover spec-writer@openrig-pm --source discovered:01H... --reason mvp-proof --json`)
|
||||
.action((seat: string, opts: HandoverActionOpts) => runSeatHandover(seat, opts, getDeps()));
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { loadHostRegistry, resolveHost, hostDisplayTarget, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
|
||||
const WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS = 5_000;
|
||||
@@ -42,6 +45,16 @@ function resolveSenderSession(): string | undefined {
|
||||
return readOpenRigEnv("OPENRIG_SESSION_NAME", "RIGGED_SESSION_NAME");
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.30 — Commander collector for `--to`: accepts BOTH a comma-list
|
||||
* (`--to a,b`) and repetition (`--to a --to b`), accumulating into one array.
|
||||
* Blank entries are dropped so a trailing comma is harmless.
|
||||
*/
|
||||
function collectSessions(value: string, previous: string[]): string[] {
|
||||
const parts = value.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
return previous.concat(parts);
|
||||
}
|
||||
|
||||
export interface SendDeps extends StatusDeps {
|
||||
/**
|
||||
* Cross-host hooks. Both default to the production loaders/executors; tests
|
||||
@@ -64,32 +77,74 @@ export function sendCommand(depsOverride?: SendDeps): Command {
|
||||
};
|
||||
|
||||
cmd
|
||||
.argument("<session>", "Target session name (e.g. dev-impl@my-rig)")
|
||||
.argument("<text>", "Message text to send")
|
||||
// OPR.0.4.3.30 — both positionals are optional so the message can stand alone with a
|
||||
// targeting flag (`rig send --pod x "text"`). Disambiguated in the action: with a
|
||||
// targeting flag the FIRST positional IS the message; without one it's `<session> <text>`.
|
||||
.argument("[session]", "Target session name for a single-seat send (e.g. dev-impl@my-rig)")
|
||||
.argument("[text]", "Message text to send")
|
||||
.option("--to <sessions>", "Multi-recipient: comma-list or repeated (--to a,b or --to a --to b)", collectSessions, [] as string[])
|
||||
.option("--pod <name>", "Send to every seat in a pod (fan-out, per-recipient results)")
|
||||
.option("--rig <name>", "Send to every seat in a rig (fan-out, per-recipient results)")
|
||||
.option("--verify", "Verify pane only delivery by checking content after send")
|
||||
.option("--force", "Send even if target pane appears mid-task")
|
||||
.option("--force", "Back-compat no-op: a mid-task/busy pane already sends-with-advisory by default; --force never bypasses the interactive-prompt/permission guard")
|
||||
.option("--wait-for-idle <seconds>", "Wait until the target is explicitly idle before sending")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--raw", "Send exact text/keystrokes without the From/To messaging envelope (still guarded against interactive prompts)")
|
||||
.option("--dangerously-interact", "DANGEROUS: deliberately drive an interactive prompt/permission block (implies --raw; requires --reason). The ONLY override of the prompt/permission guard.")
|
||||
.option("--reason <text>", "Why the prompt is being driven (required with --dangerously-interact; recorded in the audit log)")
|
||||
.option("--host <id>", "Send on a remote host declared in ~/.openrig/hosts.yaml (ssh hosts shell out; http hosts go CLI-direct to the remote daemon)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig send dev-impl@my-rig "Context update: QA approved. Proceed."
|
||||
rig send dev-impl@my-rig "message" --verify
|
||||
rig send --to dev-impl@my-rig,dev-qa@my-rig "message to two seats"
|
||||
rig send --pod dev "message to the whole dev pod"
|
||||
rig send --rig my-rig "message to the whole rig"
|
||||
rig send dev-impl@my-rig "safe proof prompt" --wait-for-idle 30 --verify
|
||||
rig send dev-impl@my-rig "Stop and read the spec." --force
|
||||
rig send dev-impl@my-rig "message" --json
|
||||
rig send --host vm-claude-test dev-impl@my-rig "remote message" --verify
|
||||
rig send dev-impl@my-rig@vps-b "host-qualified target sugar (suffix must be a registered host id)"
|
||||
|
||||
Targeting: a bare seat (single send), OR one of --to / --pod / --rig (fan-out).
|
||||
Fan-out reports per-recipient results + an "N/M delivered" summary; one recipient's
|
||||
guard refusal does NOT block the others. Each recipient gets its own From/To envelope.
|
||||
|
||||
The two-step send pattern (paste text, wait, submit Enter) is handled
|
||||
automatically. Use --wait-for-idle to send only after explicit idle evidence;
|
||||
it fails closed on attention prompts, unknown activity, or timeout. Use --verify
|
||||
to confirm the message appeared in the pane only; it is not agent acknowledgement.
|
||||
Use --force to override mid-task safety checks without wait mode.
|
||||
automatically. By default a send is REFUSED only on POSITIVE evidence the target
|
||||
is at an interactive prompt or permission block (so a message can never
|
||||
select/approve another agent's prompt). When the target's activity CANNOT be
|
||||
determined (unknown / missing / stale telemetry) the send PROCEEDS with an
|
||||
advisory note — telemetry is advisory, not authority over whether agents can
|
||||
communicate. Use --wait-for-idle to send only after explicit idle evidence. Use
|
||||
--verify to confirm the message appeared in the pane only; it is not agent
|
||||
acknowledgement.
|
||||
|
||||
--host runs the same command on a remote host declared in ~/.openrig/hosts.yaml
|
||||
via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
'Verified: yes/no' line is what counts and is surfaced verbatim.`)
|
||||
.action(async (session: string, text: string, opts: { verify?: boolean; force?: boolean; waitForIdle?: string; host?: string; json?: boolean }) => {
|
||||
A mid-task/busy target now sends-with-advisory by default (busy is not a block);
|
||||
--force is a back-compat no-op and never bypasses the interactive-prompt/permission
|
||||
guard. Use --raw to send exact text/keystrokes
|
||||
without the From/To envelope (e.g. a slash command); it is still guarded. Use
|
||||
--dangerously-interact --reason "<why>" to DELIBERATELY drive a prompt (select an
|
||||
option, approve a permission, send /compact to a blocked pane) — the only override
|
||||
of the prompt guard; it implies --raw and is audit-logged.
|
||||
|
||||
--host sends on a remote host declared in ~/.openrig/hosts.yaml. The host
|
||||
entry's transport decides the path: ssh hosts run the same command via
|
||||
single-hop ssh (SSH success is NOT verify success: the remote rig's
|
||||
'Verified: yes/no' line is what counts and is surfaced verbatim); http hosts
|
||||
(e.g. pair-registered) go CLI-direct to the remote daemon's send route — the
|
||||
result and verify verdict are the REMOTE's, verbatim. A target of the form
|
||||
agent@rig@host is sugar for --host when the suffix is a REGISTERED host id
|
||||
(explicit --host > target sugar > persisted selection; a conflict between
|
||||
--host and the sugar is an error).`)
|
||||
.action(async (session: string | undefined, text: string | undefined, opts: { to?: string[]; pod?: string; rig?: string; verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; host?: string; json?: boolean }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly. OPR.0.4.6.MH4 §4: the raw flag is kept
|
||||
// so the single-seat target sugar can slot BETWEEN explicit and
|
||||
// selection (explicit > sugar > selection).
|
||||
const explicitHost = opts.host;
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const waitForIdleMs = parseWaitForIdleMs(opts.waitForIdle);
|
||||
if (opts.force && waitForIdleMs !== undefined) {
|
||||
console.error("--wait-for-idle cannot be combined with --force");
|
||||
@@ -101,12 +156,84 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.1.10 — the danger override requires a reason (for the audit) and cannot compose with
|
||||
// wait mode. Reject locally before contacting the daemon.
|
||||
if (opts.dangerouslyInteract && (!opts.reason || opts.reason.trim().length === 0)) {
|
||||
console.error("--dangerously-interact requires --reason \"<why>\" (recorded in the audit log)");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.dangerouslyInteract && waitForIdleMs !== undefined) {
|
||||
console.error("--dangerously-interact cannot be combined with --wait-for-idle");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// OPR.0.4.3.30 — targeting-mode resolution. Exactly one of: a bare seat, --to, --pod, --rig.
|
||||
const toList = opts.to && opts.to.length > 0 ? opts.to : undefined;
|
||||
const fanModes = [toList ? "to" : null, opts.pod ? "pod" : null, opts.rig ? "rig" : null].filter(Boolean);
|
||||
if (fanModes.length > 1) {
|
||||
console.error("Choose exactly ONE target: a seat, --to, --pod, or --rig (not several).");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const isFanOut = fanModes.length === 1;
|
||||
|
||||
const deps = getDeps();
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
if (isFanOut) {
|
||||
// With a targeting flag the FIRST positional IS the message; a second positional (or a
|
||||
// bare seat name) means the caller mixed a single-seat and a fan-out target — reject.
|
||||
if (text !== undefined) {
|
||||
console.error("A bare seat name cannot be combined with --to/--pod/--rig. Provide only the message.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const message = session;
|
||||
if (message === undefined || message.length === 0) {
|
||||
console.error("Provide a message to send.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.host) {
|
||||
console.error("--host (cross-host) supports single-seat sends only; --to/--pod/--rig are local.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (waitForIdleMs !== undefined) {
|
||||
console.error("--wait-for-idle is not supported with a multi/pod/rig target (cumulative wait risks a client timeout). Send single-seat, or drop --wait-for-idle.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await runFanOutSend({ toList, pod: opts.pod, rig: opts.rig, message, opts, deps });
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Single-seat path (byte-identical to pre-0.4.3.30) ---
|
||||
if (session === undefined || text === undefined) {
|
||||
console.error("Usage: rig send <session> <text> (or --to/--pod/--rig <message> for fan-out)");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// OPR.0.4.6.MH4 §4 — the `agent@rig@host` target sugar (single-seat
|
||||
// only; the fan-out positional is message text). Suffix must match a
|
||||
// REGISTERED host id, else the target passes through unchanged and
|
||||
// the hint rides any later failure. Precedence: explicit --host >
|
||||
// sugar > persisted selection (already folded into opts.host above).
|
||||
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
|
||||
if (!targetResolution.ok) {
|
||||
console.error(targetResolution.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
session = targetResolution.target;
|
||||
const crossHostHint = targetResolution.hint;
|
||||
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side; ssh shell-out or the MH-4 http branch; daemon untouched) ---
|
||||
if (opts.host) {
|
||||
await runCrossHostSend(opts.host, session, text, opts, deps);
|
||||
await runCrossHostSend(opts.host, session, text, opts, deps, waitForIdleMs, crossHostHint);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,10 +246,14 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const wrappedText = wrapSendBody(resolveSenderSession(), session, text);
|
||||
const senderSession = resolveSenderSession();
|
||||
// --raw (and --dangerously-interact, which implies it) send EXACT text with no messaging envelope.
|
||||
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
|
||||
const outboundText = raw ? text : wrapSendBody(senderSession, session, text);
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/send", {
|
||||
session, text: wrappedText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
}, waitForIdleRequestOptions(waitForIdleMs));
|
||||
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
}, transportRequestOptions(waitForIdleMs));
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -133,14 +264,33 @@ via single-hop ssh. SSH success is NOT verify success: the remote rig's
|
||||
if (res.status >= 400) {
|
||||
const error = res.data["error"] as string | undefined;
|
||||
console.error(error ?? `Send failed (HTTP ${res.status})`);
|
||||
// MH-4 §4 loud-failure hint: the target was 3-part-shaped but its
|
||||
// suffix matched no registered host — name the near-miss.
|
||||
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sent to ${session}`);
|
||||
// OPR.0.4.3.28 correction — an `unknown`-telemetry send now PROCEEDS with a non-blocking
|
||||
// advisory (was a fail-closed refusal). Surface it on the human output, not only in --json.
|
||||
const advisory = res.data["warning"] as string | undefined;
|
||||
if (advisory) {
|
||||
console.log(`Advisory: ${advisory}`);
|
||||
}
|
||||
if (opts.verify) {
|
||||
// Legacy line preserved verbatim (existing scripts grep `Verified:`);
|
||||
// the Delivery line below carries the honest three-outcome vocabulary
|
||||
// (OPR.99.0.6.3): `Verified: no` alone collapsed a landed-but-redraw-
|
||||
// raced send into the same line as a miss.
|
||||
const verified = res.data["verified"] as boolean | undefined;
|
||||
console.log(`Verified: ${verified ? "yes" : "no"}`);
|
||||
const outcome = res.data["outcome"] as string | undefined;
|
||||
if (outcome === "delivered") {
|
||||
console.log("Delivery: delivered (message landed; render confirmed)");
|
||||
} else if (outcome === "rendered-unconfirmed") {
|
||||
console.log(`Delivery: rendered-unconfirmed (landed; pane re-render not confirmed - confirm with: rig capture ${session})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,8 +301,10 @@ async function runCrossHostSend(
|
||||
hostId: string,
|
||||
session: string,
|
||||
text: string,
|
||||
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; json?: boolean },
|
||||
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
|
||||
deps: SendDeps,
|
||||
waitForIdleMs?: number,
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const runner = deps.crossHostRun ?? runCrossHostCommand;
|
||||
@@ -164,37 +316,197 @@ async function runCrossHostSend(
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", resolved.error, opts.json);
|
||||
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// OPR.0.4.6.MH4 — the http transport branch: an http-registered host (the
|
||||
// founder's `pair` front door) takes the CLI-direct path to the remote
|
||||
// daemon's shipped /api/transport/send. The ssh path below stays
|
||||
// byte-verbatim for ssh hosts (transport is dictated by the host entry —
|
||||
// ssh XOR http, never a fallback).
|
||||
if (host.transport === "http") {
|
||||
await runHttpHostSend(host, session, text, opts, deps, waitForIdleMs, hint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconstruct argv for the remote `rig send` invocation. Order is positional
|
||||
// first so the remote Commander parses it the same way local does.
|
||||
const argv: string[] = ["rig", "send", session, text];
|
||||
if (opts.verify) argv.push("--verify");
|
||||
if (opts.force) argv.push("--force");
|
||||
if (opts.waitForIdle !== undefined) argv.push("--wait-for-idle", opts.waitForIdle);
|
||||
if (opts.raw) argv.push("--raw");
|
||||
if (opts.dangerouslyInteract) argv.push("--dangerously-interact");
|
||||
if (opts.reason !== undefined) argv.push("--reason", opts.reason);
|
||||
if (opts.json) argv.push("--json");
|
||||
|
||||
const result = await runner(host, argv);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: host.target },
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host) },
|
||||
result,
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, opts.json);
|
||||
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, opts.json);
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 C1 — cross-host send over http, CLI-DIRECT to the remote
|
||||
* daemon's shipped POST /api/transport/send (zero daemon-side changes).
|
||||
* Wrap parity BY CONSTRUCTION: the body is built exactly as the LOCAL path
|
||||
* builds it (same wrapSendBody call, same fields), so the remote daemon
|
||||
* receives what its own local CLI would post. `actorSession` is the local
|
||||
* sender verbatim — honest provenance; unknown on the remote it degrades to
|
||||
* the shipped non-blocking advisory, never a refusal. `--verify` prints the
|
||||
* REMOTE route's verified/outcome verbatim (remote-authoritative, never
|
||||
* locally synthesized). Deadline: the read-class client default, or
|
||||
* waitForIdleMs + overhead when --wait-for-idle (the local path's math).
|
||||
*
|
||||
* Auth posture (named, v0): runRemoteHttpOp presents the REGISTRY bearer;
|
||||
* /api/transport/* is gated by the remote's TERMINAL bearer class. Default
|
||||
* (null) + tailnet binds = pass-through by design; a remote enforcing a
|
||||
* DIFFERENT terminal bearer surfaces as the structured permission-gate step
|
||||
* (never a hang, never silent). Remedy documented in cli-reference.md.
|
||||
*/
|
||||
async function runHttpHostSend(
|
||||
host: HttpHostEntry,
|
||||
session: string,
|
||||
text: string,
|
||||
opts: { verify?: boolean; force?: boolean; waitForIdle?: string; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean },
|
||||
deps: SendDeps,
|
||||
waitForIdleMs?: number,
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
const senderSession = resolveSenderSession();
|
||||
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
|
||||
const outboundText = raw ? text : wrapSendBody(senderSession, session, text);
|
||||
|
||||
const result = await runRemoteHttpOp(host.id, "POST", "/api/transport/send", {
|
||||
session, text: outboundText, verify: opts.verify, force: opts.force, waitForIdleMs,
|
||||
dangerouslyInteract: opts.dangerouslyInteract, reason: opts.reason, actorSession: senderSession ?? null,
|
||||
}, deps, waitForIdleMs !== undefined ? { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS } : {});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
|
||||
result,
|
||||
...(!result.ok && hint ? { hint } : {}),
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
const data = (result.data ?? {}) as Record<string, unknown>;
|
||||
console.log(`Sent to ${session}`);
|
||||
const advisory = data["warning"] as string | undefined;
|
||||
if (advisory) {
|
||||
console.log(`Advisory: ${advisory}`);
|
||||
}
|
||||
if (opts.verify) {
|
||||
// The REMOTE route's verdict, verbatim — mirrors the local render so
|
||||
// scripts grepping `Verified:` behave identically across hosts.
|
||||
const verified = data["verified"] as boolean | undefined;
|
||||
console.log(`Verified: ${verified ? "yes" : "no"}`);
|
||||
const outcome = data["outcome"] as string | undefined;
|
||||
if (outcome === "delivered") {
|
||||
console.log("Delivery: delivered (message landed; render confirmed)");
|
||||
} else if (outcome === "rendered-unconfirmed") {
|
||||
console.log(`Delivery: rendered-unconfirmed (landed; pane re-render not confirmed - confirm with: rig capture ${session})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPR.0.4.3.30 — fan-out send (`--to` / `--pod` / `--rig`). Reuses the DAEMON's broadcast
|
||||
// machinery (resolve → per-seat send loop → per-recipient results) via /api/transport/broadcast.
|
||||
// The message is sent BARE; the daemon wraps each recipient in its OWN From/To envelope
|
||||
// (envelopeSender), so every seat gets `To: <that seat>` — byte-identical to a single send.
|
||||
// --raw / --dangerously-interact send exact text with NO envelope (envelopeSender omitted).
|
||||
// Each recipient is guarded INDEPENDENTLY server-side; one refusal never aborts the set.
|
||||
async function runFanOutSend(params: {
|
||||
toList: string[] | undefined;
|
||||
pod: string | undefined;
|
||||
rig: string | undefined;
|
||||
message: string;
|
||||
opts: { verify?: boolean; force?: boolean; raw?: boolean; dangerouslyInteract?: boolean; reason?: string; json?: boolean };
|
||||
deps: SendDeps;
|
||||
}): Promise<void> {
|
||||
const { toList, pod, rig, message, opts, deps } = params;
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const senderSession = resolveSenderSession();
|
||||
const raw = Boolean(opts.raw || opts.dangerouslyInteract);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
text: message,
|
||||
verify: opts.verify,
|
||||
force: opts.force,
|
||||
dangerouslyInteract: opts.dangerouslyInteract,
|
||||
reason: opts.reason,
|
||||
actorSession: senderSession ?? null,
|
||||
};
|
||||
if (toList) body.sessions = toList;
|
||||
else if (pod) body.pod = pod;
|
||||
else if (rig) body.rig = rig;
|
||||
// Per-recipient envelope daemon-side unless raw/danger. Always pass a truthy sender (falling
|
||||
// back to the same "<unknown sender>" marker single-send uses) so the wrap fires for parity.
|
||||
if (!raw) {
|
||||
body.envelopeSender = senderSession && senderSession.trim().length > 0 ? senderSession : SENDER_FALLBACK;
|
||||
}
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/transport/broadcast", body, transportRequestOptions());
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
const results = (res.data["results"] as Array<{ ok: boolean }> | undefined) ?? [];
|
||||
if (res.status >= 400 || results.some((r) => !r.ok)) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const error = res.data["error"] as string | undefined;
|
||||
console.error(error ?? `Send failed (HTTP ${res.status})`);
|
||||
process.exitCode = res.status >= 500 ? 2 : 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
const results = (data["results"] as Array<{ sessionName: string; ok: boolean; error?: string }>) ?? [];
|
||||
for (const r of results) {
|
||||
if (r.ok) {
|
||||
console.log(`${r.sessionName}: sent`);
|
||||
} else {
|
||||
console.log(`${r.sessionName}: FAILED — ${r.error ?? "unknown error"}`);
|
||||
}
|
||||
}
|
||||
console.log(`${data["sent"]}/${data["total"]} delivered`);
|
||||
if ((data["failed"] as number) > 0 || results.some((r) => !r.ok)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWaitForIdleMs(value: string | undefined): number | undefined | null {
|
||||
@@ -208,3 +520,14 @@ function waitForIdleRequestOptions(waitForIdleMs: number | undefined): { timeout
|
||||
if (waitForIdleMs === undefined) return undefined;
|
||||
return { timeoutMs: waitForIdleMs + WAIT_FOR_IDLE_REQUEST_OVERHEAD_MS };
|
||||
}
|
||||
|
||||
function transportRequestOptions(waitForIdleMs?: number): { timeoutMs?: number; headers?: Record<string, string> } | undefined {
|
||||
const waitOptions = waitForIdleRequestOptions(waitForIdleMs);
|
||||
const headers = terminalAuthHeaders();
|
||||
const hasHeaders = Object.keys(headers).length > 0;
|
||||
if (!waitOptions && !hasHeaders) return undefined;
|
||||
return {
|
||||
...(waitOptions ?? {}),
|
||||
...(hasHeaders ? { headers } : {}),
|
||||
};
|
||||
}
|
||||
@@ -599,6 +599,23 @@ function buildDefaultDoctorDeps(setupDeps: SetupDeps): DoctorDeps {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.3.3.04.2 (AC-1): the ONE canonical ordered golden path over EXISTING
|
||||
* verbs - no magic mega-command, no hidden state. `rig setup` prints this as its
|
||||
* next-steps; `rig status`/`rig doctor` only HINT back to it; the durable
|
||||
* reference is docs/reference/getting-started.md. Returns the lines to print.
|
||||
*/
|
||||
export function goldenPathNextSteps(): string[] {
|
||||
return [
|
||||
"Next steps (the guided path; full reference: docs/reference/getting-started.md):",
|
||||
" 1. rig up <rig-spec> Launch a rig (auto-starts the daemon; the kernel boots on daemon-start)",
|
||||
" 2. rig status See daemon port, kernel readiness, and your effective workspace root",
|
||||
" 3. rig workspace doctor Check your workspace is ready",
|
||||
" 4. rig workflow instantiate <name> Start a workflow by its discovered name (e.g. conveyor); 'rig workflow specs' shows built-ins",
|
||||
" 5. rig scope ... Browse the durable mission/slice artifacts the workflow coordinates",
|
||||
];
|
||||
}
|
||||
|
||||
export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
const cmd = new Command("setup").description("Prepare the machine for OpenRig");
|
||||
|
||||
@@ -632,7 +649,16 @@ export function setupCommand(depsOverride?: SetupDeps): Command {
|
||||
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."}`);
|
||||
// OPR.0.3.3.04.2 (AC-1): the canonical ordered golden path. `rig setup` is
|
||||
// the primary surface for the new-operator sequence (status/doctor only
|
||||
// HINT back to it; the durable reference is docs/reference/getting-started.md).
|
||||
if (result.ready) {
|
||||
console.log("\nSetup complete.\n");
|
||||
for (const line of goldenPathNextSteps()) console.log(line);
|
||||
} else {
|
||||
console.log("\nSome steps need attention. Run `rig doctor` for detailed diagnostics.");
|
||||
console.log("Once setup is healthy, follow the guided path: docs/reference/getting-started.md");
|
||||
}
|
||||
if (!opts.dryRun && !result.ready) process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface AuditFinding {
|
||||
class: string;
|
||||
file: string;
|
||||
reason: string;
|
||||
remediation: string;
|
||||
}
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
path: string;
|
||||
sourceKind: string;
|
||||
shadowed: boolean;
|
||||
stage: string | null;
|
||||
verified: { status: string; date?: string; source?: string };
|
||||
contentHash: string;
|
||||
state: string;
|
||||
owner: string | null;
|
||||
sourceRef: string | null;
|
||||
findings: AuditFinding[];
|
||||
}
|
||||
|
||||
interface AuditResponse {
|
||||
ok: boolean;
|
||||
entries: AuditEntry[];
|
||||
totalFindings: number;
|
||||
mirrorDriftError?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function skillCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("skill").description("Skill management and audit");
|
||||
const getDeps = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.command("audit")
|
||||
.description("Read-only skill provenance and freshness audit")
|
||||
.option("--json", "JSON output")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
const res = await client.get<AuditResponse>("/api/skills/audit");
|
||||
if (res.status >= 400 || !res.data.ok) {
|
||||
console.error(res.data.error ?? `Audit failed (HTTP ${res.status})`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { entries, totalFindings, mirrorDriftError } = res.data;
|
||||
const hasFail = totalFindings > 0 || !!mirrorDriftError;
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (hasFail) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const active = entries.filter((e) => !e.shadowed);
|
||||
const shadowed = entries.filter((e) => e.shadowed);
|
||||
const withFindings = active.filter((e) => e.findings.length > 0);
|
||||
|
||||
console.log(`Skill audit: ${active.length} active, ${shadowed.length} shadowed, ${totalFindings} findings\n`);
|
||||
|
||||
if (withFindings.length > 0) {
|
||||
console.log("FINDINGS:");
|
||||
for (const entry of withFindings) {
|
||||
for (const f of entry.findings) {
|
||||
console.log(` [${f.class}] ${entry.id}`);
|
||||
console.log(` file: ${f.file}`);
|
||||
console.log(` reason: ${f.reason}`);
|
||||
console.log(` fix: ${f.remediation}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (mirrorDriftError) {
|
||||
console.log(`MIRROR DRIFT CHECK UNAVAILABLE: ${mirrorDriftError}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (shadowed.length > 0) {
|
||||
console.log("SHADOWED:");
|
||||
for (const s of shadowed) {
|
||||
console.log(` ${s.id} at ${s.path} (${s.sourceKind}) -- shadowed by precedence winner`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
if (hasFail) {
|
||||
const parts: string[] = [];
|
||||
if (totalFindings > 0) parts.push(`${totalFindings} finding(s) on active skills`);
|
||||
if (mirrorDriftError) parts.push("mirror drift check unavailable");
|
||||
console.log(`FAIL: ${parts.join("; ")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("PASS: all active skills have provenance and verified freshness");
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
// OPR.0.3.4.1 — rig start: one-command recovery orchestrator.
|
||||
// Sequences existing primitives: daemon-start -> kernel auto-boot observe ->
|
||||
// candidate listing (restore-usable snapshots) -> picker/flags ->
|
||||
// per-rig restore (slice-02 /api/up) + reconcile (slice-03). Re-codes nothing.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient, DaemonConnectionError } from "../client.js";
|
||||
import {
|
||||
getDaemonStatus,
|
||||
getDaemonUrl,
|
||||
startDaemon,
|
||||
waitForKernelReady,
|
||||
type LifecycleDeps,
|
||||
} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
const KERNEL_WAIT_MS = 60_000;
|
||||
|
||||
interface RigSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
nodeCount?: number;
|
||||
lifecycleState?: string;
|
||||
}
|
||||
|
||||
interface PlanPreviewNode {
|
||||
logicalId: string;
|
||||
intendedAction: string;
|
||||
reason?: string;
|
||||
// OPR.0.4.3.20 FR-6 — per-seat token truth on the wire (rig start's picker
|
||||
// only aggregates intendedAction; `rig up --plan` renders these per seat).
|
||||
tokenState?: "present" | "missing" | "stale" | "unverified";
|
||||
provenance?: string | null;
|
||||
lastVerified?: string | null;
|
||||
freshRequired?: boolean;
|
||||
runtimePrompt?: string;
|
||||
}
|
||||
|
||||
interface PlanPreviewResponse {
|
||||
status: string;
|
||||
mode: string;
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
snapshot: { id: string; kind: string; createdAt: string } | null;
|
||||
wouldCaptureCurrentState: boolean;
|
||||
nodes: PlanPreviewNode[];
|
||||
mutated: boolean;
|
||||
}
|
||||
|
||||
export interface StartCandidate {
|
||||
rigId: string;
|
||||
rigName: string;
|
||||
lifecycleState: string;
|
||||
nodeCount: number;
|
||||
lastActivity: string | null;
|
||||
preview: PlanPreviewResponse | null;
|
||||
}
|
||||
|
||||
/** Default interactive [y/N] prompt (same shape as up.ts, reused for the TTY path). */
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal spacebar multi-select picker for TTY (NET-NEW, lightweight). */
|
||||
async function multiSelectPicker(items: Array<{ label: string; value: string; checked: boolean }>): Promise<string[]> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
rl.close();
|
||||
|
||||
const { createInterface } = readline;
|
||||
return new Promise<string[]>((resolve) => {
|
||||
const state = items.map((item) => ({ ...item }));
|
||||
let cursor = 0;
|
||||
|
||||
const render = () => {
|
||||
process.stdout.write("\x1b[?25l");
|
||||
for (let i = 0; i < state.length; i++) {
|
||||
const prefix = i === cursor ? ">" : " ";
|
||||
const check = state[i]!.checked ? "[x]" : "[ ]";
|
||||
process.stdout.write(`\r${prefix} ${check} ${state[i]!.label}\n`);
|
||||
}
|
||||
process.stdout.write(`\r (space=toggle, enter=confirm, a=all, n=none)\n`);
|
||||
process.stdout.write(`\x1b[${state.length + 1}A`);
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
resolve(state.filter((s) => s.checked).map((s) => s.value));
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
|
||||
const cleanup = () => {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stdout.write(`\x1b[${state.length + 1}B\r`);
|
||||
process.stdout.write("\x1b[?25h");
|
||||
};
|
||||
|
||||
const onData = (buf: Buffer) => {
|
||||
const key = buf.toString();
|
||||
if (key === " ") {
|
||||
state[cursor]!.checked = !state[cursor]!.checked;
|
||||
render();
|
||||
} else if (key === "\r" || key === "\n") {
|
||||
cleanup();
|
||||
resolve(state.filter((s) => s.checked).map((s) => s.value));
|
||||
} else if (key === "\x1b[A" || key === "k") {
|
||||
cursor = Math.max(0, cursor - 1);
|
||||
render();
|
||||
} else if (key === "\x1b[B" || key === "j") {
|
||||
cursor = Math.min(state.length - 1, cursor + 1);
|
||||
render();
|
||||
} else if (key === "a") {
|
||||
state.forEach((s) => { s.checked = true; });
|
||||
render();
|
||||
} else if (key === "n") {
|
||||
state.forEach((s) => { s.checked = false; });
|
||||
render();
|
||||
} else if (key === "\x03") {
|
||||
cleanup();
|
||||
process.exitCode = 130;
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
export interface StartDeps extends StatusDeps {
|
||||
promptYesNo?: (question: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export function startCommand(depsOverride?: StartDeps): Command {
|
||||
const cmd = new Command("start")
|
||||
.description("Start the daemon, verify kernel, and restore rigs that were last running")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig start Interactive: daemon + kernel + pick-and-restore
|
||||
rig start --last Headless: restore everything that was running
|
||||
rig start --all Headless: restore all rigs with restore-usable snapshots
|
||||
rig start --rigs prod-rig dev-rig Headless: restore only the named rigs
|
||||
`);
|
||||
const getDepsF = (): StartDeps =>
|
||||
depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.option("--last", "Headless: restore all rigs that were last running (zero prompts)")
|
||||
.option("--all", "Headless: restore all rigs with restore-usable snapshots (zero prompts)")
|
||||
.option("--rigs <names...>", "Headless: restore only the named rigs (zero prompts)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { last?: boolean; all?: boolean; rigs?: string[]; json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
|
||||
// ---- PHASE 1: ensure daemon is running ----
|
||||
let status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
if (!opts.json) console.log("Starting daemon...");
|
||||
try {
|
||||
const { ConfigStore } = await import("../config-store.js");
|
||||
const configStore = new ConfigStore();
|
||||
const resolvedConfig = configStore.resolve();
|
||||
const hostResolution = configStore.resolveWithSource("daemon.host");
|
||||
const hostForDaemon = hostResolution.source === "default"
|
||||
? undefined
|
||||
: resolvedConfig.daemon.host;
|
||||
const { SystemPreflight } = await import("../system-preflight.js");
|
||||
const { execSync } = await import("node:child_process");
|
||||
const { OPENRIG_DIR } = await import("../daemon-lifecycle.js");
|
||||
const preflight = new SystemPreflight({
|
||||
exec: async (cmd: string) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }),
|
||||
configStore,
|
||||
getDaemonStatus: () => getDaemonStatus(deps.lifecycleDeps),
|
||||
openrigHome: OPENRIG_DIR,
|
||||
});
|
||||
const preflightResult = await preflight.run();
|
||||
if (!preflightResult.ready) {
|
||||
for (const check of preflightResult.checks.filter((c) => !c.ok)) {
|
||||
console.error(` ${check.name}: ${check.error}`);
|
||||
if (check.fix) console.error(` Fix: ${check.fix}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
await startDaemon({
|
||||
port: resolvedConfig.daemon.port,
|
||||
host: hostForDaemon,
|
||||
db: resolvedConfig.db.path,
|
||||
transcriptsEnabled: resolvedConfig.transcripts.enabled,
|
||||
transcriptsPath: resolvedConfig.transcripts.path,
|
||||
transcriptsLines: resolvedConfig.transcripts.lines,
|
||||
transcriptsPollIntervalSeconds: resolvedConfig.transcripts.pollIntervalSeconds,
|
||||
workspaceRoot: resolvedConfig.workspace.root,
|
||||
}, deps.lifecycleDeps);
|
||||
status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
} catch (err) {
|
||||
console.error(`Daemon start failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon is not healthy. Check with: rig daemon status");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = getDaemonUrl(status);
|
||||
const client = deps.clientFactory(baseUrl);
|
||||
|
||||
// ---- PHASE 2: kernel invariant — verify/await kernel readiness ----
|
||||
if (!opts.json) console.log("Waiting for kernel...");
|
||||
const kernelResult = await waitForKernelReady(baseUrl, KERNEL_WAIT_MS);
|
||||
if (!kernelResult.ok) {
|
||||
if (kernelResult.kernelState === "skipped") {
|
||||
if (!opts.json) console.log("Kernel auto-boot skipped (--no-kernel or test mode).");
|
||||
} else {
|
||||
console.error(`Kernel failed to start: state=${kernelResult.kernelState ?? "unknown"}, detail=${kernelResult.detail ?? "none"}`);
|
||||
console.error("Cannot proceed to rig restore without a working kernel.");
|
||||
console.error("Fix: resolve the kernel issue, then rerun: rig start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else if (!opts.json) {
|
||||
console.log("Kernel ready.");
|
||||
}
|
||||
|
||||
// Verify UI is actually serving before announcing the URL.
|
||||
let uiUrl: string | null = null;
|
||||
try {
|
||||
const healthRes = await fetch(`${baseUrl}/healthz`);
|
||||
if (healthRes.ok) {
|
||||
uiUrl = baseUrl;
|
||||
}
|
||||
} catch { /* UI not serving yet — degrade gracefully */ }
|
||||
if (uiUrl && !opts.json) {
|
||||
console.log(`UI: ${uiUrl}`);
|
||||
}
|
||||
|
||||
// ---- PHASE 3: list last-running candidates ----
|
||||
let allSummaries: RigSummary[];
|
||||
try {
|
||||
const res = await client.get<RigSummary[]>("/api/rigs/summary");
|
||||
allSummaries = res.data ?? [];
|
||||
} catch {
|
||||
console.error("Failed to list rigs. Daemon may not be ready.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard BLOCKING f359e3a3: use the id-based Explorer route for candidate
|
||||
// preview (POST /api/rigs/:id/up with plan:true) instead of the name-based
|
||||
// POST /api/up which 409s on same-name rigs and silently drops them.
|
||||
const candidates: StartCandidate[] = [];
|
||||
// Guard BLOCKING bfed4ce3: track preview errors so they are not collapsed
|
||||
// into a clean empty-candidate result.
|
||||
const previewErrors: Array<{ rigId: string; rigName: string; code: string; message: string }> = [];
|
||||
for (const rig of allSummaries) {
|
||||
if (rig.name === "kernel") continue;
|
||||
if (rig.lifecycleState === "running") continue;
|
||||
|
||||
try {
|
||||
const planRes = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rig.id)}/up`,
|
||||
{ plan: true },
|
||||
);
|
||||
if (planRes.status === 200 && planRes.data["status"] === "plan") {
|
||||
const preview = planRes.data as unknown as PlanPreviewResponse;
|
||||
|
||||
let lastActivity: string | null = null;
|
||||
try {
|
||||
const rigDetail = await client.get<{ sessions?: Array<{ lastSeenAt?: string | null }> }>(`/api/rigs/${rig.id}`);
|
||||
const sessions = rigDetail.data?.sessions ?? [];
|
||||
const dates = sessions.map((s) => s.lastSeenAt).filter((d): d is string => !!d).sort();
|
||||
lastActivity = dates.length > 0 ? dates[dates.length - 1]! : null;
|
||||
} catch { /* degrade: lastActivity stays null */ }
|
||||
|
||||
candidates.push({
|
||||
rigId: rig.id,
|
||||
rigName: rig.name,
|
||||
lifecycleState: rig.lifecycleState ?? "unknown",
|
||||
nodeCount: rig.nodeCount ?? 0,
|
||||
lastActivity,
|
||||
preview,
|
||||
});
|
||||
} else if (planRes.status === 404) {
|
||||
// no_snapshot or not found — this rig is not a candidate (expected exclusion).
|
||||
} else {
|
||||
const code = String(planRes.data["code"] ?? `http_${planRes.status}`);
|
||||
const errorText = String(planRes.data["error"] ?? "preview failed");
|
||||
previewErrors.push({ rigId: rig.id, rigName: rig.name, code, message: errorText });
|
||||
if (!opts.json) console.error(` ${rig.name}: candidate preview failed -- ${errorText} (${code})`);
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
previewErrors.push({ rigId: rig.id, rigName: rig.name, code: "transport_error", message: msg });
|
||||
if (!opts.json) console.error(` ${rig.name}: candidate preview unavailable (transport error)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
status: previewErrors.length > 0 ? "started_with_errors" : "started",
|
||||
candidates: [],
|
||||
restoredRigs: [],
|
||||
previewErrors: previewErrors.length > 0 ? previewErrors : undefined,
|
||||
}));
|
||||
} else {
|
||||
if (previewErrors.length > 0) {
|
||||
console.error(`${previewErrors.length} rig(s) failed preview (see errors above). Some candidates may be missing.`);
|
||||
} else {
|
||||
console.log("Daemon and kernel are up. No rigs to restore.");
|
||||
}
|
||||
}
|
||||
if (previewErrors.length > 0) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- PHASE 4: selection (id-grounded, rev1 BLOCKING b4c6ada4) ----
|
||||
let selectedCandidates: StartCandidate[];
|
||||
if (opts.all || opts.last) {
|
||||
selectedCandidates = [...candidates];
|
||||
} else if (opts.rigs) {
|
||||
selectedCandidates = candidates.filter((c) => opts.rigs!.includes(c.rigName));
|
||||
const missing = opts.rigs.filter((n) => !candidates.some((c) => c.rigName === n));
|
||||
if (missing.length > 0) {
|
||||
console.error(`Rig(s) not found in candidate set: ${missing.join(", ")}`);
|
||||
console.error(`Available candidates: ${candidates.map((c) => c.rigName).join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// TTY interactive: offer fast default + picker
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!interactive) {
|
||||
console.error("No TTY available. Use --last, --all, or --rigs <names> for headless mode.");
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ status: "started", candidates: candidates.map((c) => ({ rigName: c.rigName, lifecycleState: c.lifecycleState })), restoredRigs: [] }));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${candidates.length} rig(s) were last running:\n`);
|
||||
for (const c of candidates) {
|
||||
const readiness = summarizeReadiness(c);
|
||||
const activity = c.lastActivity ? `last active ${c.lastActivity}` : "";
|
||||
console.log(` ${c.rigName} (${c.nodeCount} seats, ${c.lifecycleState}${activity ? ", " + activity : ""}) ${readiness}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const ask = depsOverride?.promptYesNo ?? defaultPromptYesNo;
|
||||
const restoreAll = await ask(`Restore all ${candidates.length} rig(s)? [Y/pick] `);
|
||||
|
||||
if (restoreAll) {
|
||||
selectedCandidates = [...candidates];
|
||||
} else {
|
||||
const pickedIds = await multiSelectPicker(
|
||||
candidates.map((c) => ({
|
||||
label: `${c.rigName} (${c.nodeCount} seats, ${c.lifecycleState}) ${summarizeReadiness(c)}`,
|
||||
value: c.rigId,
|
||||
checked: true,
|
||||
})),
|
||||
);
|
||||
selectedCandidates = candidates.filter((c) => pickedIds.includes(c.rigId));
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCandidates.length === 0) {
|
||||
if (!opts.json) console.log("No rigs selected for restore.");
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- PHASE 5: restore each selected rig via id-based route (compose slice-02 path) ----
|
||||
if (!opts.json) console.log(`\nRestoring ${selectedCandidates.length} rig(s)...\n`);
|
||||
|
||||
const results: Array<{ rigName: string; status: string; nodes: Array<{ logicalId: string; status: string; error?: string }> }> = [];
|
||||
|
||||
for (const candidate of selectedCandidates) {
|
||||
const rigName = candidate.rigName;
|
||||
const rigId = candidate.rigId;
|
||||
|
||||
// Check if the rig is already running (idempotent rerun: reconcile, don't relaunch).
|
||||
try {
|
||||
const freshSummary = await client.get<RigSummary[]>("/api/rigs/summary");
|
||||
const current = (freshSummary.data ?? []).find((r) => r.id === rigId);
|
||||
if (current?.lifecycleState === "running") {
|
||||
if (!opts.json) console.log(` ${rigName}: already running (skipping)`);
|
||||
results.push({ rigName, status: "already_running", nodes: [] });
|
||||
continue;
|
||||
}
|
||||
} catch { /* proceed with restore attempt */ }
|
||||
|
||||
try {
|
||||
// Rev1 BLOCKING b4c6ada4: use id-based route for restore too (not
|
||||
// name-based /api/up which 409s on same-name rigs).
|
||||
const res = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/up`,
|
||||
{ plan: false },
|
||||
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Guard BLOCKING 25661f72: check HTTP status before treating the response
|
||||
// as a success. The daemon returns non-2xx JSON payloads (rig_not_stopped,
|
||||
// pre_restore_validation_failed, ambiguous_name) without throwing.
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
const errorText = String(res.data["error"] ?? "restore failed");
|
||||
if (code === "pre_restore_validation_failed") {
|
||||
console.error(` ${rigName}: restore blocked (pre-validation failed)`);
|
||||
const blockers = (res.data["blockers"] as Array<{ message: string; remediation: string }>) ?? [];
|
||||
for (const b of blockers) {
|
||||
console.error(` ${b.message}`);
|
||||
console.error(` fix: ${b.remediation}`);
|
||||
}
|
||||
} else {
|
||||
console.error(` ${rigName}: ${errorText} (${code ?? `HTTP ${res.status}`})`);
|
||||
}
|
||||
results.push({ rigName, status: code ?? "error", nodes: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const resStatus = res.data["status"] as string;
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
const rigResult = res.data["rigResult"] as string | undefined;
|
||||
|
||||
if (!opts.json) {
|
||||
console.log(` ${rigName}: ${rigResult ?? resStatus}`);
|
||||
for (const n of nodes) {
|
||||
if (n.status === "awaiting-decision" && n.error) {
|
||||
console.log(` ${n.logicalId}: awaiting-decision -- ${n.error}`);
|
||||
} else {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle the awaiting-decision ASK (compose the slice-02 TTY flow).
|
||||
const awaiting = nodes.filter((n) => n.status === "awaiting-decision");
|
||||
if (awaiting.length > 0) {
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || Boolean(depsOverride?.promptYesNo);
|
||||
if (interactive) {
|
||||
const ask = depsOverride?.promptYesNo ?? defaultPromptYesNo;
|
||||
const accepted: string[] = [];
|
||||
for (const n of awaiting) {
|
||||
const reason = n.error ?? "original session unresumable";
|
||||
const yes = await ask(` Couldn't resume ${n.logicalId} (reason: ${reason}). Fresh-prime? [y/N] `);
|
||||
if (yes) accepted.push(n.logicalId);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
try {
|
||||
const freshRes = await client.post<Record<string, unknown>>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/up`,
|
||||
{ plan: false, freshLogicalIds: accepted },
|
||||
{ timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS },
|
||||
);
|
||||
if (freshRes.status >= 400) {
|
||||
const freshError = String(freshRes.data["error"] ?? "fresh-prime failed");
|
||||
console.error(` Fresh-prime for ${rigName}: ${freshError} (HTTP ${freshRes.status})`);
|
||||
} else {
|
||||
const freshNodes = (freshRes.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const fn of freshNodes.filter((fn) => accepted.includes(fn.logicalId))) {
|
||||
if (!opts.json) console.log(` ${fn.logicalId}: ${fn.status}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
console.error(` Fresh-prime timed out for ${rigName}; the daemon may still be processing. Verify with: rig ps`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else if (!opts.json) {
|
||||
console.log(` No fresh sessions started for ${rigName}.`);
|
||||
}
|
||||
} else {
|
||||
// Headless: report honestly, no auto-substitute.
|
||||
for (const n of awaiting) {
|
||||
if (!opts.json) console.error(` ${n.logicalId}: awaiting-decision -- to fresh-prime: rig up --existing ${rigName} --fresh ${n.logicalId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ rigName, status: rigResult ?? resStatus, nodes });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
console.error(` ${rigName}: timed out; the daemon may still be processing. Verify with: rig ps`);
|
||||
results.push({ rigName, status: "timeout", nodes: [] });
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${rigName}: ${msg}`);
|
||||
results.push({ rigName, status: "error", nodes: [] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
status: previewErrors.length > 0 ? "started_with_errors" : "started",
|
||||
uiUrl,
|
||||
candidates: candidates.map((c) => ({ rigName: c.rigName, lifecycleState: c.lifecycleState })),
|
||||
restoredRigs: results,
|
||||
previewErrors: previewErrors.length > 0 ? previewErrors : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// Exit code: 1 if any rig has non-clean outcome. Include HTTP error codes
|
||||
// and non-clean rigResult values (partially_restored, failed, not_attempted).
|
||||
const NON_CLEAN_STATUSES = new Set(["timeout", "error", "skipped", "partially_restored", "failed", "not_attempted", "rig_not_stopped", "ambiguous_name", "pre_restore_validation_failed"]);
|
||||
const hasFailure = previewErrors.length > 0 || results.some((r) =>
|
||||
NON_CLEAN_STATUSES.has(r.status) ||
|
||||
r.nodes.some((n) => n.status === "failed" || n.status === "awaiting-decision"),
|
||||
);
|
||||
if (hasFailure) process.exitCode = 1;
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function summarizeReadiness(c: StartCandidate): string {
|
||||
if (!c.preview) return "";
|
||||
const actions = c.preview.nodes.map((n) => n.intendedAction);
|
||||
if (actions.every((a) => a === "resume-original")) return "[ready to resume]";
|
||||
if (actions.some((a) => a === "awaiting-decision")) return "[will ask before fresh]";
|
||||
if (actions.every((a) => a === "fresh-primed")) return "[fresh start]";
|
||||
return "[mixed]";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
|
||||
export interface StartupProofDeps {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
fetchImpl?: typeof fetch;
|
||||
readFile?: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface StartupProofSubmitResult {
|
||||
ok: true;
|
||||
oriented: "verified";
|
||||
nodeId: string;
|
||||
challengeId: string;
|
||||
}
|
||||
|
||||
interface Endpoint {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function first(...values: unknown[]): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) return value.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveStartupProofEndpoint(deps: StartupProofDeps = {}): Endpoint | null {
|
||||
const env = deps.env ?? process.env;
|
||||
const readFile = deps.readFile ?? ((p: string) => fs.readFileSync(p, "utf8"));
|
||||
const home = first(env["OPENRIG_HOME"], env["RIGGED_HOME"]) ?? path.join(os.homedir(), ".openrig");
|
||||
let baseUrl = first(env["OPENRIG_URL"], env["RIGGED_URL"]);
|
||||
let token = first(env["OPENRIG_ACTIVITY_HOOK_TOKEN"], env["RIGGED_ACTIVITY_HOOK_TOKEN"]);
|
||||
|
||||
if (!baseUrl || !token) {
|
||||
try {
|
||||
const endpoint = JSON.parse(readFile(path.join(home, "activity-endpoint.json"))) as Record<string, unknown>;
|
||||
baseUrl ??= first(endpoint["baseUrl"]);
|
||||
token ??= first(endpoint["token"]);
|
||||
} catch {
|
||||
// File discovery is best-effort; env and host/port fallback may still work.
|
||||
}
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
const port = first(env["OPENRIG_PORT"], env["RIGGED_PORT"]);
|
||||
if (port) baseUrl = `http://${first(env["OPENRIG_HOST"], env["RIGGED_HOST"]) ?? "127.0.0.1"}:${port}`;
|
||||
}
|
||||
|
||||
return baseUrl && token ? { baseUrl, token } : null;
|
||||
}
|
||||
|
||||
export async function submitStartupProof(
|
||||
input: { challengeId: string; answer: string },
|
||||
deps: StartupProofDeps = {},
|
||||
): Promise<StartupProofSubmitResult> {
|
||||
const env = deps.env ?? process.env;
|
||||
const fetchImpl = deps.fetchImpl ?? fetch;
|
||||
const endpoint = resolveStartupProofEndpoint(deps);
|
||||
if (!endpoint) {
|
||||
throw new Error("OpenRig startup proof endpoint is unavailable");
|
||||
}
|
||||
|
||||
const res = await fetchImpl(new URL("/api/activity/hooks", endpoint.baseUrl).toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${endpoint.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
eventFamily: "startup_proof",
|
||||
sessionName: first(env["OPENRIG_SESSION_NAME"], env["RIGGED_SESSION_NAME"]),
|
||||
nodeId: first(env["OPENRIG_NODE_ID"], env["RIGGED_NODE_ID"]),
|
||||
runtime: first(env["OPENRIG_RUNTIME"], env["RIGGED_RUNTIME"]),
|
||||
challengeId: input.challengeId,
|
||||
answer: input.answer,
|
||||
}),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = { ok: false, error: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const message = typeof parsed === "object" && parsed && "error" in parsed
|
||||
? String((parsed as { error?: unknown }).error)
|
||||
: text;
|
||||
throw new Error(`startup_proof rejected: ${res.status} ${message}`);
|
||||
}
|
||||
return parsed as StartupProofSubmitResult;
|
||||
}
|
||||
|
||||
export function startupProofCommand(depsOverride?: StartupProofDeps): Command {
|
||||
const cmd = new Command("startup-proof")
|
||||
.description("Submit and inspect startup orientation proof");
|
||||
|
||||
cmd.command("submit")
|
||||
.description("Submit this seat's startup proof through the authenticated OpenRig activity hook")
|
||||
.requiredOption("--challenge-id <id>", "Startup challenge id from the startup prompt")
|
||||
.requiredOption("--answer <answer>", "Expected answer from the startup prompt")
|
||||
.option("--json", "Print raw JSON response")
|
||||
.action(async (opts: { challengeId: string; answer: string; json?: boolean }) => {
|
||||
try {
|
||||
const result = await submitStartupProof({ challengeId: opts.challengeId, answer: opts.answer }, depsOverride);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
} else {
|
||||
console.log(`startup_proof: ${result.oriented}`);
|
||||
console.log(`node_id: ${result.nodeId}`);
|
||||
console.log(`challenge_id: ${result.challengeId}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type LifecycleDeps,
|
||||
} from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import { ConfigStore } from "../config-store.js";
|
||||
|
||||
export interface StatusDeps {
|
||||
lifecycleDeps: LifecycleDeps;
|
||||
@@ -52,14 +53,39 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// Fetch summary + cmux status
|
||||
const [summaryRes, cmuxRes] = await Promise.all([
|
||||
// Fetch summary + cmux + kernel readiness
|
||||
const [summaryRes, cmuxRes, kernelRes] = await Promise.all([
|
||||
client.get<Array<{ id: string; name: string; nodeCount: number; latestSnapshotAt: string | null; latestSnapshotId: string | null }>>("/api/rigs/summary"),
|
||||
client.get<{ available: boolean }>("/api/adapters/cmux/status").catch(() => null),
|
||||
client.get<{ kernel_state?: string; error?: string }>("/api/kernel/status").catch(() => null),
|
||||
]);
|
||||
|
||||
console.log(`Daemon running on port ${status.port}`);
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-2): kernel readiness is a DISTINCT signal from daemon
|
||||
// health - the kernel rig auto-boots on daemon-start, and a daemon can be up
|
||||
// while the kernel is not yet ready (or a kernel agent is unhealthy). Surface
|
||||
// it as "here's what's currently true," never as a guarantee that downstream
|
||||
// agents are healthy.
|
||||
if (kernelRes && kernelRes.status === 200 && kernelRes.data?.kernel_state) {
|
||||
console.log(`Kernel: ${kernelRes.data.kernel_state} (boots on daemon-start; distinct from daemon health)`);
|
||||
} else if (kernelRes && kernelRes.status === 503) {
|
||||
console.log("Kernel: not tracked (no kernel-boot tracker wired)");
|
||||
} else {
|
||||
console.log("Kernel: unknown (status unavailable)");
|
||||
}
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-2 / gap #7): surface WHICH workspace root is effective
|
||||
// and whether it is the default or an override - the operator never guesses.
|
||||
// This reports what is currently live, NOT that any root is the right one.
|
||||
try {
|
||||
const resolved = new ConfigStore().resolveWithSource("workspace.root");
|
||||
const origin = resolved.source === "default" ? "default" : `override via ${resolved.source}`;
|
||||
console.log(`Workspace root: ${resolved.value} (${origin})`);
|
||||
} catch {
|
||||
// config resolution unavailable - omit rather than guess.
|
||||
}
|
||||
|
||||
if (summaryRes.status !== 200) {
|
||||
console.error(`Failed to fetch rig summary (HTTP ${summaryRes.status})`);
|
||||
process.exitCode = 1;
|
||||
@@ -80,6 +106,13 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
// cmux status
|
||||
const cmuxAvailable = cmuxRes?.data?.available ?? false;
|
||||
console.log(`cmux: ${cmuxAvailable ? "available" : "unavailable"}`);
|
||||
|
||||
// OPR.0.3.3.04.2 (AC-1): reinforcing HINT back to the one canonical ordered
|
||||
// path - status does not re-author the sequence (that lives in `rig setup`
|
||||
// next-steps + docs/reference/getting-started.md).
|
||||
if (rigs.length === 0) {
|
||||
console.log("\nNext: launch a rig with `rig up <rig-spec>`. Guided path: `rig setup` output or docs/reference/getting-started.md");
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// OPR.0.4.6.02 C3 — the `rig terminal` command family (the TUI-1 opaque verb +
|
||||
// the operator's terminal-provider-ride entry). Three subcommands, all hitting
|
||||
// the ONE canonical daemon composer (`/api/terminal/...`):
|
||||
//
|
||||
// rig terminal open <view> [--provider herdr|cmux] [--json]
|
||||
// rig terminal views [--json]
|
||||
// rig terminal status [--provider herdr|cmux] [--json]
|
||||
//
|
||||
// `<view>` resolves daemon-side: a rig name (per-rig derived) | `mission:<id>` |
|
||||
// `slice:<id>` (derived) | a saved-view id. The result is the ONE shared
|
||||
// `{ opened, absent, degraded }` partition, carried byte-identically here and
|
||||
// in the route JSON (arch Q3).
|
||||
//
|
||||
// Exit semantics (PRD / arch Q3): a partial open WITH NAMES is a SUCCESS with
|
||||
// disclosure → exit 0; a ZERO-pane open (nothing tiled: unknown view, provider
|
||||
// down, every seat absent/degraded) → non-zero. `views`/`status` are always
|
||||
// exit 0 unless the daemon is unreachable.
|
||||
|
||||
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";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface TerminalDeps extends StatusDeps {}
|
||||
|
||||
/** The one shared open-result shape (mirrors the daemon `OpenViewResult`). */
|
||||
interface OpenViewResult {
|
||||
provider: string;
|
||||
ok: boolean;
|
||||
opened: string[];
|
||||
absent: { seat: string; host: string | null; reason: string }[];
|
||||
degraded: { seat: string; host: string; reason: string }[];
|
||||
pages: number;
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: TerminalDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
/** Print a plain daemon response (views/status). JSON = compact; human = pretty. */
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
console.log(json ? JSON.stringify(body) : JSON.stringify(body, null, 2));
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
/** Human-format the honest-partial open result (opened/absent/degraded, each named). */
|
||||
function humanOpen(r: OpenViewResult): string {
|
||||
const lines: string[] = [];
|
||||
const tiled = r.opened.length;
|
||||
lines.push(
|
||||
tiled > 0
|
||||
? `Opened ${tiled} tile(s) in ${r.provider}${r.pages > 1 ? ` across ${r.pages} page(s)` : ""}.`
|
||||
: `No tiles opened in ${r.provider}.`,
|
||||
);
|
||||
if (r.error) lines.push(` provider: ${r.error}${r.code ? ` (${r.code})` : ""}`);
|
||||
for (const seat of r.opened) lines.push(` ● ${seat}`);
|
||||
for (const a of r.absent) lines.push(` ○ ${a.seat} — absent: ${a.reason}`);
|
||||
for (const d of r.degraded) lines.push(` ▲ ${d.seat} — skipped (${d.host}): ${d.reason}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Open exit rule: exit 0 iff at least one pane was tiled (partial-with-names is success). */
|
||||
function printOpen(json: boolean, r: OpenViewResult, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(r));
|
||||
} else {
|
||||
console.log(humanOpen(r));
|
||||
}
|
||||
// A 4xx/5xx (bad input / unknown view / service down) OR a zero-pane open is a failure.
|
||||
if (status >= 400 || r.opened.length === 0) {
|
||||
process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function terminalCommand(depsOverride?: TerminalDeps): Command {
|
||||
const cmd = new Command("terminal").description(
|
||||
"Open OpenRig views (agent terminals) as tiles in a terminal provider (herdr / cmux)",
|
||||
);
|
||||
|
||||
const getDeps = (): TerminalDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
cmd
|
||||
.command("open")
|
||||
.argument("<view>", "a rig name, mission:<id>, slice:<id>, or a saved-view id")
|
||||
.description("Open every live agent in the view as an interactive terminal tile")
|
||||
.option("--provider <name>", "terminal provider: herdr (default) or cmux (best-effort)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (view: string, opts: { provider?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const body = { view, ...(opts.provider ? { provider: opts.provider } : {}) };
|
||||
const res = await client.post<OpenViewResult>("/api/terminal/open", body);
|
||||
printOpen(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("views")
|
||||
.description("List saved views + the rigs openable as derived views")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/terminal/views");
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Show terminal provider availability + liveness (doctor)")
|
||||
.option("--provider <name>", "restrict to one provider (herdr / cmux)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { provider?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const path = opts.provider
|
||||
? `/api/terminal/status?provider=${encodeURIComponent(opts.provider)}`
|
||||
: "/api/terminal/status";
|
||||
const res = await client.get<unknown>(path);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost, hostDisplayTarget } from "../host-registry.js";
|
||||
import { emitCrossHostError, emitRemoteHttpFailure } from "../cross-host-cli-helpers.js";
|
||||
import { resolveCrossHostTarget } from "../cross-host-target.js";
|
||||
import { runRemoteHttpOp } from "../remote-host-ops.js";
|
||||
|
||||
export function transcriptCommand(depsOverride?: StatusDeps): Command {
|
||||
export interface TranscriptDeps extends StatusDeps {
|
||||
/** Test seam: inject a registry loader so no real ~/.openrig is touched. */
|
||||
hostRegistryLoader?: () => ReturnType<typeof loadHostRegistry>;
|
||||
}
|
||||
|
||||
export function transcriptCommand(depsOverride?: TranscriptDeps): Command {
|
||||
const cmd = new Command("transcript").description("Read agent transcript output");
|
||||
const getDeps = (): StatusDeps => depsOverride ?? {
|
||||
const getDeps = (): TranscriptDeps => depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
@@ -15,14 +25,56 @@ export function transcriptCommand(depsOverride?: StatusDeps): Command {
|
||||
.argument("<session>", "Session name (e.g. dev-impl@my-rig)")
|
||||
.option("--tail <lines>", "Show last N lines (default: 50)", "50")
|
||||
.option("--grep <pattern>", "Search for lines matching pattern (regex)")
|
||||
.option("--host <id>", "Read from a remote host declared in ~/.openrig/hosts.yaml (http hosts only — CLI-direct to the remote daemon's transcript routes)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
rig transcript dev-impl@my-rig --tail 100
|
||||
rig transcript dev-impl@my-rig --grep "decision|architecture"
|
||||
rig transcript dev-impl@my-rig --json`)
|
||||
.action(async (session: string, opts: { tail?: string; grep?: string; json?: boolean }) => {
|
||||
rig transcript dev-impl@my-rig --json
|
||||
rig transcript --host vps-b dev-impl@my-rig --tail 100
|
||||
rig transcript dev-impl@my-rig@vps-b --grep "handoff"
|
||||
|
||||
--host reads the transcript from a remote host declared in
|
||||
~/.openrig/hosts.yaml, CLI-direct against that daemon's shipped transcript
|
||||
routes (http-registered hosts, e.g. pair-registered; an ssh-registered host
|
||||
fails with the structured transport-requirement error). Output shape is the
|
||||
origin's, verbatim. A session of the form agent@rig@host is sugar for --host
|
||||
when the suffix is a REGISTERED host id (explicit --host > sugar > persisted
|
||||
selection).`)
|
||||
.action(async (session: string, opts: { tail?: string; grep?: string; host?: string; json?: boolean }) => {
|
||||
// OPR.0.4.6.MH4 C2 — cross-host observe: explicit --host > the
|
||||
// `agent@rig@host` target sugar > the persisted host selection
|
||||
// (resolveEffectiveHost). The local path below is byte-untouched.
|
||||
const explicitHost = opts.host;
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDeps();
|
||||
|
||||
const targetResolution = resolveCrossHostTarget(session, explicitHost, deps.hostRegistryLoader);
|
||||
if (!targetResolution.ok) {
|
||||
console.error(targetResolution.error);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
session = targetResolution.target;
|
||||
const crossHostHint = targetResolution.hint;
|
||||
opts.host = explicitHost ?? targetResolution.sugarHost ?? opts.host;
|
||||
|
||||
// --grep takes precedence over --tail when both given
|
||||
const useGrep = !!opts.grep;
|
||||
const tailLines = parseInt(opts.tail ?? "50", 10);
|
||||
const apiPath = useGrep
|
||||
? `/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`
|
||||
: `/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(tailLines) ? 50 : tailLines}`;
|
||||
|
||||
// --- Cross-host path (CLI-direct GET to the remote daemon's shipped
|
||||
// transcript routes — the SAME paths the local path builds, origin
|
||||
// shape verbatim; read-class deadline; daemon untouched). ---
|
||||
if (opts.host) {
|
||||
await runCrossHostTranscript(opts.host, apiPath, useGrep, opts, deps, crossHostHint);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
@@ -33,20 +85,7 @@ Examples:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// --grep takes precedence over --tail when both given
|
||||
const useGrep = !!opts.grep;
|
||||
let res: { status: number; data: Record<string, unknown> };
|
||||
|
||||
if (useGrep) {
|
||||
res = await client.get<Record<string, unknown>>(
|
||||
`/api/transcripts/${encodeURIComponent(session)}/grep?pattern=${encodeURIComponent(opts.grep!)}`,
|
||||
);
|
||||
} else {
|
||||
const lines = parseInt(opts.tail ?? "50", 10);
|
||||
res = await client.get<Record<string, unknown>>(
|
||||
`/api/transcripts/${encodeURIComponent(session)}/tail?lines=${isNaN(lines) ? 50 : lines}`,
|
||||
);
|
||||
}
|
||||
const res = await client.get<Record<string, unknown>>(apiPath);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -57,30 +96,90 @@ Examples:
|
||||
if (res.status >= 400) {
|
||||
const error = (res.data as Record<string, unknown>)["error"] as string | undefined;
|
||||
console.error(error ?? `Transcript request failed (HTTP ${res.status})`);
|
||||
// MH-4 §4 loud-failure hint: 3-part-shaped target, unregistered suffix.
|
||||
if (crossHostHint) console.error(`hint: ${crossHostHint}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (useGrep) {
|
||||
const matches = (res.data as Record<string, unknown>)["matches"] as string[] | undefined;
|
||||
if (matches && matches.length > 0) {
|
||||
for (const line of matches) {
|
||||
console.log(line);
|
||||
}
|
||||
} else {
|
||||
console.log("No matches found.");
|
||||
}
|
||||
} else {
|
||||
const content = (res.data as Record<string, unknown>)["content"] as string | undefined;
|
||||
if (content) {
|
||||
// Print each line via console.log for consistent capture in tests and terminal
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line) console.log(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderTranscript(res.data, useGrep);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 C2 — cross-host transcript over http, CLI-DIRECT to the
|
||||
* remote daemon's shipped GET /api/transcripts/:session/tail | /grep
|
||||
* (zero daemon-side changes; the MH-2 read-through PATTERN, one hop from
|
||||
* the bearer-capable CLI). An ssh-registered host surfaces runRemoteHttpOp's
|
||||
* structured transport error — never a silent wrong-transport attempt.
|
||||
*/
|
||||
async function runCrossHostTranscript(
|
||||
hostId: string,
|
||||
apiPath: string,
|
||||
useGrep: boolean,
|
||||
opts: { json?: boolean },
|
||||
deps: TranscriptDeps,
|
||||
hint?: string,
|
||||
): Promise<void> {
|
||||
// Resolve the host in the caller (the send/capture pattern) so an unknown
|
||||
// host surfaces as the SAME `unknown-host` step class across all four
|
||||
// verbs, and the banner carries the display target uniformly.
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
emitCrossHostError(hostId, "registry-load-failed", registry.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const resolved = resolveHost(registry.registry, hostId);
|
||||
if (!resolved.ok) {
|
||||
emitCrossHostError(hostId, "unknown-host", hint ? `${resolved.error} (${hint})` : resolved.error, opts.json);
|
||||
return;
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
const result = await runRemoteHttpOp(hostId, "GET", apiPath, undefined, deps, {});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
cross_host: { host: host.id, target: hostDisplayTarget(host), transport: "http" },
|
||||
result,
|
||||
...(!result.ok && hint ? { hint } : {}),
|
||||
}));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
emitRemoteHttpFailure(host.id, hostDisplayTarget(host), result, false, hint);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
renderTranscript((result.data ?? {}) as Record<string, unknown>, useGrep);
|
||||
}
|
||||
|
||||
/** One renderer, two callers — the remote output shape is the origin's,
|
||||
* so local and cross-host reads render identically. */
|
||||
function renderTranscript(data: Record<string, unknown>, useGrep: boolean): void {
|
||||
if (useGrep) {
|
||||
const matches = data["matches"] as string[] | undefined;
|
||||
if (matches && matches.length > 0) {
|
||||
for (const line of matches) {
|
||||
console.log(line);
|
||||
}
|
||||
} else {
|
||||
console.log("No matches found.");
|
||||
}
|
||||
} else {
|
||||
const content = data["content"] as string | undefined;
|
||||
if (content) {
|
||||
// Print each line via console.log for consistent capture in tests and terminal
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line) console.log(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
interface UnarchiveResult {
|
||||
ok: boolean;
|
||||
rigId: string;
|
||||
unarchived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* `rig unarchive <rigId>` - OPR.0.3.3.19. Reverse of `rig archive`: clears the
|
||||
* `archived_at` flag so the rig returns to the default explorer + `rig ps` view.
|
||||
* Always non-destructive (the row and snapshots were retained while archived);
|
||||
* no `--force` and no running-rig guard - unarchiving only makes a rig visible.
|
||||
*/
|
||||
export function unarchiveCommand(depsOverride?: StatusDeps): Command {
|
||||
const cmd = new Command("unarchive").description(
|
||||
"Unarchive a rig (reverse of 'rig archive'): returns it to the default view. Always safe.",
|
||||
);
|
||||
const getDepsF = () => depsOverride ?? { lifecycleDeps: realDeps(), clientFactory: (url: string) => new DaemonClient(url) };
|
||||
|
||||
cmd
|
||||
.argument("<rigId>", "Rig identifier to unarchive")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (rigId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDepsF();
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
const res = await client.post<UnarchiveResult | { error: string }>(
|
||||
`/api/rigs/${encodeURIComponent(rigId)}/unarchive`,
|
||||
{},
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
console.error(`Rig not found: ${rigId}. List archived rigs with: rig ps --include-archived`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
console.error(`Unarchive failed (HTTP ${res.status}).`);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const r = res.data as UnarchiveResult;
|
||||
if (r.unarchived) {
|
||||
console.log(`Rig ${rigId} unarchived. It is back in the default view.`);
|
||||
} else {
|
||||
console.log(`Rig ${rigId} was not archived.`);
|
||||
}
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, terminalAuthHeaders } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -32,7 +32,7 @@ Notes:
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await client.post<Record<string, unknown>>(`/api/sessions/${encodeURIComponent(sessionRef)}/unclaim`, {});
|
||||
const res = await client.post<Record<string, unknown>>(`/api/sessions/${encodeURIComponent(sessionRef)}/unclaim`, {}, { headers: terminalAuthHeaders() });
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
|
||||
+334
-14
@@ -1,16 +1,66 @@
|
||||
import nodePath from "node:path";
|
||||
import { resolveEffectiveHost } from "../host-selection.js";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { parse as parseYamlDoc } from "yaml";
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { DaemonClient, DaemonConnectionError } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl, startDaemon, type LifecycleDeps } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
const LONG_RUNNING_UP_TIMEOUT_MS = 120_000;
|
||||
|
||||
/** OPR.0.3.4.2 — default interactive [y/N] prompt for the awaiting-decision
|
||||
* ASK (TTY only; tests inject promptYesNo instead). Default answer: No. */
|
||||
async function defaultPromptYesNo(question: string): Promise<boolean> {
|
||||
const readline = await import("node:readline");
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise<boolean>((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(/^y(es)?$/i.test(answer.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// OPR.0.3.2.22 Bug 3 helper — mirrors cwd-resolution.isPathInsideRoot in
|
||||
// the daemon so the CLI can decide whether a path-form sourceRef lives
|
||||
// inside the daemon install root without importing the daemon package.
|
||||
function isPathInsideRoot(candidate: string, root: string): boolean {
|
||||
const relative = nodePath.relative(nodePath.resolve(root), nodePath.resolve(candidate));
|
||||
return relative === "" || (!relative.startsWith("..") && !nodePath.isAbsolute(relative));
|
||||
}
|
||||
|
||||
// OPR.0.4.4.11 (arch return R11-2) — pre-dispatch detection of a topology
|
||||
// source for the --host rejection. Mirrors the daemon's FR-1 detection
|
||||
// contract without importing the daemon package: `.rigtopology` extension,
|
||||
// or a readable path-form YAML document with a top-level `rigs:` LIST.
|
||||
export function sourceLooksLikeTopology(source: string): boolean {
|
||||
if (/\.rigtopology$/i.test(source)) return true;
|
||||
// Name-form sources (no slash, no known extension) keep rig-name
|
||||
// semantics — never sniffed (same precedence as the daemon router).
|
||||
if (!source.includes("/") && !source.match(/\.(ya?ml)$/i)) return false;
|
||||
try {
|
||||
const p = nodePath.resolve(source);
|
||||
if (!existsSync(p)) return false;
|
||||
const parsed: unknown = parseYamlDoc(readFileSync(p, "utf-8"));
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
||||
return Array.isArray((parsed as Record<string, unknown>)["rigs"]);
|
||||
} catch {
|
||||
return false; // unreadable/unparseable — existing flows own the error
|
||||
}
|
||||
}
|
||||
|
||||
export const HOST_TOPOLOGY_REJECTION =
|
||||
"rig up --host cannot take a topology source: per-entry 'host:' in the manifest is the ONLY placement mechanism for topologies (two placement mechanisms must not coexist). Put 'host: <id>' on the entries you want placed remotely and run 'rig up <topology>' without --host.";
|
||||
|
||||
export function upCommand(
|
||||
depsOverride?: StatusDeps & {
|
||||
lifecycleDeps?: LifecycleDeps;
|
||||
preflightExec?: (cmd: string) => Promise<string>;
|
||||
/** OPR.0.3.4.2 — injectable [y/N] prompt for the awaiting-decision ASK
|
||||
* (tests drive it; default = readline on stdin, offered on TTY only). */
|
||||
promptYesNo?: (question: string) => Promise<boolean>;
|
||||
},
|
||||
): Command {
|
||||
const cmd = new Command("up")
|
||||
@@ -30,10 +80,56 @@ Examples:
|
||||
.option("--cwd <path>", "Override launch working directory for all members for this run only")
|
||||
.option("--target <root>", "Target root directory for package installation (.rigbundle only; does not change agent cwd)")
|
||||
.option("--existing", "Treat <source> as an existing rig name; bypass library-spec name resolution")
|
||||
.option("--fresh <seats...>", "Deliberately fresh-prime the named seats (logical ids) instead of resuming their original sessions (operation B; reported as fresh-primed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; json?: boolean }) => {
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.action(async (source: string, opts: { plan?: boolean; yes?: boolean; cwd?: string; target?: string; existing?: boolean; fresh?: string[]; json?: boolean; host?: string }) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly. Topology
|
||||
// sources are EXEMPT: per-entry host: is the only topology placement
|
||||
// (shipped R11-2); implicit selection must not turn a flagless
|
||||
// topology up into the rejected --host form.
|
||||
if (!sourceLooksLikeTopology(source)) opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDepsF();
|
||||
|
||||
if (opts.host) {
|
||||
// OPR.0.4.4.11 R11-2: --host + topology source is REJECTED before
|
||||
// any dispatch — per-entry 'host:' is the only topology placement
|
||||
// mechanism. The daemon route carries the same rejection on its
|
||||
// public write path (double-sided enforcement, arch ruling 4).
|
||||
if (sourceLooksLikeTopology(source)) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: HOST_TOPOLOGY_REJECTION, code: "host_flag_topology" }));
|
||||
} else {
|
||||
console.error(HOST_TOPOLOGY_REJECTION);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const { runRemoteHttpOp } = await import("../remote-host-ops.js");
|
||||
const body = {
|
||||
sourceRef: source,
|
||||
plan: opts.plan,
|
||||
autoApprove: opts.yes,
|
||||
cwdOverride: opts.cwd,
|
||||
targetRoot: opts.target,
|
||||
existing: opts.existing,
|
||||
freshLogicalIds: opts.fresh,
|
||||
};
|
||||
const result = await runRemoteHttpOp(opts.host, "POST", "/api/up", body, deps, opts);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
} else if (result.ok) {
|
||||
console.log(JSON.stringify(result.data, null, 2));
|
||||
} else {
|
||||
console.error(`Error on host ${opts.host}: ${result.error}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Run preflight before auto-start
|
||||
let status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running") {
|
||||
@@ -113,8 +209,13 @@ Examples:
|
||||
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
|
||||
// Detect rig name vs file path: names don't contain / and don't end in .yaml/.yml/.rigbundle
|
||||
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle)$/i);
|
||||
// Detect rig name vs file path: names don't contain / and don't end in
|
||||
// .yaml/.yml/.rigbundle/.rigtopology. OPR.0.4.4.11 (guard G-1): the
|
||||
// topology extension resolves as a PATH (same as yaml/rigbundle) so a
|
||||
// bare `factory.rigtopology` reaches the daemon's topology routing
|
||||
// instead of library/existing-rig name resolution. Extensionless
|
||||
// no-slash sources keep rig_name precedence byte-for-byte.
|
||||
const isRigName = !source.includes("/") && !source.match(/\.(ya?ml|rigbundle|rigtopology)$/i);
|
||||
let sourceRef = isRigName ? source : nodePath.resolve(source);
|
||||
|
||||
// If it looks like a name, check for library spec match
|
||||
@@ -132,6 +233,54 @@ Examples:
|
||||
}
|
||||
return rigSummariesCache;
|
||||
};
|
||||
// OPR.0.3.3.19 (AC-7): archived rigs are excluded from default `rig up`
|
||||
// name resolution. If <source> matches ONLY an archived rig (no active
|
||||
// rig of that name), refuse with an honest error pointing at
|
||||
// `rig unarchive` - never silently restore an archived rig, never
|
||||
// silently fall through. Applies to both default and --existing paths.
|
||||
if (isRigName) {
|
||||
const activeSummaries = await fetchRigSummaries();
|
||||
const activeMatch = activeSummaries.some((r) => r.name === source);
|
||||
if (!activeMatch) {
|
||||
try {
|
||||
const archRes = await client.get<Array<{ id: string; name: string }>>(
|
||||
"/api/rigs/summary?archived=only",
|
||||
);
|
||||
const archivedMatches = (archRes.data ?? []).filter((r) => r.name === source);
|
||||
if (archivedMatches.length > 0) {
|
||||
// `rig unarchive` resolves by rig ID, not name (it posts to
|
||||
// /api/rigs/<rigId>/unarchive), so the remediation MUST name the
|
||||
// id - telling the operator `rig unarchive <name>` would 404. If
|
||||
// the name is ambiguous across multiple archived rigs, surface the
|
||||
// id list rather than guessing a single target.
|
||||
const ids = archivedMatches.map((r) => r.id);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({
|
||||
error: "rig_archived",
|
||||
rig: source,
|
||||
archivedRigIds: ids,
|
||||
action: ids.length === 1
|
||||
? `rig unarchive ${ids[0]}`
|
||||
: `rig unarchive <rigId> (archived rigs named '${source}': ${ids.join(", ")})`,
|
||||
}));
|
||||
} else if (ids.length === 1) {
|
||||
console.error(`Rig "${source}" is archived, so it is hidden from 'rig up' name resolution.`);
|
||||
console.error(` Bring it back first: rig unarchive ${ids[0]}`);
|
||||
console.error(` Then power it on: rig up ${source}`);
|
||||
} else {
|
||||
console.error(`${ids.length} archived rigs are named "${source}"; they are hidden from 'rig up' name resolution.`);
|
||||
console.error(` Unarchive the one you want by id (then 'rig up'):`);
|
||||
for (const id of ids) console.error(` rig unarchive ${id}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Archived-summary probe failed (e.g. older daemon) - fall through
|
||||
// to normal resolution; there are no archive semantics to enforce.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isRigName && !opts.existing) {
|
||||
try {
|
||||
const { resolveLibrarySpec } = await import("./specs.js");
|
||||
@@ -189,13 +338,78 @@ Examples:
|
||||
const isRigBundle = !isRigName && /\.rigbundle$/i.test(sourceRef);
|
||||
const targetRoot = opts.target ?? (isRigBundle ? process.cwd() : undefined);
|
||||
|
||||
const res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
// OPR.0.3.2.22 Bug 3 — extend the bare `rig up <builtin>` default-cwd
|
||||
// treatment to path-form. Builtin starter specs declare member-level
|
||||
// cwd: "." which resolves to the spec directory (inside the daemon's
|
||||
// install root). Without --cwd that trips getOpenRigInstallCwdError
|
||||
// at preflight. Bare-name form is already rescued at the
|
||||
// resolveLibrarySpec branch above (entry.sourceType === "builtin");
|
||||
// path-form `rig up <install-internal-spec>` is the remaining gap.
|
||||
//
|
||||
// Detection: source is path-form (not isRigName, not isRigBundle),
|
||||
// no --cwd was given, no defaultLibraryCwdOverride was set by the
|
||||
// bare-name branch above, AND the resolved path lives inside the
|
||||
// daemon's install root (fetched via /api/info). When all hold,
|
||||
// default cwdOverride to process.cwd() so the operator's project
|
||||
// dir is used as launch cwd, and print a one-line notice. If
|
||||
// /api/info is unavailable, fall through silently — the daemon
|
||||
// preflight will still surface the install-cwd error.
|
||||
//
|
||||
// Structural redesign of how builtin starter specs declare cwd is
|
||||
// out of scope (deferred to 0.3.3 per the slice triage).
|
||||
if (!opts.cwd && !isRigName && !isRigBundle && defaultLibraryCwdOverride === undefined) {
|
||||
try {
|
||||
// Short timeout: a healthy daemon answers /api/info in <100ms; if
|
||||
// it doesn't, fall through to the daemon's own preflight error
|
||||
// rather than adding a multi-second stall to every rig up.
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { timeoutMs: 2000 });
|
||||
const installRoot = infoRes.data?.installRoot;
|
||||
if (installRoot && isPathInsideRoot(sourceRef, installRoot)) {
|
||||
defaultLibraryCwdOverride = process.cwd();
|
||||
if (!opts.json) {
|
||||
console.log("Defaulting cwd to current directory because the spec lives inside the OpenRig install.");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /api/info unavailable — fall through. The daemon preflight
|
||||
// returns getOpenRigInstallCwdError if this is in fact the
|
||||
// install-internal case; the operator gets the same hint they
|
||||
// would have gotten pre-Bug-3.
|
||||
}
|
||||
}
|
||||
|
||||
// OPR.0.3.4.4 — honest async: a client timeout / connection loss in
|
||||
// APPLY mode does NOT mean the operation failed; the daemon may still
|
||||
// be processing. Report in-progress/unknown with a verify command,
|
||||
// never a bare connection failure (the false-failure that caused the
|
||||
// outage's wrong next move). No operation id is surfaced client-side
|
||||
// on timeout today, so the honest message is the MVP floor.
|
||||
const printHonestTimeout = (err: DaemonConnectionError): void => {
|
||||
console.error(`The CLI timed out waiting for the daemon, but the operation may STILL BE IN PROGRESS.`);
|
||||
console.error(`This does not mean the operation failed; the daemon may still be processing it.`);
|
||||
console.error(`Verify the actual state with: rig ps`);
|
||||
console.error(`(underlying: ${err.message})`);
|
||||
process.exitCode = 1;
|
||||
};
|
||||
|
||||
let res: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
res = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: opts.plan ?? false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
// OPR.0.3.4.2 — operation B opt-in seats (deliberate fresh-prime).
|
||||
freshLogicalIds: opts.fresh,
|
||||
}, opts.plan ? undefined : { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError && !opts.plan) {
|
||||
printHonestTimeout(err);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data));
|
||||
@@ -205,6 +419,25 @@ Examples:
|
||||
return;
|
||||
}
|
||||
|
||||
// OPR.0.4.4.11 — topology aggregate rendering (full success OR honest
|
||||
// partial; the daemon returns the same closed {rigRef, host, status,
|
||||
// error?} entries either way, skipped entries explicitly present).
|
||||
const topoEntries = res.data["entries"] as Array<{ rigRef: string; host: string; status: string; error?: string }> | undefined;
|
||||
if (typeof res.data["topology"] === "string" && Array.isArray(topoEntries)) {
|
||||
const topoOk = res.data["ok"] === true;
|
||||
console.log(
|
||||
topoOk
|
||||
? `Topology up: all ${topoEntries.length} rigs launched.`
|
||||
: "Topology up FAILED — honest partial state (started rigs stay up; no rollback):",
|
||||
);
|
||||
for (const e of topoEntries) {
|
||||
const tag = e.status === "ok" ? " ok " : e.status === "failed" ? "FAIL" : "skip";
|
||||
console.log(` [${tag}] ${e.rigRef} @ ${e.host}${e.error ? ` — ${e.error}` : ""}`);
|
||||
}
|
||||
if (!topoOk) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status >= 400) {
|
||||
const code = res.data["code"] as string | undefined;
|
||||
if (code === "cycle_error") {
|
||||
@@ -217,6 +450,9 @@ Examples:
|
||||
console.error(`Preflight check failed:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: resolve the issues above and retry.`);
|
||||
} else if (code === "pre_restore_validation_failed") {
|
||||
printRestoreNotAttempted(res.data as RestoreNotAttemptedData);
|
||||
} else if (code === "invalid_topology_manifest") {
|
||||
const errors = (res.data["errors"] as string[]) ?? [];
|
||||
console.error(`Topology manifest invalid:\n${errors.map((e) => ` ${e}`).join("\n")}\nFix: the manifest key set is CLOSED — rigs[]{source, host?} plus optional concurrency.`);
|
||||
} else {
|
||||
const errorText = String(res.data["error"] ?? "unknown error");
|
||||
console.error(`Up failed: ${errorText} (HTTP ${res.status}). Check daemon logs or validate your spec with: rig spec validate <path>`);
|
||||
@@ -236,6 +472,35 @@ Examples:
|
||||
// Success output
|
||||
const resStatus = res.data["status"] as string;
|
||||
|
||||
// OPR.0.3.4.4 — read-only restore plan preview (`--plan` on the
|
||||
// existing-rig path). Renders intended per-seat actions; nothing ran.
|
||||
if (resStatus === "plan" && res.data["mode"] === "restore") {
|
||||
const planRigName = res.data["rigName"] as string | undefined;
|
||||
const planSnapshot = res.data["snapshot"] as { id: string; kind: string; createdAt: string } | null;
|
||||
console.log(`Plan: restore rig "${planRigName ?? source}" (read-only preview)`);
|
||||
if (planSnapshot) {
|
||||
console.log(`Snapshot: ${planSnapshot.id} (kind=${planSnapshot.kind}, captured ${planSnapshot.createdAt})`);
|
||||
} else if (res.data["wouldCaptureCurrentState"] === true) {
|
||||
console.log(`Snapshot: none usable — apply would first capture current DB state as an auto-rehydrate snapshot.`);
|
||||
}
|
||||
const planNodes = (res.data["nodes"] as Array<{ logicalId: string; intendedAction: string; reason?: string; tokenState?: string; provenance?: string | null; lastVerified?: string | null; freshRequired?: boolean; runtimePrompt?: string }>) ?? [];
|
||||
for (const n of planNodes) {
|
||||
console.log(` ${n.logicalId}: ${n.intendedAction}${n.reason ? ` — ${n.reason}` : ""}`);
|
||||
// OPR.0.4.3.20 FR-6 — per-seat token truth: present/missing/stale/unverified
|
||||
// + provenance + freshness + expected runtime prompt + explicit --fresh.
|
||||
if (n.tokenState) {
|
||||
const reverify = (n.tokenState === "stale" || n.tokenState === "unverified") ? " — re-verify" : "";
|
||||
const prov = n.provenance ? ` (${n.provenance})` : "";
|
||||
const verified = n.lastVerified ? `, verified ${n.lastVerified}` : "";
|
||||
const fresh = n.freshRequired ? "; --fresh required" : "";
|
||||
const prompt = n.runtimePrompt ? `; ${n.runtimePrompt}` : "";
|
||||
console.log(` token: ${n.tokenState}${reverify}${prov}${verified}${fresh}${prompt}`);
|
||||
}
|
||||
}
|
||||
console.log("No changes made.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (resStatus === "restored") {
|
||||
// Existing-rig power-on handoff
|
||||
const rigId = res.data["rigId"] as string;
|
||||
@@ -251,9 +516,15 @@ Examples:
|
||||
}
|
||||
console.log(`Rig "${rigName ?? rigId}" restored (ID: ${rigId})`);
|
||||
if (rigResult) console.log(`Result: ${rigResult}`);
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
const nodes = (res.data["nodes"] as Array<{ logicalId: string; status: string; error?: string }>) ?? [];
|
||||
for (const n of nodes) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
// OPR.0.3.4.2 — the five-term vocabulary renders distinctly; the
|
||||
// awaiting-decision reason is part of the line (not a dead end).
|
||||
if (n.status === "awaiting-decision" && n.error) {
|
||||
console.log(` ${n.logicalId}: awaiting-decision — ${n.error}`);
|
||||
} else {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
}
|
||||
const warnings = (res.data["warnings"] as string[]) ?? [];
|
||||
for (const w of warnings) {
|
||||
@@ -264,7 +535,56 @@ Examples:
|
||||
if (attachCommand) {
|
||||
console.log(`Attach: ${attachCommand}`);
|
||||
}
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed")) {
|
||||
|
||||
// OPR.0.3.4.2 — the actionable ASK/offer for awaiting-decision seats.
|
||||
// TTY: interactive [y/N] per seat; accepted seats re-run as a
|
||||
// deliberate fresh-prime (operation B). Headless: the machine status
|
||||
// stays awaiting-decision with the explicit --fresh hint. NEVER an
|
||||
// auto-substitution.
|
||||
const awaiting = nodes.filter((n) => n.status === "awaiting-decision");
|
||||
if (awaiting.length > 0) {
|
||||
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) || Boolean(deps.promptYesNo);
|
||||
if (interactive) {
|
||||
const ask = deps.promptYesNo ?? defaultPromptYesNo;
|
||||
const accepted: string[] = [];
|
||||
for (const n of awaiting) {
|
||||
const reason = n.error ?? "original session unresumable";
|
||||
const yes = await ask(`Couldn't resume original session for ${n.logicalId} (reason: ${reason}). Start a fresh primed session instead? [y/N] `);
|
||||
if (yes) accepted.push(n.logicalId);
|
||||
}
|
||||
if (accepted.length > 0) {
|
||||
let freshRes: { status: number; data: Record<string, unknown> };
|
||||
try {
|
||||
freshRes = await client.post<Record<string, unknown>>("/api/up", {
|
||||
sourceRef,
|
||||
plan: false,
|
||||
autoApprove: opts.yes ?? false,
|
||||
cwdOverride: opts.cwd ? nodePath.resolve(opts.cwd) : defaultLibraryCwdOverride,
|
||||
targetRoot,
|
||||
freshLogicalIds: accepted,
|
||||
}, { timeoutMs: LONG_RUNNING_UP_TIMEOUT_MS });
|
||||
} catch (err) {
|
||||
if (err instanceof DaemonConnectionError) {
|
||||
printHonestTimeout(err);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const freshNodes = (freshRes.data["nodes"] as Array<{ logicalId: string; status: string }>) ?? [];
|
||||
for (const n of freshNodes.filter((fn) => accepted.includes(fn.logicalId))) {
|
||||
console.log(` ${n.logicalId}: ${n.status}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` No fresh sessions started. Re-run with --fresh <seat...> when you decide.`);
|
||||
}
|
||||
} else {
|
||||
for (const n of awaiting) {
|
||||
console.error(` ${n.logicalId}: awaiting-decision — no session started. To deliberately fresh-prime: rig up --existing ${source} --fresh ${n.logicalId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rigResult === "partially_restored" || rigResult === "failed" || rigResult === "not_attempted" || nodes.some((n) => n.status === "failed" || n.status === "awaiting-decision")) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -42,7 +42,7 @@ function printResult(json: boolean, body: unknown, status: number): void {
|
||||
|
||||
export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
const cmd = new Command("watchdog").description(
|
||||
"Coordination Watchdog — daemon-native scheduler for periodic-reminder, artifact-pool-ready, edge-artifact-required (PL-004 Phase C; workflow-keepalive ships in Phase D)",
|
||||
"Coordination Watchdog — daemon-native scheduler for periodic-reminder, artifact-pool-ready, edge-artifact-required (PL-004 Phase C; workflow-keepalive + idle-gate-qitem in Phase D)",
|
||||
);
|
||||
const getDeps = (): WatchdogDeps =>
|
||||
depsOverride ?? {
|
||||
@@ -54,7 +54,7 @@ export function watchdogCommand(depsOverride?: WatchdogDeps): Command {
|
||||
.command("register")
|
||||
.description("Register a new watchdog job from a YAML spec file")
|
||||
.requiredOption("--spec <path>", "Path to YAML spec file (policy + target + interval + context)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive)")
|
||||
.requiredOption("--policy <policy>", "Policy name (one of: periodic-reminder, artifact-pool-ready, edge-artifact-required, workflow-keepalive, idle-gate-qitem)")
|
||||
.requiredOption("--target-session <session>", "Canonical <member>@<rig> target")
|
||||
.requiredOption("--interval-seconds <n>", "Evaluation interval (positive integer)")
|
||||
.requiredOption("--registered-by <session>", "Registering session (for audit)")
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Command } from "commander";
|
||||
import { readOwnHostName, resolveEffectiveHost } from "../host-selection.js";
|
||||
import { execSync } from "node:child_process";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { readOpenRigEnv } from "../openrig-compat.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { loadHostRegistry, resolveHost } from "../host-registry.js";
|
||||
import { loadHostRegistry, resolveHost, hostDisplayTarget, resolveRemoteBearer, classifyHttpFailedStep, classifyHttpError, type HttpHostEntry } from "../host-registry.js";
|
||||
import { runCrossHostCommand, type RunCrossHostCommandOpts } from "../cross-host-executor.js";
|
||||
import { emitCrossHostError, emitCrossHostFailure } from "../cross-host-cli-helpers.js";
|
||||
|
||||
@@ -13,7 +14,11 @@ interface WhoamiCliOptions {
|
||||
nodeId?: string;
|
||||
session?: string;
|
||||
host?: string;
|
||||
allHosts?: boolean;
|
||||
hosts?: string;
|
||||
json?: boolean;
|
||||
full?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface WhoamiDeps extends StatusDeps {
|
||||
@@ -64,6 +69,40 @@ interface WhoamiResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.0.27 — project the full whoami payload to the identity-recovery
|
||||
* ALLOWLIST (not a denylist: a future payload field defaults to --full and
|
||||
* cannot silently re-bloat the every-boot path). Carries exactly the fields the
|
||||
* boot + compaction-restore recovery contract treats as ground truth.
|
||||
*/
|
||||
function projectCompactWhoami(data: Record<string, unknown>): Record<string, unknown> {
|
||||
const id = (data["identity"] ?? {}) as Record<string, unknown>;
|
||||
const peers = Array.isArray(data["peers"]) ? (data["peers"] as unknown[]) : [];
|
||||
const transcript = (data["transcript"] ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
resolvedBy: data["resolvedBy"],
|
||||
identity: {
|
||||
rigName: id["rigName"],
|
||||
nodeId: id["nodeId"],
|
||||
logicalId: id["logicalId"],
|
||||
podId: id["podId"],
|
||||
podNamespace: id["podNamespace"],
|
||||
memberId: id["memberId"],
|
||||
sessionName: id["sessionName"],
|
||||
runtime: id["runtime"],
|
||||
},
|
||||
peers: peers.map((p) => {
|
||||
const peer = (p ?? {}) as Record<string, unknown>;
|
||||
return { logicalId: peer["logicalId"], sessionName: peer["sessionName"], runtime: peer["runtime"] };
|
||||
}),
|
||||
// KEEP: openrig-user SKILL.md documents peersNote as a required recovery field.
|
||||
peersNote: data["peersNote"],
|
||||
// edges already carry only kind + to/from {logicalId, sessionName}.
|
||||
edges: data["edges"],
|
||||
transcript: { path: transcript["path"], tailCommand: transcript["tailCommand"] },
|
||||
};
|
||||
}
|
||||
|
||||
type TmuxExecFn = (cmd: string) => string;
|
||||
|
||||
const defaultTmuxExec: TmuxExecFn = (cmd: string) => execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
@@ -163,14 +202,32 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
cmd
|
||||
.option("--node-id <id>", "Resolve by node ID")
|
||||
.option("--session <name>", "Resolve by session name")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml (CLI-side ssh shell-out)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.option("--host <id>", "Run on a remote host declared in ~/.openrig/hosts.yaml")
|
||||
.option("--all-hosts", "Fan out to all registered HTTP hosts")
|
||||
.option("--hosts <ids>", "Fan out to specific hosts (comma-separated)")
|
||||
.option("--json", "JSON output for agents (compact identity-recovery projection by default)")
|
||||
.option("--full", "Show the complete whoami payload (contextUsage, commands, runtimeContext, workspace, all sub-fields)")
|
||||
.option("--verbose", "Alias for --full")
|
||||
.addHelpText("after", `
|
||||
By default rig whoami is COMPACT: identity (rig/pod/member/session/runtime),
|
||||
peers (with sessionName for 'rig send'), edges, and the transcript path — the
|
||||
boot + compaction-restore recovery essentials. Use --full / --verbose for the
|
||||
complete payload (contextUsage, command examples, runtime token detail,
|
||||
workspace block). The compact form omits the Context line; use 'rig context' or
|
||||
'rig whoami --full' for usage.`)
|
||||
.action(async (opts: WhoamiCliOptions) => {
|
||||
// OPR.0.4.6.MH1 FR-2: selected-host routing — explicit --host wins;
|
||||
// else the persisted selection feeds the SHIPPED --host path; no
|
||||
// selection = today exactly.
|
||||
opts.host = resolveEffectiveHost(opts.host);
|
||||
const deps = getDeps();
|
||||
const full = Boolean(opts.full || opts.verbose);
|
||||
|
||||
if (opts.allHosts || opts.hosts) {
|
||||
await runFanOutWhoami(opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Cross-host short-circuit (CLI-side ssh shell-out; daemon untouched) ---
|
||||
// Runs BEFORE local identity-source resolution: the remote rig has its
|
||||
// own daemon and identity context; we just forward the raw flags.
|
||||
if (opts.host) {
|
||||
await runCrossHostWhoami(opts.host, opts, deps);
|
||||
return;
|
||||
@@ -203,11 +260,20 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
else params.set("sessionName", source.sessionName!);
|
||||
const targetRepo = readOpenRigEnv("OPENRIG_TARGET_REPO", "RIGGED_TARGET_REPO");
|
||||
if (targetRepo) params.set("targetRepo", targetRepo);
|
||||
// Compact by default (the every-boot recovery call); --full opts out so
|
||||
// the daemon also skips the contextUsage/runtimeContext compute.
|
||||
if (!full) params.set("compact", "1");
|
||||
|
||||
const res = await client.get<Record<string, unknown>>(`/api/whoami?${params.toString()}`);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
if (full || res.status >= 400) {
|
||||
// --full: today's complete payload (parity). Errors: pass through.
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
} else {
|
||||
// Compact default: the identity-recovery ALLOWLIST projection.
|
||||
console.log(JSON.stringify(projectCompactWhoami(res.data)));
|
||||
}
|
||||
if (res.status >= 400) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -236,6 +302,12 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
// Human-readable output
|
||||
const data = res.data as unknown as WhoamiResult;
|
||||
const id = data.identity;
|
||||
// OPR.0.4.6.MH1 FR-4: the own-host name renders here when RENAMED;
|
||||
// unnamed (default "localhost") keeps today's output byte-identical.
|
||||
const ownHostName = readOwnHostName();
|
||||
if (ownHostName !== "localhost") {
|
||||
console.log(`Host: ${ownHostName}`);
|
||||
}
|
||||
console.log(`Rig: ${id.rigName}`);
|
||||
console.log(`Logical ID: ${id.logicalId}`);
|
||||
console.log(`Pod: ${(id.podNamespace ?? id.podId) ?? "—"} / ${id.memberId}`);
|
||||
@@ -246,7 +318,10 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
|
||||
if (data.peers.length > 0) {
|
||||
console.log("");
|
||||
console.log("Peers:");
|
||||
// OPR.99.0.6.1: name the contract on the header so peers cannot be
|
||||
// misread as the edge-subset or as host inventory. Keeps the literal
|
||||
// `Peers:` prefix (existing output greps key on it).
|
||||
console.log("Peers: (this rig's roster, excluding self — directional edges below; `rig ps --nodes` for inventory incl. self + live state)");
|
||||
for (const peer of data.peers) {
|
||||
console.log(` ${peer.logicalId.padEnd(20)} ${(peer.sessionName ?? "—").padEnd(30)} ${peer.runtime}`);
|
||||
}
|
||||
@@ -269,12 +344,15 @@ export function whoamiCommand(depsOverride?: WhoamiDeps): Command {
|
||||
console.log(` ${data.transcript.tailCommand}`);
|
||||
}
|
||||
|
||||
// Context usage
|
||||
const ctx = data.contextUsage;
|
||||
if (ctx && ctx.availability === "known") {
|
||||
console.log(`Context: ${ctx.usedPercentage}% used (${ctx.remainingPercentage}% remaining, ${ctx.contextWindowSize} window)`);
|
||||
} else {
|
||||
console.log("Context: unknown");
|
||||
// Context usage — OPR.0.4.0.27: shown only in --full (compact omits the
|
||||
// contextUsage payload entirely; use 'rig context' or 'rig whoami --full').
|
||||
if (full) {
|
||||
const ctx = data.contextUsage;
|
||||
if (ctx && ctx.availability === "known") {
|
||||
console.log(`Context: ${ctx.usedPercentage}% used (${ctx.remainingPercentage}% remaining, ${ctx.contextWindowSize} window)`);
|
||||
} else {
|
||||
console.log("Context: unknown");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -301,9 +379,12 @@ async function runCrossHostWhoami(
|
||||
}
|
||||
const host = resolved.host;
|
||||
|
||||
// Reconstruct argv. `rig whoami` has no positional args; just the resolution
|
||||
// flags. Identity resolution happens on the REMOTE rig (each host has its
|
||||
// own daemon + tmux + identity context); we don't pre-resolve locally.
|
||||
if (host.transport === "http") {
|
||||
await runHttpWhoami(host as import("../host-registry.js").HttpHostEntry, opts, deps);
|
||||
return;
|
||||
}
|
||||
|
||||
// SSH path: reconstruct argv.
|
||||
const argv: string[] = ["rig", "whoami"];
|
||||
if (opts.nodeId !== undefined) argv.push("--node-id", opts.nodeId);
|
||||
if (opts.session !== undefined) argv.push("--session", opts.session);
|
||||
@@ -319,15 +400,159 @@ async function runCrossHostWhoami(
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, true);
|
||||
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[via host=${host.id} (${host.target})]`);
|
||||
console.log(`[via host=${host.id} (${hostDisplayTarget(host)})]`);
|
||||
if (result.ok) {
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
return;
|
||||
}
|
||||
emitCrossHostFailure(host.id, host.target, result, false);
|
||||
emitCrossHostFailure(host.id, hostDisplayTarget(host), result, false);
|
||||
}
|
||||
|
||||
async function runHttpWhoami(
|
||||
host: HttpHostEntry,
|
||||
opts: WhoamiCliOptions,
|
||||
deps: WhoamiDeps,
|
||||
): Promise<void> {
|
||||
const bearerResult = resolveRemoteBearer(host);
|
||||
if (!bearerResult.ok) {
|
||||
emitCrossHostError(host.id, bearerResult.failedStep, bearerResult.error, opts.json);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { classifyHttpFailedStep: classifyStatus } = await import("../host-registry.js");
|
||||
const client = deps.clientFactory(host.url);
|
||||
const headers = { Authorization: `Bearer ${bearerResult.token}` };
|
||||
|
||||
try {
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { headers });
|
||||
const infoStep = classifyStatus(infoRes.status);
|
||||
if (infoStep !== "none") {
|
||||
emitCrossHostError(host.id, infoStep, `Remote /api/info returned HTTP ${infoRes.status}`, opts.json);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const psRes = await client.get<Array<{ rigId: string; name: string }>>("/api/ps", { headers });
|
||||
const psStep = classifyStatus(psRes.status);
|
||||
if (psStep !== "none") {
|
||||
emitCrossHostError(host.id, psStep, `Remote /api/ps returned HTTP ${psRes.status}`, opts.json);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = {
|
||||
host: host.id,
|
||||
url: host.url,
|
||||
installRoot: infoRes.data?.installRoot ?? "unknown",
|
||||
rigs: Array.isArray(psRes.data) ? psRes.data.map((r) => ({ id: r.rigId, name: r.name })) : [],
|
||||
};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(identity));
|
||||
} else {
|
||||
console.log(`Host: ${identity.host} (${identity.url})`);
|
||||
console.log(`Install: ${identity.installRoot}`);
|
||||
console.log(`Rigs: ${identity.rigs.length > 0 ? identity.rigs.map((r) => r.name).join(", ") : "(none)"}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const failedStep = classifyHttpError(err);
|
||||
emitCrossHostError(host.id, failedStep, (err as Error).message, opts.json);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function runFanOutWhoami(opts: WhoamiCliOptions, deps: WhoamiDeps): Promise<void> {
|
||||
const loader = deps.hostRegistryLoader ?? loadHostRegistry;
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
console.error(`Error: ${registry.error}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const allHosts = registry.registry.hosts;
|
||||
let targetIds: string[];
|
||||
if (opts.hosts) {
|
||||
targetIds = opts.hosts.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const unknown = targetIds.filter((id) => !allHosts.some((h) => h.id === id));
|
||||
if (unknown.length > 0) {
|
||||
console.error(`Error: unknown host ids: ${unknown.join(", ")}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
targetIds = allHosts.filter((h) => h.transport === "http").map((h) => h.id);
|
||||
}
|
||||
|
||||
interface HostIdentityResult {
|
||||
host: string;
|
||||
ok: boolean;
|
||||
failedStep: string;
|
||||
identity?: { url: string; installRoot: string; rigs: Array<{ id: string; name: string }> };
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const results: HostIdentityResult[] = await Promise.all(
|
||||
targetIds.map(async (id): Promise<HostIdentityResult> => {
|
||||
const host = allHosts.find((h) => h.id === id);
|
||||
if (!host) return { host: id, ok: false, failedStep: "remote-daemon-unreachable", error: `unknown host ${id}` };
|
||||
if (host.transport !== "http") {
|
||||
return { host: id, ok: false, failedStep: "remote-command-failed", error: `host ${id} uses transport ${host.transport}; whoami fan-out requires http` };
|
||||
}
|
||||
const httpHost = host as HttpHostEntry;
|
||||
const bearerResult = resolveRemoteBearer(httpHost);
|
||||
if (!bearerResult.ok) {
|
||||
return { host: id, ok: false, failedStep: bearerResult.failedStep, error: bearerResult.error };
|
||||
}
|
||||
const client = deps.clientFactory(httpHost.url);
|
||||
const headers = { Authorization: `Bearer ${bearerResult.token}` };
|
||||
try {
|
||||
const { classifyHttpFailedStep: classifyStatus } = await import("../host-registry.js");
|
||||
const infoRes = await client.get<{ installRoot?: string }>("/api/info", { headers });
|
||||
if (classifyStatus(infoRes.status) !== "none") {
|
||||
return { host: id, ok: false, failedStep: classifyStatus(infoRes.status), error: `HTTP ${infoRes.status}` };
|
||||
}
|
||||
const psRes = await client.get<Array<{ rigId: string; name: string }>>("/api/ps", { headers });
|
||||
if (classifyStatus(psRes.status) !== "none") {
|
||||
return { host: id, ok: false, failedStep: classifyStatus(psRes.status), error: `HTTP ${psRes.status}` };
|
||||
}
|
||||
return {
|
||||
host: id,
|
||||
ok: true,
|
||||
failedStep: "none",
|
||||
identity: {
|
||||
url: httpHost.url,
|
||||
installRoot: infoRes.data?.installRoot ?? "unknown",
|
||||
rigs: Array.isArray(psRes.data) ? psRes.data.map((r) => ({ id: r.rigId, name: r.name })) : [],
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return { host: id, ok: false, failedStep: classifyHttpError(err), error: (err as Error).message };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const hasFailure = results.some((r) => !r.ok);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ hosts: results }));
|
||||
} else {
|
||||
for (const r of results) {
|
||||
if (r.ok && r.identity) {
|
||||
console.log(`\n[host=${r.host}] ${r.identity.url}`);
|
||||
console.log(` Install: ${r.identity.installRoot}`);
|
||||
console.log(` Rigs: ${r.identity.rigs.length > 0 ? r.identity.rigs.map((g) => g.name).join(", ") : "(none)"}`);
|
||||
} else {
|
||||
console.log(`\n[host=${r.host}] FAILED (${r.failedStep}): ${r.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFailure) process.exitCode = 3;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* OPR.0.4.6.WF3 FR-5 — what/why/fix rendering for the named daemon
|
||||
* rejections (the house 3-part shape; clig.dev's rewrite-for-humans
|
||||
* rule). HUMAN MODE ONLY: `--json` preserves the RAW daemon body
|
||||
* byte-identically so scripts keep the stable error codes; exit codes
|
||||
* are untouched (1=4xx / 2=5xx, plus FR-1's outcome codes).
|
||||
*
|
||||
* Unrecognized error bodies fall back to the raw JSON render — this
|
||||
* module only rewrites what it genuinely understands.
|
||||
*/
|
||||
|
||||
export interface ThreePartRejection {
|
||||
fact: string;
|
||||
consequence: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface RejectionBody {
|
||||
error?: string;
|
||||
message?: string;
|
||||
instanceId?: string;
|
||||
expectedVersion?: number;
|
||||
actualVersion?: number;
|
||||
allowedExits?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a recognized daemon rejection to the 3-part shape, or null when
|
||||
* the body is not a named rejection (caller falls back to raw JSON).
|
||||
*/
|
||||
export function describeDaemonRejection(body: unknown, instanceHint?: string): ThreePartRejection | null {
|
||||
if (typeof body !== "object" || body === null) return null;
|
||||
const b = body as RejectionBody;
|
||||
if (typeof b.error !== "string") return null;
|
||||
const instance = typeof b.instanceId === "string" ? b.instanceId : instanceHint;
|
||||
const traceCmd = instance ? `rig workflow trace ${instance}` : "rig workflow trace <instance>";
|
||||
const showCmd = instance ? `rig workflow show ${instance}` : "rig workflow show <instance>";
|
||||
|
||||
switch (b.error) {
|
||||
case "packet_not_on_frontier":
|
||||
return {
|
||||
fact: `That packet is not on the instance frontier${b.message ? ` (${b.message})` : ""}.`,
|
||||
consequence:
|
||||
"The step it belonged to was already closed, replayed, or re-routed — the frontier has moved past it, so this close cannot apply.",
|
||||
action: `See where the instance actually is: ${traceCmd}`,
|
||||
};
|
||||
case "instance_not_active": {
|
||||
const state = typeof b.message === "string" && b.message.length > 0 ? b.message : "not active";
|
||||
return {
|
||||
fact: `The instance is ${state}.`,
|
||||
consequence: "Only an active instance can advance; terminal and waiting states reject projection.",
|
||||
// OPR.0.4.6.WF5 FR-4: the promised pointer upgrade — resume is
|
||||
// real now. A FAILED instance redrives; other terminal states
|
||||
// still inspect-first.
|
||||
action: `If it is failed, redrive it: rig workflow resume <instanceId> --actor-session <you>. Otherwise inspect: ${showCmd}`,
|
||||
};
|
||||
}
|
||||
case "instance_not_failed":
|
||||
return {
|
||||
fact: "The instance is not in the failed state.",
|
||||
consequence: "resume re-drives FAILED instances only — an active instance needs no resume, and a waiting instance continues via its preserved frontier packet (project), not a redrive.",
|
||||
action: `See the real state first: ${showCmd}`,
|
||||
};
|
||||
case "resume_step_unrecoverable":
|
||||
return {
|
||||
fact: "The instance carries no recorded failed step to rebind to.",
|
||||
consequence: "This is a pre-R2 row without a durable step binding; a redrive has no anchor.",
|
||||
action: "Instantiate a fresh run of the workflow (the trail of this instance remains for the record).",
|
||||
};
|
||||
case "resume_step_missing_from_spec":
|
||||
return {
|
||||
fact: "The failed step no longer exists in the cached workflow spec.",
|
||||
consequence: "The spec drifted after this instance failed; rebinding would route a step the workflow no longer declares.",
|
||||
action: "Restore the step in the spec (re-validate to refresh the cache) or instantiate a fresh run.",
|
||||
};
|
||||
case "instance_version_conflict":
|
||||
return {
|
||||
fact: `A concurrent writer advanced the instance first (expected version ${b.expectedVersion ?? "?"}, actual ${b.actualVersion ?? "?"}).`,
|
||||
consequence: "This projection was rolled back whole; no partial state was written.",
|
||||
action: `Re-read the current state (${traceCmd}), then retry against it.`,
|
||||
};
|
||||
case "exit_not_allowed": {
|
||||
const allowed = Array.isArray(b.allowedExits) && b.allowedExits.length > 0 ? b.allowedExits.join(" | ") : null;
|
||||
return {
|
||||
fact: `That exit is not allowed for this step${b.message ? ` (${b.message})` : ""}.`,
|
||||
consequence: "The step declares which exits it accepts; the daemon rejected the close before any state changed.",
|
||||
action: allowed ? `Use one of: ${allowed}.` : `Check the step's allowed exits in the spec, then re-close: ${traceCmd}`,
|
||||
};
|
||||
}
|
||||
case "no_next_step":
|
||||
return {
|
||||
fact: `The workflow has no next step for that exit${b.message ? ` (${b.message})` : ""}.`,
|
||||
consequence: "The handoff had nowhere to route; the close was rejected whole.",
|
||||
action: `Check the spec's routing for this step, or close terminally (--exit done | failed). Inspect: ${traceCmd}`,
|
||||
};
|
||||
case "next_owner_unresolved":
|
||||
return {
|
||||
fact: `The next step's owner could not be resolved${b.message ? ` (${b.message})` : ""}.`,
|
||||
consequence: "A projected packet needs a destination seat; nothing was routed.",
|
||||
action: "Pass --next-owner <session>, or fix the step's suggested_roles in the spec.",
|
||||
};
|
||||
case "instance_not_found":
|
||||
return {
|
||||
fact: `No such workflow instance${instance ? `: ${instance}` : ""}.`,
|
||||
consequence: "Nothing was read or changed.",
|
||||
action: "List instances: rig workflow list",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Render the 3-part shape as stderr lines (the emit3PartError shape). */
|
||||
export function formatThreePart(rej: ThreePartRejection): string[] {
|
||||
return [`Error: ${rej.fact}`, rej.consequence, rej.action];
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { DaemonClient } from "../client.js";
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF3 FR-1 — the shared follow engine behind `rig workflow
|
||||
* run` and `rig workflow watch`: two verbs, ONE renderer (the argo
|
||||
* shape: same render at attach and live).
|
||||
*
|
||||
* Transport: the SHIPPED workflow SSE endpoint (`GET
|
||||
* /api/workflow/sse`) consumed with the in-repo chatroom reader
|
||||
* pattern (fetch + event-stream line decode). Zero daemon changes
|
||||
* (BR-2) — this file only reads.
|
||||
*
|
||||
* Attach race (arch R2, snapshot-first-then-stream): the stream is
|
||||
* OPENED first, then a state snapshot (`/trace`) is rendered, then
|
||||
* live events flow. Events that duplicate a trail row already in the
|
||||
* snapshot are deduped by priorQitemId, so fast early steps that
|
||||
* close before attach are still shown exactly once.
|
||||
*
|
||||
* Exit codes (arch n1 — outcome codes DISTINCT from error codes):
|
||||
* 0 = workflow completed
|
||||
* 3 = workflow FAILED (the outcome-as-exit-code kubectl default —
|
||||
* a workflow that fails is a command that fails; never collides
|
||||
* with the shipped 1=4xx / 2=5xx transport codes)
|
||||
*/
|
||||
export const EXIT_WORKFLOW_FAILED = 3;
|
||||
|
||||
/** Terminal statuses a follow run resolves on. */
|
||||
const TERMINAL_STATUSES = new Set(["completed", "failed"]);
|
||||
|
||||
export interface FollowInstanceView {
|
||||
instanceId: string;
|
||||
workflowName?: string;
|
||||
status: string;
|
||||
currentStepId?: string | null;
|
||||
currentFrontier?: string[];
|
||||
}
|
||||
|
||||
export interface FollowTrailRow {
|
||||
stepId: string;
|
||||
stepRole?: string;
|
||||
closedAt?: string;
|
||||
closureReason: string;
|
||||
actorSession: string;
|
||||
nextQitemId?: string | null;
|
||||
priorQitemId: string;
|
||||
}
|
||||
|
||||
interface WorkflowEvent {
|
||||
type: string;
|
||||
instanceId?: string;
|
||||
stepId?: string;
|
||||
closureReason?: string;
|
||||
actorSession?: string;
|
||||
priorQitemId?: string;
|
||||
nextQitemId?: string;
|
||||
nextOwner?: string;
|
||||
nextStepId?: string;
|
||||
workflowName?: string;
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FollowIo {
|
||||
/** stdout line sink (tests inject; default console.log). */
|
||||
out: (line: string) => void;
|
||||
/** stderr line sink for honest transport-state notices. */
|
||||
err: (line: string) => void;
|
||||
/** sleep injection so tests never wait wall-clock. */
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
/** fetch injection for the SSE leg (tests stub the stream). */
|
||||
fetchImpl: typeof fetch;
|
||||
}
|
||||
|
||||
export function realFollowIo(): FollowIo {
|
||||
return {
|
||||
out: (line) => console.log(line),
|
||||
err: (line) => process.stderr.write(`${line}\n`),
|
||||
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
fetchImpl: fetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface FollowOptions {
|
||||
json: boolean;
|
||||
io?: FollowIo;
|
||||
/** SSE reconnect attempts before degrading to the poll fallback. */
|
||||
maxReconnects?: number;
|
||||
/** Poll fallback interval (ms). */
|
||||
pollIntervalMs?: number;
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<string, string> = {
|
||||
completed: "✔",
|
||||
failed: "✖",
|
||||
active: "●",
|
||||
waiting: "◐",
|
||||
};
|
||||
|
||||
function glyphFor(status: string): string {
|
||||
return STATUS_GLYPH[status] ?? "●";
|
||||
}
|
||||
|
||||
function renderTrailRow(row: FollowTrailRow): string {
|
||||
const exitGlyph = row.closureReason === "failed" ? "✖" : "✔";
|
||||
const next = row.nextQitemId ? ` → ${row.nextQitemId}` : "";
|
||||
return ` ${exitGlyph} ${row.stepId} ${row.closureReason} by ${row.actorSession}${next}`;
|
||||
}
|
||||
|
||||
function renderEvent(event: WorkflowEvent): string | null {
|
||||
switch (event.type) {
|
||||
case "workflow.instantiated":
|
||||
return ` ● instance ${event.instanceId ?? ""} created${event.workflowName ? ` (${event.workflowName})` : ""}`;
|
||||
case "workflow.step_closed":
|
||||
return ` ${event.closureReason === "failed" ? "✖" : "✔"} ${event.stepId ?? "(step)"} ${event.closureReason ?? ""} by ${event.actorSession ?? "(unknown)"}`;
|
||||
case "workflow.next_qitem_projected":
|
||||
return ` → ${event.nextStepId ?? "(next)"} owner ${event.nextOwner ?? "(unresolved)"} packet ${event.nextQitemId ?? ""}`;
|
||||
case "workflow.completed":
|
||||
return ` ✔ workflow completed`;
|
||||
case "workflow.failed":
|
||||
return ` ✖ workflow FAILED: ${event.reason ?? "(no reason recorded)"}`;
|
||||
default:
|
||||
// routing_table_changed and future additive types render as a
|
||||
// neutral one-liner rather than being silently dropped.
|
||||
return ` · ${event.type}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Outcome → process exit code, per the FR-1 contract. */
|
||||
export function outcomeExitCode(status: string): number {
|
||||
if (status === "failed") return EXIT_WORKFLOW_FAILED;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface SnapshotResult {
|
||||
instance: FollowInstanceView;
|
||||
trail: FollowTrailRow[];
|
||||
}
|
||||
|
||||
async function fetchSnapshot(
|
||||
client: DaemonClient,
|
||||
instanceId: string,
|
||||
): Promise<{ ok: true; snapshot: SnapshotResult } | { ok: false; status: number; body: unknown }> {
|
||||
const res = await client.get<{ instance?: FollowInstanceView; trail?: FollowTrailRow[] }>(
|
||||
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
|
||||
);
|
||||
if (res.status >= 400 || !res.data?.instance) {
|
||||
return { ok: false, status: res.status, body: res.data };
|
||||
}
|
||||
return { ok: true, snapshot: { instance: res.data.instance, trail: res.data.trail ?? [] } };
|
||||
}
|
||||
|
||||
function renderSnapshot(snapshot: SnapshotResult, io: FollowIo, json: boolean): void {
|
||||
if (json) {
|
||||
io.out(JSON.stringify({ type: "snapshot", instance: snapshot.instance, trail: snapshot.trail }));
|
||||
return;
|
||||
}
|
||||
const inst = snapshot.instance;
|
||||
io.out(`${glyphFor(inst.status)} ${inst.instanceId}${inst.workflowName ? ` ${inst.workflowName}` : ""} status=${inst.status}`);
|
||||
for (const row of snapshot.trail) io.out(renderTrailRow(row));
|
||||
if (inst.currentStepId) {
|
||||
io.out(` ▸ at step ${inst.currentStepId} frontier=[${(inst.currentFrontier ?? []).join(", ")}]`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one instance to a terminal state. Returns the exit code the
|
||||
* caller should set (0 completed / 3 failed / 1 transport-4xx / 2
|
||||
* transport-5xx). Never throws for stream drops — those degrade
|
||||
* honestly (reconnect notice → poll-fallback notice), per the
|
||||
* chatroom precedent: the render may fall back, it never freezes
|
||||
* silently.
|
||||
*/
|
||||
export async function followInstance(
|
||||
client: DaemonClient,
|
||||
instanceId: string,
|
||||
opts: FollowOptions,
|
||||
): Promise<number> {
|
||||
const io = opts.io ?? realFollowIo();
|
||||
const maxReconnects = opts.maxReconnects ?? 3;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? 3000;
|
||||
|
||||
// Walk-caught (VM iteration 2): without aborting the SSE connection
|
||||
// on return, the open socket keeps the node event loop alive and the
|
||||
// process hangs ~minutes after the terminal event before exiting —
|
||||
// a kubectl-style verb must exit PROMPTLY on outcome. Every return
|
||||
// path aborts via the finally below.
|
||||
const aborter = new AbortController();
|
||||
try {
|
||||
// 1. Open the stream FIRST (arch R2) so no event can fall between
|
||||
// snapshot and attach. Events are buffered by the reader until
|
||||
// the snapshot has rendered.
|
||||
const sseUrl = `${client.baseUrl}/api/workflow/sse`;
|
||||
let streamRes: Response | null = null;
|
||||
try {
|
||||
streamRes = await io.fetchImpl(sseUrl, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: aborter.signal,
|
||||
});
|
||||
if (!streamRes.ok || !streamRes.body) streamRes = null;
|
||||
} catch {
|
||||
streamRes = null;
|
||||
}
|
||||
|
||||
// 2. Snapshot + render.
|
||||
const snap = await fetchSnapshot(client, instanceId);
|
||||
if (!snap.ok) {
|
||||
io.out(JSON.stringify(snap.body ?? { error: "trace_failed" }, null, opts.json ? 0 : 2));
|
||||
return snap.status >= 500 ? 2 : 1;
|
||||
}
|
||||
renderSnapshot(snap.snapshot, io, opts.json);
|
||||
if (TERMINAL_STATUSES.has(snap.snapshot.instance.status)) {
|
||||
return outcomeExitCode(snap.snapshot.instance.status);
|
||||
}
|
||||
|
||||
// Dedup guard: trail rows already rendered by the snapshot must not
|
||||
// re-render when the (already-open) stream replays their events.
|
||||
const seenClosures = new Set(snap.snapshot.trail.map((row) => row.priorQitemId));
|
||||
|
||||
let reconnectsLeft = maxReconnects;
|
||||
// 3. Stream loop with honest degradation.
|
||||
while (true) {
|
||||
if (streamRes?.body) {
|
||||
const outcome = await consumeStream(streamRes.body, instanceId, seenClosures, io, opts.json);
|
||||
if (outcome !== null) return outcome;
|
||||
// Stream ended without a terminal event — the drop path.
|
||||
streamRes = null;
|
||||
}
|
||||
if (reconnectsLeft > 0) {
|
||||
reconnectsLeft -= 1;
|
||||
io.err(`stream dropped — reconnecting (${maxReconnects - reconnectsLeft}/${maxReconnects})`);
|
||||
try {
|
||||
const retry = await io.fetchImpl(sseUrl, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: aborter.signal,
|
||||
});
|
||||
if (retry.ok && retry.body) {
|
||||
streamRes = retry;
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// fall through to next reconnect / poll fallback
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// 4. Poll fallback — announced, never silent (the chatroom rule).
|
||||
io.err(`stream unavailable — degrading to poll fallback every ${Math.round(pollIntervalMs / 1000)}s`);
|
||||
while (true) {
|
||||
await io.sleep(pollIntervalMs);
|
||||
const poll = await fetchSnapshot(client, instanceId);
|
||||
if (!poll.ok) {
|
||||
io.out(JSON.stringify(poll.body ?? { error: "poll_failed" }, null, opts.json ? 0 : 2));
|
||||
return poll.status >= 500 ? 2 : 1;
|
||||
}
|
||||
// Render only trail rows not yet seen, preserving exactly-once.
|
||||
for (const row of poll.snapshot.trail) {
|
||||
if (seenClosures.has(row.priorQitemId)) continue;
|
||||
seenClosures.add(row.priorQitemId);
|
||||
if (opts.json) io.out(JSON.stringify({ type: "trail", row }));
|
||||
else io.out(renderTrailRow(row));
|
||||
}
|
||||
if (TERMINAL_STATUSES.has(poll.snapshot.instance.status)) {
|
||||
if (opts.json) io.out(JSON.stringify({ type: "terminal", status: poll.snapshot.instance.status }));
|
||||
else io.out(` ${glyphFor(poll.snapshot.instance.status)} workflow ${poll.snapshot.instance.status}`);
|
||||
return outcomeExitCode(poll.snapshot.instance.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Kill the SSE socket so the process exits promptly on outcome
|
||||
// (the walk-caught hang: an un-aborted keep-alive held the event
|
||||
// loop open for minutes after workflow.completed).
|
||||
aborter.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one SSE body until a terminal event for the instance (→
|
||||
* exit code) or stream end (→ null: caller reconnects/degrades).
|
||||
*/
|
||||
async function consumeStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
instanceId: string,
|
||||
seenClosures: Set<string>,
|
||||
io: FollowIo,
|
||||
json: boolean,
|
||||
): Promise<number | null> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return null;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
let event: WorkflowEvent;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim()) as WorkflowEvent;
|
||||
} catch {
|
||||
continue; // malformed data line — skip (chatroom precedent)
|
||||
}
|
||||
if (event.instanceId !== instanceId) continue;
|
||||
if (event.type === "workflow.step_closed" && typeof event.priorQitemId === "string") {
|
||||
if (seenClosures.has(event.priorQitemId)) continue; // snapshot already showed it
|
||||
seenClosures.add(event.priorQitemId);
|
||||
}
|
||||
if (json) {
|
||||
io.out(JSON.stringify(event));
|
||||
} else {
|
||||
const rendered = renderEvent(event);
|
||||
if (rendered !== null) io.out(rendered);
|
||||
}
|
||||
if (event.type === "workflow.completed") return 0;
|
||||
if (event.type === "workflow.failed") return EXIT_WORKFLOW_FAILED;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null; // read error = drop; caller handles honestly
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* OPR.0.4.6.WF3 FR-2 — the glanceable human renderers for
|
||||
* trace/list/show. RENDER-SIDE ONLY (BR-2): these functions format
|
||||
* daemon payloads the CLI already receives; `--json` paths never come
|
||||
* through here and stay byte-identical to the shipped output.
|
||||
*
|
||||
* Shapes ported from in-repo precedent: the `rig ps` table mechanics
|
||||
* (fitCell/truncate) and the argo-get per-step tree bar (STEP / ACTOR
|
||||
* / EXIT / DURATION columns with status glyphs).
|
||||
*
|
||||
* Present-tolerant fields: WF-2's branch/gate additions
|
||||
* (closureEvidence.branch_taken etc.) render when the payload carries
|
||||
* them and leave no residue when absent — this module never requires
|
||||
* them (WF-3 builds against the pre-WF-2 tip; the fields land with
|
||||
* WF-2).
|
||||
*/
|
||||
|
||||
export interface RenderInstance {
|
||||
instanceId: string;
|
||||
workflowName?: string;
|
||||
workflowVersion?: string;
|
||||
status: string;
|
||||
createdBySession?: string;
|
||||
createdAt?: string;
|
||||
currentStepId?: string | null;
|
||||
currentFrontier?: string[];
|
||||
hopCount?: number;
|
||||
/** OPR.0.4.6.FAC1: the instance's bound rig (API-carried; null/absent = unbound, renders nothing). */
|
||||
boundRig?: string | null;
|
||||
/**
|
||||
* WF-1 FR-2's API-carried classification (present since the
|
||||
* completion fixback at 384e60f1). RAIL 1 (arch ruling): the CLI
|
||||
* CONSUMES this verbatim and never recomputes a threshold or class.
|
||||
*/
|
||||
deadline?: {
|
||||
state: string;
|
||||
evidence?: {
|
||||
stepId?: string | null;
|
||||
ownerSession?: string;
|
||||
overdueBySeconds?: number;
|
||||
ageSeconds?: number;
|
||||
} | null;
|
||||
};
|
||||
/** Present on waiting instances that recorded their park decision. */
|
||||
lastContinuationDecision?: { blockedOn?: string | null } | null;
|
||||
}
|
||||
|
||||
export interface RenderTrailRow {
|
||||
stepId: string;
|
||||
stepRole?: string;
|
||||
closedAt?: string;
|
||||
closureReason: string;
|
||||
closureEvidence?: Record<string, unknown> | null;
|
||||
actorSession: string;
|
||||
nextQitemId?: string | null;
|
||||
priorQitemId?: string;
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<string, string> = {
|
||||
completed: "✔",
|
||||
failed: "✖",
|
||||
active: "●",
|
||||
waiting: "◐",
|
||||
};
|
||||
|
||||
export function statusGlyph(status: string): string {
|
||||
return STATUS_GLYPH[status] ?? "●";
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
|
||||
function fitCell(value: string, width: number): string {
|
||||
return truncate(value, width).padEnd(width);
|
||||
}
|
||||
|
||||
/** Compact human duration between two ISO timestamps ("3s", "4m", "2h", "5d"). */
|
||||
export function humanDuration(fromIso: string | undefined, toIso: string | undefined): string {
|
||||
if (!fromIso || !toIso) return "";
|
||||
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||
if (!Number.isFinite(ms) || ms < 0) return "";
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.round(s / 60);
|
||||
// Minutes render up to AND INCLUDING 120 so sub-/at-2h durations keep
|
||||
// their precision ("90m"/"120m" beat a rounded-up "2h" for judging
|
||||
// step latency). Guard prepass catch: `m < 120` excluded exactly 120.
|
||||
if (m <= 120) return `${m}m`;
|
||||
const h = Math.round(m / 60);
|
||||
if (h < 48) return `${h}h`;
|
||||
return `${Math.round(h / 24)}d`;
|
||||
}
|
||||
|
||||
/** Instance age relative to a supplied "now" (injectable for tests). */
|
||||
export function humanAge(createdAt: string | undefined, nowIso: string): string {
|
||||
return humanDuration(createdAt, nowIso);
|
||||
}
|
||||
|
||||
/** Compact render of a plain seconds quantity (same scale rules). */
|
||||
export function humanSeconds(seconds: number): string {
|
||||
return humanDuration("1970-01-01T00:00:00.000Z", new Date(seconds * 1000).toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* The trace tree (mini-req 2's bar: where it is + how it got there,
|
||||
* one screen, no JSON literacy required).
|
||||
*/
|
||||
export function renderTraceTree(
|
||||
instance: RenderInstance,
|
||||
trail: RenderTrailRow[],
|
||||
nowIso: string = new Date().toISOString(),
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
const name = instance.workflowName ? `${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}` : "";
|
||||
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} ${name} status=${instance.status}${instance.hopCount !== undefined ? ` hops=${instance.hopCount}` : ""}${instance.boundRig ? ` rig=${instance.boundRig}` : ""}`);
|
||||
if (instance.createdAt) {
|
||||
lines.push(` created ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} age ${humanAge(instance.createdAt, nowIso)}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(` ${fitCell("STEP", 22)}${fitCell("ACTOR", 28)}${fitCell("EXIT", 10)}DURATION`);
|
||||
let prevClosed = instance.createdAt;
|
||||
for (let i = 0; i < trail.length; i++) {
|
||||
const row = trail[i];
|
||||
if (!row) continue;
|
||||
const glyph = row.closureReason === "failed" ? "✖" : "✔";
|
||||
const bar = i === trail.length - 1 && !instance.currentStepId ? "└─" : "├─";
|
||||
const duration = humanDuration(prevClosed, row.closedAt);
|
||||
lines.push(` ${bar} ${glyph} ${fitCell(row.stepId, 20)}${fitCell(row.actorSession, 28)}${fitCell(row.closureReason, 10)}${duration}`);
|
||||
const branchTaken = row.closureEvidence?.["branch_taken"];
|
||||
if (typeof branchTaken === "string" && branchTaken.length > 0) {
|
||||
lines.push(` │ ↳ branch: ${branchTaken}`);
|
||||
}
|
||||
prevClosed = row.closedAt ?? prevClosed;
|
||||
}
|
||||
if (instance.currentStepId) {
|
||||
const owner = ""; // frontier owner is a queue-side fact; the frontier packet ids are what the payload carries
|
||||
lines.push(` └─ ▸ ${fitCell(instance.currentStepId, 20)}${fitCell(owner || "(current)", 28)}${fitCell("open", 10)}frontier=[${(instance.currentFrontier ?? []).join(", ")}]`);
|
||||
} else if (trail.length === 0) {
|
||||
lines.push(` (no steps closed yet)`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.WF3 FR-3 — the attention marker for a list row (the
|
||||
* single marker home). Classes: failed · stuck (consumed VERBATIM
|
||||
* from the API-carried deadline classification — rail 1: the CLI
|
||||
* never recomputes a threshold or class) · waiting. A row shows its
|
||||
* highest-priority class; the `status` verb shows ALL classes per
|
||||
* instance (exactly-once, combined reasons).
|
||||
*/
|
||||
export function attentionMarker(instance: RenderInstance): string {
|
||||
if (instance.status === "failed") return "▲ failed";
|
||||
if (isStuck(instance)) return "▲ stuck";
|
||||
if (instance.status === "waiting") return "▲ waiting";
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Rail 1: stuck-ness is READ from the API field, never derived. */
|
||||
function isStuck(instance: RenderInstance): boolean {
|
||||
const state = instance.deadline?.state;
|
||||
return typeof state === "string" && state.startsWith("overdue");
|
||||
}
|
||||
|
||||
// ── FR-3 part B: the needs-attention rollup (`rig workflow status`) ──
|
||||
|
||||
export interface AttentionRow {
|
||||
instanceId: string;
|
||||
workflowName: string;
|
||||
/** All classes for this instance — exactly-once row, combined. */
|
||||
classes: string[];
|
||||
reasons: string[];
|
||||
/** The actionable affordance (what-to-do-next), per class priority. */
|
||||
affordance: string;
|
||||
}
|
||||
|
||||
export interface AttentionRollup {
|
||||
counts: { total: number; active: number; waiting: number; completed: number; failed: number };
|
||||
attention: AttentionRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the rollup from list rows. Pure arithmetic over
|
||||
* pre-classified rows (the arch ruling's boundary: counting +
|
||||
* grouping + rendering is render-side; the ONE correctness-rule
|
||||
* composition — the threshold classification — arrives ALREADY DONE
|
||||
* in instance.deadline). Per-instance dedup by instanceId (rail 2):
|
||||
* an instance with multiple attention reasons renders ONCE with all
|
||||
* of them.
|
||||
*/
|
||||
export function composeAttentionRollup(instances: RenderInstance[]): AttentionRollup {
|
||||
const counts = { total: instances.length, active: 0, waiting: 0, completed: 0, failed: 0 };
|
||||
const attention: AttentionRow[] = [];
|
||||
for (const inst of instances) {
|
||||
if (inst.status === "active") counts.active += 1;
|
||||
else if (inst.status === "waiting") counts.waiting += 1;
|
||||
else if (inst.status === "completed") counts.completed += 1;
|
||||
else if (inst.status === "failed") counts.failed += 1;
|
||||
|
||||
const classes: string[] = [];
|
||||
const reasons: string[] = [];
|
||||
if (inst.status === "failed") {
|
||||
classes.push("failed");
|
||||
reasons.push("workflow failed");
|
||||
}
|
||||
if (isStuck(inst)) {
|
||||
classes.push("stuck");
|
||||
const ev = inst.deadline?.evidence;
|
||||
reasons.push(
|
||||
`${inst.deadline?.state}${ev?.stepId ? ` at step ${ev.stepId}` : ""}${ev?.ownerSession ? ` (owner ${ev.ownerSession})` : ""}${typeof ev?.overdueBySeconds === "number" ? `, overdue ${humanSeconds(ev.overdueBySeconds)}` : ""}`,
|
||||
);
|
||||
}
|
||||
if (inst.status === "waiting") {
|
||||
classes.push("waiting");
|
||||
const blocker = inst.lastContinuationDecision?.blockedOn;
|
||||
reasons.push(`waiting${blocker ? ` on ${blocker}` : " (blocker unrecorded)"}`);
|
||||
}
|
||||
if (classes.length === 0) continue;
|
||||
// Affordance by highest-priority class. `route` ships in this same
|
||||
// slice (commit 6) — the verbs land together at merge (BR-1 holds
|
||||
// at the shipped boundary).
|
||||
const affordance = classes.includes("failed")
|
||||
? `inspect: rig workflow trace ${inst.instanceId}`
|
||||
: classes.includes("stuck")
|
||||
? `re-route: rig workflow route ${inst.instanceId} --to <seat>`
|
||||
: `resolve the blocker, then the owner projects (rig workflow trace ${inst.instanceId})`;
|
||||
attention.push({
|
||||
instanceId: inst.instanceId,
|
||||
workflowName: inst.workflowName ?? "",
|
||||
classes,
|
||||
reasons,
|
||||
affordance,
|
||||
});
|
||||
}
|
||||
return { counts, attention };
|
||||
}
|
||||
|
||||
/** Human render of the rollup — proven-empty, never blank. */
|
||||
export function renderStatus(rollup: AttentionRollup): string[] {
|
||||
const c = rollup.counts;
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${c.total} instance${c.total === 1 ? "" : "s"}: ${c.active} active · ${c.waiting} waiting · ${c.completed} completed · ${c.failed} failed`,
|
||||
);
|
||||
if (rollup.attention.length === 0) {
|
||||
lines.push("No instances need attention (proven empty — every in-flight instance reads healthy).");
|
||||
return lines;
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(`${fitCell("", 2)}${fitCell("INSTANCE", 30)}${fitCell("WORKFLOW", 20)}${fitCell("CLASS", 16)}REASON`);
|
||||
for (const row of rollup.attention) {
|
||||
lines.push(
|
||||
`${fitCell("▲", 2)}${fitCell(row.instanceId, 30)}${fitCell(row.workflowName, 20)}${fitCell(row.classes.join("+"), 16)}${row.reasons.join("; ")}`,
|
||||
);
|
||||
lines.push(` ${fitCell("", 2)}└ ${row.affordance}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The list table: INSTANCE · WORKFLOW · STATUS · STEP · AGE · ATTN. */
|
||||
export function renderInstanceList(
|
||||
instances: RenderInstance[],
|
||||
nowIso: string = new Date().toISOString(),
|
||||
): string[] {
|
||||
if (instances.length === 0) return ["No workflow instances."];
|
||||
const lines: string[] = [];
|
||||
lines.push(`${fitCell("", 2)}${fitCell("INSTANCE", 30)}${fitCell("WORKFLOW", 22)}${fitCell("STATUS", 11)}${fitCell("STEP", 20)}${fitCell("AGE", 6)}ATTN`);
|
||||
for (const inst of instances) {
|
||||
lines.push(
|
||||
`${fitCell(statusGlyph(inst.status), 2)}${fitCell(inst.instanceId, 30)}${fitCell(inst.workflowName ?? "", 22)}${fitCell(inst.status, 11)}${fitCell(inst.currentStepId ?? "-", 20)}${fitCell(humanAge(inst.createdAt, nowIso), 6)}${attentionMarker(inst)}`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The show summary, headed by the status line. */
|
||||
export function renderInstanceShow(
|
||||
instance: RenderInstance,
|
||||
nowIso: string = new Date().toISOString(),
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${statusGlyph(instance.status)} ${instance.instanceId} status=${instance.status}`);
|
||||
if (instance.workflowName) lines.push(` workflow: ${instance.workflowName}${instance.workflowVersion ? ` v${instance.workflowVersion}` : ""}`);
|
||||
// OPR.0.4.6.FAC1: the bound rig renders when present (unbound rows unchanged).
|
||||
if (instance.boundRig) lines.push(` rig: ${instance.boundRig}`);
|
||||
if (instance.createdAt) lines.push(` created: ${instance.createdAt}${instance.createdBySession ? ` by ${instance.createdBySession}` : ""} (age ${humanAge(instance.createdAt, nowIso)})`);
|
||||
if (instance.currentStepId) lines.push(` at step: ${instance.currentStepId} frontier=[${(instance.currentFrontier ?? []).join(", ")}]`);
|
||||
if (instance.hopCount !== undefined) lines.push(` hops: ${instance.hopCount}`);
|
||||
lines.push(` next: rig workflow trace ${instance.instanceId}`);
|
||||
return lines;
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
import { followInstance } from "./workflow-follow.js";
|
||||
import {
|
||||
composeAttentionRollup,
|
||||
renderInstanceList,
|
||||
renderInstanceShow,
|
||||
renderStatus,
|
||||
renderTraceTree,
|
||||
} from "./workflow-render.js";
|
||||
import { describeDaemonRejection, formatThreePart } from "./workflow-errors.js";
|
||||
|
||||
/**
|
||||
* `rig workflow` — daemon-native Workflow Runtime (PL-004 Phase D).
|
||||
@@ -24,8 +33,7 @@ async function withClient<T>(
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
@@ -35,12 +43,80 @@ async function withClient<T>(
|
||||
function printResult(json: boolean, body: unknown, status: number): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify(body));
|
||||
} else if (status >= 400) {
|
||||
// WF3 FR-5: named daemon rejections render the house what/why/fix
|
||||
// 3-part in human mode; unrecognized bodies keep the raw-JSON
|
||||
// fallback. --json (above) stays the RAW body byte-identically;
|
||||
// exit codes below are unchanged.
|
||||
const rejection = describeDaemonRejection(body);
|
||||
if (rejection) {
|
||||
for (const line of formatThreePart(rejection)) process.stderr.write(`${line}\n`);
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
} else {
|
||||
console.log(JSON.stringify(body, null, 2));
|
||||
}
|
||||
if (status >= 400) process.exitCode = status >= 500 ? 2 : 1;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 GA polish — every mutating workflow command
|
||||
// ends with a 3-line what/state/next summary in human mode so the
|
||||
// operator doesn't have to grep the raw JSON for the outcome. JSON
|
||||
// mode keeps the daemon response verbatim — agent consumers parse
|
||||
// the structured body, not the human summary.
|
||||
export interface OutcomeSummary {
|
||||
what: string;
|
||||
state: string;
|
||||
next: string;
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — runtime validator for the
|
||||
// project --exit enum. TypeScript types don't enforce at runtime;
|
||||
// without this guard `--exit banana` would forward to the daemon
|
||||
// transactional-scribe surface. Match the same 3-part error shape
|
||||
// used elsewhere in the slice.
|
||||
export const PROJECT_EXIT_KINDS = ["handoff", "waiting", "done", "failed"] as const;
|
||||
export type ProjectExitKind = (typeof PROJECT_EXIT_KINDS)[number];
|
||||
|
||||
export function isProjectExitKind(value: unknown): value is ProjectExitKind {
|
||||
return typeof value === "string" && (PROJECT_EXIT_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function printOutcomeSummary(json: boolean, status: number, summary: OutcomeSummary | null): void {
|
||||
if (json || !summary || status >= 400) return;
|
||||
console.log("");
|
||||
console.log(` what: ${summary.what}`);
|
||||
console.log(` state: ${summary.state}`);
|
||||
console.log(` next: ${summary.next}`);
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
// OPR.0.4.6.FAC1 (arch ruling 2026-07-07): instantiate-time advisories
|
||||
// (currently the spec-default-target.rig degrade-to-unbound notice) MUST
|
||||
// be LOUD. Always write to STDERR — even in --json mode the structured
|
||||
// body already carries `advisories`, but an operator piping stdout to a
|
||||
// consumer still needs to see the warning, so it lands on stderr in both
|
||||
// modes. Non-fatal: never sets a non-zero exit code.
|
||||
export function printWorkflowAdvisories(advisories: string[] | undefined): void {
|
||||
if (!advisories || advisories.length === 0) return;
|
||||
for (const line of advisories) {
|
||||
process.stderr.write(`⚠ workflow advisory: ${line}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const cmd = new Command("workflow").description(
|
||||
"Daemon-native Workflow Runtime — declarative spec + transactional-scribe step projection (PL-004 Phase D)",
|
||||
@@ -55,6 +131,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("validate <specPath>")
|
||||
.description("Validate a workflow spec file (returns structured ok/error report)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow validate workflows/conveyor-starter.workflow.md
|
||||
$ rig workflow validate ./my-spec.workflow.md --json | jq .ok
|
||||
`)
|
||||
.action(async (specPath: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -69,22 +150,56 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.requiredOption("--root-objective <text>", "Root objective for the run")
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--rig <name>", "Bind the instance to this rig (overrides the spec's target.rig default; roles resolve to seats on this rig)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow instantiate workflows/conveyor.workflow.md \\
|
||||
--root-objective "Ship release-0.3.2" \\
|
||||
--created-by orch-lead@openrig-velocity
|
||||
|
||||
$ rig workflow instantiate ./my-spec.workflow.md \\
|
||||
--root-objective "Run dogfood" \\
|
||||
--created-by velocity-driver@openrig-velocity \\
|
||||
--entry-owner velocity-qa@openrig-velocity --json
|
||||
`)
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
entryOwner?: string;
|
||||
rig?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/instantiate", {
|
||||
const res = await client.post<{
|
||||
instanceId?: string;
|
||||
entryStepId?: string;
|
||||
entryOwnerSession?: string;
|
||||
status?: string;
|
||||
instance?: { boundRig?: string | null };
|
||||
advisories?: string[];
|
||||
}>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
targetRig: opts.rig,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
printWorkflowAdvisories(res.data?.advisories);
|
||||
const body = res.data ?? {};
|
||||
const instanceId = asString(body.instanceId) ?? "(no instance id)";
|
||||
const entryStepId = asString(body.entryStepId);
|
||||
const owner = asString(body.entryOwnerSession) ?? opts.entryOwner ?? "(default from spec)";
|
||||
const boundRig = asString(body.instance?.boundRig ?? undefined);
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Instantiated workflow from ${specPath} (instance ${instanceId})`,
|
||||
state: `${body.status ?? "active"}${boundRig ? `; bound to rig ${boundRig}` : ""}; entry packet ${entryStepId ?? "pending"} owned by ${owner}`,
|
||||
next: entryStepId
|
||||
? `Inspect: rig workflow show ${instanceId} | Open packet: rig queue show ${entryStepId}`
|
||||
: `Inspect: rig workflow show ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,28 +214,82 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.option("--blocked-on <ref>", "For waiting exits: blocker reference (qitem id, gate name)")
|
||||
.option("--next-owner <session>", "Override default next-step owner")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
# handoff to the spec's default next step
|
||||
$ rig workflow project \\
|
||||
--instance WF01ABC \\
|
||||
--current-packet QITEM-123 \\
|
||||
--exit handoff \\
|
||||
--actor-session velocity-driver@openrig-velocity \\
|
||||
--result-note "implementation green; ready for review"
|
||||
|
||||
# close the run cleanly
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-9 \\
|
||||
--exit done --actor-session orch-lead@openrig-velocity
|
||||
|
||||
# block on an external gate
|
||||
$ rig workflow project --instance WF01ABC --current-packet QITEM-4 \\
|
||||
--exit waiting --actor-session velocity-qa@openrig-velocity \\
|
||||
--blocked-on "founder-gate-2"
|
||||
`)
|
||||
.action(async (opts: {
|
||||
instance: string;
|
||||
currentPacket: string;
|
||||
exit: "handoff" | "waiting" | "done" | "failed";
|
||||
exit: string;
|
||||
actorSession: string;
|
||||
resultNote?: string;
|
||||
blockedOn?: string;
|
||||
nextOwner?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
// HG-6 — runtime-validate the --exit enum BEFORE the daemon
|
||||
// call. TypeScript types are erased at runtime; without this
|
||||
// guard `--exit banana` would forward to the transactional
|
||||
// workflow surface.
|
||||
if (!isProjectExitKind(opts.exit)) {
|
||||
emit3PartError(
|
||||
Boolean(opts.json),
|
||||
`--exit must be one of ${PROJECT_EXIT_KINDS.join(" | ")} (got "${opts.exit}").`,
|
||||
"rig workflow project did not run; daemon was not contacted.",
|
||||
`Pass a valid exit kind. Example: --exit handoff.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const exitKind: ProjectExitKind = opts.exit;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>("/api/workflow/project", {
|
||||
const res = await client.post<{
|
||||
closedPacketId?: string;
|
||||
nextPacketId?: string;
|
||||
nextOwnerSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>("/api/workflow/project", {
|
||||
instanceId: opts.instance,
|
||||
currentPacketId: opts.currentPacket,
|
||||
exit: opts.exit,
|
||||
exit: exitKind,
|
||||
actorSession: opts.actorSession,
|
||||
resultNote: opts.resultNote,
|
||||
blockedOn: opts.blockedOn,
|
||||
nextOwnerSession: opts.nextOwner,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const closedId = asString(body.closedPacketId) ?? opts.currentPacket;
|
||||
const nextId = asString(body.nextPacketId);
|
||||
const nextOwner = asString(body.nextOwnerSession) ?? opts.nextOwner ?? "(default from spec)";
|
||||
const status = body.instanceStatus ?? (opts.exit === "done" ? "completed" : opts.exit === "failed" ? "failed" : "active");
|
||||
const whatTail = nextId ? ` and projected ${nextId} to ${nextOwner}` : (opts.exit === "done" ? " (instance done)" : "");
|
||||
const nextAction = nextId
|
||||
? `Inspect: rig queue show ${nextId}`
|
||||
: (opts.exit === "done" || opts.exit === "failed")
|
||||
? `Inspect: rig workflow show ${opts.instance}`
|
||||
: `Wait for upstream; rig workflow show ${opts.instance} to monitor`;
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Closed ${closedId} (${opts.exit})${whatTail}`,
|
||||
state: `instance ${opts.instance} = ${status}`,
|
||||
next: nextAction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +298,12 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.description("List workflow instances; optionally filter by status")
|
||||
.option("--status <s>", "Filter by status (active | waiting | completed | failed)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow list
|
||||
$ rig workflow list --status active --json
|
||||
$ rig workflow list --status waiting | jq -r '.instances[].instanceId'
|
||||
`)
|
||||
.action(async (opts: { status?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
const params = new URLSearchParams();
|
||||
@@ -136,7 +311,14 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
const qs = params.toString();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/list${qs ? `?${qs}` : ""}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
// WF3 FR-2: human mode renders the table; --json byte-identical (BR-2).
|
||||
if (opts.json || res.status >= 400) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
const body = res.data as { instances?: unknown[] } | unknown[];
|
||||
const rows = (Array.isArray(body) ? body : body?.instances ?? []) as Parameters<typeof renderInstanceList>[0];
|
||||
for (const line of renderInstanceList(rows)) console.log(line);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +332,11 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("specs")
|
||||
.description("List registered workflow specs; built-in starters tagged with (built-in)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow specs
|
||||
$ rig workflow specs --json | jq '.specs[] | select(.isBuiltIn==false)'
|
||||
`)
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -185,11 +372,21 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("show <instanceId>")
|
||||
.description("Show one workflow instance")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow show WF01ABC
|
||||
$ rig workflow show WF01ABC --json | jq '.instance.status'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}`);
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
// WF3 FR-2: human summary headed by the status line; --json byte-identical (BR-2).
|
||||
if (opts.json || res.status >= 400) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
for (const line of renderInstanceShow(res.data as Parameters<typeof renderInstanceShow>[0])) console.log(line);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -197,23 +394,299 @@ export function workflowCommand(depsOverride?: WorkflowDeps): Command {
|
||||
.command("trace <instanceId>")
|
||||
.description("Show one workflow instance + its append-only step trail (audit-only verdict)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ rig workflow trace WF01ABC
|
||||
$ rig workflow trace WF01ABC --json | jq '.trail[] | {step, actor, exit}'
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/trace`);
|
||||
const res = await client.get<{ instance?: unknown; trail?: unknown[] }>(
|
||||
`/api/workflow/${encodeURIComponent(instanceId)}/trace`,
|
||||
);
|
||||
// WF3 FR-2: human mode renders the per-step tree (mini-req 2's
|
||||
// one-screen bar); --json byte-identical (BR-2).
|
||||
if (opts.json || res.status >= 400 || !res.data?.instance) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
const instance = res.data.instance as Parameters<typeof renderTraceTree>[0];
|
||||
const trail = (res.data.trail ?? []) as Parameters<typeof renderTraceTree>[1];
|
||||
for (const line of renderTraceTree(instance, trail)) console.log(line);
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF1 FR-8 (G6): `continue` RELABELED to its real
|
||||
// inspector semantics. The wire has been a read-only frontier+trail
|
||||
// inspector since Phase D v1, but the label said "Mechanically
|
||||
// advance" and the summary printed "Advanced instance ..." — the
|
||||
// label-vs-wire lie dies here. A true mechanical advance would mint
|
||||
// a closure without the owner's truthful exit, violating
|
||||
// owner-as-author + BR-2 (project is the sole advance write path) —
|
||||
// arch-endorsed as architecture, not just wording.
|
||||
cmd
|
||||
.command("continue <instanceId>")
|
||||
.description("Inspect an instance's current frontier + step trail (read-only; advancing happens via 'rig workflow project')")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Read-only: reports where the instance is and how it got there so the
|
||||
frontier owner can continue truthfully. To actually advance, the packet
|
||||
OWNER closes it via:
|
||||
$ rig workflow project --instance <id> --current-packet <qitem> --exit <exit> --actor-session <you>
|
||||
|
||||
Examples:
|
||||
$ rig workflow continue WF01ABC
|
||||
$ rig workflow continue WF01ABC --json
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<{
|
||||
instance?: { status?: string; currentFrontier?: string[]; currentStepId?: string | null };
|
||||
trail?: unknown[];
|
||||
}>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
const status = body.instance?.status ?? "(unknown)";
|
||||
const frontier = body.instance?.currentFrontier ?? [];
|
||||
const stepId = body.instance?.currentStepId ?? null;
|
||||
const trailLen = body.trail?.length ?? 0;
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Inspected instance ${instanceId} (read-only; no state changed)`,
|
||||
state: `status = ${status}; step = ${stepId ?? "(terminal)"}; frontier = [${frontier.join(", ")}]; trail rows = ${trailLen}`,
|
||||
next: frontier.length > 0
|
||||
? `The frontier owner advances via: rig workflow project --instance ${instanceId} --current-packet ${frontier[0]} --exit <handoff|waiting|done|failed> --actor-session <owner>`
|
||||
: `Terminal or empty frontier - see: rig workflow trace ${instanceId}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF3 FR-1 — the follow verbs. Two verbs, ONE renderer
|
||||
// (workflow-follow.ts). BR-1 verb honesty: `run` instantiates AND
|
||||
// follows; `watch` ONLY watches — neither advances a step (project
|
||||
// remains the sole advance path). Outcome-as-exit-code by default
|
||||
// (the kubectl choice): completed=0, workflow-failed=3 (distinct
|
||||
// from the shipped 1=4xx / 2=5xx transport codes), so
|
||||
// `rig workflow run … && next-thing` is honest in scripts.
|
||||
cmd
|
||||
.command("run <specPath>")
|
||||
.description("Instantiate a workflow AND follow it live to a terminal state (exit 0 completed / 3 failed)")
|
||||
.requiredOption("--root-objective <text>", "Root objective for the run")
|
||||
.requiredOption("--created-by <session>", "Session creating the instance (canonical <member>@<rig>)")
|
||||
.option("--entry-owner <session>", "Override default entry-step owner")
|
||||
.option("--rig <name>", "Bind the instance to this rig (overrides the spec's target.rig default; roles resolve to seats on this rig)")
|
||||
.option("--json", "Stream events as JSON lines for agents")
|
||||
.addHelpText("after", `
|
||||
Streams each step event as it happens; exits when the workflow reaches
|
||||
a terminal state. Exit codes: 0 = completed, 3 = workflow failed,
|
||||
1/2 = transport errors (4xx/5xx). If the event stream drops, the
|
||||
command reconnects, then degrades to polling — announced, never a
|
||||
silent freeze.
|
||||
|
||||
Examples:
|
||||
$ rig workflow run workflows/conveyor.workflow.md \\
|
||||
--root-objective "Ship it" --created-by orch-lead@my-rig
|
||||
$ rig workflow run ./spec.yaml --root-objective x --created-by a@b --json
|
||||
`)
|
||||
.action(async (specPath: string, opts: {
|
||||
rootObjective: string;
|
||||
createdBy: string;
|
||||
entryOwner?: string;
|
||||
rig?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<{
|
||||
instanceId?: string;
|
||||
instance?: { instanceId?: string };
|
||||
advisories?: string[];
|
||||
}>("/api/workflow/instantiate", {
|
||||
specPath,
|
||||
rootObjective: opts.rootObjective,
|
||||
createdBySession: opts.createdBy,
|
||||
entryOwnerSession: opts.entryOwner,
|
||||
targetRig: opts.rig,
|
||||
});
|
||||
printWorkflowAdvisories(res.data?.advisories);
|
||||
// The daemon returns the nested InstantiateResult shape
|
||||
// ({instance:{instanceId}}); tolerate a flattened {instanceId}
|
||||
// too (walk-caught: reading only the flat field made `run`
|
||||
// print-and-exit-0 without ever following — the same
|
||||
// flattening confusion as the pre-existing instantiate
|
||||
// summary polish note from WF-1 rev1-r2).
|
||||
const instanceId =
|
||||
asString(res.data?.instance?.instanceId) ?? asString(res.data?.instanceId);
|
||||
if (res.status >= 400 || !instanceId) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
if (!opts.json) console.log(`● instance ${instanceId} created — following`);
|
||||
const code = await followInstance(client, instanceId, { json: opts.json ?? false });
|
||||
if (code !== 0) process.exitCode = code;
|
||||
});
|
||||
});
|
||||
|
||||
cmd
|
||||
.command("continue <instanceId>")
|
||||
.description("Mechanically advance an instance after truthful closure (idempotent inspector in v1)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.command("watch <instanceId>")
|
||||
.description("Attach to an in-flight instance and follow it live (read-only; exit mirrors the outcome)")
|
||||
.option("--json", "Stream events as JSON lines for agents")
|
||||
.addHelpText("after", `
|
||||
Read-only: renders the instance's current state (snapshot), then
|
||||
streams live events until a terminal state. Attaching to an already
|
||||
fast-moving instance is safe — steps that closed before attach render
|
||||
from the snapshot exactly once. Exit codes: 0 = completed, 3 =
|
||||
workflow failed, 1/2 = transport errors.
|
||||
|
||||
Examples:
|
||||
$ rig workflow watch WF01ABC
|
||||
$ rig workflow watch WF01ABC --json
|
||||
`)
|
||||
.action(async (instanceId: string, opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<unknown>(`/api/workflow/${encodeURIComponent(instanceId)}/continue`, {});
|
||||
const code = await followInstance(client, instanceId, { json: opts.json ?? false });
|
||||
if (code !== 0) process.exitCode = code;
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF3 FR-4 — the ONE WF-3 mutation. BR-1: `route` routes;
|
||||
// it NEVER advances a step (hop count untouched; project remains the
|
||||
// sole advance path). Exception-lane by usage, not by gate.
|
||||
cmd
|
||||
.command("route <instanceId>")
|
||||
.description("Re-route the current frontier step to a new owner (same step, honest handoff closure; never advances)")
|
||||
.requiredOption("--to <session>", "New owner session (canonical <member>@<rig>)")
|
||||
.requiredOption("--actor-session <session>", "Session performing the re-route (recorded as provenance)")
|
||||
.option("--reason <text>", "Why the step is being re-routed (recorded in the audit trail)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Use when a step's owner is unresponsive (dead seat, compaction) and the
|
||||
work should continue from the SAME step under a new owner. The old
|
||||
owner's stale close attempts are structurally rejected afterwards
|
||||
(packet_not_on_frontier).
|
||||
|
||||
Examples:
|
||||
$ rig workflow route WF01ABC --to dev2-driver@my-rig \\
|
||||
--actor-session orch-lead@my-rig --reason "owner seat dead"
|
||||
`)
|
||||
.action(async (instanceId: string, opts: {
|
||||
to: string;
|
||||
actorSession: string;
|
||||
reason?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<{
|
||||
stepId?: string | null;
|
||||
closedPacketId?: string;
|
||||
newPacketId?: string;
|
||||
fromSession?: string;
|
||||
toSession?: string;
|
||||
instanceStatus?: string;
|
||||
}>(`/api/workflow/${encodeURIComponent(instanceId)}/route`, {
|
||||
toSession: opts.to,
|
||||
actorSession: opts.actorSession,
|
||||
reason: opts.reason,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Re-routed step ${body.stepId ?? "?"} from ${body.fromSession ?? "?"} to ${body.toSession ?? opts.to} (packet ${body.closedPacketId ?? "?"} → ${body.newPacketId ?? "?"})`,
|
||||
state: `instance ${instanceId} = ${body.instanceStatus ?? "active"}; same step, new owner; no step advanced`,
|
||||
next: `The new owner advances via: rig workflow project --instance ${instanceId} --current-packet ${body.newPacketId ?? "<packet>"} --exit <exit> --actor-session ${opts.to}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF5 FR-4 — resume: redrive a FAILED instance from the
|
||||
// failed step (BR-1 verb honesty: resume re-drives, it never advances
|
||||
// a step itself and never re-runs completed steps; waiting instances
|
||||
// resume via the shipped project path, not this verb).
|
||||
cmd
|
||||
.command("resume <instanceId>")
|
||||
.description("Redrive a FAILED instance from its failed step (completed steps never re-run; one fresh max_hops window)")
|
||||
.requiredOption("--actor-session <session>", "Session performing the resume (recorded as provenance)")
|
||||
.option("--decision <text>", "Durable instruction for the step owner (lands in the redrive packet)")
|
||||
.option("--json", "JSON output for agents")
|
||||
.addHelpText("after", `
|
||||
Use after diagnosing an exception item: the instance returns to active,
|
||||
REBOUND to the step that failed, with a fresh packet routed to that
|
||||
step's re-resolved owner. The trail is preserved and extended; the
|
||||
resolved exception occurrence closes; a NEW failure of the same step
|
||||
raises a NEW occurrence honestly.
|
||||
|
||||
Examples:
|
||||
$ rig workflow resume WF01ABC --actor-session orch-lead@my-rig \\
|
||||
--decision "flaky fixture fixed in commit abc123 — retry"
|
||||
`)
|
||||
.action(async (instanceId: string, opts: {
|
||||
actorSession: string;
|
||||
decision?: string;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.post<{
|
||||
stepId?: string;
|
||||
newPacketId?: string;
|
||||
ownerSession?: string;
|
||||
resumeCount?: number;
|
||||
exceptionItemsClosed?: number;
|
||||
}>(`/api/workflow/${encodeURIComponent(instanceId)}/resume`, {
|
||||
actorSession: opts.actorSession,
|
||||
decision: opts.decision,
|
||||
});
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
const body = res.data ?? {};
|
||||
printOutcomeSummary(opts.json ?? false, res.status, {
|
||||
what: `Redrove instance ${instanceId} from step ${body.stepId ?? "?"} (redrive #${body.resumeCount ?? "?"}; packet ${body.newPacketId ?? "?"} → ${body.ownerSession ?? "?"}; ${body.exceptionItemsClosed ?? 0} exception item(s) resolved)`,
|
||||
state: `instance ${instanceId} = active, rebound to ${body.stepId ?? "?"}; completed steps untouched; fresh max_hops window`,
|
||||
next: `The owner advances via: rig workflow project --instance ${instanceId} --current-packet ${body.newPacketId ?? "<packet>"} --exit <exit> --actor-session ${body.ownerSession ?? "<owner>"}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// OPR.0.4.6.WF3 FR-3 part B — the needs-attention rollup. CLI-SIDE
|
||||
// composition per the arch ruling (Rev-4 rails): consumes the
|
||||
// API-carried instance.deadline classification + instance.status
|
||||
// from the SHIPPED read surface; counting/grouping/rendering only —
|
||||
// NO threshold or class is ever computed here, NO daemon route was
|
||||
// added (the WF-4-era web UI adds a daemon rollup endpoint under its
|
||||
// own authorization when it needs one; this verb is not the
|
||||
// permanent home of that composition).
|
||||
cmd
|
||||
.command("status")
|
||||
.description("Which instances need attention: counts + one row per failed/stuck/waiting instance with reason + next action (read-only)")
|
||||
.option("--json", "Rollup as JSON for agents")
|
||||
.addHelpText("after", `
|
||||
Answers "what needs me" (list answers "what exists"). Every
|
||||
attention-worthy instance appears exactly once with ALL its classes
|
||||
(failed / stuck / waiting) and the actionable next step. A clean fleet
|
||||
renders the proven-empty statement with counts — never a blank.
|
||||
|
||||
Examples:
|
||||
$ rig workflow status
|
||||
$ rig workflow status --json | jq '.attention[] | {instanceId, classes}'
|
||||
`)
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<unknown>("/api/workflow/list");
|
||||
if (res.status >= 400) {
|
||||
printResult(opts.json ?? false, res.data, res.status);
|
||||
return;
|
||||
}
|
||||
const body = res.data as { instances?: unknown[] } | unknown[];
|
||||
const rows = (Array.isArray(body) ? body : body?.instances ?? []) as Parameters<typeof composeAttentionRollup>[0];
|
||||
const rollup = composeAttentionRollup(rows);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(rollup));
|
||||
return;
|
||||
}
|
||||
for (const line of renderStatus(rollup)) console.log(line);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// PL-007 Workspace Primitive v0 — `rig workspace` CLI surface.
|
||||
// PL-007 Workspace Primitive — `rig workspace` CLI surface.
|
||||
//
|
||||
// v0 surface (one verb): `rig workspace validate`. Walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, and emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes the
|
||||
// report.
|
||||
// Verbs:
|
||||
// - `rig workspace validate` (slice-01) — walks a workspace root,
|
||||
// parses each .md file's YAML frontmatter, emits a structured gap
|
||||
// report. Advisory only — never modifies. curate-steward consumes.
|
||||
// - `rig workspace doctor` (slice-21 FR-5; +1 check OPR.0.4.4.23) — runs
|
||||
// the 8-check workspace-readiness diagnostic against the daemon's
|
||||
// resolved workspace (or a --workspace override). Reports state + fix-hints.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { getDaemonStatus, getDaemonUrl, printDaemonNotRunning } from "../daemon-lifecycle.js";
|
||||
import * as path from "node:path";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
@@ -38,17 +41,42 @@ async function withClient<T>(
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
printDaemonNotRunning();
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
// release-0.3.2 slice 01 BC repair — strict-int validator for
|
||||
// --max-files. Rejects `12abc`, `abc`, `0`, `-1`, etc. with a 3-part
|
||||
// fact/consequence/action error; does NOT call the daemon on invalid
|
||||
// input. Keeps positive cases (`10000`, `12`, etc.) flowing through.
|
||||
export function parseMaxFilesStrict(raw: string): number {
|
||||
if (!/^[1-9][0-9]*$/.test(raw)) {
|
||||
const err = new Error(
|
||||
`--max-files must be a positive integer (got "${raw}").`,
|
||||
) as Error & { fact: string; consequence: string; action: string };
|
||||
err.fact = `--max-files must be a positive integer (got "${raw}").`;
|
||||
err.consequence = "rig workspace validate did not run; daemon was not contacted.";
|
||||
err.action = "Pass a positive integer like --max-files 10000.";
|
||||
throw err;
|
||||
}
|
||||
return Number.parseInt(raw, 10);
|
||||
}
|
||||
|
||||
function emit3PartError(json: boolean, fact: string, consequence: string, action: string): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, error: { fact, consequence, action } }, null, 2));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${fact}\n${consequence}\n${action}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
const cmd = new Command("workspace").description(
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. v0: `validate` walks a root and reports frontmatter gaps.",
|
||||
"PL-007 Workspace Primitive — typed-kind tooling. `validate` walks a root and reports frontmatter gaps; `doctor` runs the 8-check workspace-readiness diagnostic.",
|
||||
);
|
||||
|
||||
const getDeps = (): WorkspaceDeps =>
|
||||
@@ -78,6 +106,17 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
// HG-6 — CLI-side validation BEFORE the daemon call. Reject
|
||||
// malformed --max-files with a 3-part error; never silently
|
||||
// coerce `12abc` → 12.
|
||||
let maxFiles: number;
|
||||
try {
|
||||
maxFiles = parseMaxFilesStrict(opts.maxFiles);
|
||||
} catch (err) {
|
||||
const e = err as Error & { fact?: string; consequence?: string; action?: string };
|
||||
emit3PartError(Boolean(opts.json), e.fact ?? e.message, e.consequence ?? "", e.action ?? "");
|
||||
return;
|
||||
}
|
||||
const root = path.resolve(rootArg ?? process.cwd());
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
@@ -86,7 +125,7 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
workspaceKind: opts.kind,
|
||||
recursive: opts.recursive !== false,
|
||||
requireFrontmatter: opts.requireFrontmatter ?? false,
|
||||
maxFiles: Number.parseInt(opts.maxFiles, 10),
|
||||
maxFiles,
|
||||
});
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
@@ -105,6 +144,59 @@ export function workspaceCommand(depsOverride?: WorkspaceDeps): Command {
|
||||
},
|
||||
);
|
||||
|
||||
// Slice-21 FR-5 — `rig workspace doctor`.
|
||||
cmd
|
||||
.command("doctor")
|
||||
.description(
|
||||
"Run the 8-check workspace-readiness diagnostic against the daemon's resolved workspace. Reports state of workspace root, missions folder, file allowlist, daemon alignment, daemon reload, slice docs, MISSION_NOTES, and SDLC convention sections. Read-only.",
|
||||
)
|
||||
.option(
|
||||
"--workspace <path>",
|
||||
"Override the workspace under check (default: daemon-resolved configured root)",
|
||||
)
|
||||
.option("--json", "JSON output for agents")
|
||||
.option(
|
||||
"--strict",
|
||||
"Exit non-zero on warn-or-fail (default: non-zero only on fail)",
|
||||
)
|
||||
.action(async (opts: { workspace?: string; json?: boolean; strict?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const requestBody: { workspaceRoot?: string; filesAllowlistOverride?: string } = {};
|
||||
if (opts.workspace) requestBody.workspaceRoot = path.resolve(opts.workspace);
|
||||
// FR-5e A2 — CLI-side env overlay for files.allowlist. The
|
||||
// daemon runs in its own process with its own env; an
|
||||
// operator who sets OPENRIG_FILES_ALLOWLIST in their CLI
|
||||
// shell expects the doctor result to reflect that override
|
||||
// even though the daemon's env is unchanged. We mirror the
|
||||
// --workspace override pattern: read the env at request
|
||||
// time and forward as a per-request overlay; the daemon
|
||||
// route applies it to check #3 with source="env".
|
||||
const cliAllowlistEnv = process.env.OPENRIG_FILES_ALLOWLIST;
|
||||
if (typeof cliAllowlistEnv === "string" && cliAllowlistEnv.length > 0) {
|
||||
requestBody.filesAllowlistOverride = cliAllowlistEnv;
|
||||
}
|
||||
const res = await client.post<DoctorReport>("/api/workspace/doctor", requestBody);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const report = res.data;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(report));
|
||||
} else {
|
||||
renderHumanDoctorReport(report);
|
||||
}
|
||||
// Exit-code semantics per FR-5 IMPL-PRD §74:
|
||||
// default: non-zero only on fail
|
||||
// --strict: non-zero on warn-or-fail
|
||||
const hasFail = report.summary.fail > 0;
|
||||
const hasWarn = report.summary.warn > 0;
|
||||
if (hasFail || (opts.strict && hasWarn)) process.exitCode = 1;
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -125,3 +217,74 @@ function renderHumanReport(r: ValidationReport): void {
|
||||
console.log(` ${g.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- slice-21 FR-5 — `rig workspace doctor` types + human formatter ---
|
||||
//
|
||||
// DoctorReport shape mirrors the daemon's runWorkspaceDoctor output
|
||||
// at packages/daemon/src/domain/workspace/workspace-doctor.ts. Type
|
||||
// is duplicated here (not imported from the daemon package) because
|
||||
// the CLI does not have a direct daemon-package dependency edge; the
|
||||
// HTTP boundary already enforces the JSON shape.
|
||||
|
||||
interface DoctorCheckResult {
|
||||
check: string;
|
||||
status: "ok" | "warn" | "fail";
|
||||
message: string;
|
||||
fixHint?: string;
|
||||
evidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
workspaceRoot: string;
|
||||
checks: DoctorCheckResult[];
|
||||
summary: { ok: number; warn: number; fail: number };
|
||||
daemonResolvedAt: string;
|
||||
}
|
||||
|
||||
const DOCTOR_CHECK_GROUPS: ReadonlyArray<{ category: string; checks: ReadonlyArray<string> }> = [
|
||||
{ category: "workspace", checks: ["workspace_root_reachable", "file_allowlist_sane"] },
|
||||
{ category: "missions", checks: ["missions_folder_present", "optional_slice_docs", "mission_notes_presence", "sdlc_convention_sections"] },
|
||||
{ category: "daemon", checks: ["daemon_points_at_this_workspace", "daemon_reload_needed"] },
|
||||
];
|
||||
|
||||
function statusIcon(status: DoctorCheckResult["status"]): string {
|
||||
switch (status) {
|
||||
case "ok": return "OK";
|
||||
case "warn": return "WARN";
|
||||
case "fail": return "FAIL";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderHumanDoctorReport(report: DoctorReport): void {
|
||||
console.log(`workspace doctor — ${report.workspaceRoot}`);
|
||||
console.log(` summary: ${report.summary.ok} ok, ${report.summary.warn} warn, ${report.summary.fail} fail`);
|
||||
console.log("");
|
||||
|
||||
const byName = new Map(report.checks.map((c) => [c.check, c]));
|
||||
const remaining = new Set(report.checks.map((c) => c.check));
|
||||
|
||||
for (const group of DOCTOR_CHECK_GROUPS) {
|
||||
console.log(`${group.category}:`);
|
||||
for (const checkName of group.checks) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
remaining.delete(checkName);
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// Any checks not in the documented groups still get rendered so a
|
||||
// future check addition doesn't silently drop from the human view
|
||||
// before the group-map is updated.
|
||||
if (remaining.size > 0) {
|
||||
console.log("other:");
|
||||
for (const checkName of remaining) {
|
||||
const c = byName.get(checkName);
|
||||
if (!c) continue;
|
||||
console.log(` [${statusIcon(c.status)}] ${c.check}: ${c.message}`);
|
||||
if (c.fixHint) console.log(` Fix: ${c.fixHint}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
|
||||
export interface RiggedConfig {
|
||||
daemon: { port: number; host: string };
|
||||
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer.
|
||||
// OPR.0.4.6.MH1 FR-4 — the own-host display name (default "localhost").
|
||||
host: { selected: string; name: string };
|
||||
db: { path: string };
|
||||
transcripts: {
|
||||
enabled: boolean;
|
||||
@@ -116,6 +119,29 @@ export interface RiggedConfig {
|
||||
postRestoreAuditInstruction: string;
|
||||
};
|
||||
};
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: boolean;
|
||||
intervalSeconds: number;
|
||||
retentionKeep: number;
|
||||
};
|
||||
};
|
||||
// OPR.0.4.6.02 S1 — inner-tmux status-bar default at session launch.
|
||||
// Static boolean (default off), lockstep with the daemon settings-store
|
||||
// twin. Consumed by the daemon NodeLauncher; the CLI carries it for the
|
||||
// `rig config get/set terminal.status_bar` surface + twin parity.
|
||||
terminal: {
|
||||
statusBar: boolean;
|
||||
};
|
||||
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs (twin of the daemon
|
||||
// settings-store): enabled + the four bounded numeric tunables.
|
||||
retention: {
|
||||
enabled: boolean;
|
||||
transitionsDays: number;
|
||||
watchdogDays: number;
|
||||
watchdogKeepPerJob: number;
|
||||
batchSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_WORKSPACE_ROOT = getDefaultOpenRigPath("workspace");
|
||||
@@ -137,6 +163,9 @@ const DEFAULT_CLAUDE_COMPACTION_EXTRA_INSTRUCTION_FILE_PATH = getDefaultOpenRigP
|
||||
|
||||
const DEFAULTS = {
|
||||
daemon: { port: 7433, host: "127.0.0.1" },
|
||||
// OPR.0.4.6.MH1 FR-1 — "local" ≡ no remote selection (LOCAL_HOST_ID).
|
||||
// FR-4 — own-host display name; default "localhost" (PRD-named).
|
||||
host: { selected: "local", name: "localhost" },
|
||||
db: { path: getDefaultOpenRigPath("openrig.sqlite") },
|
||||
transcripts: { enabled: true, path: getDefaultOpenRigPath("transcripts"), lines: 1000, pollIntervalSeconds: 2 },
|
||||
workspace: {
|
||||
@@ -203,11 +232,48 @@ const DEFAULTS = {
|
||||
postRestoreAuditInstruction: DEFAULT_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION,
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: true,
|
||||
intervalSeconds: 300,
|
||||
retentionKeep: 10,
|
||||
},
|
||||
},
|
||||
// OPR.0.4.6.02 S1 — inner-tmux status bar OFF at launch by default.
|
||||
terminal: {
|
||||
statusBar: false,
|
||||
},
|
||||
// OPR.0.4.6.FS-1 W2 — retention defaults (twin of daemon getDefaultValue).
|
||||
retention: {
|
||||
enabled: true,
|
||||
transitionsDays: 30,
|
||||
watchdogDays: 14,
|
||||
watchdogKeepPerJob: 50,
|
||||
batchSize: 500,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const VALID_KEYS = [
|
||||
"daemon.port",
|
||||
"daemon.host",
|
||||
// OPR.0.4.6.MH1 FR-1 — the persisted host-selection pointer (the
|
||||
// kubectl current-context shape). ONE static key, lockstep with the
|
||||
// daemon settings-store twin (parity test pins both). Registry-
|
||||
// membership validation of the VALUE happens at the `rig host select`
|
||||
// verb layer (the store stays value-agnostic); the daemon config
|
||||
// write is the one write path — the CLI verb is a thin client.
|
||||
// Default "local" (unset ≡ the local host — the FR-2 zero-regression
|
||||
// posture by construction).
|
||||
"host.selected",
|
||||
// OPR.0.4.6.MH1 FR-4 — the own-host display name (arch Ruling 1: home =
|
||||
// the settings twins, never hosts.yaml). One stored name, every surface
|
||||
// reads it (dashboard/explorer/ls/whoami). Write path = the daemon
|
||||
// config write via `rig host rename` (thin client, same as select).
|
||||
"host.name",
|
||||
// OPR.0.4.6.WF5 FR-2 — the host-level maturity-dial default (lockstep
|
||||
// with the daemon settings twin). "orchestrator" | "human_only";
|
||||
// unset ≡ orchestrator-first.
|
||||
"workflow.exception_routing",
|
||||
"db.path",
|
||||
"transcripts.enabled",
|
||||
"transcripts.path",
|
||||
@@ -253,6 +319,20 @@ export const VALID_KEYS = [
|
||||
"policies.claude_compaction.message_inline",
|
||||
"policies.claude_compaction.message_file_path",
|
||||
"policies.claude_compaction.post_restore_audit_instruction",
|
||||
"snapshots.periodic.enabled",
|
||||
"snapshots.periodic.interval_seconds",
|
||||
"snapshots.periodic.retention_keep",
|
||||
// OPR.0.4.6.02 S1 — inner-tmux status-bar launch default. ONE static
|
||||
// boolean (default off), lockstep with the daemon settings-store twin
|
||||
// (the parity test pins both). Flip is future-launches-only (BR-1).
|
||||
"terminal.status_bar",
|
||||
// OPR.0.4.6.FS-1 W2 — queue-retention maintenance knobs; CLI-settable twin,
|
||||
// lockstep with the daemon settings-store SETTINGS_VALID_KEYS.
|
||||
"retention.enabled",
|
||||
"retention.transitions_days",
|
||||
"retention.watchdog_days",
|
||||
"retention.watchdog_keep_per_job",
|
||||
"retention.batch_size",
|
||||
] as const;
|
||||
|
||||
export type ValidKey = typeof VALID_KEYS[number];
|
||||
@@ -261,6 +341,11 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
// Only the original runtime keys keep RIGGED_* aliases for upgrade
|
||||
// compatibility. New typed keys use OPENRIG_* only.
|
||||
"daemon.port": { primary: "OPENRIG_PORT", legacy: "RIGGED_PORT" },
|
||||
// OPR.0.4.6.MH1 FR-1/FR-4 — new keys, OPENRIG_* only (no RIGGED_* legacy).
|
||||
"host.selected": { primary: "OPENRIG_HOST_SELECTED" },
|
||||
"host.name": { primary: "OPENRIG_HOST_NAME" },
|
||||
// OPR.0.4.6.WF5 FR-2 — new key, OPENRIG_* only.
|
||||
"workflow.exception_routing": { primary: "OPENRIG_WORKFLOW_EXCEPTION_ROUTING" },
|
||||
"daemon.host": { primary: "OPENRIG_HOST", legacy: "RIGGED_HOST" },
|
||||
"db.path": { primary: "OPENRIG_DB", legacy: "RIGGED_DB" },
|
||||
"transcripts.enabled": { primary: "OPENRIG_TRANSCRIPTS_ENABLED", legacy: "RIGGED_TRANSCRIPTS_ENABLED" },
|
||||
@@ -303,6 +388,17 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
"policies.claude_compaction.message_inline": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_INLINE" },
|
||||
"policies.claude_compaction.message_file_path": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_MESSAGE_FILE_PATH" },
|
||||
"policies.claude_compaction.post_restore_audit_instruction": { primary: "OPENRIG_POLICIES_CLAUDE_COMPACTION_POST_RESTORE_AUDIT_INSTRUCTION" },
|
||||
"snapshots.periodic.enabled": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_ENABLED" },
|
||||
"snapshots.periodic.interval_seconds": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_INTERVAL_SECONDS" },
|
||||
"snapshots.periodic.retention_keep": { primary: "OPENRIG_SNAPSHOTS_PERIODIC_RETENTION_KEEP" },
|
||||
// OPR.0.4.6.02 S1 — net-new key; OPENRIG_* primary only (no RIGGED_* legacy).
|
||||
"terminal.status_bar": { primary: "OPENRIG_TERMINAL_STATUS_BAR" },
|
||||
// OPR.0.4.6.FS-1 W2 — retention knobs; net-new keys, OPENRIG_* primary only.
|
||||
"retention.enabled": { primary: "OPENRIG_RETENTION_ENABLED" },
|
||||
"retention.transitions_days": { primary: "OPENRIG_RETENTION_TRANSITIONS_DAYS" },
|
||||
"retention.watchdog_days": { primary: "OPENRIG_RETENTION_WATCHDOG_DAYS" },
|
||||
"retention.watchdog_keep_per_job": { primary: "OPENRIG_RETENTION_WATCHDOG_KEEP_PER_JOB" },
|
||||
"retention.batch_size": { primary: "OPENRIG_RETENTION_BATCH_SIZE" },
|
||||
};
|
||||
|
||||
// Maps dotted-string config keys to the camelCase RiggedConfig path.
|
||||
@@ -311,6 +407,9 @@ export const ENV_MAP: Record<ValidKey, { primary: string; legacy?: string }> = {
|
||||
// to match TS conventions.
|
||||
const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"daemon.port": ["daemon", "port"],
|
||||
"host.selected": ["host", "selected"],
|
||||
"host.name": ["host", "name"],
|
||||
"workflow.exception_routing": ["workflow", "exceptionRouting"],
|
||||
"daemon.host": ["daemon", "host"],
|
||||
"db.path": ["db", "path"],
|
||||
"transcripts.enabled": ["transcripts", "enabled"],
|
||||
@@ -346,12 +445,56 @@ const KEY_TO_PATH: Record<ValidKey, string[]> = {
|
||||
"policies.claude_compaction.message_inline": ["policies", "claudeCompaction", "messageInline"],
|
||||
"policies.claude_compaction.message_file_path": ["policies", "claudeCompaction", "messageFilePath"],
|
||||
"policies.claude_compaction.post_restore_audit_instruction": ["policies", "claudeCompaction", "postRestoreAuditInstruction"],
|
||||
"snapshots.periodic.enabled": ["snapshots", "periodic", "enabled"],
|
||||
"snapshots.periodic.interval_seconds": ["snapshots", "periodic", "intervalSeconds"],
|
||||
"snapshots.periodic.retention_keep": ["snapshots", "periodic", "retentionKeep"],
|
||||
"terminal.status_bar": ["terminal", "statusBar"],
|
||||
"retention.enabled": ["retention", "enabled"],
|
||||
"retention.transitions_days": ["retention", "transitionsDays"],
|
||||
"retention.watchdog_days": ["retention", "watchdogDays"],
|
||||
"retention.watchdog_keep_per_job": ["retention", "watchdogKeepPerJob"],
|
||||
"retention.batch_size": ["retention", "batchSize"],
|
||||
};
|
||||
|
||||
function isValidKey(key: string): key is ValidKey {
|
||||
return (VALID_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
|
||||
// ── OPR.0.4.4.15 (G15-P1) — TWIN of the daemon settings-store's ONE
|
||||
// registered dynamic key class: `feed.subscriptions.<hostId>.enabled`
|
||||
// (boolean; v1 per-host key set CLOSED to {enabled}; no env mapping).
|
||||
// Kept in lockstep with packages/daemon/src/domain/user-settings/
|
||||
// settings-store.ts — the parity test pins both twins (host-registry twin
|
||||
// discipline). Reserved segments cover key-level snake_case AND FILE-level
|
||||
// camelCase toggle names so no host id shadows a flat toggle at either
|
||||
// layer. Every other unknown key keeps the reject-loud behavior.
|
||||
const FEED_HOST_KEY_RE = /^feed\.subscriptions\.([A-Za-z0-9_-]+)\.enabled$/;
|
||||
export const FEED_HOST_RESERVED_SEGMENTS = new Set([
|
||||
"action_required",
|
||||
"actionRequired",
|
||||
"approvals",
|
||||
"shipped",
|
||||
"progress",
|
||||
"audit_log",
|
||||
"auditLog",
|
||||
"enabled",
|
||||
]);
|
||||
|
||||
export function parseFeedHostSubscriptionKey(key: string): { hostId: string } | null {
|
||||
const m = key.match(FEED_HOST_KEY_RE);
|
||||
if (!m) return null;
|
||||
const hostId = m[1]!;
|
||||
if (FEED_HOST_RESERVED_SEGMENTS.has(hostId)) return null;
|
||||
return { hostId };
|
||||
}
|
||||
|
||||
function coerceFeedHostSubscriptionValue(key: string, raw: string): boolean {
|
||||
const v = raw.trim().toLowerCase();
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
throw new Error(`Invalid value for ${key}: expected "true" or "false", got "${raw}"`);
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, parts: string[]): unknown {
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
@@ -469,6 +612,53 @@ const KEY_CONSTRAINTS: Partial<Record<ValidKey, (raw: string, coerced: string |
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.interval_seconds": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.interval_seconds: expected an integer >= 60, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 60) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.interval_seconds: must be >= 60, got ${raw}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
"snapshots.periodic.retention_keep": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim())) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.retention_keep: expected an integer >= 1, got "${raw}"`,
|
||||
);
|
||||
}
|
||||
if (typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(
|
||||
`Invalid value for snapshots.periodic.retention_keep: must be >= 1, got ${raw}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
// OPR.0.4.6.FS-1 W2 — retention numeric bounds. Lockstep with the daemon
|
||||
// settings-store KEY_CONSTRAINTS twin (same messages/bounds). retention.enabled
|
||||
// is a boolean (coerceValue enforces true/false) — no constraint entry.
|
||||
"retention.transitions_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.transitions_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.watchdog_days": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.watchdog_days: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.watchdog_keep_per_job": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 0) {
|
||||
throw new Error(`Invalid value for retention.watchdog_keep_per_job: must be an integer >= 0, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
"retention.batch_size": (raw, coerced) => {
|
||||
if (!/^-?\d+$/.test((raw ?? "").trim()) || typeof coerced !== "number" || !Number.isInteger(coerced) || coerced < 1) {
|
||||
throw new Error(`Invalid value for retention.batch_size: must be an integer >= 1, got "${raw}"`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function validateKeyConstraints(key: ValidKey, raw: string, coerced: string | number | boolean): void {
|
||||
@@ -530,6 +720,10 @@ export class ConfigStore {
|
||||
port: v("daemon.port") as number,
|
||||
host: v("daemon.host") as string,
|
||||
},
|
||||
host: {
|
||||
selected: v("host.selected") as string,
|
||||
name: v("host.name") as string,
|
||||
},
|
||||
db: {
|
||||
path: v("db.path") as string,
|
||||
},
|
||||
@@ -594,6 +788,23 @@ export class ConfigStore {
|
||||
postRestoreAuditInstruction: v("policies.claude_compaction.post_restore_audit_instruction") as string,
|
||||
},
|
||||
},
|
||||
snapshots: {
|
||||
periodic: {
|
||||
enabled: v("snapshots.periodic.enabled") as boolean,
|
||||
intervalSeconds: v("snapshots.periodic.interval_seconds") as number,
|
||||
retentionKeep: v("snapshots.periodic.retention_keep") as number,
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
statusBar: v("terminal.status_bar") as boolean,
|
||||
},
|
||||
retention: {
|
||||
enabled: v("retention.enabled") as boolean,
|
||||
transitionsDays: v("retention.transitions_days") as number,
|
||||
watchdogDays: v("retention.watchdog_days") as number,
|
||||
watchdogKeepPerJob: v("retention.watchdog_keep_per_job") as number,
|
||||
batchSize: v("retention.batch_size") as number,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,6 +814,14 @@ export class ConfigStore {
|
||||
* default).
|
||||
*/
|
||||
resolveWithSource(key: string): ResolvedSetting {
|
||||
// OPR.0.4.4.15: dynamic class resolves file-or-default (no env in v1).
|
||||
const feedHost = parseFeedHostSubscriptionKey(key);
|
||||
if (feedHost) {
|
||||
const fcDyn = this.readConfigFile();
|
||||
const fileVal = getNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"]);
|
||||
if (typeof fileVal === "boolean") return { value: fileVal, source: "file", defaultValue: false };
|
||||
return { value: false, source: "default", defaultValue: false };
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
@@ -668,6 +887,17 @@ export class ConfigStore {
|
||||
}
|
||||
|
||||
set(key: string, value: string): void {
|
||||
// OPR.0.4.4.15: the registered dynamic class is accepted; every other
|
||||
// unknown key keeps the reject-loud behavior below byte-for-byte.
|
||||
const feedHost = parseFeedHostSubscriptionKey(key);
|
||||
if (feedHost) {
|
||||
const coercedDyn = coerceFeedHostSubscriptionValue(key, value);
|
||||
const fcDyn = this.readConfigFile();
|
||||
setNestedValue(fcDyn, ["feed", "subscriptions", feedHost.hostId, "enabled"], coercedDyn);
|
||||
mkdirSync(dirname(this.configPath), { recursive: true });
|
||||
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
|
||||
return;
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
@@ -691,6 +921,16 @@ export class ConfigStore {
|
||||
try { unlinkSync(this.configPath); } catch { /* missing is fine */ }
|
||||
return;
|
||||
}
|
||||
// OPR.0.4.4.15: dynamic-class reset removes the whole host node.
|
||||
const feedHost = parseFeedHostSubscriptionKey(key);
|
||||
if (feedHost) {
|
||||
if (!existsSync(this.configPath)) return;
|
||||
const fcDyn = this.readConfigFile();
|
||||
const subsParent = getNestedValue(fcDyn, ["feed", "subscriptions"]) as Record<string, unknown> | undefined;
|
||||
if (subsParent && feedHost.hostId in subsParent) delete subsParent[feedHost.hostId];
|
||||
writeFileSync(this.configPath, JSON.stringify(fcDyn, null, 2) + "\n", "utf-8");
|
||||
return;
|
||||
}
|
||||
if (!isValidKey(key)) {
|
||||
throw new Error(`Unknown config key "${key}". Valid keys: ${VALID_KEYS.join(", ")}`);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,38 @@ import type { CrossHostResult } from "./cross-host-executor.js";
|
||||
* `capture.ts` (and any future v1 cross-host command) format identically.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 — structured failure surface for the http transport branch.
|
||||
* Each branch names its OWN step taxonomy: the http branch surfaces
|
||||
* `runRemoteHttpOp`'s steps (registry/unknown-host/permission-gate/
|
||||
* remote-daemon-unreachable/remote-command-failed) with the host named and
|
||||
* the remote route's own error text as the detail — never a generic
|
||||
* "failed", never a locally-invented message.
|
||||
*/
|
||||
export function emitRemoteHttpFailure(
|
||||
hostId: string,
|
||||
target: string,
|
||||
result: { failedStep: string; error?: string; data?: unknown },
|
||||
json?: boolean,
|
||||
hint?: string,
|
||||
): void {
|
||||
const remoteError = (result.data as { error?: string } | undefined)?.error;
|
||||
const detail = [result.error, remoteError].filter(Boolean).join(" — ");
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
ok: false,
|
||||
cross_host: { host: hostId, target, transport: "http" },
|
||||
failedStep: result.failedStep,
|
||||
error: detail,
|
||||
...(hint ? { hint } : {}),
|
||||
}));
|
||||
} else {
|
||||
console.error(`cross-host (host=${hostId}, ${target}): http ${result.failedStep}: ${detail}`);
|
||||
if (hint) console.error(`hint: ${hint}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
export function emitCrossHostError(hostId: string, code: string, message: string, json?: boolean): void {
|
||||
if (json) {
|
||||
console.log(JSON.stringify({ ok: false, cross_host: { host: hostId }, failedStep: code, error: message }));
|
||||
|
||||
@@ -15,7 +15,7 @@ export type CrossHostResult =
|
||||
| { ok: false; failedStep: "remote-daemon-unreachable"; stdout: string; stderr: string; remoteExitCode: number }
|
||||
| { ok: false; failedStep: "remote-command-failed"; stdout: string; stderr: string; remoteExitCode: number };
|
||||
|
||||
export type FailedStep = CrossHostResult["failedStep"];
|
||||
export type { FailedStep } from "./cross-host-types.js";
|
||||
|
||||
/**
|
||||
* Pluggable spawn function for testing. The default uses Node's
|
||||
@@ -56,9 +56,10 @@ export async function runCrossHostCommand(
|
||||
return {
|
||||
ok: false,
|
||||
failedStep: "ssh-unreachable",
|
||||
sshStderr: `host '${host.id}': transport '${host.transport}' is not supported (v0 supports ssh only)`,
|
||||
sshStderr: `host '${host.id}': transport '${host.transport}' is not ssh; use HTTP transport for this host`,
|
||||
};
|
||||
}
|
||||
const sshHost = host;
|
||||
if (argv.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -77,7 +78,7 @@ export async function runCrossHostCommand(
|
||||
// Stay deferential to ~/.ssh/config defaults.
|
||||
|
||||
const remoteCommandLine = argv.map(shellQuote).join(" ");
|
||||
const fullArgs = [...sshOpts, host.target, remoteCommandLine];
|
||||
const fullArgs = [...sshOpts, sshHost.target, remoteCommandLine];
|
||||
|
||||
const spawn = opts.spawn ?? (nodeSpawn as unknown as SpawnFn);
|
||||
const child = spawn("ssh", fullArgs);
|
||||
@@ -166,7 +167,10 @@ const PERMISSION_GATE_PATTERNS = [
|
||||
/no mutual signature algorithm/i,
|
||||
];
|
||||
|
||||
function looksLikePermissionGate(stderr: string): boolean {
|
||||
// OPR.0.4.4.18: exported so `rig file` classifies rsync stderr with the SAME
|
||||
// signature (single-source classification per arch ruling — never a second
|
||||
// pattern copy). Behavior unchanged.
|
||||
export function looksLikePermissionGate(stderr: string): boolean {
|
||||
return PERMISSION_GATE_PATTERNS.some((re) => re.test(stderr));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { loadHostRegistry, resolveHost } from "./host-registry.js";
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.MH4 §4 — the host-qualified TARGET sugar + precedence contract,
|
||||
* uniform across the cross-host coordination verbs (send / capture /
|
||||
* transcript; broadcast has NO session-target operand — its positional is
|
||||
* message text, which must never be sugar-parsed — so it takes `--host` +
|
||||
* the persisted selection only).
|
||||
*
|
||||
* Sugar parse rule (CLI edge ONLY — BR-1: the daemon never sees a 3-part
|
||||
* session string either way):
|
||||
* - A target of the form `X@Y@Z` is host-qualified IFF `Z` matches a
|
||||
* REGISTERED host id after registry load; then target=`X@Y`, host=`Z`.
|
||||
* - If `Z` matches no registered host (or the registry cannot load), the
|
||||
* string passes through UNCHANGED — it fails exactly as today
|
||||
* (adopted/raw session names containing `@` keep working). The returned
|
||||
* `hint` is appended to the eventual failure surface so a mistyped
|
||||
* 3-part form always dies LOUD with the host named (never a silent
|
||||
* fallthrough). This is deliberately DIFFERENT from MH-3's queue rule
|
||||
* (canonical-only destinations always-strip post-classifier); the
|
||||
* per-verb-class split is documented in cli-reference.md.
|
||||
* - Reserved ids (kernel/host/local) can never be registered
|
||||
* (RESERVED_HOST_IDS), so `@kernel`/`@host` human-seat forms can never
|
||||
* be captured by the sugar.
|
||||
*
|
||||
* Precedence (the caller composes with the persisted selection):
|
||||
* explicit `--host` > target sugar > persisted selection
|
||||
* (`resolveEffectiveHost`). `--host X` + sugar `@Y` where X≠Y is a
|
||||
* structured conflict — never a silent precedence pick. The same host
|
||||
* named twice is fine.
|
||||
*/
|
||||
export interface CrossHostTargetResolution {
|
||||
ok: true;
|
||||
/** The target with any matched host qualifier stripped. */
|
||||
target: string;
|
||||
/** The sugar-derived host id (registered suffix), if any. */
|
||||
sugarHost: string | undefined;
|
||||
/**
|
||||
* Loud-failure hint when the target was 3-part-SHAPED but the suffix
|
||||
* matched no registered host. Callers append it to failure surfaces for
|
||||
* this target; it never changes behavior on success paths.
|
||||
*/
|
||||
hint: string | undefined;
|
||||
}
|
||||
|
||||
export interface CrossHostTargetConflict {
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function resolveCrossHostTarget(
|
||||
rawTarget: string,
|
||||
explicitHost: string | undefined,
|
||||
registryLoader?: () => ReturnType<typeof loadHostRegistry>,
|
||||
): CrossHostTargetResolution | CrossHostTargetConflict {
|
||||
const atCount = rawTarget.split("@").length - 1;
|
||||
if (atCount < 2) {
|
||||
return { ok: true, target: rawTarget, sugarHost: undefined, hint: undefined };
|
||||
}
|
||||
|
||||
const lastAt = rawTarget.lastIndexOf("@");
|
||||
const base = rawTarget.slice(0, lastAt);
|
||||
const suffix = rawTarget.slice(lastAt + 1);
|
||||
|
||||
const unregisteredHint = suffix.length > 0
|
||||
? `no registered host '${suffix}' — if '${suffix}' was meant as a host, check \`rig host ls\``
|
||||
: undefined;
|
||||
|
||||
if (suffix.length === 0 || base.length === 0) {
|
||||
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
|
||||
}
|
||||
|
||||
const loader = registryLoader ?? loadHostRegistry;
|
||||
const registry = loader();
|
||||
if (!registry.ok) {
|
||||
// No registry = no registered suffix can match; pass through unchanged
|
||||
// (the plain-target behavior must not gain a new failure mode).
|
||||
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
|
||||
}
|
||||
|
||||
const resolved = resolveHost(registry.registry, suffix);
|
||||
if (!resolved.ok) {
|
||||
return { ok: true, target: rawTarget, sugarHost: undefined, hint: unregisteredHint };
|
||||
}
|
||||
|
||||
if (explicitHost !== undefined && explicitHost !== suffix) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ambiguous host: --host ${explicitHost} conflicts with the target's host qualifier @${suffix} — name one host`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, target: base, sugarHost: suffix, hint: undefined };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type FailedStep = "none" | "ssh-unreachable" | "permission-gate" | "remote-daemon-unreachable" | "remote-command-failed";
|
||||
Loaded 100 of 1049 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user