@popovych.co/witness 0.19.0 → 0.19.2

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,6 +1,6 @@
1
1
  import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { prepareDeparture, prepareReturns } from './departure.js';
3
+ import { canonExit, prepareDeparture, prepareReturns, takeBack } from './departure.js';
4
4
  import { designRel } from './design.js';
5
5
  import { splitDoc } from './fm.js';
6
6
  import { canonicalSha } from './sha.js';
@@ -9,33 +9,85 @@ import { acquireLock } from './lock.js';
9
9
  import { crashPoint, withTxn } from './txn.js';
10
10
  import { appendEntry, effortAbandoned, effortStreams, entryLine, journalRel, readStream } from './journal.js';
11
11
  import { findById, plansOn } from './scan.js';
12
- import { effortOf, effortWrites } from './reviewed.js';
13
12
  import { humanCall, ok, refuse, v } from './refusal.js';
14
13
  import { prepareStamp, writeStamp } from './stamp.js';
15
14
  import { kv } from './toon.js';
16
15
  import { removeWorktree, worktreePath } from './worktree.js';
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);
20
- if (!log.ok)
21
- return undefined;
22
- // Status-only touch-ups (approve, stampLive, a gate's writeStamp) commit the same
23
- // canonical sha under a new commit, since status is a volatile field excluded from
24
- // it. Walking newest-first and stopping at the first non-match — rather than
25
- // returning the first match — lands on the OLDEST commit that established this
26
- // content, so its parent is the true prior (different-content) version.
16
+ const isWrite = (e, id, sha) => {
17
+ const w = e;
18
+ return w.t === 'write' && w.artifact === id && (sha === undefined || w.sha === sha);
19
+ };
20
+ const isAncestor = (root, a, b) => tryGit(root, 'merge-base', '--is-ancestor', a, b).ok;
21
+ // D168. Each line of a stream as history stood at `at`, with the commit that appended it — which
22
+ // `git blame` names because the journal is append-only. `--no-ignore-revs-file`: a blame config
23
+ // (a dotfile's `blame.ignoreRevsFile`) re-attributes lines, or makes blame fail outright in a repo
24
+ // that lacks the file. A blank line is skipped, as `readStream` skips it.
25
+ function journalActs(root, stream, at = 'HEAD') {
26
+ const blame = tryGit(root, 'blame', '--line-porcelain', '--no-ignore-revs-file', at, '--', journalRel(stream));
27
+ if (!blame.ok)
28
+ return [];
29
+ const out = [];
30
+ let commit = '';
31
+ for (const line of blame.out.split('\n')) {
32
+ if (/^[0-9a-f]{40} \d+ \d+/.test(line)) {
33
+ commit = line.slice(0, 40);
34
+ continue;
35
+ }
36
+ if (line.startsWith('\t') && line.trim() !== '')
37
+ out.push({ entry: JSON.parse(line.slice(1)), commit });
38
+ }
39
+ return out;
40
+ }
41
+ // D168. The act this content of a spec is, as history stood at `at`: the newest write of it, by
42
+ // whichever effort made it. Rows 29 and 84 resolved a pin by walking the spec's path for matching
43
+ // content, and content is not an act: a return or another abandon's restore brings the same content
44
+ // back under a later commit, whose parent was never the write's — so an amendment read as a create
45
+ // and was deleted, and a create restored another effort's abandoned amendment. And the same bytes
46
+ // written again later, by another effort, are that effort's: who may take them back is the
47
+ // caller's question, asked of this answer.
48
+ export function writeAct(root, id, sha, at = 'HEAD') {
27
49
  let found;
28
- for (const sha of log.out.split('\n').filter(Boolean)) {
29
- const show = tryGit(root, 'show', `${sha}:${rel}`);
30
- if (!show.ok)
31
- break;
32
- const doc = splitDoc(show.out);
33
- if (!doc.ok || canonicalSha(doc.value.meta, doc.value.body) !== pin)
34
- break;
35
- found = sha;
50
+ for (const effort of effortStreams(root)) {
51
+ if (!readStream(root, effort).some((e) => isWrite(e, id, sha)))
52
+ continue;
53
+ for (const { entry, commit } of journalActs(root, effort, at)) {
54
+ if (!isWrite(entry, id, sha))
55
+ continue;
56
+ if (found === undefined || isAncestor(root, found.commit, commit)) {
57
+ found = { effort, commit, created: entry.created === true };
58
+ }
59
+ }
36
60
  }
37
61
  return found;
38
62
  }
63
+ const shaAt = (root, rev, rel) => {
64
+ const blob = gitBlob(root, rev, rel);
65
+ const doc = blob === undefined ? undefined : splitDoc(blob.toString('utf8'));
66
+ return doc?.ok ? canonicalSha(doc.value.meta, doc.value.body) : undefined;
67
+ };
68
+ // D168 (review). Whether this effort's abandon left its write standing — kept, because a plan
69
+ // stood on it or on a sibling slice, or the walk stopped at it. That content is canon of record:
70
+ // no later walk goes past it, or an amendment on top of it took the keep with it.
71
+ function stoodAtAbandon(root, effort, rel, sha) {
72
+ const at = journalActs(root, effort).find(({ entry }) => entry.t === 'human-decision' && entry.decision === 'abandon-effort');
73
+ return at !== undefined && shaAt(root, at.commit, rel) === sha;
74
+ }
75
+ // This effort's newest write of an artifact (of this content, when `sha` is given).
76
+ export const effortWrite = (root, effort, id, sha) => journalActs(root, effort).filter(({ entry }) => isWrite(entry, id, sha)).at(-1);
77
+ // D168 (review). Whether an earlier abandon already took this effort's newest write of a spec
78
+ // back — `abandon <plan>` does, before the effort's own abandon: a status entry the abandon
79
+ // journaled on the spec after that write, which is not a return, made where the spec stood at
80
+ // the write's content. Then the effort's abandon has nothing of its own left there to take back.
81
+ export function takenBackAlready(root, effort, id, rel, sha) {
82
+ const write = effortWrite(root, effort, id, sha);
83
+ if (write === undefined)
84
+ return false;
85
+ return journalActs(root, id).some(({ entry, commit }) => {
86
+ const s = entry;
87
+ const takes = s.t === 'status' && s.cause === 'abandon' && !(s.from === 'superseded' && s.to !== 'removed');
88
+ return takes && commit !== write.commit && isAncestor(root, write.commit, commit) && shaAt(root, `${commit}^`, rel) === sha;
89
+ });
90
+ }
39
91
  export const keptLine = (k) => `${String(k.doc.meta.id)} — not unwound · still standing on it: ${k.standing.join(' ')}`;
40
92
  // D167. The abandon says everything it did to canon, on both paths, as `kept:` already did: what
41
93
  // it reverted, what came back with it, and what the walk took back on the way.
@@ -43,28 +95,32 @@ export function abandonLines(items, kept) {
43
95
  return [
44
96
  ...items.flatMap((item) => [
45
97
  ...(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
- ]),
98
+ ...(item.revert?.returns ?? []).map((r) => kv('returned', `${r.entry.artifact} — back in canon as ${r.entry.to} · the re-slice into ${r.by} is taken back`)),
99
+ ...(item.revert?.unwound ?? []).map((u) => kv('unwound', `${u.artifact} — stays out of canon · an abandoned effort created it`)),
50
100
  ...(item.kept ? [kv('kept', keptLine(item.kept))] : []),
101
+ ...(item.note ? [kv('note', item.note)] : []),
51
102
  ]),
52
103
  ...kept.map((k) => kv('kept', keptLine(k))),
53
104
  ];
54
105
  }
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;
106
+ // D166 (review). The efforts whose writes the way back takes back besides this abandon's own
107
+ // targets: every effort already abandoned — whose abandon skipped a spec a later re-slice had
108
+ // departed — and the effort being abandoned now (`effort`, absent on the plan path).
109
+ const goneEfforts = (root, effort) => effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
110
+ // Content whose act is a write of one of `efforts` never comes back into canon on its own —
111
+ // unless a plan outside the abandon set stands on it (the keep rule, which never takes shipped
112
+ // content back), or that effort's own abandon left it standing.
113
+ function takenBackBy(root, canon, abandonSet, efforts) {
114
+ return (id, sha, rel, at) => {
115
+ if (plansOn(canon, id, { exclude: abandonSet, pin: sha }).length > 0)
116
+ return undefined;
117
+ const act = writeAct(root, id, sha, at);
118
+ return act !== undefined && efforts.includes(act.effort) && !stoodAtAbandon(root, act.effort, rel, sha) ? act : undefined;
119
+ };
66
120
  }
