@popovych.co/witness 0.14.6 → 0.14.7

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
13
  import { 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,60 @@ 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
+ // D166 (review). The writes the way back takes back besides this abandon's own targets: every
41
+ // write of an effort already abandoned — whose abandon skipped a spec a later re-slice had
42
+ // departed — and every write of the effort being abandoned now (`effort`, absent on the plan
43
+ // path). Content one of them wrote never comes back into canon on its own — unless a plan
44
+ // outside the abandon set stands on it: the keep rule, which never takes shipped content back.
45
+ function takenBackBy(root, canon, abandonSet, effort) {
46
+ const gone = effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
47
+ return (id, sha, rel, at) => plansOn(canon, id, { exclude: abandonSet, pin: sha }).length === 0 &&
48
+ gone.some((e) => readStream(root, e).some((x) => x.t === 'write' && x.artifact === id && x.sha === sha))
49
+ ? pinCommit(root, rel, sha, at)
50
+ : undefined;
51
+ }
52
+ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
37
53
  const current = canonicalSha(spec.meta, spec.body);
38
54
  if (current !== pin) {
39
55
  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
56
  }
57
+ // D166. After "newest-first" (row 84 stands) and ahead of every precondition of the unwind
58
+ // itself — a resolvable pin, no waiting dependents — because a kept spec unwinds nothing and
59
+ // dangles nothing. Same pin in both branches: the siblings a delete would orphan were all
60
+ // derived from the content it removes, and a restore moves toward older pins.
61
+ const standing = plansOn(canon, String(spec.meta.id), { exclude: abandonSet, pin });
62
+ if (standing.length > 0) {
63
+ return ok({
64
+ doc: spec, action: 'keep', standing: standing.map((p) => String(p.meta.id)),
65
+ shipped: standing.some((p) => String(p.meta.status) === 'done'),
66
+ });
67
+ }
41
68
  const commit = pinCommit(root, spec.rel, pin);
42
69
  if (!commit) {
43
70
  return refuse([v('spec', 'pin-unresolvable', pin.slice(0, 7), 'a committed version matching the pin')]);
44
71
  }
72
+ // D166. The way back: the write being taken back may have departed a predecessor (a
73
+ // re-slice), which then returns with it. A re-slice can cut one spec into several slices, and
74
+ // taking back one while another still stands would leave the predecessor half-described, so
75
+ // that write is kept until its siblings go too — together, through the effort that made them.
76
+ const returns = prepareReturns(root, canon.paths, commit, String(spec.meta.id), takenBackBy(root, canon, abandonSet, effort));
77
+ // D166 (review). A sibling slice stands when this abandon leaves it in canon: it is outside the
78
+ // abandon set, or inside it but kept because a plan outside the set stands on it. It stands for
79
+ // good when a plan shipped it — and then so does this keep, since nothing can take the
80
+ // sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
81
+ const cut = new Set(returns.flatMap((r) => [r.entry.artifact, ...r.unwound.map((u) => u.artifact)]));
82
+ const slices = canon.docs
83
+ .filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== String(spec.meta.id))
84
+ .flatMap((d) => {
85
+ const id = String(d.meta.id);
86
+ const leaving = abandonSet.has(id);
87
+ const on = plansOn(canon, id, leaving ? { exclude: abandonSet, pin: canonicalSha(d.meta, d.body) } : { exclude: abandonSet });
88
+ return leaving && on.length === 0 ? [] : [{ id, shipped: on.some((p) => String(p.meta.status) === 'done') }];
89
+ });
90
+ if (slices.length > 0) {
91
+ return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
92
+ }
45
93
  const prior = tryGit(root, 'show', `${commit}^:${spec.rel}`);
