@popovych.co/witness 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -175,14 +175,15 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
175
175
  | `recap [--amend] --file <json>` | validate + persist a scope recap; births the effort journal |
176
176
  | `write <id> --effort <e> --meta <json> --body <md>` | validated manifest → spec/plan on disk + journal entry |
177
177
  | `diff <spec-id>` | delta since the last realized state (plan pin → empty) |
178
+ | `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 |
178
179
  | `check` | schema, graph, invariants, needs, trailer audit, probes |
179
- | `index` | id · summary · status · depends across the canon |
180
+ | `index` | id · summary · status · depends across the canon, then each spec's plans |
180
181
  | `satisfy <id> --need <text\|n>` | flip a manual need via the write path |
181
182
  | `log <id>` | render a journal stream |
182
183
  | `gate <decompose\|plan\|implement\|ship\|design> <id>` | run the reviewer gate; journals the round, stamps on pass |
183
184
  | `decide <gate> <id> --approve\|--revise [--pin <policy>]` | record the human decision on a stopped gate; `--pin` adds a standing content policy |
184
185
  | `design <spec-id> --file <html>\|--open\|--reconfirm` | register/show a `ui` spec's approved look |
185
- | `start <plan-id>` | create/re-attach the plan's worktree (`.witness/worktrees/<id>`) |
186
+ | `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 |
186
187
  | `next` | the one next action across every effort |
187
188
  | `ship <plan-id>` | lanes → ship gate → PR → CI watch |
188
189
  | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
@@ -216,8 +217,11 @@ pnpm install
216
217
  pnpm test # vitest, in-process CLI against throwaway git repos
217
218
  pnpm run typecheck
218
219
  pnpm run build
