@popovych.co/witness 0.19.3 → 0.19.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -200,7 +200,7 @@ the wrong file refuses. There are no `WITNESS_*` env vars for configuration.
200
200
  | `ship <plan-id> [--manual]` | lanes → ship gate → PR → CI watch → under autonomy: merge on green · `--manual` keeps that run's stop and merge for a human |
201
201
  | `test-evidence` / `verify-red` | journal red/green criteria evidence from a worktree |
202
202
  | `adopt <path>` | absolve a finished hand-edit into the journal |
203
- | `abandon <plan-id \| effort-slug>` | wind a plan/effort down; reverts only specs that effort itself wrote |
203
+ | `abandon <plan-id \| effort-slug…>` | wind a plan/effort down; reverts only specs that effort itself wrote — several efforts go as one act, so a split or an amendment they share goes back together |
204
204
  | `dispatch-report <plan-id> --steps-assigned <n> --steps-completed <n>` | journal a session slice's telemetry |
205
205
  | `rename <old> <new>` | id rename across canon, refs, journal |
206
206
  | `clean` | reap stale worktrees |
package/dist/abandon.js CHANGED
@@ -58,12 +58,32 @@ const shaAt = (root, rev, rel) => {
58
58
  // D168 (review). Whether this effort's abandon left its write standing — kept, because a plan
59
59
  // stood on it or on a sibling slice, or the walk stopped at it. That content is canon of record:
60
60
  // no later walk goes past it, or an amendment on top of it took the keep with it.
61
- function stoodAtAbandon(root, effort, rel, sha) {
61
+ export function stoodAtAbandon(root, effort, rel, sha) {
62
62
  const at = journalActs(root, effort, 'HEAD').find(({ entry }) => entry.t === 'human-decision' && entry.decision === 'abandon-effort');
63
63
  return at !== undefined && shaAt(root, at.commit, rel) === sha;
64
64
  }
65
65
  // This effort's newest write of an artifact (of this content, when `sha` is given).
66
66
  export const effortWrite = (root, effort, id, sha) => journalActs(root, effort, 'HEAD').filter(({ entry }) => isWrite(entry, id, sha)).at(-1);
67
+ // D190 (review). The effort whose standing write a take-back stopped at, over some write of
68
+ // `effort` that the take-back neither went past nor found already taken back — the write would
69
+ // stay in canon with its effort abandoned. A write under content that stands for good (shipped by
70
+ // a plan outside the set, or left standing by its own effort's abandon) has nothing left to take.
71
+ export function leftUnder(root, canon, spec, effort, walk, abandonSet) {
72
+ const id = String(spec.meta.id);
73
+ const stop = walk.stop === undefined ? undefined : writeAct(root, id, walk.stop.sha, walk.stop.src);
74
+ const forGood = walk.stop !== undefined && (plansOn(canon, id, { exclude: abandonSet, pin: walk.stop.sha }).some((p) => String(p.meta.status) === 'done') ||
75
+ (stop !== undefined && effortAbandoned(readStream(root, stop.effort)) && stoodAtAbandon(root, stop.effort, spec.rel, walk.stop.sha)));
76
+ for (const { entry, commit } of journalActs(root, effort, 'HEAD')) {
77
+ if (!isWrite(entry, id) || walk.passed.includes(commit))
78
+ continue;
79
+ if (takenBackAlready(root, effort, id, spec.rel, String(entry.sha)))
80
+ continue;
81
+ if (forGood)
82
+ continue;
83
+ return stop?.effort ?? 'another write';
84
+ }
85
+ return undefined;
86
+ }
67
87
  // D168 (review). Whether an earlier abandon already took this effort's newest write of a spec
68
88
  // back — `abandon <plan>` does, before the effort's own abandon: a status entry the abandon
69
89
  // journaled on the spec after that write, which is not a return, made where the spec stood at
@@ -95,8 +115,9 @@ export function abandonLines(items, kept) {
95
115
  }
96
116
  // D166 (review). The efforts whose writes the way back takes back besides this abandon's own
97
117
  // targets: every effort already abandoned — whose abandon skipped a spec a later re-slice had
98
- // departed — and the effort being abandoned now (`effort`, absent on the plan path).
99
- const goneEfforts = (root, effort) => effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
118
+ // departed — and the efforts being abandoned now (`efforts`, absent on the plan path; D190:
119
+ // several, when they are abandoned as one act).
120
+ const goneEfforts = (root, efforts) => effortStreams(root).filter((e) => (efforts ?? []).includes(e) || effortAbandoned(readStream(root, e)));
100
121
  // Content whose act is a write of one of `efforts` never comes back into canon on its own —
101
122
  // unless a plan outside the abandon set stands on it (the keep rule, which never takes shipped
102
123
  // content back), or that effort's own abandon left it standing.
@@ -108,9 +129,10 @@ function takenBackBy(root, canon, abandonSet, efforts) {
108
129
  return act !== undefined && efforts.includes(act.effort) && !stoodAtAbandon(root, act.effort, rel, sha) ? act : undefined;
109
130
  };
110
131
  }
111
- // `act`: the write of `pin` this abandon takes back — the effort's own on the effort path, the
112
- // write the plan is paired with on the plan path. `effort`: the effort being abandoned, if any.
113
- export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
132
+ // `act`: the write of `pin` this abandon takes back — on the effort path the write that is the
133
+ // spec's content, by one of the efforts being abandoned (`efforts`); on the plan path the write
134
+ // the plan is paired with.
135
+ export function specRevertFor(root, canon, spec, pin, abandonSet, act, efforts) {
114
136
  const current = canonicalSha(spec.meta, spec.body);
115
137
  if (current !== pin) {
116
138
  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')]);
@@ -131,7 +153,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
131
153
  // this spec it made: a revise round re-writes, and taking back only the last write left the
132
154
  // effort's own first draft in canon. The walk goes back past those and past every abandoned
133
155
  // effort's write, and stops at content a plan stands on.
134
- const gone = goneEfforts(root, effort);
156
+ const gone = goneEfforts(root, efforts);
135
157
  const walking = [...new Set([...gone, act.effort])];
136
158
  const back = takeBack(root, spec.rel, id, act, takenBackBy(root, canon, abandonSet, walking));
137
159
  if (back.end === 'lost') {
@@ -141,7 +163,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
141
163
  // another plan still building stands on it, is a keep of that effort's content: the same keep
142
164
  // the first round alone would have been, and refused for the same reason — this abandon is the
143
165
  // last act that could take it back. Once a plan shipped it, it stands for good.
144
- if (effort !== undefined && back.end === 'content') {
166
+ if (efforts !== undefined && back.end === 'content') {
145
167
  const sha = canonicalSha(back.doc.meta, back.doc.body);
146
168
  const on = plansOn(canon, id, { exclude: abandonSet, pin: sha });
147
169
  const owner = on.length > 0 ? writeAct(root, id, sha, back.src) : undefined;
@@ -163,12 +185,12 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
163
185
  // good when a plan shipped it — and then so does this keep, since nothing can take the
164
186
  // sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
165
187
  // D188: on the plan path, also a slice whose own write departed nothing — another slice's
166
- // write took the predecessor out. Its effort is still live, and its own abandon is the act that
167
- // takes the split back together; deleting the slice now left the split half described. The
168
- // effort path is that last act, and there a slice another effort cut cannot go back together
169
- // with this one: holding it would leave two efforts' abandons each refusing for the other's.
188
+ // write took the predecessor out. Its effort is still live, and that effort's abandon is the act
189
+ // that takes the split back together. On the effort path a hold would tie this abandon to the
190
+ // other slices' efforts, and row 190's review found that no single act frees every such hold
191
+ // (an effort with shipped work cannot be abandoned; an adopted slice has no effort at all).
170
192
  const own = spec.meta.supersedes === undefined ? undefined : String(spec.meta.supersedes);
171
- const slice = effort === undefined && back.end === 'create' && own !== undefined && canonExit(readStream(root, own))?.to === 'superseded' ? [own] : [];
193
+ const slice = efforts === undefined && back.end === 'create' && own !== undefined && canonExit(readStream(root, own))?.to === 'superseded' ? [own] : [];
172
194
  const cut = new Set([...returns.map((r) => r.entry.artifact), ...unwound.map((u) => u.artifact), ...slice]);
173
195
  const slices = canon.docs
174
196
  .filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== id)
@@ -176,7 +198,8 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
176
198
  const id = String(d.meta.id);
177
199
  const leaving = abandonSet.has(id);
178
200
  const on = plansOn(canon, id, leaving ? { exclude: abandonSet, pin: canonicalSha(d.meta, d.body) } : { exclude: abandonSet });
179
- return leaving && on.length === 0 ? [] : [{ id, shipped: on.some((p) => String(p.meta.status) === 'done') }];
201
+ // D190: a slice stamped live is shipped behavior whether or not a plan of it is on file.
202
+ return leaving && on.length === 0 ? [] : [{ id, shipped: String(d.meta.status) === 'live' || on.some((p) => String(p.meta.status) === 'done') }];
180
203
  });
181
204
  if (slices.length > 0) {
182
205
  return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
@@ -192,7 +215,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
192
215
  // D166. The unwind departs the spec through the same door the re-slice uses, so
193
216
  // everything that leaves canon with a spec — its design included — leaves here too.
194
217
  return ok({
195
- doc: spec, action: 'delete', returns, unwound,
218
+ doc: spec, action: 'delete', returns, unwound, walk: { passed: back.passed.map((w) => w.commit) },
196
219
  departure: prepareDeparture(root, canon.paths, spec, {
197
220
  v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: 'removed', cause: 'abandon',
198
221
  }),
@@ -204,7 +227,9 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
204
227
  const designR = designRel(canon.paths, id);
205
228
  const priorDesign = gitBlob(root, back.src, designR);
206
229
  const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
207
- return ok({ doc: spec, action: 'restore', content: back.blob.toString('utf8'), toStatus, design, returns, unwound });
230
+ const stopSha = back.end === 'content' ? canonicalSha(back.doc.meta, back.doc.body) : undefined;
231
+ const walk = { passed: back.passed.map((w) => w.commit), ...(stopSha === undefined ? {} : { stop: { src: back.src, sha: stopSha } }) };
232
+ return ok({ doc: spec, action: 'restore', content: back.blob.toString('utf8'), toStatus, design, returns, unwound, walk });
208
233
  }
209
234
  // D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
210
235
  // file, so another doc may hold that path — or another spec this abandon brings back may have
@@ -232,9 +257,18 @@ export function returnCollisions(root, canon, items) {
232
257
  // back only beside what it depends on: a dependency in canon that this abandon does not take
233
258
  // out, or one it brings back. `write` refuses a dangling `depends`, and an abandon landing one
234
259
  // left `check` red after it exited 0. Bringing the dependency back first is a human's call.
235
- export function landingEdges(root, canon, items) {
260
+ export function landingEdges(root, canon, items, abandonSet = new Set()) {
236
261
  const leaving = new Set(items.flatMap((i) => (i.revert?.action === 'delete' ? [String(i.revert.doc.meta.id)] : [])));
237
262
  const coming = new Set(items.flatMap((i) => (i.revert?.returns ?? []).map((r) => r.entry.artifact)));
263
+ const restored = new Set(items.flatMap((i) => (i.revert?.action === 'restore' ? [String(i.revert.doc.meta.id)] : [])));
264
+ // D190 (review). A doc of the abandon set that this act leaves as it is — kept, or a write an
265
+ // earlier abandon already took back — stays in canon, so what it depends on may not leave:
266
+ // `waiting-dependents` reads only docs outside the set, and the act deleted the dependency.
267
+ const stays = canon.docs
268
+ .filter((d) => d.meta.type === 'spec' && abandonSet.has(String(d.meta.id)) && !leaving.has(String(d.meta.id)) && !restored.has(String(d.meta.id)))
269
+ .flatMap((d) => (d.meta.depends ?? []).map(String)
270
+ .filter((dep) => leaving.has(dep) && !coming.has(dep))
271
+ .map((dep) => v('spec', 'waiting-dependents', `${String(d.meta.id)} depends on ${dep}`, `${dep} kept while ${String(d.meta.id)} stays in canon — no doc this abandon leaves standing may depend on a spec it deletes`)));
238
272
  const lands = items.flatMap((i) => {
239
273
  if (!i.revert)
240
274
  return [];
@@ -251,7 +285,7 @@ export function landingEdges(root, canon, items) {
251
285
  const why = leaving.has(dep) ? 'it leaves with this abandon'
252
286
  : exit?.to === 'superseded' ? `it left superseded by ${exit.by ?? 'a successor'}` : 'it left canon';
253
287
  return humanCall(v('spec', 'dangling-depends', `${id} depends on ${dep}`, `${dep} in canon when ${id} comes back — ${why} · a human's call`));
254
- }));
288
+ })).concat(stays);
255
289
  }
256
290
  // D188 (review). A spec kept for good stays in canon with what it depends on: a dependency this
257
291
  // abandon would delete is kept beside it, and for good with it. `waiting-dependents` reads only
@@ -276,7 +310,7 @@ export function keepDependencies(items, kept) {
276
310
  }
277
311
  return { items: walked, kept: held };
278
312
  }
279
- export function executeAbandon(root, ctx, items, effortEntry, subject) {
313
+ export function executeAbandon(root, ctx, items, efforts, subject) {
280
314
  const files = new Set();
281
315
  const journalMulti = [];
282
316
  const stamps = [];
@@ -319,16 +353,17 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
319
353
  returnEntries.push(...lines);
320
354
  }
321
355
  }
322
- let effortLine;
323
- if (effortEntry) {
356
+ // D190. Each effort the act abandons ends in the same transaction: one terminal entry each.
357
+ const effortLines = efforts.map((effort) => {
324
358
  const entry = {
325
- v: 1, t: 'human-decision', gate: 'effort', artifact: effortEntry.effort,
359
+ v: 1, t: 'human-decision', gate: 'effort', artifact: effort,
326
360
  round: 0, decision: 'abandon-effort',
327
361
  };
328
- effortLine = { stream: effortEntry.effort, line: entryLine(entry) };
329
- files.add(journalRel(effortEntry.effort));
330
- journalMulti.push(effortLine);
331
- }
362
+ const line = { stream: effort, line: entryLine(entry) };
363
+ files.add(journalRel(effort));
364
+ journalMulti.push(line);
365
+ return line;
366
+ });
332
367
  const lockR = acquireLock(root);
333
368
  if (!lockR.ok)
334
369
  return lockR;
@@ -360,9 +395,8 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
360
395
  appendEntry(root, se.stream, se.entry);
361
396
  for (const re of returnEntries)
362
397
  appendEntry(root, re.stream, re.entry);
363
- if (effortLine) {
364
- appendEntry(root, effortLine.stream, JSON.parse(effortLine.line));
365
- }
398
+ for (const l of effortLines)
399
+ appendEntry(root, l.stream, JSON.parse(l.line));
366
400
  crashPoint(ctx.env, 'abandon-commit');
367
401
  const r = stateCommit(root, [...files], subject);
368
402
  // D141. `abandon` runs from wherever the human typed it, which may be the very
package/dist/cli.js CHANGED
@@ -51,7 +51,7 @@ const VERBS = {
51
51
  // answered centrally so no verb can crash on --help (several parse argv
52
52
  // strictly); each line mirrors the verb's own usage refusal where one exists
53
53
  const VERB_USAGE = {
54
- abandon: 'witness abandon <plan-id | effort-slug>',
54
+ abandon: 'witness abandon <plan-id | effort-slug…>',
55
55
  adopt: 'witness adopt <specs/... | plans/...>.md',
56
56
  ask: 'witness ask <text> | --withdraw <id> [--note <why>]',
57
57
  calibrate: 'witness calibrate <exact-model-id> [--suite all|reviewers|skills] [--only <name>] [--samples <n>] [--publish]',
package/dist/departure.js CHANGED
@@ -137,12 +137,18 @@ function returnsThrough(root, paths, commit, chain, takenBack, seen) {
137
137
  const reached = walkBack(root, rel, id, { src, blob, doc: parsed.value }, takenBack, seen);
138
138
  // A write the walk cannot go past leaves the return where it stood before that write.
139
139
  const walked = reached.end === 'lost' ? { ...reached, ...reached.at, end: 'content' } : reached;
140
+ // D190 (review). Every amendment the walk took back on the way brings back what it folded
141
+ // in (an amendment with `supersedes`): row 168 left that out, and a shipped spec folded into
142
+ // a predecessor that a later re-slice departed never came back.
143
+ const amended = walked.passed.filter((w) => walked.end !== 'create' || w !== walked.act);
144
+ const folds = amended.map((w) => returnsThrough(root, paths, w.commit, [id], takenBack, seen));
145
+ const folded = { returns: folds.flatMap((f) => f.returns), unwound: folds.flatMap((f) => f.unwound) };
140
146
  if (walked.end === 'create') {
141
147
  // That write created it: it stays out — journaled so whether or not anything lies under
142
148
  // the create — and what it had departed comes back instead.
143
149
  const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
144
150
  const under = returnsThrough(root, paths, walked.act.commit, [...chain, id], takenBack, seen);
145
- return { returns: under.returns, unwound: [gone, ...under.unwound] };
151
+ return { returns: [...under.returns, ...folded.returns], unwound: [gone, ...under.unwound, ...folded.unwound] };
146
152
  }
147
153
  const status = walked.passed.length > 0 ? String(walked.doc.meta.status) : exit.from;
148
154
  const design = designRel(paths, id);
@@ -165,8 +171,8 @@ function returnsThrough(root, paths, commit, chain, takenBack, seen) {
165
171
  rel,
166
172
  by: successor,
167
173
  depends: (walked.doc.meta.depends ?? []).map(String),
168
- }],
169
- unwound: [],
174
+ }, ...folded.returns],
175
+ unwound: folded.unwound,
170
176
  };
171
177
  });
