@popovych.co/witness 0.14.6 → 0.14.8

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/dist/abandon.js CHANGED
@@ -1,19 +1,22 @@
1
- import { unlinkSync, writeFileSync } from 'node:fs';
1
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ import { prepareDeparture, prepareReturns } from './departure.js';
4
+ import { designRel } from './design.js';
3
5
  import { splitDoc } from './fm.js';
4
6
  import { canonicalSha } from './sha.js';
5
- import { tryGit, stateCommit } from './gitio.js';
7
+ import { gitBlob, tryGit, stateCommit } from './gitio.js';
6
8
  import { acquireLock } from './lock.js';
7
9
  import { crashPoint, withTxn } from './txn.js';
8
- import { appendEntry, entryLine, journalRel } from './journal.js';
9
- import { findById } from './scan.js';
10
+ import { appendEntry, effortAbandoned, effortStreams, entryLine, journalRel, readStream } from './journal.js';
11
+ import { findById, plansOn } from './scan.js';
10
12
  import { effortOf, effortWrites } from './reviewed.js';
11
- import { ok, refuse, v } from './refusal.js';
13
+ import { humanCall, ok, refuse, v } from './refusal.js';
12
14
  import { prepareStamp, writeStamp } from './stamp.js';
13
15
  import { kv } from './toon.js';
14
16
  import { removeWorktree, worktreePath } from './worktree.js';
