@popovych.co/witness 0.21.0 → 0.22.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 +5 -3
- package/dist/arbiter.js +2 -2
- package/dist/ask.js +8 -42
- package/dist/autonomy.js +5 -0
- package/dist/cli.js +2 -2
- package/dist/config.js +2 -2
- package/dist/context.js +7 -4
- package/dist/design.js +12 -0
- package/dist/drive-report.js +13 -10
- package/dist/drive.js +71 -34
- package/dist/gate.js +1 -1
- package/dist/gates/design.js +7 -14
- package/dist/journal.js +5 -2
- package/dist/settings.js +1 -1
- package/dist/settle.js +2 -7
- package/dist/ship.js +2 -5
- package/dist/slice.js +31 -18
- package/dist/steps.js +56 -33
- package/dist/verbs/dashboard.js +5 -11
- package/dist/verbs/decide.js +2 -5
- package/dist/verbs/drive.js +2 -12
- package/dist/verbs/evidence.js +16 -12
- package/dist/verbs/log.js +1 -12
- package/dist/verbs/next.js +157 -73
- package/dist/verbs/recap.js +8 -95
- package/dist/verbs/step-close.js +4 -5
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +4 -3
- package/plugin/hooks/context-core.mjs +20 -10
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +2 -12
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +2 -7
- package/plugin/skills/witness-implement/SKILL.md +3 -3
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
- package/dist/recap-check.js +0 -66
- package/prompts/arbiter-recap.md +0 -23
package/README.md
CHANGED
|
@@ -186,7 +186,7 @@ the list the CLI reads (`src/settings.ts`).
|
|
|
186
186
|
| `ship.merge` | how ship merges its own PR under autonomy · merge \| squash \| rebase — accepts: merge \| squash \| rebase | `merge` |
|
|
187
187
|
| `ship.ciSettleMs` | ms green CI must hold still before an autonomous merge · 0–1800000 — accepts: an integer from 0 to 1800000 — milliseconds a green check set must hold before witness merges | `60000` |
|
|
188
188
|
| `autonomy` | on = the CLI settles its own stops and merges green PRs · on \| off · counts once committed — accepts: on \| off | `off` |
|
|
189
|
-
| `arbiter.model` | model for the arbiter that settles stops
|
|
189
|
+
| `arbiter.model` | model for the arbiter that settles stops under autonomy · exact id, no aliases — accepts: [provider/]model[:thinking] | `claude-opus-5-5` |
|
|
190
190
|
| `session.contextLimit` | % of the context window at which a session hands over to a fresh one · 1–100 — accepts: an integer from 1 to 100 — % of the session's context window at which witness hands over to a fresh session | `20` |
|
|
191
191
|
| `implement.stepsPerDispatch` | steps an implement session takes when its context fill can't be read · ≥ 1 — accepts: an integer >= 1 — steps handed to each fresh implement agent | `3` |
|
|
192
192
|
| `gates.reviewerTimeoutMs` | ms one reviewer, or the arbiter, may run before it is stopped (10 min) · ≥ 1 — accepts: an integer >= 1 — milliseconds each reviewer invocation may take | `600000` |
|
|
@@ -233,7 +233,9 @@ need and a trust grant. `witness ship` merges with `ship.merge` only once the sh
|
|
|
233
233
|
approved and CI is green — never when the approval lapsed or GitHub refuses — and only after the
|
|
234
234
|
check set has held still for `ship.ciSettleMs`: gh's watch ends the moment nothing is pending, so
|
|
235
235
|
a check that registers late would otherwise be missed. Green means at least one check passed and
|
|
236
|
-
none failed, was cancelled or is pending.
|
|
236
|
+
none failed, was cancelled or is pending. Brainstorm and design stay conversations with a human in
|
|
237
|
+
every mode: `/witness` starts drive for the machine work and keeps the human's own items —
|
|
238
|
+
decisions left for them, asks to grill, looks to design — in the chat.
|
|
237
239
|
|
|
238
240
|
**`drive`.** `drive.parallelSuites: false` makes test runs in two worktrees take turns, because
|
|
239
241
|
suites that boot servers or browsers collide. A target that reaches any `drive.caps.*` bound is
|
|
@@ -285,7 +287,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
285
287
|
| `design <spec-id> --file <html>\|--open\|--reconfirm` | register/show a `ui` spec's approved look |
|
|
286
288
|
| `start <plan-id>` | create/re-attach the plan's worktree (`.witness/worktrees/<id>`) — code only: the canon dirs are excluded from it, so a plan cannot go stale in there |
|
|
287
289
|
| `next` | the one next action across every effort |
|
|
288
|
-
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach]
|
|
290
|
+
| `drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach]` | 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`; `--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 |
|
|
289
291
|
| `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 |
|
|
290
292
|
| `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:` |
|
|
291
293
|
| `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 |
|
package/dist/arbiter.js
CHANGED
|
@@ -13,8 +13,8 @@ import { effortOf, effortSpecs } from './reviewed.js';
|
|
|
13
13
|
import { anchorRecurrence, gateRuns, roundBudget } from './rounds.js';
|
|
14
14
|
import { findById } from './scan.js';
|
|
15
15
|
import { canonicalJson } from './sha.js';
|
|
16
|
-
// How a later mode
|
|
17
|
-
export const PROMPT_OF = { decision: 'arbiter'
|
|
16
|
+
// How a later mode adds its own prompt without a second invoker.
|
|
17
|
+
export const PROMPT_OF = { decision: 'arbiter' };
|
|
18
18
|
const TEXT_MAX = 600;
|
|
19
19
|
function renderOptions(menu) {
|
|
20
20
|
return menu.flatMap((o, i) => [
|
package/dist/ask.js
CHANGED
|
@@ -3,28 +3,13 @@ import { stateCommit } from './gitio.js';
|
|
|
3
3
|
import { appendEntry, effortStreams, entryLine, journalRel, readStream } from './journal.js';
|
|
4
4
|
import { acquireLock } from './lock.js';
|
|
5
5
|
import { ok, refuse, v } from './refusal.js';
|
|
6
|
-
import { ROUND_BOUND } from './rounds.js';
|
|
7
6
|
import { crashPoint, pendingTxn, withTxn } from './txn.js';
|
|
8
7
|
// Row 176. The human's ask is journaled state, on a stream witness owns the way it owns
|
|
9
8
|
// `floor`. This module is its one home: the entries, the queue derived from them, and the
|
|
10
|
-
// one write path the verb
|
|
9
|
+
// one write path the verb and `/witness <text>` share — so a crashed session
|
|
11
10
|
// loses nothing, because the brainstorm that answers an ask reads it from here, never from
|
|
12
11
|
// its prompt.
|
|
13
12
|
export const ASK_STREAM = 'asks';
|
|
14
|
-
// The house bound on review rounds: a recap sent back three times waits for a human.
|
|
15
|
-
export const RECAP_CHECK_BOUND = ROUND_BOUND;
|
|
16
|
-
export function recapChecks(root, id) {
|
|
17
|
-
return readStream(root, ASK_STREAM)
|
|
18
|
-
.filter((e) => e.t === 'human-decision' && e.gate === 'recap' && e.artifact === id);
|
|
19
|
-
}
|
|
20
|
-
// The one rendering of why a check sent a recap back, shared by `recap`'s output, `next`'s
|
|
21
|
-
// rung and the arbiter's own prior-checks section — so it lives here, and this module never
|
|
22
|
-
// imports recap-check.ts.
|
|
23
|
-
export function checkReason(e) {
|
|
24
|
-
return e.rule === 'recap-class'
|
|
25
|
-
? `class — the recap says ${e.class} and the check decided ${e.decided ?? '?'} · ${e.reason}`
|
|
26
|
-
: e.reason;
|
|
27
|
-
}
|
|
28
13
|
// Display only: the stored text is the human's, trimmed and otherwise as given.
|
|
29
14
|
export function askText(text) {
|
|
30
15
|
return oneLine(text);
|
|
@@ -37,33 +22,17 @@ export function oneLine(text) {
|
|
|
37
22
|
}
|
|
38
23
|
export function askQueue(root) {
|
|
39
24
|
const out = [];
|
|
25
|
+
// Row 194. A `human-decision` entry on this stream is an old recap check (row 177): history
|
|
26
|
+
// `log` still prints, and nothing a queue derives from.
|
|
40
27
|
for (const e of readStream(root, ASK_STREAM)) {
|
|
41
28
|
if (e.t === 'ask')
|
|
42
|
-
out.push({ id: String(e.id), text: String(e.text), state: 'unanswered'
|
|
29
|
+
out.push({ id: String(e.id), text: String(e.text), state: 'unanswered' });
|
|
43
30
|
else if (e.t === 'ask-withdrawn') {
|
|
44
31
|
const a = out.find((x) => x.id === e.ask);
|
|
45
32
|
if (a)
|
|
46
33
|
a.state = 'withdrawn';
|
|
47
34
|
}
|
|
48
35
|
}
|
|
49
|
-
for (const a of out) {
|
|
50
|
-
const checks = recapChecks(root, a.id);
|
|
51
|
-
const last = checks.at(-1);
|
|
52
|
-
a.checks = checks.length;
|
|
53
|
-
if (last?.decision === 'revise') {
|
|
54
|
-
a.reason = oneLine(checkReason({ rule: String(last.rule), class: last.class, decided: last.decided, reason: String(last.reason ?? '') }));
|
|
55
|
-
if (last.note !== undefined)
|
|
56
|
-
a.note = oneLine(last.note);
|
|
57
|
-
}
|
|
58
|
-
if (a.state !== 'unanswered' || last === undefined)
|
|
59
|
-
continue;
|
|
60
|
-
if (last.decision === 'stop')
|
|
61
|
-
a.parked = 'malformed';
|
|
62
|
-
else if (last.decision === 'revise' && checks.length >= RECAP_CHECK_BOUND)
|
|
63
|
-
a.parked = 'bound';
|
|
64
|
-
if (a.parked !== undefined)
|
|
65
|
-
a.state = 'parked';
|
|
66
|
-
}
|
|
67
36
|
// An ask is answered once any recap names it, even one an abandon later ended: re-asking is
|
|
68
37
|
// the human's act, so an abandoned effort never puts its ask back in the queue.
|
|
69
38
|
const answeredBy = new Map();
|
|
@@ -78,7 +47,6 @@ export function askQueue(root) {
|
|
|
78
47
|
if (effort !== undefined) {
|
|
79
48
|
a.state = 'answered';
|
|
80
49
|
a.effort = effort;
|
|
81
|
-
delete a.parked;
|
|
82
50
|
}
|
|
83
51
|
}
|
|
84
52
|
return out;
|
|
@@ -114,7 +82,7 @@ export function queueAsk(ctx, root, text) {
|
|
|
114
82
|
// crashed `/witness <words>` run again must not queue, and build, the work twice.
|
|
115
83
|
const queue = askQueue(root);
|
|
116
84
|
const unanswered = () => askQueue(root).filter((a) => a.state === 'unanswered').length;
|
|
117
|
-
const open = queue.find((a) =>
|
|
85
|
+
const open = queue.find((a) => a.state === 'unanswered' && askText(a.text) === askText(text));
|
|
118
86
|
if (open !== undefined)
|
|
119
87
|
return ok({ id: open.id, unanswered: unanswered(), already: true });
|
|
120
88
|
// numbered under the lock: two queuers racing must not both take the next id
|
|
@@ -165,11 +133,9 @@ export function withdrawAsk(ctx, root, id, note) {
|
|
|
165
133
|
lock.value();
|
|
166
134
|
}
|
|
167
135
|
}
|
|
168
|
-
// The one derivation of which ask is routed next: the oldest still open.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
export function nextAsk(root, autonomous) {
|
|
172
|
-
return askQueue(root).find((a) => a.state === 'unanswered' || (a.state === 'parked' && !autonomous));
|
|
136
|
+
// The one derivation of which ask is routed next: the oldest still open.
|
|
137
|
+
export function nextAsk(root) {
|
|
138
|
+
return askQueue(root).find((a) => a.state === 'unanswered');
|
|
173
139
|
}
|
|
174
140
|
// The ask an effort answers: what the decompose arbiter judges a slicing against beside the
|
|
175
141
|
// recap itself (row 177). Named by the recap that birthed the effort — an amend carries no
|
package/dist/autonomy.js
CHANGED
|
@@ -18,6 +18,11 @@ export function autonomyLines(cfg) {
|
|
|
18
18
|
export function keptStopLine(stop, detail) {
|
|
19
19
|
return cmd('kept-stop', `${KEPT_STOP_NAMES[stop]} — ${detail}`);
|
|
20
20
|
}
|
|
21
|
+
// Row 179's kept stop for one plan's start: what is missing, then how to satisfy it. Drive's
|
|
22
|
+
// set-aside and the chat's notice (row 195) print this one line.
|
|
23
|
+
export function unmetNeedLine(needs) {
|
|
24
|
+
return keptStopLine('unmet-need', [...needs.map((u) => u.text), ...needs.map((u) => u.satisfy)].join(' · '));
|
|
25
|
+
}
|
|
21
26
|
// The rules whose recommendation the CLI takes as it stands (design §7 A2's table). Each is
|
|
22
27
|
// a fix with a right answer — revise here, revise upstream, approve over nits, re-run a
|
|
23
28
|
// stale or malformed round — so no judgment is owed and none is spent.
|
package/dist/cli.js
CHANGED
|
@@ -67,7 +67,7 @@ const VERB_USAGE = {
|
|
|
67
67
|
floor: 'witness floor --show | --set <triple> --note <why>',
|
|
68
68
|
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
69
69
|
diff: 'witness diff <spec-id>',
|
|
70
|
-
drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach]
|
|
70
|
+
drive: 'witness drive [--flow <plan-id>] [--max-spawns <n>] [--manual] [--detach] — runs every session itself · under autonomy it needs no terminal and reports when nothing else can move',
|
|
71
71
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
|
72
72
|
gate: 'witness gate <decompose|plan|implement|ship|design> <target> [--fresh] [--manual]',
|
|
73
73
|
index: 'witness index',
|
|
@@ -76,7 +76,7 @@ const VERB_USAGE = {
|
|
|
76
76
|
log: 'witness log <stream> [--all] [--lineage]',
|
|
77
77
|
next: 'witness next',
|
|
78
78
|
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
|
|
79
|
-
recap: 'witness recap --file <recap.json> [--ask <id>] [--amend]
|
|
79
|
+
recap: 'witness recap --file <recap.json> [--ask <id>] [--amend]',
|
|
80
80
|
recover: 'witness recover [--complete | --rollback]',
|
|
81
81
|
rename: 'witness rename <old-id> <new-id>',
|
|
82
82
|
satisfy: 'witness satisfy <doc-id> --need <id | index | text>',
|
package/dist/config.js
CHANGED
|
@@ -17,8 +17,8 @@ export const DEFAULT_PATHS = {
|
|
|
17
17
|
export const DOC_KEYS = ['conventions', 'design'];
|
|
18
18
|
export const DEFAULT_STEPS_PER_DISPATCH = listDefault('implement.stepsPerDispatch');
|
|
19
19
|
// Row 191 §4.2. The default 20 is the row's premise (§1): once a session's context is more
|
|
20
|
-
// than about 20% full, its output gets worse. A repo decision, so its
|
|
21
|
-
// witness.config.yaml
|
|
20
|
+
// than about 20% full, its output gets worse. A repo decision, so its home is
|
|
21
|
+
// witness.config.yaml; row 191 deferred a machine-local override (config.local.yaml, row 90).
|
|
22
22
|
export const DEFAULT_CONTEXT_LIMIT = listDefault('session.contextLimit');
|
|
23
23
|
// D145. One hour. An implement slice is ~25 minutes at the default budget of 3 steps,
|
|
24
24
|
// and the cost of the two errors is not symmetric: a generous ceiling wastes an hour
|
package/dist/context.js
CHANGED
|
@@ -27,6 +27,8 @@ const isInt = (x) => typeof x === 'number' && Number.isInteger(x);
|
|
|
27
27
|
// harmless: a drive launched from a chat session's shell carries the chat's reading, and its
|
|
28
28
|
// child runs as a session of its own. The order is the reasons' precedence: a broken value is
|
|
29
29
|
// malformed whoever wrote it, and only this session's own value can say it has no reading yet.
|
|
30
|
+
// A value with no window is not a broken one (D192): Pi's adapter writes the session alone when
|
|
31
|
+
// the harness gives no usage at all.
|
|
30
32
|
export function readFill(env) {
|
|
31
33
|
const raw = env[CONTEXT_ENV];
|
|
32
34
|
if (raw === undefined || raw === '')
|
|
@@ -43,16 +45,17 @@ export function readFill(env) {
|
|
|
43
45
|
const { session, tokens, window } = parsed;
|
|
44
46
|
if (typeof session !== 'string' || session === '')
|
|
45
47
|
return unmeasured('malformed');
|
|
46
|
-
if (!isInt(window) || window < 1)
|
|
48
|
+
if (window !== undefined && (!isInt(window) || window < 1))
|
|
47
49
|
return unmeasured('malformed');
|
|
48
|
-
if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || tokens > window)) {
|
|
50
|
+
if (tokens !== undefined && tokens !== null && (!isInt(tokens) || tokens < 0 || (isInt(window) && tokens > window))) {
|
|
49
51
|
return unmeasured('malformed');
|
|
50
52
|
}
|
|
51
53
|
if (session !== sessionIdOf(env))
|
|
52
54
|
return unmeasured('stale');
|
|
53
55
|
// 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
|
-
|
|
56
|
+
// and Pi sends null right after a compaction. Each is a reading not taken yet, and so is Pi's
|
|
57
|
+
// session alone, with no window.
|
|
58
|
+
if (!isInt(window) || !isInt(tokens) || tokens === 0)
|
|
56
59
|
return unmeasured('no reading yet');
|
|
57
60
|
return { measured: true, reading: { session, tokens, window } };
|
|
58
61
|
}
|
package/dist/design.js
CHANGED
|
@@ -53,6 +53,18 @@ export function designPairSha(html, spec) {
|
|
|
53
53
|
.update(`spec:${canonicalSha(spec.meta, spec.body)}`)
|
|
54
54
|
.digest('hex');
|
|
55
55
|
}
|
|
56
|
+
// What the design gate judges: the artifact PAIRED with the spec it realizes. With no artifact
|
|
57
|
+
// the spec alone is the sha, so a re-authored spec still lapses a prior verdict. One derivation
|
|
58
|
+
// for the gate's `resolve` and `currentSha` and for `next`'s revise-owed read (row 194), so none
|
|
59
|
+
// can disagree about what moved.
|
|
60
|
+
export function designGateSha(html, spec) {
|
|
61
|
+
return html !== undefined ? designPairSha(html, spec) : canonicalSha(spec.meta, spec.body);
|
|
62
|
+
}
|
|
63
|
+
// The same sha for the artifact on disk now.
|
|
64
|
+
export function designCurrentSha(root, paths, spec) {
|
|
65
|
+
const abs = join(root, designRel(paths, String(spec.meta.id)));
|
|
66
|
+
return designGateSha(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
|
|
67
|
+
}
|
|
56
68
|
export function designRel(paths, specId) {
|
|
57
69
|
return `${paths.designs}/${specId}.html`;
|
|
58
70
|
}
|
package/dist/drive-report.js
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { TARGETLESS } from './drive-presence.js';
|
|
3
3
|
import { handoffLine, resolveDriver } from './harness.js';
|
|
4
|
-
import { readStream } from './journal.js';
|
|
4
|
+
import { readStream, streamIds } from './journal.js';
|
|
5
5
|
import { unmetNeeds } from './needs.js';
|
|
6
6
|
import { findById, loadCanon } from './scan.js';
|
|
7
7
|
import { pendingTxn } from './txn.js';
|
|
8
8
|
import { owedDecisions, parkedGates } from './verbs/next.js';
|
|
9
9
|
import { recoverChoice } from './verbs/recover.js';
|
|
10
|
-
const journalDir = (root) => join(root, '.witness', 'journal');
|
|
11
|
-
const streams = (root) => existsSync(journalDir(root))
|
|
12
|
-
? readdirSync(journalDir(root)).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length)).sort()
|
|
13
|
-
: [];
|
|
14
10
|
export function runStart(root) {
|
|
15
|
-
return { lines: new Map(
|
|
11
|
+
return { lines: new Map(streamIds(root).map((s) => [s, readStream(root, s).length])) };
|
|
16
12
|
}
|
|
17
13
|
export function entriesSince(root, start) {
|
|
18
|
-
return
|
|
14
|
+
return streamIds(root).flatMap((stream) => readStream(root, stream).slice(start.lines.get(stream) ?? 0).map((entry) => ({ stream, entry })));
|
|
19
15
|
}
|
|
20
16
|
const prOf = (line) => /PR #(\d+)/.exec(line)?.[1];
|
|
21
17
|
// Every `run:` and `then:` line is raw: a command is never kv-escaped (D120).
|
|
@@ -57,7 +53,14 @@ export function driveReport(root, ctx, cfg, start, aside, run) {
|
|
|
57
53
|
if (named.has(a.target))
|
|
58
54
|
continue;
|
|
59
55
|
if (a.kind === 'conversation') {
|
|
60
|
-
|
|
56
|
+
// Row 196. A target-less conversation is a grill: of a queued ask, or — with nothing
|
|
57
|
+
// queued — of what to build next, which waits on nobody, so it is said and not counted.
|
|
58
|
+
const ask = a.target === TARGETLESS ? / --ask (\S+)/.exec(a.line)?.[1] : undefined;
|
|
59
|
+
if (a.target === TARGETLESS && ask === undefined) {
|
|
60
|
+
waits.push('nothing queued — /witness <what to build> in a chat');
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const who = a.target === TARGETLESS ? `brainstorm · ask ${ask}` : a.target;
|
|
61
64
|
wait(a.target, [`wait: conversation · ${who}`,
|
|
62
65
|
...(driver.ok ? [` run: ${handoffLine(driver.value.harness, root, undefined)}`] : [])]);
|
|
63
66
|
}
|
package/dist/drive.js
CHANGED
|
@@ -9,10 +9,10 @@ import { detectionVariables, resolveJudge } from './harness.js';
|
|
|
9
9
|
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
10
10
|
import { findById, loadCanon } from './scan.js';
|
|
11
11
|
import { resolveSpawn } from './spawn.js';
|
|
12
|
-
import { computeNext } from './verbs/next.js';
|
|
12
|
+
import { computeNext, conversationStage } from './verbs/next.js';
|
|
13
13
|
import { DRIVE_DEADLINE_ENV, DRIVE_ENV, DRIVE_LOG, DRIVE_LOG_ENV, TARGETLESS } from './drive-presence.js';
|
|
14
14
|
import { ASK_STREAM } from './ask.js';
|
|
15
|
-
import { KEPT_STOP_NAMES,
|
|
15
|
+
import { KEPT_STOP_NAMES, unmetNeedLine } from './autonomy.js';
|
|
16
16
|
import { unmetNeeds } from './needs.js';
|
|
17
17
|
import { capHit, parkAtCap } from './caps.js';
|
|
18
18
|
import { effortStreams, readStream } from './journal.js';
|
|
@@ -23,27 +23,17 @@ import { duration, limitIn, realClock, sessionLimit, SINGLE_WAIT_MAX_MS, TOTAL_W
|
|
|
23
23
|
import { keepAwake } from './awake.js';
|
|
24
24
|
import { driveReport, runStart } from './drive-report.js';
|
|
25
25
|
import { notify } from './notify.js';
|
|
26
|
-
// A conversation is a stage whose product IS the exchange with a human: brainstorm has no
|
|
27
|
-
// artifact to author headlessly, and design authoring/showing needs eyes on a rendered
|
|
28
|
-
// page. Drive prints the handoff and gets out of the way rather than spawning a session
|
|
29
|
-
// that would talk to nobody.
|
|
30
|
-
function isConversation(action) {
|
|
31
|
-
if (action.stage === 'brainstorm')
|
|
32
|
-
return true;
|
|
33
|
-
if (action.line.startsWith('witness recap'))
|
|
34
|
-
return true;
|
|
35
|
-
return action.line.startsWith('witness design ')
|
|
36
|
-
&& (action.line.includes('--open') || action.line.includes('--file'));
|
|
37
|
-
}
|
|
38
26
|
// The rule table is an ORDERED FIRST-MATCH list, on `recommend.ts`'s precedent: a
|
|
39
27
|
// misrouted action is attributable to one line here rather than to a weighting. Pure over
|
|
40
28
|
// the NextAction shape — no I/O — so every branch is one `expect` away.
|
|
41
29
|
//
|
|
42
|
-
// Row
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
30
|
+
// Row 194. A conversation (`conversationStage`) is one in both modes: brainstorm, `witness
|
|
31
|
+
// recap` and design authoring and showing are exchanges with a human, so drive prints the
|
|
32
|
+
// handoff and gets out of the way rather than spawning a session that would talk to nobody.
|
|
33
|
+
// Row 180: under autonomy A1 answers the choose block and the recovery fork with the policy's
|
|
34
|
+
// pick. A block that still reaches drive under a runnable line spawns, and the session's own
|
|
35
|
+
// `next` takes it, so no block stops an unattended run. `witness decide` lines stay decisions
|
|
36
|
+
// in both modes.
|
|
47
37
|
export function classifyAction(action, root, opts = {}) {
|
|
48
38
|
const { line } = action;
|
|
49
39
|
// Both decision surfaces render `witness decide <gate> <target> …`: tier 2's pending
|
|
@@ -60,7 +50,7 @@ export function classifyAction(action, root, opts = {}) {
|
|
|
60
50
|
if (action.block !== undefined && !opts.autonomy) {
|
|
61
51
|
return { kind: 'decision', target: action.target, block: action.block };
|
|
62
52
|
}
|
|
63
|
-
if (
|
|
53
|
+
if (conversationStage(action))
|
|
64
54
|
return { kind: 'conversation', line };
|
|
65
55
|
// The merge is GitHub's act, not witness's — a flow at `watch` has nothing left for an
|
|
66
56
|
// agent to do. In practice this line arrives in the spawned ship session's OUTPUT
|
|
@@ -284,7 +274,7 @@ const moved = (e) => e.t !== 'write-refused' && !vacuousEvidence(e);
|
|
|
284
274
|
// A target's key is its line, its stream, and the entries in its owning effort's stream that
|
|
285
275
|
// are about its own flow (F5: one effort carries every sibling's writes). Target-less work
|
|
286
276
|
// (brainstorm, the recovery fork) keys on the effort count and the asks stream, because a
|
|
287
|
-
//
|
|
277
|
+
// queued ask grows the asks and a recap births an effort. A crashed
|
|
288
278
|
// transaction is part of the target-less key (F4): the recovery fork is judged like any other
|
|
289
279
|
// work, so a session that leaves the same crash behind is set aside instead of being
|
|
290
280
|
// respawned to the ceiling.
|
|
@@ -380,6 +370,46 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
380
370
|
return ![...lanes.keys()].some((l) => mine.includes(l) || family(l).includes(t));
|
|
381
371
|
};
|
|
382
372
|
const setAside = (key, a) => { aside.set(key, { target: key, ...a }); };
|
|
373
|
+
// Row 196. A target's own next action, read with every other target filtered out: how the
|
|
374
|
+
// settle pass, which holds no action, and `takeBack` derive the key a human may move.
|
|
375
|
+
const ownNext = (key) => computeNext(root, ctx, canonNow, cfg, { scope: key === TARGETLESS ? (t) => t === undefined : (t) => t === key });
|
|
376
|
+
const keyOf = (action) => progressKey(root, action, family);
|
|
377
|
+
// Whether `now` is this target's own answer: a pending transaction (a crash, or a lane caught
|
|
378
|
+
// mid-write) and canon errors answer every scope with one global rung, which says nothing
|
|
379
|
+
// about the target, so reading it as a move would release every aside at once.
|
|
380
|
+
const ownAnswer = (key, now) => pendingTxn(root) === undefined && (key === TARGETLESS || now.target === key);
|
|
381
|
+
// Row 196. First in every round: each target set aside for a human whose key moved since
|
|
382
|
+
// (an ask answered or queued, a need satisfied, a stop decided in a chat) leaves the aside
|
|
383
|
+
// set and competes for a slot again. One whose next act is the human's again is set aside
|
|
384
|
+
// anew, under its new key, by the settle pass or the fill that meets it.
|
|
385
|
+
const takeBack = () => {
|
|
386
|
+
for (const [key, a] of aside) {
|
|
387
|
+
if (a.key0 === undefined)
|
|
388
|
+
continue;
|
|
389
|
+
const now = ownNext(key);
|
|
390
|
+
if (!ownAnswer(key, now) || keyOf(now) === a.key0)
|
|
391
|
+
continue;
|
|
392
|
+
aside.delete(key);
|
|
393
|
+
ctx.out(`drive: taken back — ${key === TARGETLESS ? 'untargeted work' : key} · ${now.line}`);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
// Row 196 (spec §4.3). Once more before the report: a target still the human's is reported as
|
|
397
|
+
// it is now, and one a human moved on to drive's work leaves the set — the report's
|
|
398
|
+
// journal-derived rows (decisions, needs) name whatever else still waits.
|
|
399
|
+
const refreshAside = () => {
|
|
400
|
+
canonNow = loadCanon(root);
|
|
401
|
+
for (const [key, a] of aside) {
|
|
402
|
+
if (a.key0 === undefined)
|
|
403
|
+
continue;
|
|
404
|
+
const now = ownNext(key);
|
|
405
|
+
if (!ownAnswer(key, now) || keyOf(now) === a.key0)
|
|
406
|
+
continue;
|
|
407
|
+
if (conversationStage(now))
|
|
408
|
+
aside.set(key, { ...a, kind: 'conversation', line: now.line, key0: keyOf(now) });
|
|
409
|
+
else if (!now.line.startsWith('witness decide '))
|
|
410
|
+
aside.delete(key);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
383
413
|
const stuck = (key, action) => {
|
|
384
414
|
ctx.out(`drive: no progress — ${action.line}`);
|
|
385
415
|
ctx.out(`help: ${action.target ?? 'untargeted work'} is set aside for this run — read its stream above`);
|
|
@@ -399,8 +429,8 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
399
429
|
const standAside = async (step, action, key) => {
|
|
400
430
|
if (step.kind === 'conversation') {
|
|
401
431
|
ctx.out(`drive: conversation — ${step.line}`);
|
|
402
|
-
ctx.out('help:
|
|
403
|
-
setAside(key, { kind: 'conversation', line: step.line, why: 'an exchange with a human' });
|
|
432
|
+
ctx.out('help: run /witness in a chat — drive takes it back when you are done');
|
|
433
|
+
setAside(key, { kind: 'conversation', line: step.line, why: 'an exchange with a human', key0: keyOf(action) });
|
|
404
434
|
return;
|
|
405
435
|
}
|
|
406
436
|
if (step.kind === 'merge') {
|
|
@@ -413,14 +443,14 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
413
443
|
// a `--manual` run's stop, which waits for the human and is never settled.
|
|
414
444
|
if (autonomous) {
|
|
415
445
|
ctx.out(`drive: waits on you — ${action.target ?? 'untargeted work'} · decision · ${action.line}`);
|
|
416
|
-
setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}) });
|
|
446
|
+
setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}), key0: keyOf(action) });
|
|
417
447
|
return;
|
|
418
448
|
}
|
|
419
449
|
if (unmoved(key, action))
|
|
420
450
|
return;
|
|
421
451
|
const r = await resolveDecision(step, action, ctx);
|
|
422
452
|
if (r === 'stands')
|
|
423
|
-
setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}) });
|
|
453
|
+
setAside(key, { kind: 'decision', line: action.line, why: action.line, ...(step.gate ? { gate: step.gate } : {}), key0: keyOf(action) });
|
|
424
454
|
if (r === 'refused')
|
|
425
455
|
setAside(key, { kind: 'stuck', line: action.line, why: `decision refused on ${action.line}` });
|
|
426
456
|
};
|
|
@@ -508,7 +538,7 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
508
538
|
// the pass leaves for a human, drive sets aside. The pass never waits inside a reviewer
|
|
509
539
|
// (F6: that wait was silent, uncounted and deaf to a signal): an arbiter refused by a usage
|
|
510
540
|
// limit becomes drive's own pause, and the stop is judged again after it.
|
|
511
|
-
const routed = new
|
|
541
|
+
const routed = new Map(); // target → the re-run its settle routed
|
|
512
542
|
const settlePass = async () => {
|
|
513
543
|
const { settleOwed, settledLine } = await import('./settle.js');
|
|
514
544
|
for (const r of await settleOwed(ctx, root, cfg, { scope: admits })) {
|
|
@@ -522,14 +552,14 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
522
552
|
ctx.out(line);
|
|
523
553
|
if (s.kind === 'kept') {
|
|
524
554
|
ctx.out(`drive: waits on you — ${r.target} · ${KEPT_STOP_NAMES[s.stop]}`);
|
|
525
|
-
setAside(r.target, { kind: 'kept', line: s.line, why: KEPT_STOP_NAMES[s.stop], gate: r.gate, stop: s.stop });
|
|
555
|
+
setAside(r.target, { kind: 'kept', line: s.line, why: KEPT_STOP_NAMES[s.stop], gate: r.gate, stop: s.stop, key0: keyOf(ownNext(r.target)) });
|
|
526
556
|
}
|
|
527
557
|
else if (s.kind === 'left') {
|
|
528
558
|
ctx.out(`drive: waits on you — ${r.target} · decision · ${s.why}`);
|
|
529
|
-
setAside(r.target, { kind: 'decision', line: `witness decide ${r.gate} ${r.target} --show`, why: s.why, gate: r.gate });
|
|
559
|
+
setAside(r.target, { kind: 'decision', line: `witness decide ${r.gate} ${r.target} --show`, why: s.why, gate: r.gate, key0: keyOf(ownNext(r.target)) });
|
|
530
560
|
}
|
|
531
561
|
else if (s.kind === 'routed') {
|
|
532
|
-
routed.
|
|
562
|
+
routed.set(r.target, s.line.replace(/^autonomy: re-run owed — /, ''));
|
|
533
563
|
}
|
|
534
564
|
}
|
|
535
565
|
};
|
|
@@ -542,9 +572,9 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
542
572
|
if (needs.length === 0)
|
|
543
573
|
return false;
|
|
544
574
|
ctx.out(`drive: waits on you — ${action.target} · unmet need`);
|
|
545
|
-
const line =
|
|
575
|
+
const line = unmetNeedLine(needs);
|
|
546
576
|
ctx.out(line);
|
|
547
|
-
setAside(key, { kind: 'kept', line, why: KEPT_STOP_NAMES['unmet-need'], stop: 'unmet-need' });
|
|
577
|
+
setAside(key, { kind: 'kept', line, why: KEPT_STOP_NAMES['unmet-need'], stop: 'unmet-need', key0: keyOf(action) });
|
|
548
578
|
return true;
|
|
549
579
|
};
|
|
550
580
|
// Row 183. Checked before every spawn for a target, the first included. Target-less work
|
|
@@ -594,6 +624,10 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
594
624
|
for (;;) {
|
|
595
625
|
stillAutonomous();
|
|
596
626
|
canonNow = loadCanon(root);
|
|
627
|
+
// A run that is stopping fills nothing more, so a target released now would be set aside
|
|
628
|
+
// by nobody and drop out of the report; it stays where it is, and the report derives it.
|
|
629
|
+
if (!stop && !signalled)
|
|
630
|
+
takeBack();
|
|
597
631
|
if (autonomous && !stop && !signalled && clock.now() >= pausedUntil)
|
|
598
632
|
await settlePass();
|
|
599
633
|
while (!stop && !signalled && lanes.size < parallel && clock.now() >= pausedUntil) {
|
|
@@ -609,9 +643,11 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
609
643
|
}
|
|
610
644
|
idle = undefined;
|
|
611
645
|
// A stop whose settle the CLI routed to a re-run: the target's own session runs it,
|
|
612
|
-
// because its scoped `next` prints the re-run (A1)
|
|
613
|
-
|
|
614
|
-
|
|
646
|
+
// because its scoped `next` prints the re-run (A1) — unless the re-run is a step of the
|
|
647
|
+
// design session, which is the chat's (row 194).
|
|
648
|
+
const rerun = action.target !== undefined ? routed.get(action.target) : undefined;
|
|
649
|
+
if (step.kind === 'decision' && autonomous && rerun !== undefined) {
|
|
650
|
+
step = conversationStage({ line: rerun }) ? { kind: 'conversation', line: rerun } : { kind: 'spawn', home: root, target: action.target };
|
|
615
651
|
}
|
|
616
652
|
if (step.kind !== 'spawn') {
|
|
617
653
|
await standAside(step, action, key);
|
|
@@ -651,6 +687,7 @@ export async function driveLoop(root, cfg, ctx, flags, clock = realClock) {
|
|
|
651
687
|
// Row 184. One report when nothing else can move, and for an autonomous run one
|
|
652
688
|
// notification — a human at a TTY prompt is already watching.
|
|
653
689
|
const stopped = signalled !== undefined ? `signal ${signalled}` : stop;
|
|
690
|
+
refreshAside();
|
|
654
691
|
const report = driveReport(root, ctx, cfg, start, [...aside.values()], { autonomous, ...(stopped ? { stopped } : {}) });
|
|
655
692
|
report.lines.forEach((l) => ctx.out(l));
|
|
656
693
|
if (autonomous) {
|
package/dist/gate.js
CHANGED
|
@@ -192,7 +192,7 @@ async function judge(ctx, gateName, target, flags) {
|
|
|
192
192
|
return owed !== undefined && !owed.manual;
|
|
193
193
|
};
|
|
194
194
|
const canon = loadCanon(root);
|
|
195
|
-
const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target
|
|
195
|
+
const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target);
|
|
196
196
|
if (!inputR.ok) {
|
|
197
197
|
renderRefusal(inputR.violations).forEach((l) => ctx.err(l));
|
|
198
198
|
return EXIT.REFUSED;
|
package/dist/gates/design.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { serializeDoc } from '../fm.js';
|
|
4
|
-
import {
|
|
4
|
+
import { designCurrentSha, designGateSha, designRel, designUnseen, elementIds, htmlSha, validateDesignArtifact } from '../design.js';
|
|
5
5
|
import { registerGate } from '../gate.js';
|
|
6
6
|
import { latestRecap } from '../journal.js';
|
|
7
7
|
import { ok, refuse, v } from '../refusal.js';
|
|
@@ -10,14 +10,10 @@ import { findById } from '../scan.js';
|
|
|
10
10
|
import { canonicalSha, short } from '../sha.js';
|
|
11
11
|
import { contextDigest } from '../delta.js';
|
|
12
12
|
import { snapshotFiles } from '../snapshot.js';
|
|
13
|
-
// What this gate judges: the artifact PAIRED with the spec it realizes. With no artifact
|
|
14
|
-
// the spec alone is the sha, so a re-authored spec still lapses a prior verdict. Shared
|
|
15
|
-
// by `resolve` and `currentSha` so the two can never disagree about what moved.
|
|
16
|
-
const reviewedShaOf = (html, spec) => html !== undefined ? designPairSha(html, spec) : canonicalSha(spec.meta, spec.body);
|
|
17
13
|
registerGate({
|
|
18
14
|
gate: 'design',
|
|
19
15
|
targetKind: 'spec',
|
|
20
|
-
async resolve(root, _ctx, canon, cfg, specId
|
|
16
|
+
async resolve(root, _ctx, canon, cfg, specId) {
|
|
21
17
|
const spec = findById(canon, specId);
|
|
22
18
|
if (!spec || spec.meta.type !== 'spec') {
|
|
23
19
|
return refuse([v('spec', 'unknown-spec', specId, 'a specs/ doc id')]);
|
|
@@ -34,11 +30,9 @@ registerGate({
|
|
|
34
30
|
// learns about. An agent that drops the show step now hits a loud refusal its repair
|
|
35
31
|
// loop already handles, instead of silently gating something nobody was shown.
|
|
36
32
|
//
|
|
37
|
-
// Row
|
|
38
|
-
//
|
|
39
|
-
|
|
40
|
-
// so the human's stop is where it is opened and nothing stamps a look unseen.
|
|
41
|
-
const unseen = run?.autonomous ? undefined : designUnseen(root, cfg.paths, specId);
|
|
33
|
+
// Row 194: in every mode. A look comes out of a conversation with a human, so the human
|
|
34
|
+
// has seen it before any critic judges it (row 178's unseen gate is gone).
|
|
35
|
+
const unseen = designUnseen(root, cfg.paths, specId);
|
|
42
36
|
if (unseen !== undefined) {
|
|
43
37
|
return refuse([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${specId} --open`)]);
|
|
44
38
|
}
|
|
@@ -59,7 +53,7 @@ registerGate({
|
|
|
59
53
|
const tree = html !== undefined ? snapshotFiles(root, [{ name: 'design.html', content: html }]) : undefined;
|
|
60
54
|
return ok({
|
|
61
55
|
class: (recap?.class ?? 'feature'),
|
|
62
|
-
reviewedSha:
|
|
56
|
+
reviewedSha: designGateSha(html, spec),
|
|
63
57
|
...(tree ? { snapshot: { tree, context: contextDigest(canonicalSha(spec.meta, spec.body)) } } : {}),
|
|
64
58
|
artifactSha: html !== undefined ? htmlSha(html) : undefined,
|
|
65
59
|
reviewed: { kind: 'design', artifact: { ids }, spec: { id: specId, body: spec.body } },
|
|
@@ -73,8 +67,7 @@ registerGate({
|
|
|
73
67
|
const spec = findById(canon, specId);
|
|
74
68
|
if (!spec || spec.meta.type !== 'spec')
|
|
75
69
|
return undefined;
|
|
76
|
-
|
|
77
|
-
return reviewedShaOf(existsSync(abs) ? readFileSync(abs, 'utf8') : undefined, spec);
|
|
70
|
+
return designCurrentSha(root, cfg.paths, spec);
|
|
78
71
|
},
|
|
79
72
|
// Upstream from the design gate is the spec's SLICING, which reopens the owning
|
|
80
73
|
// effort's decompose (Decision 52) — so the id is the effort, not the spec.
|
package/dist/journal.js
CHANGED
|
@@ -73,15 +73,18 @@ export function latestRecap(root, effort) {
|
|
|
73
73
|
const recaps = readStream(root, effort).filter((e) => e.t === 'recap');
|
|
74
74
|
return recaps.at(-1);
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Every stream the journal holds, by id, sorted.
|
|
77
|
+
export function streamIds(root) {
|
|
77
78
|
if (!existsSync(dir(root)))
|
|
78
79
|
return [];
|
|
79
80
|
return readdirSync(dir(root))
|
|
80
81
|
.filter((f) => f.endsWith('.jsonl'))
|
|
81
82
|
.map((f) => f.slice(0, -'.jsonl'.length))
|
|
82
|
-
.filter((id) => readStream(root, id)[0]?.t === 'recap')
|
|
83
83
|
.sort();
|
|
84
84
|
}
|
|
85
|
+
export function effortStreams(root) {
|
|
86
|
+
return streamIds(root).filter((id) => readStream(root, id)[0]?.t === 'recap');
|
|
87
|
+
}
|
|
85
88
|
export function effortAbandoned(entries) {
|
|
86
89
|
return entries.some((e) => e.t === 'human-decision' && e.decision === 'abandon-effort');
|
|
87
90
|
}
|
package/dist/settings.js
CHANGED
|
@@ -111,7 +111,7 @@ export const SETTINGS = [
|
|
|
111
111
|
},
|
|
112
112
|
{
|
|
113
113
|
path: 'arbiter.model', shape: PIN, since: 2, defaults: { 2: DEFAULT_MODEL }, ambient: true, group: 'behavior',
|
|
114
|
-
comment: 'model for the arbiter that settles stops
|
|
114
|
+
comment: 'model for the arbiter that settles stops under autonomy · exact id, no aliases', want: PIN_WANT,
|
|
115
115
|
},
|
|
116
116
|
{
|
|
117
117
|
path: 'session.contextLimit', shape: { kind: 'int', min: 1, max: 100 }, since: 1, defaults: { 1: 20 }, hookRead: true, group: 'behavior',
|
package/dist/settle.js
CHANGED
|
@@ -2,7 +2,6 @@ import { trustedCommands, untrustedCmdsFor } from './allowlist.js';
|
|
|
2
2
|
import { arbitrate, decisionEvidence, menuFor } from './arbiter.js';
|
|
3
3
|
import { keptStopLine, policyFor } from './autonomy.js';
|
|
4
4
|
import { EXIT } from './cli.js';
|
|
5
|
-
import { designUnseen } from './design.js';
|
|
6
5
|
import { liftedHolds } from './needs.js';
|
|
7
6
|
import { gateSpec } from './gate.js';
|
|
8
7
|
import { readStream } from './journal.js';
|
|
@@ -76,13 +75,9 @@ export async function settleStop(ctx, root, cfg, gate, target) {
|
|
|
76
75
|
};
|
|
77
76
|
};
|
|
78
77
|
if (policy.kind === 'human') {
|
|
79
|
-
// Row
|
|
80
|
-
// to open it — `decide design --approve` refuses until they have.
|
|
81
|
-
const unseen = policy.stop === 'design-look' && designUnseen(root, cfg.paths, target) !== undefined;
|
|
78
|
+
// Row 194: the gate ran only on a look the human opened, so approving is their one act.
|
|
82
79
|
const detail = policy.stop === 'design-look'
|
|
83
|
-
?
|
|
84
|
-
? `the human opens the look with witness design ${target} --open · then approves it or sends a note — witness decide design ${target} --show`
|
|
85
|
-
: `the human approves the screen or sends a note — witness decide design ${target} --show`
|
|
80
|
+
? `the human approves the screen or sends a note — witness decide design ${target} --show`
|
|
86
81
|
: `${untrusted.join(' · ')} — the human runs witness gate ${gate} ${target} in a terminal and answers each prompt`;
|
|
87
82
|
const line = keptStopLine(policy.stop, detail);
|
|
88
83
|
renderDecision(block).forEach((l) => ctx.out(l));
|