@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 +1 -1
- package/dist/abandon.js +63 -29
- package/dist/cli.js +1 -1
- package/dist/departure.js +9 -3
- package/dist/verbs/abandon.js +75 -27
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/commands/witness.md +1 -1
- package/plugin/hooks/session-dashboard.sh +1 -1
- package/plugin/skills/witness-brainstorm/SKILL.md +1 -1
- package/plugin/skills/witness-decompose/SKILL.md +1 -1
- package/plugin/skills/witness-design/SKILL.md +1 -1
- package/plugin/skills/witness-implement/SKILL.md +1 -1
- package/plugin/skills/witness-plan/SKILL.md +1 -1
- package/plugin/skills/witness-ship/SKILL.md +1 -1
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
|
|
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
|
|
99
|
-
|
|
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
|
|
112
|
-
//
|
|
113
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
167
|
-
// takes the split back together
|
|
168
|
-
//
|
|
169
|
-
// with
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
323
|
-
|
|
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:
|
|
359
|
+
v: 1, t: 'human-decision', gate: 'effort', artifact: effort,
|
|
326
360
|
round: 0, decision: 'abandon-effort',
|
|
327
361
|
};
|
|
328
|
-
|
|
329
|
-
files.add(journalRel(
|
|
330
|
-
journalMulti.push(
|
|
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
|
-
|
|
364
|
-
appendEntry(root,
|
|
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) };
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -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
|
|
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],
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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,
|
|
72
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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,
|
|
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,
|
|
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',
|
|
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
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|