220
+ pnpm run release patch # bump + stamp pins + commit + tag, in one act — see docs/RELEASING.md
219
221
  ```
220
222
 
223
+ Releasing is `node scripts/release.mjs <major|minor|patch|x.y.z>` followed by two pushes; the tag push is what publishes. The order is fixed because CI checks the tagged tree, not the intent — [docs/RELEASING.md](docs/RELEASING.md) explains why and what to do if a tag was pushed early.
224
+
221
225
  ## Run economics (operator notes)
222
226
 
223
227
  Non-load-bearing knobs for the machine that hosts long implement runs — the design
package/dist/cli.js CHANGED
@@ -10,6 +10,9 @@ const VERBS = {
10
10
  init: () => import('./verbs/init.js'),
11
11
  recap: () => import('./verbs/recap.js'),
12
12
  write: () => import('./verbs/write.js'),
13
+ // Row 132's read route. Canon lives at the primary root and nowhere else, so reading it
14
+ // is a verb rather than a path — which is also what lets a worktree carry no copy.
15
+ read: () => import('./verbs/read.js'),
13
16
  diff: () => import('./verbs/diff.js'),
14
17
  check: () => import('./verbs/check.js'),
15
18
  index: () => import('./verbs/index-verb.js'),
@@ -57,6 +60,7 @@ const VERB_USAGE = {
57
60
  init: 'witness init [--agent claude-code|pi|auto]',
58
61
  log: 'witness log <stream> [--all] [--lineage]',
59
62
  next: 'witness next',
63
+ read: 'witness read <id> [--design] [--outline] [--lines <a>-<b>]',
60
64
  recap: 'witness recap --file <recap.json> [--amend]',
61
65
  recover: 'witness recover [--complete | --rollback]',
62
66
  rename: 'witness rename <old-id> <new-id>',
package/dist/gate.js CHANGED
@@ -96,8 +96,9 @@ export function renderGateRun(ctx, entry, mode, opts = {}) {
96
96
  // --approve read as the default at stops with a live blocking finding. The block ranks them.
97
97
  // One renderer for all four decision surfaces in this file — a per-site copy is how D119's
98
98
  // nine hand-copied exits sets happened, and three of these four sites were among them.
99
- // `undefined` from `recommend` means no decision exists at this state (stale below the
100
- // bound, where the only live act is a re-gate); the exits line still answers that.
99
+ // `undefined` from `recommend` means no anchor resolves at this state — stale with no
100
+ // decision pending (reopened, or already revised), where every decide verb refuses with
101
+ // nothing-pending and the exits line's single re-gate act is the whole live set (D131).
101
102
  function renderChoices(ctx, gate, target, entries, upstream, stale = false) {
102
103
  const d = recommend({ gate, target, entries, upstream, stale });
103
104
  if (d)
package/dist/recommend.js CHANGED
@@ -1,4 +1,4 @@
1
- import { anchorRecurrence, boundReached, ladderSpent, lastGateRun, notePrefill, repairGranted, roundBudget, roundsSinceApprove, } from './rounds.js';
1
+ import { anchorRecurrence, boundReached, ladderSpent, lastGateRun, notePrefill, pendingDecision, repairGranted, roundBudget, roundsSinceApprove, } from './rounds.js';
2
2
  // Commands are emitted raw (D120): `esc` would quote the `--note "…"` argument and the
3
3
  // line would paste into a shell as an empty note.
4
4
  export function renderDecision(d) {
@@ -99,9 +99,34 @@ export function recommend(ctx) {
99
99
  ],
100
100
  };
101
101
  }
102
- // 2 — stale below the bound: no decision exists, the caller renders the re-gate act.
103
- if (stale && !atBound)
104
- return undefined;
102
+ // 2 — stale below the bound. Staleness blocks STAMPING, not judging: `--approve` asserts
103
+ // about current content and `decide` refuses it with `stale-verdict`, while a stop, a
104
+ // revise and an upstream judge the WORK and are all legal (probed 2026-08-12, all exit 0).
105
+ // Gated on a pending decision because that is what resolves an anchor: on the reopened
106
+ // and revised screens every decide verb refuses with `nothing-pending`, and there the
107
+ // caller's single re-gate act is the honest answer.
108
+ if (stale && !atBound) {
109
+ if (pendingDecision(entries, gate) === undefined)
110
+ return undefined;
111
+ const nextRound = spent + 1;
112
+ return {
113
+ key: 'decide', rule: 'stale-below-bound',
114
+ // No anchor: the findings describe bytes that no longer exist, and pinning a decision
115
+ // to one of them would file recurrence against a verdict nothing can still reproduce.
116
+ options: [
117
+ opt(`witness gate ${gate} ${target}`, 'root', {
118
+ why: `the verdict judged @${last.reviewed_sha.slice(0, 7)} and the content has moved since — no battery has read the current bytes, so every finding above describes a tree that no longer exists`,
119
+ tradeoff: `spends round ${nextRound} of ${budget}${nextRound >= budget ? ' — the last before the bound' : ''}`,
120
+ }),
121
+ opt(note, 'root', {
122
+ when: 'you already know the next edit and want no verdict on this state',
123
+ tradeoff: 'spends no round now — the round is spent by the re-gate that follows it',
124
+ }),
125
+ ...upAlt('the parent artifact is what is wrong, whatever the current bytes say', 'reopens the parent stage and resets this budget'),
126
+ stopOpt,
127
+ ],
128
+ };
129
+ }
105
130
  const anchors = blockingAnchors(last);
106
131
  const primary = anchors[0];
107
132
  const recurrence = primary === undefined ? 0 : anchorRecurrence(entries, gate, primary);
package/dist/rounds.js CHANGED
@@ -175,8 +175,15 @@ export function liveExits(gate, target, entries, stale, upstream) {
175
175
  return [`${d} ${[...approve, ...up, ...repair, '--stop'].join(' | ')}`,
176
176
  `witness abandon ${target}`].join(' | ');
177
177
  }
178
- if (stale)
179
- return `witness gate ${gate} ${target}`;
178
+ // Stale removes --approve and nothing else: `decide` refuses that one with `stale-verdict`
179
+ // because a stamp asserts about current content, while a stop or a revise judges the work.
180
+ // Both halves are measured (2026-08-12). The pending check is what separates this from the
181
+ // reopened and revised screens, where no anchor resolves and every decide verb refuses.
182
+ if (stale) {
183
+ if (pendingDecision(entries, gate) === undefined)
184
+ return `witness gate ${gate} ${target}`;
185
+ return [`witness gate ${gate} ${target}`, `${d} ${[note, ...up, '--stop'].join(' | ')}`].join(' | ');
186
+ }
180
187
  return `${d} ${['--approve', note, ...up, '--stop'].join(' | ')}`;
181
188
  }
182
189
  // What un-parks a gate stopped under D124. Below the bound a fresh run is the act — the
@@ -10,7 +10,7 @@ import { probe } from '../probe.js';
10
10
  import { loadConfig, loadLocalConfig, localConfigPath } from '../config.js';
11
11
  import { designPending } from '../design.js';
12
12
  import { runDrift } from '../drift.js';
13
- import { auditStateCommits, dirtyStatePaths, primaryRoot, tryGit } from '../gitio.js';
13
+ import { auditStateCommits, dirtyStatePaths, primaryRoot, stateDirs, tryGit } from '../gitio.js';
14
14
  import { contentAtSha } from '../history.js';
15
15
  import { effortStreams, readStream } from '../journal.js';
16
16
  import { sourceTags } from '../matcher.js';
@@ -24,7 +24,7 @@ import { canonicalSha } from '../sha.js';
24
24
  import { lazyStamp } from '../stamp.js';
25
25
  import { kv, rows } from '../toon.js';
26
26
  import { pendingTxn } from '../txn.js';
27
- import { listWorktrees, worktreePath } from '../worktree.js';
27
+ import { listWorktrees, materializedCanon, RESIDUE_GOT, worktreePath } from '../worktree.js';
28
28
  const f = (level, area, field, rule, detail) => ({ level, area, field, rule, detail });
29
29
  export async function run(ctx, argv = []) {
30
30
  if (argv.includes('--drift'))
@@ -137,6 +137,39 @@ export async function run(ctx, argv = []) {
137
137
  if (!doc || ['done', 'abandoned'].includes(String(doc.meta.status))) {
138
138
  findings.push(f('warn', 'motion', planId, 'stray-worktree', 'witness clean sweeps it'));
139
139
  }
140
+ // Row 132. A worktree cut before the canon exclusion existed still carries a copy, and
141
+ // `start` is what removes it — so this finding exists to make that state discoverable
142
+ // WITHOUT running the verb that fixes it. Indexed by worktree, like every finding in
143
+ // this loop; severity is what splits the causes apart, because they are different
144
+ // events with different remedies.
145
+ const wtPath = worktreePath(root, planId);
146
+ const residue = materializedCanon(root, wtPath);
147
+ if (residue.length === 0)
148
+ continue;
149
+ const st = tryGit(wtPath, 'status', '--porcelain', '--untracked-files=all', '--', ...stateDirs(root));
150
+ const edited = new Set(st.ok && st.out !== ''
151
+ ? st.out.split('\n').flatMap((l) => l.slice(3).replace(/^"|"$/g, '').split(' -> '))
152
+ : []);
153
+ // A clean pre-upgrade copy is status-silent; a hand edit is not. `planted` and
154
+ // `untracked` are hand events by definition whatever status says — sparse never leaves
155
+ // an `S` file on disk and never invents an untracked one — so cause alone convicts them
156
+ // and the status query is only there to sort the `materialized` rows.
157
+ const handled = residue.filter((r) => r.how !== 'materialized' || edited.has(r.rel));
158
+ if (handled.length > 0) {
159
+ // The dirty paths are the whole story while they exist: read-tree declines them, so
160
+ // the clean rows' remedy (`witness start`) would refuse until these are resolved, and
161
+ // advertising it here would name a command that cannot run.
162
+ for (const r of handled) {
163
+ findings.push(f('error', 'motion', `${planId}: ${r.rel}`, 'canon-in-worktree', `${RESIDUE_GOT[r.how]} — revert it in the worktree; canon changes belong at the primary root (witness write · witness adopt)`));
164
+ }
165
+ continue;
166
+ }
167
+ // ONE row for the clean case, never one per path: a pre-upgrade worktree materializes
168
+ // EVERY canon doc, and a row each is dozens of identical warns for a single event. The
169
+ // sample names an ARTIFACT when the set has one: the finding is about a doc a session
170
+ // could read, while `.gitkeep` and journal rows sort first and name nothing at stake.
171
+ const sample = (residue.find((r) => /\.(md|html)$/.test(r.rel)) ?? residue[0]).rel;
172
+ findings.push(f('warn', 'motion', planId, 'canon-in-worktree', `${residue.length} canon file(s) checked out here (e.g. ${sample}) — worktree predates the exclusion; witness start ${planId} removes them`));
140
173
  }
141
174
  for (const doc of canon.docs) {
142
175
  const needs = Array.isArray(doc.meta.needs) ? doc.meta.needs : [];
@@ -132,8 +132,8 @@ export async function run(ctx, argv) {
132
132
  ctx.out(kv('note', disposition.note));
133
133
  }
134
134
  // The surface a human actually reads while deciding, so it is the one that most owes a
135
- // ranking rather than a menu. `undefined` is stale below the bound, where no decision
136
- // exists and the exits line's single re-gate act is the honest answer.
135
+ // ranking rather than a menu. `undefined` is stale with no decision pending, where no
136
+ // act but the re-gate is legal and the exits line already says exactly that (D131).
137
137
  const shown = recommend({ gate, target, entries, upstream: upstreamId, stale });
138
138
  if (shown)
139
139
  renderDecision(shown).forEach((l) => ctx.out(l));
@@ -275,7 +275,13 @@ export async function run(ctx, argv) {
275
275
  // `decision` — so the two are directly comparable in a log query. The full command would
276
276
  // carry a prefilled note that changes with the findings, which would make two identical
277
277
  // recommendations look different.
278
- const rec = recommend({ gate, target, entries, upstream: upstreamId, stale: false });
278
+ const rec = recommend({
279
+ gate, target, entries, upstream: upstreamId,
280
+ // The truth, not `false`. The hardcode existed because a stale state produced no rule at
281
+ // all and the entry's fields would have been empty; with the stale rule in place it only
282
+ // misattributes — D130's audit was reporting rules that were never rendered.
283
+ stale: nowSha !== undefined && nowSha !== anchor.reviewed_sha,
284
+ });
279
285
  const recommendedVerb = rec?.options[0]?.command.match(/--(approve|revise|stop)/)?.[1];
280
286
  const entry = {
281
287
  v: 1, t: 'human-decision', gate, artifact: target, round: anchor.round,
@@ -29,6 +29,19 @@ export async function run(ctx) {
29
29
  depends: Array.isArray(d.meta.depends) ? d.meta.depends.join(' ') : '',
30
30
  }))).forEach(ctx.out);
31
31
  }
32
- ctx.out('help: witness diff <id> · witness log <id>');
32
+ // Row 132. `witness read <id>` replaced the plan skill's `cat plans/…`; this replaced its
33
+ // `ls plans/`. One table keyed by parent, because the question a planner asks is "which
34
+ // plans has this spec already had" — a spec accumulates them over its life. Emitted only
35
+ // when plans exist: a repo mid-decompose owes no plans, and an empty table is a row
36
+ // every `index` run would pay for.
37
+ const plans = canon.docs.filter((d) => d.meta.type === 'plan');
38
+ if (plans.length > 0) {
39
+ const byParent = plans.sort((a, b) => String(a.meta.parent).localeCompare(String(b.meta.parent))
40
+ || String(a.meta.id).localeCompare(String(b.meta.id)));
41
+ rows('plans', ['id', 'parent', 'status'], byParent.map((d) => ({
42
+ id: d.meta.id, parent: d.meta.parent ?? '', status: d.meta.status,
43
+ }))).forEach(ctx.out);
44
+ }
45
+ ctx.out('help: witness read <id> · witness diff <id> · witness log <id>');
33
46
  return EXIT.OK;
34
47
  }
@@ -256,7 +256,7 @@ function flowBlocked(root, plan, entries) {
256
256
  }
257
257
  // The upstream artifact a gate's `--revise --upstream` books its reopen against. The gate
258
258
  // registry answers this via `gateSpec().upstreamOf`, but `gate.ts` imports THIS module
259
- // (gate.ts:23), so importing the registry back would close the cycle the codebase keeps
259
+ // (gate.ts:24), so importing the registry back would close the cycle the codebase keeps
260
260
  // one-way on purpose — the same edge that moved `liveExits` out of gate.ts into rounds.ts.
261
261
  // Tier 2 hand-resolved it inline in three places; this is that derivation named once, and
262
262
  // it agrees with the registry by test (exits-line.test.ts pins upstreamOf for all five).
@@ -0,0 +1,116 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { EXIT } from '../cli.js';
5
+ import { designRel, elementIds } from '../design.js';
6
+ import { serializeDoc } from '../fm.js';
7
+ import { primaryRoot } from '../gitio.js';
8
+ import { renderRefusal, v } from '../refusal.js';
9
+ import { findById, loadCanon } from '../scan.js';
10
+ import { rows } from '../toon.js';
11
+ // Row 132. `--outline` + `--lines` rather than `--section <anchor>`, deliberately: the
12
+ // implement skill's fat-artifact rule was *grep for the anchors, then read those offsets*
13
+ // — two moves, and deleting the worktree copy breaks both, so the verb must replace both.
14
+ // A `--section` extractor would need a hand-written tag-depth scanner over HTML with no
15
+ // parser dependency, and its failure mode is a SILENT mis-slice. Line ranges cannot
16
+ // mis-slice: at worst a range is wider or narrower than the reader wanted, and they can see
17
+ // that in what comes back.
18
+ //
19
+ // Spans, never element extents. An HTML anchor runs from its own line to the line before
20
+ // the next anchor (the last one to EOF), because knowing where `<footer id="save-bar">`
21
+ // CLOSES needs the parser this verb refuses to grow.
22
+ function htmlOutline(lines) {
23
+ const hits = [];
24
+ lines.forEach((l, i) => { for (const id of elementIds(l))
25
+ hits.push({ id, line: i + 1 }); });
26
+ return hits.map((h, i) => {
27
+ const next = hits[i + 1]?.line ?? lines.length + 1;
28
+ return { anchor: h.id, lines: `${h.line}-${Math.max(h.line, next - 1)}` };
29
+ });
30
+ }
31
+ // Markdown headings, nesting by level: a `##` span covers its `###` children, so one range
32
+ // is the whole section a reader asked for. A `#` inside a fenced code block reads as a
33
+ // heading here — an outline that names one anchor too many costs a reader one glance, and
34
+ // the ranges it produces still slice exactly what they say.
35
+ function mdOutline(lines) {
36
+ const heads = [];
37
+ lines.forEach((l, i) => {
38
+ const m = /^(#{1,6})\s+\S/.exec(l);
39
+ if (m)
40
+ heads.push({ level: m[1].length, text: l.trim(), line: i + 1 });
41
+ });
42
+ return heads.map((h, i) => {
43
+ const closer = heads.slice(i + 1).find((x) => x.level <= h.level);
44
+ return { anchor: h.text, lines: `${h.line}-${closer ? closer.line - 1 : lines.length}` };
45
+ });
46
+ }
47
+ export async function run(ctx, argv) {
48
+ const { values, positionals } = parseArgs({
49
+ args: argv, allowPositionals: true,
50
+ options: { design: { type: 'boolean' }, outline: { type: 'boolean' }, lines: { type: 'string' } },
51
+ });
52
+ const id = positionals[0];
53
+ if (!id) {
54
+ ctx.err('usage: witness read <id> [--design] [--outline] [--lines <a>-<b>]');
55
+ return EXIT.REFUSED;
56
+ }
57
+ // primaryRoot, never ctx.cwd: this verb exists BECAUSE a worktree carries no canon, so
58
+ // reading relative to where it was typed is the exact bug it closes.
59
+ const rootR = primaryRoot(ctx.cwd);
60
+ if (!rootR.ok) {
61
+ renderRefusal(rootR.violations).forEach(ctx.err);
62
+ return EXIT.REFUSED;
63
+ }
64
+ const root = rootR.value;
65
+ const canon = loadCanon(root);
66
+ const doc = findById(canon, id);
67
+ if (!doc) {
68
+ renderRefusal([v('id', 'unknown-id', id, 'an id from witness index')]).forEach(ctx.err);
69
+ return EXIT.REFUSED;
70
+ }
71
+ let text;
72
+ if (values.design) {
73
+ const rel = designRel(canon.paths, id);
74
+ const abs = join(root, rel);
75
+ // One refusal for both causes, because the design skill reads it as one signal: no
76
+ // artifact here yet means "new screen", and a plan id was never going to have one.
77
+ if (doc.meta.type !== 'spec' || !existsSync(abs)) {
78
+ renderRefusal([v('design', 'no-design', `${id} — ${rel} absent`, 'a ui spec whose design stage has run — witness design <spec-id> --file <html>')]).forEach(ctx.err);
79
+ return EXIT.REFUSED;
80
+ }
81
+ text = readFileSync(abs, 'utf8');
82
+ }
83
+ else {
84
+ text = serializeDoc({ meta: doc.meta, body: doc.body });
85
+ }
86
+ // ONE line array behind all three modes. Whole, outline and slice must count lines the
87
+ // same way, or an outline range names offsets `--lines` would resolve to other content.
88
+ const lines = text.split('\n');
89
+ if (lines[lines.length - 1] === '')
90
+ lines.pop();
91
+ if (values.outline) {
92
+ const outline = values.design ? htmlOutline(lines) : mdOutline(lines);
93
+ rows('outline', ['anchor', 'lines'], outline).forEach(ctx.out);
94
+ return EXIT.OK;
95
+ }
96
+ if (values.lines !== undefined) {
97
+ const m = /^(\d+)(?:-(\d+))?$/.exec(values.lines);
98
+ const from = m ? Number(m[1]) : 0;
99
+ const to = m?.[2] !== undefined ? Number(m[2]) : from;
100
+ if (!m || from < 1 || to < from) {
101
+ renderRefusal([v('lines', 'bad-range', values.lines, 'a 1-indexed inclusive range, low end first — e.g. --lines 12-48')]).forEach(ctx.err);
102
+ return EXIT.REFUSED;
103
+ }
104
+ // A start past the last line prints nothing, and silence is the one answer a caller
105
+ // cannot act on. Name the count so the retry is computable. An end past it is fine —
106
+ // asking for more lines than exist is not a mistake.
107
+ if (from > lines.length) {
108
+ renderRefusal([v('lines', 'bad-range', `${values.lines} — the artifact has ${lines.length} lines`, 'a range that starts inside the artifact — witness read <id> --outline names them')]).forEach(ctx.err);
109
+ return EXIT.REFUSED;
110
+ }
111
+ ctx.out(lines.slice(from - 1, to).join('\n'));
112
+ return EXIT.OK;
113
+ }
114
+ ctx.out(lines.join('\n'));
115
+ return EXIT.OK;
116
+ }
package/dist/worktree.js CHANGED
@@ -1,6 +1,6 @@
1
- import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { isAbsolute, join, sep } from 'node:path';
3
- import { git, tryGit } from './gitio.js';
3
+ import { git, stateDirs, tryGit } from './gitio.js';
4
4
  import { ok, refuse, v } from './refusal.js';
5
5
  export function worktreesDir(root) {
6
6
  return join(root, '.witness', 'worktrees');
@@ -42,13 +42,110 @@ export function ensureExcluded(root) {
42
42
  appendFileSync(excludePath, `${line}\n`);
43
43
  }
44
44
  }
45
+ // Row 132. Canon has ONE home, and it is the primary root. The exclusion set is
46
+ // `stateDirs(root)` — the same predicate `isStatePath` and `stateCommit` already use, never
47
+ // a second list: a divergence between "what the CLI refuses to let you edit" and "what the
48
+ // worktree hides" is the split-brain this row exists to close.
49
+ //
50
+ // Non-cone patterns, and `/*` first: cone mode cannot express "keep docs/, drop
51
+ // docs/plans/", which is exactly what a repo with relocated `paths:` needs.
52
+ export function canonPatterns(root) {
53
+ return ['/*', ...stateDirs(root).map((d) => `!/${d}/`)];
54
+ }
55
+ export const RESIDUE_GOT = {
56
+ materialized: 'canon checked out in a worktree',
57
+ planted: 'canon planted on disk over the exclusion',
58
+ untracked: 'an untracked canon file in a worktree',
59
+ };
60
+ // Everything under a state dir that a worktree can still SEE. Three shapes, because the
61
+ // exclusion can fail in three different ways and `git status` reports only one of them:
62
+ // • `materialized` — tracked and not `S`-tagged: the sparse rule never took (a worktree
63
+ // cut before row 132, or a dirty file read-tree declined to remove);
64
+ // • `planted` — tracked, `S`-tagged, yet present on disk: put there by hand over the
65
+ // rule, since sparse never leaves an `S` file on disk. Measured on git 2.50.1: an index
66
+ // refresh CLEARS skip-worktree for any path it finds present, so the same forgery reads
67
+ // as `planted` before something refreshes and as a modified `materialized` entry after.
68
+ // Both shapes are listed here so the answer never depends on who ran `git status` first;
69
+ // • `untracked` (`.witness/screens/` and `.witness/worktrees/` are already filtered by
70
+ // `ensureExcluded`'s `info/exclude` entries).
71
+ export function materializedCanon(root, wtPath) {
72
+ const dirs = stateDirs(root);
73
+ const found = new Map();
74
+ // --full-name: ls-files prints cwd-relative paths by default, and every consumer here
75
+ // treats what comes back as worktree-relative
76
+ const tracked = tryGit(wtPath, 'ls-files', '--full-name', '-t', '--', ...dirs);
77
+ if (tracked.ok && tracked.out !== '') {
78
+ for (const line of tracked.out.split('\n')) {
79
+ const rel = line.slice(2);
80
+ if (rel === '')
81
+ continue;
82
+ if (line[0] !== 'S')
83
+ found.set(rel, 'materialized');
84
+ else if (existsSync(join(wtPath, rel)))
85
+ found.set(rel, 'planted');
86
+ }
87
+ }
88
+ const others = tryGit(wtPath, 'ls-files', '--full-name', '--others', '--exclude-standard', '--', ...dirs);
89
+ if (others.ok && others.out !== '') {
90
+ for (const rel of others.out.split('\n'))
91
+ if (rel !== '')
92
+ found.set(rel, 'untracked');
93
+ }
94
+ return [...found].map(([rel, how]) => ({ rel, how }));
95
+ }
96
+ // Applied AND verified: sparse-checkout is a checkout policy, so "we asked git to hide it"
97
+ // is not the same fact as "it is hidden", and the second one is the invariant. read-tree
98
+ // declines a dirty path with a warning and exit 0 (measured), so the verification below is
99
+ // the only thing that can tell the two apart.
100
+ //
101
+ // The low-level mechanism (patterns file + `core.sparseCheckout` + `read-tree -mu HEAD`)
102
+ // rather than the `sparse-checkout` porcelain: the porcelain's flags moved across git
103
+ // 2.25–2.37 (`init --no-cone`, `set --no-cone`), while this trio is stable across the whole
104
+ // range `primaryRoot` already binds (git >=2.31, via `rev-parse --path-format`).
105
+ export function excludeCanon(root, wtPath) {
106
+ // git's own caveat: enabling worktreeConfig relocates these two to per-worktree scope.
107
+ // Witness must not silently move a setting the user made, so it refuses instead.
108
+ for (const key of ['core.bare', 'core.worktree']) {
109
+ // --local: the shared .git/config is the scope worktreeConfig relocates FROM, and
110
+ // merged scope would refuse over a harmless global/system setting. `git init` writes
111
+ // `bare = false` locally on every repo, so the value test is load-bearing, not belt.
112
+ const cur = tryGit(root, 'config', '--local', '--get', key);
113
+ const val = cur.ok ? cur.out.trim() : '';
114
+ if (val !== '' && val !== 'false') {
115
+ return refuse([v('git', 'worktree-config-unsafe', `${key}=${val}`, `${key} unset — enabling extensions.worktreeConfig would move it to per-worktree scope`)]);
116
+ }
117
+ }
118
+ const gitDir = git(wtPath, 'rev-parse', '--path-format=absolute', '--git-dir');
119
+ mkdirSync(join(gitDir, 'info'), { recursive: true });
120
+ writeFileSync(join(gitDir, 'info', 'sparse-checkout'), `${canonPatterns(root).join('\n')}\n`);
121
+ git(root, 'config', 'extensions.worktreeConfig', 'true');
122
+ git(wtPath, 'config', '--worktree', 'core.sparseCheckout', 'true');
123
+ git(wtPath, 'config', '--worktree', 'core.sparseCheckoutCone', 'false');
124
+ // tryGit: an unstaged edit elsewhere in the tree is the NORMAL mid-slice state (the
125
+ // implement stage leaves the worktree dirty by design) and read-tree carries it through
126
+ // untouched; a dirty canon path is what it declines, and the residue check is the judge.
127
+ tryGit(wtPath, 'read-tree', '-mu', 'HEAD');
128
+ const residue = materializedCanon(root, wtPath);
129
+ if (residue.length) {
130
+ return refuse(residue.map((r) => v(r.rel, 'canon-in-worktree', RESIDUE_GOT[r.how], 'canon read at the primary root (witness read <id>) — revert or witness adopt the edit, then re-run')));
131
+ }
132
+ return ok(undefined);
133
+ }
45
134
  export function createWorktree(root, planId, baseBranch) {
46
135
  const path = worktreePath(root, planId);
47
136
  const branch = branchName(planId);
48
137
  ensureExcluded(root);
49
138
  mkdirSync(worktreesDir(root), { recursive: true });
50
- if (existsSync(path))
139
+ // BOTH arms exclude. The early return is the re-attach path, which is also the upgrade
140
+ // path: every worktree cut before row 132 arrives here carrying a canon copy, and a
141
+ // create-only exclusion would leave exactly those worktrees — the ones already in flight
142
+ // — as the population the row was written for.
143
+ if (existsSync(path)) {
144
+ const ex = excludeCanon(root, path);
145
+ if (!ex.ok)
146
+ return refuse(ex.violations);
51
147
  return ok({ path, branch });
148
+ }
52
149
  tryGit(root, 'worktree', 'prune');
53
150
  const branchExists = tryGit(root, 'rev-parse', '--verify', '--quiet', `refs/heads/${branch}`).ok;
54
151
  const r = branchExists
@@ -57,6 +154,9 @@ export function createWorktree(root, planId, baseBranch) {
57
154
  if (!r.ok) {
58
155
  return refuse([v('worktree', 'worktree-create-failed', r.out.slice(0, 200), 'git worktree add to succeed')]);
59
156
  }
157
+ const ex = excludeCanon(root, path);
158
+ if (!ex.ok)
159
+ return refuse(ex.violations);
60
160
  return ok({ path, branch });
61
161
  }
62
162
  export function removeWorktree(root, planId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.11.0",
3
+ "version": "0.12.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",
@@ -57,7 +57,8 @@
57
57
  "typecheck": "tsc --noEmit",
58
58
  "test": "vitest run",
59
59
  "test:watch": "vitest",
60
- "sync-versions": "node scripts/sync-versions.mjs"
60
+ "sync-versions": "node scripts/sync-versions.mjs",
61
+ "release": "node scripts/release.mjs"
61
62
  },
62
63
  "dependencies": {
63
64
  "yaml": "^2.7.0"
@@ -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.11.0",
4
+ "version": "0.12.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.11.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.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.
@@ -48,13 +48,159 @@ function isStateRel(rel, dirs) {
48
48
  return dirs.some((d) => posix === d || posix.startsWith(`${d}/`));
49
49
  }
50
50
 
51
- const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
+ // ── Bash: mutation targets, not mentions (Decision 133) ─────────────────────────────
52
+ //
53
+ // The old shape blocked when a state path and a writeish token co-occurred ANYWHERE in the
54
+ // command string. That fails CLOSED on ambiguity, against this file's own contract — and it
55
+ // blocked `grep "Do not touch" <plan>`, because \btouch\b matched inside the search string.
56
+ //
57
+ // The shape now: mask quoted regions and heredoc bodies OFFSET-PRESERVINGLY, read structure
58
+ // only from unmasked text, resolve every candidate target from the ORIGINAL command at the
59
+ // same offsets, and block a segment only when a resolved target is a state path. Resolving
60
+ // from the original is what the offset preservation is FOR: matching against the masked text
61
+ // instead would let `echo hi > "docs/plans/p1.md"` fall open, and a quoted path is how an
62
+ // agent usually spells one.
63
+ const CODE = 0; // shell structure lives here, and only here
64
+ const QUOTED = 1; // inside '…' or "…" — part of a token, never structure
65
+ const FILLER = 2; // a heredoc body — neither structure nor token
66
+
67
+ function maskOf(cmd) {
68
+ const mask = new Uint8Array(cmd.length);
69
+ let i = 0;
70
+ while (i < cmd.length) {
71
+ const c = cmd[i];
72
+ if (c === '\\') { i += 2; continue; }
73
+ if (c === '<' && cmd[i + 1] === '<') {
74
+ // <<TAG, <<-TAG, <<'TAG', <<"TAG" — the body is the reporter's own case: an issue
75
+ // written with `cat > /tmp/x.md <<'EOF'` quoting a plan path it must not be blocked for
76
+ const m = /^<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(cmd.slice(i));
77
+ if (m) {
78
+ const nl = cmd.indexOf('\n', i + m[0].length);
79
+ if (nl === -1) { i += m[0].length; continue; }
80
+ const term = new RegExp(`\\n[ \\t]*${m[2]}[ \\t]*(?=\\n|$)`).exec(cmd.slice(nl));
81
+ const end = term ? nl + term.index + term[0].length : cmd.length;
82
+ mask.fill(FILLER, nl + 1, end);
83
+ i = end;
84
+ continue;
85
+ }
86
+ }
87
+ if (c === "'" || c === '"') {
88
+ let j = i + 1;
89
+ while (j < cmd.length && cmd[j] !== c) j += c === '"' && cmd[j] === '\\' ? 2 : 1;
90
+ // unterminated: everything after it is unparseable, so mask it and fall open
91
+ const end = Math.min(j, cmd.length);
92
+ mask.fill(QUOTED, i + 1, end);
93
+ i = end + 1;
94
+ continue;
95
+ }
96
+ i++;
97
+ }
98
+ return mask;
99
+ }
100
+
101
+ // Separators found in UNMASKED text only, so a quoted `;` or `|` cannot split a segment.
102
+ const SEPARATORS = ';|&\n()';
103
+
104
+ function segmentsOf(cmd, mask) {
105
+ const out = [];
106
+ let start = 0;
107
+ for (let i = 0; i < cmd.length; i++) {
108
+ if (mask[i] === CODE && SEPARATORS.includes(cmd[i])) { out.push([start, i]); start = i + 1; }
109
+ }
110
+ out.push([start, cmd.length]);
111
+ return out.filter(([a, b]) => b > a);
112
+ }
113
+
114
+ // A masked FILLER char separates like whitespace; a QUOTED one joins, which is what keeps a
115
+ // quoted path one token. Token text comes from the original command, quotes and all.
116
+ const isBreak = (cmd, mask, i) => mask[i] === FILLER || (mask[i] === CODE && /\s/.test(cmd[i]));
52
117
 
53
- function statePathRe(dirs) {
54
- return new RegExp(`(^|[\\s"'=;|&(])(\\./)?(${dirs.map(escapeRe).join('|')})/`);
118
+ function tokensOf(cmd, mask, from, to) {
119
+ const out = [];
120
+ let i = from;
121
+ while (i < to) {
122
+ if (isBreak(cmd, mask, i)) { i++; continue; }
123
+ const start = i;
124
+ while (i < to && !isBreak(cmd, mask, i)) i++;
125
+ out.push(cmd.slice(start, i));
126
+ }
127
+ return out;
55
128
  }
56
129
 
57
- const WRITEISH = /(>>?|\btee\b|\bsed\b[^\n]*\s-i\b|\bmv\b|\bcp\b|\brm\b|\btouch\b|\btruncate\b|\bdd\b)/;
130
+ const unquote = (t) => t.replace(/['"]/g, '');
131
+
132
+ // Redirect targets, located by the operator in unmasked text: `>`, `>>`, `2>`, `&>`. `>&2`
133
+ // and `2>&1` duplicate a descriptor and have no path target at all.
134
+ function redirectTargets(cmd, mask, from, to) {
135
+ const out = [];
136
+ for (let i = from; i < to; i++) {
137
+ if (mask[i] !== CODE || cmd[i] !== '>') continue;
138
+ let j = i + 1;
139
+ while (j < to && cmd[j] === '>') j++;
140
+ while (j < to && mask[j] === CODE && /[ \t]/.test(cmd[j])) j++;
141
+ if (j >= to || (mask[j] === CODE && cmd[j] === '&')) continue;
142
+ const start = j;
143
+ while (j < to && !isBreak(cmd, mask, j) && !(mask[j] === CODE && cmd[j] === '>')) j++;
144
+ if (j > start) out.push(cmd.slice(start, j));
145
+ i = j - 1;
146
+ }
147
+ return out;
148
+ }
149
+
150
+ // Command words that write their arguments. Same set the co-occurrence guard used — this row
151
+ // narrows the JUDGMENT, not the vocabulary.
152
+ const MUTATORS = new Set(['mv', 'cp', 'rm', 'tee', 'touch', 'truncate', 'dd']);
153
+ // Words that stand in front of the real command word rather than being one.
154
+ const PREFIXES = new Set(['sudo', 'command', 'env', 'time', 'nice', 'nohup', 'xargs']);
155
+
156
+ function mutatorTargets(tokens) {
157
+ const none = { word: undefined, targets: [] };
158
+ let i = 0;
159
+ let word;
160
+ for (; i < tokens.length; i++) {
161
+ const w = unquote(tokens[i]);
162
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || w.startsWith('-')) continue; // env assignment / a prefix's flag
163
+ const base = w.split('/').pop();
164
+ if (PREFIXES.has(base)) continue;
165
+ word = base;
166
+ break;
167
+ }
168
+ if (word === undefined) return none;
169
+ let args = tokens.slice(i + 1);
170
+ // `git rm` / `git mv` mutate canon under a command word that is not itself a mutator.
171
+ // `git checkout`/`restore` deliberately stay open: restoring canon is how a hand edit in a
172
+ // worktree is REVERTED, which is the remedy `check` names.
173
+ if (word === 'git') {
174
+ const sub = args.find((a) => !unquote(a).startsWith('-'));
175
+ if (sub === undefined || (unquote(sub) !== 'rm' && unquote(sub) !== 'mv')) return none;
176
+ word = `git ${unquote(sub)}`;
177
+ args = args.slice(args.indexOf(sub) + 1);
178
+ }
179
+ const files = args.filter((a) => !unquote(a).startsWith('-'));
180
+ if (word === 'sed') {
181
+ return { word, targets: args.some((a) => /^--?i/.test(unquote(a))) ? files : [] };
182
+ }
183
+ // cp writes only its destination — `cp <plan> /tmp/x` copies canon OUT, which is a read.
184
+ // mv has no such half: its source is destroyed, so every argument is a target.
185
+ if (word === 'mv' || word === 'git rm' || word === 'git mv') return { word, targets: files };
186
+ if (word === 'cp') return { word, targets: files.slice(-1) };
187
+ return MUTATORS.has(word) ? { word, targets: files } : none;
188
+ }
189
+
190
+ // A token is a state path when the FILESYSTEM says so, not when its spelling does — the same
191
+ // resolve-then-relativise predicate the Write/Edit branch above uses, so one fact has one
192
+ // home and an absolute or `../`-relative target lands where a regex over the raw string
193
+ // never could. `key=value` forms (`dd of=…`, `--file=…`) are tested on both halves.
194
+ function stateTargetRel(root, cwd, token, dirs) {
195
+ const raw = unquote(token);
196
+ const eq = /^[A-Za-z0-9_-]+=(.+)$/.exec(raw);
197
+ for (const cand of eq ? [raw, eq[1]] : [raw]) {
198
+ if (cand === '') continue;
199
+ const rel = relative(root, isAbsolute(cand) ? cand : resolve(cwd, cand)).split('\\').join('/');
200
+ if (rel !== '' && !rel.startsWith('..') && isStateRel(rel, dirs)) return rel;
201
+ }
202
+ return undefined;
203
+ }
58
204
 
59
205
  function reasonFor(what) {
60
206
  return (
@@ -88,12 +234,24 @@ export function canonGuard(call) {
88
234
 
89
235
  if (BASH_TOOLS.has(tool)) {
90
236
  const cmd = args.command;
91
- if (typeof cmd !== 'string') return undefined;
237
+ if (typeof cmd !== 'string' || cmd === '') return undefined;
92
238
  const root = configRoot(cwd);
93
239
  if (!root) return undefined;
94
240
  const dirs = canonDirs(root);
95
- if (statePathRe(dirs).test(cmd) && WRITEISH.test(cmd)) {
96
- return { block: true, reason: reasonFor(`a ${dirs.map((d) => `${d}/`).join(' or ')} path in that command`) };
241
+ const mask = maskOf(cmd);
242
+ for (const [from, to] of segmentsOf(cmd, mask)) {
243
+ const mut = mutatorTargets(tokensOf(cmd, mask, from, to));
244
+ const candidates = [
245
+ ...redirectTargets(cmd, mask, from, to).map((t) => [t, 'a redirect target']),
246
+ ...mut.targets.map((t) => [t, `a ${mut.word} target`]),
247
+ ];
248
+ for (const [token, how] of candidates) {
249
+ // Name the RESOLVED target and what writes it. The old message named a directory
250
+ // set and never the token that tripped it — which is why a block on a search string
251
+ // read as a bug in the guard rather than a fact about the command.
252
+ const rel = stateTargetRel(root, cwd, token, dirs);
253
+ if (rel) return { block: true, reason: reasonFor(`${rel} (${how})`) };
254
+ }
97
255
  }
98
256
  return undefined;
99
257
  }
@@ -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.11.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0} 2>/dev/null || true
@@ -15,12 +15,13 @@ 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.11.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
22
22
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
23
23
  - **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.)
24
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
24
25
  - **Never invoke gate reviewers or relay verdicts.** `witness gate` runs reviewers itself and journals what they said; your summary of a verdict is not evidence.
25
26
  - **Refusal repair loop:** a `witness` verb exiting 2 prints structured violations (`field · rule · got · want`). Fix your input and retry — **3 total attempts** per artifact, then stop, show the human the violation list verbatim, and end your turn.
26
27
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.
@@ -10,12 +10,13 @@ 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.11.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
14
14
  ```
15
15
 
16
16
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
17
17
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
18
18
  - **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.)
19
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
19
20
  - **Never invoke gate reviewers or relay verdicts.** `witness gate` runs reviewers itself and journals what they said; your summary of a verdict is not evidence.
20
21
  - **Refusal repair loop:** a `witness` verb exiting 2 prints structured violations (`field · rule · got · want`). Fix your input and retry — **3 total attempts** per artifact, then stop, show the human the violation list verbatim, and end your turn.
21
22
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.
@@ -34,7 +35,7 @@ The class comes from the recap. Never ask for it again.
34
35
  ## Route by class
35
36
 
36
37
  - **feature** — slice (next section). Expect the gate to stop for scope approval afterwards: that is the standing stop working, not a failure.
37
- - **fix** — find **THE one spec** to amend: match the broken behavior against `witness index` summaries; when summaries tie, grep `specs/` read-only. Amend exactly one spec. If the fix genuinely needs a brand-new spec, write it — the gate's tripwire stops for a human, which is the designed check on your routing (on a young canon this fires often; say so rather than fighting it).
38
+ - **fix** — find **THE one spec** to amend: match the broken behavior against `witness index` summaries; when summaries tie, grep the specs dir read-only **at the primary root** (searching across canon is not an artifact read, and there is no verb for it — the read rule governs reading an artifact you have already identified). Amend exactly one spec. If the fix genuinely needs a brand-new spec, write it — the gate's tripwire stops for a human, which is the designed check on your routing (on a young canon this fires often; say so rather than fighting it).
38
39
  - **chore** — **write NO specs** (a chore writing spec content is refused at write time, by definition of the class). There is nothing here for you: `next` routes a chore straight to the plan stage, because the decompose gate refuses `nothing-to-gate` without written specs and `write` refuses spec content from a chore — the stage is unsatisfiable in both directions. The parent for the coming plan is chosen when that plan is authored. Hand back immediately if you were invoked for one.
39
40
 
40
41
  ## Slicing rules (feature)
@@ -16,12 +16,13 @@ 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.11.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
20
20
  ```
21
21
 
22
22
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
23
23
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
24
24
  - **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.)
25
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
25
26
  - **Never invoke gate reviewers or relay verdicts.** `witness gate design` runs the design-critic itself and journals what it said.
26
27
  - **Refusal repair loop:** `witness design` exiting 2 prints structured violations (`field · rule · got · want`). Fix and retry — **3 total attempts**, then stop and show the human the list verbatim.
27
28
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.
@@ -31,15 +32,16 @@ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.11.0}"
31
32
 
32
33
  The `/witness` loop invokes you for a `ui`-flagged spec in a **feature** effort whose design is pending (missing or stale). fix/chore specs never reach you. Two modes:
33
34
 
34
- - **New screen** — no `designs/<spec-id>.html` yet. Design from the spec.
35
+ - **New screen** — `$WITNESS read <spec-id> --design` refuses `no-design`: there is no living look yet. Design from the spec.
35
36
  - **Amend a living look** — the file exists but the spec was amended. Read the current look first; propose the *smallest* change that re-serves the (changed) behavior. If there is genuinely no visual delta, tell the human to run `witness design <spec-id> --reconfirm` (re-stamps, no session).
36
37
 
37
38
  ## Inputs (rebuild them, never remember them)
38
39
 
39
40
  ```bash
40
- cat specs/<spec-id>.md # the parent spec — Behavior is your coverage contract
41
- $WITNESS index # sibling specs, for pattern consistency
42
- cat designs/<spec-id>.html 2>/dev/null # the current look, in amend mode
41
+ $WITNESS read <spec-id> # the parent spec — Behavior is your coverage contract
42
+ $WITNESS index # sibling specs, for pattern consistency
43
+ $WITNESS read <spec-id> --design # the current look, in amend mode; its `no-design`
44
+ # refusal IS the new-screen signal, so read it, don't hide it
43
45
  # the repo's design canon, if configured — read docs.design from witness.config.yaml:
44
46
  # docs: { design: [docs/ui/design-language.md, ...] } → read each; it is the blocking authority
45
47
  ```
@@ -15,12 +15,13 @@ 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.11.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
22
22
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
23
23
  - **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.)
24
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
24
25
  - **Never invoke gate reviewers or relay verdicts.** `witness gate` runs reviewers itself and journals what they said; your summary of a verdict is not evidence.
25
26
  - **Refusal repair loop:** a `witness` verb exiting 2 prints structured violations (`field · rule · got · want`). Fix your input and retry — **3 total attempts** per artifact, then stop, show the human the violation list verbatim, and end your turn.
26
27
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.
@@ -34,7 +35,7 @@ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.11.0}"
34
35
  $WITNESS start <plan-id>
35
36
  ```
36
37
 
37
- Prints the worktree path (`.witness/worktrees/<plan-id>`, branch `witness/<plan-id>`) and `agent-model:` — the config's implement-stage model pin (`gates.implement.model`, falling back to `gates.model`). Re-entry is safe: `in-progress` re-attaches or recreates. Refusals go to the human, with the remedy the CLI names: `blocked-deps` (a dependency isn't live/done yet), `needs-unmet` (show `witness satisfy …` for manual needs), `not-approved`.
38
+ Prints the worktree path (`.witness/worktrees/<plan-id>`, branch `witness/<plan-id>`) and `agent-model:`. The worktree carries **code only** — the canon dirs are excluded from it deliberately, so the plan you are implementing is not a file in there; `$WITNESS read <plan-id>` is how you read it, and what comes back is current rather than whatever was true when the worktree was cut. `agent-model:` is the config's implement-stage model pin (`gates.implement.model`, falling back to `gates.model`). Re-entry is safe: `in-progress` re-attaches or recreates. Refusals go to the human, with the remedy the CLI names: `blocked-deps` (a dependency isn't live/done yet), `needs-unmet` (show `witness satisfy …` for manual needs), `not-approved`.
38
39
 
39
40
  ## Work the plan in slices (session relay)
40
41
 
@@ -48,15 +49,15 @@ Protocol per step, in order — red/green/refactor with witnessed evidence:
48
49
  4. Run `$WITNESS test-evidence <plan-id> --phase green` — it must record green.
49
50
  5. Refactor freely while green; leave everything uncommitted — the worktree stays dirty by design.
50
51
  6. Early exit: if you exceed **~15 inner-loop iterations** inside one step, or you have left the step's scope to fight infrastructure, the economy is to relay — finish the red→green you are in (never stop mid-red), report, and relay so a fresh context continues. Exceeding your slice is never the right economy.
51
- 7. Fat artifacts (the living designs/<spec>.html, long docs) are read **by section** — grep for the anchors/ids you need, then read those offsets — never whole into your context; you would re-pay their tokens on every subsequent request.
52
+ 7. Fat artifacts (the living design, long docs) are read **by section** — `$WITNESS read <spec-id> --design --outline` names every anchor with its line range, then `--lines <a>-<b>` fetches the one you need — never whole into your context; you would re-pay their tokens on every subsequent request.
52
53
 
53
54
  **Tests you did not come to change.** When a step makes you edit a test file tagged for a spec other than your parent — a shared fixture, a helper, a suite-wide rename — you owe that spec **green now**, never a red→green pair: `red` means you observed the behavior missing before you built it, and you are not building that spec. The gate's `regression` check runs those specs' tagged tests and fails if any is red. Fix them in the worktree; never journal evidence under a tag this plan does not own, and never weaken the foreign test to pass.
54
55
 
55
56
  UI work TDDs at the browser, end-to-end: when a step changes what the browser renders or how the user interacts with it (markup, styles, routes, client-side behavior), the failing test in step 1 is a **Puppeteer** test driving the real UI **through the whole slice** — headless, tagged in its NAME like any test, living in the repo's regular suite so the criteria runner reaches it (Puppeteer is a library, not a runner — a test the runner can't reach has unwitnessable evidence). Everything the repo owns runs real: the test owns the slice's lifecycle — build, boot the backend and its store, serve the frontend; never assume a running dev server (evidence runs in a clean worktree) — but inside your own inner loop, keep the app server alive across iterations where the harness allows; the per-run floor is boot + compile, not test time. Fake only what the repo does not own (third-party services). Stubbing the slice's own backend — request interception, mocked fetch, canned API fixtures — turns the test UI-only, and the implement gate's pr-test lens flags it like any substitution. Unit/component tests may accompany a browser test, never replace it.
56
57
 
57
- **Capture the screens you build.** When `$WITNESS_SCREENS_DIR` is set (the CLI exports it during `test-evidence`), the browser test writes a named PNG per screen-level moment the step produces — `$WITNESS_SCREENS_DIR/<name>.png` via the driver's screenshot call. Name captures for the moments a reviewer must see, not every frame: `initial` (screen as it opens), `filled` (primary inputs populated), `error` (a validation/error state the slice can reach), `success` (the post-action confirmation). One capture per meaningful state; skip states the step doesn't touch. Guard the writes on the env var so the same test screenshots nothing during the gate's own drift lane. These captures are the design-reviewer's evidence — `test-evidence` witnesses a sha per PNG, and at the gate a **vision `design-reviewer`** (feature and fix batteries, only when the plan pins a `design-from` design) judges them against the repo's `docs.design` canon and the approved `designs/<spec>.html` look. A UI plan that witnesses zero captures is refused `screens-matched-nothing` before any reviewer runs — a screen with no screenshot has unwitnessable design evidence, exactly as a browser test outside the runner has unwitnessable behavior evidence.
58
+ **Capture the screens you build.** When `$WITNESS_SCREENS_DIR` is set (the CLI exports it during `test-evidence`), the browser test writes a named PNG per screen-level moment the step produces — `$WITNESS_SCREENS_DIR/<name>.png` via the driver's screenshot call. Name captures for the moments a reviewer must see, not every frame: `initial` (screen as it opens), `filled` (primary inputs populated), `error` (a validation/error state the slice can reach), `success` (the post-action confirmation). One capture per meaningful state; skip states the step doesn't touch. Guard the writes on the env var so the same test screenshots nothing during the gate's own drift lane. These captures are the design-reviewer's evidence — `test-evidence` witnesses a sha per PNG, and at the gate a **vision `design-reviewer`** (feature and fix batteries, only when the plan pins a `design-from` design) judges them against the repo's `docs.design` canon and the approved look (`$WITNESS read <spec-id> --design`). A UI plan that witnesses zero captures is refused `screens-matched-nothing` before any reviewer runs — a screen with no screenshot has unwitnessable design evidence, exactly as a browser test outside the runner has unwitnessable behavior evidence.
58
59
 
59
- Never touch `specs/` or `plans/` (state lives on main; the CLI routes it). Never `git commit`, never push, never open PRs — ship is a later stage and owns the sole code commit (evidence and gates read the working tree directly, so nothing needs committing here). Scaffolding steps skip evidence (nothing tagged changes). When every step is done, report the worktree diff summary.
60
+ Never touch `specs/` or `plans/` (state lives on main; the CLI routes it) — they are not in your worktree at all, and `$WITNESS read <id>` is the route to their current content. Never `git commit`, never push, never open PRs — ship is a later stage and owns the sole code commit (evidence and gates read the working tree directly, so nothing needs committing here). Scaffolding steps skip evidence (nothing tagged changes). When every step is done, report the worktree diff summary.
60
61
 
61
62
  **Relay at the slice boundary.** When your slice's steps are done (or you early-exited), report telemetry —
62
63
 
@@ -87,4 +88,4 @@ Its deterministic checks are the exit bar: a diff exists, every diff-added/modif
87
88
  - **Stop** (blocking findings or `--manual`) → render the gate output verbatim and in full, including its ranked options and `run:` line, and END YOUR TURN.
88
89
  - **Re-entered after `--revise`** → `decide --show` for the verdict + note (code findings anchor to `path#symbol`); fix in the worktree yourself under the same TDD protocol (new behavior = new red first); re-gate. Rounds are counted by the CLI; at the bound it forces the human. `--show` also emits `state:` and `exits:` — a `reopened` or `settled` state means the verdict above it is history, so act on the `exits:` line, not on remembered findings.
89
90
  - Findings implicate the **plan** (code faithful, plan wrong)? Tell the human `--revise --upstream <plan-id>` reopens the plan stage.
90
- - A **design-reviewer** stop anchors to a capture filename (e.g. `initial.png`) — the finding is about the rendered screen, not the code. Fix the UI in the worktree, re-run the browser test so `test-evidence` witnesses the new capture, then re-gate; the living `designs/<spec>.html` is the approved direction to fix toward.
91
+ - A **design-reviewer** stop anchors to a capture filename (e.g. `initial.png`) — the finding is about the rendered screen, not the code. Fix the UI in the worktree, re-run the browser test so `test-evidence` witnesses the new capture, then re-gate; `$WITNESS read <spec-id> --design` is the approved direction to fix toward.
@@ -15,12 +15,13 @@ 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.11.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
22
22
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
23
23
  - **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.)
24
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
24
25
  - **Never invoke gate reviewers or relay verdicts.** `witness gate` runs reviewers itself and journals what they said; your summary of a verdict is not evidence.
25
26
  - **Refusal repair loop:** a `witness` verb exiting 2 prints structured violations (`field · rule · got · want`). Fix your input and retry — **3 total attempts** per artifact, then stop, show the human the violation list verbatim, and end your turn.
26
27
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.
@@ -28,13 +29,13 @@ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.11.0}"
28
29
 
29
30
  ## Inputs (rebuild them, never remember them)
30
31
 
31
- Everything below is rebuilt from `witness diff` and current files — never from conversation memory.
32
+ Everything below is rebuilt from `witness diff` and the CLI's read verbs — never from conversation memory, never by path.
32
33
 
33
34
  ```bash
34
35
  $WITNESS diff <spec-id> # the delta this plan must realize (base: previous plan's pin → last live → empty)
35
- cat specs/<spec-id>.md # the parent spec, current content (reading is fine — writing is not)
36
- ls plans/ && cat plans/<spec-id>-plan-*.md # prior plans for this spec, if any
37
- # (default layout shown — a repo's `paths:` config may relocate specs/ and plans/)
36
+ $WITNESS read <spec-id> # the parent spec, current content (reading is fine — writing is not)
37
+ $WITNESS index # the plans table names this spec's prior plans, if any
38
+ $WITNESS read <plan-id> # …then read the one you care about
38
39
  $WITNESS decide plan <plan-id> --show # ONLY when re-entered after a revise
39
40
  ```
40
41
 
@@ -46,7 +47,7 @@ $WITNESS decide plan <plan-id> --show # ONLY when re-entered after a revis
46
47
 
47
48
  Every criterion in the delta must be realized by ≥ 1 step; every step maps to ≥ 1 criterion **or** is honestly `scaffolding: true` (rigging only — fixtures, wiring, config; never behavior a criterion owns). `derives-from` is **stamped by the CLI** from the parent's current content — never put it in the manifest; a supplied stale pin refuses.
48
49
 
49
- If the parent spec is `ui`-flagged, its **design must already be approved** (the design stage runs between decompose and plan). Read `designs/<spec-id>.html` — your steps derive from that approved look, not a fresh invention — and put its approved artifact sha in the manifest as `"design-from"` (the CLI refuses a plan whose pin is missing, stale, or present on a non-ui parent; get it from the spec's `design.sha` stamp via `$WITNESS log <spec-id>`). A UI step names the design section (`design#<id>`) it realizes alongside its `@spec:` browser test.
50
+ If the parent spec is `ui`-flagged, its **design must already be approved** (the design stage runs between decompose and plan). Read it with `$WITNESS read <spec-id> --design` (`--outline`, then `--lines <a>-<b>`, when it is fat) — your steps derive from that approved look, not a fresh invention — and put its approved artifact sha in the manifest as `"design-from"` (the CLI refuses a plan whose pin is missing, stale, or present on a non-ui parent; get it from the spec's `design.sha` stamp via `$WITNESS log <spec-id>`). A UI step names the design section (`design#<id>`) it realizes alongside its `@spec:` browser test.
50
51
 
51
52
  ```bash
52
53
  DIR=$(mktemp -d)
@@ -15,12 +15,13 @@ 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.11.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.12.0}"
19
19
  ```
20
20
 
21
21
  - **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
22
22
  - **The human decides; you may type it.** Run a `witness decide` verb only when the human **names an option** — its number or its verb — and then run the **printed string byte-for-byte**: never recomposed, never reformatted, never with a placeholder you resolved yourself. The moment you compose a `--note` or resolve an id, you are authoring their decision. A bare affirmation ("ok", "sounds good", "yes") **is not a selection** — ask which option, especially where option 1 is `--approve` at a stop that exists because a human must look. A selection does not survive session death: killed and re-run, render the block again and ask again.
23
23
  - **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.)
24
+ - **Read canon with `witness read <id>`, never by path.** Canon lives at the primary root; inside a worktree the files are **absent by design**, so a path read finds nothing and a stale copy cannot be mistaken for the contract. Fat artifacts: `witness read <spec-id> --design --outline`, then `--lines <a>-<b>`.
24
25
  - **Never invoke gate reviewers or relay verdicts.** `witness gate` runs reviewers itself and journals what they said; your summary of a verdict is not evidence.
25
26
  - **Refusal repair loop:** a `witness` verb exiting 2 prints structured violations (`field · rule · got · want`). Fix your input and retry — **3 total attempts** per artifact, then stop, show the human the violation list verbatim, and end your turn.
26
27
  - **A refused or hook-blocked command is a stop, not a step to drop.** Re-issue it on its own; if it still refuses, tell the human what was blocked and why. Never proceed by deleting the refused half of a compound command — a dropped step is silent, and silence is how a skipped check becomes a shipped defect.