67
- export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
121
+ // `act`: the write of `pin` this abandon takes back — the effort's own on the effort path, the
122
+ // write the plan is paired with on the plan path. `effort`: the effort being abandoned, if any.
123
+ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
68
124
  const current = canonicalSha(spec.meta, spec.body);
69
125
  if (current !== pin) {
70
126
  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')]);
@@ -80,20 +136,50 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
80
136
  shipped: standing.some((p) => String(p.meta.status) === 'done'),
81
137
  });
82
138
  }
83
- const commit = pinCommit(root, spec.rel, pin);
84
- if (!commit) {
85
- return refuse([v('spec', 'pin-unresolvable', pin.slice(0, 7), 'a committed version matching the pin')]);
139
+ const id = String(spec.meta.id);
140
+ // D168. The write being taken back is one act of its effort, and so is every earlier write of
141
+ // this spec it made: a revise round re-writes, and taking back only the last write left the
142
+ // effort's own first draft in canon. The walk goes back past those and past every abandoned
143
+ // effort's write, and stops at content a plan stands on.
144
+ const gone = goneEfforts(root, effort);
145
+ const walking = [...new Set([...gone, act.effort])];
146
+ const back = takeBack(root, spec.rel, id, act, takenBackBy(root, canon, abandonSet, walking));
147
+ if (back.end === 'lost') {
148
+ return refuse([humanCall(v('spec', 'pin-unresolvable', `${id}: no content at ${spec.rel} before ${back.act.effort}'s write ${back.act.commit.slice(0, 7)}`, 'that write to read as an amendment of what stood on this path — the spec was moved by hand or history was cut · a human\'s call'))]);
86
149
  }
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));
150
+ // D168 (review). The walk stopping at an earlier round of the effort being abandoned, because
151
+ // another plan still building stands on it, is a keep of that effort's content: the same keep
152
+ // the first round alone would have been, and refused for the same reason — this abandon is the
153
+ // last act that could take it back. Once a plan shipped it, it stands for good.
154
+ if (effort !== undefined && back.end === 'content') {
155
+ const sha = canonicalSha(back.doc.meta, back.doc.body);
156
+ const on = plansOn(canon, id, { exclude: abandonSet, pin: sha });
157
+ const owner = on.length > 0 ? writeAct(root, id, sha, back.src) : undefined;
158
+ if (owner !== undefined && walking.includes(owner.effort) && !on.some((p) => String(p.meta.status) === 'done')) {
159
+ return ok({ doc: spec, action: 'keep', standing: on.map((p) => String(p.meta.id)), shipped: false });
160
+ }
161
+ }
162
+ // D166. The way back: a write being taken back may have departed a predecessor (a re-slice),
163
+ // which then returns with it. A re-slice can cut one spec into several slices, and taking
164
+ // back one while another still stands would leave the predecessor half-described, so that
165
+ // write is kept until its siblings go too — together, through the effort that made them.
166
+ // D168: every write the walk takes back, not only the last — the create that re-sliced, or
167
+ // an amendment that folded another spec in, may lie under a revision.
168
+ const ways = back.passed.map((w) => prepareReturns(root, canon.paths, w.commit, id, takenBackBy(root, canon, abandonSet, gone)));
169
+ const returns = ways.flatMap((w) => w.returns);
170
+ const unwound = ways.flatMap((w) => w.unwound);
92
171
  // D166 (review). A sibling slice stands when this abandon leaves it in canon: it is outside the
