@popovych.co/witness 0.19.0 → 0.19.1
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 +160 -73
- package/dist/departure.js +89 -46
- package/dist/reviewed.js +3 -1
- package/dist/verbs/abandon.js +14 -5
- 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/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 { 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,83 @@ 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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
// D168 (review). Whether an earlier abandon already took this effort's newest write of a spec
|
|
76
|
+
// back — `abandon <plan>` does, before the effort's own abandon: a status entry the abandon
|
|
77
|
+
// journaled on the spec after that write, which is not a return, made where the spec stood at
|
|
78
|
+
// the write's content. Then the effort's abandon has nothing of its own left there to take back.
|
|
79
|
+
export function takenBackAlready(root, effort, id, rel, sha) {
|
|
80
|
+
const write = journalActs(root, effort).filter(({ entry }) => isWrite(entry, id, sha)).at(-1);
|
|
81
|
+
if (write === undefined)
|
|
82
|
+
return false;
|
|
83
|
+
return journalActs(root, id).some(({ entry, commit }) => {
|
|
84
|
+
const s = entry;
|
|
85
|
+
const takes = s.t === 'status' && s.cause === 'abandon' && !(s.from === 'superseded' && s.to !== 'removed');
|
|
86
|
+
return takes && commit !== write.commit && isAncestor(root, write.commit, commit) && shaAt(root, `${commit}^`, rel) === sha;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
39
89
|
export const keptLine = (k) => `${String(k.doc.meta.id)} — not unwound · still standing on it: ${k.standing.join(' ')}`;
|
|
40
90
|
// D167. The abandon says everything it did to canon, on both paths, as `kept:` already did: what
|
|
41
91
|
// it reverted, what came back with it, and what the walk took back on the way.
|
|
@@ -43,28 +93,32 @@ export function abandonLines(items, kept) {
|
|
|
43
93
|
return [
|
|
44
94
|
...items.flatMap((item) => [
|
|
45
95
|
...(item.revert ? [kv('reverted', `${String(item.revert.doc.meta.id)} (${item.revert.action}) — journal untouched`)] : []),
|
|
46
|
-
...(item.revert?.returns ?? []).
|
|
47
|
-
|
|
48
|
-
...r.unwound.map((u) => kv('unwound', `${u.artifact} — stays out of canon · an abandoned effort created it`)),
|
|
49
|
-
]),
|
|
96
|
+
...(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`)),
|
|
97
|
+
...(item.revert?.unwound ?? []).map((u) => kv('unwound', `${u.artifact} — stays out of canon · an abandoned effort created it`)),
|
|
50
98
|
...(item.kept ? [kv('kept', keptLine(item.kept))] : []),
|
|
99
|
+
...(item.note ? [kv('note', item.note)] : []),
|
|
51
100
|
]),
|
|
52
101
|
...kept.map((k) => kv('kept', keptLine(k))),
|
|
53
102
|
];
|
|
54
103
|
}
|
|
55
|
-
// D166 (review). The writes the way back takes back besides this abandon's own
|
|
56
|
-
//
|
|
57
|
-
// departed — and
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
104
|
+
// D166 (review). The efforts whose writes the way back takes back besides this abandon's own
|
|
105
|
+
// targets: every effort already abandoned — whose abandon skipped a spec a later re-slice had
|
|
106
|
+
// departed — and the effort being abandoned now (`effort`, absent on the plan path).
|
|
107
|
+
const goneEfforts = (root, effort) => effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
|
|
108
|
+
// Content whose act is a write of one of `efforts` never comes back into canon on its own —
|
|
109
|
+
// unless a plan outside the abandon set stands on it (the keep rule, which never takes shipped
|
|
110
|
+
// content back), or that effort's own abandon left it standing.
|
|
111
|
+
function takenBackBy(root, canon, abandonSet, efforts) {
|
|
112
|
+
return (id, sha, rel, at) => {
|
|
113
|
+
if (plansOn(canon, id, { exclude: abandonSet, pin: sha }).length > 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
const act = writeAct(root, id, sha, at);
|
|
116
|
+
return act !== undefined && efforts.includes(act.effort) && !stoodAtAbandon(root, act.effort, rel, sha) ? act : undefined;
|
|
117
|
+
};
|
|
66
118
|
}
|
|
67
|
-
|
|
119
|
+
// `act`: the write of `pin` this abandon takes back — the effort's own on the effort path, the
|
|
120
|
+
// write the plan is paired with on the plan path. `effort`: the effort being abandoned, if any.
|
|
121
|
+
export function specRevertFor(root, canon, spec, pin, abandonSet, act, effort) {
|
|
68
122
|
const current = canonicalSha(spec.meta, spec.body);
|
|
69
123
|
if (current !== pin) {
|
|
70
124
|
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 +134,43 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
|
|
|
80
134
|
shipped: standing.some((p) => String(p.meta.status) === 'done'),
|
|
81
135
|
});
|
|
82
136
|
}
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
const id = String(spec.meta.id);
|
|
138
|
+
// D168. The write being taken back is one act of its effort, and so is every earlier write of
|
|
139
|
+
// this spec it made: a revise round re-writes, and taking back only the last write left the
|
|
140
|
+
// effort's own first draft in canon. The walk goes back past those and past every abandoned
|
|
141
|
+
// effort's write, and stops at content a plan stands on.
|
|
142
|
+
const gone = goneEfforts(root, effort);
|
|
143
|
+
const walking = [...new Set([...gone, act.effort])];
|
|
144
|
+
const back = takeBack(root, spec.rel, id, act, takenBackBy(root, canon, abandonSet, walking));
|
|
145
|
+
if (back.end === 'lost') {
|
|
146
|
+
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'))]);
|
|
147
|
+
}
|
|
148
|
+
// D168 (review). The walk stopping at an earlier round of the effort being abandoned, because
|
|
149
|
+
// another plan still building stands on it, is a keep of that effort's content: the same keep
|
|
150
|
+
// the first round alone would have been, and refused for the same reason — this abandon is the
|
|
151
|
+
// last act that could take it back. Once a plan shipped it, it stands for good.
|
|
152
|
+
if (effort !== undefined && back.end === 'content') {
|
|
153
|
+
const sha = canonicalSha(back.doc.meta, back.doc.body);
|
|
154
|
+
const on = plansOn(canon, id, { exclude: abandonSet, pin: sha });
|
|
155
|
+
const owner = on.length > 0 ? writeAct(root, id, sha, back.src) : undefined;
|
|
156
|
+
if (owner !== undefined && walking.includes(owner.effort) && !on.some((p) => String(p.meta.status) === 'done')) {
|
|
157
|
+
return ok({ doc: spec, action: 'keep', standing: on.map((p) => String(p.meta.id)), shipped: false });
|
|
158
|
+
}
|
|
86
159
|
}
|
|
87
|
-
// D166. The way back:
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
|
|
160
|
+
// D166. The way back: a write being taken back may have departed a predecessor (a re-slice),
|
|
161
|
+
// which then returns with it. A re-slice can cut one spec into several slices, and taking
|
|
162
|
+
// back one while another still stands would leave the predecessor half-described, so that
|
|
163
|
+
// write is kept until its siblings go too — together, through the effort that made them.
|
|
164
|
+
// D168: every write the walk takes back, not only the last — the create that re-sliced, or
|
|
165
|
+
// an amendment that folded another spec in, may lie under a revision.
|
|
166
|
+
const ways = back.passed.map((w) => prepareReturns(root, canon.paths, w.commit, id, takenBackBy(root, canon, abandonSet, gone)));
|
|
167
|
+
const returns = ways.flatMap((w) => w.returns);
|
|
168
|
+
const unwound = ways.flatMap((w) => w.unwound);
|
|
92
169
|
// D166 (review). A sibling slice stands when this abandon leaves it in canon: it is outside the
|
|
93
170
|
// abandon set, or inside it but kept because a plan outside the set stands on it. It stands for
|
|
94
171
|
// good when a plan shipped it — and then so does this keep, since nothing can take the
|
|
95
172
|
// sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
|
|
96
|
-
const cut = new Set(returns.
|
|
173
|
+
const cut = new Set([...returns.map((r) => r.entry.artifact), ...unwound.map((u) => u.artifact)]);
|
|
97
174
|
const slices = canon.docs
|
|
98
175
|
.filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== String(spec.meta.id))
|
|
99
176
|
.flatMap((d) => {
|
|
@@ -105,8 +182,7 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
|
|
|
105
182
|
if (slices.length > 0) {
|
|
106
183
|
return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
|
|
107
184
|
}
|
|
108
|
-
|
|
109
|
-
if (!prior.ok) {
|
|
185
|
+
if (back.end === 'create') {
|
|
110
186
|
const dependents = canon.docs
|
|
111
187
|
.filter((d) => !abandonSet.has(String(d.meta.id)))
|
|
112
188
|
.filter((d) => (d.meta.depends ?? []).includes(String(spec.meta.id)))
|
|
@@ -117,20 +193,19 @@ export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
|
|
|
117
193
|
// D166. The unwind departs the spec through the same door the re-slice uses, so
|
|
118
194
|
// everything that leaves canon with a spec — its design included — leaves here too.
|
|
119
195
|
return ok({
|
|
120
|
-
doc: spec, action: 'delete', returns,
|
|
196
|
+
doc: spec, action: 'delete', returns, unwound,
|
|
121
197
|
departure: prepareDeparture(root, canon.paths, spec, {
|
|
122
|
-
v: 1, t: 'status', artifact:
|
|
198
|
+
v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: 'removed', cause: 'abandon',
|
|
123
199
|
}),
|
|
124
200
|
});
|
|
125
201
|
}
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
const priorDesign = gitBlob(root, `${commit}^`, designR);
|
|
202
|
+
const toStatus = back.end === 'content' ? String(back.doc.meta.status) : 'draft';
|
|
203
|
+
// D166. The look goes back with the content it was drawn for: the design as it stood where the
|
|
204
|
+
// walk stopped, byte for byte — or none, if there was none then.
|
|
205
|
+
const designR = designRel(canon.paths, id);
|
|
206
|
+
const priorDesign = gitBlob(root, back.src, designR);
|
|
132
207
|
const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
|
|
133
|
-
return ok({ doc: spec, action: 'restore', content:
|
|
208
|
+
return ok({ doc: spec, action: 'restore', content: back.blob.toString('utf8'), toStatus, design, returns, unwound });
|
|
134
209
|
}
|
|
135
210
|
// D167. A return lands on the path its spec left from, and `rename` moves an id but leaves its
|
|
136
211
|
// file, so another doc may hold that path — or another spec this abandon brings back may have
|
|
@@ -180,20 +255,21 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
|
180
255
|
for (const f of touched)
|
|
181
256
|
files.add(f);
|
|
182
257
|
journalMulti.push({ stream: id, line: entryLine(entry) });
|
|
183
|
-
// D166. A spec this write had departed comes back in the same state commit
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
258
|
+
// D166. A spec this write had departed comes back in the same state commit — and, D168,
|
|
259
|
+
// a spec the walk took back past its create leaves canon's record in it too.
|
|
260
|
+
const asLine = (e) => ({ stream: e.artifact, entry: e });
|
|
261
|
+
const lines = [
|
|
262
|
+
...item.revert.returns.flatMap((r) => [asLine(r.entry), ...r.moves]),
|
|
263
|
+
...item.revert.unwound.map(asLine),
|
|
264
|
+
];
|
|
265
|
+
for (const r of item.revert.returns)
|
|
189
266
|
for (const f of r.files)
|
|
190
267
|
files.add(f);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
returnEntries.push(...lines);
|
|
268
|
+
for (const l of lines) {
|
|
269
|
+
files.add(journalRel(l.stream));
|
|
270
|
+
journalMulti.push({ stream: l.stream, line: entryLine(l.entry) });
|
|
196
271
|
}
|
|
272
|
+
returnEntries.push(...lines);
|
|
197
273
|
}
|
|
198
274
|
}
|
|
199
275
|
let effortLine;
|
|
@@ -270,13 +346,18 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
|
|
|
270
346
|
};
|
|
271
347
|
if (pairing === 'stamp-only')
|
|
272
348
|
return ok(item);
|
|
273
|
-
|
|
349
|
+
// D168. Paired by the act: an effort that wrote this plan also made the write of the parent the
|
|
350
|
+
// plan derives from. Deriving the owner through effortOf tie-broke alphabetically across every
|
|
351
|
+
// effort that wrote the plan, and pairing on "wrote the parent at all" reverted content another
|
|
352
|
+
// effort wrote whenever the plan was derived from it.
|
|
274
353
|
const parentId = String(plan.meta.parent);
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
354
|
+
const parent = findById(canon, parentId);
|
|
355
|
+
if (parent) {
|
|
356
|
+
const pin = String(plan.meta['derives-from']);
|
|
357
|
+
const writers = effortStreams(root).filter((e) => readStream(root, e).some((x) => isWrite(x, planId)));
|
|
358
|
+
const act = writeAct(root, parentId, pin);
|
|
359
|
+
if (act !== undefined && writers.includes(act.effort)) {
|
|
360
|
+
const revertR = specRevertFor(root, canon, parent, pin, abandonSet, act);
|
|
280
361
|
if (!revertR.ok)
|
|
281
362
|
return revertR;
|
|
282
363
|
const outcome = revertR.value;
|
|
@@ -285,6 +366,12 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
|
|
|
285
366
|
else
|
|
286
367
|
item.revert = outcome;
|
|
287
368
|
}
|
|
369
|
+
else if (act === undefined && writers.some((e) => readStream(root, e).some((x) => isWrite(x, parentId)))) {
|
|
370
|
+
// The plan's effort wrote this spec, but no journaled write made the content the plan
|
|
371
|
+
// derives from (a hand edit, an adopt): there is no act to pair with, and saying so is
|
|
372
|
+
// what keeps a spec left standing from looking unwound.
|
|
373
|
+
item.note = `${parentId} — not unwound · no journaled write made the content ${planId} derives from`;
|
|
374
|
+
}
|
|
288
375
|
}
|
|
289
376
|
return ok(item);
|
|
290
377
|
}
|
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,64 @@ 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],
|
|
114
|
+
return returnsThrough(root, paths, commit, [successor], takenBack, new Set([commit]));
|
|
66
115
|
}
|
|
67
|
-
function returnsThrough(root, paths, commit, chain,
|
|
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
|
-
|
|
122
|
+
const ways = deleted.map((rel) => {
|
|
123
|
+
const none = { returns: [], unwound: [] };
|
|
74
124
|
if (!rel.startsWith(`${paths.specs}/`) || !rel.endsWith('.md'))
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
return none;
|
|
126
|
+
const src = `${commit}^`;
|
|
127
|
+
const blob = gitBlob(root, src, rel);
|
|
78
128
|
if (blob === undefined)
|
|
79
|
-
return
|
|
80
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}],
|
|
168
|
+
unwound: [],
|
|
169
|
+
};
|
|
128
170
|
});
|
|
171
|
+
return { returns: ways.flatMap((w) => w.returns), unwound: ways.flatMap((w) => w.unwound) };
|
|
129
172
|
}
|
|
130
173
|
// Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
|
|
131
174
|
// 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
|
-
|
|
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
|
}
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -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, 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) {
|
|
@@ -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
|
-
|
|
84
|
-
|
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popovych.co/witness",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.1",
|
|
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.1",
|
|
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.1}"
|
|
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.1} 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.1}"
|
|
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.1}"
|
|
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.1}"
|
|
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.1}"
|
|
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.1}"
|
|
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.1}"
|
|
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.
|