46
94
  if (!prior.ok) {
47
95
  const dependents = canon.docs
@@ -51,17 +99,30 @@ export function specRevertFor(root, canon, spec, pin, abandonSet) {
51
99
  if (dependents.length > 0) {
52
100
  return refuse([v('spec', 'waiting-dependents', dependents.join(' '), 'no docs outside the abandon set may depend on a spec being deleted')]);
53
101
  }
54
- return ok({ doc: spec, action: 'delete' });
102
+ // D166. The unwind departs the spec through the same door the re-slice uses, so
103
+ // everything that leaves canon with a spec — its design included — leaves here too.
104
+ return ok({
105
+ doc: spec, action: 'delete', returns,
106
+ departure: prepareDeparture(root, canon.paths, spec, {
107
+ v: 1, t: 'status', artifact: String(spec.meta.id), from: String(spec.meta.status), to: 'removed', cause: 'abandon',
108
+ }),
109
+ });
55
110
  }
56
111
  const parsed = splitDoc(prior.out);
57
112
  const toStatus = parsed.ok ? String(parsed.value.meta.status) : 'draft';
58
- return ok({ doc: spec, action: 'restore', content: prior.out, toStatus });
113
+ // D166. The look goes back with the content it was drawn for: the design as it stood before
114
+ // the commit being taken back, byte for byte — or none, if there was none then.
115
+ const designR = designRel(canon.paths, String(spec.meta.id));
116
+ const priorDesign = gitBlob(root, `${commit}^`, designR);
117
+ const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
118
+ return ok({ doc: spec, action: 'restore', content: prior.out, toStatus, design, returns });
59
119
  }
