@popovych.co/witness 0.14.5 → 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/calibrate.js +13 -3
- package/dist/deferral.js +12 -0
- package/dist/departure.js +147 -0
- package/dist/drive.js +11 -3
- package/dist/gitio.js +11 -0
- package/dist/probe.js +14 -8
- package/dist/refusal.js +4 -1
- package/dist/reviewer.js +11 -2
- package/dist/scan.js +17 -0
- package/dist/spawn.js +107 -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/calibrate.js
CHANGED
|
@@ -9,9 +9,10 @@ import { loadConfig } from './config.js';
|
|
|
9
9
|
import { changedFiles, diffBase, evidenceForDiff } from './evidence.js';
|
|
10
10
|
import { readDoc, splitDoc, writeDoc } from './fm.js';
|
|
11
11
|
import { git, stateCommit } from './gitio.js';
|
|
12
|
-
import { ok, refuse, v } from './refusal.js';
|
|
12
|
+
import { ok, refuse, renderRefusal, v } from './refusal.js';
|
|
13
13
|
import { PROMPT_NAMES, invokeReviewer, parseVerdictText, resolvePrompt } from './reviewer.js';
|
|
14
14
|
import { findById, loadCanon } from './scan.js';
|
|
15
|
+
import { resolveSpawn } from './spawn.js';
|
|
15
16
|
import { anchorMenu, parseVerdict, verdictViolations } from './verdict.js';
|
|
16
17
|
import { createWorktree } from './worktree.js';
|
|
17
18
|
export function calibrationDir() {
|
|
@@ -478,11 +479,20 @@ export async function runPlanSeed(ctx, harness, model, seed, extras) {
|
|
|
478
479
|
// a pure-pi user, the residual the routing decision exists to kill.
|
|
479
480
|
export async function defaultAgent(ctx, harness, worktree, prompt) {
|
|
480
481
|
const { cmd, args, env } = harness.worker.spawn(prompt);
|
|
481
|
-
|
|
482
|
+
// Row 164. The prompt rides argv, and the implement seed's is a whole SKILL.md: on
|
|
483
|
+
// Windows no `.cmd` shim can receive its line breaks. Say so — a worker that never
|
|
484
|
+
// started otherwise reads, one step later, as an agent that made no diff.
|
|
485
|
+
const run = resolveSpawn(cmd, args, { ...ctx.env, ...env, WITNESS_BIN: `node ${join(dirname(fileURLToPath(import.meta.url)), 'bin.js')}` });
|
|
486
|
+
if (!run.ok) {
|
|
487
|
+
renderRefusal(run.violations).forEach((l) => ctx.err(l));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
spawnSync(run.value.file, run.value.args, {
|
|
482
491
|
cwd: worktree,
|
|
483
|
-
env:
|
|
492
|
+
env: run.value.env,
|
|
484
493
|
timeout: 900_000,
|
|
485
494
|
stdio: 'ignore',
|
|
495
|
+
windowsVerbatimArguments: run.value.windowsVerbatimArguments,
|
|
486
496
|
});
|
|
487
497
|
}
|
|
488
498
|
export async function runImplementSeed(ctx, harness, seed, agent) {
|
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/drive.js
CHANGED
|
@@ -6,6 +6,7 @@ import { EXIT, main } from './cli.js';
|
|
|
6
6
|
import { resolveJudge } from './harness.js';
|
|
7
7
|
import { renderRefusal } from './refusal.js';
|
|
8
8
|
import { loadCanon } from './scan.js';
|
|
9
|
+
import { resolveSpawn } from './spawn.js';
|
|
9
10
|
import { computeNext, flowAction, resolveFlow } from './verbs/next.js';
|
|
10
11
|
// A conversation is a stage whose product IS the exchange with a human: brainstorm has no
|
|
11
12
|
// artifact to author headlessly, and design authoring/showing needs eyes on a rendered
|
|
@@ -69,7 +70,7 @@ const EXIT_GRACE_MS = 2_000;
|
|
|
69
70
|
// This CLI, for the child. A spawned session shells out to `witness` through
|
|
70
71
|
// ${WITNESS_BIN:-npx …} in every stage skill, and an unset value means the child resolves
|
|
71
72
|
// the PUBLISHED version while its parent runs an unpublished one — which the state floor
|
|
72
|
-
// then refuses (cli-behind-state). calibrate.ts:
|
|
73
|
+
// then refuses (cli-behind-state). calibrate.ts:520 sets it for its worker for exactly
|
|
73
74
|
// this reason. An explicitly set value wins: a session that already knows its bin is
|
|
74
75
|
// stating a fact drive has no better answer for.
|
|
75
76
|
function localBin(ctx) {
|
|
@@ -115,14 +116,21 @@ export async function spawnSession(step, cfg, ctx, spawnN) {
|
|
|
115
116
|
// a scripted fake that performs real CLI acts. Never documented for users — a spawn
|
|
116
117
|
// binary chosen by ambient env is exactly the identity confusion row 90 removed.
|
|
117
118
|
const bin = ctx.env.WITNESS_DRIVE_AGENT_BIN ?? cmd;
|
|
119
|
+
// Row 164: on Windows the agent CLI may be a `.cmd` shim, which only cmd.exe can start.
|
|
120
|
+
const run = resolveSpawn(bin, args, { ...ctx.env, ...env, WITNESS_BIN: localBin(ctx) });
|
|
121
|
+
if (!run.ok) {
|
|
122
|
+
renderRefusal(run.violations).forEach((l) => ctx.err(l));
|
|
123
|
+
return 'spawn-failed';
|
|
124
|
+
}
|
|
118
125
|
const prefix = `[${spawnN} ${step.stage ?? 'drive'}/${step.target ?? step.home.split('/').pop() ?? '-'}] `;
|
|
119
126
|
const out = lineSink(prefix, (l) => ctx.out(l));
|
|
120
127
|
const err = lineSink(prefix, (l) => ctx.out(l));
|
|
121
128
|
return await new Promise((resolve) => {
|
|
122
|
-
const child = spawn(
|
|
129
|
+
const child = spawn(run.value.file, run.value.args, {
|
|
123
130
|
cwd: step.home,
|
|
124
|
-
env:
|
|
131
|
+
env: run.value.env,
|
|
125
132
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
133
|
+
windowsVerbatimArguments: run.value.windowsVerbatimArguments,
|
|
126
134
|
});
|
|
127
135
|
let outcome = 'exited';
|
|
128
136
|
let settled = false;
|
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/probe.js
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { resolveSpawn } from './spawn.js';
|
|
2
3
|
// Revision 3. The old form passed no `env`, so it silently answered about process.env
|
|
3
|
-
// while reviewer.ts:
|
|
4
|
+
// while reviewer.ts:133 spawns `claude` with `env: ctx.env` — two different questions,
|
|
4
5
|
// one answer. It also made the finding untestable: helpers.ts runs main() in-process,
|
|
5
6
|
// so a test can control ctx.env and nothing else.
|
|
7
|
+
//
|
|
8
|
+
// Row 164, the same argument one level down: the probe starts the binary through
|
|
9
|
+
// resolveSpawn, exactly as a gate will — a probe that resolved differently would call a
|
|
10
|
+
// `.cmd` shim missing while the gate ran it. spawnSync, not execFileSync, whose typings
|
|
11
|
+
// omit windowsVerbatimArguments; the question is unchanged: did it start and exit 0.
|
|
6
12
|
export function probe(cmd, args, env) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return true;
|
|
10
|
-
}
|
|
11
|
-
catch {
|
|
13
|
+
const run = resolveSpawn(cmd, args, env);
|
|
14
|
+
if (!run.ok)
|
|
12
15
|
return false;
|
|
13
|
-
|
|
16
|
+
const r = spawnSync(run.value.file, run.value.args, {
|
|
17
|
+
stdio: 'ignore', env: run.value.env, windowsVerbatimArguments: run.value.windowsVerbatimArguments,
|
|
18
|
+
});
|
|
19
|
+
return r.error === undefined && r.status === 0;
|
|
14
20
|
}
|
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/reviewer.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { validatePin } from './harness.js';
|
|
7
7
|
import { ok, refuse, v } from './refusal.js';
|
|
8
|
+
import { resolveSpawn } from './spawn.js';
|
|
8
9
|
export const PROMPT_NAMES = [
|
|
9
10
|
'slicing-critic', 'plan-critic', 'code-reviewer', 'silent-failure-hunter',
|
|
10
11
|
'type-design', 'pr-test', 'drift-reviewer', 'design-critic', 'design-reviewer',
|
|
@@ -95,15 +96,23 @@ export function invokeReviewer(ctx, harness, opts) {
|
|
|
95
96
|
pin = pinR.value;
|
|
96
97
|
}
|
|
97
98
|
const { cmd, args, env } = harness.reviewer.spawn(pin, opts.extensions);
|
|
99
|
+
// Row 164: on Windows the CLI may be a `.cmd` shim, which only cmd.exe can start. The
|
|
100
|
+
// prompt rides stdin, so the shim's second parse of the command line never sees it. A
|
|
101
|
+
// reviewer that cannot be started is an invocation failure on every platform: the
|
|
102
|
+
// resolution's own got/want, under the rule a missing binary has always reported.
|
|
103
|
+
const run = resolveSpawn(cmd, args, { ...ctx.env, ...env });
|
|
104
|
+
if (!run.ok)
|
|
105
|
+
return refuse(run.violations.map((x) => ({ ...x, rule: 'reviewer-invocation' })));
|
|
98
106
|
const timeout = opts.timeoutMs ?? REVIEWER_TIMEOUT_MS;
|
|
99
107
|
for (let attempt = 0;; attempt += 1) {
|
|
100
|
-
const r = spawnSync(
|
|
108
|
+
const r = spawnSync(run.value.file, run.value.args, {
|
|
101
109
|
cwd: opts.cwd,
|
|
102
|
-
env:
|
|
110
|
+
env: run.value.env,
|
|
103
111
|
input: opts.prompt,
|
|
104
112
|
encoding: 'utf8',
|
|
105
113
|
timeout,
|
|
106
114
|
maxBuffer: 64 * 1024 * 1024,
|
|
115
|
+
windowsVerbatimArguments: run.value.windowsVerbatimArguments,
|
|
107
116
|
});
|
|
108
117
|
if (r.error) {
|
|
109
118
|
// A stall is NOT the missing-binary case. Reporting it as one sent the last
|
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) {
|