15
- export function pinCommit(root, rel, pin) {
16
- const log = tryGit(root, 'log', '--format=%H', '--', rel);
17
+ export function pinCommit(root, rel, pin, from) {
18
+ // `from`: walk history from that revision instead of HEAD — for a file a later commit deleted
19
+ const log = tryGit(root, 'log', '--format=%H', ...(from === undefined ? [] : [from]), '--', rel);
17
20
  if (!log.ok)
18
21
  return undefined;
19
22
  // Status-only touch-ups (approve, stampLive, a gate's writeStamp) commit the same
@@ -33,15 +36,75 @@ export function pinCommit(root, rel, pin) {
33
36
  }
34
37
  return found;
35
38
  }
36
- export function specRevertFor(root, canon, spec, pin, abandonSet) {
39
+ export const keptLine = (k) => `${String(k.doc.meta.id)} — not unwound · still standing on it: ${k.standing.join(' ')}`;
40
+ // D167. The abandon says everything it did to canon, on both paths, as `kept:` already did: what
41
+ // it reverted, what came back with it, and what the walk took back on the way.
42
+ export function abandonLines(items, kept) {
43
+ return [
44
+ ...items.flatMap((item) => [
45
+ ...(item.revert ? [kv('reverted', `${String(item.revert.doc.meta.id)} (${item.revert.action}) — journal untouched`)] : []),
46
+ ...(item.revert?.returns ?? []).flatMap((r) => [
47
+ kv('returned', `${r.entry.artifact} — back in canon as ${r.entry.to} · the re-slice into ${r.by} is taken back`),
48
+ ...r.unwound.map((u) => kv('unwound', `${u.artifact} — stays out of canon · an abandoned effort created it`)),
49
+ ]),
50
+ ...(item.kept ? [kv('kept', keptLine(item.kept))] : []),
51
+ ]),
52
+ ...kept.map((k) => kv('kept', keptLine(k))),
53
+ ];
54
+ }
55
+ // D166 (review). The writes the way back takes back besides this abandon's own targets: every
56
+ // write of an effort already abandoned — whose abandon skipped a spec a later re-slice had
57
+ // departed — and every write of the effort being abandoned now (`effort`, absent on the plan
58
+ // path). Content one of them wrote never comes back into canon on its own — unless a plan
59
+ // outside the abandon set stands on it: the keep rule, which never takes shipped content back.
60
+ function takenBackBy(root, canon, abandonSet, effort) {
61
+ const gone = effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
62
+ return (id, sha, rel, at) => plansOn(canon, id, { exclude: abandonSet, pin: sha }).length === 0 &&
63
+ gone.some((e) => readStream(root, e).some((x) => x.t === 'write' && x.artifact === id && x.sha === sha))
64
+ ? pinCommit(root, rel, sha, at)
65
+ : undefined;
66
+ }
67
+ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
37
68
  const current = canonicalSha(spec.meta, spec.body);
38
69
  if (current !== pin) {
39
70
  return refuse([v('spec', 'stacked-amendment', `${String(spec.meta.id)} is at ${current.slice(0, 7)}, pin is ${pin.slice(0, 7)}`, 'abandon newest-first: a later amendment touched this spec')]);
40
71
  }
72
+ // D166. After "newest-first" (row 84 stands) and ahead of every precondition of the unwind
73
+ // itself — a resolvable pin, no waiting dependents — because a kept spec unwinds nothing and
74
+ // dangles nothing. Same pin in both branches: the siblings a delete would orphan were all
75
+ // derived from the content it removes, and a restore moves toward older pins.
76
+ const standing = plansOn(canon, String(spec.meta.id), { exclude: abandonSet, pin });
77
+ if (standing.length > 0) {
78
+ return ok({
79
+ doc: spec, action: 'keep', standing: standing.map((p) => String(p.meta.id)),
80
+ shipped: standing.some((p) => String(p.meta.status) === 'done'),
81
+ });
82
+ }
41
83
  const commit = pinCommit(root, spec.rel, pin);
42
84
  if (!commit) {
43
85
  return refuse([v('spec', 'pin-unresolvable', pin.slice(0, 7), 'a committed version matching the pin')]);
44
86
  }
87
+ // D166. The way back: the write being taken back may have departed a predecessor (a
88
+ // re-slice), which then returns with it. A re-slice can cut one spec into several slices, and
89
+ // taking back one while another still stands would leave the predecessor half-described, so
90
+ // that write is kept until its siblings go too — together, through the effort that made them.
91
+ const returns = prepareReturns(root, canon.paths, commit, String(spec.meta.id), takenBackBy(root, canon, abandonSet, effort));
92
+ // D166 (review). A sibling slice stands when this abandon leaves it in canon: it is outside the
93
+ // abandon set, or inside it but kept because a plan outside the set stands on it. It stands for
94
+ // good when a plan shipped it — and then so does this keep, since nothing can take the
95
+ // sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
96
+ const cut = new Set(returns.flatMap((r) => [r.entry.artifact, ...r.unwound.map((u) => u.artifact)]));
97
+ const slices = canon.docs
98
+ .filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== String(spec.meta.id))
99
+ .flatMap((d) => {
100
+ const id = String(d.meta.id);
101
+ const leaving = abandonSet.has(id);
102
+ const on = plansOn(canon, id, leaving ? { exclude: abandonSet, pin: canonicalSha(d.meta, d.body) } : { exclude: abandonSet });
103
+ return leaving && on.length === 0 ? [] : [{ id, shipped: on.some((p) => String(p.meta.status) === 'done') }];
104
+ });
105
+ if (slices.length > 0) {
106
+ return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
107
+ }
45
108
  const prior = tryGit(root, 'show', `${commit}^:${spec.rel}`);
46
109
  if (!prior.ok) {
47
110
  const dependents = canon.docs
@@ -51,17 +114,52 @@ export function specRevertFor(root, canon, spec, pin, abandonSet) {
51
114
  if (dependents.length > 0) {
52
115
  return refuse([v('spec', 'waiting-dependents', dependents.join(' '), 'no docs outside the abandon set may depend on a spec being deleted')]);
53
116
  }
54
- return ok({ doc: spec, action: 'delete' });
117
+ // D166. The unwind departs the spec through the same door the re-slice uses, so
118
+ // everything that leaves canon with a spec — its design included — leaves here too.
119
+ return ok({
120
+ doc: spec, action: 'delete', returns,
121
+ departure: prepareDeparture(root, canon.paths, spec, {
122
+ v: 1, t: 'status', artifact: String(spec.meta.id), from: String(spec.meta.status), to: 'removed', cause: 'abandon',
123
+ }),
124
+ });
55
125
  }
56
126
  const parsed = splitDoc(prior.out);
57
127
  const toStatus = parsed.ok ? String(parsed.value.meta.status) : 'draft';
58
- return ok({ doc: spec, action: 'restore', content: prior.out, toStatus });
128
+ // D166. The look goes back with the content it was drawn for: the design as it stood before
129
+ // the commit being taken back, byte for byte — or none, if there was none then.
130
+ const designR = designRel(canon.paths, String(spec.meta.id));
131
+ const priorDesign = gitBlob(root, `${commit}^`, designR);
132
+ const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
133
+ return ok({ doc: spec, action: 'restore', content: prior.out, toStatus, design, returns });
134
+ }
135
+ // D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
136
+ // file, so another doc may hold that path — or another spec this abandon brings back may have
137
+ // left from it too. Written over, either left canon with nothing to say so. Only a doc this same
138
+ // abandon departs may stand there; any other holder, or a second return, is a human's call.
139
+ export function returnCollisions(root, canon, items) {
140
+ const departing = new Set(items.flatMap((i) => (i.revert?.action === 'delete' ? [i.revert.doc.rel] : [])));
141
+ const returns = items.flatMap((i) => (i.revert?.returns ?? []).map((r) => ({ r, going: String(i.revert.doc.meta.id) })));
142
+ const landing = new Map();
143
+ for (const { r } of returns) {
144
+ const ids = landing.get(r.rel) ?? [];
145
+ if (!ids.includes(r.entry.artifact))
146
+ landing.set(r.rel, [...ids, r.entry.artifact]);
147
+ }
148
+ return [
149
+ ...returns.filter(({ r }) => !departing.has(r.rel) && existsSync(join(root, r.rel))).map(({ r, going }) => {
150
+ const held = canon.docs.find((d) => d.rel === r.rel);
151
+ const holder = held ? String(held.meta.id) : 'a doc canon cannot read';
152
+ return humanCall(v('spec', 'path-taken', `${r.rel} holds ${holder}`, `that path free first — ${r.entry.artifact} comes back to it when ${going} goes · what becomes of ${holder} is a human's call`));
153
+ }),
154
+ ...[...landing].filter(([, ids]) => ids.length > 1).map(([rel, ids]) => humanCall(v('spec', 'path-taken', `${rel} takes ${ids.join(' and ')}`, `one return per path — each left from it · which one comes back is a human's call`))),
155
+ ];
59
156
  }
60
157
  export function executeAbandon(root, ctx, items, effortEntry, subject) {
61
158
  const files = new Set();
62
159
  const journalMulti = [];
63
160
  const stamps = [];
64
161
  const specEntries = [];
162
+ const returnEntries = [];
65
163
  for (const item of items) {
66
164
  if (item.planStamp) {
67
165
  stamps.push(item.planStamp);
@@ -72,15 +170,30 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
72
170
  if (item.revert) {
73
171
  const spec = item.revert.doc;
74
172
  const id = String(spec.meta.id);
75
- const entry = {
76
- v: 1, t: 'status', artifact: id, from: String(spec.meta.status),
77
- to: item.revert.action === 'restore' ? item.revert.toStatus : 'removed',
78
- cause: 'abandon',
173
+ const entry = item.revert.action === 'delete' ? item.revert.departure.entry : {
174
+ v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: item.revert.toStatus, cause: 'abandon',
79
175
  };
80
176
  specEntries.push({ stream: id, entry });
81
- files.add(spec.rel);
82
- files.add(journalRel(id));
177
+ const touched = item.revert.action === 'delete'
178
+ ? item.revert.departure.files
179
+ : [spec.rel, journalRel(id), ...(item.revert.design ? [item.revert.design.rel] : [])];
180
+ for (const f of touched)
181
+ files.add(f);
83
182
  journalMulti.push({ stream: id, line: entryLine(entry) });
183
+ // D166. A spec this write had departed comes back in the same state commit.
184
+ for (const r of item.revert.returns) {
185
+ const lines = [
186
+ ...[r.entry, ...r.unwound].map((e) => ({ stream: e.artifact, entry: e })),
187
+ ...r.moves,
188
+ ];
189
+ for (const f of r.files)
190
+ files.add(f);
191
+ for (const l of lines) {
192
+ files.add(journalRel(l.stream));
193
+ journalMulti.push({ stream: l.stream, line: entryLine(l.entry) });
194
+ }
195
+ returnEntries.push(...lines);
196
+ }
84
197
  }
85
198
  }
86
199
  let effortLine;
@@ -100,17 +213,30 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
100
213
  return withTxn(root, { op: 'abandon', files: [...files], journalMulti }, () => {
101
214
  for (const item of items) {
102
215
  if (item.revert) {
103
- const abs = join(root, item.revert.doc.rel);
104
- if (item.revert.action === 'restore')
105
- writeFileSync(abs, item.revert.content);
106
- else
107
- unlinkSync(abs); // stateCommit's `git add` below correctly stages a tracked-but-missing file as a deletion
216
+ // stateCommit's `git add` below stages a tracked-but-missing file as a deletion
217
+ if (item.revert.action === 'delete')
218
+ item.revert.departure.remove();
219
+ else {
220
+ writeFileSync(join(root, item.revert.doc.rel), item.revert.content);
221
+ const d = item.revert.design;
222
+ if (d?.prior !== undefined)
223
+ writeFileSync(join(root, d.rel), d.prior);
224
+ else if (d !== undefined && existsSync(join(root, d.rel)))
225
+ unlinkSync(join(root, d.rel));
226
+ }
108
227
  }
109
228
  }
229
+ // D167. Every departure lands before any return (P9b): restored per item, a later item's
230
+ // departure unlinked the spec an earlier item's return had just brought back to its path.
231
+ for (const item of items)
232
+ for (const r of item.revert?.returns ?? [])
233
+ r.restore();
110
234
  for (const s of stamps)
111
235
  writeStamp(root, s);
112
236
  for (const se of specEntries)
113
237
  appendEntry(root, se.stream, se.entry);
238
+ for (const re of returnEntries)
239
+ appendEntry(root, re.stream, re.entry);
114
240
  if (effortLine) {
115
241
  appendEntry(root, effortLine.stream, JSON.parse(effortLine.line));
116
242
  }
@@ -153,7 +279,11 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
153
279
  const revertR = specRevertFor(root, canon, parent, String(plan.meta['derives-from']), abandonSet);
154
280
  if (!revertR.ok)
155
281
  return revertR;
156
- item.revert = revertR.value;
282
+ const outcome = revertR.value;
283
+ if (outcome?.action === 'keep')
284
+ item.kept = outcome;
285
+ else
286
+ item.revert = outcome;
157
287
  }
158
288
  }
159
289
  return ok(item);
package/dist/adopt.js CHANGED
@@ -6,9 +6,11 @@ import { readStream } from './journal.js';
6
6
  import { canonicalSha } from './sha.js';
7
7
  const hasTrailer = (root, sha) => git(root, 'show', '-s', '--format=%B', sha).split('\n').some((l) => l.trim() === TRAILER);
8
8
  const commitsTouching = (root, rel) => git(root, 'log', '--format=%H', '--', rel).split('\n').filter(Boolean);
9
- export function lastWitnessed(root, rel) {
9
+ // `absolved`: commits an `adopt` entry took in (`adoptedCommits`), which count as witnessed too —
10
+ // a hand commit that `adopt` absolved is the doc's witnessed state from then on.
11
+ export function lastWitnessed(root, rel, absolved = new Set()) {
10
12
  for (const sha of commitsTouching(root, rel)) {
11
- if (!hasTrailer(root, sha))
13
+ if (!hasTrailer(root, sha) && !absolved.has(sha))
12
14
  continue;
13
15
  const raw = tryGit(root, 'show', `${sha}:${rel}`);
14
16
  if (!raw.ok)
@@ -16,7 +18,7 @@ export function lastWitnessed(root, rel) {
16
18
  const doc = splitDoc(raw.out);
17
19
  if (!doc.ok)
18
20
  continue;
19
- return { sha: canonicalSha(doc.value.meta, doc.value.body), criteria: doc.value.meta.criteria ?? null };
21
+ return { sha: canonicalSha(doc.value.meta, doc.value.body), criteria: doc.value.meta.criteria ?? null, id: String(doc.value.meta.id) };
20
22
  }
21
23
  return undefined;
22
24
  }
package/dist/deferral.js CHANGED
@@ -41,3 +41,15 @@ export function deferralsBlock(open) {
41
41
  'is read as "still present".\n\n' +
42
42
  open.map((d, i) => `${i + 1}. ${d.anchor} — ${d.gate} round ${d.round} (${d.id})`).join('\n') + '\n\n';
43
43
  }
44
+ // D122 + D166. A debt changes homes when the artifact holding it stops being the one the next
45
+ // effort will meet: a plan's merge hands its open obligations to the parent spec (stamp.ts), a
46
+ // re-slice hands the superseded spec's to its successor (write.ts), and taking a re-slice back
47
+ // hands them home again (departure.ts, `which` picking the ones that came from there). One
48
+ // rule for all three: the id is preserved, because age is measured across the move, and the
49
+ // old stream records where each debt went.
50
+ export function deferralMoves(entries, from, to, which = () => true) {
51
+ return openDeferrals(entries).filter(which).flatMap((d) => [
52
+ { stream: from, entry: { v: 1, t: 'deferral-moved', id: d.id, to } },
53
+ { stream: to, entry: { ...d, artifact: to, moved_from: from } },
54
+ ]);
55
+ }
@@ -0,0 +1,169 @@
1
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { deferralMoves } from './deferral.js';
4
+ import { designRel } from './design.js';
5
+ import { FLOOR_STREAM } from './floor.js';
6
+ import { splitDoc } from './fm.js';
7
+ import { adoptedCommits, lastWitnessed } from './adopt.js';
8
+ import { gitBlob, tryGit } from './gitio.js';
9
+ import { journalRel, readStream } from './journal.js';
10
+ import { v } from './refusal.js';
11
+ import { findById } from './scan.js';
12
+ import { canonicalSha } from './sha.js';
13
+ // D166. A spec leaves canon through exactly two verbs — the re-slice (row 62), which
14
+ // journals `superseded` and names the successor, and abandon's unwind of a spec its own
15
+ // effort created (row 29), which journals `removed`. This module is the one home for that
16
+ // act: how a departure is read, what it takes out of canon, how it is taken back, and which
17
+ // ids it retires. What may still point INTO a departing spec is settled by the two verbs
18
+ // that depart one.
19
+ // The status entry with which an artifact LEFT canon, if a witness verb took it out: the
20
+ // stream's LATEST status entry, when it is a departure. Latest, not any — a departure taken
21
+ // back, or a removed spec written again, is followed by a later status and reads as present.
22
+ // Callers consult canon first: this answers only for an id that canon no longer holds.
23
+ export function canonExit(entries) {
24
+ const last = entries.filter((e) => e.t === 'status').at(-1);
25
+ return last !== undefined && (last.to === 'superseded' || last.to === 'removed') ? last : undefined;
26
+ }
27
+ // Every stream whose artifact left canon. An obligation recorded there is still open until it
28
+ // is dismissed (D122), so the ledger reads these streams too.
29
+ export function departedStreams(root, canon) {
30
+ const dir = join(root, '.witness', 'journal');
31
+ if (!existsSync(dir))
32
+ return [];
33
+ return readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length))
34
+ .filter((id) => !findById(canon, id) && canonExit(readStream(root, id)) !== undefined);
35
+ }
36
+ // What leaves canon WITH a spec. Its design is spec-scoped (row 69) and goes with it: the
37
+ // re-slice always deleted it, while the unwind left a ui spec's look behind in canon after the
38
+ // spec it described was gone. A design is listed only when present, because a state commit
39
+ // cannot stage a path that never existed.
40
+ export function prepareDeparture(root, paths, spec, entry) {
41
+ const design = designRel(paths, String(spec.meta.id));
42
+ const leaving = [spec.rel, ...(existsSync(join(root, design)) ? [design] : [])];
43
+ return {
44
+ entry,
45
+ files: [...leaving, journalRel(String(spec.meta.id))],
46
+ remove: () => { for (const rel of leaving)
47
+ unlinkSync(join(root, rel)); },
48
+ };
49
+ }
50
+ // The way back. Row 29 unwinds a write by selectively reverting its state commit, and a
51
+ // re-slice's commit did two things: it wrote the successor and departed its predecessor.
52
+ // Taking that write back therefore brings the predecessor back — its file and design as they
53
+ // stood before the commit, byte for byte, a status entry that ends the departure, and the
54
+ // obligations that had followed it to the successor. Without this, abandoning the effort that
55
+ // re-sliced a shipped spec left canon describing none of what had shipped, under an id no
56
+ // verb could use again.
57
+ //
58
+ // A return never lands content an abandoned effort wrote: that write is taken back too. An
59
+ // earlier abandon skips a spec a later re-slice had already departed, and one effort can
60
+ // re-slice its own re-slice, so the predecessor of the write being taken back may itself be
61
+ // work no live effort owns. The walk goes back past each such write — to the content before an
62
+ // amendment, or, past a create, to whatever that create had departed in its turn — so canon
63
+ // ends where it stood before every abandoned write, whatever order the efforts went in.
64
+ export function prepareReturns(root, paths, commit, successor, takenBack = () => undefined) {
65
+ return returnsThrough(root, paths, commit, [successor], [], takenBack, new Set([commit]));
66
+ }
67
+ function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
68
+ const successor = chain.at(-1);
69
+ const shown = tryGit(root, 'show', '--no-renames', '--name-status', '--format=', commit);
70
+ if (!shown.ok)
71
+ return [];
72
+ const deleted = shown.out.split('\n').filter((l) => l.startsWith('D\t')).map((l) => l.slice(2));
73
+ return deleted.flatMap((rel) => {
74
+ if (!rel.startsWith(`${paths.specs}/`) || !rel.endsWith('.md'))
75
+ return [];
76
+ let src = `${commit}^`;
77
+ let blob = gitBlob(root, src, rel);
78
+ if (blob === undefined)
79
+ return [];
80
+ let parsed = splitDoc(blob.toString('utf8'));
81
+ if (!parsed.ok)
82
+ return [];
83
+ const id = String(parsed.value.meta.id);
84
+ const exit = canonExit(readStream(root, id));
85
+ if (exit?.to !== 'superseded' || exit.by !== successor)
86
+ return [];
87
+ let status = exit.from;
88
+ for (;;) {
89
+ const back = takenBack(id, canonicalSha(parsed.value.meta, parsed.value.body), rel, src);
90
+ if (back === undefined || seen.has(back))
91
+ break;
92
+ seen.add(back);
93
+ const before = gitBlob(root, `${back}^`, rel);
94
+ if (before === undefined) {
95
+ // That write created it: it stays out, and what it had departed comes back instead.
96
+ const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
97
+ return returnsThrough(root, paths, back, [...chain, id], [...unwound, gone], takenBack, seen);
98
+ }
99
+ const prior = splitDoc(before.toString('utf8'));
100
+ if (!prior.ok)
101
+ break;
102
+ src = `${back}^`;
103
+ blob = before;
104
+ parsed = prior;
105
+ status = String(prior.value.meta.status);
106
+ }
107
+ const bytes = blob;
108
+ const design = designRel(paths, id);
109
+ const designBlob = deleted.includes(design) ? gitBlob(root, src, design) : undefined;
110
+ const put = (r, b) => {
111
+ mkdirSync(dirname(join(root, r)), { recursive: true }); // git keeps no empty directory: a fresh clone may lack it
112
+ writeFileSync(join(root, r), b);
113
+ };
114
+ return [{
115
+ entry: { v: 1, t: 'status', artifact: id, from: 'superseded', to: status, cause: 'abandon' },
116
+ files: [rel, ...(designBlob === undefined ? [] : [design]), journalRel(id)],
117
+ restore: () => {
118
+ put(rel, bytes);
119
+ if (designBlob !== undefined)
120
+ put(design, designBlob);
121
+ },
122
+ // Each link of the chain hands home the debts that came to it from the next one down.
123
+ moves: chain.flatMap((s, k) => deferralMoves(readStream(root, s), s, id, (d) => d.moved_from === (chain[k + 1] ?? id))),
124
+ unwound,
125
+ rel,
126
+ by: successor,
127
+ }];
128
+ });
129
+ }
130
+ // Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
131
+ // because its stream IS that artifact's history. Three streams already belong to something
132
+ // else: a superseded spec's (a successor owns its content, and its finished plans resolve their
133
+ // parent there), an effort's, and witness's own floor pins. The one re-use is the same artifact
134
+ // again: a spec its own abandon unwound may be written again as a spec. A stream that records
135
+ // none of these — a stray, or a doc canon cannot parse — does not hold its id; the findings
136
+ // that already name it (`orphan-journal`, the parse error) are its remedy.
137
+ export function idRetired(root, id, type) {
138
+ if (id === FLOOR_STREAM)
139
+ return v('id', 'id-taken', id, `a new id — ${id} is witness's own floor stream`);
140
+ const entries = readStream(root, id);
141
+ if (entries[0]?.t === 'recap')
142
+ return v('id', 'id-taken', id, `a new id — ${id} is an effort's slug`);
143
+ const exit = canonExit(entries);
144
+ if (exit?.to === 'superseded') {
145
+ return v('id', 'id-taken', id, `a new id — ${id} was superseded by ${exit.by ?? 'a successor'} and its stream is that spec's lineage`);
146
+ }
147
+ if (exit?.to === 'removed' && type !== 'spec')
148
+ return v('id', 'id-taken', id, `a new id — ${id} was a spec its abandon unwound`);
149
+ return undefined;
150
+ }
151
+ // D167. Whether a doc under this id may land on this path, which was last witnessed (by a
152
+ // trailer, or by an `adopt` that absolved the commit) under `prior`. A superseded spec comes back
153
+ // to the path it left from, so its departure holds that path as it holds the id. Any other id
154
+ // left a path only by hand, since `rename` keeps the file: the path is held only while the doc
155
+ // on it is what `prior` became — an in-place id change, which is `rename` done by hand. A
156
+ // removal, a `prior` living at another path, or a path left empty holds nothing: nothing lands back.
157
+ export function pathRetired(root, canon, rel, id) {
158
+ const prior = lastWitnessed(root, rel, adoptedCommits(root))?.id;
159
+ if (prior === undefined || prior === id)
160
+ return undefined;
161
+ const exit = canonExit(readStream(root, prior));
162
+ if (exit?.to === 'superseded') {
163
+ return v('id', 'path-taken', rel, `a new id — ${rel} is where ${prior} comes back if the re-slice that superseded it is taken back`);
164
+ }
165
+ const elsewhere = findById(canon, prior);
166
+ if (exit !== undefined || (elsewhere !== undefined && elsewhere.rel !== rel) || !existsSync(join(root, rel)))
167
+ return undefined;
168
+ return v('id', 'path-taken', rel, `the id it was witnessed under — ${rel} held ${prior} · witness rename is how an id changes`);
169
+ }
package/dist/gate.js CHANGED
@@ -470,10 +470,12 @@ export async function runGate(ctx, gateName, target, flags) {
470
470
  const marker = {
471
471
  op: `gate-${spec.gate}`,
472
472
  files: [...new Set(files)],
473
+ // D167. Per stream, the marker lists lines in the order the body appends them, because
474
+ // `landedJournal` reads a crash by that order — and the plan gate stamps its own target.
473
475
  journalMulti: [
474
476
  { stream: target, line: entryLine(entry) },
475
- ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
476
477
  ...discharged.map((d) => ({ stream: target, line: entryLine(d) })),
478
+ ...stamps.map((s) => ({ stream: s.stream, line: s.line })),
477
479
  ],
478
480
  };
479
481
  const txn = withTxn(root, marker, () => {
@@ -8,6 +8,10 @@ import { effortOf, planPairSha } from '../reviewed.js';
8
8
  import { validateDoc } from '../schemas.js';
9
9
  import { registerGate } from '../gate.js';
10
10
  import { designPending, designStamp } from '../design.js';
11
+ // D167. One derivation for the gate's stamp and `decide`'s: a pass approves a draft and nothing
12
+ // else. Row 95 keeps an in-flight status through a re-author, and a passing re-gate must not
13
+ // take it back — nor stamp a finished plan approved again.
14
+ const draftApproval = (plan) => String(plan.meta.status) === 'draft' ? [{ artifact: String(plan.meta.id), to: 'approved' }] : [];
11
15
  registerGate({
12
16
  gate: 'plan',
13
17
  targetKind: 'plan',
@@ -69,7 +73,7 @@ registerGate({
69
73
  },
70
74
  promptBody,
71
75
  checks,
72
- stamps: [{ artifact: planId, to: 'approved' }],
76
+ stamps: draftApproval(plan),
73
77
  });
74
78
  },
75
79
  currentSha(_root, canon, _cfg, planId) {
@@ -83,6 +87,6 @@ registerGate({
83
87
  },
84
88
  approveStamps(_root, canon, planId) {
85
89
  const doc = findById(canon, planId);
86
- return doc && String(doc.meta.status) === 'draft' ? [{ artifact: planId, to: 'approved' }] : [];
90
+ return doc ? draftApproval(doc) : [];
87
91
  },
88
92
  });
package/dist/gitio.js CHANGED
@@ -27,6 +27,17 @@ export function tryGit(root, ...args) {
27
27
  return { ok: false, out: String(err.stdout || err.stderr || e).trim() };
28
28
  }
29
29
  }
30
+ // D166. The bytes git stores for a path at a revision, untouched. `git()` strips one trailing
31
+ // newline, which is right for command output and wrong for a file put back from history: a
32
+ // design is hashed as bytes, so a restored look must be the look that was stored.
33
+ export function gitBlob(root, rev, rel) {
34
+ try {
35
+ return execFileSync('git', ['show', `${rev}:${rel}`], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: GIT_MAX_BUFFER });
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
30
41
  export function primaryRoot(cwd) {
31
42
  const top = tryGit(cwd, 'rev-parse', '--show-toplevel');
32
43
  if (!top.ok) {
package/dist/refusal.js CHANGED
@@ -8,13 +8,21 @@ export function refuse(violations) {
8
8
  export function v(field, rule, got, want, remedy) {
9
9
  return remedy === undefined ? { field, rule, got, want } : { field, rule, got, want, remedy };
10
10
  }
11
+ export const humanCall = (x) => ({ ...x, human: true });
11
12
  export function renderRefusal(violations) {
12
13
  const runnable = [...new Set(violations.map((x) => x.remedy).filter((r) => r !== undefined && !/<[^>]+>/.test(r)))];
14
+ // D167. Row 108's principle cuts both ways: the stop is true for the human's rows and false for
15
+ // the reader's own, so the trailer names the human's rules and leaves the rest self-repairable.
16
+ const human = [...new Set(violations.filter((x) => x.human).map((x) => x.rule))];
17
+ const mixed = violations.some((x) => !x.human);
13
18
  return [
14
19
  // The destructure keeps `remedy` out of the row objects: the column list already
15
20
  // governs what prints, but a remedy is not a column and must not become one.
16
21
  ...rows('refused', ['field', 'rule', 'got', 'want'], violations.map(({ remedy: _remedy, ...rest }) => rest)),
17
22
  ...runnable.map((r) => `run: ${r}`),
18
- 'help: fix each row and re-run — rows are structured for self-repair',
23
+ human.length
24
+ ? `help: a human's decision: ${human.join(' · ')} — stop and surface the list; re-running cannot clear it`
25
+ + (mixed ? ' · the other rows are structured for self-repair once it is settled' : '')
26
+ : 'help: fix each row and re-run — rows are structured for self-repair',
19
27
  ];
20
28
  }
package/dist/scan.js CHANGED
@@ -52,6 +52,23 @@ export function plannableParent(doc) {
52
52
  const s = String(doc.meta.status);
53
53
  return doc.meta.type === 'principles' ? s === 'approved' : s === 'approved' || s === 'live';
54
54
  }
55
+ // D166. The one derivation of "this plan no longer moves": `done` merged and `abandoned` was
56
+ // withdrawn. check's stray-worktree rule, next's plan queue and the re-slice's guard all ask
57
+ // exactly this.
58
+ export function planFinished(doc) {
59
+ const s = String(doc.meta.status);
60
+ return s === 'done' || s === 'abandoned';
61
+ }
62
+ // D166. The plans that stand on a spec: its children that have not been withdrawn, outside
63
+ // the set of plans the asking operation is itself retiring. A `done` plan stands — it shipped
64
+ // this spec — and so does one still moving; only `abandoned` has stepped off. `pin` narrows to
65
+ // the children derived from one version of the spec, which is what an unwind would take away.
66
+ export function plansOn(canon, specId, opts = {}) {
67
+ return canon.docs.filter((d) => d.meta.type === 'plan' && d.meta.parent === specId &&
68
+ String(d.meta.status) !== 'abandoned' &&
69
+ !(opts.exclude?.has(String(d.meta.id)) ?? false) &&
70
+ (opts.pin === undefined || d.meta['derives-from'] === opts.pin));
71
+ }
55
72
  export function findCycle(canon, extra) {
56
73
  const deps = new Map();
57
74
  for (const d of canon.docs) {
package/dist/stamp.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { openDeferrals } from './deferral.js';
4
+ import { deferralMoves } from './deferral.js';
5
5
  import { writeDoc } from './fm.js';
6
6
  import { stateCommit } from './gitio.js';
7
7
  import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
@@ -70,10 +70,7 @@ export function lazyStamp(root, ctx, canon) {
70
70
  // Keyed on `parent`, not on `specStamp`: a stale merge writes no spec stamp, and that is
71
71
  // exactly the merge whose debts most need somewhere to live.
72
72
  const parentId = parent ? String(parent.meta.id) : undefined;
73
- const moves = parentId === undefined ? [] : openDeferrals(readStream(root, planId)).flatMap((d) => [
74
- { stream: planId, entry: { v: 1, t: 'deferral-moved', id: d.id, to: parentId } },
75
- { stream: parentId, entry: { ...d, artifact: parentId, moved_from: planId } },
76
- ]);
73
+ const moves = parentId === undefined ? [] : deferralMoves(readStream(root, planId), planId, parentId);
77
74
  const files = [planStamp.rel, journalRel(planStamp.stream),
78
75
  ...(specStamp ? [specStamp.rel, journalRel(specStamp.stream)] : []),
79
76
  ...moves.map((m) => journalRel(m.stream))];
package/dist/txn.js CHANGED
@@ -40,16 +40,34 @@ export function rollbackTxn(root, marker) {
40
40
  }
41
41
  rmSync(markerPath(root), { force: true });
42
42
  }
43
+ // D166. What a crash left of a transaction's journal, per stream: the marker's pending lines
44
+ // for that stream, in order, and how many of them the stream already ends with. One
45
+ // transaction may append SEVERAL lines to one stream — the merge stamp's status and its
46
+ // deferral moves, a re-slice's departure and its moves — and the crash can land after any
47
+ // prefix of them. `completeTxn` appends the rest; `recover` ranks --complete over --rollback
48
+ // when nothing is missing. One derivation for both: comparing each line with the stream's
49
+ // last line re-appended every earlier line, and counted it as missing.
50
+ export function landedJournal(root, marker) {
51
+ const pending = new Map();
52
+ for (const { stream, line } of [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])]) {
53
+ pending.set(stream, [...(pending.get(stream) ?? []), line]);
54
+ }
55
+ return [...pending].map(([stream, lines]) => {
56
+ const p = join(root, journalRel(stream));
57
+ const current = (existsSync(p) ? readFileSync(p, 'utf8') : '').split('\n').filter(Boolean);
58
+ let landed = Math.min(lines.length, current.length);
59
+ while (landed > 0 && current.slice(-landed).join('\n') !== lines.slice(0, landed).join('\n'))
60
+ landed--;
61
+ return { stream, lines, landed };
62
+ });
63
+ }
43
64
  export function completeTxn(root, marker) {
44
- const items = [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])];
45
- if (items.length)
65
+ const streams = landedJournal(root, marker);
66
+ if (streams.length)
46
67
  mkdirSync(join(root, '.witness', 'journal'), { recursive: true });
47
- for (const { stream, line } of items) {
48
- const p = join(root, journalRel(stream));
49
- const current = existsSync(p) ? readFileSync(p, 'utf8') : '';
50
- const lastLine = current.split('\n').filter(Boolean).at(-1);
51
- if (lastLine !== line)
52
- appendFileSync(p, line + '\n');
68
+ for (const { stream, lines, landed } of streams) {
69
+ for (const line of lines.slice(landed))
70
+ appendFileSync(join(root, journalRel(stream)), line + '\n');
53
71
  }
54
72
  const res = stateCommit(root, marker.files, `${marker.op} (recovered)`);
55
73
  if (res.ok)
@@ -7,7 +7,7 @@ import { effortWrites } from '../reviewed.js';
7
7
  import { canonicalSha } from '../sha.js';
8
8
  import { renderRefusal, v } from '../refusal.js';
9
9
  import { kv } from '../toon.js';
10
- import { executeAbandon, planItems, specRevertFor } from '../abandon.js';
10
+ import { abandonLines, executeAbandon, planItems, returnCollisions, specRevertFor } from '../abandon.js';
11
11
  export async function run(ctx, argv) {
12
12
  const target = argv.find((a) => !a.startsWith('--'));
13
13
  if (!target) {
@@ -35,15 +35,18 @@ export async function run(ctx, argv) {
35
35
  renderRefusal(itemR.violations).forEach((l) => ctx.err(l));
36
36
  return EXIT.REFUSED;
37
37
  }
38
+ const taken = returnCollisions(root, canon, [itemR.value]);
39
+ if (taken.length > 0) {
40
+ renderRefusal(taken).forEach((l) => ctx.err(l));
41
+ return EXIT.REFUSED;
42
+ }
38
43
  const done = executeAbandon(root, ctx, [itemR.value], undefined, `abandon(${target})`);
39
44
  if (!done.ok) {
40
45
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
41
46
  return EXIT.REFUSED;
42
47
  }
43
48
  ctx.out(kv('abandoned', target));
44
- if (itemR.value.revert) {
45
- ctx.out(kv('reverted', `${String(itemR.value.revert.doc.meta.id)} (${itemR.value.revert.action}) — journal untouched`));
46
- }
49
+ abandonLines([itemR.value], []).forEach((l) => ctx.out(l));
47
50
  return EXIT.OK;
48
51
  }
49
52
  if (streamExists(root, target) && latestRecap(root, target)) {
@@ -56,6 +59,7 @@ export async function run(ctx, argv) {
56
59
  const abandonSet = new Set(writes.keys());
57
60
  const items = [];
58
61
  const problems = [];
62
+ const kept = [];
59
63
  for (const id of writes.keys()) {
60
64
  const artifact = findById(canon, id);
61
65
  if (!artifact)
@@ -80,14 +84,27 @@ export async function run(ctx, argv) {
80
84
  problems.push(v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
81
85
  continue;
82
86
  }
83
- const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet);
87
+ const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, target);
84
88
  if (!revertR.ok) {
85
89
  problems.push(...revertR.violations);
86
90
  continue;
87
91
  }
88
- if (revertR.value)
89
- items.push({ revert: revertR.value });
92
+ const outcome = revertR.value;
93
+ if (outcome?.action === 'keep') {
94
+ // D166. This abandon is the effort's last act, so a keep here must be one nothing has
95
+ // to take back later: a shipped plan makes it so — canon describes built behavior. A
96
+ // plan of another effort still building on the spec would leave it owned by no one and
97
+ // removable by no verb, so that case refuses until the plan finishes or goes.
98
+ if (!outcome.shipped) {
99
+ problems.push(v('spec', 'still-standing', `${id}: ${outcome.standing.join(' ')}`, 'those finished or withdrawn first — this abandon is the last act that could take the spec back'));
100
+ continue;
101
+ }
102
+ kept.push(outcome);
103
+ }
104
+ else if (outcome)
105
+ items.push({ revert: outcome });
90
106
  }
107
+ problems.push(...returnCollisions(root, canon, items));
91
108
  if (problems.length > 0) {
92
109
  renderRefusal(problems).forEach((l) => ctx.err(l));
93
110
  return EXIT.REFUSED;
@@ -97,7 +114,8 @@ export async function run(ctx, argv) {
97
114
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
98
115
  return EXIT.REFUSED;
99
116
  }
100
- ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked, journal remembers the attempt`));
117
+ ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked · journal remembers the attempt`));
118
+ abandonLines(items, kept).forEach((l) => ctx.out(l));
101
119
  return EXIT.OK;
102
120
  }
103
121
  renderRefusal([v('target', 'unknown-target', target, 'a plan id or an effort slug')]).forEach((l) => ctx.err(l));
@@ -9,6 +9,7 @@ import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
9
9
  import { appendEntry, entryLine, journalRel } from '../journal.js';
10
10
  import { acquireLock } from '../lock.js';
11
11
  import { renderRefusal, v } from '../refusal.js';
12
+ import { idRetired, pathRetired } from '../departure.js';
12
13
  import { findById, findCycle, loadCanon } from '../scan.js';
13
14
  import { validateDoc } from '../schemas.js';
14
15
  import { canonicalSha, short } from '../sha.js';
@@ -42,6 +43,15 @@ export async function run(ctx, argv) {
42
43
  if (canon.docs.filter((d) => String(d.meta.id) === id).length > 1) {
43
44
  violations.push(v('id', 'duplicate-id', id, 'one doc per id'));
44
45
  }
46
+ // D166. Adopt runs the checks write runs: a hand-made doc may not take a retired id either.
47
+ const taken = idRetired(root, id, String(meta.type));
48
+ if (taken)
49
+ violations.push(taken);
50
+ // D167. Nor land on a path another id was last witnessed at — the same check `write` runs on a
51
+ // create. A doc witnessed where it sits passes on its own id, so any amendment stays adoptable.
52
+ const retired = pathRetired(root, canon, rel, id);
53
+ if (retired)
54
+ violations.push(retired);
45
55
  for (const dep of Array.isArray(meta.depends) ? meta.depends : []) {
46
56
  if (!findById(canon, dep))
47
57
  violations.push(v('depends', 'unknown-dep', dep, 'an existing doc id'));
@@ -8,7 +8,10 @@ import { latestPublished } from '../registry.js';
8
8
  import { NPX_LATEST, compareTriple } from '../version.js';
9
9
  import { probe } from '../probe.js';
10
10
  import { loadConfig, loadLocalConfig, localConfigPath } from '../config.js';
11
+ import { openDeferrals } from '../deferral.js';
12
+ import { canonExit } from '../departure.js';
11
13
  import { designPending } from '../design.js';
14
+ import { FLOOR_STREAM } from '../floor.js';
12
15
  import { runDrift } from '../drift.js';
13
16
  import { auditStateCommits, dirtyStatePaths, divergence, primaryRoot, stateDirs, tryGit } from '../gitio.js';
14
17
  import { contentAtSha } from '../history.js';
@@ -19,7 +22,7 @@ import { evaluateNeeds } from '../needs.js';
19
22
  import { renderRefusal } from '../refusal.js';
20
23
  import { pendingDecision } from '../rounds.js';
21
24
  import { criteriaExcludes } from '../runner.js';
22
- import { findById, findCycle, loadCanon } from '../scan.js';
25
+ import { findById, findCycle, loadCanon, planFinished } from '../scan.js';
23
26
  import { canonicalSha } from '../sha.js';
24
27
  import { lazyStamp } from '../stamp.js';
25
28
  import { kv, rows } from '../toon.js';
@@ -101,7 +104,22 @@ export async function run(ctx, argv = []) {
101
104
  for (const plan of canon.docs.filter((d) => d.meta.type === 'plan')) {
102
105
  const parent = typeof plan.meta.parent === 'string' ? findById(canon, plan.meta.parent) : undefined;
103
106
  if (!parent) {
104
- findings.push(f('error', 'invariants', `${plan.rel}: parent`, 'unknown-parent', String(plan.meta.parent)));
107
+ // D166. A plan that no longer moves keeps its parent in the departed spec's stream — the
108
+ // rule row 62 already applies to `supersedes:` and `--lineage`. Its pin is not
109
+ // re-verified: nothing reads a finished plan's pin once its parent is history, and the
110
+ // departure never journaled the path it would resolve against. Two cases stay errors: a
111
+ // plan still moving (every gate reads its parent), and a `done` plan under `removed` —
112
+ // only a re-slice may take shipped content out of canon, so that is a pre-D166 unwind
113
+ // that deleted built behavior. No departure entry at all is a stray, as before.
114
+ const exit = typeof plan.meta.parent === 'string' ? canonExit(readStream(root, plan.meta.parent)) : undefined;
115
+ const status = String(plan.meta.status);
116
+ if (exit !== undefined && (status === 'abandoned' || (status === 'done' && exit.to === 'superseded')))
117
+ continue;
118
+ findings.push(f('error', 'invariants', `${plan.rel}: parent`, 'unknown-parent', exit === undefined
119
+ ? String(plan.meta.parent)
120
+ : status === 'done'
121
+ ? `${String(plan.meta.parent)} removed by abandon after this plan shipped it — canon lost built behavior; write that spec again under this id`
122
+ : `${String(plan.meta.parent)} ${exit.to} by ${exit.by ?? exit.cause} while this plan is ${status} — witness abandon ${String(plan.meta.id)} · or re-author it under a live parent`));
105
123
  continue;
106
124
  }
107
125
  if (plan.meta.status !== 'draft' && plan.meta.status !== 'abandoned' && parent.meta.status === 'draft') {
@@ -145,7 +163,7 @@ export async function run(ctx, argv = []) {
145
163
  }
146
164
  for (const planId of listWorktrees(root)) {
147
165
  const doc = findById(canon, planId);
148
- if (!doc || ['done', 'abandoned'].includes(String(doc.meta.status))) {
166
+ if (!doc || planFinished(doc)) {
149
167
  findings.push(f('warn', 'motion', planId, 'stray-worktree', 'witness clean sweeps it'));
150
168
  }
151
169
  // Row 132. A worktree cut before the canon exclusion existed still carries a copy, and
@@ -212,10 +230,25 @@ export async function run(ctx, argv = []) {
212
230
  continue;
213
231
  const id = file.slice(0, -'.jsonl'.length);
214
232
  const entries = readStream(root, id);
215
- if (entries[0]?.t === 'recap')
233
+ if (entries[0]?.t === 'recap' || id === FLOOR_STREAM)
234
+ continue;
235
+ if (findById(canon, id))
236
+ continue;
237
+ // D166. A departed spec's stream is not an orphan — it is lineage by construction, what a
238
+ // finished plan's parent and `log --lineage` resolve against. It speaks only while it
239
+ // still holds open obligations: a re-slice moves them to the successor, but an unwind has
240
+ // no successor, and a departure from before D166 moved nothing. `superseded` is the
241
+ // dismissal cause that fits either: the thing the obligation was about no longer exists.
242
+ // A stream with no departure entry is what this rule was always for: a stray.
243
+ const exit = canonExit(entries);
244
+ if (exit === undefined) {
245
+ findings.push(f('warn', 'journal', file, 'orphan-journal', 'stream matches no doc and records no departure — a stray'));
216
246
  continue;
217
- if (!findById(canon, id))
218
- findings.push(f('warn', 'journal', file, 'orphan-journal', 'stream matches no doc (frozen lineage or stray)'));
247
+ }
248
+ const owed = openDeferrals(entries);
249
+ if (owed.length > 0) {
250
+ findings.push(f('warn', 'journal', id, 'stranded-deferral', `${owed.length} open deferral(s) on a spec that left canon (${exit.to} by ${exit.by ?? exit.cause}): ${owed.map((d) => d.id).join(' ')} — witness dismiss ${id} --deferral <id> --cause superseded --note <why>`));
251
+ }
219
252
  }
220
253
  }
221
254
  if (cfg.ok) {
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { EXIT, version } from '../cli.js';
3
3
  import { loadConfig } from '../config.js';
4
+ import { departedStreams } from '../departure.js';
4
5
  import { designArtifactCurrent, designPending } from '../design.js';
5
6
  import { reconcileRows } from '../drift.js';
6
7
  import { divergence, primaryRoot } from '../gitio.js';
@@ -50,7 +51,9 @@ function blockedRows(canon, ctx) {
50
51
  // D122. Aged in ROUNDS, never in wall-clock: this CLI has no timestamps anywhere — the
51
52
  // gate-run entry carries none — so "61 rounds" is derivable and "3 weeks" is not.
52
53
  export function deferralRows(root, canon) {
53
- const streams = new Set([...effortStreams(root), ...canon.docs.map((d) => String(d.meta.id))]);
54
+ // D166. A departed spec's stream is the third source: an obligation recorded there is still
55
+ // open until it is dismissed, and `status` is where every open obligation is listed (D122).
56
+ const streams = new Set([...effortStreams(root), ...canon.docs.map((d) => String(d.meta.id)), ...departedStreams(root, canon)]);
54
57
  const out = [];
55
58
  for (const id of streams) {
56
59
  const entries = readStream(root, id);
package/dist/verbs/log.js CHANGED
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { EXIT } from '../cli.js';
5
5
  import { git, primaryRoot, tryGit } from '../gitio.js';
6
+ import { canonExit } from '../departure.js';
6
7
  import { journalRel, readStream, streamExists } from '../journal.js';
7
8
  import { renderRefusal, v } from '../refusal.js';
8
9
  import { short } from '../sha.js';
@@ -50,9 +51,11 @@ const streamIds = (root) => {
50
51
  .map((f) => f.slice(0, -'.jsonl'.length))
51
52
  .sort();
52
53
  };
54
+ // D166. Read through `canonExit`, the one reading of a departure: a re-slice that was taken
55
+ // back is no longer a lineage step.
53
56
  function successorOf(root, id) {
54
- const terminal = readStream(root, id).find((e) => e.t === 'status' && e.to === 'superseded');
55
- return terminal?.by;
57
+ const exit = canonExit(readStream(root, id));
58
+ return exit?.to === 'superseded' ? exit.by : undefined;
56
59
  }
57
60
  function predecessorsOf(root, id) {
58
61
  return streamIds(root).filter((s) => successorOf(root, s) === id);
@@ -4,7 +4,7 @@ import { EXIT } from '../cli.js';
4
4
  import { loadConfig } from '../config.js';
5
5
  import { pendingTxn } from '../txn.js';
6
6
  import { primaryRoot } from '../gitio.js';
7
- import { findById, loadCanon, plannableParent } from '../scan.js';
7
+ import { findById, loadCanon, planFinished, plannableParent } from '../scan.js';
8
8
  import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
9
9
  import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
10
10
  import { diffReviewedSha, effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
@@ -640,8 +640,7 @@ export function computeNext(root, ctx, canon, cfg) {
640
640
  };
641
641
  const planless = canon.docs
642
642
  .filter((d) => d.meta.type === 'spec' && plannableParent(d))
643
- .filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) &&
644
- !['done', 'abandoned'].includes(String(p.meta.status))))
643
+ .filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) && !planFinished(p)))
645
644
  .filter((d) => (d.meta.depends ?? []).every(ready))
646
645
  // last on purpose: the only filter that costs a subprocess — the in-memory ones above
647
646
  // discard for free, and all four are pure predicates over an intersection
@@ -1,38 +1,36 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  import { EXIT } from '../cli.js';
3
3
  import { dirtyStatePaths, primaryRoot } from '../gitio.js';
4
- import { journalRel } from '../journal.js';
5
4
  import { renderDecision } from '../recommend.js';
6
5
  import { kv } from '../toon.js';
7
6
  import { renderRefusal } from '../refusal.js';
8
- import { completeTxn, pendingTxn, rollbackTxn } from '../txn.js';
9
- import { existsSync, readFileSync, rmSync } from 'node:fs';
7
+ import { completeTxn, landedJournal, pendingTxn, rollbackTxn } from '../txn.js';
8
+ import { rmSync } from 'node:fs';
10
9
  import { join } from 'node:path';
11
- // D121. The interrupted transaction's own state ranks this: `completeTxn` is idempotent
12
- // about the journal append (it compares the last line), so if the line already landed the
13
- // write finished and only the commit is missing. `--complete | --rollback` named the two
14
- // flags and neither the right answer nor the cost of guessing wrong — at the one prompt a
15
- // human meets while looking at a crash.
10
+ // D121. The interrupted transaction's own state ranks this: `completeTxn` appends only the
11
+ // journal lines that have not landed, so if every one already landed the write finished and
12
+ // only the commit is missing. `--complete | --rollback` named the two flags and neither the
13
+ // right answer nor the cost of guessing wrong — at the one prompt a human meets while
14
+ // looking at a crash. D166: the count is `landedJournal`'s, the derivation `completeTxn`
15
+ // uses, so a stream that takes several lines reads the same to both.
16
16
  export function recoverChoice(root, marker) {
17
- const items = [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])];
18
- const landed = items.filter(({ stream, line }) => {
19
- const p = join(root, journalRel(stream));
20
- return existsSync(p) && readFileSync(p, 'utf8').split('\n').filter(Boolean).at(-1) === line;
21
- }).length;
22
- const complete = landed === items.length && items.length > 0;
17
+ const streams = landedJournal(root, marker);
18
+ const total = streams.reduce((n, s) => n + s.lines.length, 0);
19
+ const landed = streams.reduce((n, s) => n + s.landed, 0);
20
+ const complete = landed === total && total > 0;
23
21
  return {
24
22
  key: 'recover', rule: complete ? 'txn-write-landed' : 'txn-write-partial',
25
23
  options: complete
26
24
  ? [
27
25
  { command: 'witness recover --complete', depth: 'root', runnable: true,
28
- why: `${landed} of ${items.length} journal line(s) are already on disk and ${marker.files.length} file(s) are written — the transaction finished and only the state commit is missing; completing makes git match what the journal already records` },
26
+ why: `${landed} of ${total} journal line(s) are already on disk and ${marker.files.length} file(s) are written — the transaction finished and only the state commit is missing; completing makes git match what the journal already records` },
29
27
  { command: 'witness recover --rollback', depth: 'root', runnable: true,
30
28
  when: 'you no longer want the operation that was interrupted',
31
29
  tradeoff: `reverts ${marker.files.length} file(s) and drops the journal append — the operation must be retaken` },
32
30
  ]
33
31
  : [
34
32
  { command: 'witness recover --rollback', depth: 'root', runnable: true,
35
- why: `only ${landed} of ${items.length} journal line(s) landed — the transaction was interrupted mid-write, and rolling back to a known state is safer than committing a partial one` },
33
+ why: `only ${landed} of ${total} journal line(s) landed — the transaction was interrupted mid-write, and rolling back to a known state is safer than committing a partial one` },
36
34
  { command: 'witness recover --complete', depth: 'root', runnable: true,
37
35
  when: 'you have inspected the files and the partial state is what you want',
38
36
  tradeoff: 'commits a transaction that never finished writing; the journal and the tree may disagree' },
@@ -1,17 +1,19 @@
1
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { EXIT } from '../cli.js';
5
5
  import { loadConfig } from '../config.js';
6
- import { designPending, designRel, designStamp } from '../design.js';
6
+ import { deferralMoves } from '../deferral.js';
7
+ import { canonExit, idRetired, pathRetired, prepareDeparture } from '../departure.js';
8
+ import { designPending, designStamp } from '../design.js';
7
9
  import { ID_RE } from '../dsl.js';
8
10
  import { serializeDoc, splitDoc, writeDoc } from '../fm.js';
9
11
  import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
10
12
  import { appendEntry, entryLine, journalRel, latestRecap, readStream, streamExists } from '../journal.js';
11
13
  import { acquireLock } from '../lock.js';
12
- import { renderRefusal, v } from '../refusal.js';
14
+ import { humanCall, renderRefusal, v } from '../refusal.js';
13
15
  import { baseForSpec, contentAtSha } from '../history.js';
14
- import { findById, findCycle, loadCanon } from '../scan.js';
16
+ import { findById, findCycle, loadCanon, planFinished, plansOn } from '../scan.js';
15
17
  import { canonicalJson, canonicalSha, short } from '../sha.js';
16
18
  import { kv } from '../toon.js';
17
19
  import { crashPoint, guardTxn, withTxn } from '../txn.js';
@@ -127,6 +129,14 @@ export async function run(ctx, argv) {
127
129
  renderRefusal(viol).forEach(ctx.err);
128
130
  return EXIT.REFUSED;
129
131
  };
132
+ // D166. A create may not take an id that already names something (`idRetired`), with the one
133
+ // re-use of a spec its abandon unwound; a stray stream holds no id. `rename` is stricter on its
134
+ // target — no stream at all — because it moves its own stream onto that id.
135
+ if (existing === undefined) {
136
+ const taken = idRetired(root, id, String(manifest.type));
137
+ if (taken)
138
+ return refuseWrite([taken]);
139
+ }
130
140
  const built = manifest.type === 'plan'
131
141
  ? buildPlanMeta(root, id, manifest, recap, canon, existing, body)
132
142
  : buildSpecMeta(root, id, manifest, recap, canon, existing, body);
@@ -141,6 +151,7 @@ export async function run(ctx, argv) {
141
151
  // meta.supersedes, so this is a no-op for plan writes.
142
152
  const supersedes = built.meta.supersedes === undefined ? undefined : String(built.meta.supersedes);
143
153
  let reslice;
154
+ let moves = [];
144
155
  if (supersedes !== undefined) {
145
156
  if (supersedes === id) {
146
157
  return refuseWrite([v('supersedes', 'self-supersede', id, 'a different spec')]);
@@ -150,28 +161,65 @@ export async function run(ctx, argv) {
150
161
  }
151
162
  const target = findById(canon, supersedes);
152
163
  if (!target) {
153
- const alreadyTerminal = readStream(root, supersedes)
154
- .some((e) => e.t === 'status' && e.to === 'superseded');
155
- if (!alreadyTerminal) {
156
- return refuseWrite([v('supersedes', 'unknown-supersedes', supersedes, 'an existing spec (or one already journal-terminal)')]);
164
+ // D166. Any departure is terminal here — a spec its abandon unwound as well as one
165
+ // already superseded: either way there is nothing left to delete. Abandoning the plan
166
+ // a `dangling-parent` refusal names can unwind the very spec this write re-slices.
167
+ if (canonExit(readStream(root, supersedes)) === undefined) {
168
+ return refuseWrite([v('supersedes', 'unknown-supersedes', supersedes, 'an existing spec (or one that already left canon)')]);
157
169
  }
158
170
  }
159
171
  else {
172
+ // D166. Re-slicing re-cuts specs (row 5). Deleting a plan here stranded a live flow, and
173
+ // deleting the principles removed every chore's only parent, with nothing to bring
174
+ // either back.
175
+ if (target.meta.type !== 'spec') {
176
+ return refuseWrite([v('supersedes', 'not-a-spec', `${supersedes} is a ${String(target.meta.type)}`, 'a spec — a plan is withdrawn with witness abandon and principles are amended in place')]);
177
+ }
160
178
  const dependents = canon.docs
161
179
  .filter((d) => String(d.meta.id) !== id)
162
180
  .filter((d) => (d.meta.depends ?? []).includes(supersedes))
163
181
  .map((d) => String(d.meta.id));
164
- if (dependents.length > 0) {
165
- return refuseWrite([v('supersedes', 'dangling-depends', dependents.join(' '), 'dependents rewritten first — the re-slice rewrites them before deleting')]);
166
- }
167
- const entry = {
182
+ // D166. A plan's `parent` is the other edge into the spec this deletes, and row 62
183
+ // guarded only `depends`: every gate reads the parent, so a plan still moving was left
184
+ // unable to move and impossible to clear (issue #34). A finished plan holds nothing — the
185
+ // successor carries the content forward, and `check` resolves the plan's parent in the
186
+ // departed stream. Whether a moving plan ships first or is withdrawn is a human's call,
187
+ // so the row says so and names no command an agent could run for them.
188
+ const moving = plansOn(canon, supersedes).filter((p) => !planFinished(p)).map((p) => String(p.meta.id));
189
+ // Both edges in ONE refusal: every refusal counts against the first-try rate the
190
+ // dashboard trends, and learning of the second edge only after repairing the first
191
+ // would cost a refusal for nothing.
192
+ const dangling = [
193
+ ...(dependents.length > 0 ? [v('supersedes', 'dangling-depends', dependents.join(' '), 'dependents rewritten first — the re-slice rewrites them before deleting')] : []),
194
+ ...(moving.length > 0 ? [humanCall(v('supersedes', 'dangling-parent', moving.join(' '), 'those plans finished or withdrawn first — the re-slice deletes their parent and which way each goes is a human\'s call'))] : []),
195
+ ];
196
+ if (dangling.length > 0)
197
+ return refuseWrite(dangling);
198
+ reslice = prepareDeparture(root, cfg.value.paths, target, {
168
199
  v: 1, t: 'status', artifact: supersedes, from: String(target.meta.status),
169
200
  to: 'superseded', cause: 'supersede', by: id,
170
- };
171
- reslice = { doc: target, entry, line: entryLine(entry) };
201
+ });
202
+ // D166. The superseded spec's open obligations follow its content to the successor —
203
+ // D122's rule, whose premise ("the parent spec, which outlives it") the re-slice ends.
204
+ // A split's later slices find the stream already settled, so its debts go to the first.
205
+ moves = deferralMoves(readStream(root, supersedes), supersedes, id);
172
206
  }
173
207
  }
174
208
  const rel = existing ? existing.rel : `${manifest.type === 'plan' ? cfg.value.paths.plans : cfg.value.paths.specs}/${id}.md`;
209
+ // D166. Nor land on another doc's file: `rename` moves the id and the journal but leaves the
210
+ // path, so `specs/<old>.md` can hold the renamed doc, and a create under the old id overwrote it.
211
+ if (existing === undefined && existsSync(join(root, rel))) {
212
+ const holder = canon.docs.find((d) => d.rel === rel);
213
+ return refuseWrite([v('id', 'path-taken', rel, holder
214
+ ? `a new id — ${rel} holds ${String(holder.meta.id)}`
215
+ : `a new id — ${rel} holds a doc canon cannot read (fix or revert it first)`)]);
216
+ }
217
+ // D167. Nor on the path a superseded spec comes back to (`pathRetired`).
218
+ if (existing === undefined) {
219
+ const retired = pathRetired(root, canon, rel, id);
220
+ if (retired)
221
+ return refuseWrite([retired]);
222
+ }
175
223
  const stream = journalRel(effort);
176
224
  const unrelated = dirtyStatePaths(root).filter((p) => p !== rel && p !== stream);
177
225
  if (unrelated.length) {
@@ -199,24 +247,21 @@ export async function run(ctx, argv) {
199
247
  // entry. Row 116's `w` stamp is what caught it; the shape is the one rows 93, 95 and
200
248
  // 96 all name, and every other txn writer already calls entryLine.
201
249
  const line = entryLine(entry);
202
- const supersededDesignRel = reslice ? designRel(cfg.value.paths, reslice.entry.artifact) : undefined;
203
- const files = [rel, stream,
204
- ...(reslice ? [reslice.doc.rel, journalRel(reslice.entry.artifact)] : []),
205
- ...(supersededDesignRel && existsSync(join(root, supersededDesignRel)) ? [supersededDesignRel] : []),
206
- ];
250
+ const files = [rel, stream, ...(reslice?.files ?? []), ...(moves.length > 0 ? [journalRel(id)] : [])];
207
251
  const res = withTxn(root, {
208
252
  op: `write(${id})`, files, journal: { stream: effort, line },
209
- ...(reslice ? { journalMulti: [{ stream: reslice.entry.artifact, line: reslice.line }] } : {}),
253
+ ...(reslice ? { journalMulti: [
254
+ { stream: reslice.entry.artifact, line: entryLine(reslice.entry) },
255
+ ...moves.map((m) => ({ stream: m.stream, line: entryLine(m.entry) })),
256
+ ] } : {}),
210
257
  }, () => {
211
258
  writeDoc(join(root, rel), { meta: built.meta, body });
212
259
  crashPoint(ctx.env, 'artifact-write');
213
260
  if (reslice) {
214
- unlinkSync(join(root, reslice.doc.rel));
215
- const supersededDesign = designRel(cfg.value.paths, reslice.entry.artifact);
216
- if (existsSync(join(root, supersededDesign))) {
217
- unlinkSync(join(root, supersededDesign));
218
- }
261
+ reslice.remove();
219
262
  appendEntry(root, reslice.entry.artifact, reslice.entry);
263
+ for (const m of moves)
264
+ appendEntry(root, m.stream, m.entry);
220
265
  crashPoint(ctx.env, 'reslice-commit');
221
266
  }
222
267
  appendEntry(root, effort, entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.14.6",
3
+ "version": "0.14.8",
4
4
  "description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
5
5
  "keywords": [
6
6
  "spec-driven-development",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "witness",
3
3
  "description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
4
- "version": "0.14.6",
4
+ "version": "0.14.8",
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.14.6}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
14
14
  ```
15
15
 
16
16
  If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
@@ -3,4 +3,4 @@
3
3
  # context). Silent everywhere that isn't a witness repo; failures swallowed:
4
4
  # session start must never hang or die on this.
5
5
  [ -f witness.config.yaml ] || exit 0
6
- ${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8} 2>/dev/null || true
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
10
10
  Resolve the CLI once per session:
11
11
 
12
12
  ```bash
13
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.
@@ -44,7 +44,7 @@ The class comes from the recap. Never ask for it again.
44
44
  - Thin **vertical** slices: each spec independently shippable, observable end-to-end. Never layers ("the schema", "the client").
45
45
  - Each spec answers exactly one question; no two overlap; **one fact, one home**. A fact needed by two slices gets extracted into its own spec, and both `depends` on it.
46
46
  - Amending a living spec is the same write with the existing id — its status resets to `draft` (re-approval owed). If the CLI warns the spec has an in-flight child plan, surface that warning at the gate stop.
47
- - Boundary changes (split, merge, extraction out of a live spec) → the new spec's manifest carries `supersedes: <old-id>`; rewrite every dependent's `depends` in the same effort — the CLI refuses dangling edges.
47
+ - Boundary changes (split, merge, extraction out of a live spec) → the new spec's manifest carries `supersedes: <old-id>`; rewrite every dependent's `depends` in the same effort — the CLI refuses dangling edges. It also refuses `dangling-parent` while a plan of the old spec is still in motion: whether that plan ships first or is abandoned is the human's call, so stop and surface it — never abandon it yourself.
48
48
  - Cross-cutting changes → sequenced per-slice amendments ordered via `depends`, expand-contract style (accept both → switch → drop old), each step independently shippable.
49
49
  - Self-check totality before gating: every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal.
50
50
 
@@ -87,7 +87,7 @@ Get these right the first time — the dashboard trends your first-try valid rat
87
87
  - `criteria`: ≥ 1 entry, unique ids, each exactly one of `test` (value must be `@spec:<this-spec-id>`) or `cmd`. A criterion that cannot fail is not a criterion.
88
88
  - `covers`: the recap goal ids this spec honestly advances — the critic judges the mapping's quality.
89
89
  - Body: exactly the `## Motivation` and `## Behavior` headings; behavior-only — internal interfaces never appear in a spec.
90
- - `id`: `[a-z0-9-]+`; new ids land at `specs/<id>.md` (or the repo's configured specs dir).
90
+ - `id`: `[a-z0-9-]+`; new ids land at `specs/<id>.md` (or the repo's configured specs dir). An id that already names something is refused `id-taken` — a superseded spec, an effort's slug, witness's `floor` — and so is a path another doc holds (`path-taken`): choose a new id.
91
91
  - `needs`: external prerequisites only, machine-checkable (`env:` / `cmd:` / `manual:` + `satisfied`).
92
92
  - `ui`: set `ui: true` in the manifest **iff** the slice changes browser-rendered or interactive behavior (a screen, a form, client-side interaction). A browser-behavior slice **without** the flag, or a flag on a non-UI slice, is a slicing-critic finding. A `ui` spec earns a **design stage** before its plan — the look is approved before code (Decision 69).
93
93
 
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
15
15
  Resolve the CLI once per session:
16
16
 
17
17
  ```bash
18
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.6}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.8}"
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.