@popovych.co/witness 0.15.0 → 0.16.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 CHANGED
@@ -149,6 +149,9 @@ either.
149
149
  | `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
150
  | `gates.reviewerTimeoutMs` | milliseconds per reviewer invocation (default 600000) |
151
151
  | `drive.sessionTimeoutMs` | milliseconds a `drive`-spawned session may run before SIGTERM (default 3600000) |
152
+ | `autonomy: on \| off` | whether the CLI settles its own stops — through its recommendation or the arbiter it launches — and merges its own PRs once CI is green (default off). A committed repo fact: it counts only once HEAD says it, and the merge also needs the ship branch on origin to say it. `--manual` on a gate run keeps that run's stop for a human, and three stops always reach one: the look of a new screen, an unmet plan need and a trust grant |
153
+ | `ship.merge: merge \| squash \| rebase` | how `witness ship` merges its own PR under `autonomy: on` (default merge) — only once the ship gate is approved and CI is green, and never when the approval lapsed or GitHub refuses |
154
+ | `ship.ciSettleMs` | milliseconds a green check set must hold still before `witness ship` merges under autonomy (default 60000, at most 1800000) — gh's watch ends the moment nothing is pending, so a check that registers late would otherwise be missed. Green means at least one check passed and none failed, was cancelled or is pending |
152
155
 
153
156
  There is no `provider:` key. `witness gate` spawns the DECLARED harness's headless
154
157
  mode for every reviewer (Decisions 88, 105): claude-code renders bare Anthropic ids, pi
@@ -187,7 +190,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
187
190
  | `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 |
188
191
  | `next` | the one next action across every effort |
189
192
  | `drive [--flow <plan-id>] [--max-spawns <n>]` | schedule green-path work through headless sessions; you appear only at judgment stops. TTY-only — agents are what drive spawns, never what spawns drive |
