@popovych.co/witness 0.19.3 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -9
- package/dist/abandon.js +63 -29
- package/dist/cli.js +14 -5
- package/dist/config.js +23 -0
- package/dist/context.js +108 -0
- package/dist/departure.js +9 -3
- package/dist/drive.js +25 -7
- package/dist/evidence.js +15 -3
- package/dist/gate.js +17 -10
- package/dist/gates/implement.js +3 -1
- package/dist/harness.js +19 -9
- package/dist/install.js +37 -0
- package/dist/journal.js +20 -1
- package/dist/reviewer.js +3 -1
- package/dist/ship.js +20 -1
- package/dist/slice.js +185 -0
- package/dist/steps.js +149 -0
- package/dist/verbs/abandon.js +75 -27
- package/dist/verbs/check.js +45 -2
- package/dist/verbs/decide.js +12 -1
- package/dist/verbs/drive.js +12 -1
- package/dist/verbs/evidence.js +65 -7
- package/dist/verbs/init.js +9 -1
- package/dist/verbs/log.js +18 -2
- package/dist/verbs/next.js +96 -14
- package/dist/verbs/start.js +21 -11
- package/dist/verbs/step-close.js +80 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +7 -5
- package/plugin/hooks/context-core.mjs +230 -0
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/hooks/witness-pi.ts +74 -12
- package/plugin/skills/witness-brainstorm/SKILL.md +2 -1
- package/plugin/skills/witness-decompose/SKILL.md +3 -2
- package/plugin/skills/witness-design/SKILL.md +3 -2
- package/plugin/skills/witness-implement/SKILL.md +45 -15
- package/plugin/skills/witness-plan/SKILL.md +3 -2
- package/plugin/skills/witness-ship/SKILL.md +3 -2
package/README.md
CHANGED
|
@@ -63,12 +63,30 @@ witness is two halves: **six skills**, distributed through the
|
|
|
63
63
|
**engine + guard + dashboard**, which no skills installer can place — `witness init
|
|
64
64
|
--agent <name>` writes those and commits them.
|
|
65
65
|
|
|
66
|
-
**Claude Code** —
|
|
66
|
+
**Claude Code** — the native path:
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
/plugin marketplace add popovych-co/witness # skills, engine, guard and dashboard together
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
**Claude Code, on either route** (the marketplace plugin above, or `init --agent claude-code`),
|
|
73
|
+
also needs the `witness-context` plugin, installed once per machine — then start a new session:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude plugin marketplace add popovych-co/witness && claude plugin install witness-context@witness
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
It puts the session's live context reading into every Bash command, which is what lets a
|
|
80
|
+
session hand over at `session.contextLimit` (see Run economics below). The reading comes from
|
|
81
|
+
a hooks module, and only a plugin can carry one. `init --agent claude-code` also declares the
|
|
82
|
+
plugin in `.claude/settings.json`, the way a project names a plugin it needs, but the
|
|
83
|
+
declaration alone does not load it in a headless session (`claude -p`, `witness drive`). A
|
|
84
|
+
session loads the plugin when it starts, so one already running when you install it does not:
|
|
85
|
+
start a new one. The install is a copy: after upgrading witness, `claude plugin update
|
|
86
|
+
witness-context@witness` refreshes it, for the sessions started afterwards. Without the plugin
|
|
87
|
+
witness still runs, on the step budget, and says so; `witness check`, run from a session, reports
|
|
88
|
+
whether that session sees the reading.
|
|
89
|
+
|
|
72
90
|
**Pi** (and any other agent the installer supports):
|
|
73
91
|
|
|
74
92
|
```bash
|
|
@@ -148,6 +166,8 @@ either.
|
|
|
148
166
|
|---|---|
|
|
149
167
|
| `harness: claude-code \| pi` | **the judge** — which harness runs this repo's gate reviewers, is measured by `calibrate`, and is probed by `check`. Judgment resolves `harness:` → `PI_CODING_AGENT` → `CLAUDECODE` → `claude-code`; session lines (`next`'s handoff, `dispatch`'s relay, `init --agent auto`) resolve detection first, because those name the CLI you are about to type at. Committed, so it binds every teammate's gates; `witness init --agent` writes it once and never re-points it. Undeclared repos are judged by whichever agent's session is open — `witness check` and `witness status` say so |
|
|
150
168
|
| `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
|
|
169
|
+
| `session.contextLimit` | the share of a session's context window, in percent, at which witness hands the work to a fresh session (integer 1–100, default 20). The harness measures the fill; the CLI decides where the journal carries the position — at a step close, after a revise, at a `witness ship` exit and at `next` — and never mid-red. A session whose fill cannot be read says so (`context: unmeasured (<reason>)`): an implement session then hands over by `implement.stepsPerDispatch`, and nothing else hands over (DESIGN.md row 191) |
|
|
170
|
+
| `implement.stepsPerDispatch` | the step budget: steps an implement session closes before it hands over when its fill cannot be read (default 3) — the fallback for `session.contextLimit` |
|
|
151
171
|
| `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
|
|
152
172
|
| `drive.parallel` | sessions `witness drive` runs at once under autonomy, one per target (default 2) |
|
|
153
173
|
| `drive.parallelSuites` | `true` lets test runs in two worktrees overlap (default false: they take turns, because suites that boot servers or browsers collide) |
|
|
@@ -198,9 +218,10 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
198
218
|
| `next` | the one next action across every effort |
|
|
199
219
|
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] [--ask "<text>"]` | runs every session itself · under autonomy it needs no terminal and reports when nothing else can move. Up to `drive.parallel` targets at once, one session per target; usage limits are waited out; a target past a cap is parked. `--detach` (autonomy only) runs it in the background into `.witness/drive.log`; `--ask` queues an ask first; `--manual` runs serially and asks you at every stop. One drive per repo. A session drive spawned is refused — agents are what drive spawns, never what spawns drive |
|
|
200
220
|
| `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
|
|
201
|
-
| `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
|
|
221
|
+
| `test-evidence <plan-id> --phase red\|green [--step <id>]` / `verify-red <plan-id> [--base <ref>] [--step <id>]` | journal red/green criteria evidence from a worktree; `--step` names the plan step the cycle closes — required before the plan's first implement gate (on a plan with a criteria step), left off for a fix round after it. A green that closes a step prints `context:`, `steps:` and `slice:` |
|
|
222
|
+
| `step-close <plan-id> --step <id>` | close a scaffolding step, which has no red→green to witness; prints `context:`, `steps:` and `slice:` as a closing green does, and closing it again changes nothing |
|
|
202
223
|
| `adopt <path>` | absolve a finished hand-edit into the journal |
|
|
203
|
-
| `abandon <plan-id \| effort-slug
|
|
224
|
+
| `abandon <plan-id \| effort-slug…>` | wind a plan/effort down; reverts only specs that effort itself wrote — several efforts go as one act, so a split or an amendment they share goes back together |
|
|
204
225
|
| `dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n>` | journal a session slice's telemetry |
|
|
205
226
|
| `rename <old> <new>` | id rename across canon, refs, journal |
|
|
206
227
|
| `clean` | reap stale worktrees |
|
|
@@ -236,15 +257,41 @@ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by tw
|
|
|
236
257
|
|
|
237
258
|
## Run economics (operator notes)
|
|
238
259
|
|
|
239
|
-
|
|
240
|
-
|
|
260
|
+
A witness session ends at a configured share of its context window:
|
|
261
|
+
`session.contextLimit`, 20% by default (DESIGN.md row 191). The harness measures the
|
|
262
|
+
fill — the `witness-context` plugin on Claude Code and the witness extension on Pi put the
|
|
263
|
+
live reading into every shell command's environment — and witness counts no tokens of its
|
|
264
|
+
own. The CLI decides only where the journal already carries the position: at a step
|
|
265
|
+
close (`slice: end`), after a revise, at a `witness ship` exit and at `next`, by
|
|
266
|
+
printing a `relay:` line. A session never hands over mid-red. Once per session, at the
|
|
267
|
+
limit, one line follows a command's result, so a long step learns it is over:
|
|
268
|
+
|
|
269
|
+
```
|
|
270
|
+
witness: context 21% of 1M has reached the 20% limit — finish what you are in (implement: the red→green), then hand over
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Where nothing supplies the reading, witness says so —
|
|
274
|
+
`context: unmeasured (no adapter) — step budget 3` — and an implement session hands over
|
|
275
|
+
after `implement.stepsPerDispatch` steps instead; nothing else hands over.
|
|
276
|
+
|
|
277
|
+
Operator notes, non-load-bearing — the design depends on none of them:
|
|
241
278
|
|
|
279
|
+
- **Mind the window.** The limit is a share, so one warm-up weighs differently by model:
|
|
280
|
+
51k tokens is 5% of a 1M window and 25% of a 200k one. A session that opens over the
|
|
281
|
+
limit is warned (`warn: this session opens at …`) and works one step per slice; on a
|
|
282
|
+
small window, raise `session.contextLimit` or accept a warm-up per step. `witness drive`'s
|
|
283
|
+
defaults were set when a slice was three steps: `drive.sessionTimeoutMs` (1 h) and
|
|
284
|
+
`drive.caps.spawns` (24). A measured slice on a large window can run past an hour, and on a
|
|
285
|
+
200k window every slice is one step, so a plan takes more sessions; raise either if drive
|
|
286
|
+
times out or caps a plan that is still moving.
|
|
242
287
|
- **Keep the host awake.** A suspend longer than an hour mid-run expires the prompt
|
|
243
288
|
cache; on wake the full agent context is re-written at cache-write price. On macOS:
|
|
244
289
|
`witness drive` holds `caffeinate -ims` for its own lifetime (a closed lid on battery
|
|
245
290
|
still sleeps); for a chat session, `caffeinate -dims`, or plug in and disable sleep.
|
|
246
291
|
- **`CLAUDE_CODE_AUTO_COMPACT_WINDOW`** (set at CLI launch) lowers the harness's
|
|
247
|
-
auto-compaction threshold as belt-and-suspenders under the
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
292
|
+
auto-compaction threshold, as belt-and-suspenders under the fill relay. Compaction
|
|
293
|
+
stays the harness's continuity mechanism, never witness's; the variable is documented
|
|
294
|
+
for main sessions only — whether subagents honor it is unverified.
|
|
295
|
+
- The designed mechanisms are the fill relay (`session.contextLimit`, with
|
|
296
|
+
`implement.stepsPerDispatch` for sessions it cannot measure), the loop-width protocol,
|
|
297
|
+
and `dispatch-report` telemetry — see DESIGN.md rows 79–81 and 191.
|
package/dist/abandon.js
CHANGED
|
@@ -58,12 +58,32 @@ const shaAt = (root, rev, rel) => {
|
|
|
58
58
|
// D168 (review). Whether this effort's abandon left its write standing — kept, because a plan
|
|
59
59
|
// stood on it or on a sibling slice, or the walk stopped at it. That content is canon of record:
|
|
60
60
|
// no later walk goes past it, or an amendment on top of it took the keep with it.
|
|
61
|
-
function stoodAtAbandon(root, effort, rel, sha) {
|
|
61
|
+
export function stoodAtAbandon(root, effort, rel, sha) {
|
|
62
62
|
const at = journalActs(root, effort, 'HEAD').find(({ entry }) => entry.t === 'human-decision' && entry.decision === 'abandon-effort');
|
|
63
63
|
return at !== undefined && shaAt(root, at.commit, rel) === sha;
|
|
64
64
|
}
|
|
65
65
|
// This effort's newest write of an artifact (of this content, when `sha` is given).
|
|
66
66
|
export const effortWrite = (root, effort, id, sha) => journalActs(root, effort, 'HEAD').filter(({ entry }) => isWrite(entry, id, sha)).at(-1);
|
|
67
|
+
// D190 (review). The effort whose standing write a take-back stopped at, over some write of
|
|
68
|
+
// `effort` that the take-back neither went past nor found already taken back — the write would
|
|
69
|
+
// stay in canon with its effort abandoned. A write under content that stands for good (shipped by
|
|
70
|
+
// a plan outside the set, or left standing by its own effort's abandon) has nothing left to take.
|
|
71
|
+
export function leftUnder(root, canon, spec, effort, walk, abandonSet) {
|
|
72
|
+
const id = String(spec.meta.id);
|
|
73
|
+
const stop = walk.stop === undefined ? undefined : writeAct(root, id, walk.stop.sha, walk.stop.src);
|
|
74
|
+
const forGood = walk.stop !== undefined && (plansOn(canon, id, { exclude: abandonSet, pin: walk.stop.sha }).some((p) => String(p.meta.status) === 'done') ||
|
|
75
|
+
(stop !== undefined && effortAbandoned(readStream(root, stop.effort)) && stoodAtAbandon(root, stop.effort, spec.rel, walk.stop.sha)));
|
|
76
|
+
for (const { entry, commit } of journalActs(root, effort, 'HEAD')) {
|
|
77
|
+
if (!isWrite(entry, id) || walk.passed.includes(commit))
|
|
78
|
+
continue;
|
|
79
|
+
if (takenBackAlready(root, effort, id, spec.rel, String(entry.sha)))
|
|
80
|
+
continue;
|
|
81
|
+
if (forGood)
|
|
82
|
+
continue;
|
|
83
|
+
return stop?.effort ?? 'another write';
|
|
84
|
+
}
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
67
87
|
// D168 (review). Whether an earlier abandon already took this effort's newest write of a spec
|
|
68
88
|
// back — `abandon <plan>` does, before the effort's own abandon: a status entry the abandon
|
|
69
89
|
// journaled on the spec after that write, which is not a return, made where the spec stood at
|
|
@@ -95,8 +115,9 @@ export function abandonLines(items, kept) {
|
|
|
95
115
|
}
|
|
96
116
|
// D166 (review). The efforts whose writes the way back takes back besides this abandon's own
|
|
97
117
|
// targets: every effort already abandoned — whose abandon skipped a spec a later re-slice had
|
|
98
|
-
// departed — and the
|
|
99
|
-
|
|
118
|
+
// departed — and the efforts being abandoned now (`efforts`, absent on the plan path; D190:
|
|
119
|
+
// several, when they are abandoned as one act).
|
|
120
|
+
const goneEfforts = (root, efforts) => effortStreams(root).filter((e) => (efforts ?? []).includes(e) || effortAbandoned(readStream(root, e)));
|
|
100
121
|
// Content whose act is a write of one of `efforts` never comes back into canon on its own —
|
|
101
122
|
// unless a plan outside the abandon set stands on it (the keep rule, which never takes shipped
|
|
102
123
|
// content back), or that effort's own abandon left it standing.
|
|
@@ -108,9 +129,10 @@ function takenBackBy(root, canon, abandonSet, efforts) {
|
|
|
108
129
|
return act !== undefined && efforts.includes(act.effort) && !stoodAtAbandon(root, act.effort, rel, sha) ? act : undefined;
|
|
109
130
|
};
|
|
110
131
|
}
|
|
111
|
-
// `act`: the write of `pin` this abandon takes back — the effort
|
|
112
|
-
//
|
|
113
|
-
|
|
132
|
+
// `act`: the write of `pin` this abandon takes back — on the effort path the write that is the
|
|
133
|
+
// spec's content, by one of the efforts being abandoned (`efforts`); on the plan path the write
|
|
134
|
+
// the plan is paired with.
|
|
135
|
+
export function specRevertFor(root, canon, spec, pin, abandonSet, act, efforts) {
|
|
114
136
|
const current = canonicalSha(spec.meta, spec.body);
|
|
115
137
|
if (current !== pin) {
|
|
116
138
|
return refuse([v('spec', 'stacked-amendment', `${String(spec.meta.id)} is at ${current.slice(0, 7)}, pin is ${pin.slice(0, 7)}`, 'abandon newest-first: a later amendment touched this spec')]);
|
|
@@ -131,7 +153,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
131
153
|
// this spec it made: a revise round re-writes, and taking back only the last write left the
|
|
132
154
|
// effort's own first draft in canon. The walk goes back past those and past every abandoned
|
|
133
155
|
// effort's write, and stops at content a plan stands on.
|
|
134
|
-
const gone = goneEfforts(root,
|
|
156
|
+
const gone = goneEfforts(root, efforts);
|
|
135
157
|
const walking = [...new Set([...gone, act.effort])];
|
|
136
158
|
const back = takeBack(root, spec.rel, id, act, takenBackBy(root, canon, abandonSet, walking));
|
|
137
159
|
if (back.end === 'lost') {
|
|
@@ -141,7 +163,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
141
163
|
// another plan still building stands on it, is a keep of that effort's content: the same keep
|
|
142
164
|
// the first round alone would have been, and refused for the same reason — this abandon is the
|
|
143
165
|
// last act that could take it back. Once a plan shipped it, it stands for good.
|
|
144
|
-
if (
|
|
166
|
+
if (efforts !== undefined && back.end === 'content') {
|
|
145
167
|
const sha = canonicalSha(back.doc.meta, back.doc.body);
|
|
146
168
|
const on = plansOn(canon, id, { exclude: abandonSet, pin: sha });
|
|
147
169
|
const owner = on.length > 0 ? writeAct(root, id, sha, back.src) : undefined;
|
|
@@ -163,12 +185,12 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
163
185
|
// good when a plan shipped it — and then so does this keep, since nothing can take the
|
|
164
186
|
// sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
|
|
165
187
|
// D188: on the plan path, also a slice whose own write departed nothing — another slice's
|
|
166
|
-
// write took the predecessor out. Its effort is still live, and
|
|
167
|
-
// takes the split back together
|
|
168
|
-
//
|
|
169
|
-
// with
|
|
188
|
+
// write took the predecessor out. Its effort is still live, and that effort's abandon is the act
|
|
189
|
+
// that takes the split back together. On the effort path a hold would tie this abandon to the
|
|
190
|
+
// other slices' efforts, and row 190's review found that no single act frees every such hold
|
|
191
|
+
// (an effort with shipped work cannot be abandoned; an adopted slice has no effort at all).
|
|
170
192
|
const own = spec.meta.supersedes === undefined ? undefined : String(spec.meta.supersedes);
|
|
171
|
-
const slice =
|
|
193
|
+
const slice = efforts === undefined && back.end === 'create' && own !== undefined && canonExit(readStream(root, own))?.to === 'superseded' ? [own] : [];
|
|
172
194
|
const cut = new Set([...returns.map((r) => r.entry.artifact), ...unwound.map((u) => u.artifact), ...slice]);
|
|
173
195
|
const slices = canon.docs
|
|
174
196
|
.filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== id)
|
|
@@ -176,7 +198,8 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
176
198
|
const id = String(d.meta.id);
|
|
177
199
|
const leaving = abandonSet.has(id);
|
|
178
200
|
const on = plansOn(canon, id, leaving ? { exclude: abandonSet, pin: canonicalSha(d.meta, d.body) } : { exclude: abandonSet });
|
|
179
|
-
|
|
201
|
+
// D190: a slice stamped live is shipped behavior whether or not a plan of it is on file.
|
|
202
|
+
return leaving && on.length === 0 ? [] : [{ id, shipped: String(d.meta.status) === 'live' || on.some((p) => String(p.meta.status) === 'done') }];
|
|
180
203
|
});
|
|
181
204
|
if (slices.length > 0) {
|
|
182
205
|
return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
|
|
@@ -192,7 +215,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
192
215
|
// D166. The unwind departs the spec through the same door the re-slice uses, so
|
|
193
216
|
// everything that leaves canon with a spec — its design included — leaves here too.
|
|
194
217
|
return ok({
|
|
195
|
-
doc: spec, action: 'delete', returns, unwound,
|
|
218
|
+
doc: spec, action: 'delete', returns, unwound, walk: { passed: back.passed.map((w) => w.commit) },
|
|
196
219
|
departure: prepareDeparture(root, canon.paths, spec, {
|
|
197
220
|
v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: 'removed', cause: 'abandon',
|
|
198
221
|
}),
|
|
@@ -204,7 +227,9 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
|
204
227
|
const designR = designRel(canon.paths, id);
|
|
205
228
|
const priorDesign = gitBlob(root, back.src, designR);
|
|
206
229
|
const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
|
|
207
|
-
|
|
230
|
+
const stopSha = back.end === 'content' ? canonicalSha(back.doc.meta, back.doc.body) : undefined;
|
|
231
|
+
const walk = { passed: back.passed.map((w) => w.commit), ...(stopSha === undefined ? {} : { stop: { src: back.src, sha: stopSha } }) };
|
|
232
|
+
return ok({ doc: spec, action: 'restore', content: back.blob.toString('utf8'), toStatus, design, returns, unwound, walk });
|
|
208
233
|
}
|
|
209
234
|
// D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
|
|
210
235
|
// file, so another doc may hold that path — or another spec this abandon brings back may have
|
|
@@ -232,9 +257,18 @@ export function returnCollisions(root, canon, items) {
|
|
|
232
257
|
// back only beside what it depends on: a dependency in canon that this abandon does not take
|
|
233
258
|
// out, or one it brings back. `write` refuses a dangling `depends`, and an abandon landing one
|
|
234
259
|
// left `check` red after it exited 0. Bringing the dependency back first is a human's call.
|
|
235
|
-
export function landingEdges(root, canon, items) {
|
|
260
|
+
export function landingEdges(root, canon, items, abandonSet = new Set()) {
|
|
236
261
|
const leaving = new Set(items.flatMap((i) => (i.revert?.action === 'delete' ? [String(i.revert.doc.meta.id)] : [])));
|
|
237
262
|
const coming = new Set(items.flatMap((i) => (i.revert?.returns ?? []).map((r) => r.entry.artifact)));
|
|
263
|
+
const restored = new Set(items.flatMap((i) => (i.revert?.action === 'restore' ? [String(i.revert.doc.meta.id)] : [])));
|
|
264
|
+
// D190 (review). A doc of the abandon set that this act leaves as it is — kept, or a write an
|
|
265
|
+
// earlier abandon already took back — stays in canon, so what it depends on may not leave:
|
|
266
|
+
// `waiting-dependents` reads only docs outside the set, and the act deleted the dependency.
|
|
267
|
+
const stays = canon.docs
|
|
268
|
+
.filter((d) => d.meta.type === 'spec' && abandonSet.has(String(d.meta.id)) && !leaving.has(String(d.meta.id)) && !restored.has(String(d.meta.id)))
|
|
269
|
+
.flatMap((d) => (d.meta.depends ?? []).map(String)
|
|
270
|
+
.filter((dep) => leaving.has(dep) && !coming.has(dep))
|
|
271
|
+
.map((dep) => v('spec', 'waiting-dependents', `${String(d.meta.id)} depends on ${dep}`, `${dep} kept while ${String(d.meta.id)} stays in canon — no doc this abandon leaves standing may depend on a spec it deletes`)));
|
|
238
272
|
const lands = items.flatMap((i) => {
|
|
239
273
|
if (!i.revert)
|
|
240
274
|
return [];
|
|
@@ -251,7 +285,7 @@ export function landingEdges(root, canon, items) {
|
|
|
251
285
|
const why = leaving.has(dep) ? 'it leaves with this abandon'
|
|
252
286
|
: exit?.to === 'superseded' ? `it left superseded by ${exit.by ?? 'a successor'}` : 'it left canon';
|
|
253
287
|
return humanCall(v('spec', 'dangling-depends', `${id} depends on ${dep}`, `${dep} in canon when ${id} comes back — ${why} · a human's call`));
|
|
254
|
-
}));
|
|
288
|
+
})).concat(stays);
|
|
255
289
|
}
|
|
256
290
|
// D188 (review). A spec kept for good stays in canon with what it depends on: a dependency this
|
|
257
291
|
// abandon would delete is kept beside it, and for good with it. `waiting-dependents` reads only
|
|
@@ -276,7 +310,7 @@ export function keepDependencies(items, kept) {
|
|
|
276
310
|
}
|
|
277
311
|
return { items: walked, kept: held };
|
|
278
312
|
}
|
|
279
|
-
export function executeAbandon(root, ctx, items,
|
|
313
|
+
export function executeAbandon(root, ctx, items, efforts, subject) {
|
|
280
314
|
const files = new Set();
|
|
281
315
|
const journalMulti = [];
|
|
282
316
|
const stamps = [];
|
|
@@ -319,16 +353,17 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
|
319
353
|
returnEntries.push(...lines);
|
|
320
354
|
}
|
|
321
355
|
}
|
|
322
|
-
|
|
323
|
-
|
|
356
|
+
// D190. Each effort the act abandons ends in the same transaction: one terminal entry each.
|
|
357
|
+
const effortLines = efforts.map((effort) => {
|
|
324
358
|
const entry = {
|
|
325
|
-
v: 1, t: 'human-decision', gate: 'effort', artifact:
|
|
359
|
+
v: 1, t: 'human-decision', gate: 'effort', artifact: effort,
|
|
326
360
|
round: 0, decision: 'abandon-effort',
|
|
327
361
|
};
|
|
328
|
-
|
|
329
|
-
files.add(journalRel(
|
|
330
|
-
journalMulti.push(
|
|
331
|
-
|
|
362
|
+
const line = { stream: effort, line: entryLine(entry) };
|
|
363
|
+
files.add(journalRel(effort));
|
|
364
|
+
journalMulti.push(line);
|
|
365
|
+
return line;
|
|
366
|
+
});
|
|
332
367
|
const lockR = acquireLock(root);
|
|
333
368
|
if (!lockR.ok)
|
|
334
369
|
return lockR;
|
|
@@ -360,9 +395,8 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
|
360
395
|
appendEntry(root, se.stream, se.entry);
|
|
361
396
|
for (const re of returnEntries)
|
|
362
397
|
appendEntry(root, re.stream, re.entry);
|
|
363
|
-
|
|
364
|
-
appendEntry(root,
|
|
365
|
-
}
|
|
398
|
+
for (const l of effortLines)
|
|
399
|
+
appendEntry(root, l.stream, JSON.parse(l.line));
|
|
366
400
|
crashPoint(ctx.env, 'abandon-commit');
|
|
367
401
|
const r = stateCommit(root, [...files], subject);
|
|
368
402
|
// D141. `abandon` runs from wherever the human typed it, which may be the very
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { configPath } from './config.js';
|
|
3
|
+
import { journalStamp } from './context.js';
|
|
3
4
|
import { stateFloor } from './floor.js';
|
|
4
5
|
import { primaryRoot } from './gitio.js';
|
|
6
|
+
import { withJournalCtx } from './journal.js';
|
|
5
7
|
import { renderRefusal, v } from './refusal.js';
|
|
6
8
|
import { NPX_LATEST, compareTriple, version } from './version.js';
|
|
7
9
|
export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
@@ -24,6 +26,8 @@ const VERBS = {
|
|
|
24
26
|
'test-evidence': () => import('./verbs/evidence.js'),
|
|
25
27
|
'verify-red': () => import('./verbs/evidence.js').then((m) => ({ run: m.runVerifyRed })),
|
|
26
28
|
'dispatch-report': () => import('./verbs/dispatch.js'),
|
|
29
|
+
// Row 191 §4.3. The close of a scaffolding step, the one plan step with no evidence to witness.
|
|
30
|
+
'step-close': () => import('./verbs/step-close.js'),
|
|
27
31
|
adopt: () => import('./verbs/adopt.js'),
|
|
28
32
|
gate: () => import('./verbs/gate.js'),
|
|
29
33
|
decide: () => import('./verbs/decide.js'),
|
|
@@ -51,7 +55,7 @@ const VERBS = {
|
|
|
51
55
|
// answered centrally so no verb can crash on --help (several parse argv
|
|
52
56
|
// strictly); each line mirrors the verb's own usage refusal where one exists
|
|
53
57
|
const VERB_USAGE = {
|
|
54
|
-
abandon: 'witness abandon <plan-id | effort-slug
|
|
58
|
+
abandon: 'witness abandon <plan-id | effort-slug…>',
|
|
55
59
|
adopt: 'witness adopt <specs/... | plans/...>.md',
|
|
56
60
|
ask: 'witness ask <text> | --withdraw <id> [--note <why>]',
|
|
57
61
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
@@ -77,10 +81,11 @@ const VERB_USAGE = {
|
|
|
77
81
|
ship: 'witness ship <plan-id> [--manual]',
|
|
78
82
|
start: 'witness start <plan-id>',
|
|
79
83
|
status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
|
|
84
|
+
'step-close': 'witness step-close <plan-id> --step <id>',
|
|
80
85
|
sync: 'witness sync',
|
|
81
86
|
trust: 'witness trust <artifact-id> [--yes]',
|
|
82
|
-
'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
|
|
83
|
-
'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
|
|
87
|
+
'test-evidence': 'witness test-evidence <plan-id> --phase red|green [--step <id>]',
|
|
88
|
+
'verify-red': 'witness verify-red <plan-id> [--base <ref>] [--step <id>]',
|
|
84
89
|
};
|
|
85
90
|
// Row 116 moved the body to version.ts, which journal.ts can import without closing a
|
|
86
91
|
// cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
|
|
@@ -132,11 +137,15 @@ export async function main(ctx, argv) {
|
|
|
132
137
|
}
|
|
133
138
|
}
|
|
134
139
|
}
|
|
140
|
+
// Row 191 §4.8. Every line this command journals carries the session it runs in, read once
|
|
141
|
+
// from this invocation's own env: an in-process `main` (drive's decisions, a test's CLI) is
|
|
142
|
+
// an invocation of its own, with its own env and so its own stamp.
|
|
143
|
+
const stamp = journalStamp(ctx.env);
|
|
135
144
|
if (!verb || verb === 'help' || verb === '--help') {
|
|
136
145
|
if (!verb) {
|
|
137
146
|
const rootRes = primaryRoot(ctx.cwd);
|
|
138
147
|
if (rootRes.ok && existsSync(configPath(rootRes.value))) {
|
|
139
|
-
return (await import('./verbs/dashboard.js')).run(ctx, []);
|
|
148
|
+
return withJournalCtx(stamp, async () => (await import('./verbs/dashboard.js')).run(ctx, []));
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
151
|
ctx.out(usage());
|
|
@@ -153,7 +162,7 @@ export async function main(ctx, argv) {
|
|
|
153
162
|
return EXIT.REFUSED;
|
|
154
163
|
}
|
|
155
164
|
try {
|
|
156
|
-
return await (await load()).run(ctx, rest);
|
|
165
|
+
return await withJournalCtx(stamp, async () => (await load()).run(ctx, rest));
|
|
157
166
|
}
|
|
158
167
|
catch (e) {
|
|
159
168
|
// Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
|
package/dist/config.js
CHANGED
|
@@ -99,6 +99,25 @@ export function resolveImplement(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
return ok({ stepsPerDispatch: val });
|
|
101
101
|
}
|
|
102
|
+
// Row 191 §4.2. The default 20 is the row's premise (§1): once a session's context is more
|
|
103
|
+
// than about 20% full, its output gets worse. A repo decision, so its one home is
|
|
104
|
+
// witness.config.yaml, never config.local.yaml (row 90).
|
|
105
|
+
export const DEFAULT_CONTEXT_LIMIT = 20;
|
|
106
|
+
export function resolveSession(raw) {
|
|
107
|
+
const conf = raw.session;
|
|
108
|
+
if (conf === undefined)
|
|
109
|
+
return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
|
|
110
|
+
if (typeof conf !== 'object' || conf === null || Array.isArray(conf)) {
|
|
111
|
+
return refuse([v('session', 'invalid', String(conf), 'a map of session settings')]);
|
|
112
|
+
}
|
|
113
|
+
const val = conf.contextLimit;
|
|
114
|
+
if (val === undefined)
|
|
115
|
+
return ok({ contextLimit: DEFAULT_CONTEXT_LIMIT });
|
|
116
|
+
if (typeof val !== 'number' || !Number.isInteger(val) || val < 1 || val > 100) {
|
|
117
|
+
return refuse([v('session.contextLimit', 'invalid', String(val), 'an integer from 1 to 100 — % of the session\'s context window at which witness hands over to a fresh session')]);
|
|
118
|
+
}
|
|
119
|
+
return ok({ contextLimit: val });
|
|
120
|
+
}
|
|
102
121
|
// D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
|
|
103
122
|
// and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
|
|
104
123
|
// once, a starved one kills a working e2e run and hands the human a false `spawn-timeout`
|
|
@@ -356,6 +375,9 @@ export function loadConfig(root) {
|
|
|
356
375
|
const implement = resolveImplement(obj);
|
|
357
376
|
if (!implement.ok)
|
|
358
377
|
return refuse(implement.violations);
|
|
378
|
+
const session = resolveSession(obj);
|
|
379
|
+
if (!session.ok)
|
|
380
|
+
return refuse(session.violations);
|
|
359
381
|
const gates = resolveGates(obj);
|
|
360
382
|
if (!gates.ok)
|
|
361
383
|
return refuse(gates.violations);
|
|
@@ -375,6 +397,7 @@ export function loadConfig(root) {
|
|
|
375
397
|
paths: paths.value,
|
|
376
398
|
docs: docs.value,
|
|
377
399
|
implement: implement.value,
|
|
400
|
+
session: session.value,
|
|
378
401
|
gates: gates.value,
|
|
379
402
|
drive: drive.value,
|
|
380
403
|
autonomy: autonomy.value && !uncommitted,
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { HARNESSES, loadHarness, resolveDriver } from './harness.js';
|
|
3
|
+
// Row 191 §4.1. Before every shell command the harness adapter puts the session's live fill
|
|
4
|
+
// here, as the harness itself measures it:
|
|
5
|
+
// {"session":"<harness session id>","tokens":31627,"window":1000000,"percent":3}
|
|
6
|
+
// The CLI counts nothing and keeps no table of windows (§2: a second ledger would drift from
|
|
7
|
+
// what the harness shows), and it ignores `percent`, so the two harnesses cannot round apart.
|
|
8
|
+
export const CONTEXT_ENV = 'WITNESS_CONTEXT';
|
|
9
|
+
// The session is a fact about the process running this command, not about the repo, so it
|
|
10
|
+
// comes from the driver ladder without the config rung: detection, else the default harness.
|
|
11
|
+
// The variable is the registry's `session` facet, never spelled here.
|
|
12
|
+
export function sessionIdOf(env) {
|
|
13
|
+
const driver = resolveDriver(env, {});
|
|
14
|
+
if (!driver.ok)
|
|
15
|
+
return undefined;
|
|
16
|
+
const id = env[driver.value.harness.session];
|
|
17
|
+
return id === undefined || id === '' ? undefined : id;
|
|
18
|
+
}
|
|
19
|
+
// Row 191 §4.8: the journal is committed, so it carries this hash and never the id itself.
|
|
20
|
+
export function sessionHash(id) {
|
|
21
|
+
return createHash('sha256').update(id).digest('hex').slice(0, 12);
|
|
22
|
+
}
|
|
23
|
+
const unmeasured = (reason) => ({ measured: false, reason });
|
|
24
|
+
const isInt = (x) => typeof x === 'number' && Number.isInteger(x);
|
|
25
|
+
// Row 191 §4.1. Trusted only when it parses, carries integer counts with tokens ≤ window, and
|
|
26
|
+
// names the session the harness names. That last check is what makes an inherited value
|
|
27
|
+
// harmless: a drive launched from a chat session's shell carries the chat's reading, and its
|
|
28
|
+
// child runs as a session of its own. The order is the reasons' precedence: a broken value is
|
|
29
|
+
// malformed whoever wrote it, and only this session's own value can say it has no reading yet.
|
|
30
|
+
export function readFill(env) {
|
|
31
|
+
const raw = env[CONTEXT_ENV];
|
|
32
|
+
if (raw === undefined || raw === '')
|
|
33
|
+
return unmeasured('no adapter');
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(raw);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return unmeasured('malformed');
|
|
40
|
+
}
|
|
41
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
42
|
+
return unmeasured('malformed');
|
|
43
|
+
const { session, tokens, window } = parsed;
|
|
44
|
+
if (typeof session !== 'string' || session === '')
|
|
45
|
+
return unmeasured('malformed');
|
|
46
|
+
if (!isInt(window) || window < 1)
|
|
47
|
+
return unmeasured('malformed');
|
|
48
|
+
if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || tokens > window)) {
|
|
49
|
+
return unmeasured('malformed');
|
|
50
|
+
}
|
|
51
|
+
if (session !== sessionIdOf(env))
|
|
52
|
+
return unmeasured('stale');
|
|
53
|
+
// P7: Claude Code sends no `tokens` before the session's first response, Pi sends 0 then,
|
|
54
|
+
// and Pi sends null right after a compaction. Each is a reading not taken yet.
|
|
55
|
+
if (!isInt(tokens) || tokens === 0)
|
|
56
|
+
return unmeasured('no reading yet');
|
|
57
|
+
return { measured: true, reading: { session, tokens, window } };
|
|
58
|
+
}
|
|
59
|
+
// Row 191 §4.8: what this invocation's journal lines carry. The session whenever the harness
|
|
60
|
+
// names one, even unmeasured, because the step-budget fallback counts a session's steps by
|
|
61
|
+
// it; the reading only when it is trusted. Outside a harness session, nothing.
|
|
62
|
+
export function journalStamp(env) {
|
|
63
|
+
const id = sessionIdOf(env);
|
|
64
|
+
if (id === undefined)
|
|
65
|
+
return undefined;
|
|
66
|
+
const session = sessionHash(id);
|
|
67
|
+
const fill = readFill(env);
|
|
68
|
+
return fill.measured ? { session, tokens: fill.reading.tokens, window: fill.reading.window } : { session };
|
|
69
|
+
}
|
|
70
|
+
// Row 191 §4.9. Drive is a scheduler, not a session. Started from a chat session's shell, it
|
|
71
|
+
// inherits that session's reading and the harness's session variable, and neither is a fact
|
|
72
|
+
// about drive or about the sessions it spawns: each harness names its own session on its own
|
|
73
|
+
// commands (P1, and pi's bash tool). Every harness's variable goes, whichever drive spawns,
|
|
74
|
+
// read from the registry so none is spelled here.
|
|
75
|
+
export function withoutSession(env) {
|
|
76
|
+
const out = { ...env };
|
|
77
|
+
delete out[CONTEXT_ENV];
|
|
78
|
+
for (const name of HARNESSES) {
|
|
79
|
+
const harness = loadHarness(name);
|
|
80
|
+
if (harness.ok)
|
|
81
|
+
delete out[harness.value.session];
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
// Row 191 §4.5. The decision is `reached`, in integer tokens; the display is floored, so the
|
|
86
|
+
// two agree: a printed 19% never hands over and a printed 20% always does. Rounding would
|
|
87
|
+
// print 20% beside a session that carries on.
|
|
88
|
+
//
|
|
89
|
+
// pctOf and windowLabel have a twin in plugin/hooks/context-core.mjs, for the adapters' warning:
|
|
90
|
+
// the CLI must not import plugin/ at runtime, so tests/context-core.test.ts holds the two equal.
|
|
91
|
+
export function pctOf(tokens, window) {
|
|
92
|
+
return Math.floor(tokens * 100 / window);
|
|
93
|
+
}
|
|
94
|
+
export function reached(tokens, window, limit) {
|
|
95
|
+
return tokens * 100 >= limit * window;
|
|
96
|
+
}
|
|
97
|
+
// The window a fill is a share of, the way people say it: 1M, 200k. Display only: a window that
|
|
98
|
+
// is not round reads approximately (1048576 is 1M), and the percentage still uses the exact one.
|
|
99
|
+
// Thousands are rounded first, so a window that rounds up to a thousand of them reads 1M, never
|
|
100
|
+
// 1000k.
|
|
101
|
+
export function windowLabel(window) {
|
|
102
|
+
if (window < 1_000)
|
|
103
|
+
return String(window);
|
|
104
|
+
const thousands = Math.round(window / 1_000);
|
|
105
|
+
if (thousands < 1_000)
|
|
106
|
+
return `${thousands}k`;
|
|
107
|
+
return `${Math.round(window / 100_000) / 10}M`;
|
|
108
|
+
}
|
package/dist/departure.js
CHANGED
|
@@ -137,12 +137,18 @@ function returnsThrough(root, paths, commit, chain, takenBack, seen) {
|
|
|
137
137
|
const reached = walkBack(root, rel, id, { src, blob, doc: parsed.value }, takenBack, seen);
|
|
138
138
|
// A write the walk cannot go past leaves the return where it stood before that write.
|
|
139
139
|
const walked = reached.end === 'lost' ? { ...reached, ...reached.at, end: 'content' } : reached;
|
|
140
|
+
// D190 (review). Every amendment the walk took back on the way brings back what it folded
|
|
141
|
+
// in (an amendment with `supersedes`): row 168 left that out, and a shipped spec folded into
|
|
142
|
+
// a predecessor that a later re-slice departed never came back.
|
|
143
|
+
const amended = walked.passed.filter((w) => walked.end !== 'create' || w !== walked.act);
|
|
144
|
+
const folds = amended.map((w) => returnsThrough(root, paths, w.commit, [id], takenBack, seen));
|
|
145
|
+
const folded = { returns: folds.flatMap((f) => f.returns), unwound: folds.flatMap((f) => f.unwound) };
|
|
140
146
|
if (walked.end === 'create') {
|
|
141
147
|
// That write created it: it stays out — journaled so whether or not anything lies under
|
|
142
148
|
// the create — and what it had departed comes back instead.
|
|
143
149
|
const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
|
|
144
150
|
const under = returnsThrough(root, paths, walked.act.commit, [...chain, id], takenBack, seen);
|
|
145
|
-
return { returns: under.returns, unwound: [gone, ...under.unwound] };
|
|
151
|
+
return { returns: [...under.returns, ...folded.returns], unwound: [gone, ...under.unwound, ...folded.unwound] };
|
|
146
152
|
}
|
|
147
153
|
const status = walked.passed.length > 0 ? String(walked.doc.meta.status) : exit.from;
|
|
148
154
|
const design = designRel(paths, id);
|
|
@@ -165,8 +171,8 @@ function returnsThrough(root, paths, commit, chain, takenBack, seen) {
|
|
|
165
171
|
rel,
|
|
166
172
|
by: successor,
|
|
167
173
|
depends: (walked.doc.meta.depends ?? []).map(String),
|
|
168
|
-
}],
|
|
169
|
-
unwound:
|
|
174
|
+
}, ...folded.returns],
|
|
175
|
+
unwound: folded.unwound,
|
|
170
176
|
};
|
|
171
177
|
});
|
|
172
178
|
return { returns: ways.flatMap((w) => w.returns), unwound: ways.flatMap((w) => w.unwound) };
|
package/dist/drive.js
CHANGED
|
@@ -4,7 +4,8 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { EXIT, main } from './cli.js';
|
|
6
6
|
import { loadConfig, loadLocalConfig } from './config.js';
|
|
7
|
-
import {
|
|
7
|
+
import { withoutSession } from './context.js';
|
|
8
|
+
import { detectionVariables, resolveJudge } from './harness.js';
|
|
8
9
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
9
10
|
import { findById, loadCanon } from './scan.js';
|
|
10
11
|
import { resolveSpawn } from './spawn.js';
|
|
@@ -16,6 +17,7 @@ import { unmetNeeds } from './needs.js';
|
|
|
16
17
|
import { capHit, parkAtCap } from './caps.js';
|
|
17
18
|
import { effortStreams, readStream } from './journal.js';
|
|
18
19
|
import { effortOf } from './reviewed.js';
|
|
20
|
+
import { vacuousEvidence } from './steps.js';
|
|
19
21
|
import { pendingTxn } from './txn.js';
|
|
20
22
|
import { duration, limitIn, realClock, sessionLimit, SINGLE_WAIT_MAX_MS, TOTAL_WAIT_MAX_MS, waitFor, } from './usage.js';
|
|
21
23
|
import { keepAwake } from './awake.js';
|
|
@@ -145,12 +147,26 @@ export async function spawnSession(step, cfg, ctx, spawnN, opts = {}) {
|
|
|
145
147
|
// a scripted fake that performs real CLI acts. Never documented for users — a spawn
|
|
146
148
|
// binary chosen by ambient env is exactly the identity confusion row 90 removed.
|
|
147
149
|
const bin = ctx.env.WITNESS_DRIVE_AGENT_BIN ?? cmd;
|
|
148
|
-
// Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
|
|
149
150
|
// Row 180: the marker is the child's target and its recursion guard in one.
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
//
|
|
152
|
+
// Row 191 §4.9: a drive started from a chat session's shell inherits that session's reading
|
|
153
|
+
// and session variable, and the child is a session of its own that its harness names itself.
|
|
154
|
+
// The session check already makes a stale reading harmless; leaving both out is the second
|
|
155
|
+
// guard. The verb drops them on entry too; this holds for any other caller.
|
|
156
|
+
//
|
|
157
|
+
// Every harness's detection variable goes too, from the child alone (final review minor 5): its
|
|
158
|
+
// harness sets its own, and an inherited one named the chat's harness to a child of another — a
|
|
159
|
+
// claude child of a drive typed in a Pi chat detected Pi, read its own reading as stale and
|
|
160
|
+
// relayed with /new. Drive keeps them: the judge above, whose worker this is, may be detection's.
|
|
161
|
+
const inherited = withoutSession(ctx.env);
|
|
162
|
+
for (const variable of detectionVariables())
|
|
163
|
+
delete inherited[variable];
|
|
164
|
+
const childEnv = {
|
|
165
|
+
...inherited, ...env, WITNESS_BIN: localBin(ctx), [DRIVE_ENV]: step.target ?? TARGETLESS,
|
|
152
166
|
[DRIVE_DEADLINE_ENV]: String(t0 + cfg.drive.sessionTimeoutMs),
|
|
153
|
-
}
|
|
167
|
+
};
|
|
168
|
+
// Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
|
|
169
|
+
const run = resolveSpawn(bin, args, childEnv);
|
|
154
170
|
if (!run.ok) {
|
|
155
171
|
renderRefusal(run.violations).forEach((l) => ctx.err(l));
|
|
156
172
|
return end('spawn-failed');
|
|
@@ -259,8 +275,10 @@ const STREAM_PREFIX = /^\[\d+ [^\]]*\] /;
|
|
|
259
275
|
// What `mergeOwnPr` prints when it leaves the merge to a human (ship.ts), and nothing else.
|
|
260
276
|
const MERGE_LINE = /^ship: "?merge PR #(\d+) on GitHub when ready/;
|
|
261
277
|
const streamLines = (root, id) => readStream(root, id).length;
|
|
262
|
-
// A refused write is the CLI saying no: it moves nothing (F5).
|
|
263
|
-
|
|
278
|
+
// A refused write is the CLI saying no: it moves nothing (F5). Nor does a vacuous red (row 191,
|
|
279
|
+
// final review I3): a session that only re-runs one has done nothing, and counted, it read as
|
|
280
|
+
// progress, so the target was respawned until its spawn cap.
|
|
281
|
+
const moved = (e) => e.t !== 'write-refused' && !vacuousEvidence(e);
|
|
264
282
|
// Row 181. Did anything happen for THIS target? A whole-journal count is satisfied by any
|
|
265
283
|
// other lane's progress, so a stalled flow beside a busy one would spawn until the ceiling.
|
|
266
284
|
// A target's key is its line, its stream, and the entries in its owning effort's stream that
|