93
172
  // abandon set, or inside it but kept because a plan outside the set stands on it. It stands for
94
173
  // good when a plan shipped it — and then so does this keep, since nothing can take the
95
174
  // 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)]));
175
+ // D188: on the plan path, also a slice whose own write departed nothing — another slice's
176
+ // write took the predecessor out. Its effort is still live, and its own abandon is the act that
177
+ // takes the split back together; deleting the slice now left the split half described. The
178
+ // effort path is that last act, and there a slice another effort cut cannot go back together
179
+ // with this one: holding it would leave two efforts' abandons each refusing for the other's.
180
+ const from = spec.meta.supersedes === undefined ? undefined : String(spec.meta.supersedes);
181
+ const slice = effort === undefined && back.end === 'create' && from !== undefined && canonExit(readStream(root, from))?.to === 'superseded' ? [from] : [];
182
+ const cut = new Set([...returns.map((r) => r.entry.artifact), ...unwound.map((u) => u.artifact), ...slice]);
97
183
  const slices = canon.docs
98
184
  .filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== String(spec.meta.id))
99
185
  .flatMap((d) => {
@@ -105,8 +191,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
105
191
  if (slices.length > 0) {
106
192
  return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
107
193
  }
108
- const prior = tryGit(root, 'show', `${commit}^:${spec.rel}`);
109
- if (!prior.ok) {
194
+ if (back.end === 'create') {
110
195
  const dependents = canon.docs
111
196
  .filter((d) => !abandonSet.has(String(d.meta.id)))
112
197
  .filter((d) => (d.meta.depends ?? []).includes(String(spec.meta.id)))
@@ -117,20 +202,19 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
117
202
  // D166. The unwind departs the spec through the same door the re-slice uses, so
118
203
  // everything that leaves canon with a spec — its design included — leaves here too.
119
204
  return ok({
120
- doc: spec, action: 'delete', returns,
205
+ doc: spec, action: 'delete', returns, unwound,
121
206
  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',
207
+ v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: 'removed', cause: 'abandon',
123
208
  }),
124
209
  });
125
210
  }
126
- const parsed = splitDoc(prior.out);
127
- const toStatus = parsed.ok ? String(parsed.value.meta.status) : 'draft';
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);
211
+ const toStatus = back.end === 'content' ? String(back.doc.meta.status) : 'draft';
212
+ // D166. The look goes back with the content it was drawn for: the design as it stood where the
213
+ // walk stopped, byte for byte — or none, if there was none then.
214
+ const designR = designRel(canon.paths, id);
215
+ const priorDesign = gitBlob(root, back.src, designR);
132
216
  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 });