172
178
  return { returns: ways.flatMap((w) => w.returns), unwound: ways.flatMap((w) => w.unwound) };
@@ -1,17 +1,18 @@
1
1
  import { EXIT } from '../cli.js';
2
2
  import { guardTxn } from '../txn.js';
3
3
  import { primaryRoot } from '../gitio.js';
4
- import { findById, loadCanon } from '../scan.js';
4
+ import { findById, loadCanon, plansOn } 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
8
  import { humanCall, renderRefusal, v } from '../refusal.js';
9
9
  import { kv } from '../toon.js';
10
- import { abandonLines, executeAbandon, keepDependencies, landingEdges, planItems, returnCollisions, specRevertFor, takenBackAlready, writeAct } from '../abandon.js';
10
+ import { abandonLines, executeAbandon, keepDependencies, landingEdges, planItems, returnCollisions, specRevertFor, leftUnder, stoodAtAbandon, takenBackAlready, writeAct } from '../abandon.js';
11
11
  export async function run(ctx, argv) {
12
- const target = argv.find((a) => !a.startsWith('--'));
12
+ const targets = argv.filter((a) => !a.startsWith('--'));
13
+ const target = targets[0];
13
14
  if (!target) {
14
- ctx.err('usage: witness abandon <plan-id | effort-slug>');
15
+ ctx.err('usage: witness abandon <plan-id | effort-slug…>');
15
16
  return EXIT.REFUSED;
16
17
  }
17
18
  const rootR = primaryRoot(ctx.cwd);
@@ -25,7 +26,16 @@ export async function run(ctx, argv) {
25
26
  return blocked;
26
27
  const canon = loadCanon(root);
27
28
  const doc = findById(canon, target);
29
+ const plans = targets.filter((t) => findById(canon, t)?.meta.type === 'plan');
30
+ if (plans.length > 0 && targets.length > 1) {
31
+ renderRefusal([v('target', 'one-plan', targets.join(' '), 'one plan id — several effort slugs may be abandoned as one act')]).forEach((l) => ctx.err(l));
32
+ return EXIT.REFUSED;
33
+ }
28
34
  if (doc && doc.meta.type === 'plan') {
35
+ if (targets.length > 1) {
36
+ renderRefusal([v('target', 'one-plan', targets.join(' '), 'one plan id — several effort slugs may be abandoned as one act')]).forEach((l) => ctx.err(l));
37
+ return EXIT.REFUSED;
38
+ }
29
39
  if (String(doc.meta.status) === 'abandoned') {
30
40
  ctx.out(kv('abandon', `${target} already abandoned`));
31
41
  return EXIT.OK;
@@ -40,7 +50,7 @@ export async function run(ctx, argv) {
40
50
  renderRefusal(taken).forEach((l) => ctx.err(l));
41
51
  return EXIT.REFUSED;
42
52
  }
43
- const done = executeAbandon(root, ctx, [itemR.value], undefined, `abandon(${target})`);
53
+ const done = executeAbandon(root, ctx, [itemR.value], [], `abandon(${target})`);
44
54
  if (!done.ok) {
45
55
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
46
56
  return EXIT.REFUSED;
@@ -49,18 +59,28 @@ export async function run(ctx, argv) {
49
59
  abandonLines([itemR.value], []).forEach((l) => ctx.out(l));
50
60
  return EXIT.OK;
51
61
  }
52
- if (streamExists(root, target) && latestRecap(root, target)) {
53
- const entries = readStream(root, target);
54
- if (effortAbandoned(entries)) {
55
- ctx.out(kv('abandon', `${target} already abandoned`));
56
- return EXIT.OK;
62
+ // D190. Several efforts may be abandoned as one act: `abandon <effort> <effort>…` — one
63
+ // abandon set, one transaction, each spec taken back once. Efforts whose work is tangled — two
64
+ // amendments of one spec, a spec and another effort's plan building on it — go back together
65
+ // instead of one command each in the one order that works.
66
+ const isEffort = (t) => streamExists(root, t) && latestRecap(root, t) !== undefined;
67
+ if (targets.every(isEffort)) {
68
+ const efforts = [];
69
+ for (const t of targets) {
70
+ if (effortAbandoned(readStream(root, t)))
71
+ ctx.out(kv('abandon', `${t} already abandoned`));
72
+ else if (!efforts.includes(t))
73
+ efforts.push(t);
57
74
  }
58
- const writes = effortWrites(root, target);
59
- const abandonSet = new Set(writes.keys());
75
+ if (efforts.length === 0)
76
+ return EXIT.OK;
77
+ const writesOf = new Map(efforts.map((e) => [e, effortWrites(root, e)]));
78
+ const ids = [...new Set([...writesOf.values()].flatMap((w) => [...w.keys()]))];
79
+ const abandonSet = new Set(ids);
60
80
  const items = [];
61
81
  const problems = [];
62
82
  const kept = [];
63
- for (const id of writes.keys()) {
83
+ for (const id of ids) {
64
84
  const artifact = findById(canon, id);
65
85
  if (!artifact)
66
86
  continue; // already gone (re-slice, earlier abandon)
@@ -68,8 +88,8 @@ export async function run(ctx, argv) {
68
88
  continue;
69
89
  if (String(artifact.meta.status) === 'abandoned')
70
90
  continue; // D189: an earlier abandon withdrew it
71
- // 'stamp-only': the spec loop below is this path's sole spec revert, pinned on
72
- // THIS effort's write sha. A paired revert here would re-derive the owner and
91
+ // 'stamp-only': the spec loop below is this path's sole spec revert, from the write
92
+ // that is the spec's content. A paired revert here would re-derive the owner and
73
93
  // could land on a sibling effort's amendment — see Pairing in abandon.ts.
74
94
  const itemR = planItems(root, canon, artifact, abandonSet, 'stamp-only');
75
95
  if (!itemR.ok) {
@@ -78,29 +98,56 @@ export async function run(ctx, argv) {
78
98
  }
79
99
  items.push(itemR.value);
80
100
  }
81
- for (const [id, w] of writes) {
101
+ for (const id of ids) {
82
102
  const artifact = findById(canon, id);
83
103
  if (!artifact || artifact.meta.type !== 'spec')
84
104
  continue;
85
- // D168. The effort's write is what this abandon takes back only while it is the act the
105
+ // D168. An effort's write is what this abandon takes back only while it is the act the
86
106
  // spec's content is — the same bytes written again later are the later effort's. A write
87
107
  // an earlier abandon already took back (`abandon <plan>`) leaves nothing here to take.
108
+ // D190: the act may be any of the efforts being abandoned; the spec is taken back once,
109
+ // from it — and only when every listed effort's write of it lies in that one take-back.
88
110
  const current = canonicalSha(artifact.meta, artifact.body);
89
111
  const act = writeAct(root, id, current);
90
- if (current !== w.sha || act?.effort !== target) {
91
- if (takenBackAlready(root, target, id, artifact.rel, w.sha))
112
+ const writers = efforts.filter((x) => writesOf.get(x).has(id));
113
+ if (act === undefined || !efforts.includes(act.effort)) {
114
+ // D190 (review). Content that stands for good — shipped by a plan, or left standing by
115
+ // its own effort's abandon — is canon of record: a write under it has nothing of its own
116
+ // left to take back, where it refused a stacked amendment no abandon could clear.
117
+ const shippedOn = plansOn(canon, id, { exclude: abandonSet, pin: current }).filter((p) => String(p.meta.status) === 'done');
118
+ const recorded = act !== undefined && effortAbandoned(readStream(root, act.effort)) && stoodAtAbandon(root, act.effort, artifact.rel, current);
119
+ if (shippedOn.length > 0 || recorded) {
120
+ kept.push({ doc: artifact, action: 'keep', shipped: true, standing: shippedOn.length > 0 ? shippedOn.map((p) => String(p.meta.id)) : [act.effort] });
92
121
  continue;
93
- problems.push(current === w.sha && act === undefined
94
- ? humanCall(v('spec', 'pin-unresolvable', id, `${target}'s journaled write of this content — the journal's history is unreadable here · a human's call`))
95
- : v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
122
+ }
123
+ for (const e of writers) {
124
+ const w = writesOf.get(e).get(id);
125
+ if (takenBackAlready(root, e, id, artifact.rel, w.sha))
126
+ continue;
127
+ problems.push(current === w.sha && act === undefined
128
+ ? humanCall(v('spec', 'pin-unresolvable', `${id} — ${e}'s write`, `${e}'s journaled write of this content — the journal's history is unreadable here · a human's call`))
129
+ : v('spec', 'stacked-amendment', `${id} — ${e}'s write`, `abandon newest-first — the effort's write is no longer the newest${act === undefined ? '' : ` · ${act.effort} wrote on top of it`}`));
130
+ }
96
131
  continue;
97
132
  }
98
- const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, act, target);
133
+ const revertR = specRevertFor(root, canon, artifact, current, abandonSet, act, efforts);
99
134
  if (!revertR.ok) {
100
135
  problems.push(...revertR.violations);
101
136
  continue;
102
137
  }
103
138
  const outcome = revertR.value;
139
+ // A take-back that stopped at another effort's standing write leaves every listed write
140
+ // under it in canon: that effort would end abandoned with its write still there.
141
+ if (outcome !== undefined && outcome.action !== 'keep') {
142
+ const buried = writers.flatMap((e) => {
143
+ const over = leftUnder(root, canon, artifact, e, outcome.walk, abandonSet);
144
+ return over === undefined ? [] : [v('spec', 'stacked-amendment', `${id} — ${e}'s write`, `abandon newest-first — the effort's write is no longer the newest · ${over} wrote on top of it`)];
145
+ });
146
+ if (buried.length > 0) {
147
+ problems.push(...buried);
148
+ continue;
149
+ }
150
+ }
104
151
  if (outcome?.action === 'keep') {
105
152
  // D166. This abandon is the effort's last act, so a keep here must be one nothing has
106
153
  // to take back later: a shipped plan makes it so — canon describes built behavior. A
@@ -116,20 +163,21 @@ export async function run(ctx, argv) {
116
163
  items.push({ revert: outcome });
117
164
  }
118
165
  const held = keepDependencies(items, kept);
119
- problems.push(...returnCollisions(root, canon, held.items), ...landingEdges(root, canon, held.items));
166
+ problems.push(...returnCollisions(root, canon, held.items), ...landingEdges(root, canon, held.items, abandonSet));
120
167
  if (problems.length > 0) {
121
168
  renderRefusal(problems).forEach((l) => ctx.err(l));
122
169
  return EXIT.REFUSED;
123
170
  }
124
- const done = executeAbandon(root, ctx, held.items, { effort: target }, `abandon(${target})`);
171
+ const done = executeAbandon(root, ctx, held.items, efforts, `abandon(${efforts.join(' ')})`);
125
172
  if (!done.ok) {
126
173
  renderRefusal(done.violations).forEach((l) => ctx.err(l));
127
174
  return EXIT.REFUSED;
128
175
  }
129
- ctx.out(kv('abandoned', `effort ${target} — ${held.items.length} artifacts walked · journal remembers the attempt`));
176
+ ctx.out(kv('abandoned', `${efforts.length === 1 ? 'effort' : 'efforts'} ${efforts.join(' · ')} — ${held.items.length} artifacts walked · journal remembers the attempt`));
130
177
  abandonLines(held.items, held.kept).forEach((l) => ctx.out(l));
131
178
  return EXIT.OK;
132
179
  }
133
- renderRefusal([v('target', 'unknown-target', target, 'a plan id or an effort slug')]).forEach((l) => ctx.err(l));
180
+ const unknown = targets.length === 1 ? targets : targets.filter((t) => !isEffort(t));
181
+ renderRefusal(unknown.map((t) => v('target', 'unknown-target', t, targets.length === 1 ? 'a plan id or an effort slug' : 'an effort slug — several are abandoned as one act'))).forEach((l) => ctx.err(l));
134
182
  return EXIT.REFUSED;
135
183
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@popovych.co/witness",
3
- "version": "0.19.3",
3
+ "version": "0.19.4",
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.3",
4
+ "version": "0.19.4",
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.3}"
15
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3} 2>/dev/null || true
6
+ ${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4} 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.3}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3}"
13
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3}"
19
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.3}"
18
+ WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.19.4}"
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.