@popovych.co/witness 0.15.0 → 0.17.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 -2
- package/dist/allowlist.js +6 -1
- package/dist/arbiter.js +195 -0
- package/dist/ask.js +182 -0
- package/dist/autonomy.js +84 -0
- package/dist/cli.js +6 -3
- package/dist/config.js +63 -1
- package/dist/criteria.js +51 -5
- package/dist/departure.js +5 -4
- package/dist/drift.js +14 -6
- package/dist/dsl.js +67 -0
- package/dist/evidence.js +4 -2
- package/dist/gate.js +60 -8
- package/dist/gates/design.js +7 -2
- package/dist/gates/implement.js +4 -9
- package/dist/gates/ship.js +7 -11
- package/dist/needs.js +67 -2
- package/dist/recap-check.js +66 -0
- package/dist/recommend.js +9 -3
- package/dist/runner.js +5 -0
- package/dist/scan.js +23 -1
- package/dist/schemas.js +2 -1
- package/dist/settle.js +179 -0
- package/dist/ship.js +259 -20
- package/dist/verbs/adopt.js +2 -1
- package/dist/verbs/ask.js +56 -0
- package/dist/verbs/check.js +7 -3
- package/dist/verbs/dashboard.js +67 -2
- package/dist/verbs/decide.js +243 -103
- package/dist/verbs/log.js +7 -1
- package/dist/verbs/next.js +148 -47
- package/dist/verbs/recap.js +151 -14
- package/dist/verbs/rename.js +13 -0
- package/dist/verbs/satisfy.js +21 -12
- package/dist/verbs/ship.js +2 -2
- package/dist/verbs/start.js +20 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +17 -5
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +13 -2
- package/plugin/skills/witness-decompose/SKILL.md +3 -2
- package/plugin/skills/witness-design/SKILL.md +10 -1
- package/plugin/skills/witness-implement/SKILL.md +2 -1
- package/plugin/skills/witness-plan/SKILL.md +2 -1
- package/plugin/skills/witness-ship/SKILL.md +3 -2
- package/prompts/arbiter-recap.md +23 -0
- package/prompts/arbiter.md +22 -0
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
|
|
@@ -179,7 +182,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
|
|
|
179
182
|
| `read <id> [--design] [--outline] [--lines <a>-<b>]` | print a spec/plan/design from the primary root — the read route, since a worktree carries no canon copy; `--outline` names each anchor's line range for fat artifacts |
|
|
180
183
|
| `check` | schema, graph, invariants, needs, trailer audit, probes |
|
|
181
184
|
| `index` | id · summary · status · depends across the canon, then each spec's plans |
|
|
182
|
-
| `satisfy <id> --need <
|
|
185
|
+
| `satisfy <id> --need <id\|n\|text>` | journal a manual need as met — the spec is never rewritten |
|
|
183
186
|
| `log <id>` | render a journal stream |
|
|
184
187
|
| `gate <decompose\|plan\|implement\|ship\|design> <id>` | run the reviewer gate; journals the round, stamps on pass |
|
|
185
188
|
| `decide <gate> <id> --approve\|--revise [--pin <policy>]` | record the human decision on a stopped gate; `--pin` adds a standing content policy |
|
|
@@ -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
|
|
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 |
|
package/dist/allowlist.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { heldCriteria } from './needs.js';
|
|
3
4
|
const allowPath = (root) => join(root, '.witness', 'allow.json');
|
|
4
5
|
function load(root) {
|
|
5
6
|
if (!existsSync(allowPath(root)))
|
|
@@ -35,11 +36,15 @@ export function grantCommands(root, cmds) {
|
|
|
35
36
|
// artifact is the SPEC that owns the criteria: a plan gate judges a plan, but criteria live
|
|
36
37
|
// on the spec it derives from, and a block that listed nothing there would hide the very
|
|
37
38
|
// commands its approve is about to leave blocked.
|
|
39
|
+
//
|
|
40
|
+
// Row 179: a criterion an unmet need holds is not run, so it needs no trust until the need is
|
|
41
|
+
// met. `witness trust <id>` still lists every command, so a human may trust one ahead of time.
|
|
38
42
|
export function untrustedCmdsFor(root, doc) {
|
|
39
43
|
if (!doc)
|
|
40
44
|
return [];
|
|
45
|
+
const held = heldCriteria(doc);
|
|
41
46
|
const list = Array.isArray(doc.meta.criteria) ? doc.meta.criteria : [];
|
|
42
|
-
const cmds = list.map((c) => c.cmd).filter((c) => typeof c === 'string');
|
|
47
|
+
const cmds = list.filter((c) => !held.has(String(c.id))).map((c) => c.cmd).filter((c) => typeof c === 'string');
|
|
43
48
|
const trusted = new Set(load(root));
|
|
44
49
|
return [...new Set(cmds.filter((c) => !trusted.has(c)))];
|
|
45
50
|
}
|
package/dist/arbiter.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { askOf, askText } from './ask.js';
|
|
2
|
+
import { decidedBy } from './autonomy.js';
|
|
3
|
+
import { loadLocalConfig } from './config.js';
|
|
4
|
+
import { openDeferrals } from './deferral.js';
|
|
5
|
+
import { serializeDoc } from './fm.js';
|
|
6
|
+
import { resolveJudge } from './harness.js';
|
|
7
|
+
import { latestRecap, policyPins } from './journal.js';
|
|
8
|
+
import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
|
|
9
|
+
import { PLACEHOLDER } from './recommend.js';
|
|
10
|
+
import { ok, refuse, v } from './refusal.js';
|
|
11
|
+
import { invokeReviewerAsync, parseVerdictText, promptsSha, resolvePrompt } from './reviewer.js';
|
|
12
|
+
import { effortOf, effortSpecs } from './reviewed.js';
|
|
13
|
+
import { anchorRecurrence, gateRuns, roundBudget } from './rounds.js';
|
|
14
|
+
import { findById } from './scan.js';
|
|
15
|
+
import { canonicalJson } from './sha.js';
|
|
16
|
+
// How a later mode (plan A2's recap check) adds its own prompt without a second invoker.
|
|
17
|
+
export const PROMPT_OF = { decision: 'arbiter', recap: 'arbiter-recap' };
|
|
18
|
+
const TEXT_MAX = 600;
|
|
19
|
+
function renderOptions(menu) {
|
|
20
|
+
return menu.flatMap((o, i) => [
|
|
21
|
+
`${i + 1}. ${o.command}`,
|
|
22
|
+
...(o.why ? [` why: ${o.why}`] : []),
|
|
23
|
+
...(o.judgeFirst ? [` judge-first: ${o.judgeFirst}`] : []),
|
|
24
|
+
...(o.when ? [` when: ${o.when}`] : []),
|
|
25
|
+
...(o.tradeoff ? [` tradeoff: ${o.tradeoff}`] : []),
|
|
26
|
+
...(o.note ? [` note: ${o.note}`] : []),
|
|
27
|
+
]).join('\n');
|
|
28
|
+
}
|
|
29
|
+
function readRuling(text, n) {
|
|
30
|
+
const rawR = parseVerdictText(text);
|
|
31
|
+
if (!rawR.ok)
|
|
32
|
+
return rawR;
|
|
33
|
+
const raw = rawR.value;
|
|
34
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
35
|
+
return refuse([v('ruling', 'not-an-object', JSON.stringify(raw).slice(0, 120), '{"choice": <n>, "reason": "…"}')]);
|
|
36
|
+
}
|
|
37
|
+
const r = raw;
|
|
38
|
+
const rows = [];
|
|
39
|
+
if (typeof r.choice !== 'number' || !Number.isInteger(r.choice) || r.choice < 1 || r.choice > n) {
|
|
40
|
+
rows.push(v('choice', 'not-an-option', String(r.choice), `an option number from 1 to ${n}`));
|
|
41
|
+
}
|
|
42
|
+
if (typeof r.reason !== 'string' || r.reason.trim() === '' || r.reason.length > TEXT_MAX) {
|
|
43
|
+
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`));
|
|
44
|
+
}
|
|
45
|
+
// An empty note is an absent one: the contract marks it optional, and a reroll spent on
|
|
46
|
+
// `"note": ""` would park a flow over a field that carried nothing.
|
|
47
|
+
const note = typeof r.note === 'string' && r.note.trim() === '' ? undefined : r.note;
|
|
48
|
+
if (note !== undefined && note !== null && (typeof note !== 'string' || note.length > TEXT_MAX)) {
|
|
49
|
+
rows.push(v('note', 'invalid', typeof note === 'string' ? `${note.length} chars` : String(note), `an optional note of at most ${TEXT_MAX} characters`));
|
|
50
|
+
}
|
|
51
|
+
if (rows.length > 0)
|
|
52
|
+
return refuse(rows);
|
|
53
|
+
return ok({ choice: r.choice, reason: r.reason, ...(typeof note === 'string' ? { note } : {}) });
|
|
54
|
+
}
|
|
55
|
+
export async function arbitrate(ctx, root, cfg, req) {
|
|
56
|
+
const lensR = resolvePrompt(PROMPT_OF[req.mode]);
|
|
57
|
+
if (!lensR.ok)
|
|
58
|
+
return lensR;
|
|
59
|
+
const lens = lensR.value;
|
|
60
|
+
const hxR = resolveJudge(ctx.env, cfg.raw);
|
|
61
|
+
if (!hxR.ok)
|
|
62
|
+
return refuse(hxR.violations);
|
|
63
|
+
const harness = hxR.value.harness;
|
|
64
|
+
const localR = loadLocalConfig(root);
|
|
65
|
+
if (!localR.ok)
|
|
66
|
+
return localR;
|
|
67
|
+
const modelR = resolveModel(cfg, loadMatrix(root, harness.name));
|
|
68
|
+
if (!modelR.ok)
|
|
69
|
+
return refuse(modelR.violations);
|
|
70
|
+
const chain = modelR.value.chain;
|
|
71
|
+
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`;
|
|
72
|
+
let rung = 0;
|
|
73
|
+
for (let attempt = 0;; attempt++) {
|
|
74
|
+
let text;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const id = chain[rung];
|
|
77
|
+
const r = await invokeReviewerAsync(ctx, harness, {
|
|
78
|
+
cwd: root, prompt, model: id === SESSION_DEFAULT ? undefined : id, lens: 'arbiter',
|
|
79
|
+
timeoutMs: cfg.gates.reviewerTimeoutMs, extensions: localR.value.reviewerExtensions,
|
|
80
|
+
});
|
|
81
|
+
if (r.ok) {
|
|
82
|
+
text = r.value.text;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
if (rung >= chain.length - 1)
|
|
86
|
+
return refuse(r.violations);
|
|
87
|
+
rung += 1;
|
|
88
|
+
}
|
|
89
|
+
const ruling = readRuling(text, req.menu.length);
|
|
90
|
+
if (ruling.ok) {
|
|
91
|
+
return ok({ ...ruling.value, model: chain[rung], prompts_sha: promptsSha([{ name: PROMPT_OF[req.mode], contents: lens.contents }]) });
|
|
92
|
+
}
|
|
93
|
+
if (attempt === 0) {
|
|
94
|
+
// One self-repair reroll on the same rung, in the shape runBattery gives a lens.
|
|
95
|
+
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`;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
return refuse([v('arbiter', 'arbiter-malformed', ruling.violations[0]?.got ?? '', 'a JSON object with choice and reason naming one listed option'), ...ruling.violations]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// The block minus every act the arbiter may never take: anything but a decision (which
|
|
102
|
+
// removes `witness abandon` — the CLI never discards work, D168), any trust grant (D154),
|
|
103
|
+
// any command whose id the CLI could not resolve (the arbiter fills a note, never an id),
|
|
104
|
+
// and every approve over a failed deterministic check — it may overrule a reviewer, never a
|
|
105
|
+
// check. `amendment-ack` alone is the exception: approving IS its acknowledgement.
|
|
106
|
+
// The design gate is the exception that holds whatever the evidence says: the look is the
|
|
107
|
+
// human's (§3 G2), so no approve of it is ever offered — at the bound, on a fallback round or
|
|
108
|
+
// past a spent ladder the arbiter may revise, reset upstream, repair or park, never stamp.
|
|
109
|
+
export function menuFor(block, run) {
|
|
110
|
+
const failed = run.checks.filter((c) => !c.ok);
|
|
111
|
+
const approveBarred = run.gate === 'design' || (failed.length > 0 && !failed.every((c) => c.name === 'amendment-ack'));
|
|
112
|
+
return block.options.filter((o) => o.command.startsWith('witness decide ') &&
|
|
113
|
+
!/ --trust-cmds\b/.test(o.command) &&
|
|
114
|
+
!PLACEHOLDER.test(o.command.replace(' --note "<why>"', '')) &&
|
|
115
|
+
!(approveBarred && / --approve\b/.test(o.command)));
|
|
116
|
+
}
|
|
117
|
+
const anchorText = (a) => (typeof a === 'string' ? a : `omission:${a.scope}`);
|
|
118
|
+
const HISTORY_RUNS = 10;
|
|
119
|
+
// The evidence the arbiter reads, composed by the CLI from the journal and canon. It never
|
|
120
|
+
// carries a session transcript: none exists to carry, and the author's narration is
|
|
121
|
+
// exactly what the arbiter must not hear (anti-goal 1).
|
|
122
|
+
export function decisionEvidence(root, canon, stop) {
|
|
123
|
+
const { gate, target, entries, run, block } = stop;
|
|
124
|
+
const findings = (run.verdicts ?? []).flatMap((rv) => rv.findings.map((f) => ({ reviewer: rv.reviewer, f })));
|
|
125
|
+
const sections = [
|
|
126
|
+
{
|
|
127
|
+
title: 'The stop',
|
|
128
|
+
body: [
|
|
129
|
+
`gate: ${gate} · target: ${target}`,
|
|
130
|
+
`round: ${run.round} of ${roundBudget(entries, gate)}`,
|
|
131
|
+
`rule: ${block.rule}`,
|
|
132
|
+
`standing: ${run.standing ?? 'none'}`,
|
|
133
|
+
`model: reviewers answered on ${run.model} · pinned ${run.pin ?? run.model}`,
|
|
134
|
+
].join('\n'),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
title: 'Checks',
|
|
138
|
+
body: run.checks.length === 0 ? 'none'
|
|
139
|
+
: run.checks.map((c) => `- ${c.name}: ${c.ok ? 'ok' : 'FAILED'}${c.detail ? ` — ${c.detail}` : ''}`).join('\n'),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
title: 'Findings',
|
|
143
|
+
body: findings.length === 0 ? 'none'
|
|
144
|
+
: 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'),
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const history = [];
|
|
148
|
+
const runs = gateRuns(entries, gate).slice(-HISTORY_RUNS);
|
|
149
|
+
for (const r of runs) {
|
|
150
|
+
const blocks = [...new Set((r.verdicts ?? []).flatMap((rv) => rv.findings.filter((f) => f.blocking).map((f) => anchorText(f.anchor))))];
|
|
151
|
+
history.push(`- round ${r.round} · @${r.reviewed_sha.slice(0, 7)} · ${r.outcome}${blocks.length ? ` · blocking: ${blocks.join(' · ')}` : ''}`);
|
|
152
|
+
const at = entries.indexOf(r);
|
|
153
|
+
for (const e of entries.slice(at + 1)) {
|
|
154
|
+
if (e.t === 'gate-run' && e.gate === gate)
|
|
155
|
+
break;
|
|
156
|
+
if (e.t !== 'human-decision' || e.gate !== gate)
|
|
157
|
+
continue;
|
|
158
|
+
const d = e;
|
|
159
|
+
history.push(` - ${d.decision} · ${decidedBy(d)}${d.rule ? ` · ${d.rule}` : ''}${d.note ? ` · ${d.note}` : ''}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const current = [...new Set(findings.filter(({ f }) => f.blocking).map(({ f }) => anchorText(f.anchor)))];
|
|
163
|
+
for (const a of current)
|
|
164
|
+
history.push(`- recurrence: ${a} · found in ${anchorRecurrence(entries, gate, a)} round(s) of this budget`);
|
|
165
|
+
for (const d of openDeferrals(entries))
|
|
166
|
+
history.push(`- open obligation ${d.id} · ${d.anchor} · ${d.kind}`);
|
|
167
|
+
for (const p of policyPins(entries))
|
|
168
|
+
history.push(`- pin #${p.ordinal}: ${p.text}`);
|
|
169
|
+
sections.push({ title: 'Round history', body: history.length === 0 ? 'none' : history.join('\n') });
|
|
170
|
+
if (gate === 'ship' || gate === 'decompose') {
|
|
171
|
+
const effort = gate === 'decompose' ? target : effortOf(root, target);
|
|
172
|
+
const recap = effort !== undefined ? latestRecap(root, effort) : undefined;
|
|
173
|
+
const scope = [`### The effort's recap\n\n${recap ? canonicalJson(recap) : 'none recorded'}`];
|
|
174
|
+
if (gate === 'ship') {
|
|
175
|
+
const plan = findById(canon, target);
|
|
176
|
+
const parent = plan ? findById(canon, String(plan.meta.parent)) : undefined;
|
|
177
|
+
if (parent)
|
|
178
|
+
scope.push(`### The spec: ${String(parent.meta.id)}\n\n${serializeDoc({ meta: parent.meta, body: parent.body })}`);
|
|
179
|
+
if (plan)
|
|
180
|
+
scope.push(`### The plan: ${target}\n\n${serializeDoc({ meta: plan.meta, body: plan.body })}`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
for (const s of effortSpecs(root, canon, target)) {
|
|
184
|
+
scope.push(`### The spec: ${String(s.meta.id)}\n\n${serializeDoc({ meta: s.meta, body: s.body })}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
sections.push({ title: 'Scope', body: scope.join('\n\n') });
|
|
188
|
+
// Row 177. A decompose stop is judged against the ask as well as the recap: both of its
|
|
189
|
+
// arbiter routes (the feature-scope stop and amendment-ack) re-scope what the human asked.
|
|
190
|
+
const ask = gate === 'decompose' ? askOf(root, target) : undefined;
|
|
191
|
+
if (ask !== undefined)
|
|
192
|
+
sections.push({ title: 'The ask', body: `${ask.id} — ${askText(ask.text)}` });
|
|
193
|
+
}
|
|
194
|
+
return sections;
|
|
195
|
+
}
|
package/dist/ask.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { FLOOR_STREAM } from './floor.js';
|
|
2
|
+
import { stateCommit } from './gitio.js';
|
|
3
|
+
import { appendEntry, effortStreams, entryLine, journalRel, readStream } from './journal.js';
|
|
4
|
+
import { acquireLock } from './lock.js';
|
|
5
|
+
import { ok, refuse, v } from './refusal.js';
|
|
6
|
+
import { ROUND_BOUND } from './rounds.js';
|
|
7
|
+
import { crashPoint, pendingTxn, withTxn } from './txn.js';
|
|
8
|
+
// Row 176. The human's ask is journaled state, on a stream witness owns the way it owns
|
|
9
|
+
// `floor`. This module is its one home: the entries, the queue derived from them, and the
|
|
10
|
+
// one write path the verb, `/witness <text>` and `drive --ask` share — so a crashed session
|
|
11
|
+
// loses nothing, because the brainstorm that answers an ask reads it from here, never from
|
|
12
|
+
// its prompt.
|
|
13
|
+
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
|
+
// Display only: the stored text is the human's, trimmed and otherwise as given.
|
|
29
|
+
export function askText(text) {
|
|
30
|
+
return oneLine(text);
|
|
31
|
+
}
|
|
32
|
+
// Text a human or a model wrote, made safe to print where a reader parses lines: the
|
|
33
|
+
// skills read `next`'s unit line by line, and a prompt section must never open a heading of
|
|
34
|
+
// its own. Whitespace runs collapse to one space; the words stay as written.
|
|
35
|
+
export function oneLine(text) {
|
|
36
|
+
return text.trim().replace(/\s+/g, ' ');
|
|
37
|
+
}
|
|
38
|
+
export function askQueue(root) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const e of readStream(root, ASK_STREAM)) {
|
|
41
|
+
if (e.t === 'ask')
|
|
42
|
+
out.push({ id: String(e.id), text: String(e.text), state: 'unanswered', checks: 0 });
|
|
43
|
+
else if (e.t === 'ask-withdrawn') {
|
|
44
|
+
const a = out.find((x) => x.id === e.ask);
|
|
45
|
+
if (a)
|
|
46
|
+
a.state = 'withdrawn';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
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
|
+
// An ask is answered once any recap names it, even one an abandon later ended: re-asking is
|
|
68
|
+
// the human's act, so an abandoned effort never puts its ask back in the queue.
|
|
69
|
+
const answeredBy = new Map();
|
|
70
|
+
for (const effort of effortStreams(root)) {
|
|
71
|
+
for (const e of readStream(root, effort)) {
|
|
72
|
+
if (e.t === 'recap' && typeof e.ask === 'string' && !answeredBy.has(e.ask))
|
|
73
|
+
answeredBy.set(e.ask, effort);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const a of out) {
|
|
77
|
+
const effort = answeredBy.get(a.id);
|
|
78
|
+
if (effort !== undefined) {
|
|
79
|
+
a.state = 'answered';
|
|
80
|
+
a.effort = effort;
|
|
81
|
+
delete a.parked;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
// The one list of stream ids witness owns. A stream id is an artifact's whole history, so an
|
|
87
|
+
// effort, spec, plan or rename that took one of these would merge two histories into one.
|
|
88
|
+
export function reservedId(id) {
|
|
89
|
+
if (id === ASK_STREAM)
|
|
90
|
+
return "witness's own ask queue";
|
|
91
|
+
if (id === FLOOR_STREAM)
|
|
92
|
+
return "witness's own floor stream";
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
const TEXT_REQUIRED = v('text', 'required', 'absent', 'what to build in words — witness ask <text>');
|
|
96
|
+
export function queueAsk(ctx, root, text) {
|
|
97
|
+
if (askText(text) === '')
|
|
98
|
+
return refuse([TEXT_REQUIRED]);
|
|
99
|
+
const lock = acquireLock(root);
|
|
100
|
+
if (!lock.ok)
|
|
101
|
+
return lock;
|
|
102
|
+
try {
|
|
103
|
+
// Under the lock: a writer that crashed before this one took it left its marker here.
|
|
104
|
+
const pending = pendingTxn(root);
|
|
105
|
+
if (pending)
|
|
106
|
+
return refuse([v('.witness/txn.json', 'pending-txn', pending.op, 'witness recover --complete | --rollback')]);
|
|
107
|
+
// Final review F7: a stream an older witness wrote under this name is another artifact's
|
|
108
|
+
// history, and asks appended to it would be read as part of that artifact.
|
|
109
|
+
const first = readStream(root, ASK_STREAM)[0];
|
|
110
|
+
if (first !== undefined && first.t !== 'ask') {
|
|
111
|
+
return refuse([v(ASK_STREAM, 'stream-taken', `the stream starts with ${first.t}`, 'a stream for asks alone — witness check names the artifact that holds it')]);
|
|
112
|
+
}
|
|
113
|
+
// Final review F10: the same words while that ask is still open are the same ask — a
|
|
114
|
+
// crashed `/witness <words>` run again must not queue, and build, the work twice.
|
|
115
|
+
const queue = askQueue(root);
|
|
116
|
+
const unanswered = () => askQueue(root).filter((a) => a.state === 'unanswered').length;
|
|
117
|
+
const open = queue.find((a) => (a.state === 'unanswered' || a.state === 'parked') && askText(a.text) === askText(text));
|
|
118
|
+
if (open !== undefined)
|
|
119
|
+
return ok({ id: open.id, unanswered: unanswered(), already: true });
|
|
120
|
+
// numbered under the lock: two queuers racing must not both take the next id
|
|
121
|
+
const id = `a${queue.length + 1}`;
|
|
122
|
+
const entry = { t: 'ask', id, text: text.trim() };
|
|
123
|
+
const stream = journalRel(ASK_STREAM);
|
|
124
|
+
const res = withTxn(root, { op: `ask(${id})`, files: [stream], journal: { stream: ASK_STREAM, line: entryLine(entry) } }, () => {
|
|
125
|
+
appendEntry(root, ASK_STREAM, entry);
|
|
126
|
+
crashPoint(ctx.env, 'journal-append');
|
|
127
|
+
return stateCommit(root, [stream], `ask(${id}): ${askText(text).slice(0, 60)}`);
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok)
|
|
130
|
+
return res;
|
|
131
|
+
return ok({ id, unanswered: unanswered(), already: false });
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
lock.value();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
export function withdrawAsk(ctx, root, id, note) {
|
|
138
|
+
const lock = acquireLock(root);
|
|
139
|
+
if (!lock.ok)
|
|
140
|
+
return lock;
|
|
141
|
+
try {
|
|
142
|
+
const pending = pendingTxn(root);
|
|
143
|
+
if (pending)
|
|
144
|
+
return refuse([v('.witness/txn.json', 'pending-txn', pending.op, 'witness recover --complete | --rollback')]);
|
|
145
|
+
const ask = askQueue(root).find((a) => a.id === id);
|
|
146
|
+
if (!ask)
|
|
147
|
+
return refuse([v('--withdraw', 'unknown-ask', id, 'an ask id — witness status lists the queue')]);
|
|
148
|
+
if (ask.state === 'withdrawn')
|
|
149
|
+
return ok({ already: true });
|
|
150
|
+
if (ask.state === 'answered') {
|
|
151
|
+
return refuse([v('--withdraw', 'ask-answered', `${id} is answered by ${ask.effort}`, `an unanswered ask — ${ask.effort} answers it now · witness abandon ${ask.effort} ends that effort`)]);
|
|
152
|
+
}
|
|
153
|
+
const entry = { t: 'ask-withdrawn', ask: id, ...(note !== undefined ? { note } : {}) };
|
|
154
|
+
const stream = journalRel(ASK_STREAM);
|
|
155
|
+
const res = withTxn(root, { op: `ask(${id})`, files: [stream], journal: { stream: ASK_STREAM, line: entryLine(entry) } }, () => {
|
|
156
|
+
appendEntry(root, ASK_STREAM, entry);
|
|
157
|
+
crashPoint(ctx.env, 'journal-append');
|
|
158
|
+
return stateCommit(root, [stream], `ask(${id}): withdrawn`);
|
|
159
|
+
});
|
|
160
|
+
if (!res.ok)
|
|
161
|
+
return res;
|
|
162
|
+
return ok({ already: false });
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
lock.value();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// The one derivation of which ask is routed next: the oldest still open. A parked ask waits
|
|
169
|
+
// for a human, so it is routable whenever a human interviews and skipped under autonomy,
|
|
170
|
+
// where re-offering it would repeat a command `recap` refuses.
|
|
171
|
+
export function nextAsk(root, autonomous) {
|
|
172
|
+
return askQueue(root).find((a) => a.state === 'unanswered' || (a.state === 'parked' && !autonomous));
|
|
173
|
+
}
|
|
174
|
+
// The ask an effort answers: what the decompose arbiter judges a slicing against beside the
|
|
175
|
+
// recap itself (row 177). Named by the recap that birthed the effort — an amend carries no
|
|
176
|
+
// ask, and it is exactly a re-scoped slicing that must still be judged against the ask.
|
|
177
|
+
export function askOf(root, effort) {
|
|
178
|
+
const id = readStream(root, effort).find((e) => e.t === 'recap' && typeof e.ask === 'string')?.ask;
|
|
179
|
+
if (id === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
return readStream(root, ASK_STREAM).find((e) => e.t === 'ask' && e.id === id);
|
|
182
|
+
}
|
package/dist/autonomy.js
ADDED
|
@@ -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
|
@@ -9,6 +9,8 @@ const VERBS = {
|
|
|
9
9
|
recover: () => import('./verbs/recover.js'),
|
|
10
10
|
init: () => import('./verbs/init.js'),
|
|
11
11
|
recap: () => import('./verbs/recap.js'),
|
|
12
|
+
// Row 176. The human's ask, queued as state so `next` routes it and no session can lose it.
|
|
13
|
+
ask: () => import('./verbs/ask.js'),
|
|
12
14
|
write: () => import('./verbs/write.js'),
|
|
13
15
|
// Row 132's read route. Canon lives at the primary root and nowhere else, so reading it
|
|
14
16
|
// is a verb rather than a path — which is also what lets a worktree carry no copy.
|
|
@@ -51,6 +53,7 @@ const VERBS = {
|
|
|
51
53
|
const VERB_USAGE = {
|
|
52
54
|
abandon: 'witness abandon <plan-id | effort-slug>',
|
|
53
55
|
adopt: 'witness adopt <specs/... | plans/...>.md',
|
|
56
|
+
ask: 'witness ask <text> | --withdraw <id> [--note <why>]',
|
|
54
57
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
55
58
|
check: 'witness check',
|
|
56
59
|
clean: 'witness clean',
|
|
@@ -66,12 +69,12 @@ const VERB_USAGE = {
|
|
|
66
69
|
log: 'witness log <stream> [--all] [--lineage]',
|
|
67
70
|
next: 'witness next',
|
|
68
71
|
read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
|
|
69
|
-
recap: 'witness recap --file <recap.json> [--amend]',
|
|
72
|
+
recap: 'witness recap --file <recap.json> [--ask <id>] [--amend] [--manual]',
|
|
70
73
|
recover: 'witness recover [--complete | --rollback]',
|
|
71
74
|
rename: 'witness rename <old-id> <new-id>',
|
|
72
|
-
satisfy: 'witness satisfy <doc-id> --need <
|
|
75
|
+
satisfy: 'witness satisfy <doc-id> --need <id | index | text>',
|
|
73
76
|
dismiss: 'witness dismiss <artifact> --deferral <id|index> --cause <superseded|lens-retired|judged-wrong> --note "<why>"',
|
|
74
|
-
ship: 'witness ship <plan-id>',
|
|
77
|
+
ship: 'witness ship <plan-id> [--manual]',
|
|
75
78
|
start: 'witness start <plan-id>',
|
|
76
79
|
status: 'witness status — flows · blocked docs · reconcile rows · pending gates (bare `witness` is the same screen)',
|
|
77
80
|
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
|
-
|
|
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
|
}
|