217
+ return ok({ doc: spec, action: 'restore', content: back.blob.toString('utf8'), toStatus, design, returns, unwound });
134
218
  }
135
219
  // D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
136
220
  // file, so another doc may hold that path — or another spec this abandon brings back may have
@@ -154,6 +238,54 @@ export function returnCollisions(root, canon, items) {
154
238
  ...[...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
239
  ];
156
240
  }
241
+ // D188. What an abandon lands — a restore's older content, a return's departed bytes — comes
242
+ // back only beside what it depends on: a dependency in canon that this abandon does not take
243
+ // out, or one it brings back. `write` refuses a dangling `depends`, and an abandon landing one
244
+ // left `check` red after it exited 0. Bringing the dependency back first is a human's call.
245
+ export function landingEdges(root, canon, items) {
246
+ const leaving = new Set(items.flatMap((i) => (i.revert?.action === 'delete' ? [String(i.revert.doc.meta.id)] : [])));
247
+ const coming = new Set(items.flatMap((i) => (i.revert?.returns ?? []).map((r) => r.entry.artifact)));
248
+ const lands = items.flatMap((i) => {
249
+ if (!i.revert)
250
+ return [];
251
+ const restored = i.revert.action === 'restore' ? splitDoc(i.revert.content) : undefined;
252
+ return [
253
+ ...(restored?.ok ? [{ id: String(i.revert.doc.meta.id), depends: (restored.value.meta.depends ?? []).map(String) }] : []),
254
+ ...i.revert.returns.map((r) => ({ id: r.entry.artifact, depends: r.depends })),
255
+ ];
256
+ });
257
+ return lands.flatMap(({ id, depends }) => depends
258
+ .filter((dep) => !coming.has(dep) && (leaving.has(dep) || findById(canon, dep) === undefined))
259
+ .map((dep) => {
260
+ const exit = canonExit(readStream(root, dep));
261
+ const why = leaving.has(dep) ? 'it leaves with this abandon'
262
+ : exit?.to === 'superseded' ? `it left superseded by ${exit.by ?? 'a successor'}` : 'it left canon';
263
+ return humanCall(v('spec', 'dangling-depends', `${id} depends on ${dep}`, `${dep} in canon when ${id} comes back — ${why} · a human's call`));
264
+ }));
265
+ }
266
+ // D188 (review). A spec kept for good stays in canon with what it depends on: a dependency this
267
+ // abandon would delete is kept beside it, and for good with it. `waiting-dependents` reads only
268
+ // docs outside the abandon set, and a kept one is inside it, so the delete left `check` red after
269
+ // the abandon exited 0; refusing instead would hold the effort for good behind a shipped plan.
270
+ export function keepDependencies(items, kept) {
271
+ let walked = items;
272
+ const held = [...kept];
273
+ for (let grew = true; grew;) {
274
+ grew = false;
275
+ walked = walked.filter((item) => {
276
+ if (item.revert?.action !== 'delete')
277
+ return true;
278
+ const id = String(item.revert.doc.meta.id);
279
+ const on = held.filter((k) => (k.doc.meta.depends ?? []).map(String).includes(id));
280
+ if (on.length === 0)
281
+ return true;
282
+ held.push({ doc: item.revert.doc, action: 'keep', standing: on.map((k) => String(k.doc.meta.id)), shipped: on.some((k) => k.shipped) });
283
+ grew = true;
284
+ return false;
285
+ });
286
+ }
287
+ return { items: walked, kept: held };
288
+ }
157
289
  export function executeAbandon(root, ctx, items, effortEntry, subject) {
158
290
  const files = new Set();
159
291
  const journalMulti = [];
@@ -180,20 +312,21 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
180
312
  for (const f of touched)
181
313
  files.add(f);
182
314
  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
- ];
315
+ // D166. A spec this write had departed comes back in the same state commit — and, D168,
316
+ // a spec the walk took back past its create leaves canon's record in it too.
317
+ const asLine = (e) => ({ stream: e.artifact, entry: e });
318
+ const lines = [
319
+ ...item.revert.returns.flatMap((r) => [asLine(r.entry), ...r.moves]),
320
+ ...item.revert.unwound.map(asLine),
321
+ ];
322
+ for (const r of item.revert.returns)
189
323
  for (const f of r.files)
