@popovych.co/witness 0.14.6 → 0.14.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/abandon.js +111 -21
- package/dist/deferral.js +12 -0
- package/dist/departure.js +147 -0
- package/dist/gitio.js +11 -0
- package/dist/refusal.js +4 -1
- package/dist/scan.js +17 -0
- package/dist/stamp.js +2 -5
- package/dist/txn.js +26 -8
- package/dist/verbs/abandon.js +21 -4
- package/dist/verbs/adopt.js +5 -0
- package/dist/verbs/check.js +39 -6
- package/dist/verbs/dashboard.js +4 -1
- package/dist/verbs/log.js +5 -2
- package/dist/verbs/next.js +2 -3
- package/dist/verbs/recover.js +14 -16
- package/dist/verbs/write.js +63 -25
- 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 +3 -3
- 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,19 +1,22 @@
|
|
|
1
|
-
import { unlinkSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { prepareDeparture, prepareReturns } from './departure.js';
|
|
4
|
+
import { designRel } from './design.js';
|
|
3
5
|
import { splitDoc } from './fm.js';
|
|
4
6
|
import { canonicalSha } from './sha.js';
|
|
5
|
-
import { tryGit, stateCommit } from './gitio.js';
|
|
7
|
+
import { gitBlob, tryGit, stateCommit } from './gitio.js';
|
|
6
8
|
import { acquireLock } from './lock.js';
|
|
7
9
|
import { crashPoint, withTxn } from './txn.js';
|
|
8
|
-
import { appendEntry, entryLine, journalRel } from './journal.js';
|
|
9
|
-
import { findById } from './scan.js';
|
|
10
|
+
import { appendEntry, effortAbandoned, effortStreams, entryLine, journalRel, readStream } from './journal.js';
|
|
11
|
+
import { findById, plansOn } from './scan.js';
|
|
10
12
|
import { effortOf, effortWrites } from './reviewed.js';
|
|
11
13
|
import { ok, refuse, v } from './refusal.js';
|
|
12
14
|
import { prepareStamp, writeStamp } from './stamp.js';
|
|
13
15
|
import { kv } from './toon.js';
|
|
14
16
|
import { removeWorktree, worktreePath } from './worktree.js';
|
|
15
|
-
export function pinCommit(root, rel, pin) {
|
|
16
|
-
|
|
17
|
+
export function pinCommit(root, rel, pin, from) {
|
|
18
|
+
// `from`: walk history from that revision instead of HEAD — for a file a later commit deleted
|
|
19
|
+
const log = tryGit(root, 'log', '--format=%H', ...(from === undefined ? [] : [from]), '--', rel);
|
|
17
20
|
if (!log.ok)
|
|
18
21
|
return undefined;
|
|
19
22
|
// Status-only touch-ups (approve, stampLive, a gate's writeStamp) commit the same
|
|
@@ -33,15 +36,60 @@ export function pinCommit(root, rel, pin) {
|
|
|
33
36
|
}
|
|
34
37
|
return found;
|
|
35
38
|
}
|
|
36
|
-
export
|
|
39
|
+
export const keptLine = (k) => `${String(k.doc.meta.id)} — not unwound · still standing on it: ${k.standing.join(' ')}`;
|
|
40
|
+
// D166 (review). The writes the way back takes back besides this abandon's own targets: every
|
|
41
|
+
// write of an effort already abandoned — whose abandon skipped a spec a later re-slice had
|
|
42
|
+
// departed — and every write of the effort being abandoned now (`effort`, absent on the plan
|
|
43
|
+
// path). Content one of them wrote never comes back into canon on its own — unless a plan
|
|
44
|
+
// outside the abandon set stands on it: the keep rule, which never takes shipped content back.
|
|
45
|
+
function takenBackBy(root, canon, abandonSet, effort) {
|
|
46
|
+
const gone = effortStreams(root).filter((e) => e === effort || effortAbandoned(readStream(root, e)));
|
|
47
|
+
return (id, sha, rel, at) => plansOn(canon, id, { exclude: abandonSet, pin: sha }).length === 0 &&
|
|
48
|
+
gone.some((e) => readStream(root, e).some((x) => x.t === 'write' && x.artifact === id && x.sha === sha))
|
|
49
|
+
? pinCommit(root, rel, sha, at)
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
export function specRevertFor(root, canon, spec, pin, abandonSet, effort) {
|
|
37
53
|
const current = canonicalSha(spec.meta, spec.body);
|
|
38
54
|
if (current !== pin) {
|
|
39
55
|
return refuse([v('spec', 'stacked-amendment', `${String(spec.meta.id)} is at ${current.slice(0, 7)}, pin is ${pin.slice(0, 7)}`, 'abandon newest-first: a later amendment touched this spec')]);
|
|
40
56
|
}
|
|
57
|
+
// D166. After "newest-first" (row 84 stands) and ahead of every precondition of the unwind
|
|
58
|
+
// itself — a resolvable pin, no waiting dependents — because a kept spec unwinds nothing and
|
|
59
|
+
// dangles nothing. Same pin in both branches: the siblings a delete would orphan were all
|
|
60
|
+
// derived from the content it removes, and a restore moves toward older pins.
|
|
61
|
+
const standing = plansOn(canon, String(spec.meta.id), { exclude: abandonSet, pin });
|
|
62
|
+
if (standing.length > 0) {
|
|
63
|
+
return ok({
|
|
64
|
+
doc: spec, action: 'keep', standing: standing.map((p) => String(p.meta.id)),
|
|
65
|
+
shipped: standing.some((p) => String(p.meta.status) === 'done'),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
41
68
|
const commit = pinCommit(root, spec.rel, pin);
|
|
42
69
|
if (!commit) {
|
|
43
70
|
return refuse([v('spec', 'pin-unresolvable', pin.slice(0, 7), 'a committed version matching the pin')]);
|
|
44
71
|
}
|
|
72
|
+
// D166. The way back: the write being taken back may have departed a predecessor (a
|
|
73
|
+
// re-slice), which then returns with it. A re-slice can cut one spec into several slices, and
|
|
74
|
+
// taking back one while another still stands would leave the predecessor half-described, so
|
|
75
|
+
// that write is kept until its siblings go too — together, through the effort that made them.
|
|
76
|
+
const returns = prepareReturns(root, canon.paths, commit, String(spec.meta.id), takenBackBy(root, canon, abandonSet, effort));
|
|
77
|
+
// D166 (review). A sibling slice stands when this abandon leaves it in canon: it is outside the
|
|
78
|
+
// abandon set, or inside it but kept because a plan outside the set stands on it. It stands for
|
|
79
|
+
// good when a plan shipped it — and then so does this keep, since nothing can take the
|
|
80
|
+
// sibling back. Every spec the walk passes through counts: each was a re-slice being taken back.
|
|
81
|
+
const cut = new Set(returns.flatMap((r) => [r.entry.artifact, ...r.unwound.map((u) => u.artifact)]));
|
|
82
|
+
const slices = canon.docs
|
|
83
|
+
.filter((d) => d.meta.type === 'spec' && cut.has(String(d.meta.supersedes)) && String(d.meta.id) !== String(spec.meta.id))
|
|
84
|
+
.flatMap((d) => {
|
|
85
|
+
const id = String(d.meta.id);
|
|
86
|
+
const leaving = abandonSet.has(id);
|
|
87
|
+
const on = plansOn(canon, id, leaving ? { exclude: abandonSet, pin: canonicalSha(d.meta, d.body) } : { exclude: abandonSet });
|
|
88
|
+
return leaving && on.length === 0 ? [] : [{ id, shipped: on.some((p) => String(p.meta.status) === 'done') }];
|
|
89
|
+
});
|
|
90
|
+
if (slices.length > 0) {
|
|
91
|
+
return ok({ doc: spec, action: 'keep', standing: slices.map((s) => s.id), shipped: slices.some((s) => s.shipped) });
|
|
92
|
+
}
|
|
45
93
|
const prior = tryGit(root, 'show', `${commit}^:${spec.rel}`);
|
|
46
94
|
if (!prior.ok) {
|
|
47
95
|
const dependents = canon.docs
|
|
@@ -51,17 +99,30 @@ export function specRevertFor(root, canon, spec, pin, abandonSet) {
|
|
|
51
99
|
if (dependents.length > 0) {
|
|
52
100
|
return refuse([v('spec', 'waiting-dependents', dependents.join(' '), 'no docs outside the abandon set may depend on a spec being deleted')]);
|
|
53
101
|
}
|
|
54
|
-
|
|
102
|
+
// D166. The unwind departs the spec through the same door the re-slice uses, so
|
|
103
|
+
// everything that leaves canon with a spec — its design included — leaves here too.
|
|
104
|
+
return ok({
|
|
105
|
+
doc: spec, action: 'delete', returns,
|
|
106
|
+
departure: prepareDeparture(root, canon.paths, spec, {
|
|
107
|
+
v: 1, t: 'status', artifact: String(spec.meta.id), from: String(spec.meta.status), to: 'removed', cause: 'abandon',
|
|
108
|
+
}),
|
|
109
|
+
});
|
|
55
110
|
}
|
|
56
111
|
const parsed = splitDoc(prior.out);
|
|
57
112
|
const toStatus = parsed.ok ? String(parsed.value.meta.status) : 'draft';
|
|
58
|
-
|
|
113
|
+
// D166. The look goes back with the content it was drawn for: the design as it stood before
|
|
114
|
+
// the commit being taken back, byte for byte — or none, if there was none then.
|
|
115
|
+
const designR = designRel(canon.paths, String(spec.meta.id));
|
|
116
|
+
const priorDesign = gitBlob(root, `${commit}^`, designR);
|
|
117
|
+
const design = priorDesign !== undefined || existsSync(join(root, designR)) ? { rel: designR, prior: priorDesign } : undefined;
|
|
118
|
+
return ok({ doc: spec, action: 'restore', content: prior.out, toStatus, design, returns });
|
|
59
119
|
}
|
|
60
120
|
export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
61
121
|
const files = new Set();
|
|
62
122
|
const journalMulti = [];
|
|
63
123
|
const stamps = [];
|
|
64
124
|
const specEntries = [];
|
|
125
|
+
const returnEntries = [];
|
|
65
126
|
for (const item of items) {
|
|
66
127
|
if (item.planStamp) {
|
|
67
128
|
stamps.push(item.planStamp);
|
|
@@ -72,15 +133,30 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
|
72
133
|
if (item.revert) {
|
|
73
134
|
const spec = item.revert.doc;
|
|
74
135
|
const id = String(spec.meta.id);
|
|
75
|
-
const entry = {
|
|
76
|
-
v: 1, t: 'status', artifact: id, from: String(spec.meta.status),
|
|
77
|
-
to: item.revert.action === 'restore' ? item.revert.toStatus : 'removed',
|
|
78
|
-
cause: 'abandon',
|
|
136
|
+
const entry = item.revert.action === 'delete' ? item.revert.departure.entry : {
|
|
137
|
+
v: 1, t: 'status', artifact: id, from: String(spec.meta.status), to: item.revert.toStatus, cause: 'abandon',
|
|
79
138
|
};
|
|
80
139
|
specEntries.push({ stream: id, entry });
|
|
81
|
-
|
|
82
|
-
|
|
140
|
+
const touched = item.revert.action === 'delete'
|
|
141
|
+
? item.revert.departure.files
|
|
142
|
+
: [spec.rel, journalRel(id), ...(item.revert.design ? [item.revert.design.rel] : [])];
|
|
143
|
+
for (const f of touched)
|
|
144
|
+
files.add(f);
|
|
83
145
|
journalMulti.push({ stream: id, line: entryLine(entry) });
|
|
146
|
+
// D166. A spec this write had departed comes back in the same state commit.
|
|
147
|
+
for (const r of item.revert.returns) {
|
|
148
|
+
const lines = [
|
|
149
|
+
...[r.entry, ...r.unwound].map((e) => ({ stream: e.artifact, entry: e })),
|
|
150
|
+
...r.moves,
|
|
151
|
+
];
|
|
152
|
+
for (const f of r.files)
|
|
153
|
+
files.add(f);
|
|
154
|
+
for (const l of lines) {
|
|
155
|
+
files.add(journalRel(l.stream));
|
|
156
|
+
journalMulti.push({ stream: l.stream, line: entryLine(l.entry) });
|
|
157
|
+
}
|
|
158
|
+
returnEntries.push(...lines);
|
|
159
|
+
}
|
|
84
160
|
}
|
|
85
161
|
}
|
|
86
162
|
let effortLine;
|
|
@@ -100,17 +176,27 @@ export function executeAbandon(root, ctx, items, effortEntry, subject) {
|
|
|
100
176
|
return withTxn(root, { op: 'abandon', files: [...files], journalMulti }, () => {
|
|
101
177
|
for (const item of items) {
|
|
102
178
|
if (item.revert) {
|
|
103
|
-
|
|
104
|
-
if (item.revert.action === '
|
|
105
|
-
|
|
106
|
-
else
|
|
107
|
-
|
|
179
|
+
// stateCommit's `git add` below stages a tracked-but-missing file as a deletion
|
|
180
|
+
if (item.revert.action === 'delete')
|
|
181
|
+
item.revert.departure.remove();
|
|
182
|
+
else {
|
|
183
|
+
writeFileSync(join(root, item.revert.doc.rel), item.revert.content);
|
|
184
|
+
const d = item.revert.design;
|
|
185
|
+
if (d?.prior !== undefined)
|
|
186
|
+
writeFileSync(join(root, d.rel), d.prior);
|
|
187
|
+
else if (d !== undefined && existsSync(join(root, d.rel)))
|
|
188
|
+
unlinkSync(join(root, d.rel));
|
|
189
|
+
}
|
|
190
|
+
for (const r of item.revert.returns)
|
|
191
|
+
r.restore();
|
|
108
192
|
}
|
|
109
193
|
}
|
|
110
194
|
for (const s of stamps)
|
|
111
195
|
writeStamp(root, s);
|
|
112
196
|
for (const se of specEntries)
|
|
113
197
|
appendEntry(root, se.stream, se.entry);
|
|
198
|
+
for (const re of returnEntries)
|
|
199
|
+
appendEntry(root, re.stream, re.entry);
|
|
114
200
|
if (effortLine) {
|
|
115
201
|
appendEntry(root, effortLine.stream, JSON.parse(effortLine.line));
|
|
116
202
|
}
|
|
@@ -153,7 +239,11 @@ export function planItems(root, canon, plan, abandonSet, pairing) {
|
|
|
153
239
|
const revertR = specRevertFor(root, canon, parent, String(plan.meta['derives-from']), abandonSet);
|
|
154
240
|
if (!revertR.ok)
|
|
155
241
|
return revertR;
|
|
156
|
-
|
|
242
|
+
const outcome = revertR.value;
|
|
243
|
+
if (outcome?.action === 'keep')
|
|
244
|
+
item.kept = outcome;
|
|
245
|
+
else
|
|
246
|
+
item.revert = outcome;
|
|
157
247
|
}
|
|
158
248
|
}
|
|
159
249
|
return ok(item);
|
package/dist/deferral.js
CHANGED
|
@@ -41,3 +41,15 @@ export function deferralsBlock(open) {
|
|
|
41
41
|
'is read as "still present".\n\n' +
|
|
42
42
|
open.map((d, i) => `${i + 1}. ${d.anchor} — ${d.gate} round ${d.round} (${d.id})`).join('\n') + '\n\n';
|
|
43
43
|
}
|
|
44
|
+
// D122 + D166. A debt changes homes when the artifact holding it stops being the one the next
|
|
45
|
+
// effort will meet: a plan's merge hands its open obligations to the parent spec (stamp.ts), a
|
|
46
|
+
// re-slice hands the superseded spec's to its successor (write.ts), and taking a re-slice back
|
|
47
|
+
// hands them home again (departure.ts, `which` picking the ones that came from there). One
|
|
48
|
+
// rule for all three: the id is preserved, because age is measured across the move, and the
|
|
49
|
+
// old stream records where each debt went.
|
|
50
|
+
export function deferralMoves(entries, from, to, which = () => true) {
|
|
51
|
+
return openDeferrals(entries).filter(which).flatMap((d) => [
|
|
52
|
+
{ stream: from, entry: { v: 1, t: 'deferral-moved', id: d.id, to } },
|
|
53
|
+
{ stream: to, entry: { ...d, artifact: to, moved_from: from } },
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { deferralMoves } from './deferral.js';
|
|
4
|
+
import { designRel } from './design.js';
|
|
5
|
+
import { FLOOR_STREAM } from './floor.js';
|
|
6
|
+
import { splitDoc } from './fm.js';
|
|
7
|
+
import { gitBlob, tryGit } from './gitio.js';
|
|
8
|
+
import { journalRel, readStream } from './journal.js';
|
|
9
|
+
import { v } from './refusal.js';
|
|
10
|
+
import { findById } from './scan.js';
|
|
11
|
+
import { canonicalSha } from './sha.js';
|
|
12
|
+
// D166. A spec leaves canon through exactly two verbs — the re-slice (row 62), which
|
|
13
|
+
// journals `superseded` and names the successor, and abandon's unwind of a spec its own
|
|
14
|
+
// effort created (row 29), which journals `removed`. This module is the one home for that
|
|
15
|
+
// act: how a departure is read, what it takes out of canon, how it is taken back, and which
|
|
16
|
+
// ids it retires. What may still point INTO a departing spec is settled by the two verbs
|
|
17
|
+
// that depart one.
|
|
18
|
+
// The status entry with which an artifact LEFT canon, if a witness verb took it out: the
|
|
19
|
+
// stream's LATEST status entry, when it is a departure. Latest, not any — a departure taken
|
|
20
|
+
// back, or a removed spec written again, is followed by a later status and reads as present.
|
|
21
|
+
// Callers consult canon first: this answers only for an id that canon no longer holds.
|
|
22
|
+
export function canonExit(entries) {
|
|
23
|
+
const last = entries.filter((e) => e.t === 'status').at(-1);
|
|
24
|
+
return last !== undefined && (last.to === 'superseded' || last.to === 'removed') ? last : undefined;
|
|
25
|
+
}
|
|
26
|
+
// Every stream whose artifact left canon. An obligation recorded there is still open until it
|
|
27
|
+
// is dismissed (D122), so the ledger reads these streams too.
|
|
28
|
+
export function departedStreams(root, canon) {
|
|
29
|
+
const dir = join(root, '.witness', 'journal');
|
|
30
|
+
if (!existsSync(dir))
|
|
31
|
+
return [];
|
|
32
|
+
return readdirSync(dir).filter((f) => f.endsWith('.jsonl')).map((f) => f.slice(0, -'.jsonl'.length))
|
|
33
|
+
.filter((id) => !findById(canon, id) && canonExit(readStream(root, id)) !== undefined);
|
|
34
|
+
}
|
|
35
|
+
// What leaves canon WITH a spec. Its design is spec-scoped (row 69) and goes with it: the
|
|
36
|
+
// re-slice always deleted it, while the unwind left a ui spec's look behind in canon after the
|
|
37
|
+
// spec it described was gone. A design is listed only when present, because a state commit
|
|
38
|
+
// cannot stage a path that never existed.
|
|
39
|
+
export function prepareDeparture(root, paths, spec, entry) {
|
|
40
|
+
const design = designRel(paths, String(spec.meta.id));
|
|
41
|
+
const leaving = [spec.rel, ...(existsSync(join(root, design)) ? [design] : [])];
|
|
42
|
+
return {
|
|
43
|
+
entry,
|
|
44
|
+
files: [...leaving, journalRel(String(spec.meta.id))],
|
|
45
|
+
remove: () => { for (const rel of leaving)
|
|
46
|
+
unlinkSync(join(root, rel)); },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// The way back. Row 29 unwinds a write by selectively reverting its state commit, and a
|
|
50
|
+
// re-slice's commit did two things: it wrote the successor and departed its predecessor.
|
|
51
|
+
// Taking that write back therefore brings the predecessor back — its file and design as they
|
|
52
|
+
// stood before the commit, byte for byte, a status entry that ends the departure, and the
|
|
53
|
+
// obligations that had followed it to the successor. Without this, abandoning the effort that
|
|
54
|
+
// re-sliced a shipped spec left canon describing none of what had shipped, under an id no
|
|
55
|
+
// verb could use again.
|
|
56
|
+
//
|
|
57
|
+
// A return never lands content an abandoned effort wrote: that write is taken back too. An
|
|
58
|
+
// earlier abandon skips a spec a later re-slice had already departed, and one effort can
|
|
59
|
+
// re-slice its own re-slice, so the predecessor of the write being taken back may itself be
|
|
60
|
+
// work no live effort owns. The walk goes back past each such write — to the content before an
|
|
61
|
+
// amendment, or, past a create, to whatever that create had departed in its turn — so canon
|
|
62
|
+
// ends where it stood before every abandoned write, whatever order the efforts went in.
|
|
63
|
+
export function prepareReturns(root, paths, commit, successor, takenBack = () => undefined) {
|
|
64
|
+
return returnsThrough(root, paths, commit, [successor], [], takenBack, new Set([commit]));
|
|
65
|
+
}
|
|
66
|
+
function returnsThrough(root, paths, commit, chain, unwound, takenBack, seen) {
|
|
67
|
+
const successor = chain.at(-1);
|
|
68
|
+
const shown = tryGit(root, 'show', '--no-renames', '--name-status', '--format=', commit);
|
|
69
|
+
if (!shown.ok)
|
|
70
|
+
return [];
|
|
71
|
+
const deleted = shown.out.split('\n').filter((l) => l.startsWith('D\t')).map((l) => l.slice(2));
|
|
72
|
+
return deleted.flatMap((rel) => {
|
|
73
|
+
if (!rel.startsWith(`${paths.specs}/`) || !rel.endsWith('.md'))
|
|
74
|
+
return [];
|
|
75
|
+
let src = `${commit}^`;
|
|
76
|
+
let blob = gitBlob(root, src, rel);
|
|
77
|
+
if (blob === undefined)
|
|
78
|
+
return [];
|
|
79
|
+
let parsed = splitDoc(blob.toString('utf8'));
|
|
80
|
+
if (!parsed.ok)
|
|
81
|
+
return [];
|
|
82
|
+
const id = String(parsed.value.meta.id);
|
|
83
|
+
const exit = canonExit(readStream(root, id));
|
|
84
|
+
if (exit?.to !== 'superseded' || exit.by !== successor)
|
|
85
|
+
return [];
|
|
86
|
+
let status = exit.from;
|
|
87
|
+
for (;;) {
|
|
88
|
+
const back = takenBack(id, canonicalSha(parsed.value.meta, parsed.value.body), rel, src);
|
|
89
|
+
if (back === undefined || seen.has(back))
|
|
90
|
+
break;
|
|
91
|
+
seen.add(back);
|
|
92
|
+
const before = gitBlob(root, `${back}^`, rel);
|
|
93
|
+
if (before === undefined) {
|
|
94
|
+
// That write created it: it stays out, and what it had departed comes back instead.
|
|
95
|
+
const gone = { v: 1, t: 'status', artifact: id, from: 'superseded', to: 'removed', cause: 'abandon' };
|
|
96
|
+
return returnsThrough(root, paths, back, [...chain, id], [...unwound, gone], takenBack, seen);
|
|
97
|
+
}
|
|
98
|
+
const prior = splitDoc(before.toString('utf8'));
|
|
99
|
+
if (!prior.ok)
|
|
100
|
+
break;
|
|
101
|
+
src = `${back}^`;
|
|
102
|
+
blob = before;
|
|
103
|
+
parsed = prior;
|
|
104
|
+
status = String(prior.value.meta.status);
|
|
105
|
+
}
|
|
106
|
+
const bytes = blob;
|
|
107
|
+
const design = designRel(paths, id);
|
|
108
|
+
const designBlob = deleted.includes(design) ? gitBlob(root, src, design) : undefined;
|
|
109
|
+
const put = (r, b) => {
|
|
110
|
+
mkdirSync(dirname(join(root, r)), { recursive: true }); // git keeps no empty directory: a fresh clone may lack it
|
|
111
|
+
writeFileSync(join(root, r), b);
|
|
112
|
+
};
|
|
113
|
+
return [{
|
|
114
|
+
entry: { v: 1, t: 'status', artifact: id, from: 'superseded', to: status, cause: 'abandon' },
|
|
115
|
+
files: [rel, ...(designBlob === undefined ? [] : [design]), journalRel(id)],
|
|
116
|
+
restore: () => {
|
|
117
|
+
put(rel, bytes);
|
|
118
|
+
if (designBlob !== undefined)
|
|
119
|
+
put(design, designBlob);
|
|
120
|
+
},
|
|
121
|
+
// Each link of the chain hands home the debts that came to it from the next one down.
|
|
122
|
+
moves: chain.flatMap((s, k) => deferralMoves(readStream(root, s), s, id, (d) => d.moved_from === (chain[k + 1] ?? id))),
|
|
123
|
+
unwound,
|
|
124
|
+
}];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
// Whether a NEW artifact may take this id. An id names one artifact for the life of the repo,
|
|
128
|
+
// because its stream IS that artifact's history. Three streams already belong to something
|
|
129
|
+
// else: a superseded spec's (a successor owns its content, and its finished plans resolve their
|
|
130
|
+
// parent there), an effort's, and witness's own floor pins. The one re-use is the same artifact
|
|
131
|
+
// again: a spec its own abandon unwound may be written again as a spec. A stream that records
|
|
132
|
+
// none of these — a stray, or a doc canon cannot parse — does not hold its id; the findings
|
|
133
|
+
// that already name it (`orphan-journal`, the parse error) are its remedy.
|
|
134
|
+
export function idRetired(root, id, type) {
|
|
135
|
+
if (id === FLOOR_STREAM)
|
|
136
|
+
return v('id', 'id-taken', id, `a new id — ${id} is witness's own floor stream`);
|
|
137
|
+
const entries = readStream(root, id);
|
|
138
|
+
if (entries[0]?.t === 'recap')
|
|
139
|
+
return v('id', 'id-taken', id, `a new id — ${id} is an effort's slug`);
|
|
140
|
+
const exit = canonExit(entries);
|
|
141
|
+
if (exit?.to === 'superseded') {
|
|
142
|
+
return v('id', 'id-taken', id, `a new id — ${id} was superseded by ${exit.by ?? 'a successor'} and its stream is that spec's lineage`);
|
|
143
|
+
}
|
|
144
|
+
if (exit?.to === 'removed' && type !== 'spec')
|
|
145
|
+
return v('id', 'id-taken', id, `a new id — ${id} was a spec its abandon unwound`);
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
package/dist/gitio.js
CHANGED
|
@@ -27,6 +27,17 @@ export function tryGit(root, ...args) {
|
|
|
27
27
|
return { ok: false, out: String(err.stdout || err.stderr || e).trim() };
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
// D166. The bytes git stores for a path at a revision, untouched. `git()` strips one trailing
|
|
31
|
+
// newline, which is right for command output and wrong for a file put back from history: a
|
|
32
|
+
// design is hashed as bytes, so a restored look must be the look that was stored.
|
|
33
|
+
export function gitBlob(root, rev, rel) {
|
|
34
|
+
try {
|
|
35
|
+
return execFileSync('git', ['show', `${rev}:${rel}`], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: GIT_MAX_BUFFER });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
30
41
|
export function primaryRoot(cwd) {
|
|
31
42
|
const top = tryGit(cwd, 'rev-parse', '--show-toplevel');
|
|
32
43
|
if (!top.ok) {
|
package/dist/refusal.js
CHANGED
|
@@ -8,6 +8,7 @@ export function refuse(violations) {
|
|
|
8
8
|
export function v(field, rule, got, want, remedy) {
|
|
9
9
|
return remedy === undefined ? { field, rule, got, want } : { field, rule, got, want, remedy };
|
|
10
10
|
}
|
|
11
|
+
export const humanCall = (x) => ({ ...x, human: true });
|
|
11
12
|
export function renderRefusal(violations) {
|
|
12
13
|
const runnable = [...new Set(violations.map((x) => x.remedy).filter((r) => r !== undefined && !/<[^>]+>/.test(r)))];
|
|
13
14
|
return [
|
|
@@ -15,6 +16,8 @@ export function renderRefusal(violations) {
|
|
|
15
16
|
// governs what prints, but a remedy is not a column and must not become one.
|
|
16
17
|
...rows('refused', ['field', 'rule', 'got', 'want'], violations.map(({ remedy: _remedy, ...rest }) => rest)),
|
|
17
18
|
...runnable.map((r) => `run: ${r}`),
|
|
18
|
-
|
|
19
|
+
violations.some((x) => x.human)
|
|
20
|
+
? 'help: a row here is a human\'s decision — stop and surface the list; re-running cannot clear it'
|
|
21
|
+
: 'help: fix each row and re-run — rows are structured for self-repair',
|
|
19
22
|
];
|
|
20
23
|
}
|
package/dist/scan.js
CHANGED
|
@@ -52,6 +52,23 @@ export function plannableParent(doc) {
|
|
|
52
52
|
const s = String(doc.meta.status);
|
|
53
53
|
return doc.meta.type === 'principles' ? s === 'approved' : s === 'approved' || s === 'live';
|
|
54
54
|
}
|
|
55
|
+
// D166. The one derivation of "this plan no longer moves": `done` merged and `abandoned` was
|
|
56
|
+
// withdrawn. check's stray-worktree rule, next's plan queue and the re-slice's guard all ask
|
|
57
|
+
// exactly this.
|
|
58
|
+
export function planFinished(doc) {
|
|
59
|
+
const s = String(doc.meta.status);
|
|
60
|
+
return s === 'done' || s === 'abandoned';
|
|
61
|
+
}
|
|
62
|
+
// D166. The plans that stand on a spec: its children that have not been withdrawn, outside
|
|
63
|
+
// the set of plans the asking operation is itself retiring. A `done` plan stands — it shipped
|
|
64
|
+
// this spec — and so does one still moving; only `abandoned` has stepped off. `pin` narrows to
|
|
65
|
+
// the children derived from one version of the spec, which is what an unwind would take away.
|
|
66
|
+
export function plansOn(canon, specId, opts = {}) {
|
|
67
|
+
return canon.docs.filter((d) => d.meta.type === 'plan' && d.meta.parent === specId &&
|
|
68
|
+
String(d.meta.status) !== 'abandoned' &&
|
|
69
|
+
!(opts.exclude?.has(String(d.meta.id)) ?? false) &&
|
|
70
|
+
(opts.pin === undefined || d.meta['derives-from'] === opts.pin));
|
|
71
|
+
}
|
|
55
72
|
export function findCycle(canon, extra) {
|
|
56
73
|
const deps = new Map();
|
|
57
74
|
for (const d of canon.docs) {
|
package/dist/stamp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { deferralMoves } from './deferral.js';
|
|
5
5
|
import { writeDoc } from './fm.js';
|
|
6
6
|
import { stateCommit } from './gitio.js';
|
|
7
7
|
import { appendEntry, entryLine, journalRel, readStream } from './journal.js';
|
|
@@ -70,10 +70,7 @@ export function lazyStamp(root, ctx, canon) {
|
|
|
70
70
|
// Keyed on `parent`, not on `specStamp`: a stale merge writes no spec stamp, and that is
|
|
71
71
|
// exactly the merge whose debts most need somewhere to live.
|
|
72
72
|
const parentId = parent ? String(parent.meta.id) : undefined;
|
|
73
|
-
const moves = parentId === undefined ? [] :
|
|
74
|
-
{ stream: planId, entry: { v: 1, t: 'deferral-moved', id: d.id, to: parentId } },
|
|
75
|
-
{ stream: parentId, entry: { ...d, artifact: parentId, moved_from: planId } },
|
|
76
|
-
]);
|
|
73
|
+
const moves = parentId === undefined ? [] : deferralMoves(readStream(root, planId), planId, parentId);
|
|
77
74
|
const files = [planStamp.rel, journalRel(planStamp.stream),
|
|
78
75
|
...(specStamp ? [specStamp.rel, journalRel(specStamp.stream)] : []),
|
|
79
76
|
...moves.map((m) => journalRel(m.stream))];
|
package/dist/txn.js
CHANGED
|
@@ -40,16 +40,34 @@ export function rollbackTxn(root, marker) {
|
|
|
40
40
|
}
|
|
41
41
|
rmSync(markerPath(root), { force: true });
|
|
42
42
|
}
|
|
43
|
+
// D166. What a crash left of a transaction's journal, per stream: the marker's pending lines
|
|
44
|
+
// for that stream, in order, and how many of them the stream already ends with. One
|
|
45
|
+
// transaction may append SEVERAL lines to one stream — the merge stamp's status and its
|
|
46
|
+
// deferral moves, a re-slice's departure and its moves — and the crash can land after any
|
|
47
|
+
// prefix of them. `completeTxn` appends the rest; `recover` ranks --complete over --rollback
|
|
48
|
+
// when nothing is missing. One derivation for both: comparing each line with the stream's
|
|
49
|
+
// last line re-appended every earlier line, and counted it as missing.
|
|
50
|
+
export function landedJournal(root, marker) {
|
|
51
|
+
const pending = new Map();
|
|
52
|
+
for (const { stream, line } of [...(marker.journal ? [marker.journal] : []), ...(marker.journalMulti ?? [])]) {
|
|
53
|
+
pending.set(stream, [...(pending.get(stream) ?? []), line]);
|
|
54
|
+
}
|
|
55
|
+
return [...pending].map(([stream, lines]) => {
|
|
56
|
+
const p = join(root, journalRel(stream));
|
|
57
|
+
const current = (existsSync(p) ? readFileSync(p, 'utf8') : '').split('\n').filter(Boolean);
|
|
58
|
+
let landed = Math.min(lines.length, current.length);
|
|
59
|
+
while (landed > 0 && current.slice(-landed).join('\n') !== lines.slice(0, landed).join('\n'))
|
|
60
|
+
landed--;
|
|
61
|
+
return { stream, lines, landed };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
43
64
|
export function completeTxn(root, marker) {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
65
|
+
const streams = landedJournal(root, marker);
|
|
66
|
+
if (streams.length)
|
|
46
67
|
mkdirSync(join(root, '.witness', 'journal'), { recursive: true });
|
|
47
|
-
for (const { stream,
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
const lastLine = current.split('\n').filter(Boolean).at(-1);
|
|
51
|
-
if (lastLine !== line)
|
|
52
|
-
appendFileSync(p, line + '\n');
|
|
68
|
+
for (const { stream, lines, landed } of streams) {
|
|
69
|
+
for (const line of lines.slice(landed))
|
|
70
|
+
appendFileSync(join(root, journalRel(stream)), line + '\n');
|
|
53
71
|
}
|
|
54
72
|
const res = stateCommit(root, marker.files, `${marker.op} (recovered)`);
|
|
55
73
|
if (res.ok)
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -7,7 +7,7 @@ import { effortWrites } from '../reviewed.js';
|
|
|
7
7
|
import { canonicalSha } from '../sha.js';
|
|
8
8
|
import { renderRefusal, v } from '../refusal.js';
|
|
9
9
|
import { kv } from '../toon.js';
|
|
10
|
-
import { executeAbandon, planItems, specRevertFor } from '../abandon.js';
|
|
10
|
+
import { executeAbandon, keptLine, planItems, specRevertFor } from '../abandon.js';
|
|
11
11
|
export async function run(ctx, argv) {
|
|
12
12
|
const target = argv.find((a) => !a.startsWith('--'));
|
|
13
13
|
if (!target) {
|
|
@@ -44,6 +44,8 @@ export async function run(ctx, argv) {
|
|
|
44
44
|
if (itemR.value.revert) {
|
|
45
45
|
ctx.out(kv('reverted', `${String(itemR.value.revert.doc.meta.id)} (${itemR.value.revert.action}) — journal untouched`));
|
|
46
46
|
}
|
|
47
|
+
if (itemR.value.kept)
|
|
48
|
+
ctx.out(kv('kept', keptLine(itemR.value.kept)));
|
|
47
49
|
return EXIT.OK;
|
|
48
50
|
}
|
|
49
51
|
if (streamExists(root, target) && latestRecap(root, target)) {
|
|
@@ -56,6 +58,7 @@ export async function run(ctx, argv) {
|
|
|
56
58
|
const abandonSet = new Set(writes.keys());
|
|
57
59
|
const items = [];
|
|
58
60
|
const problems = [];
|
|
61
|
+
const kept = [];
|
|
59
62
|
for (const id of writes.keys()) {
|
|
60
63
|
const artifact = findById(canon, id);
|
|
61
64
|
if (!artifact)
|
|
@@ -80,13 +83,25 @@ export async function run(ctx, argv) {
|
|
|
80
83
|
problems.push(v('spec', 'stacked-amendment', id, 'the effort\'s write is no longer the newest'));
|
|
81
84
|
continue;
|
|
82
85
|
}
|
|
83
|
-
const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet);
|
|
86
|
+
const revertR = specRevertFor(root, canon, artifact, w.sha, abandonSet, target);
|
|
84
87
|
if (!revertR.ok) {
|
|
85
88
|
problems.push(...revertR.violations);
|
|
86
89
|
continue;
|
|
87
90
|
}
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
const outcome = revertR.value;
|
|
92
|
+
if (outcome?.action === 'keep') {
|
|
93
|
+
// D166. This abandon is the effort's last act, so a keep here must be one nothing has
|
|
94
|
+
// to take back later: a shipped plan makes it so — canon describes built behavior. A
|
|
95
|
+
// plan of another effort still building on the spec would leave it owned by no one and
|
|
96
|
+
// removable by no verb, so that case refuses until the plan finishes or goes.
|
|
97
|
+
if (!outcome.shipped) {
|
|
98
|
+
problems.push(v('spec', 'still-standing', `${id}: ${outcome.standing.join(' ')}`, 'those finished or withdrawn first — this abandon is the last act that could take the spec back'));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
kept.push(outcome);
|
|
102
|
+
}
|
|
103
|
+
else if (outcome)
|
|
104
|
+
items.push({ revert: outcome });
|
|
90
105
|
}
|
|
91
106
|
if (problems.length > 0) {
|
|
92
107
|
renderRefusal(problems).forEach((l) => ctx.err(l));
|
|
@@ -98,6 +113,8 @@ export async function run(ctx, argv) {
|
|
|
98
113
|
return EXIT.REFUSED;
|
|
99
114
|
}
|
|
100
115
|
ctx.out(kv('abandoned', `effort ${target} — ${items.length} artifacts walked, journal remembers the attempt`));
|
|
116
|
+
for (const k of kept)
|
|
117
|
+
ctx.out(kv('kept', keptLine(k)));
|
|
101
118
|
return EXIT.OK;
|
|
102
119
|
}
|
|
103
120
|
renderRefusal([v('target', 'unknown-target', target, 'a plan id or an effort slug')]).forEach((l) => ctx.err(l));
|
package/dist/verbs/adopt.js
CHANGED
|
@@ -9,6 +9,7 @@ import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
|
|
|
9
9
|
import { appendEntry, entryLine, journalRel } from '../journal.js';
|
|
10
10
|
import { acquireLock } from '../lock.js';
|
|
11
11
|
import { renderRefusal, v } from '../refusal.js';
|
|
12
|
+
import { idRetired } from '../departure.js';
|
|
12
13
|
import { findById, findCycle, loadCanon } from '../scan.js';
|
|
13
14
|
import { validateDoc } from '../schemas.js';
|
|
14
15
|
import { canonicalSha, short } from '../sha.js';
|
|
@@ -42,6 +43,10 @@ export async function run(ctx, argv) {
|
|
|
42
43
|
if (canon.docs.filter((d) => String(d.meta.id) === id).length > 1) {
|
|
43
44
|
violations.push(v('id', 'duplicate-id', id, 'one doc per id'));
|
|
44
45
|
}
|
|
46
|
+
// D166. Adopt runs the checks write runs: a hand-made doc may not take a retired id either.
|
|
47
|
+
const taken = idRetired(root, id, String(meta.type));
|
|
48
|
+
if (taken)
|
|
49
|
+
violations.push(taken);
|
|
45
50
|
for (const dep of Array.isArray(meta.depends) ? meta.depends : []) {
|
|
46
51
|
if (!findById(canon, dep))
|
|
47
52
|
violations.push(v('depends', 'unknown-dep', dep, 'an existing doc id'));
|
package/dist/verbs/check.js
CHANGED
|
@@ -8,7 +8,10 @@ import { latestPublished } from '../registry.js';
|
|
|
8
8
|
import { NPX_LATEST, compareTriple } from '../version.js';
|
|
9
9
|
import { probe } from '../probe.js';
|
|
10
10
|
import { loadConfig, loadLocalConfig, localConfigPath } from '../config.js';
|
|
11
|
+
import { openDeferrals } from '../deferral.js';
|
|
12
|
+
import { canonExit } from '../departure.js';
|
|
11
13
|
import { designPending } from '../design.js';
|
|
14
|
+
import { FLOOR_STREAM } from '../floor.js';
|
|
12
15
|
import { runDrift } from '../drift.js';
|
|
13
16
|
import { auditStateCommits, dirtyStatePaths, divergence, primaryRoot, stateDirs, tryGit } from '../gitio.js';
|
|
14
17
|
import { contentAtSha } from '../history.js';
|
|
@@ -19,7 +22,7 @@ import { evaluateNeeds } from '../needs.js';
|
|
|
19
22
|
import { renderRefusal } from '../refusal.js';
|
|
20
23
|
import { pendingDecision } from '../rounds.js';
|
|
21
24
|
import { criteriaExcludes } from '../runner.js';
|
|
22
|
-
import { findById, findCycle, loadCanon } from '../scan.js';
|
|
25
|
+
import { findById, findCycle, loadCanon, planFinished } from '../scan.js';
|
|
23
26
|
import { canonicalSha } from '../sha.js';
|
|
24
27
|
import { lazyStamp } from '../stamp.js';
|
|
25
28
|
import { kv, rows } from '../toon.js';
|
|
@@ -101,7 +104,22 @@ export async function run(ctx, argv = []) {
|
|
|
101
104
|
for (const plan of canon.docs.filter((d) => d.meta.type === 'plan')) {
|
|
102
105
|
const parent = typeof plan.meta.parent === 'string' ? findById(canon, plan.meta.parent) : undefined;
|
|
103
106
|
if (!parent) {
|
|
104
|
-
|
|
107
|
+
// D166. A plan that no longer moves keeps its parent in the departed spec's stream — the
|
|
108
|
+
// rule row 62 already applies to `supersedes:` and `--lineage`. Its pin is not
|
|
109
|
+
// re-verified: nothing reads a finished plan's pin once its parent is history, and the
|
|
110
|
+
// departure never journaled the path it would resolve against. Two cases stay errors: a
|
|
111
|
+
// plan still moving (every gate reads its parent), and a `done` plan under `removed` —
|
|
112
|
+
// only a re-slice may take shipped content out of canon, so that is a pre-D166 unwind
|
|
113
|
+
// that deleted built behavior. No departure entry at all is a stray, as before.
|
|
114
|
+
const exit = typeof plan.meta.parent === 'string' ? canonExit(readStream(root, plan.meta.parent)) : undefined;
|
|
115
|
+
const status = String(plan.meta.status);
|
|
116
|
+
if (exit !== undefined && (status === 'abandoned' || (status === 'done' && exit.to === 'superseded')))
|
|
117
|
+
continue;
|
|
118
|
+
findings.push(f('error', 'invariants', `${plan.rel}: parent`, 'unknown-parent', exit === undefined
|
|
119
|
+
? String(plan.meta.parent)
|
|
120
|
+
: status === 'done'
|
|
121
|
+
? `${String(plan.meta.parent)} removed by abandon after this plan shipped it — canon lost built behavior; write that spec again under this id`
|
|
122
|
+
: `${String(plan.meta.parent)} ${exit.to} by ${exit.by ?? exit.cause} while this plan is ${status} — witness abandon ${String(plan.meta.id)} · or re-author it under a live parent`));
|
|
105
123
|
continue;
|
|
106
124
|
}
|
|
107
125
|
if (plan.meta.status !== 'draft' && plan.meta.status !== 'abandoned' && parent.meta.status === 'draft') {
|
|
@@ -145,7 +163,7 @@ export async function run(ctx, argv = []) {
|
|
|
145
163
|
}
|
|
146
164
|
for (const planId of listWorktrees(root)) {
|
|
147
165
|
const doc = findById(canon, planId);
|
|
148
|
-
if (!doc ||
|
|
166
|
+
if (!doc || planFinished(doc)) {
|
|
149
167
|
findings.push(f('warn', 'motion', planId, 'stray-worktree', 'witness clean sweeps it'));
|
|
150
168
|
}
|
|
151
169
|
// Row 132. A worktree cut before the canon exclusion existed still carries a copy, and
|
|
@@ -212,10 +230,25 @@ export async function run(ctx, argv = []) {
|
|
|
212
230
|
continue;
|
|
213
231
|
const id = file.slice(0, -'.jsonl'.length);
|
|
214
232
|
const entries = readStream(root, id);
|
|
215
|
-
if (entries[0]?.t === 'recap')
|
|
233
|
+
if (entries[0]?.t === 'recap' || id === FLOOR_STREAM)
|
|
234
|
+
continue;
|
|
235
|
+
if (findById(canon, id))
|
|
236
|
+
continue;
|
|
237
|
+
// D166. A departed spec's stream is not an orphan — it is lineage by construction, what a
|
|
238
|
+
// finished plan's parent and `log --lineage` resolve against. It speaks only while it
|
|
239
|
+
// still holds open obligations: a re-slice moves them to the successor, but an unwind has
|
|
240
|
+
// no successor, and a departure from before D166 moved nothing. `superseded` is the
|
|
241
|
+
// dismissal cause that fits either: the thing the obligation was about no longer exists.
|
|
242
|
+
// A stream with no departure entry is what this rule was always for: a stray.
|
|
243
|
+
const exit = canonExit(entries);
|
|
244
|
+
if (exit === undefined) {
|
|
245
|
+
findings.push(f('warn', 'journal', file, 'orphan-journal', 'stream matches no doc and records no departure — a stray'));
|
|
216
246
|
continue;
|
|
217
|
-
|
|
218
|
-
|
|
247
|
+
}
|
|
248
|
+
const owed = openDeferrals(entries);
|
|
249
|
+
if (owed.length > 0) {
|
|
250
|
+
findings.push(f('warn', 'journal', id, 'stranded-deferral', `${owed.length} open deferral(s) on a spec that left canon (${exit.to} by ${exit.by ?? exit.cause}): ${owed.map((d) => d.id).join(' ')} — witness dismiss ${id} --deferral <id> --cause superseded --note <why>`));
|
|
251
|
+
}
|
|
219
252
|
}
|
|
220
253
|
}
|
|
221
254
|
if (cfg.ok) {
|
package/dist/verbs/dashboard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { EXIT, version } from '../cli.js';
|
|
3
3
|
import { loadConfig } from '../config.js';
|
|
4
|
+
import { departedStreams } from '../departure.js';
|
|
4
5
|
import { designArtifactCurrent, designPending } from '../design.js';
|
|
5
6
|
import { reconcileRows } from '../drift.js';
|
|
6
7
|
import { divergence, primaryRoot } from '../gitio.js';
|
|
@@ -50,7 +51,9 @@ function blockedRows(canon, ctx) {
|
|
|
50
51
|
// D122. Aged in ROUNDS, never in wall-clock: this CLI has no timestamps anywhere — the
|
|
51
52
|
// gate-run entry carries none — so "61 rounds" is derivable and "3 weeks" is not.
|
|
52
53
|
export function deferralRows(root, canon) {
|
|
53
|
-
|
|
54
|
+
// D166. A departed spec's stream is the third source: an obligation recorded there is still
|
|
55
|
+
// open until it is dismissed, and `status` is where every open obligation is listed (D122).
|
|
56
|
+
const streams = new Set([...effortStreams(root), ...canon.docs.map((d) => String(d.meta.id)), ...departedStreams(root, canon)]);
|
|
54
57
|
const out = [];
|
|
55
58
|
for (const id of streams) {
|
|
56
59
|
const entries = readStream(root, id);
|
package/dist/verbs/log.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { EXIT } from '../cli.js';
|
|
5
5
|
import { git, primaryRoot, tryGit } from '../gitio.js';
|
|
6
|
+
import { canonExit } from '../departure.js';
|
|
6
7
|
import { journalRel, readStream, streamExists } from '../journal.js';
|
|
7
8
|
import { renderRefusal, v } from '../refusal.js';
|
|
8
9
|
import { short } from '../sha.js';
|
|
@@ -50,9 +51,11 @@ const streamIds = (root) => {
|
|
|
50
51
|
.map((f) => f.slice(0, -'.jsonl'.length))
|
|
51
52
|
.sort();
|
|
52
53
|
};
|
|
54
|
+
// D166. Read through `canonExit`, the one reading of a departure: a re-slice that was taken
|
|
55
|
+
// back is no longer a lineage step.
|
|
53
56
|
function successorOf(root, id) {
|
|
54
|
-
const
|
|
55
|
-
return
|
|
57
|
+
const exit = canonExit(readStream(root, id));
|
|
58
|
+
return exit?.to === 'superseded' ? exit.by : undefined;
|
|
56
59
|
}
|
|
57
60
|
function predecessorsOf(root, id) {
|
|
58
61
|
return streamIds(root).filter((s) => successorOf(root, s) === id);
|
package/dist/verbs/next.js
CHANGED
|
@@ -4,7 +4,7 @@ import { EXIT } from '../cli.js';
|
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { pendingTxn } from '../txn.js';
|
|
6
6
|
import { primaryRoot } from '../gitio.js';
|
|
7
|
-
import { findById, loadCanon, plannableParent } from '../scan.js';
|
|
7
|
+
import { findById, loadCanon, planFinished, plannableParent } from '../scan.js';
|
|
8
8
|
import { designArtifactCurrent, designPending, designUnseen } from '../design.js';
|
|
9
9
|
import { effortAbandoned, effortStreams, latestRecap, readStream } from '../journal.js';
|
|
10
10
|
import { diffReviewedSha, effortOf, effortReviewedSha, effortSpecs, effortWrites, implementReviewedSha, planPairSha } from '../reviewed.js';
|
|
@@ -640,8 +640,7 @@ export function computeNext(root, ctx, canon, cfg) {
|
|
|
640
640
|
};
|
|
641
641
|
const planless = canon.docs
|
|
642
642
|
.filter((d) => d.meta.type === 'spec' && plannableParent(d))
|
|
643
|
-
.filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) &&
|
|
644
|
-
!['done', 'abandoned'].includes(String(p.meta.status))))
|
|
643
|
+
.filter((d) => !plans.some((p) => String(p.meta.parent) === String(d.meta.id) && !planFinished(p)))
|
|
645
644
|
.filter((d) => (d.meta.depends ?? []).every(ready))
|
|
646
645
|
// last on purpose: the only filter that costs a subprocess — the in-memory ones above
|
|
647
646
|
// discard for free, and all four are pure predicates over an intersection
|
package/dist/verbs/recover.js
CHANGED
|
@@ -1,38 +1,36 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { EXIT } from '../cli.js';
|
|
3
3
|
import { dirtyStatePaths, primaryRoot } from '../gitio.js';
|
|
4
|
-
import { journalRel } from '../journal.js';
|
|
5
4
|
import { renderDecision } from '../recommend.js';
|
|
6
5
|
import { kv } from '../toon.js';
|
|
7
6
|
import { renderRefusal } from '../refusal.js';
|
|
8
|
-
import { completeTxn, pendingTxn, rollbackTxn } from '../txn.js';
|
|
9
|
-
import {
|
|
7
|
+
import { completeTxn, landedJournal, pendingTxn, rollbackTxn } from '../txn.js';
|
|
8
|
+
import { rmSync } from 'node:fs';
|
|
10
9
|
import { join } from 'node:path';
|
|
11
|
-
// D121. The interrupted transaction's own state ranks this: `completeTxn`
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
10
|
+
// D121. The interrupted transaction's own state ranks this: `completeTxn` appends only the
|
|
11
|
+
// journal lines that have not landed, so if every one already landed the write finished and
|
|
12
|
+
// only the commit is missing. `--complete | --rollback` named the two flags and neither the
|
|
13
|
+
// right answer nor the cost of guessing wrong — at the one prompt a human meets while
|
|
14
|
+
// looking at a crash. D166: the count is `landedJournal`'s, the derivation `completeTxn`
|
|
15
|
+
// uses, so a stream that takes several lines reads the same to both.
|
|
16
16
|
export function recoverChoice(root, marker) {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}).length;
|
|
22
|
-
const complete = landed === items.length && items.length > 0;
|
|
17
|
+
const streams = landedJournal(root, marker);
|
|
18
|
+
const total = streams.reduce((n, s) => n + s.lines.length, 0);
|
|
19
|
+
const landed = streams.reduce((n, s) => n + s.landed, 0);
|
|
20
|
+
const complete = landed === total && total > 0;
|
|
23
21
|
return {
|
|
24
22
|
key: 'recover', rule: complete ? 'txn-write-landed' : 'txn-write-partial',
|
|
25
23
|
options: complete
|
|
26
24
|
? [
|
|
27
25
|
{ command: 'witness recover --complete', depth: 'root', runnable: true,
|
|
28
|
-
why: `${landed} of ${
|
|
26
|
+
why: `${landed} of ${total} journal line(s) are already on disk and ${marker.files.length} file(s) are written — the transaction finished and only the state commit is missing; completing makes git match what the journal already records` },
|
|
29
27
|
{ command: 'witness recover --rollback', depth: 'root', runnable: true,
|
|
30
28
|
when: 'you no longer want the operation that was interrupted',
|
|
31
29
|
tradeoff: `reverts ${marker.files.length} file(s) and drops the journal append — the operation must be retaken` },
|
|
32
30
|
]
|
|
33
31
|
: [
|
|
34
32
|
{ command: 'witness recover --rollback', depth: 'root', runnable: true,
|
|
35
|
-
why: `only ${landed} of ${
|
|
33
|
+
why: `only ${landed} of ${total} journal line(s) landed — the transaction was interrupted mid-write, and rolling back to a known state is safer than committing a partial one` },
|
|
36
34
|
{ command: 'witness recover --complete', depth: 'root', runnable: true,
|
|
37
35
|
when: 'you have inspected the files and the partial state is what you want',
|
|
38
36
|
tradeoff: 'commits a transaction that never finished writing; the journal and the tree may disagree' },
|
package/dist/verbs/write.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import { existsSync, readFileSync
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { join, resolve } from 'node:path';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { EXIT } from '../cli.js';
|
|
5
5
|
import { loadConfig } from '../config.js';
|
|
6
|
-
import {
|
|
6
|
+
import { deferralMoves } from '../deferral.js';
|
|
7
|
+
import { canonExit, idRetired, prepareDeparture } from '../departure.js';
|
|
8
|
+
import { designPending, designStamp } from '../design.js';
|
|
7
9
|
import { ID_RE } from '../dsl.js';
|
|
8
10
|
import { serializeDoc, splitDoc, writeDoc } from '../fm.js';
|
|
9
11
|
import { dirtyStatePaths, primaryRoot, stateCommit } from '../gitio.js';
|
|
10
12
|
import { appendEntry, entryLine, journalRel, latestRecap, readStream, streamExists } from '../journal.js';
|
|
11
13
|
import { acquireLock } from '../lock.js';
|
|
12
|
-
import { renderRefusal, v } from '../refusal.js';
|
|
14
|
+
import { humanCall, renderRefusal, v } from '../refusal.js';
|
|
13
15
|
import { baseForSpec, contentAtSha } from '../history.js';
|
|
14
|
-
import { findById, findCycle, loadCanon } from '../scan.js';
|
|
16
|
+
import { findById, findCycle, loadCanon, planFinished, plansOn } from '../scan.js';
|
|
15
17
|
import { canonicalJson, canonicalSha, short } from '../sha.js';
|
|
16
18
|
import { kv } from '../toon.js';
|
|
17
19
|
import { crashPoint, guardTxn, withTxn } from '../txn.js';
|
|
@@ -127,6 +129,13 @@ export async function run(ctx, argv) {
|
|
|
127
129
|
renderRefusal(viol).forEach(ctx.err);
|
|
128
130
|
return EXIT.REFUSED;
|
|
129
131
|
};
|
|
132
|
+
// D166. A create may not take an id that already names something (`idRetired` — the rule
|
|
133
|
+
// `rename` applies to its target, with the one re-use of a spec its abandon unwound).
|
|
134
|
+
if (existing === undefined) {
|
|
135
|
+
const taken = idRetired(root, id, String(manifest.type));
|
|
136
|
+
if (taken)
|
|
137
|
+
return refuseWrite([taken]);
|
|
138
|
+
}
|
|
130
139
|
const built = manifest.type === 'plan'
|
|
131
140
|
? buildPlanMeta(root, id, manifest, recap, canon, existing, body)
|
|
132
141
|
: buildSpecMeta(root, id, manifest, recap, canon, existing, body);
|
|
@@ -141,6 +150,7 @@ export async function run(ctx, argv) {
|
|
|
141
150
|
// meta.supersedes, so this is a no-op for plan writes.
|
|
142
151
|
const supersedes = built.meta.supersedes === undefined ? undefined : String(built.meta.supersedes);
|
|
143
152
|
let reslice;
|
|
153
|
+
let moves = [];
|
|
144
154
|
if (supersedes !== undefined) {
|
|
145
155
|
if (supersedes === id) {
|
|
146
156
|
return refuseWrite([v('supersedes', 'self-supersede', id, 'a different spec')]);
|
|
@@ -150,28 +160,59 @@ export async function run(ctx, argv) {
|
|
|
150
160
|
}
|
|
151
161
|
const target = findById(canon, supersedes);
|
|
152
162
|
if (!target) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
163
|
+
// D166. Any departure is terminal here — a spec its abandon unwound as well as one
|
|
164
|
+
// already superseded: either way there is nothing left to delete. Abandoning the plan
|
|
165
|
+
// a `dangling-parent` refusal names can unwind the very spec this write re-slices.
|
|
166
|
+
if (canonExit(readStream(root, supersedes)) === undefined) {
|
|
167
|
+
return refuseWrite([v('supersedes', 'unknown-supersedes', supersedes, 'an existing spec (or one that already left canon)')]);
|
|
157
168
|
}
|
|
158
169
|
}
|
|
159
170
|
else {
|
|
171
|
+
// D166. Re-slicing re-cuts specs (row 5). Deleting a plan here stranded a live flow, and
|
|
172
|
+
// deleting the principles removed every chore's only parent, with nothing to bring
|
|
173
|
+
// either back.
|
|
174
|
+
if (target.meta.type !== 'spec') {
|
|
175
|
+
return refuseWrite([v('supersedes', 'not-a-spec', `${supersedes} is a ${String(target.meta.type)}`, 'a spec — a plan is withdrawn with witness abandon and principles are amended in place')]);
|
|
176
|
+
}
|
|
160
177
|
const dependents = canon.docs
|
|
161
178
|
.filter((d) => String(d.meta.id) !== id)
|
|
162
179
|
.filter((d) => (d.meta.depends ?? []).includes(supersedes))
|
|
163
180
|
.map((d) => String(d.meta.id));
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
181
|
+
// D166. A plan's `parent` is the other edge into the spec this deletes, and row 62
|
|
182
|
+
// guarded only `depends`: every gate reads the parent, so a plan still moving was left
|
|
183
|
+
// unable to move and impossible to clear (issue #34). A finished plan holds nothing — the
|
|
184
|
+
// successor carries the content forward, and `check` resolves the plan's parent in the
|
|
185
|
+
// departed stream. Whether a moving plan ships first or is withdrawn is a human's call,
|
|
186
|
+
// so the row says so and names no command an agent could run for them.
|
|
187
|
+
const moving = plansOn(canon, supersedes).filter((p) => !planFinished(p)).map((p) => String(p.meta.id));
|
|
188
|
+
// Both edges in ONE refusal: every refusal counts against the first-try rate the
|
|
189
|
+
// dashboard trends, and learning of the second edge only after repairing the first
|
|
190
|
+
// would cost a refusal for nothing.
|
|
191
|
+
const dangling = [
|
|
192
|
+
...(dependents.length > 0 ? [v('supersedes', 'dangling-depends', dependents.join(' '), 'dependents rewritten first — the re-slice rewrites them before deleting')] : []),
|
|
193
|
+
...(moving.length > 0 ? [humanCall(v('supersedes', 'dangling-parent', moving.join(' '), 'those plans finished or withdrawn first — the re-slice deletes their parent and which way each goes is a human\'s call'))] : []),
|
|
194
|
+
];
|
|
195
|
+
if (dangling.length > 0)
|
|
196
|
+
return refuseWrite(dangling);
|
|
197
|
+
reslice = prepareDeparture(root, cfg.value.paths, target, {
|
|
168
198
|
v: 1, t: 'status', artifact: supersedes, from: String(target.meta.status),
|
|
169
199
|
to: 'superseded', cause: 'supersede', by: id,
|
|
170
|
-
};
|
|
171
|
-
|
|
200
|
+
});
|
|
201
|
+
// D166. The superseded spec's open obligations follow its content to the successor —
|
|
202
|
+
// D122's rule, whose premise ("the parent spec, which outlives it") the re-slice ends.
|
|
203
|
+
// A split's later slices find the stream already settled, so its debts go to the first.
|
|
204
|
+
moves = deferralMoves(readStream(root, supersedes), supersedes, id);
|
|
172
205
|
}
|
|
173
206
|
}
|
|
174
207
|
const rel = existing ? existing.rel : `${manifest.type === 'plan' ? cfg.value.paths.plans : cfg.value.paths.specs}/${id}.md`;
|
|
208
|
+
// D166. Nor land on another doc's file: `rename` moves the id and the journal but leaves the
|
|
209
|
+
// path, so `specs/<old>.md` can hold the renamed doc, and a create under the old id overwrote it.
|
|
210
|
+
if (existing === undefined && existsSync(join(root, rel))) {
|
|
211
|
+
const holder = canon.docs.find((d) => d.rel === rel);
|
|
212
|
+
return refuseWrite([v('id', 'path-taken', rel, holder
|
|
213
|
+
? `a new id — ${rel} holds ${String(holder.meta.id)}`
|
|
214
|
+
: `a new id — ${rel} holds a doc canon cannot read (fix or revert it first)`)]);
|
|
215
|
+
}
|
|
175
216
|
const stream = journalRel(effort);
|
|
176
217
|
const unrelated = dirtyStatePaths(root).filter((p) => p !== rel && p !== stream);
|
|
177
218
|
if (unrelated.length) {
|
|
@@ -199,24 +240,21 @@ export async function run(ctx, argv) {
|
|
|
199
240
|
// entry. Row 116's `w` stamp is what caught it; the shape is the one rows 93, 95 and
|
|
200
241
|
// 96 all name, and every other txn writer already calls entryLine.
|
|
201
242
|
const line = entryLine(entry);
|
|
202
|
-
const
|
|
203
|
-
const files = [rel, stream,
|
|
204
|
-
...(reslice ? [reslice.doc.rel, journalRel(reslice.entry.artifact)] : []),
|
|
205
|
-
...(supersededDesignRel && existsSync(join(root, supersededDesignRel)) ? [supersededDesignRel] : []),
|
|
206
|
-
];
|
|
243
|
+
const files = [rel, stream, ...(reslice?.files ?? []), ...(moves.length > 0 ? [journalRel(id)] : [])];
|
|
207
244
|
const res = withTxn(root, {
|
|
208
245
|
op: `write(${id})`, files, journal: { stream: effort, line },
|
|
209
|
-
...(reslice ? { journalMulti: [
|
|
246
|
+
...(reslice ? { journalMulti: [
|
|
247
|
+
{ stream: reslice.entry.artifact, line: entryLine(reslice.entry) },
|
|
248
|
+
...moves.map((m) => ({ stream: m.stream, line: entryLine(m.entry) })),
|
|
249
|
+
] } : {}),
|
|
210
250
|
}, () => {
|
|
211
251
|
writeDoc(join(root, rel), { meta: built.meta, body });
|
|
212
252
|
crashPoint(ctx.env, 'artifact-write');
|
|
213
253
|
if (reslice) {
|
|
214
|
-
|
|
215
|
-
const supersededDesign = designRel(cfg.value.paths, reslice.entry.artifact);
|
|
216
|
-
if (existsSync(join(root, supersededDesign))) {
|
|
217
|
-
unlinkSync(join(root, supersededDesign));
|
|
218
|
-
}
|
|
254
|
+
reslice.remove();
|
|
219
255
|
appendEntry(root, reslice.entry.artifact, reslice.entry);
|
|
256
|
+
for (const m of moves)
|
|
257
|
+
appendEntry(root, m.stream, m.entry);
|
|
220
258
|
crashPoint(ctx.env, 'reslice-commit');
|
|
221
259
|
}
|
|
222
260
|
appendEntry(root, effort, entry);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@popovych.co/witness",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.7",
|
|
4
4
|
"description": "Evidence-gated spec-driven development pipeline for AI coding agents: brainstorm to PR in six stages, where specs are state, plans are motion, and no transition passes without evidence. Drives from Claude Code or Pi.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "witness",
|
|
3
3
|
"description": "Spec-driven development with evidence gates: brainstorm → PR through six stages and five gates. Skills author, the pinned witness CLI writes state and witnesses evidence; gates stop where judgment is owed.",
|
|
4
|
-
"version": "0.14.
|
|
4
|
+
"version": "0.14.7",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Kostiantyn Popovych"
|
|
7
7
|
},
|
|
@@ -10,7 +10,7 @@ The CLI decides, you act. If `$ARGUMENTS` contains `--manual`, append `--manual`
|
|
|
10
10
|
Resolve the CLI once:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
If the repo has no `witness.config.yaml`, run `$WITNESS init` first (one bootstrap commit), then proceed. If init or any verb refuses on a dirty tree, the human commits or cleans it — **never `git commit` yourself, anywhere, at any stage**: the CLI makes its own state commits, and the ship phase makes the sole code commit.
|
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
# context). Silent everywhere that isn't a witness repo; failures swallowed:
|
|
4
4
|
# session start must never hang or die on this.
|
|
5
5
|
[ -f witness.config.yaml ] || exit 0
|
|
6
|
-
${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
6
|
+
${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7} 2>/dev/null || true
|
|
@@ -15,7 +15,7 @@ witness: ends in a confirmed scope recap, writes no document. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|
|
@@ -10,7 +10,7 @@ description: Slice a witness effort's confirmed recap into vertical spec slices,
|
|
|
10
10
|
Resolve the CLI once per session:
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
13
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|
|
@@ -44,7 +44,7 @@ The class comes from the recap. Never ask for it again.
|
|
|
44
44
|
- Thin **vertical** slices: each spec independently shippable, observable end-to-end. Never layers ("the schema", "the client").
|
|
45
45
|
- Each spec answers exactly one question; no two overlap; **one fact, one home**. A fact needed by two slices gets extracted into its own spec, and both `depends` on it.
|
|
46
46
|
- Amending a living spec is the same write with the existing id — its status resets to `draft` (re-approval owed). If the CLI warns the spec has an in-flight child plan, surface that warning at the gate stop.
|
|
47
|
-
- Boundary changes (split, merge, extraction out of a live spec) → the new spec's manifest carries `supersedes: <old-id>`; rewrite every dependent's `depends` in the same effort — the CLI refuses dangling edges.
|
|
47
|
+
- Boundary changes (split, merge, extraction out of a live spec) → the new spec's manifest carries `supersedes: <old-id>`; rewrite every dependent's `depends` in the same effort — the CLI refuses dangling edges. It also refuses `dangling-parent` while a plan of the old spec is still in motion: whether that plan ships first or is abandoned is the human's call, so stop and surface it — never abandon it yourself.
|
|
48
48
|
- Cross-cutting changes → sequenced per-slice amendments ordered via `depends`, expand-contract style (accept both → switch → drop old), each step independently shippable.
|
|
49
49
|
- Self-check totality before gating: every goal covered by ≥ 1 spec, every spec covers ≥ 1 goal.
|
|
50
50
|
|
|
@@ -87,7 +87,7 @@ Get these right the first time — the dashboard trends your first-try valid rat
|
|
|
87
87
|
- `criteria`: ≥ 1 entry, unique ids, each exactly one of `test` (value must be `@spec:<this-spec-id>`) or `cmd`. A criterion that cannot fail is not a criterion.
|
|
88
88
|
- `covers`: the recap goal ids this spec honestly advances — the critic judges the mapping's quality.
|
|
89
89
|
- Body: exactly the `## Motivation` and `## Behavior` headings; behavior-only — internal interfaces never appear in a spec.
|
|
90
|
-
- `id`: `[a-z0-9-]+`; new ids land at `specs/<id>.md` (or the repo's configured specs dir).
|
|
90
|
+
- `id`: `[a-z0-9-]+`; new ids land at `specs/<id>.md` (or the repo's configured specs dir). An id that already names something is refused `id-taken` — a superseded spec, an effort's slug, witness's `floor` — and so is a path another doc holds (`path-taken`): choose a new id.
|
|
91
91
|
- `needs`: external prerequisites only, machine-checkable (`env:` / `cmd:` / `manual:` + `satisfied`).
|
|
92
92
|
- `ui`: set `ui: true` in the manifest **iff** the slice changes browser-rendered or interactive behavior (a screen, a form, client-side interaction). A browser-behavior slice **without** the flag, or a flag on a non-UI slice, is a slicing-critic finding. A `ui` spec earns a **design stage** before its plan — the look is approved before code (Decision 69).
|
|
93
93
|
|
|
@@ -16,7 +16,7 @@ No text copied verbatim; the design-critic lens is new material. -->
|
|
|
16
16
|
Resolve the CLI once per session:
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
19
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|
|
@@ -15,7 +15,7 @@ tests carry the parent spec's tag in their NAME. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|
|
@@ -15,7 +15,7 @@ parent's criteria. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|
|
@@ -15,7 +15,7 @@ model can. See NOTICE.md. -->
|
|
|
15
15
|
Resolve the CLI once per session:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.
|
|
18
|
+
WITNESS="${WITNESS_BIN:-npx -y @popovych.co/witness@0.14.7}"
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
- **Render the CLI's decision output verbatim and in full — every line, unmodified.** Never print a command set you remember; never recompose, reformat, summarise or reorder what the CLI emitted. Which decisions are live, how they rank, and what each costs are the CLI's answers, and they change with the round, the bound, the repair grant and the content sha — a remembered set is wrong in more states than it is right.
|