190
- | `ship <plan-id>` | lanes → ship gate → PR → CI watch |
193
+ | `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 |
191
194
  | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
192
195
  | `adopt <path>` | absolve a finished hand-edit into the journal |
193
196
  | `abandon <plan-id \| effort-slug>` | wind a plan/effort down; reverts only specs that effort itself wrote |
@@ -0,0 +1,189 @@
1
+ import { decidedBy } from './autonomy.js';
2
+ import { loadLocalConfig } from './config.js';
3
+ import { openDeferrals } from './deferral.js';
4
+ import { serializeDoc } from './fm.js';
5
+ import { resolveJudge } from './harness.js';
6
+ import { latestRecap, policyPins } from './journal.js';
7
+ import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
8
+ import { PLACEHOLDER } from './recommend.js';
9
+ import { ok, refuse, v } from './refusal.js';
10
+ import { invokeReviewerAsync, parseVerdictText, promptsSha, resolvePrompt } from './reviewer.js';
11
+ import { effortOf, effortSpecs } from './reviewed.js';
12
+ import { anchorRecurrence, gateRuns, roundBudget } from './rounds.js';
13
+ import { findById } from './scan.js';
14
+ import { canonicalJson } from './sha.js';
15
+ // How a later mode (plan A2's recap check) adds its own prompt without a second invoker.
16
+ export const PROMPT_OF = { decision: 'arbiter' };
17
+ const TEXT_MAX = 600;
18
+ function renderOptions(menu) {
19
+ return menu.flatMap((o, i) => [
20
+ `${i + 1}. ${o.command}`,
21
+ ...(o.why ? [` why: ${o.why}`] : []),
22
+ ...(o.judgeFirst ? [` judge-first: ${o.judgeFirst}`] : []),
23
+ ...(o.when ? [` when: ${o.when}`] : []),
24
+ ...(o.tradeoff ? [` tradeoff: ${o.tradeoff}`] : []),
25
+ ...(o.note ? [` note: ${o.note}`] : []),
26
+ ]).join('\n');
27
+ }
28
+ function readRuling(text, n) {
29
+ const rawR = parseVerdictText(text);
30
+ if (!rawR.ok)
31
+ return rawR;
32
+ const raw = rawR.value;
33
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
34
+ return refuse([v('ruling', 'not-an-object', JSON.stringify(raw).slice(0, 120), '{"choice": <n>, "reason": "…"}')]);
35
+ }
36
+ const r = raw;
37
+ const rows = [];
38
+ if (typeof r.choice !== 'number' || !Number.isInteger(r.choice) || r.choice < 1 || r.choice > n) {
39
+ rows.push(v('choice', 'not-an-option', String(r.choice), `an option number from 1 to ${n}`));
40
+ }
41
+ if (typeof r.reason !== 'string' || r.reason.trim() === '' || r.reason.length > TEXT_MAX) {
42
+ rows.push(v('reason', 'invalid', typeof r.reason === 'string' ? `${r.reason.length} chars` : String(r.reason), `a non-empty reason of at most ${TEXT_MAX} characters`));
43
+ }
44
+ // An empty note is an absent one: the contract marks it optional, and a reroll spent on
45
+ // `"note": ""` would park a flow over a field that carried nothing.
46
+ const note = typeof r.note === 'string' && r.note.trim() === '' ? undefined : r.note;
47
+ if (note !== undefined && note !== null && (typeof note !== 'string' || note.length > TEXT_MAX)) {
48
+ rows.push(v('note', 'invalid', typeof note === 'string' ? `${note.length} chars` : String(note), `an optional note of at most ${TEXT_MAX} characters`));
49
+ }
50
+ if (rows.length > 0)
51
+ return refuse(rows);
52
+ return ok({ choice: r.choice, reason: r.reason, ...(typeof note === 'string' ? { note } : {}) });
53
+ }
54
+ export async function arbitrate(ctx, root, cfg, req) {
55
+ const lensR = resolvePrompt(PROMPT_OF[req.mode]);
56
+ if (!lensR.ok)
57
+ return lensR;
58
+ const lens = lensR.value;
59
+ const hxR = resolveJudge(ctx.env, cfg.raw);
60
+ if (!hxR.ok)
61
+ return refuse(hxR.violations);
62
+ const harness = hxR.value.harness;
63
+ const localR = loadLocalConfig(root);
64
+ if (!localR.ok)
65
+ return localR;
66
+ const modelR = resolveModel(cfg, loadMatrix(root, harness.name));
67
+ if (!modelR.ok)
68
+ return refuse(modelR.violations);
69
+ const chain = modelR.value.chain;
70
+ let prompt = `${lens.contents}\n${req.evidence.map((s) => `\n## ${s.title}\n\n${s.body}\n`).join('')}\n## Options\n\n${renderOptions(req.menu)}\n`;
71
+ let rung = 0;
72
+ for (let attempt = 0;; attempt++) {
73
+ let text;
74
+ for (;;) {
75
+ const id = chain[rung];
76
+ const r = await invokeReviewerAsync(ctx, harness, {
77
+ cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id, lens: 'arbiter',
78
+ timeoutMs: cfg.gates.reviewerTimeoutMs, extensions: localR.value.reviewerExtensions,
79
+ });
80
+ if (r.ok) {
81
+ text = r.value.text;
82
+ break;
83
+ }
84
+ if (rung >= chain.length - 1)
85
+ return refuse(r.violations);
86
+ rung += 1;
87
+ }
88
+ const ruling = readRuling(text, req.menu.length);
89
+ if (ruling.ok) {
90
+ return ok({ ...ruling.value, model: chain[rung], prompts_sha: promptsSha([{ name: 'arbiter', contents: lens.contents }]) });
91
+ }
92
+ if (attempt === 0) {
93
+ // One self-repair reroll on the same rung, in the shape runBattery gives a lens.
94
+ prompt += `\n## Previous attempt rejected\n\n${ruling.violations.map((x) => `- ${x.field}: ${x.rule} — got ${x.got}; want ${x.want}`).join('\n')}\n\nRe-emit the complete corrected JSON object.\n`;
95
+ continue;
96
+ }
97
+ return refuse([v('arbiter', 'arbiter-malformed', ruling.violations[0]?.got ?? '', 'a JSON object with choice and reason naming one listed option'), ...ruling.violations]);
98
+ }
99
+ }
100
+ // The block minus every act the arbiter may never take: anything but a decision (which
101
+ // removes `witness abandon` — the CLI never discards work, D168), any trust grant (D154),
102
+ // any command whose id the CLI could not resolve (the arbiter fills a note, never an id),
103
+ // and every approve over a failed deterministic check — it may overrule a reviewer, never a
104
+ // check. `amendment-ack` alone is the exception: approving IS its acknowledgement.
105
+ // The design gate is the exception that holds whatever the evidence says: the look is the
106
+ // human's (§3 G2), so no approve of it is ever offered — at the bound, on a fallback round or
107
+ // past a spent ladder the arbiter may revise, reset upstream, repair or park, never stamp.
108
+ export function menuFor(block, run) {
109
+ const failed = run.checks.filter((c) => !c.ok);
110
+ const approveBarred = run.gate === 'design' || (failed.length > 0 && !failed.every((c) => c.name === 'amendment-ack'));
111
+ return block.options.filter((o) => o.command.startsWith('witness decide ') &&
112
+ !/ --trust-cmds\b/.test(o.command) &&
113
+ !PLACEHOLDER.test(o.command.replace(' --note "<why>"', '')) &&
114
+ !(approveBarred && / --approve\b/.test(o.command)));
115
+ }
116
+ const anchorText = (a) => (typeof a === 'string' ? a : `omission:${a.scope}`);
117
+ const HISTORY_RUNS = 10;
118
+ // The evidence the arbiter reads, composed by the CLI from the journal and canon. It never
119
+ // carries a session transcript: none exists to carry, and the author's narration is
120
+ // exactly what the arbiter must not hear (anti-goal 1).
121
+ export function decisionEvidence(root, canon, stop) {
122
+ const { gate, target, entries, run, block } = stop;
123
+ const findings = (run.verdicts ?? []).flatMap((rv) => rv.findings.map((f) => ({ reviewer: rv.reviewer, f })));
124
+ const sections = [
125
+ {
126
+ title: 'The stop',
127
+ body: [
128
+ `gate: ${gate} · target: ${target}`,
129
+ `round: ${run.round} of ${roundBudget(entries, gate)}`,
130
+ `rule: ${block.rule}`,
131
+ `standing: ${run.standing ?? 'none'}`,
132
+ `model: reviewers answered on ${run.model} · pinned ${run.pin ?? run.model}`,
133
+ ].join('\n'),
134
+ },
135
+ {
136
+ title: 'Checks',
137
+ body: run.checks.length === 0 ? 'none'
138
+ : run.checks.map((c) => `- ${c.name}: ${c.ok ? 'ok' : 'FAILED'}${c.detail ? ` — ${c.detail}` : ''}`).join('\n'),
139
+ },
140
+ {
141
+ title: 'Findings',
142
+ body: findings.length === 0 ? 'none'
143
+ : findings.map(({ reviewer, f }) => `- [${reviewer} · ${f.blocking ? 'blocking' : 'non-blocking'}] ${anchorText(f.anchor)} — ${f.claim}${f.contradicts_pin !== undefined ? ` · contradicts pin #${f.contradicts_pin}` : ''}`).join('\n'),
144
+ },
145
+ ];
146
+ const history = [];
147
+ const runs = gateRuns(entries, gate).slice(-HISTORY_RUNS);
148
+ for (const r of runs) {
149
+ const blocks = [...new Set((r.verdicts ?? []).flatMap((rv) => rv.findings.filter((f) => f.blocking).map((f) => anchorText(f.anchor))))];
150
+ history.push(`- round ${r.round} · @${r.reviewed_sha.slice(0, 7)} · ${r.outcome}${blocks.length ? ` · blocking: ${blocks.join(' · ')}` : ''}`);
151
+ const at = entries.indexOf(r);
152
+ for (const e of entries.slice(at + 1)) {
153
+ if (e.t === 'gate-run' && e.gate === gate)
154
+ break;
155
+ if (e.t !== 'human-decision' || e.gate !== gate)
156
+ continue;
157
+ const d = e;
158
+ history.push(` - ${d.decision} · ${decidedBy(d)}${d.rule ? ` · ${d.rule}` : ''}${d.note ? ` · ${d.note}` : ''}`);
159
+ }
160
+ }
161
+ const current = [...new Set(findings.filter(({ f }) => f.blocking).map(({ f }) => anchorText(f.anchor)))];
162
+ for (const a of current)
163
+ history.push(`- recurrence: ${a} · found in ${anchorRecurrence(entries, gate, a)} round(s) of this budget`);
164
+ for (const d of openDeferrals(entries))
165
+ history.push(`- open obligation ${d.id} · ${d.anchor} · ${d.kind}`);
166
+ for (const p of policyPins(entries))
167
+ history.push(`- pin #${p.ordinal}: ${p.text}`);
168
+ sections.push({ title: 'Round history', body: history.length === 0 ? 'none' : history.join('\n') });
169
+ if (gate === 'ship' || gate === 'decompose') {
170
+ const effort = gate === 'decompose' ? target : effortOf(root, target);
171
+ const recap = effort !== undefined ? latestRecap(root, effort) : undefined;
172
+ const scope = [`### The effort's recap\n\n${recap ? canonicalJson(recap) : 'none recorded'}`];
173
+ if (gate === 'ship') {
174
+ const plan = findById(canon, target);
175
+ const parent = plan ? findById(canon, String(plan.meta.parent)) : undefined;
176
+ if (parent)
177
+ scope.push(`### The spec: ${String(parent.meta.id)}\n\n${serializeDoc({ meta: parent.meta, body: parent.body })}`);
178
+ if (plan)
179
+ scope.push(`### The plan: ${target}\n\n${serializeDoc({ meta: plan.meta, body: plan.body })}`);
180
+ }
181
+ else {
182
+ for (const s of effortSpecs(root, canon, target)) {
183
+ scope.push(`### The spec: ${String(s.meta.id)}\n\n${serializeDoc({ meta: s.meta, body: s.body })}`);
184
+ }
185
+ }
186
+ sections.push({ title: 'Scope', body: scope.join('\n\n') });
187
+ }
188
+ return sections;
189
+ }
@@ -0,0 +1,84 @@
1
+ import { boundReached, fellBack } from './rounds.js';
2
+ import { cmd, kv } from './toon.js';
3
+ export const KEPT_STOP_NAMES = {
4
+ 'design-look': 'design look',
5
+ 'unmet-need': 'unmet need',
6
+ 'trust-grant': 'trust grant',
7
+ };
8
+ // Printed first on `next`, `start`, `gate` and `decide --show` whenever the repo has opted
9
+ // in, so a skill learns the mode from CLI output and never from memory (A1, A8).
10
+ export function autonomyLines(cfg) {
11
+ if (!cfg.autonomy)
12
+ return [];
13
+ return [kv('autonomy', 'on'),
14
+ kv('kept-stops', Object.keys(KEPT_STOP_NAMES).map((k) => KEPT_STOP_NAMES[k]).join(' · '))];
15
+ }
16
+ // `cmd`, not `kv`: a detail can carry a quoted command, and `kv` would quote the whole
17
+ // line into something that no longer pastes (D120).
18
+ export function keptStopLine(stop, detail) {
19
+ return cmd('kept-stop', `${KEPT_STOP_NAMES[stop]} — ${detail}`);
20
+ }
21
+ // The rules whose recommendation the CLI takes as it stands (design §7 A2's table). Each is
22
+ // a fix with a right answer — revise here, revise upstream, approve over nits, re-run a
23
+ // stale or malformed round — so no judgment is owed and none is spent.
24
+ const TAKEN = new Set(['blocking-here', 'blocking-parent', 'bound-recurrence', 'non-blocking-only',
25
+ 'stale-below-bound', 'malformed-rerun']);
26
+ // An ordered first-match list, like recommend.ts's rule table, and dispatched by GATE before
27
+ // RULE: the recommender's rules overlap, so a design stop with only non-blocking findings
28
+ // matches `non-blocking-only` before `reserved-stop-clean` (E8), and a rule-keyed policy
29
+ // would approve the one look the user kept for themselves.
30
+ export function policyFor(facts) {
31
+ if (facts.at === 'start')
32
+ return { kind: 'human', stop: 'unmet-need' };
33
+ if (facts.at !== 'gate')
34
+ return { kind: 'take', command: facts.block.options[0].command };
35
+ const { gate, target, entries, run, block, untrusted } = facts;
36
+ const failed = run.checks.filter((c) => !c.ok);
37
+ const blocks = (run.verdicts ?? []).some((rv) => rv.findings.some((f) => f.blocking));
38
+ const clean = failed.length === 0 && !blocks;
39
+ // Option 1 only when it pastes: a placeholder (`<effort>`) means no upstream resolved,
40
+ // and filling an id is exactly what the arbiter may never do either — but it can pick
41
+ // another option, so the stop goes there rather than to a command that would refuse.
42
+ const first = () => {
43
+ const o = block.options[0];
44
+ return o !== undefined && o.runnable ? { kind: 'take', command: o.command } : { kind: 'arbiter' };
45
+ };
46
+ if (untrusted.length > 0)
47
+ return { kind: 'human', stop: 'trust-grant' };
48
+ // A verdict that describes bytes no longer there, or a round that judged nothing, is a free
49
+ // re-run before it is anything else: no human or arbiter can honestly act on it, and the
50
+ // standing and fallback arms below would hand it to one with the re-run filtered away.
51
+ if (block.rule === 'stale-below-bound' || block.rule === 'malformed-rerun')
52
+ return first();
53
+ if (gate === 'design' && clean)
54
+ return { kind: 'human', stop: 'design-look' };
55
+ if ((gate === 'ship' || gate === 'decompose') && clean && run.standing !== undefined)
56
+ return { kind: 'arbiter' };
57
+ if (fellBack(run))
58
+ return { kind: 'arbiter' };
59
+ // A trust failure granted since the stop: the check never ran, so the fix is a re-run,
60
+ // not a revise — sending the author back would have them "fix" a command nobody judged.
61
+ if (failed.some((c) => (c.untrusted?.length ?? 0) > 0) && !boundReached(entries, gate)) {
62
+ return { kind: 'take', command: `witness gate ${gate} ${target}` };
63
+ }
64
+ if (block.rule === 'checks-failed') {
65
+ // amendment-ack is the one check an approve clears, so approving is a judgment about the
66
+ // amendment against the ask — the arbiter's, never the rule's (§11 call 10).
67
+ return failed.every((c) => c.name === 'amendment-ack') ? { kind: 'arbiter' } : first();
68
+ }
69
+ if (TAKEN.has(block.rule) || block.rule.startsWith('anchor-recurrence-'))
70
+ return first();
71
+ // bound · bound-stale · ladder-spent · pin-contradiction · reserved-stop-clean · manual-stop
72
+ return { kind: 'arbiter' };
73
+ }
74
+ // Row 173. Who made a disposition, in one spelling for every surface that says so
75
+ // (`decide --show`, `log`, `status`).
76
+ export function decidedBy(d) {
77
+ if (d.selected === 'policy')
78
+ return 'policy';
79
+ if (d.selected === 'arbiter')
80
+ return `arbiter · ${d.arbiter?.model ?? 'unknown'}`;
81
+ if (d.selected === 'affirmation')
82
+ return 'human · affirmation';
83
+ return 'human';
84
+ }
package/dist/cli.js CHANGED
@@ -71,7 +71,7 @@ const VERB_USAGE = {
71
71
  rename: 'witness rename <old-id> <new-id>',
72
72
  satisfy: 'witness satisfy <doc-id> --need <text | index>',
73
73
  dismiss: 'witness dismiss <artifact> --deferral <id|index> --cause <superseded|lens-retired|judged-wrong> --note "<why>"',
74
- ship: 'witness ship <plan-id>',
74
+ ship: 'witness ship <plan-id> [--manual]',
75
75
  start: 'witness start <plan-id>',
76
76
  status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
77
77
  sync: 'witness sync',
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync, readFileSync } from 'node:fs';
2
3
  import { isAbsolute, join } from 'node:path';
3
4
  import { parse as parseYaml } from 'yaml';
@@ -137,6 +138,56 @@ export function resolveGates(raw) {
137
138
  }
138
139
  return ok({ reviewerTimeoutMs: val });
139
140
  }