190
324
  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);
325
+ for (const l of lines) {
326
+ files.add(journalRel(l.stream));
327
+ journalMulti.push({ stream: l.stream, line: entryLine(l.entry) });
196
328
  }
329
+ returnEntries.push(...lines);
197
330
  }
198
331
  }
199
332
  let effortLine;
@@ -270,13 +403,18 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
270
403
  };
271
404
  if (pairing === 'stamp-only')
272
405
  return ok(item);
273
- const effort = effortOf(root, planId);
406
+ // D168. Paired by the act: an effort that wrote this plan also made the write of the parent the
407
+ // plan derives from. Deriving the owner through effortOf tie-broke alphabetically across every
408
+ // effort that wrote the plan, and pairing on "wrote the parent at all" reverted content another
409
+ // effort wrote whenever the plan was derived from it.
274
410
  const parentId = String(plan.meta.parent);
275
- const paired = effort !== undefined && effortWrites(root, effort).has(parentId);
276
- if (paired) {
277
- const parent = findById(canon, parentId);
278
- if (parent) {
279
- const revertR = specRevertFor(root, canon, parent, String(plan.meta['derives-from']), abandonSet);
411
+ const parent = findById(canon, parentId);
412
+ if (parent) {
413
+ const pin = String(plan.meta['derives-from']);
414
+ const writers = effortStreams(root).filter((e) => readStream(root, e).some((x) => isWrite(x, planId)));
415
+ const act = writeAct(root, parentId, pin);
416
+ if (act !== undefined && writers.includes(act.effort)) {
417
+ const revertR = specRevertFor(root, canon, parent, pin, abandonSet, act);
280
418
  if (!revertR.ok)
281
419
  return revertR;
282
420
  const outcome = revertR.value;
@@ -285,6 +423,12 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
285
423
  else
286
424
  item.revert = outcome;
287
425
  }
426
+ else if (act === undefined && writers.some((e) => readStream(root, e).some((x) => isWrite(x, parentId)))) {
427
+ // The plan's effort wrote this spec, but no journaled write made the content the plan
428
+ // derives from (a hand edit, an adopt): there is no act to pair with, and saying so is
429
+ // what keeps a spec left standing from looking unwound.
430
+ item.note = `${parentId} — not unwound · no journaled write made the content ${planId} derives from`;
431
+ }
288
432
  }
289
433
  return ok(item);
290
434
  }
package/dist/departure.js CHANGED
@@ -47,6 +47,55 @@ export function prepareDeparture(root, paths, spec, entry) {
47
47
  unlinkSync(join(root, rel)); },
48
48
  };
