@popovych.co/witness 0.8.0 → 0.10.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 +15 -0
- package/dist/cli.js +62 -8
- package/dist/floor.js +74 -0
- package/dist/gate.js +46 -30
- package/dist/gates/implement.js +1 -1
- package/dist/install.js +40 -1
- package/dist/journal.js +8 -1
- package/dist/rounds.js +44 -1
- package/dist/verbs/check.js +16 -6
- package/dist/verbs/decide.js +71 -38
- package/dist/verbs/floor.js +49 -0
- package/dist/verbs/init.js +20 -3
- package/dist/verbs/next.js +4 -4
- package/dist/verbs/recap.js +4 -2
- package/dist/verbs/sync.js +16 -0
- package/dist/verbs/write.js +7 -2
- package/dist/version.js +12 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +1 -1
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -117,6 +117,21 @@ witness commits the payload. It refuses the whole run if a payload path carries
|
|
|
117
117
|
uncommitted change (`payload-dirty`) or if the CLI you are running is older than the
|
|
118
118
|
payload already installed (`cli-behind-payload`).
|
|
119
119
|
|
|
120
|
+
From **0.10.0** onward `init` installs into **every home of the repository** — the primary
|
|
121
|
+
root and each live `.witness/worktrees/<plan-id>` — because a worktree is a branch
|
|
122
|
+
checkout, so its payload is a different file on a different timeline. A worktree cut
|
|
123
|
+
before an upgrade used to keep the old pin forever, and the old pin is what selects the
|
|
124
|
+
CLI that runs there. Every home is preflighted before any is written, so a dirty payload
|
|
125
|
+
in one refuses the whole run rather than leaving the set half-upgraded, and `witness check`
|
|
126
|
+
now reports `payload-stale` per home and names the stale one.
|
|
127
|
+
|
|
128
|
+
The same release makes the bound structural: every journal entry records the CLI that
|
|
129
|
+
wrote it, and a CLI older than anything the repository's state has seen refuses every verb
|
|
130
|
+
with `cli-behind-state` rather than answering from rules the repository has moved past.
|
|
131
|
+
`witness floor --show` reports the bound. If you are deliberately rolling back — a bad
|
|
132
|
+
release, say — lower it first with `witness floor --set <version> --note <why>`; the
|
|
133
|
+
decision is journaled, and `floor` is the one verb that still runs under a bound it fails.
|
|
134
|
+
|
|
120
135
|
### Support tiers
|
|
121
136
|
|
|
122
137
|
| Tier | Agents | What they get |
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
3
2
|
import { configPath } from './config.js';
|
|
3
|
+
import { stateFloor } from './floor.js';
|
|
4
4
|
import { primaryRoot } from './gitio.js';
|
|
5
|
+
import { renderRefusal, v } from './refusal.js';
|
|
6
|
+
import { NPX_LATEST, compareTriple, version } from './version.js';
|
|
5
7
|
export const EXIT = { OK: 0, FINDINGS: 1, REFUSED: 2, BLOCKED: 3 };
|
|
6
8
|
const VERBS = {
|
|
7
9
|
recover: () => import('./verbs/recover.js'),
|
|
@@ -32,6 +34,9 @@ const VERBS = {
|
|
|
32
34
|
rename: () => import('./verbs/rename.js'),
|
|
33
35
|
sync: () => import('./verbs/sync.js'),
|
|
34
36
|
calibrate: () => import('./verbs/calibrate.js'),
|
|
37
|
+
// Row 116's valve. Exempt from the floor gate in `main`, because a repository that
|
|
38
|
+
// refuses every verb must still be able to run the one that unrefuses it.
|
|
39
|
+
floor: () => import('./verbs/floor.js'),
|
|
35
40
|
};
|
|
36
41
|
// answered centrally so no verb can crash on --help (several parse argv
|
|
37
42
|
// strictly); each line mirrors the verb's own usage refusal where one exists
|
|
@@ -41,7 +46,8 @@ const VERB_USAGE = {
|
|
|
41
46
|
calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
|
|
42
47
|
check: 'witness check',
|
|
43
48
|
clean: 'witness clean',
|
|
44
|
-
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
|
|
49
|
+
decide: 'witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]',
|
|
50
|
+
floor: 'witness floor --show | --set <triple> --note <why>',
|
|
45
51
|
design: 'witness design <spec-id> --file <html> | --reconfirm | --open',
|
|
46
52
|
diff: 'witness diff <spec-id>',
|
|
47
53
|
'dispatch-report': 'witness dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n> [--tokens <n>] [--tool-uses <n>] [--duration-ms <n>]',
|
|
@@ -61,10 +67,10 @@ const VERB_USAGE = {
|
|
|
61
67
|
'test-evidence': 'witness test-evidence <plan-id> --phase red|green',
|
|
62
68
|
'verify-red': 'witness verify-red <plan-id> [--base <ref>]',
|
|
63
69
|
};
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
70
|
+
// Row 116 moved the body to version.ts, which journal.ts can import without closing a
|
|
71
|
+
// cycle. Re-exported here because eleven call sites already ask the CLI shell for it and
|
|
72
|
+
// the question they are asking has not changed.
|
|
73
|
+
export { version };
|
|
68
74
|
export function usage() {
|
|
69
75
|
return [
|
|
70
76
|
'witness — spec-led pipeline: specs are state, plans are motion',
|
|
@@ -79,6 +85,38 @@ export async function main(ctx, argv) {
|
|
|
79
85
|
ctx.out(version());
|
|
80
86
|
return EXIT.OK;
|
|
81
87
|
}
|
|
88
|
+
// Row 116. Above the verb table on purpose — including above the bare-`witness`
|
|
89
|
+
// dashboard route, which reads the same state every verb does. The invariant is "one
|
|
90
|
+
// repository, one witness version", and a per-verb opt-in is a list some future verb
|
|
91
|
+
// forgets to join.
|
|
92
|
+
//
|
|
93
|
+
// This is the guard row 102's `cli-behind-payload` structurally could not be. That one
|
|
94
|
+
// fires inside the home it protects, so a home frozen at an old pin runs an old CLI that
|
|
95
|
+
// never learned to check — which is how two sessions a lifecycle apart each computed a
|
|
96
|
+
// different next stage, each redirected to the other's home, and each honestly ended its
|
|
97
|
+
// turn. The state is the one thing every home shares, so the state is where the bound
|
|
98
|
+
// belongs.
|
|
99
|
+
//
|
|
100
|
+
// Forward-only, and the text must never imply otherwise: a CLI published before this row
|
|
101
|
+
// cannot run this code. Homes already frozen are found by `check` and repaired by `init`.
|
|
102
|
+
//
|
|
103
|
+
// Two exemptions, both load-bearing. `floor` is how a bad publish is recovered from — a
|
|
104
|
+
// repository that refuses every verb must still be able to run the one that unrefuses it.
|
|
105
|
+
// An explicit help request is information, and refusing to print a usage line teaches
|
|
106
|
+
// the human nothing they can act on.
|
|
107
|
+
const helpRequested = verb === 'help' || verb === '--help'
|
|
108
|
+
|| rest.includes('--help') || rest.includes('-h');
|
|
109
|
+
if (!helpRequested && verb !== 'floor') {
|
|
110
|
+
const gateRoot = primaryRoot(ctx.cwd);
|
|
111
|
+
if (gateRoot.ok) {
|
|
112
|
+
const floor = stateFloor(gateRoot.value);
|
|
113
|
+
if (floor !== undefined && (compareTriple(version(), floor.pin) ?? 0) < 0) {
|
|
114
|
+
renderRefusal([v('witness', 'cli-behind-state', `this CLI is ${version()}, this repository's state was written by ${floor.pin}`, `a CLI at or ahead of ${floor.pin} — run ${NPX_LATEST} init --agent <name>; if you are `
|
|
115
|
+
+ `deliberately rolling back, lower the bound first with witness floor --set ${version()} --note <why>`)]).forEach((l) => ctx.err(l));
|
|
116
|
+
return EXIT.REFUSED;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
82
120
|
if (!verb || verb === 'help' || verb === '--help') {
|
|
83
121
|
if (!verb) {
|
|
84
122
|
const rootRes = primaryRoot(ctx.cwd);
|
|
@@ -99,5 +137,21 @@ export async function main(ctx, argv) {
|
|
|
99
137
|
ctx.err(usage());
|
|
100
138
|
return EXIT.REFUSED;
|
|
101
139
|
}
|
|
102
|
-
|
|
140
|
+
try {
|
|
141
|
+
return await (await load()).run(ctx, rest);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
// Row 113. Eight verbs parse argv with node's strict `parseArgs`, which THROWS on a
|
|
145
|
+
// stray positional or a mistyped flag. bin.ts's crash net then rendered a typo as
|
|
146
|
+
// `unexpected-error … a bug — re-run with the same arguments and report this line`:
|
|
147
|
+
// the tool asked to be reported for a mistake it could answer with its own usage. This
|
|
148
|
+
// is the one throw class that is a user statement rather than a witness fault, and the
|
|
149
|
+
// usage line lives right here. Anything else still reaches bin.ts unchanged.
|
|
150
|
+
const code = e.code;
|
|
151
|
+
if (typeof code === 'string' && code.startsWith('ERR_PARSE_ARGS')) {
|
|
152
|
+
renderRefusal([v(verb, 'bad-arguments', String(e.message).split('.')[0].slice(0, 160), VERB_USAGE[verb] ?? `witness ${verb} --help`)]).forEach((l) => ctx.err(l));
|
|
153
|
+
return EXIT.REFUSED;
|
|
154
|
+
}
|
|
155
|
+
throw e;
|
|
156
|
+
}
|
|
103
157
|
}
|
package/dist/floor.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { journalRel } from './journal.js';
|
|
4
|
+
import { compareTriple } from './version.js';
|
|
5
|
+
// Row 116. The state's own answer to "what is the oldest CLI allowed to touch this
|
|
6
|
+
// repository", derived from the `w` stamps entryLine writes and stored nowhere. Derived
|
|
7
|
+
// because a stored floor is a second source of truth that drifts from the entries it
|
|
8
|
+
// summarises — the shape rows 93, 95 and 96 all name — and because a derived one cannot
|
|
9
|
+
// be lowered by editing a file, only by a journaled decision (verbs/floor.ts).
|
|
10
|
+
//
|
|
11
|
+
// MAXIMUM, not most-recent: a downgraded CLI writing a lower stamp after a higher one is
|
|
12
|
+
// exactly the event this guards against, and a floor that fell to the last writer would
|
|
13
|
+
// ratify the regression it exists to refuse.
|
|
14
|
+
//
|
|
15
|
+
// `undefined` means the state carries no readable stamp at all — every repository written
|
|
16
|
+
// before this row shipped. It is silence, not zero: a floor of 0.0.0 would be a claim the
|
|
17
|
+
// state never made, and it would refuse nothing anyway.
|
|
18
|
+
export function stateFloor(root) {
|
|
19
|
+
const dir = join(root, '.witness', 'journal');
|
|
20
|
+
if (!existsSync(dir))
|
|
21
|
+
return undefined;
|
|
22
|
+
// An explicit decision outranks the derived maximum — including downward, which is the
|
|
23
|
+
// only reason the verb exists. Latest wins, so a second rollback supersedes the first:
|
|
24
|
+
// the last decision is the state, the same doctrine D94 applies to gate decisions.
|
|
25
|
+
const pinned = entries(root, FLOOR_STREAM)
|
|
26
|
+
.filter((e) => e.t === 'policy-pin' && e.key === FLOOR_KEY && typeof e.pin === 'string')
|
|
27
|
+
.at(-1);
|
|
28
|
+
if (pinned !== undefined)
|
|
29
|
+
return { pin: pinned.pin, stream: 'a human decision' };
|
|
30
|
+
let best;
|
|
31
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl')).sort()) {
|
|
32
|
+
const stream = file.slice(0, -'.jsonl'.length);
|
|
33
|
+
for (const e of entries(root, stream)) {
|
|
34
|
+
if (typeof e.w !== 'string')
|
|
35
|
+
continue;
|
|
36
|
+
const pin = e.w;
|
|
37
|
+
// `compareTriple(pin, pin)` is the parse check: undefined means CANNOT COMPARE, the
|
|
38
|
+
// same contract install.ts's two guards read it under. A stamp we cannot read must
|
|
39
|
+
// never raise a bound — that would refuse a legal CLI over a typo.
|
|
40
|
+
if (compareTriple(pin, pin) === undefined)
|
|
41
|
+
continue;
|
|
42
|
+
if (best === undefined || (compareTriple(pin, best.pin) ?? 0) > 0)
|
|
43
|
+
best = { pin, stream };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best;
|
|
47
|
+
}
|
|
48
|
+
// The journal stream a floor decision lives in. Its first entry is a policy-pin, never a
|
|
49
|
+
// recap, so `effortStreams` does not mistake it for an effort.
|
|
50
|
+
export const FLOOR_STREAM = 'floor';
|
|
51
|
+
export const FLOOR_KEY = 'floor';
|
|
52
|
+
// Deliberately NOT journal.ts's readStream, whose bare JSON.parse throws. This runs on
|
|
53
|
+
// EVERY verb now, so one unreadable line would brick the whole CLI — including `floor`
|
|
54
|
+
// itself, the one verb that could unstick it. readStream's strict parse stays right for
|
|
55
|
+
// callers that need the entry; a bound derived from stamps needs only the lines it can
|
|
56
|
+
// actually read, and skipping the rest is the honest reading of a damaged file.
|
|
57
|
+
function entries(root, stream) {
|
|
58
|
+
const path = join(root, journalRel(stream));
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
return [];
|
|
61
|
+
const out = [];
|
|
62
|
+
for (const raw of readFileSync(path, 'utf8').split('\n')) {
|
|
63
|
+
if (raw === '')
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
out.push(JSON.parse(raw));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// a line we cannot parse carries no decision and no stamp we can trust — the same
|
|
70
|
+
// answer as a line that never had one
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
package/dist/gate.js
CHANGED
|
@@ -14,7 +14,8 @@ import { kv, rows } from './toon.js';
|
|
|
14
14
|
import { loadMatrix, resolveModel, SESSION_DEFAULT } from './model.js';
|
|
15
15
|
import { docKeysFor, docsBlock, invokeReviewer, loadLensDocs, parseVerdictText, pinsBlock, promptsSha, resolvePrompt } from './reviewer.js';
|
|
16
16
|
import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
|
|
17
|
-
import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, roundsSinceApprove, runsSinceReset, } from './rounds.js';
|
|
17
|
+
import { ROUND_BOUND, appendKind, boundReached, fellBack, lastGateRun, liveExits, roundBudget, roundsSinceApprove, runsSinceReset, } from './rounds.js';
|
|
18
|
+
export { liveExits } from './rounds.js';
|
|
18
19
|
import { gateSettled } from './verbs/next.js';
|
|
19
20
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
20
21
|
const GATES = new Map();
|
|
@@ -40,27 +41,16 @@ export function batteryFor(cfg, gate, cls) {
|
|
|
40
41
|
}
|
|
41
42
|
return ok(picked);
|
|
42
43
|
}
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
export function
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// read — which is the same set decide's stale-verdict refusal names.
|
|
51
|
-
if (boundReached(entries, gate)) {
|
|
52
|
-
return stale
|
|
53
|
-
? `witness decide ${gate} ${target} --revise --upstream <id> | --stop`
|
|
54
|
-
: `witness decide ${gate} ${target} --approve --override | --revise --upstream <id> | --stop`;
|
|
55
|
-
}
|
|
56
|
-
if (stale)
|
|
57
|
-
return `witness gate ${gate} ${target}`;
|
|
58
|
-
return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream <id> | --stop`;
|
|
59
|
-
}
|
|
60
|
-
export function renderGateRun(ctx, entry, mode) {
|
|
44
|
+
// `entries` is the stream AS OF AFTER this entry: without it the renderer cannot tell the
|
|
45
|
+
// round that spends the budget from any other, and rows 109/110 are both about that round.
|
|
46
|
+
// `help: false` is for callers that render their own `exits:` line (`decide --show`).
|
|
47
|
+
export function renderGateRun(ctx, entry, mode, opts = {}) {
|
|
48
|
+
const entries = opts.entries ?? [];
|
|
49
|
+
const budget = opts.entries ? roundBudget(entries, entry.gate) : ROUND_BOUND;
|
|
50
|
+
const atBound = opts.entries !== undefined && boundReached(entries, entry.gate);
|
|
61
51
|
ctx.out(kv('gate', entry.gate));
|
|
62
52
|
ctx.out(kv('target', entry.artifact));
|
|
63
|
-
ctx.out(kv('round', `${entry.round} of ${
|
|
53
|
+
ctx.out(kv('round', `${entry.round} of ${budget}${mode === 'resume' ? ' (resume — content unchanged)' : ''}`));
|
|
64
54
|
ctx.out(kv('reviewed', entry.reviewed_sha.slice(0, 7)));
|
|
65
55
|
ctx.out(kv('model', `${entry.model} · calibration: ${entry.calibration}${entry.cached ? ' · cached' : ''}`));
|
|
66
56
|
if (entry.skipped?.length)
|
|
@@ -84,11 +74,20 @@ export function renderGateRun(ctx, entry, mode) {
|
|
|
84
74
|
ctx.out(kv('standing-stop', entry.standing));
|
|
85
75
|
ctx.out(kv('outcome', entry.outcome));
|
|
86
76
|
if (entry.outcome !== 'passed') {
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// the
|
|
91
|
-
|
|
77
|
+
// Row 110. The round that SPENDS the budget is the one that has to say so. It rendered
|
|
78
|
+
// like any other, so the natural next act — fix the finding, re-gate — was advertised
|
|
79
|
+
// by the help line below and then refused twice: the gate short-circuits, and the edit
|
|
80
|
+
// that answered the finding forfeits `--approve` under D75. Nothing warned, and the
|
|
81
|
+
// finding is itself an instruction to edit. Printed above the `help:` suppression, not
|
|
82
|
+
// inside it: `decide --show` renders its own exits and is the surface a human reads
|
|
83
|
+
// while deciding — the one place this warning is most owed.
|
|
84
|
+
if (atBound) {
|
|
85
|
+
ctx.out(kv('last-round', 'the round budget is spent — the gate will not run again, and an edit from here forfeits --approve (the verdict would describe an older tree)'));
|
|
86
|
+
}
|
|
87
|
+
// Without `entries` this stays a pure formatter over the entry it was handed, and the
|
|
88
|
+
// off-bound triple is the right answer; with them it tells the truth at the bound too.
|
|
89
|
+
if (opts.help !== false)
|
|
90
|
+
ctx.out(`help: ${liveExits(entry.gate, entry.artifact, entries, false)}`);
|
|
92
91
|
}
|
|
93
92
|
}
|
|
94
93
|
// Row 79: the human approving a plan sees the run's shape before it starts.
|
|
@@ -139,9 +138,9 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
139
138
|
renderRefusal(batteryR.violations).forEach((l) => ctx.err(l));
|
|
140
139
|
return EXIT.REFUSED;
|
|
141
140
|
}
|
|
142
|
-
const skip = new
|
|
141
|
+
const skip = new Map((input.skipLenses ?? []).map((s) => [s.lens, s.why]));
|
|
143
142
|
const active = batteryR.value.filter((n) => !skip.has(n));
|
|
144
|
-
const dropped = batteryR.value.filter((n) => skip.has(n));
|
|
143
|
+
const dropped = batteryR.value.filter((n) => skip.has(n)).map((n) => `${n} — ${skip.get(n)}`);
|
|
145
144
|
const entries = readStream(root, target);
|
|
146
145
|
const pins = policyPins(entries);
|
|
147
146
|
const pinsText = pinsBlock(pins);
|
|
@@ -216,7 +215,24 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
216
215
|
.forEach((l) => ctx.err(l));
|
|
217
216
|
return EXIT.REFUSED;
|
|
218
217
|
}
|
|
219
|
-
|
|
218
|
+
let kind = flags.fresh ? { kind: 'fresh' } : appendKind(entries, spec.gate, key);
|
|
219
|
+
// Row 112. The cache key is the REVIEWED content digest, but the deterministic checks
|
|
220
|
+
// read inputs it cannot see — decompose's `goal-coverage`/`spec-coverage` read journaled
|
|
221
|
+
// `covers`, `amendment-ack` reads a sibling plan's pin, `graph` reads the whole canon. So
|
|
222
|
+
// correcting a goal mapping left the doc bytes untouched, the key unmoved, and `resume`
|
|
223
|
+
// served a blocking finding describing a state that no longer existed; `--fresh` was the
|
|
224
|
+
// only escape, and an operator who did not think of it spent a human decision on it.
|
|
225
|
+
// `resolve` has already recomputed the checks by this point — the fresh answer was in
|
|
226
|
+
// hand and thrown away. A flip is a new judgment, so it appends a real round rather than
|
|
227
|
+
// silently re-rendering; the verdicts are REPLAYED (`cached`), so no battery is spent.
|
|
228
|
+
// Compared on `ok` alone: a detail carries run output on some gates (timings, paths), and
|
|
229
|
+
// keying on it would append a round for every re-run of an unchanged tree.
|
|
230
|
+
const checkOk = (cs) => cs.map((c) => `${c.name}:${c.ok}`).sort().join('|');
|
|
231
|
+
if ((kind.kind === 'resume' || kind.kind === 'changed-nothing') &&
|
|
232
|
+
checkOk(kind.entry.checks) !== checkOk(input.checks) &&
|
|
233
|
+
kind.entry.outcome !== 'malformed' && (kind.entry.verdicts?.length ?? 0) > 0) {
|
|
234
|
+
kind = { kind: 'cached', from: kind.entry };
|
|
235
|
+
}
|
|
220
236
|
// A key that moved for a NON-content reason — edited prompt, re-pinned model, new
|
|
221
237
|
// witness version — un-settles just as quietly as --fresh did. Content moving explains
|
|
222
238
|
// itself; this does not.
|
|
@@ -225,7 +241,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
225
241
|
ctx.err(`warning: reviewer setup changed — this run discards the settled approve on ${spec.gate} ${target}`);
|
|
226
242
|
}
|
|
227
243
|
if (kind.kind === 'resume') {
|
|
228
|
-
renderGateRun(ctx, kind.entry, 'resume');
|
|
244
|
+
renderGateRun(ctx, kind.entry, 'resume', { entries });
|
|
229
245
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
230
246
|
return kind.entry.outcome === 'passed' ? EXIT.OK : EXIT.FINDINGS;
|
|
231
247
|
}
|
|
@@ -428,7 +444,7 @@ export async function runGate(ctx, gateName, target, flags) {
|
|
|
428
444
|
renderRefusal(txn.violations).forEach((l) => ctx.err(l));
|
|
429
445
|
return EXIT.REFUSED;
|
|
430
446
|
}
|
|
431
|
-
renderGateRun(ctx, entry, 'ran');
|
|
447
|
+
renderGateRun(ctx, entry, 'ran', { entries: [...entriesNow, entry] });
|
|
432
448
|
printDispatchArithmetic(ctx, root, spec.gate, target);
|
|
433
449
|
if (input.repin)
|
|
434
450
|
ctx.out(kv('re-pinned', `derives-from → ${input.repin.sha.slice(0, 7)} (witnessed by the drift lane)`));
|
package/dist/gates/implement.js
CHANGED
|
@@ -104,7 +104,7 @@ registerGate({
|
|
|
104
104
|
}
|
|
105
105
|
else {
|
|
106
106
|
// not UI-work: drop the lens if a battery names it (stages shrink, never skip)
|
|
107
|
-
skipLenses.push('design-reviewer');
|
|
107
|
+
skipLenses.push({ lens: 'design-reviewer', why: 'no design-from pin: not UI work' });
|
|
108
108
|
}
|
|
109
109
|
const checks = [];
|
|
110
110
|
checks.push({ name: 'diff', ok: files.length > 0,
|
package/dist/install.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname, join } from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { version } from './cli.js';
|
|
@@ -114,6 +114,45 @@ export function installPayload(root, harness) {
|
|
|
114
114
|
}
|
|
115
115
|
return ok(out);
|
|
116
116
|
}
|
|
117
|
+
// Row 117. "Upgrade the repository" has always meant "upgrade one checkout of it":
|
|
118
|
+
// installPayload had exactly one call site and it targeted the primary root, so nothing
|
|
119
|
+
// ever refreshed a live worktree. A worktree is a branch checkout, so its payload is a
|
|
120
|
+
// different file on a different timeline — the same property row 87 relies on to get the
|
|
121
|
+
// payload to the agent at all, read the other way round.
|
|
122
|
+
//
|
|
123
|
+
// Root first, then worktrees in sorted order: the root is the home the human is standing
|
|
124
|
+
// in, and if a later home fails they need the report to read top-down.
|
|
125
|
+
export function payloadHomes(root) {
|
|
126
|
+
const dir = join(root, '.witness', 'worktrees');
|
|
127
|
+
if (!existsSync(dir))
|
|
128
|
+
return [root];
|
|
129
|
+
return [root, ...readdirSync(dir, { withFileTypes: true })
|
|
130
|
+
.filter((e) => e.isDirectory())
|
|
131
|
+
.map((e) => join(dir, e.name))
|
|
132
|
+
.sort()];
|
|
133
|
+
}
|
|
134
|
+
// Preflight EVERY home before writing ANY. install.ts's standing doctrine is that a
|
|
135
|
+
// refusal leaves nothing half-installed; across homes that matters more, not less — a
|
|
136
|
+
// half-upgraded set of homes IS the skew row 116 refuses and this row prevents.
|
|
137
|
+
export function installAllHomes(root, harness) {
|
|
138
|
+
const homes = payloadHomes(root);
|
|
139
|
+
const failures = homes.flatMap((home) => {
|
|
140
|
+
const pre = preflightPayload(home, harness);
|
|
141
|
+
// Name the home in the field, or a `payload-dirty` row over `.claude/commands/witness.md`
|
|
142
|
+
// is ambiguous across four checkouts of the same repository.
|
|
143
|
+
return pre.ok ? [] : pre.violations.map((x) => ({ ...x, field: home === root ? x.field : `${home}: ${x.field}` }));
|
|
144
|
+
});
|
|
145
|
+
if (failures.length > 0)
|
|
146
|
+
return refuse(failures);
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const home of homes) {
|
|
149
|
+
const synced = installPayload(home, harness);
|
|
150
|
+
if (!synced.ok)
|
|
151
|
+
return refuse(synced.violations);
|
|
152
|
+
out.push({ home, synced: synced.value });
|
|
153
|
+
}
|
|
154
|
+
return ok(out);
|
|
155
|
+
}
|
|
117
156
|
function hasCommand(entries, command) {
|
|
118
157
|
return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => h.command === command));
|
|
119
158
|
}
|
package/dist/journal.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { version } from './version.js';
|
|
3
4
|
const dir = (root) => join(root, '.witness', 'journal');
|
|
4
5
|
export const journalRel = (id) => `.witness/journal/${id}.jsonl`;
|
|
5
6
|
export function streamExists(root, id) {
|
|
6
7
|
return existsSync(join(root, journalRel(id)));
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
+
// Row 116. `w` is the CLI that wrote this line. Stamped HERE because entryLine is the
|
|
10
|
+
// single funnel every state write passes through, so one edit covers fourteen entry types
|
|
11
|
+
// and every verb without a widened signature to disagree over. It is what lets the state
|
|
12
|
+
// name its own floor (floor.ts) instead of a stored number that drifts from the entries it
|
|
13
|
+
// summarises — and it is why a repo can refuse a CLI older than its own history without
|
|
14
|
+
// consulting any payload file, which is the guard row 102 structurally could not be.
|
|
15
|
+
export const entryLine = (entry) => JSON.stringify({ v: 1, w: version(), ...entry });
|
|
9
16
|
export function appendEntry(root, id, entry) {
|
|
10
17
|
mkdirSync(dir(root), { recursive: true });
|
|
11
18
|
const line = entryLine(entry);
|
package/dist/rounds.js
CHANGED
|
@@ -66,8 +66,51 @@ export function roundsSinceApprove(entries, gate) {
|
|
|
66
66
|
return runsSinceReset(entries, gate)
|
|
67
67
|
.filter((r) => r.outcome !== 'malformed' && !fellBack(r)).length;
|
|
68
68
|
}
|
|
69
|
+
// Row 109. The final round's blocking findings can only be VERIFIED by another round, so
|
|
70
|
+
// fixing one at the bound forfeits `--approve` (D75 puts staleness at consumption) and the
|
|
71
|
+
// only exits left are a full upstream re-cycle or a stop. A repair grant buys exactly one
|
|
72
|
+
// more round. It is read from the journal window rather than counted anywhere: the window
|
|
73
|
+
// closes on every reset `lastResetIndex` already knows about, so an approve, a
|
|
74
|
+
// revise-upstream or a passed run refreshes the grant for the next game and nothing else
|
|
75
|
+
// does. A second grant inside one window is therefore a no-op by construction, not by a
|
|
76
|
+
// rule someone has to remember to write.
|
|
77
|
+
export function repairGranted(entries, gate) {
|
|
78
|
+
return entries.slice(lastResetIndex(entries, gate) + 1).some((e) => isDecision(e, gate) && e.repair === true);
|
|
79
|
+
}
|
|
80
|
+
export function roundBudget(entries, gate) {
|
|
81
|
+
return ROUND_BOUND + (repairGranted(entries, gate) ? 1 : 0);
|
|
82
|
+
}
|
|
69
83
|
export function boundReached(entries, gate) {
|
|
70
|
-
return roundsSinceApprove(entries, gate) >=
|
|
84
|
+
return roundsSinceApprove(entries, gate) >= roundBudget(entries, gate);
|
|
85
|
+
}
|
|
86
|
+
// Which decisions are legal RIGHT NOW is a pure function of journal state, so it lives
|
|
87
|
+
// where the state is — it was in `gate.ts`, which `next.ts` cannot import back (gate.ts
|
|
88
|
+
// already imports next.ts), and that one-way edge is precisely why `next` grew three
|
|
89
|
+
// hand-copied bound triples that then went stale twice over: they miss the repair grant
|
|
90
|
+
// (109), and a fixed set is what this function was written to abolish. Skills used to
|
|
91
|
+
// recite the same triple, which is wrong at the bound (D67's endgame set) and now wrong
|
|
92
|
+
// in three more states.
|
|
93
|
+
// `upstream` is the id the caller already resolved (next knows the owning effort; the gate
|
|
94
|
+
// and decide surfaces do not) — the placeholder is a fallback, never a downgrade of a line
|
|
95
|
+
// that used to name the real target.
|
|
96
|
+
export function liveExits(gate, target, entries, stale, upstream = '<id>') {
|
|
97
|
+
// The bound outranks staleness: at the bound the gate short-circuits and will not run
|
|
98
|
+
// again (gate.ts), so "re-gate" is the D67 lie whatever the sha says. Stale content only
|
|
99
|
+
// removes APPROVE from the endgame — a human cannot honestly stamp bytes no battery
|
|
100
|
+
// read — which is the same set decide's stale-verdict refusal names.
|
|
101
|
+
if (boundReached(entries, gate)) {
|
|
102
|
+
// Row 109: the repair grant is an exit exactly while it is unspent, and it is the one
|
|
103
|
+
// the human standing at the bound with a fixed finding actually wants — naming it
|
|
104
|
+
// beside `--upstream` is what stops "fix it and re-gate" from being a move the tool
|
|
105
|
+
// advertises nowhere and refuses twice.
|
|
106
|
+
const repair = repairGranted(entries, gate) ? '' : ' | --revise --repair';
|
|
107
|
+
return stale
|
|
108
|
+
? `witness decide ${gate} ${target} --revise --upstream ${upstream}${repair} | --stop`
|
|
109
|
+
: `witness decide ${gate} ${target} --approve --override | --revise --upstream ${upstream}${repair} | --stop`;
|
|
110
|
+
}
|
|
111
|
+
if (stale)
|
|
112
|
+
return `witness gate ${gate} ${target}`;
|
|
113
|
+
return `witness decide ${gate} ${target} --approve | --revise --note "<why>" | --revise --upstream ${upstream} | --stop`;
|
|
71
114
|
}
|
|
72
115
|
export function pendingDecision(entries, gate) {
|
|
73
116
|
for (let i = entries.length - 1; i >= 0; i--) {
|
package/dist/verbs/check.js
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { adoptedCommits } from '../adopt.js';
|
|
4
4
|
import { EXIT, version } from '../cli.js';
|
|
5
5
|
import { DEFAULT_HARNESS, HARNESSES, judgeLine, loadHarness, resolveJudge, resolveSkills, skillPins } from '../harness.js';
|
|
6
|
-
import { packageRoot } from '../install.js';
|
|
6
|
+
import { packageRoot, payloadHomes } from '../install.js';
|
|
7
7
|
import { latestPublished } from '../registry.js';
|
|
8
8
|
import { NPX_LATEST, compareTriple } from '../version.js';
|
|
9
9
|
import { probe } from '../probe.js';
|
|
@@ -254,14 +254,24 @@ export async function run(ctx, argv = []) {
|
|
|
254
254
|
// whole suite stays green, because vitest reads from the repo root. installPayload
|
|
255
255
|
// is where that condition refuses (`source-missing`); a diagnostic verb must not
|
|
256
256
|
// crash on it, and must not report the repo as stale for it either.
|
|
257
|
-
|
|
257
|
+
//
|
|
258
|
+
// Row 118: per HOME, not per repository. A worktree is a branch checkout, so its
|
|
259
|
+
// payload is a different file that can be a different age — and that is exactly the
|
|
260
|
+
// state row 116's floor cannot see, because the floor binds only CLIs at or after
|
|
261
|
+
// the version that introduced it while the frozen homes were cut before it. The
|
|
262
|
+
// root keeps its bare relative path; a worktree is named, or four checkouts of one
|
|
263
|
+
// repository all report `.claude/commands/witness.md` and the human cannot tell
|
|
264
|
+
// which of them is the rotten one.
|
|
265
|
+
const stale = payloadHomes(root).flatMap((home) => installed
|
|
266
|
+
.filter((p) => {
|
|
258
267
|
const src = join(packageRoot(), p.from);
|
|
259
|
-
if (!existsSync(src))
|
|
268
|
+
if (!existsSync(src) || !existsSync(join(home, p.to)))
|
|
260
269
|
return false;
|
|
261
|
-
return readFileSync(join(
|
|
262
|
-
})
|
|
270
|
+
return readFileSync(join(home, p.to), 'utf8') !== readFileSync(src, 'utf8');
|
|
271
|
+
})
|
|
272
|
+
.map((p) => (home === root ? p.to : `${home}: ${p.to}`)));
|
|
263
273
|
if (stale.length > 0) {
|
|
264
|
-
findings.push(f('warn', 'harness', `${name}: payload`, 'payload-stale', `${stale.
|
|
274
|
+
findings.push(f('warn', 'harness', `${name}: payload`, 'payload-stale', `${stale.join(' · ')} differ from what ${version()} ships — run ${NPX_LATEST} init --agent ${name}`));
|
|
265
275
|
}
|
|
266
276
|
}
|
|
267
277
|
const skills = resolveSkills(ctx.env, root, harness);
|
package/dist/verbs/decide.js
CHANGED
|
@@ -15,7 +15,7 @@ import { newRunId } from '../drift.js';
|
|
|
15
15
|
import { renderRefusal, v } from '../refusal.js';
|
|
16
16
|
import { short } from '../sha.js';
|
|
17
17
|
import { kv, rows } from '../toon.js';
|
|
18
|
-
import { boundReached, lastGateRun, openReopen, pendingDecision, roundsSinceApprove, } from '../rounds.js';
|
|
18
|
+
import { ROUND_BOUND, boundReached, lastGateRun, openReopen, pendingDecision, repairGranted, roundsSinceApprove, } from '../rounds.js';
|
|
19
19
|
import { prepareStamp, writeStamp } from '../stamp.js';
|
|
20
20
|
const asEntry = (e) => e;
|
|
21
21
|
function flagValue(argv, flag) {
|
|
@@ -47,7 +47,7 @@ export async function run(ctx, argv) {
|
|
|
47
47
|
const [gate, target] = positional;
|
|
48
48
|
const spec = gate ? gateSpec(gate) : undefined;
|
|
49
49
|
if (!gate || !target || !spec) {
|
|
50
|
-
ctx.err('usage: witness decide <gate> <target> --approve|--revise|--stop [--override] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]');
|
|
50
|
+
ctx.err('usage: witness decide <gate> <target> --approve|--revise|--stop [--override] [--repair] [--note <t>] [--upstream <artifact|effort>] [--pin <policy>]… [--show]');
|
|
51
51
|
return EXIT.REFUSED;
|
|
52
52
|
}
|
|
53
53
|
const rootR = primaryRoot(ctx.cwd);
|
|
@@ -113,7 +113,7 @@ export async function run(ctx, argv) {
|
|
|
113
113
|
return EXIT.OK;
|
|
114
114
|
}
|
|
115
115
|
// pending (no disposition) or revise (the author's input): the verdict is actionable
|
|
116
|
-
renderGateRun(ctx, last, 'ran');
|
|
116
|
+
renderGateRun(ctx, last, 'ran', { entries, help: false });
|
|
117
117
|
const shownPins = policyPins(entries);
|
|
118
118
|
if (shownPins.length > 0)
|
|
119
119
|
ctx.out(rows('pins', ['ordinal', 'text'], shownPins).join('\n'));
|
|
@@ -134,6 +134,7 @@ export async function run(ctx, argv) {
|
|
|
134
134
|
const decision = picked[0].slice(2);
|
|
135
135
|
const note = flagValue(argv, '--note');
|
|
136
136
|
const override = argv.includes('--override');
|
|
137
|
+
const repair = argv.includes('--repair');
|
|
137
138
|
const upstream = flagValue(argv, '--upstream');
|
|
138
139
|
const pinTexts = flagValues(argv, '--pin');
|
|
139
140
|
if (pinTexts.length > 0 && gate !== 'implement') {
|
|
@@ -146,11 +147,30 @@ export async function run(ctx, argv) {
|
|
|
146
147
|
return EXIT.REFUSED;
|
|
147
148
|
}
|
|
148
149
|
const atBound = boundReached(entries, gate);
|
|
150
|
+
// Row 109. The grant is a REVISE — the human is sending the artifact back, they are just
|
|
151
|
+
// paying for the round that verifies the fix. Refusing the other spellings is not
|
|
152
|
+
// pedantry: `--approve --repair` reads as "approve and let it re-run", which is not a
|
|
153
|
+
// state this machine has, and silently ignoring the flag would journal an approve the
|
|
154
|
+
// human believed was something else.
|
|
155
|
+
if (repair && decision !== 'revise') {
|
|
156
|
+
renderRefusal([v('repair', 'repair-scope', `--${decision}`, 'witness decide … --revise --repair — a repair grant sends the artifact back for one verified round')])
|
|
157
|
+
.forEach((l) => ctx.err(l));
|
|
158
|
+
return EXIT.REFUSED;
|
|
159
|
+
}
|
|
160
|
+
if (repair && !atBound) {
|
|
161
|
+
renderRefusal([v('repair', 'repair-not-at-bound', `${roundsSinceApprove(entries, gate)} of ${ROUND_BOUND} rounds spent`, `the round bound — below it a plain revise already re-gates: witness decide ${gate} ${target} --revise --note "<why>"`)])
|
|
162
|
+
.forEach((l) => ctx.err(l));
|
|
163
|
+
return EXIT.REFUSED;
|
|
164
|
+
}
|
|
165
|
+
if (repair && repairGranted(entries, gate)) {
|
|
166
|
+
renderRefusal([v('repair', 'repair-spent', 'one repair round was already granted in this budget window', liveExits(gate, target, entries, false))]).forEach((l) => ctx.err(l));
|
|
167
|
+
return EXIT.REFUSED;
|
|
168
|
+
}
|
|
149
169
|
// at the bound the gate refuses to run again, so no fresh pending decision can
|
|
150
170
|
// ever exist — the endgame decisions must stay reachable anchored to the last
|
|
151
171
|
// run, or the target livelocks (incident c2692b93)
|
|
152
172
|
const boundEndgame = atBound && (decision === 'stop' || (decision === 'approve' && override) ||
|
|
153
|
-
(decision === 'revise' && upstream !== undefined));
|
|
173
|
+
(decision === 'revise' && (upstream !== undefined || repair)));
|
|
154
174
|
// D94: a revise is the author's INPUT, not a disposition — it leaves the run
|
|
155
175
|
// undisposed, and with the content unchanged `gate` answers `changed-nothing` and
|
|
156
176
|
// appends nothing, so a pending decision can never reappear. Every exit the human is
|
|
@@ -186,44 +206,50 @@ export async function run(ctx, argv) {
|
|
|
186
206
|
const blockedCode = guardTxn(ctx, root);
|
|
187
207
|
if (blockedCode !== undefined)
|
|
188
208
|
return blockedCode;
|
|
189
|
-
if (decision === 'revise' && atBound && upstream === undefined) {
|
|
190
|
-
return renderBound(ctx, gate, target, entries, last !== undefined && nowSha !== undefined && nowSha !== last.reviewed_sha, 'upstream reopens the parent and resets the budget');
|
|
191
|
-
}
|
|
192
|
-
if (decision === 'approve' && atBound && !override) {
|
|
193
|
-
renderRefusal([v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override')]).forEach((l) => ctx.err(l));
|
|
194
|
-
return EXIT.REFUSED;
|
|
209
|
+
if (decision === 'revise' && atBound && upstream === undefined && !repair) {
|
|
210
|
+
return renderBound(ctx, gate, target, entries, last !== undefined && nowSha !== undefined && nowSha !== last.reviewed_sha, 'upstream reopens the parent and resets the budget; --repair buys one more round here');
|
|
195
211
|
}
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
if (gate === 'design' && decision === 'approve') {
|
|
202
|
-
const unseen = designUnseen(root, cfgR.value.paths, target);
|
|
203
|
-
if (unseen !== undefined) {
|
|
204
|
-
renderRefusal([v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${target} --open`)]).forEach((l) => ctx.err(l));
|
|
205
|
-
return EXIT.REFUSED;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
|
|
209
|
-
// tree does afterward, which is why D76 declined a write-time staleness check. A stamp
|
|
210
|
-
// is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
|
|
211
|
-
// puts staleness checks at consumption. approveMeta reads the artifact from disk and
|
|
212
|
-
// never consults the run, so approving after a re-author blessed unreviewed bytes.
|
|
213
|
-
// `currentSha` is undefined when it cannot be computed (no worktree, missing parent):
|
|
214
|
-
// approve then proceeds, and the entry still honestly records what it judged — this
|
|
215
|
-
// check must never convert an unrelated condition into a misleading refusal.
|
|
212
|
+
// Row 111. Every reason an approve cannot happen, in ONE refusal. These used to fire
|
|
213
|
+
// sequentially: `--approve` reported `override-required`, and only the re-run carrying
|
|
214
|
+
// `--override` reported the `stale-verdict` that was the actual blocker — the operator
|
|
215
|
+
// learned the real state one refusal later than the tool knew it, and at the bound that
|
|
216
|
+
// costs a round of thinking on an exit that was never available.
|
|
216
217
|
if (decision === 'approve') {
|
|
218
|
+
const blockers = [];
|
|
219
|
+
if (atBound && !override) {
|
|
220
|
+
blockers.push(v('decision', 'override-required', 'approve at the round bound', 'witness decide … --approve --override'));
|
|
221
|
+
}
|
|
222
|
+
// A standing stop is only human judgment if the human was shown the thing. The gate
|
|
223
|
+
// refuses the same way, but the gate does not run at the round bound (gate.ts:176) —
|
|
224
|
+
// and approve is the act that stamps, so the line has to hold here too. Sha-keyed:
|
|
225
|
+
// re-authoring invalidates prior sight, as re-capturing invalidates a witnessed
|
|
226
|
+
// screenshot (D71). Only approve — revise and stop need no sight to be honest.
|
|
227
|
+
if (gate === 'design') {
|
|
228
|
+
const unseen = designUnseen(root, cfgR.value.paths, target);
|
|
229
|
+
if (unseen !== undefined) {
|
|
230
|
+
blockers.push(v('design', 'design-unseen', `no sight witnessed for ${short(unseen)}`, `a human shown this artifact — run: witness design ${target} --open`));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// A `human-decision` entry is a RECORD — honest about the sha it judged whatever the
|
|
234
|
+
// tree does afterward, which is why D76 declined a write-time staleness check. A stamp
|
|
235
|
+
// is an ASSERTION about current content (`status: approved`, `design: {sha}`), and D75
|
|
236
|
+
// puts staleness checks at consumption. approveMeta reads the artifact from disk and
|
|
237
|
+
// never consults the run, so approving after a re-author blessed unreviewed bytes.
|
|
238
|
+
// `currentSha` is undefined when it cannot be computed (no worktree, missing parent):
|
|
239
|
+
// approve then proceeds, and the entry still honestly records what it judged — this
|
|
240
|
+
// check must never convert an unrelated condition into a misleading refusal.
|
|
217
241
|
const now = spec.currentSha?.(root, canon, cfgR.value, target);
|
|
218
242
|
if (now !== undefined && now !== anchor.reviewed_sha) {
|
|
219
243
|
// At the bound the gate will not re-run (gate.ts:176), so "go re-gate" would be
|
|
220
|
-
// the lie D67 was written about.
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
// the lie D67 was written about. `liveExits` names the ones that actually work —
|
|
245
|
+
// including the repair grant while it is unspent, which is the exit an operator who
|
|
246
|
+
// just fixed the finding is looking for.
|
|
247
|
+
blockers.push(v('gate', 'stale-verdict', `verdict @${short(anchor.reviewed_sha)}, content @${short(now)}`, atBound
|
|
248
|
+
? `${liveExits(gate, target, entries, true)} (bound reached — the gate will not re-run as it stands)`
|
|
249
|
+
: `a verdict describing current content — run: witness gate ${gate} ${target}`));
|
|
250
|
+
}
|
|
251
|
+
if (blockers.length > 0) {
|
|
252
|
+
renderRefusal(blockers).forEach((l) => ctx.err(l));
|
|
227
253
|
return EXIT.REFUSED;
|
|
228
254
|
}
|
|
229
255
|
}
|
|
@@ -231,6 +257,7 @@ export async function run(ctx, argv) {
|
|
|
231
257
|
v: 1, t: 'human-decision', gate, artifact: target, round: anchor.round,
|
|
232
258
|
decision: decision === 'revise' && upstream ? 'revise-upstream' : decision,
|
|
233
259
|
...(override ? { override: true } : {}),
|
|
260
|
+
...(repair ? { repair: true } : {}),
|
|
234
261
|
...(note ? { note } : {}),
|
|
235
262
|
};
|
|
236
263
|
const priorPins = entries.filter((e) => e.t === 'policy-pin').length;
|
|
@@ -364,9 +391,15 @@ export async function run(ctx, argv) {
|
|
|
364
391
|
ctx.out(kv('decided', `${gate} ${target} → ${entry.decision}${override ? ' (override)' : ''}`));
|
|
365
392
|
for (const p of pinEntries)
|
|
366
393
|
ctx.out(kv('pinned', `#${p.ordinal} ${p.text}`));
|
|
394
|
+
if (repair) {
|
|
395
|
+
// What was bought, and that it does not come again — a grant the human forgets is
|
|
396
|
+
// spent is a second walk into the same wall.
|
|
397
|
+
ctx.out(kv('repair', `one extra round granted — round ${ROUND_BOUND + 1} of ${ROUND_BOUND + 1} is the last, and the grant does not refresh until an approve, a revise-upstream or a passed run`));
|
|
398
|
+
ctx.out(`help: fix the finding, then re-run: witness gate ${gate} ${target}`);
|
|
399
|
+
}
|
|
367
400
|
if (entry.decision === 'revise' || entry.decision === 'revise-upstream') {
|
|
368
401
|
ctx.out('revise-context: (reconstructed from the journal — survives session death)');
|
|
369
|
-
renderGateRun(ctx, anchor, 'ran');
|
|
402
|
+
renderGateRun(ctx, anchor, 'ran', { entries, help: false });
|
|
370
403
|
const pins = policyPins(readStream(root, target));
|
|
371
404
|
if (pins.length > 0)
|
|
372
405
|
ctx.out(rows('pins', ['ordinal', 'text'], pins).join('\n'));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { EXIT } from '../cli.js';
|
|
3
|
+
import { FLOOR_KEY, FLOOR_STREAM, stateFloor } from '../floor.js';
|
|
4
|
+
import { primaryRoot } from '../gitio.js';
|
|
5
|
+
import { appendEntry } from '../journal.js';
|
|
6
|
+
import { renderRefusal, v } from '../refusal.js';
|
|
7
|
+
import { kv } from '../toon.js';
|
|
8
|
+
import { compareTriple, version } from '../version.js';
|
|
9
|
+
// Row 116's safety valve. The floor is derived from what the state has seen, which is
|
|
10
|
+
// correct until a published version turns out to be broken: then every repository that ran
|
|
11
|
+
// it once carries a bound no fixed CLI can satisfy, and the tool has locked its users out
|
|
12
|
+
// over its own defect. Lowering it is a human act with a reason, so it is journaled as a
|
|
13
|
+
// policy-pin — the entry type row 83 added for exactly this shape — and never a flag on
|
|
14
|
+
// another verb or a file someone can edit without leaving a trace.
|
|
15
|
+
export async function run(ctx, argv) {
|
|
16
|
+
const { values } = parseArgs({
|
|
17
|
+
args: argv,
|
|
18
|
+
options: { show: { type: 'boolean' }, set: { type: 'string' }, note: { type: 'string' } },
|
|
19
|
+
});
|
|
20
|
+
const rootRes = primaryRoot(ctx.cwd);
|
|
21
|
+
if (!rootRes.ok) {
|
|
22
|
+
renderRefusal(rootRes.violations).forEach((l) => ctx.err(l));
|
|
23
|
+
return EXIT.REFUSED;
|
|
24
|
+
}
|
|
25
|
+
const root = rootRes.value;
|
|
26
|
+
if (values.set !== undefined) {
|
|
27
|
+
// Both faults in one refusal, the way row 111 collects an approve's: a human who fixes
|
|
28
|
+
// one and learns about the other on the re-run has spent a turn witness could have saved.
|
|
29
|
+
const violations = [];
|
|
30
|
+
if (compareTriple(values.set, values.set) === undefined) {
|
|
31
|
+
violations.push(v('--set', 'bad-pin', values.set, 'a numeric triple such as 0.9.0 — prerelease ordering is out of scope (row 102)'));
|
|
32
|
+
}
|
|
33
|
+
if (values.note === undefined || values.note.trim() === '') {
|
|
34
|
+
violations.push(v('--note', 'note-required', 'absent', 'a reason — lowering the bound is a decision the journal has to be able to explain later'));
|
|
35
|
+
}
|
|
36
|
+
if (violations.length > 0) {
|
|
37
|
+
renderRefusal(violations).forEach((l) => ctx.err(l));
|
|
38
|
+
return EXIT.REFUSED;
|
|
39
|
+
}
|
|
40
|
+
appendEntry(root, FLOOR_STREAM, { t: 'policy-pin', key: FLOOR_KEY, pin: values.set, note: values.note });
|
|
41
|
+
ctx.out(kv('floor', `${values.set} — set by hand: ${values.note}`));
|
|
42
|
+
return EXIT.OK;
|
|
43
|
+
}
|
|
44
|
+
const floor = stateFloor(root);
|
|
45
|
+
ctx.out(floor === undefined
|
|
46
|
+
? kv('floor', 'none — this state predates the writer stamp, so it claims no bound')
|
|
47
|
+
: kv('floor', `${floor.pin} · from ${floor.stream} · this CLI is ${version()}`));
|
|
48
|
+
return EXIT.OK;
|
|
49
|
+
}
|
package/dist/verbs/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import { configPath } from '../config.js';
|
|
|
6
6
|
import { writeDoc } from '../fm.js';
|
|
7
7
|
import { commitWithTrailer, primaryRoot, tryGit } from '../gitio.js';
|
|
8
8
|
import { loadHarness, resolveDriver } from '../harness.js';
|
|
9
|
-
import {
|
|
9
|
+
import { installAllHomes, mergeSettings, preflightPayload, readIfExists, recordHarness, writeSettings, } from '../install.js';
|
|
10
10
|
import { probe } from '../probe.js';
|
|
11
11
|
import { acquireLock } from '../lock.js';
|
|
12
12
|
import { renderRefusal, v } from '../refusal.js';
|
|
@@ -119,13 +119,30 @@ export async function run(ctx, argv = []) {
|
|
|
119
119
|
}
|
|
120
120
|
let synced;
|
|
121
121
|
if (harness !== undefined) {
|
|
122
|
-
const payload =
|
|
122
|
+
const payload = installAllHomes(root, harness);
|
|
123
123
|
if (!payload.ok) {
|
|
124
124
|
renderRefusal(payload.violations).forEach(ctx.err);
|
|
125
125
|
return EXIT.REFUSED;
|
|
126
126
|
}
|
|
127
|
-
synced = payload.value;
|
|
127
|
+
synced = payload.value.find((p) => p.home === root)?.synced ?? { written: [], overwritten: [] };
|
|
128
128
|
files.push(...synced.written, ...synced.overwritten);
|
|
129
|
+
// Row 117. Each worktree is a separate checkout on a separate branch, so its payload
|
|
130
|
+
// can only be committed there — the root's commit below cannot reach it.
|
|
131
|
+
// commitWithTrailer, not a hand-rolled `commit --only`: `--only` resolves its
|
|
132
|
+
// pathspec against the index, so a freshly written payload the worktree has never
|
|
133
|
+
// tracked is "did not match any file(s) known to git" until the add it already does.
|
|
134
|
+
// It also carries the state-commit trailer and treats a no-op as silent success.
|
|
135
|
+
for (const { home, synced: s } of payload.value.filter((p) => p.home !== root)) {
|
|
136
|
+
const touched = [...s.written, ...s.overwritten];
|
|
137
|
+
if (touched.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
const committed = commitWithTrailer(home, touched, `init(${harness.name}): agent payloads`);
|
|
140
|
+
if (!committed.ok) {
|
|
141
|
+
renderRefusal(committed.violations).forEach(ctx.err);
|
|
142
|
+
return EXIT.REFUSED;
|
|
143
|
+
}
|
|
144
|
+
ctx.out(kv('payload-synced', `${home} — ${touched.join(' · ')}`));
|
|
145
|
+
}
|
|
129
146
|
if (harness.settings !== undefined) {
|
|
130
147
|
const merged = mergeSettings(readIfExists(root, harness.settings));
|
|
131
148
|
if (merged.changed) {
|
package/dist/verbs/next.js
CHANGED
|
@@ -15,7 +15,7 @@ import { worktreeFlow, worktreePath } from '../worktree.js';
|
|
|
15
15
|
import { lazyStamp } from '../stamp.js';
|
|
16
16
|
import { ok, refuse, renderRefusal, v } from '../refusal.js';
|
|
17
17
|
import { kv, rows } from '../toon.js';
|
|
18
|
-
import { ROUND_BOUND, boundReached, lastGateRun, openReopen, pendingDecision } from '../rounds.js';
|
|
18
|
+
import { ROUND_BOUND, boundReached, lastGateRun, liveExits, openReopen, pendingDecision } from '../rounds.js';
|
|
19
19
|
// A gate is settled only if the verdict that settled it still describes the CURRENT
|
|
20
20
|
// content. `reviewed_sha` is the tree we actually judged; when the caller can compute
|
|
21
21
|
// what that sha is now and the two differ, the approval has lapsed and the gate re-arms.
|
|
@@ -341,7 +341,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
341
341
|
for (const e of efforts) {
|
|
342
342
|
if (boundReached(e.entries, 'decompose') && !gateSettled(e.entries, 'decompose')) {
|
|
343
343
|
return {
|
|
344
|
-
line:
|
|
344
|
+
line: liveExits('decompose', e.slug, e.entries, false, e.slug),
|
|
345
345
|
target: e.slug, note: 'round bound reached — human decision required',
|
|
346
346
|
};
|
|
347
347
|
}
|
|
@@ -353,7 +353,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
353
353
|
if (boundReached(entries, gate) && !gateSettled(entries, gate)) {
|
|
354
354
|
const up = gate === 'plan' ? String(plan.meta.parent) : id;
|
|
355
355
|
return {
|
|
356
|
-
line:
|
|
356
|
+
line: liveExits(gate, id, entries, false, up),
|
|
357
357
|
target: id, note: 'round bound reached — human decision required',
|
|
358
358
|
};
|
|
359
359
|
}
|
|
@@ -365,7 +365,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
365
365
|
if (boundReached(entries, 'design') && !gateSettled(entries, 'design')) {
|
|
366
366
|
const eff = effortOf(root, id);
|
|
367
367
|
return {
|
|
368
|
-
line:
|
|
368
|
+
line: liveExits('design', id, entries, false, eff ?? '<effort>'),
|
|
369
369
|
target: id, note: 'round bound reached — human decision required',
|
|
370
370
|
};
|
|
371
371
|
}
|
package/dist/verbs/recap.js
CHANGED
|
@@ -5,7 +5,7 @@ import { EXIT } from '../cli.js';
|
|
|
5
5
|
import { loadConfig } from '../config.js';
|
|
6
6
|
import { ID_RE } from '../dsl.js';
|
|
7
7
|
import { primaryRoot, stateCommit } from '../gitio.js';
|
|
8
|
-
import { appendEntry, effortAbandoned, journalRel, readStream, streamExists } from '../journal.js';
|
|
8
|
+
import { appendEntry, effortAbandoned, entryLine, journalRel, readStream, streamExists } from '../journal.js';
|
|
9
9
|
import { acquireLock } from '../lock.js';
|
|
10
10
|
import { renderRefusal, v } from '../refusal.js';
|
|
11
11
|
import { kv } from '../toon.js';
|
|
@@ -120,7 +120,9 @@ export async function run(ctx, argv) {
|
|
|
120
120
|
t: 'recap', effort: recap.effort, class: recap.class,
|
|
121
121
|
goals: recap.goals, non_goals: recap.non_goals, constraints: recap.constraints, slices: recap.slices,
|
|
122
122
|
};
|
|
123
|
-
|
|
123
|
+
// entryLine, never a second inline JSON.stringify — completeTxn re-appends when this
|
|
124
|
+
// marker line differs from what appendEntry wrote, so the two must be one derivation.
|
|
125
|
+
const line = entryLine(entry);
|
|
124
126
|
const stream = journalRel(recap.effort);
|
|
125
127
|
const res = withTxn(root, { op: `recap(${recap.effort})`, files: [stream], journal: { stream: recap.effort, line } }, () => {
|
|
126
128
|
appendEntry(root, recap.effort, entry);
|
package/dist/verbs/sync.js
CHANGED
|
@@ -14,6 +14,22 @@ export async function run(ctx, _argv) {
|
|
|
14
14
|
const blocked = guardTxn(ctx, root);
|
|
15
15
|
if (blocked !== undefined)
|
|
16
16
|
return blocked;
|
|
17
|
+
// Row 114. `git pull --rebase` refuses on ANY unstaged TRACKED change, and every
|
|
18
|
+
// non-conflict failure below rendered as `rebase conflict — resolve manually`, which
|
|
19
|
+
// names neither the real condition nor a single file. A field report read that line as a
|
|
20
|
+
// structural deadlock ("ship owns the sole code commit, so the work is always unstaged")
|
|
21
|
+
// — sync runs in the primary root and never in a worktree, and the actual cause was one
|
|
22
|
+
// hand-edited tracked file. Untracked paths are deliberately not counted: git rebases
|
|
23
|
+
// around them, so refusing on them would invent a block that does not exist.
|
|
24
|
+
const dirty = tryGit(root, 'status', '--porcelain');
|
|
25
|
+
const tracked = dirty.ok
|
|
26
|
+
? dirty.out.split('\n').filter((l) => l.trim() !== '' && !l.startsWith('??'))
|
|
27
|
+
: [];
|
|
28
|
+
if (tracked.length > 0) {
|
|
29
|
+
renderRefusal([v('worktree', 'worktree-dirty', tracked.map((l) => l.slice(3)).join(' · ').slice(0, 200), 'a clean tree — commit or stash the tracked edits, then witness sync (a rebase cannot run over them)')])
|
|
30
|
+
.forEach((l) => ctx.err(l));
|
|
31
|
+
return EXIT.REFUSED;
|
|
32
|
+
}
|
|
17
33
|
const lockR = acquireLock(root);
|
|
18
34
|
if (!lockR.ok) {
|
|
19
35
|
renderRefusal(lockR.violations).forEach((l) => ctx.err(l));
|
package/dist/verbs/write.js
CHANGED
|
@@ -193,7 +193,12 @@ export async function run(ctx, argv) {
|
|
|
193
193
|
...(existing === undefined ? { created: true } : {}),
|
|
194
194
|
...(supersedes !== undefined ? { supersedes } : {}),
|
|
195
195
|
};
|
|
196
|
-
|
|
196
|
+
// entryLine, never a second inline JSON.stringify: completeTxn decides whether to
|
|
197
|
+
// re-append by comparing this marker line against the last journal line, so any field
|
|
198
|
+
// entryLine adds and this does not makes the two differ and the recovery duplicate the
|
|
199
|
+
// entry. Row 116's `w` stamp is what caught it; the shape is the one rows 93, 95 and
|
|
200
|
+
// 96 all name, and every other txn writer already calls entryLine.
|
|
201
|
+
const line = entryLine(entry);
|
|
197
202
|
const supersededDesignRel = reslice ? designRel(cfg.value.paths, reslice.entry.artifact) : undefined;
|
|
198
203
|
const files = [rel, stream,
|
|
199
204
|
...(reslice ? [reslice.doc.rel, journalRel(reslice.entry.artifact)] : []),
|
|
@@ -238,7 +243,7 @@ function journalRefusal(ctx, root, effort, artifact, violations) {
|
|
|
238
243
|
t: 'write-refused', effort, artifact,
|
|
239
244
|
rules: violations.map(({ field, rule }) => ({ field, rule })),
|
|
240
245
|
};
|
|
241
|
-
const line =
|
|
246
|
+
const line = entryLine(entry);
|
|
242
247
|
const stream = journalRel(effort);
|
|
243
248
|
const res = withTxn(root, { op: `write-refused(${artifact})`, files: [stream], journal: { stream: effort, line } }, () => {
|
|
244
249
|
appendEntry(root, effort, entry);
|
package/dist/version.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
// The one home for "which version of witness is which". install.ts parsed the pin to
|
|
2
4
|
// decide whether a payload was only a pin apart (row 102 killed that rule) and check.ts
|
|
3
5
|
// re-declared the same regex inline — one question answered in two places, which is the
|
|
@@ -36,3 +38,13 @@ export function compareTriple(a, b) {
|
|
|
36
38
|
// through a frozen /witness is `npx …@0.5.1 init`, which restamps 0.5.1 onto 0.5.1 — a
|
|
37
39
|
// no-op that reads as compliance. Remedies name the version.
|
|
38
40
|
export const NPX_LATEST = 'npx -y @popovych.co/witness@latest';
|
|
41
|
+
// Row 116. Moved here from cli.ts. `journal.ts` stamps every entry with it and cannot
|
|
42
|
+
// import the CLI shell without closing a cycle (cli → verbs/* → journal). This module
|
|
43
|
+
// already claimed to be the one home for "which version of witness is which" while the
|
|
44
|
+
// reader itself lived in the shell — the same split rows 93, 95 and 96 keep naming. The
|
|
45
|
+
// single readFileSync is the exception to this file's no-I/O rule and the only one it
|
|
46
|
+
// may ever have.
|
|
47
|
+
export function version() {
|
|
48
|
+
const pkg = JSON.parse(readFileSync(join(new URL('.', import.meta.url).pathname, '..', 'package.json'), 'utf8'));
|
|
49
|
+
return pkg.version;
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popovych.co/witness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "witness",
|
|
3
3
|
"description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.10.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kostiantyn Popovych"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
|
|
|
10
10
|
Resolve the CLI once:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
# context). Silent everywhere that isn't a witness repo; failures swallowed:
|
|
4
4
|
# session start must never hang or die on this.
|
|
5
5
|
[ -f witness.config.yaml ] || exit 0
|
|
6
|
-
${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
6
|
+
${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0} 2>/dev/null || true
|
|
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
|
|
|
10
10
|
Resolve the CLI once per session:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
|
|
|
16
16
|
Resolve the CLI once per session:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
19
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- **Never edit `specs/**`, `plans/**`, or `designs/**`** — the CLI is the sole writer of state. Author HTML in `$(mktemp -d)` and hand it to `witness design`. (The canon guard blocks direct edits; the trailer audit catches end-runs.)
|
|
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|
|
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.10.0}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Never edit `specs/**` or `plans/**`** (the canon dirs — `paths:` in witness.config.yaml may relocate them) — not with an edit tool, not with a write tool, not with Bash redirection. The CLI is the sole writer of state; you author in scratch files under `$(mktemp -d)` and hand them to the CLI. (The canon guard blocks you; the trailer audit catches what it can't.)
|