60
120
  export function executeAbandon(root, ctx, items, effortEntry, subject) {
61
121
  const files = new Set();
62
122
  const journalMulti = [];
63
123
  const stamps = [];
64
124
  const specEntries = [];
125
+ const returnEntries = [];
65
126
  for (const item of items) {
66
127
  if (item.planStamp) {
67
128
  stamps.push(item.planStamp);
@@ -72,15 +133,30 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
72
133
  if (item.revert) {
73
134
  const spec = item.revert.doc;
74
135
  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',
136
+ const entry = item.revert.action === 'delete' ? item.revert.departure.entry : {
137
+ v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: item.revert.toStatus, cause: 'abandon',
79
138
  };
80
139
  specEntries.push({ stream: id, entry });
81
- files.add(spec.rel);
82
- files.add(journalRel(id));
140
+ const touched = item.revert.action === 'delete'
141
+ ? item.revert.departure.files
142
+ : [spec.rel, journalRel(id), ...(item.revert.design ? [item.revert.design.rel] : [])];
143
+ for (const f of touched)
144
+ files.add(f);
83
145
  journalMulti.push({ stream: id, line: entryLine(entry) });
146
+ // D166. A spec this write had departed comes back in the same state commit.
147
+ for (const r of item.revert.returns) {
148
+ const lines = [
149
+ ...[r.entry, ...r.unwound].map((e) => ({ stream: e.artifact, entry: e })),
150
+ ...r.moves,
151
+ ];
152
+ for (const f of r.files)
153
+ files.add(f);
154
+ for (const l of lines) {
155
+ files.add(journalRel(l.stream));
156
+ journalMulti.push({ stream: l.stream, line: entryLine(l.entry) });
157
+ }
158
+ returnEntries.push(...lines);
159
+ }
84
160
  }
85
161
  }
86
162
  let effortLine;
@@ -100,17 +176,27 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
100
176
  return withTxn(root, { op: 'abandon', files: [...files], journalMulti }, () => {
101
177
  for (const item of items) {
102
178
  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
179
+ // stateCommit's `git add` below stages a tracked-but-missing file as a deletion
180
+ if (item.revert.action === 'delete')
181
+ item.revert.departure.remove();
182
+ else {
183
+ writeFileSync(join(root, item.revert.doc.rel), item.revert.content);
184
+ const d = item.revert.design;
185
+ if (d?.prior !== undefined)
186
+ writeFileSync(join(root, d.rel), d.prior);
187
+ else if (d !== undefined && existsSync(join(root, d.rel)))
188
+ unlinkSync(join(root, d.rel));
189
+ }
190
+ for (const r of item.revert.returns)
191
+ r.restore();
108
192
  }
109
193
  }
110
194
  for (const s of stamps)
111
195
  writeStamp(root, s);
112
196
  for (const se of specEntries)
113
197
  appendEntry(root, se.stream, se.entry);
198
+ for (const re of returnEntries)
199
+ appendEntry(root, re.stream, re.entry);
114
200
  if (effortLine) {
115
201
  appendEntry(root, effortLine.stream, JSON.parse(effortLine.line));
116
202
  }
@@ -153,7 +239,11 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
153
239
  const revertR = specRevertFor(root, canon, parent, String(plan.meta['derives-from']), abandonSet);
154
240
  if (!revertR.ok)
155
241
  return revertR;
156
- item.revert = revertR.value;
242
+ const outcome = revertR.value;
243
+ if (outcome?.action === 'keep')
244
+ item.kept = outcome;
245
+ else
246
+ item.revert = outcome;
157
247
  }
158
248
  }
159
249
  return ok(item);
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,147 @@
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 { gitBlob, tryGit } from './gitio.js';
8
+ import { journalRel, readStream } from './journal.js';
9
+ import { v } from './refusal.js';
10
+ import { findById } from './scan.js';
11
+ import { canonicalSha } from './sha.js';
12
+ // D166. A spec leaves canon through exactly two verbs — the re-slice (row 62), which
13
+ // journals `superseded` and names the successor, and abandon's unwind of a spec its own
14
+ // effort created (row 29), which journals `removed`. This module is the one home for that
15
+ // act: how a departure is read, what it takes out of canon, how it is taken back, and which
16
+ // ids it retires. What may still point INTO a departing spec is settled by the two verbs
17
+ // that depart one.
18
+ // The status entry with which an artifact LEFT canon, if a witness verb took it out: the
19
+ // stream's LATEST status entry, when it is a departure. Latest, not any — a departure taken
20
+ // back, or a removed spec written again, is followed by a later status and reads as present.
21
+ // Callers consult canon first: this answers only for an id that canon no longer holds.
22
+ export function canonExit(entries) {
23
+ const last = entries.filter((e) => e.t === 'status').at(-1);
24
+ return last !== undefined && (last.to === 'superseded' || last.to === 'removed') ? last : undefined;
25
+ }
26
+ // Every stream whose artifact left canon. An obligation recorded there is still open until it
27
+ // is dismissed (D122), so the ledger reads these streams too.
28
+ export function departedStreams(root, canon) {
29
+ const dir = join(root, '.witness', 'journal');
30
+ if (!existsSync(dir))
31
+ return [];
32
+ return readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length))
33
+ .filter((id) => !findById(canon, id) && canonExit(readStream(root, id)) !== undefined);
34
+ }
35
+ // What leaves canon WITH a spec. Its design is spec-scoped (row 69) and goes with it: the
36
+ // re-slice always deleted it, while the unwind left a ui spec's look behind in canon after the
37
+ // spec it described was gone. A design is listed only when present, because a state commit
38
+ // cannot stage a path that never existed.
39
+ export function prepareDeparture(root, paths, spec, entry) {
40
+ const design = designRel(paths, String(spec.meta.id));
41
+ const leaving = [spec.rel, ...(existsSync(join(root, design)) ? [design] : [])];
42
+ return {
43
+ entry,
44
+ files: [...leaving, journalRel(String(spec.meta.id))],
45
+ remove: () => { for (const rel of leaving)
46
+ unlinkSync(join(root, rel)); },
47
+ };
48
+ }
49
+ // The way back. Row 29 unwinds a write by selectively reverting its state commit, and a
50
+ // re-slice's commit did two things: it wrote the successor and departed its predecessor.
51
+ // Taking that write back therefore brings the predecessor back — its file and design as they
52
+ // stood before the commit, byte for byte, a status entry that ends the departure, and the
53
+ // obligations that had followed it to the successor. Without this, abandoning the effort that
54
+ // re-sliced a shipped spec left canon describing none of what had shipped, under an id no
55
+ // verb could use again.
56
+ //
57
+ // A return never lands content an abandoned effort wrote: that write is taken back too. An
58
+ // earlier abandon skips a spec a later re-slice had already departed, and one effort can
59
+ // re-slice its own re-slice, so the predecessor of the write being taken back may itself be
60
+ // work no live effort owns. The walk goes back past each such write — to the content before an
61
+ // amendment, or, past a create, to whatever that create had departed in its turn — so canon
62
+ // ends where it stood before every abandoned write, whatever order the efforts went in.
63
+ export function prepareReturns(root, paths, commit, successor, takenBack = () => undefined) {
64
+ return returnsThrough(root, paths, commit, [successor], [], takenBack, new Set([commit]));
65
+ }
66
+ function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
67
+ const successor = chain.at(-1);
68
+ const shown = tryGit(root, 'show', '--no-renames', '--name-status', '--format=', commit);
69
+ if (!shown.ok)
70
+ return [];
71
+ const deleted = shown.out.split('\n').filter((l) => l.startsWith('D\t')).map((l) => l.slice(2));
72
+ return deleted.flatMap((rel) => {
73
+ if (!rel.startsWith(`${paths.specs}/`) || !rel.endsWith('.md'))
74
+ return [];
75
+ let src = `${commit}^`;
76
+ let blob = gitBlob(root, src, rel);
77
+ if (blob === undefined)
78
+ return [];
79
+ let parsed = splitDoc(blob.toString('utf8'));
80
+ if (!parsed.ok)
81
+ return [];
82
+ const id = String(parsed.value.meta.id);
83
+ const exit = canonExit(readStream(root, id));
84
+ if (exit?.to !== 'superseded' || exit.by !== successor)
85
+ return [];
86
+ let status = exit.from;
87
+ for (;;) {
88
+ const back = takenBack(id, canonicalSha(parsed.value.meta, parsed.value.body), rel, src);
89
+ if (back === undefined || seen.has(back))
90
+ break;
91
+ seen.add(back);
92
+ const before = gitBlob(root, `${back}^`, rel);
93
+ if (before === undefined) {
94
+ // That write created it: it stays out, and what it had departed comes back instead.
95
+ const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
96
+ return returnsThrough(root, paths, back, [...chain, id], [...unwound, gone], takenBack, seen);
97
+ }
98
+ const prior = splitDoc(before.toString('utf8'));
99
+ if (!prior.ok)
100
+ break;
101
+ src = `${back}^`;
102
+ blob = before;
103
+ parsed = prior;
104
+ status = String(prior.value.meta.status);
105
+ }
106
+ const bytes = blob;
107
+ const design = designRel(paths, id);
108
+ const designBlob = deleted.includes(design) ? gitBlob(root, src, design) : undefined;
109
+ const put = (r, b) => {
110
+ mkdirSync(dirname(join(root, r)), { recursive: true }); // git keeps no empty directory: a fresh clone may lack it
111
+ writeFileSync(join(root, r), b);
112
+ };
113
+ return [{
114
+ entry: { v: 1, t: 'status', artifact: id, from: 'superseded', to: status, cause: 'abandon' },
115
+ files: [rel, ...(designBlob === undefined ? [] : [design]), journalRel(id)],
116
+ restore: () => {
117
+ put(rel, bytes);
118
+ if (designBlob !== undefined)
119
+ put(design, designBlob);
120
+ },
121
+ // Each link of the chain hands home the debts that came to it from the next one down.
122
+ moves: chain.flatMap((s, k) => deferralMoves(readStream(root, s), s, id, (d) => d.moved_from === (chain[k + 1] ?? id))),
123
+ unwound,
124
+ }];
125
+ });
126
+ }
127
+ // Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
128
+ // because its stream IS that artifact's history. Three streams already belong to something
129
+ // else: a superseded spec's (a successor owns its content, and its finished plans resolve their
130
+ // parent there), an effort's, and witness's own floor pins. The one re-use is the same artifact
131
+ // again: a spec its own abandon unwound may be written again as a spec. A stream that records
132
+ // none of these — a stray, or a doc canon cannot parse — does not hold its id; the findings
133
+ // that already name it (`orphan-journal`, the parse error) are its remedy.
134
+ export function idRetired(root, id, type) {
135
+ if (id === FLOOR_STREAM)
136
+ return v('id', 'id-taken', id, `a new id — ${id} is witness's own floor stream`);
137
+ const entries = readStream(root, id);
138
+ if (entries[0]?.t === 'recap')
139
+ return v('id', 'id-taken', id, `a new id — ${id} is an effort's slug`);
140
+ const exit = canonExit(entries);
141
+ if (exit?.to === 'superseded') {
142
+ 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`);
143
+ }
144
+ if (exit?.to === 'removed' && type !== 'spec')
145
+ return v('id', 'id-taken', id, `a new id — ${id} was a spec its abandon unwound`);
146
+ return undefined;
147
+ }
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,6 +8,7 @@ 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)))];
13
14
  return [
@@ -15,6 +16,8 @@ export function renderRefusal(violations) {
15
16
  // governs what prints, but a remedy is not a column and must not become one.
16
17
  ...rows('refused', ['field', 'rule', 'got', 'want'], violations.map(({ remedy: _remedy, ...rest }) => rest)),
17
18
  ...runnable.map((r) => `run: ${r}`),
18
- 'help: fix each row and re-run — rows are structured for self-repair',
19
+ violations.some((x) => x.human)
20
+ ? 'help: a row here is a human\'s decision — stop and surface the list; re-running cannot clear it'
21
+ : 'help: fix each row and re-run — rows are structured for self-repair',
19
22
  ];
20
23
  }
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 { executeAbandon, keptLine, planItems, 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) {
@@ -44,6 +44,8 @@ export async function run(ctx, argv) {
44
44
  if (itemR.value.revert) {
45
45
  ctx.out(kv('reverted', `${String(itemR.value.revert.doc.meta.id)} (${itemR.value.revert.action}) — journal untouched`));
46
46
  }
47
+ if (itemR.value.kept)
48
+ ctx.out(kv('kept', keptLine(itemR.value.kept)));
47
49
  return EXIT.OK;
48
50
  }
49
51
  if (streamExists(root, target) && latestRecap(root, target)) {
@@ -56,6 +58,7 @@ export async function run(ctx, argv) {
56
58
  const abandonSet = new Set(writes.keys());
57
59
  const items = [];
58
60
  const problems = [];
61
+ const kept = [];
59
62
  for (const id of writes.keys()) {
60
63
  const artifact = findById(canon, id);
61
64
  if (!artifact)
@@ -80,13 +83,25 @@ export async function run(ctx, argv) {
80
83
  problems.push(v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
81
84
  continue;
82
85
  }
83
- const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet);
86
+ const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, target);
84
87
  if (!revertR.ok) {
85
88
  problems.push(...revertR.violations);
86
89
  continue;
87
90
  }
88
- if (revertR.value)
89
- items.push({ revert: revertR.value });
91
+ const outcome = revertR.value;
92
+ if (outcome?.action === 'keep') {
93
+ // D166. This abandon is the effort's last act, so a keep here must be one nothing has
94
+ // to take back later: a shipped plan makes it so — canon describes built behavior. A
95
+ // plan of another effort still building on the spec would leave it owned by no one and
96
+ // removable by no verb, so that case refuses until the plan finishes or goes.
97
+ if (!outcome.shipped) {
98
+ 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'));
99
+ continue;
100
+ }
101
+ kept.push(outcome);
102
+ }
103
+ else if (outcome)
104
+ items.push({ revert: outcome });
90
105
  }
91
106
  if (problems.length > 0) {
92
107
  renderRefusal(problems).forEach((l) => ctx.err(l));
@@ -98,6 +113,8 @@ export async function run(ctx, argv) {
98
113
  return EXIT.REFUSED;
99
114
  }
100
115
  ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked, journal remembers the attempt`));