49
49
  }
50
+ // The content a spec stood at when this write was made: none, when the entry says the write
51
+ // created it; lost, when the entry says amendment but the path held nothing then — moved by
52
+ // hand, or history cut — which is never read as a create.
53
+ function before(root, rel, act) {
54
+ if (act.created)
55
+ return 'create';
56
+ const src = `${act.commit}^`;
57
+ const blob = gitBlob(root, src, rel);
58
+ return blob === undefined ? 'lost' : { src, blob };
59
+ }
60
+ // D168. From content at `src`, back past every write `takenBack` names, each found by its own
61
+ // journal entry: to the content before the last of them, or to a create, past which the spec
62
+ // has no content to stand at. `passed` is every write taken back on the way, oldest last.
63
+ export function walkBack(root, rel, id, at, takenBack, seen) {
64
+ let { src, blob, doc } = at;
65
+ const passed = [];
66
+ for (;;) {
67
+ const back = takenBack(id, canonicalSha(doc.meta, doc.body), rel, src);
68
+ if (back === undefined || seen.has(back.commit))
69
+ return { end: 'content', src, blob, doc, passed };
70
+ seen.add(back.commit);
71
+ const prior = before(root, rel, back);
72
+ if (prior === 'create')
73
+ return { end: 'create', act: back, passed: [...passed, back] };
74
+ if (prior === 'lost')
75
+ return { end: 'lost', act: back, passed, at: { src, blob, doc } };
76
+ const parsed = splitDoc(prior.blob.toString('utf8'));
77
+ if (!parsed.ok)
78
+ return { end: 'content', src, blob, doc, passed }; // kept: nothing readable before it
79
+ passed.push(back);
80
+ ({ src, blob } = prior);
81
+ doc = parsed.value;
82
+ }
83
+ }
84
+ // D168. Taking back one write: the content before it, walked back past every write `takenBack`
85
+ // names — or the create, when the write or a write before it created the spec. An unreadable
86
+ // prior is restored as it stands, which is all the walk could do with it.
87
+ export function takeBack(root, rel, id, act, takenBack) {
88
+ const prior = before(root, rel, act);
89
+ if (prior === 'create')
90
+ return { end: 'create', act, passed: [act] };
91
+ if (prior === 'lost')
92
+ return { end: 'lost', act, passed: [] };
93
+ const parsed = splitDoc(prior.blob.toString('utf8'));
94
+ if (!parsed.ok)
95
+ return { end: 'unreadable', ...prior, passed: [act] };
96
+ const walked = walkBack(root, rel, id, { ...prior, doc: parsed.value }, takenBack, new Set([act.commit]));
97
+ return { ...walked, passed: [act, ...walked.passed] };
98
+ }
50
99
  // The way back. Row 29 unwinds a write by selectively reverting its state commit, and a
51
100
  // re-slice's commit did two things: it wrote the successor and departed its predecessor.
52
101
  // Taking that write back therefore brings the predecessor back — its file and design as they