141
+ // Rows 171-175. Whether the CLI settles its own stops is a REPO fact — committed, read by
142
+ // every teammate's CLI alike — so it lives here and never in config.local.yaml. Spelled as
143
+ // the strings `on | off` and nothing else: YAML 1.1 reads `yes`/`on` as booleans and 1.2
144
+ // does not, so accepting booleans would make the same file mean two things across parsers.
145
+ export function resolveAutonomy(raw) {
146
+ const val = raw.autonomy;
147
+ if (val === undefined)
148
+ return ok(false);
149
+ if (val === 'on')
150
+ return ok(true);
151
+ if (val === 'off')
152
+ return ok(false);
153
+ return refuse([v('autonomy', 'invalid', String(val), 'on | off')]);
154
+ }
155
+ const MERGE_STYLES = ['merge', 'squash', 'rebase'];
156
+ export const DEFAULT_CI_SETTLE_MS = 60_000;
157
+ export const MAX_CI_SETTLE_MS = 1_800_000;
158
+ // The rest of `ship.*` (test, lint, branch) is still read loosely at its consumers; only
159
+ // the key with a closed value set is resolved here, and only when `ship` is a map.
160
+ export function resolveShip(raw) {
161
+ const conf = raw.ship;
162
+ if (typeof conf !== 'object' || conf === null || Array.isArray(conf))
163
+ return ok({ merge: 'merge', ciSettleMs: DEFAULT_CI_SETTLE_MS });
164
+ const val = conf.merge;
165
+ if (val !== undefined && (typeof val !== 'string' || !MERGE_STYLES.includes(val))) {
166
+ return refuse([v('ship.merge', 'invalid', String(val), 'merge | squash | rebase')]);
167
+ }
168
+ const settle = conf.ciSettleMs;
169
+ // Bounded by the watch window (30 minutes): a longer settle can never be met, and past
170
+ // 2^31-1 ms a Node timer fires at once — the window would silently become zero.
171
+ if (settle !== undefined && (typeof settle !== 'number' || !Number.isInteger(settle) || settle < 0 || settle > MAX_CI_SETTLE_MS)) {
172
+ return refuse([v('ship.ciSettleMs', 'invalid', String(settle), `an integer from 0 to ${MAX_CI_SETTLE_MS} — milliseconds a green check set must hold before witness merges`)]);
173
+ }
174
+ return ok({ merge: (val ?? 'merge'), ciSettleMs: settle ?? DEFAULT_CI_SETTLE_MS });
175
+ }
176
+ // Row 171 (F11). Autonomy is a COMMITTED repo fact: on only when HEAD's witness.config.yaml
177
+ // says so as well. A working-copy edit — the coding agent's, say — grants nothing, because
178
+ // the setting it would grant is merge authority over that agent's own work. `autonomyAt` reads
179
+ // any revision: the merge asks the shipped branch's remote copy (row 174), a fact the agent
180
+ // cannot make true with a local commit.
181
+ export function autonomyAt(root, rev) {
182
+ try {
183
+ const text = execFileSync('git', ['show', `${rev}:witness.config.yaml`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
184
+ return (parseYaml(text) ?? {}).autonomy === 'on';
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ const committedAutonomy = (root) => autonomyAt(root, 'HEAD');
140
191
  const LOCAL_KEYS = ['reviewerExtensions', 'opener'];
141
192
  export const localConfigPath = (root) => join(root, '.witness', 'config.local.yaml');
142
193
  export function loadLocalConfig(root) {
@@ -235,6 +286,13 @@ export function loadConfig(root) {
235
286
  const drive = resolveDrive(obj);
236
287
  if (!drive.ok)
237
288
  return refuse(drive.violations);
289
+ const autonomy = resolveAutonomy(obj);
290
+ if (!autonomy.ok)
291
+ return refuse(autonomy.violations);
292
+ const shipCfg = resolveShip(obj);
293
+ if (!shipCfg.ok)
294
+ return refuse(shipCfg.violations);
295
+ const uncommitted = autonomy.value && !committedAutonomy(root);
238
296
  return ok({
239
297
  schema,
240
298
  raw: obj,
@@ -243,6 +301,10 @@ export function loadConfig(root) {
243
301
  implement: implement.value,
244
302
  gates: gates.value,
245
303
  drive: drive.value,
246
- warning: schema < SCHEMA_VERSION ? `schema ${schema} < ${SCHEMA_VERSION} — run witness migrate (reserved)` : undefined,
304
+ autonomy: autonomy.value && !uncommitted,
305
+ ship: shipCfg.value,
306
+ warning: schema < SCHEMA_VERSION ? `schema ${schema} < ${SCHEMA_VERSION} — run witness migrate (reserved)`
307
+ : uncommitted ? 'autonomy: on is not committed — it stays off until witness.config.yaml is committed'
308
+ : undefined,
247
309
  });
248
310
  }
package/dist/criteria.js CHANGED
@@ -2,7 +2,7 @@ import { ensureTrusted } from './allowlist.js';
2
2
  import { loadConfig } from './config.js';
3
3
  import { matchesTag, sourceTags } from './matcher.js';
4
4
  import { ok } from './refusal.js';
5
- import { criteriaExcludes, execCommand, runFiltered, runFullSuite, runnerConfig } from './runner.js';
5
+ import { criteriaExcludes, execCommand, runFiltered, runFullSuite, runnerConfig, untrustedOf } from './runner.js';
6
6
  import { canonicalSha } from './sha.js';
7
7
  export async function runCriteria(runRoot, ctx, doc, opts = {}) {
8
8
  const trustRoot = opts.trustRoot ?? runRoot;
@@ -21,12 +21,15 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
21
21
  const list = (Array.isArray(doc.meta.criteria) ? doc.meta.criteria : []);
22
22
  let suite = opts.suite;
23
23
  let suiteProblem;
24
+ let suiteUntrusted;
24
25
  if (rc.mode === 'full-suite' && suite === undefined && list.some((c) => typeof c.test === 'string')) {
25
26
  const res = await runFullSuite(runRoot, ctx, rc, trustRoot);
26
27
  if (res.ok)
27
28
  suite = res.value.tests;
28
- else
29
+ else {
29
30
  suiteProblem = res.violations.map((x) => `${x.rule}: ${x.field}`).join('; ');
31
+ suiteUntrusted = untrustedOf(res.violations);
32
+ }
30
33
  }
31
34
  const src = rc.mode === 'filtered'
32
35
  ? sourceTags(runRoot, criteriaExcludes(cfg.value))
@@ -37,7 +40,8 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
37
40
  if (typeof c.test === 'string') {
38
41
  if (rc.mode === 'full-suite') {
39
42
  if (!suite) {
40
- criteria.push({ id: cid, kind: 'test', ok: false, detail: suiteProblem ?? 'suite did not run' });
43
+ criteria.push({ id: cid, kind: 'test', ok: false, detail: suiteProblem ?? 'suite did not run',
44
+ ...(suiteUntrusted !== undefined ? { untrusted: suiteUntrusted } : {}) });
41
45
  continue;
42
46
  }
43
47
  const mine = suite.filter((t) => matchesTag(t.name, id));
@@ -52,7 +56,9 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
52
56
  const run = await runFiltered(runRoot, ctx, rc.template, id, trustRoot);
53
57
  const count = src?.counts.get(id) ?? 0;
54
58
  if (!run.ok) {
55
- criteria.push({ id: cid, kind: 'test', ok: false, detail: run.violations.map((x) => x.rule).join('; ') });
59
+ const blocked = untrustedOf(run.violations);
60
+ criteria.push({ id: cid, kind: 'test', ok: false, detail: run.violations.map((x) => x.rule).join('; '),
61
+ ...(blocked !== undefined ? { untrusted: blocked } : {}) });
56
62
  }
57
63
  else if (count === 0) {
58
64
  criteria.push({ id: cid, kind: 'test', ok: false, detail: 'no tagged test found in source (grep side)' });
@@ -74,6 +80,7 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
74
80
  criteria.push({
75
81
  id: cid, kind: 'cmd', ok: false,
76
82
  detail: `untrusted-${trust} — run: witness trust ${id} (or set WITNESS_TRUST_CMDS=1)`,
83
+ untrusted: c.cmd,
77
84
  });
78
85
  continue;
79
86
  }
@@ -93,3 +100,19 @@ export async function runCriteria(runRoot, ctx, doc, opts = {}) {
93
100
  criteria,
94
101
  });
95
102
  }
103
+ // The drift-lane row both code gates journal — one renderer, so implement and ship cannot
104
+ // disagree about it. `untrusted` (row 171) collects every command trust kept from running,
105
+ // deduplicated in criterion order, so the trust stop reads it from the run's own record.
106
+ export function laneCheck(lane) {
107
+ const untrusted = lane.ok
108
+ ? [...new Set(lane.value.criteria.flatMap((c) => (c.untrusted !== undefined ? [c.untrusted] : [])))]
109
+ : [];
110
+ return {
111
+ name: 'drift-lane',
112
+ ok: lane.ok && lane.value.ok,
113
+ detail: lane.ok
114
+ ? lane.value.criteria.map((c) => `${c.id}:${c.ok ? 'ok' : 'fail'}`).join(' · ')
115
+ : lane.violations.map((x) => `${x.field}:${x.rule}`).join(' · '),
116
+ ...(untrusted.length > 0 ? { untrusted } : {}),
117
+ };
118
+ }
package/dist/evidence.js CHANGED
@@ -6,7 +6,7 @@ import { entryLine, appendEntry, journalRel, readStream } from './journal.js';
6
6
  import { mergeReports, reportFiles } from './junit.js';
7
7
  import { extractCanonicalTags, matchesTag } from './matcher.js';
8
8
  import { ok, refuse, v } from './refusal.js';
9
- import { runFiltered, runFullSuite, runnerConfig } from './runner.js';
9
+ import { runFiltered, runFullSuite, runnerConfig, untrustedOf } from './runner.js';
10
10
  import { git, stateCommit, tryGit } from './gitio.js';
11
11
  import { kv } from './toon.js';
12
12
  import { withTxn } from './txn.js';
@@ -269,7 +269,9 @@ export async function runRegression(runRoot, ctx, trustRoot, specIds, known) {
269
269
  memo.set(id, run);
270
270
  }
271
271
  if (!run.ok) {
272
- out.push({ spec: id, state: 'unrunnable', detail: run.violations.map((x) => x.rule).join(' · ') });
272
+ const blocked = untrustedOf(run.violations);
273
+ out.push({ spec: id, state: 'unrunnable', detail: run.violations.map((x) => x.rule).join(' · '),
274
+ ...(blocked !== undefined ? { untrusted: blocked } : {}) });
273
275
  continue;
274
276
  }
275
277
  const failed = run.value.tests.filter((t) => !t.ok).length;
package/dist/gate.js CHANGED
@@ -2,7 +2,7 @@ import { join } from 'node:path';
2
2
  import { EXIT, version } from './cli.js';
3
3
  import { loadConfig, loadLocalConfig } from './config.js';
4
4
  import { writeDoc } from './fm.js';
5
- import { crashPoint, guardTxn, withTxn } from './txn.js';
5
+ import { crashPoint, guardTxn, pendingTxn, withTxn } from './txn.js';
6
6
  import { acquireLock } from './lock.js';
7
7
  import { appendEntry, entryLine, journalRel, policyPins, readStream } from './journal.js';
8
8
  import { untrustedCmdsFor } from './allowlist.js';
@@ -19,9 +19,10 @@ import { runBattery } from './battery.js';
19
19
  import { anchorMenu } from './verdict.js';
20
20
  import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
21
21
  export { liveExits } from './rounds.js';
22
- import { gateSettled } from './verbs/next.js';
22
+ import { gateSettled, owedRun } from './verbs/next.js';
23
23
  import { prepareStamp, writeStamp } from './stamp.js';
24
24
  import { recommend, renderDecision } from './recommend.js';
25
+ import { autonomyLines } from './autonomy.js';
25
26
  const GATES = new Map();
26
27
  export function registerGate(spec) { GATES.set(spec.gate, spec); }
27
28
  export function gateSpec(name) { return GATES.get(name); }
@@ -132,7 +133,27 @@ export function printDispatchArithmetic(ctx, root, gateName, target) {
132
133
  const steps = (doc.meta.steps ?? []).length;
133
134
  ctx.out(kv('dispatches', `${steps} step(s) ≈ ${Math.ceil(steps / budget)} dispatch(es) at budget ${budget}`));
134
135
  }
136
+ // Row 173. The gate, then — under `autonomy: on` and not a `--manual` run — the settling of
137
+ // the stop it produced or re-showed, in the same invocation, so a chat session and drive
138
+ // meet one rule. Below that line nothing changes: with autonomy off this is `judge` alone.
135
139
  export async function runGate(ctx, gateName, target, flags) {
140
+ const code = await judge(ctx, gateName, target, flags);
141
+ if (code !== EXIT.FINDINGS && code !== EXIT.BLOCKED)
142
+ return code;
143
+ if (flags.manual)
144
+ return code;
145
+ const rootR = primaryRoot(ctx.cwd);
146
+ if (!rootR.ok)
147
+ return code;
148
+ const cfgR = loadConfig(rootR.value);
149
+ if (!cfgR.ok || !cfgR.value.autonomy || pendingTxn(rootR.value) !== undefined)
150
+ return code;
151
+ // A dynamic import: settle.ts reaches decide.ts, whose gates/index.js import would close a
152
+ // static cycle through this module — each gate's registerGate would run before GATES exists.
153
+ const { settleStop, settledCode } = await import('./settle.js');
154
+ return settledCode(code, await settleStop(ctx, rootR.value, cfgR.value, gateName, target));
155
+ }
156
+ async function judge(ctx, gateName, target, flags) {
136
157
  const rootR = primaryRoot(ctx.cwd);
137
158
  if (!rootR.ok) {
138
159
  renderRefusal(rootR.violations).forEach((l) => ctx.err(l));
@@ -152,6 +173,20 @@ export async function runGate(ctx, gateName, target, flags) {
152
173
  renderRefusal(cfgR.violations).forEach((l) => ctx.err(l));
153
174
  return EXIT.REFUSED;
154
175
  }
176
+ // `--manual` keeps this run's stop for a human, so for this run autonomy is off and the
177
+ // CLI does not claim otherwise.
178
+ if (!flags.manual)
179
+ autonomyLines(cfgR.value).forEach((l) => ctx.out(l));
180
+ // Row 173. Under autonomy the tail settles the stop, so the block is ITS to print — the
181
+ // human's when the stop is kept, nobody's when the CLI decides. Left in place for a stop
182
+ // the tail will not touch: a `--manual` run's, a parked or settled one, a malformed round.
183
+ const autonomous = cfgR.value.autonomy && !flags.manual;
184
+ const leftToTail = (es) => {
185
+ if (!autonomous)
186
+ return false;
187
+ const owed = owedRun(es, gateName);
188
+ return owed !== undefined && !owed.manual;
189
+ };
155
190
  const canon = loadCanon(root);
156
191
  const inputR = await spec.resolve(root, ctx, canon, cfgR.value, target);
157
192
  if (!inputR.ok) {
@@ -262,7 +297,10 @@ export async function runGate(ctx, gateName, target, flags) {
262
297
  // silently re-rendering; the verdicts are REPLAYED (`cached`), so no battery is spent.
263
298
  // Compared on `ok` alone: a detail carries run output on some gates (timings, paths), and
264
299
  // keying on it would append a round for every re-run of an unchanged tree.
265
- const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}`).sort().join('|');
300
+ // A check trust kept from running and one that ran and failed are both `ok: false`, but they
301
+ // are different judgments: granting the command flips the first into the second, and without
302
+ // this term the gate resumed the blocked round forever while the policy re-ran it (F3).
303
+ const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}${(c.untrusted?.length ?? 0) > 0 ? ':blocked' : ''}`).sort().join('|');
266
304
  if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
267
305
  checkOk(kind.entry.checks) !== checkOk(input.checks) &&
268
306
  kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
@@ -276,7 +314,10 @@ export async function runGate(ctx, gateName, target, flags) {
276
314
  ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
277
315
  }
278
316
  if (kind.kind === 'resume') {
279
- renderGateRun(ctx, kind.entry, 'resume', { entries, upstream: upstreamId });
317
+ renderGateRun(ctx, kind.entry, 'resume', {
318
+ entries, upstream: upstreamId,
319
+ ...(kind.entry.outcome === 'stopped' && leftToTail(entries) ? { help: false } : {}),
320
+ });
280
321
  printDispatchArithmetic(ctx, root, spec.gate, target);
281
322
  return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
282
323
  }
@@ -291,7 +332,8 @@ export async function runGate(ctx, gateName, target, flags) {
291
332
  if (boundReached(entries, spec.gate)) {
292
333
  ctx.out(kv('gate', spec.gate));
293
334
  ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entries, spec.gate)} rounds since last approve)`));
294
- renderChoices(ctx, spec.gate, target, entries, upstreamId);
335
+ if (!leftToTail(entries))
336
+ renderChoices(ctx, spec.gate, target, entries, upstreamId);
295
337
  return EXIT.BLOCKED;
296
338
  }
297
339
  if (!flags.fresh) {
@@ -400,7 +442,8 @@ export async function runGate(ctx, gateName, target, flags) {
400
442
  if (boundReached(entriesNow, spec.gate)) {
401
443
  ctx.out(kv('gate', spec.gate));
402
444
  ctx.out(kv('outcome', `round bound reached (${roundsSinceApprove(entriesNow, spec.gate)} rounds since last approve)`));
403
- renderChoices(ctx, spec.gate, target, entriesNow, upstreamId);
445
+ if (!leftToTail(entriesNow))
446
+ renderChoices(ctx, spec.gate, target, entriesNow, upstreamId);
404
447
  return EXIT.BLOCKED;
405
448
  }
406
449
  const entry = {
@@ -470,7 +513,11 @@ export async function runGate(ctx, gateName, target, flags) {
470
513
  renderRefusal(txn.violations).forEach((l) => ctx.err(l));
471
514
  return EXIT.REFUSED;
472
515
  }
473
- renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry], upstream: upstreamId });
516
+ const after = [...entriesNow, entry];
517
+ renderGateRun(ctx, entry, 'ran', {
518
+ entries: after, upstream: upstreamId,
519
+ ...(entry.outcome === 'stopped' && leftToTail(after) ? { help: false } : {}),
520
+ });
474
521
  printDispatchArithmetic(ctx, root, spec.gate, target);
475
522
  for (const d of discharged)
476
523
  ctx.out(kv('discharged', `${d.id} — no longer reported by this round`));
@@ -9,7 +9,7 @@ import { git, stateDirs } from '../gitio.js';
9
9
  import { latestRecap, readStream } from '../journal.js';
10
10
  import { findById } from '../scan.js';
11
11
  import { changedFiles, changedTestSpecs, diffBase, evidenceForDiff, runRegression, screensDir } from '../evidence.js';
12
- import { runCriteria } from '../criteria.js';
12
+ import { laneCheck, runCriteria } from '../criteria.js';
13
13
  import { diffReviewedSha, effortOf, implementReviewedSha } from '../reviewed.js';
14
14
  import { worktreePath } from '../worktree.js';
15
15
  import { registerGate } from '../gate.js';
@@ -123,6 +123,7 @@ registerGate({
123
123
  // criteria, arbitrary trust-gated commands, inside an unattended gate.
124
124
  const foreign = changedTestSpecs(wt, base, String(plan.meta.parent));
125
125
  const regression = await runRegression(wt, ctx, root, foreign, (id) => findById(canon, id) !== undefined);
126
+ const regressionUntrusted = [...new Set(regression.flatMap((r) => (r.untrusted !== undefined ? [r.untrusted] : [])))];
126
127
  checks.push({
127
128
  name: 'regression',
128
129
  // `unknown` does not fail: witness cannot judge an obligation for a spec it does not
@@ -132,15 +133,9 @@ registerGate({
132
133
  detail: regression.length === 0
133
134
  ? 'no foreign spec tests touched'
134
135
  : regression.map((r) => `${r.spec}:${r.state}`).join(' · '),
136
+ ...(regressionUntrusted.length > 0 ? { untrusted: regressionUntrusted } : {}),
135
137
  });
136
- const lane = await runCriteria(wt, ctx, parent, { trustRoot: root });
137
- checks.push({
138
- name: 'drift-lane',
139
- ok: lane.ok && lane.value.ok,
140
- detail: lane.ok
141
- ? lane.value.criteria.map((c) => `${c.id}:${c.ok ? 'ok' : 'fail'}`).join(' · ')
142
- : lane.violations.map((x) => `${x.field}:${x.rule}`).join(' · '),
143
- });
138
+ checks.push(laneCheck(await runCriteria(wt, ctx, parent, { trustRoot: root })));
144
139
  const effort = effortOf(root, planId);
145
140
  const recap = effort ? latestRecap(root, effort) : undefined;
146
141
  return ok({