@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 +6 -2
- package/dist/cli.js +4 -0
- package/dist/gate.js +3 -2
- package/dist/recommend.js +29 -4
- package/dist/rounds.js +9 -2
- package/dist/verbs/check.js +35 -2
- package/dist/verbs/decide.js +9 -3
- package/dist/verbs/index-verb.js +14 -1
- package/dist/verbs/next.js +1 -1
- package/dist/verbs/read.js +116 -0
- package/dist/worktree.js +103 -3
- package/package.json +3 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/canon-guard.mjs +165 -7
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +2 -1
- package/plugin/skills/witness-decompose/SKILL.md +3 -2
- package/plugin/skills/witness-design/SKILL.md +7 -5
- package/plugin/skills/witness-implement/SKILL.md +7 -6
- package/plugin/skills/witness-plan/SKILL.md +7 -6
- package/plugin/skills/witness-ship/SKILL.md +2 -1
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
|
|
100
|
-
//
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
package/dist/verbs/check.js
CHANGED
|
@@ -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 : [];
|
package/dist/verbs/decide.js
CHANGED
|
@@ -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
|
|
136
|
-
//
|
|
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({
|
|
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,
|
package/dist/verbs/index-verb.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/verbs/next.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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** —
|
|
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
|
-
|
|
41
|
-
$WITNESS index
|
|
42
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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.
|
|
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
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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.
|
|
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.
|