@@ -62,70 +111,65 @@ export function prepareDeparture(root, paths, spec, entry) {
62
111
  // amendment, or, past a create, to whatever that create had departed in its turn — so canon
63
112
  // ends where it stood before every abandoned write, whatever order the efforts went in.
64
113
  export function prepareReturns(root, paths, commit, successor, takenBack = () => undefined) {
65
- return returnsThrough(root, paths, commit, [successor], [], takenBack, new Set([commit]));
114
+ return returnsThrough(root, paths, commit, [successor], takenBack, new Set([commit]));
66
115
  }
67
- function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
116
+ function returnsThrough(root, paths, commit, chain, takenBack, seen) {
68
117
  const successor = chain.at(-1);
69
118
  const shown = tryGit(root, 'show', '--no-renames', '--name-status', '--format=', commit);
70
119
  if (!shown.ok)
71
- return [];
120
+ return { returns: [], unwound: [] };
72
121
  const deleted = shown.out.split('\n').filter((l) => l.startsWith('D\t')).map((l) => l.slice(2));
73
- return deleted.flatMap((rel) => {
122
+ const ways = deleted.map((rel) => {
123
+ const none = { returns: [], unwound: [] };
74
124
  if (!rel.startsWith(`${paths.specs}/`) || !rel.endsWith('.md'))
75
- return [];
76
- let src = `${commit}^`;
77
- let blob = gitBlob(root, src, rel);
125
+ return none;
126
+ const src = `${commit}^`;
127
+ const blob = gitBlob(root, src, rel);
78
128
  if (blob === undefined)
79
- return [];
80
- let parsed = splitDoc(blob.toString('utf8'));
129
+ return none;
130
+ const parsed = splitDoc(blob.toString('utf8'));
81
131
  if (!parsed.ok)
82
- return [];
132
+ return none;
83
133
  const id = String(parsed.value.meta.id);
84
134
  const exit = canonExit(readStream(root, id));
85
135
  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);
136
+ return none;
137
+ const reached = walkBack(root, rel, id, { src, blob, doc: parsed.value }, takenBack, seen);
138
+ // A write the walk cannot go past leaves the return where it stood before that write.
139
+ const walked = reached.end === 'lost' ? { ...reached, ...reached.at, end: 'content' } : reached;
140
+ if (walked.end === 'create') {
141
+ // That write created it: it stays out — journaled so whether or not anything lies under
142
+ // the create — and what it had departed comes back instead.
143
+ const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
144
+ const under = returnsThrough(root, paths, walked.act.commit, [...chain, id], takenBack, seen);
145
+ return { returns: under.returns, unwound: [gone, ...under.unwound] };
106
146
  }
107
- const bytes = blob;
147
+ const status = walked.passed.length > 0 ? String(walked.doc.meta.status) : exit.from;
108
148
  const design = designRel(paths, id);
109
- const designBlob = deleted.includes(design) ? gitBlob(root, src, design) : undefined;
149
+ const designBlob = deleted.includes(design) ? gitBlob(root, walked.src, design) : undefined;
110
150
  const put = (r, b) => {
111
151
  mkdirSync(dirname(join(root, r)), { recursive: true }); // git keeps no empty directory: a fresh clone may lack it
112
152
  writeFileSync(join(root, r), b);
113
153
  };
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
- }];
154
+ return {
155
+ returns: [{
156
+ entry: { v: 1, t: 'status', artifact: id, from: 'superseded', to: status, cause: 'abandon' },
157
+ files: [rel, ...(designBlob === undefined ? [] : [design]), journalRel(id)],
158
+ restore: () => {
159
+ put(rel, walked.blob);
160
+ if (designBlob !== undefined)
161
+ put(design, designBlob);
162
+ },
163
+ // Each link of the chain hands home the debts that came to it from the next one down.
164
+ moves: chain.flatMap((s, k) => deferralMoves(readStream(root, s), s, id, (d) => d.moved_from === (chain[k + 1] ?? id))),
165
+ rel,
166
+ by: successor,
167
+ depends: (walked.doc.meta.depends ?? []).map(String),
168
+ }],
169
+ unwound: [],
170
+ };
128
171
  });
172
+ return { returns: ways.flatMap((w) => w.returns), unwound: ways.flatMap((w) => w.unwound) };
129
173
  }
130
174
  // Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
131
175
  // because its stream IS that artifact's history. Three streams already belong to something
package/dist/reviewed.js CHANGED
@@ -75,7 +75,9 @@ export function effortWrites(root, effort) {
75
75
  if (e.t !== 'write')
76
76
  continue;
77
77
  const w = e;
78
- out.set(w.artifact, { sha: w.sha, covers: w.covers, created: w.created });
78
+ // D168. Latest write wins for sha and covers, but a revision does not un-create: the effort
79
+ // created the artifact if any of its writes did.
80
+ out.set(w.artifact, { sha: w.sha, covers: w.covers, created: w.created ?? out.get(w.artifact)?.created });
79
81
  }
80
82
  return out;
81
83
  }
@@ -5,9 +5,9 @@ import { findById, loadCanon } from '../scan.js';
5
5
  import { effortAbandoned, latestRecap, readStream, streamExists } from '../journal.js';
6
6
  import { effortWrites } from '../reviewed.js';