116
+ for (const k of kept)
117
+ ctx.out(kv('kept', keptLine(k)));
101
118
  return EXIT.OK;
102
119
  }
103
120
  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 } 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,10 @@ 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);
45
50
  for (const dep of Array.isArray(meta.depends) ? meta.depends : []) {
46
51
  if (!findById(canon, dep))
47
52
  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, 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,13 @@ 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` — the rule
133
+ // `rename` applies to its target, with the one re-use of a spec its abandon unwound).
134
+ if (existing === undefined) {
135
+ const taken = idRetired(root, id, String(manifest.type));
136
+ if (taken)
137
+ return refuseWrite([taken]);
138
+ }
130
139
  const built = manifest.type === 'plan'
131
140
  ? buildPlanMeta(root, id, manifest, recap, canon, existing, body)
132
141
  : buildSpecMeta(root, id, manifest, recap, canon, existing, body);
@@ -141,6 +150,7 @@ export async function run(ctx, argv) {
141
150
  // meta.supersedes, so this is a no-op for plan writes.
142
151
  const supersedes = built.meta.supersedes === undefined ? undefined : String(built.meta.supersedes);
143
152
  let reslice;
153
+ let moves = [];
144
154
  if (supersedes !== undefined) {
145
155
  if (supersedes === id) {
146
156
  return refuseWrite([v('supersedes', 'self-supersede', id, 'a different spec')]);
@@ -150,28 +160,59 @@ export async function run(ctx, argv) {
150
160
  }
151
161
  const target = findById(canon, supersedes);
152
162
  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)')]);
163
+ // D166. Any departure is terminal here — a spec its abandon unwound as well as one
164
+ // already superseded: either way there is nothing left to delete. Abandoning the plan
165
+ // a `dangling-parent` refusal names can unwind the very spec this write re-slices.
166
+ if (canonExit(readStream(root, supersedes)) === undefined) {
167
+ return refuseWrite([v('supersedes', 'unknown-supersedes', supersedes, 'an existing spec (or one that already left canon)')]);
157
168
  }
158
169
  }
159
170
  else {
171
+ // D166. Re-slicing re-cuts specs (row 5). Deleting a plan here stranded a live flow, and
172
+ // deleting the principles removed every chore's only parent, with nothing to bring
173
+ // either back.
174
+ if (target.meta.type !== 'spec') {
175
+ 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')]);
176
+ }
160
177
  const dependents = canon.docs
161
178
  .filter((d) => String(d.meta.id) !== id)
162
179
  .filter((d) => (d.meta.depends ?? []).includes(supersedes))
163
180
  .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 = {
181
+ // D166. A plan's `parent` is the other edge into the spec this deletes, and row 62
182
+ // guarded only `depends`: every gate reads the parent, so a plan still moving was left
183
+ // unable to move and impossible to clear (issue #34). A finished plan holds nothing — the
184
+ // successor carries the content forward, and `check` resolves the plan's parent in the
185
+ // departed stream. Whether a moving plan ships first or is withdrawn is a human's call,
186
+ // so the row says so and names no command an agent could run for them.
187
+ const moving = plansOn(canon, supersedes).filter((p) => !planFinished(p)).map((p) => String(p.meta.id));
188
+ // Both edges in ONE refusal: every refusal counts against the first-try rate the
189
+ // dashboard trends, and learning of the second edge only after repairing the first
190
+ // would cost a refusal for nothing.
191
+ const dangling = [
192
+ ...(dependents.length > 0 ? [v('supersedes', 'dangling-depends', dependents.join(' '), 'dependents rewritten first — the re-slice rewrites them before deleting')] : []),
193
+ ...(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'))] : []),
194
+ ];
195
+ if (dangling.length > 0)
196
+ return refuseWrite(dangling);
197
+ reslice = prepareDeparture(root, cfg.value.paths, target, {
168
198
  v: 1, t: 'status', artifact: supersedes, from: String(target.meta.status),
169
199
  to: 'superseded', cause: 'supersede', by: id,
170
- };
171
- reslice = { doc: target, entry, line: entryLine(entry) };
200
+ });
201
+ // D166. The superseded spec's open obligations follow its content to the successor —
202
+ // D122's rule, whose premise ("the parent spec, which outlives it") the re-slice ends.
203
+ // A split's later slices find the stream already settled, so its debts go to the first.
204
+ moves = deferralMoves(readStream(root, supersedes), supersedes, id);
172
205
  }
173
206
  }
174
207
  const rel = existing ? existing.rel : `${manifest.type === 'plan' ? cfg.value.paths.plans : cfg.value.paths.specs}/${id}.md`;
208
+ // D166. Nor land on another doc's file: `rename` moves the id and the journal but leaves the
209
+ // path, so `specs/<old>.md` can hold the renamed doc, and a create under the old id overwrote it.
210
+ if (existing === undefined && existsSync(join(root, rel))) {
211
+ const holder = canon.docs.find((d) => d.rel === rel);
212
+ return refuseWrite([v('id', 'path-taken', rel, holder
213
+ ? `a new id — ${rel} holds ${String(holder.meta.id)}`
214
+ : `a new id — ${rel} holds a doc canon cannot read (fix or revert it first)`)]);
215
+ }
175
216
  const stream = journalRel(effort);
176
217
  const unrelated = dirtyStatePaths(root).filter((p) => p !== rel && p !== stream);
177
218
  if (unrelated.length) {
@@ -199,24 +240,21 @@ export async function run(ctx, argv) {
199
240
  // entry. Row 116's `w` stamp is what caught it; the shape is the one rows 93, 95 and
200
241
  // 96 all name, and every other txn writer already calls entryLine.
201
242
  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
- ];
243
+ const files = [rel, stream, ...(reslice?.files ?? []), ...(moves.length > 0 ? [journalRel(id)] : [])];
207
244
  const res = withTxn(root, {
208
245
  op: `write(${id})`, files, journal: { stream: effort, line },
209
- ...(reslice ? { journalMulti: [{ stream: reslice.entry.artifact, line: reslice.line }] } : {}),
246
+ ...(reslice ? { journalMulti: [
247
+ { stream: reslice.entry.artifact, line: entryLine(reslice.entry) },
248
+ ...moves.map((m) => ({ stream: m.stream, line: entryLine(m.entry) })),
249
+ ] } : {}),
210
250
  }, () => {
211
251
  writeDoc(join(root, rel), { meta: built.meta, body });
212
252
  crashPoint(ctx.env, 'artifact-write');
213
253
  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
- }
254
+ reslice.remove();
219
255
  appendEntry(root, reslice.entry.artifact, reslice.entry);
256
+ for (const m of moves)
257
+ appendEntry(root, m.stream, m.entry);
220
258
  crashPoint(ctx.env, 'reslice-commit');
221
259
  }
222
260
  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.7",
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.7",
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.7}"
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.7} 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.7}"
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.7}"
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.7}"
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.7}"
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.7}"
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.7}"
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.