7
7
  import { canonicalSha } from '../sha.js';
8
- import { renderRefusal, v } from '../refusal.js';
8
+ import { humanCall, renderRefusal, v } from '../refusal.js';
9
9
  import { kv } from '../toon.js';
10
- import { abandonLines, executeAbandon, planItems, returnCollisions, specRevertFor } from '../abandon.js';
10
+ import { abandonLines, executeAbandon, keepDependencies, landingEdges, planItems, returnCollisions, specRevertFor, takenBackAlready, writeAct } 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,7 +35,7 @@ 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]);
38
+ const taken = [...returnCollisions(root, canon, [itemR.value]), ...landingEdges(root, canon, [itemR.value])];
39
39
  if (taken.length > 0) {
40
40
  renderRefusal(taken).forEach((l) => ctx.err(l));
41
41
  return EXIT.REFUSED;
@@ -80,11 +80,20 @@ export async function run(ctx, argv) {
80
80
  const artifact = findById(canon, id);
81
81
  if (!artifact || artifact.meta.type !== 'spec')
82
82
  continue;
83
- if (canonicalSha(artifact.meta, artifact.body) !== w.sha) {
84
- problems.push(v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
83
+ // D168. The effort's write is what this abandon takes back only while it is the act the
84
+ // spec's content is — the same bytes written again later are the later effort's. A write
85
+ // an earlier abandon already took back (`abandon <plan>`) leaves nothing here to take.
86
+ const current = canonicalSha(artifact.meta, artifact.body);
87
+ const act = writeAct(root, id, current);
88
+ if (current !== w.sha || act?.effort !== target) {
89
+ if (takenBackAlready(root, target, id, artifact.rel, w.sha))
90
+ continue;
91
+ problems.push(current === w.sha && act === undefined
92
+ ? humanCall(v('spec', 'pin-unresolvable', id, `${target}'s journaled write of this content — the journal's history is unreadable here · a human's call`))
93
+ : v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
85
94
  continue;
86
95
  }
87
- const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, target);
96
+ const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, act, target);
88
97
  if (!revertR.ok) {
89
98
  problems.push(...revertR.violations);
90
99
  continue;
@@ -104,18 +113,19 @@ export async function run(ctx, argv) {
104
113
  else if (outcome)
105
114
  items.push({ revert: outcome });
106
115
  }
107
- problems.push(...returnCollisions(root, canon, items));
116
+ const held = keepDependencies(items, kept);
117
+ problems.push(...returnCollisions(root, canon, held.items), ...landingEdges(root, canon, held.items));
108
118
  if (problems.length > 0) {
109
119
  renderRefusal(problems).forEach((l) => ctx.err(l));
110
120
  return EXIT.REFUSED;
111
121
  }
112
- const done = executeAbandon(root, ctx, items, { effort: target }, `abandon(${target})`);
122
+ const done = executeAbandon(root, ctx, held.items, { effort: target }, `abandon(${target})`);
113
123
  if (!done.ok) {
114
124
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
115
125
  return EXIT.REFUSED;
116
126
  }
117
- ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked · journal remembers the attempt`));
118
- abandonLines(items, kept).forEach((l) => ctx.out(l));
127
+ ctx.out(kv('abandoned', `effort ${target} — ${held.items.length} artifacts walked · journal remembers the attempt`));
128
+ abandonLines(held.items, held.kept).forEach((l) => ctx.out(l));
119
129
  return EXIT.OK;
120
130
  }
121
131
  renderRefusal([v('target', 'unknown-target', target, 'a plan id or an effort slug')]).forEach((l) => ctx.err(l));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
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.19.0",
4
+ "version": "0.19.2",
5
5
  "author": {
6
6
  "name": "Kostiantyn Popovych"
7
7
  },
@@ -12,7 +12,7 @@ Any other words in `$ARGUMENTS` are the human's ask: run `$WITNESS ask "<those w
12
12
  Resolve the CLI once:
13
13
 
14
14
  ```bash
15
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.0}"
15
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
16
16
  ```
17
17
 
18
18
  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.19.0} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2} 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.19.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.19.0}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
16
16
  Resolve the CLI once per session:
17
17
 
18
18
  ```bash
19
- WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.0}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.19.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.19.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.19.0}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.2}"
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.