@sabaiway/agent-workflow-kit 5.10.0 → 5.11.0
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/CHANGELOG.md +91 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/commit-guard.md +11 -8
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +32 -10
- package/references/modes/worktrees.md +47 -3
- package/tools/advisor-matrix.mjs +165 -0
- package/tools/commands.mjs +2 -2
- package/tools/commit-guard.mjs +74 -17
- package/tools/core-evidence.mjs +10 -0
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +68 -14
- package/tools/flow-check-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +20 -5
- package/tools/observation-builder.mjs +123 -0
- package/tools/satellite-locator.mjs +179 -0
- package/tools/worktree-handoff-return.mjs +369 -0
- package/tools/worktree-prompt.mjs +190 -0
- package/tools/worktrees-record.mjs +171 -0
- package/tools/worktrees.mjs +308 -297
package/tools/worktrees.mjs
CHANGED
|
@@ -24,20 +24,35 @@ import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './re
|
|
|
24
24
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
25
25
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
26
26
|
import { isFinalCapableDeclaration } from './run-gates.mjs';
|
|
27
|
+
import {
|
|
28
|
+
WORKTREES_STOP, stop, EXIT, handoffBasename, recordValue, hasControlByte, displayValue,
|
|
29
|
+
composeProvisionRecordSection, composeLandingValue, composeHandoffStub,
|
|
30
|
+
locateProvisionRecordSection, parseProvisionRecord,
|
|
31
|
+
} from './worktrees-record.mjs';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_BRANCH_PREFIX, listWorktrees, classifyNodeNoFollow, scanPlansDir, findSatelliteEntry,
|
|
34
|
+
readSatelliteIdentity,
|
|
35
|
+
} from './satellite-locator.mjs';
|
|
36
|
+
import { composeSatellitePrompt, resolveSeededPlan } from './worktree-prompt.mjs';
|
|
37
|
+
|
|
38
|
+
// The record format and the satellite locator now live in leaves the dispatch side reads too — but
|
|
39
|
+
// every name they took away is re-exported here, so no import site and no asserted error `code`
|
|
40
|
+
// moved when they left.
|
|
41
|
+
export {
|
|
42
|
+
WORKTREES_STOP, stop, EXIT, handoffBasename, QUEUE_SHARED_RULE, composeHandoffStub,
|
|
43
|
+
parseProvisionRecord,
|
|
44
|
+
} from './worktrees-record.mjs';
|
|
45
|
+
export {
|
|
46
|
+
DEFAULT_BRANCH_PREFIX, parseWorktreeList, findSatelliteEntry, readSatelliteIdentity,
|
|
47
|
+
} from './satellite-locator.mjs';
|
|
27
48
|
|
|
28
|
-
export const WORKTREES_STOP = 'WORKTREES_STOP';
|
|
29
|
-
export const stop = (message, fields = {}) =>
|
|
30
|
-
Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'WorktreesStop', code: WORKTREES_STOP, ...fields });
|
|
31
49
|
const usageStop = (message) => stop(message, { exitCode: EXIT.usage });
|
|
32
50
|
const errorText = (error) => String(error?.message ?? error).replace(/^\[agent-workflow-kit\] /, '');
|
|
33
51
|
const composeFailure = (primary, secondaryName, secondary) =>
|
|
34
52
|
stop(`${errorText(primary)}; ${secondaryName} failed: ${errorText(secondary)}`);
|
|
35
53
|
|
|
36
|
-
export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
|
|
37
54
|
export const CONFIG_REL = 'docs/ai/worktrees.json';
|
|
38
55
|
export const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
39
|
-
export const DEFAULT_BRANCH_PREFIX = 'aw/';
|
|
40
|
-
export const handoffBasename = (slug) => `handoff-${slug}.md`;
|
|
41
56
|
const WORKTREES_TOOL_ABS = fileURLToPath(import.meta.url);
|
|
42
57
|
const WORKTREES_TOOL_DIR = dirname(WORKTREES_TOOL_ABS);
|
|
43
58
|
|
|
@@ -67,6 +82,10 @@ const USAGE = [
|
|
|
67
82
|
` the ${CONFIG_REL} "parentDir" setting when present). --install only PRINTS the`,
|
|
68
83
|
' install command. --resume completes a half-done provision (identity-checked).',
|
|
69
84
|
' list show every worktree of this repo: slug, path, branch, base OID, dirty, handoff.',
|
|
85
|
+
' prompt <slug>',
|
|
86
|
+
' re-print the satellite cold-start prompt for a provisioned worktree: where it is,',
|
|
87
|
+
' what MAIN answers now (derived LIVE, a stale record value is named), the handoff as',
|
|
88
|
+
' the one return channel, and the bars. Read-only — it writes nothing.',
|
|
70
89
|
' land <slug> --prepare',
|
|
71
90
|
' stage the satellite diff onto a clean main (no commit — the commit stays a',
|
|
72
91
|
' dialogue ask). Refuses divergence, incomplete satellite state, or a dirty main.',
|
|
@@ -74,13 +93,21 @@ const USAGE = [
|
|
|
74
93
|
' remove a LANDED worktree (fail-closed verification); --abandon is the one',
|
|
75
94
|
' destructive arm and destroys unlanded work.',
|
|
76
95
|
'',
|
|
77
|
-
'The slug is REQUIRED and positional on provision/land/cleanup: lowercase letters, digits,',
|
|
96
|
+
'The slug is REQUIRED and positional on provision/prompt/land/cleanup: lowercase letters, digits,',
|
|
78
97
|
'hyphens, max 64 chars, letter/digit first. Exit codes: 0 ok / 1 refusal / 2 usage.',
|
|
79
98
|
].join('\n');
|
|
80
99
|
|
|
81
100
|
// ── deps + git plumbing (every seam injectable for hermetic tests) ─────────────────────
|
|
82
101
|
|
|
83
|
-
const fsOf = (deps) =>
|
|
102
|
+
const fsOf = (deps) => {
|
|
103
|
+
const fs = fsSeams(deps);
|
|
104
|
+
// The ONE content read, bound to THIS seam set and handed to the locator leaf, which owns no read
|
|
105
|
+
// door of its own — one body, one place, provable by the tripwires below it.
|
|
106
|
+
fs.readFileNoFollow = (abs) => readFileNoFollow(fs, abs);
|
|
107
|
+
return fs;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const fsSeams = (deps) => ({
|
|
84
111
|
lstat: deps.lstat ?? lstatSync,
|
|
85
112
|
mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
|
|
86
113
|
mkdirPlain: deps.mkdirPlain ?? mkdirSync,
|
|
@@ -133,43 +160,9 @@ const lstatNoFollow = (lstat, path) => {
|
|
|
133
160
|
}
|
|
134
161
|
};
|
|
135
162
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return { stat: fs.lstat(path) };
|
|
140
|
-
} catch (error) {
|
|
141
|
-
return error?.code === 'ENOENT'
|
|
142
|
-
? { stat: null }
|
|
143
|
-
: { error: error?.code ?? 'fs error' };
|
|
144
|
-
}
|
|
145
|
-
})();
|
|
146
|
-
if (node.error) return { kind: 'error', error: node.error };
|
|
147
|
-
if (node.stat === null) return { kind: 'absent' };
|
|
148
|
-
if (!node.stat.isSymbolicLink()) {
|
|
149
|
-
if (node.stat.isDirectory()) return { kind: 'plain-directory', stat: node.stat };
|
|
150
|
-
if (node.stat.isFile()) return { kind: 'regular-file', stat: node.stat };
|
|
151
|
-
return { kind: 'special', stat: node.stat };
|
|
152
|
-
}
|
|
153
|
-
const realPath = (() => {
|
|
154
|
-
try {
|
|
155
|
-
return { path: fs.realpath(path) };
|
|
156
|
-
} catch (error) {
|
|
157
|
-
return { error: error?.code ?? 'fs error' };
|
|
158
|
-
}
|
|
159
|
-
})();
|
|
160
|
-
if (realPath.error) return { kind: 'symlink-unresolvable', error: realPath.error };
|
|
161
|
-
const target = (() => {
|
|
162
|
-
try {
|
|
163
|
-
return { stat: fs.lstat(realPath.path) };
|
|
164
|
-
} catch (error) {
|
|
165
|
-
return { error: error?.code ?? 'fs error' };
|
|
166
|
-
}
|
|
167
|
-
})();
|
|
168
|
-
if (target.error) return { kind: 'symlink-unresolvable', error: target.error };
|
|
169
|
-
if (target.stat.isDirectory()) return { kind: 'symlink-to-directory', realPath: realPath.path, stat: node.stat };
|
|
170
|
-
if (target.stat.isFile()) return { kind: 'symlink-to-file', realPath: realPath.path, stat: node.stat };
|
|
171
|
-
return { kind: 'symlink-to-special', realPath: realPath.path, stat: node.stat };
|
|
172
|
-
};
|
|
163
|
+
// classifyNodeNoFollow moved to satellite-locator.mjs; the ONE content-read door stays HERE, and the
|
|
164
|
+
// locator receives it through its injected fs seam — a second body anywhere is exactly what the
|
|
165
|
+
// door tripwires exist to prevent.
|
|
173
166
|
|
|
174
167
|
// The ONE content-read door: no-follow lstat, then an O_NOFOLLOW|O_NONBLOCK descriptor with an
|
|
175
168
|
// fstat recheck — a node swapped after the lstat can neither follow a link nor block on a FIFO.
|
|
@@ -541,36 +534,8 @@ export const realpathThroughExistingParent = (target, deps = {}) => {
|
|
|
541
534
|
|
|
542
535
|
// ── roots + worktree registry ──────────────────────────────────────────────────────────
|
|
543
536
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
let fields = [];
|
|
547
|
-
const finishEntry = () => {
|
|
548
|
-
if (fields.length === 0) return;
|
|
549
|
-
const entry = { path: null, head: null, branch: null, detached: false, prunable: false, bare: false };
|
|
550
|
-
for (const field of fields) {
|
|
551
|
-
if (field.startsWith('worktree ')) entry.path = field.slice('worktree '.length);
|
|
552
|
-
else if (field.startsWith('HEAD ')) entry.head = field.slice('HEAD '.length);
|
|
553
|
-
else if (field.startsWith('branch ')) entry.branch = field.slice('branch '.length);
|
|
554
|
-
else if (field === 'detached') entry.detached = true;
|
|
555
|
-
else if (field === 'bare') entry.bare = true;
|
|
556
|
-
else if (field === 'prunable' || field.startsWith('prunable ')) entry.prunable = true;
|
|
557
|
-
}
|
|
558
|
-
if (entry.path !== null) entries.push(entry);
|
|
559
|
-
fields = [];
|
|
560
|
-
};
|
|
561
|
-
for (const field of String(text).split('\0')) {
|
|
562
|
-
if (field === '') finishEntry();
|
|
563
|
-
else fields.push(field);
|
|
564
|
-
}
|
|
565
|
-
finishEntry();
|
|
566
|
-
return entries;
|
|
567
|
-
};
|
|
568
|
-
|
|
569
|
-
const listWorktrees = (git, cwd) => {
|
|
570
|
-
const r = git(['worktree', 'list', '--porcelain', '-z'], cwd);
|
|
571
|
-
if (r.status !== 0) throw stop(`git worktree list failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
572
|
-
return parseWorktreeList(r.stdout);
|
|
573
|
-
};
|
|
537
|
+
// parseWorktreeList and listWorktrees moved to satellite-locator.mjs with the resolver that needs
|
|
538
|
+
// them; both are re-exported above, so every caller and test import site is unchanged.
|
|
574
539
|
|
|
575
540
|
// The MAIN worktree is the first `git worktree list --porcelain -z` entry; provision/land/cleanup
|
|
576
541
|
// refuse to run from inside a linked worktree.
|
|
@@ -994,33 +959,8 @@ const assertTargetOutsideSources = ({ targetReal, sources }) => {
|
|
|
994
959
|
|
|
995
960
|
// ── the shared plans-chain scanner (resume identity + list ride the SAME no-follow walk) ─
|
|
996
961
|
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
// ANY stat failure (not just readdir) renders honestly — list must never crash on a bad node.
|
|
1000
|
-
const scanPlansDir = ({ wtRoot, fs }) => {
|
|
1001
|
-
if (classifyNodeNoFollow(wtRoot, fs).kind !== 'plain-directory') return { state: 'unreadable' };
|
|
1002
|
-
const docs = classifyNodeNoFollow(join(wtRoot, 'docs'), fs);
|
|
1003
|
-
if (docs.kind === 'absent') return { state: 'absent' };
|
|
1004
|
-
if (docs.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
1005
|
-
const plans = classifyNodeNoFollow(join(wtRoot, PLANS_REL), fs);
|
|
1006
|
-
if (plans.kind === 'absent') return { state: 'absent' };
|
|
1007
|
-
if (plans.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
1008
|
-
let names;
|
|
1009
|
-
try {
|
|
1010
|
-
names = fs.readdir(join(wtRoot, PLANS_REL));
|
|
1011
|
-
} catch {
|
|
1012
|
-
return { state: 'unreadable' };
|
|
1013
|
-
}
|
|
1014
|
-
const handoffs = [];
|
|
1015
|
-
const nonRegular = [];
|
|
1016
|
-
for (const n of names) {
|
|
1017
|
-
if (!/^handoff-.+\.md$/.test(n)) continue;
|
|
1018
|
-
const cand = classifyNodeNoFollow(join(wtRoot, PLANS_REL, n), fs);
|
|
1019
|
-
if (cand.kind !== 'regular-file') nonRegular.push(n);
|
|
1020
|
-
else handoffs.push(n);
|
|
1021
|
-
}
|
|
1022
|
-
return { state: 'ok', handoffs, nonRegular };
|
|
1023
|
-
};
|
|
962
|
+
// scanPlansDir moved to satellite-locator.mjs — the resolver is its heaviest caller, and the
|
|
963
|
+
// dispatch side needs the same walk to find a satellite without importing this tool.
|
|
1024
964
|
|
|
1025
965
|
// Resume writes NOTHING before this: the existing handoff must be the live identity.
|
|
1026
966
|
const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
|
|
@@ -1030,24 +970,24 @@ const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
|
|
|
1030
970
|
}
|
|
1031
971
|
if (scan.state === 'absent') return;
|
|
1032
972
|
if (scan.nonRegular.length > 0) {
|
|
1033
|
-
throw stop(`--resume: handoff-named entr${scan.nonRegular.length === 1 ? 'y is' : 'ies are'} not regular file(s): ${scan.nonRegular.join(', ')} — fix before resuming`);
|
|
973
|
+
throw stop(`--resume: handoff-named entr${scan.nonRegular.length === 1 ? 'y is' : 'ies are'} not regular file(s): ${scan.nonRegular.map(displayValue).join(', ')} — fix before resuming`);
|
|
1034
974
|
}
|
|
1035
975
|
if (scan.handoffs.length === 0) return;
|
|
1036
976
|
if (scan.handoffs.length > 1) {
|
|
1037
|
-
throw stop(`--resume: multiple handoff files found (${scan.handoffs.join(', ')}) — exactly one may exist`);
|
|
977
|
+
throw stop(`--resume: multiple handoff files found (${scan.handoffs.map(displayValue).join(', ')}) — exactly one may exist`);
|
|
1038
978
|
}
|
|
1039
979
|
const name = scan.handoffs[0];
|
|
1040
980
|
if (name !== handoffBasename(slug)) {
|
|
1041
|
-
throw stop(`--resume identity mismatch: the existing handoff is ${name}, the live slug is ${slug} (${handoffBasename(slug)})`);
|
|
981
|
+
throw stop(`--resume identity mismatch: the existing handoff is ${displayValue(name)}, the live slug is ${slug} (${handoffBasename(slug)})`);
|
|
1042
982
|
}
|
|
1043
983
|
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, name));
|
|
1044
|
-
if (!rf.bytes) throw stop(`--resume: the handoff ${name} is not readable as a regular file — fix it before resuming`);
|
|
984
|
+
if (!rf.bytes) throw stop(`--resume: the handoff ${displayValue(name)} is not readable as a regular file — fix it before resuming`);
|
|
1045
985
|
const record = parseProvisionRecord(String(rf.bytes));
|
|
1046
986
|
if (record.slug !== slug) {
|
|
1047
|
-
throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug
|
|
987
|
+
throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}`);
|
|
1048
988
|
}
|
|
1049
989
|
if (record.branch !== branch) {
|
|
1050
|
-
throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch
|
|
990
|
+
throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}`);
|
|
1051
991
|
}
|
|
1052
992
|
};
|
|
1053
993
|
|
|
@@ -1056,12 +996,12 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
|
1056
996
|
if (inFlight.length === 0 || (inFlight.length === 1 && inFlight[0] === seedName)) return;
|
|
1057
997
|
if (inFlight.length === 1) {
|
|
1058
998
|
throw stop(
|
|
1059
|
-
`--resume plan mismatch: found [${inFlight[0]}], expected [${seedName}] or no in-flight plan — ` +
|
|
1060
|
-
`re-run with --as ${inFlight[0]}, or remove the existing plan by hand`,
|
|
999
|
+
`--resume plan mismatch: found [${displayValue(inFlight[0])}], expected [${displayValue(seedName)}] or no in-flight plan — ` +
|
|
1000
|
+
`re-run with --as ${displayValue(inFlight[0])}, or remove the existing plan by hand`,
|
|
1061
1001
|
);
|
|
1062
1002
|
}
|
|
1063
1003
|
throw stop(
|
|
1064
|
-
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
1004
|
+
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.map(displayValue).join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
1065
1005
|
);
|
|
1066
1006
|
};
|
|
1067
1007
|
|
|
@@ -1222,8 +1162,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
|
|
|
1222
1162
|
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
|
|
1223
1163
|
if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
|
|
1224
1164
|
const record = parseProvisionRecord(String(rf.bytes));
|
|
1225
|
-
if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug
|
|
1226
|
-
if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch
|
|
1165
|
+
if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}` };
|
|
1166
|
+
if (record.branch !== branch) return { binds: false, reason: `the record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}` };
|
|
1227
1167
|
return { binds: true, reason: null };
|
|
1228
1168
|
} catch (err) {
|
|
1229
1169
|
return { binds: false, reason: errorText(err) };
|
|
@@ -1287,8 +1227,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
|
|
|
1287
1227
|
// The orientation facts a fresh satellite session cannot derive from its own checkout. They are
|
|
1288
1228
|
// CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
|
|
1289
1229
|
export const QUEUE_BASENAME = 'queue.md';
|
|
1290
|
-
|
|
1291
|
-
|
|
1230
|
+
// QUEUE_SHARED_RULE travels with the composer that emits it (worktrees-record.mjs) and is
|
|
1231
|
+
// re-exported above; LANDING_FROM_MAIN stays where its only user is.
|
|
1292
1232
|
export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
|
|
1293
1233
|
export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declares no dependencies';
|
|
1294
1234
|
// The recorded node_modules mode for that same verdict: provision neither advised nor created a
|
|
@@ -1431,108 +1371,16 @@ const verifyPlacedPaths = ({ git, wtRoot, members }) => {
|
|
|
1431
1371
|
if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
|
|
1432
1372
|
};
|
|
1433
1373
|
|
|
1434
|
-
// The record is LINE-oriented and is parsed back for IDENTITY, so a value carrying a control byte
|
|
1435
|
-
// is refused rather than written: a newline spills a second line the parser reads as a real field
|
|
1436
|
-
// (`- include:` is exempt from the duplicate-identity STOP, and an `## …` spill truncates or bricks
|
|
1437
|
-
// the whole section). Values reach here from the repo ROOT path and from --include, both of which
|
|
1438
|
-
// may legally carry a newline on POSIX — so the guard is the only thing between them and a forged
|
|
1439
|
-
// record. U+2028/U+2029 ride the same refusal: they are line terminators to the JS regex `.` but
|
|
1440
|
-
// not to String.split('\n'), so such a value WRITES fine and is then silently DROPPED on read —
|
|
1441
|
-
// a lost field with no error, which is the one outcome this codebase never allows.
|
|
1442
|
-
// Fail closed: refuse to write, never sanitize silently.
|
|
1443
|
-
const RECORD_CONTROL_BYTE = /[\u0000-\u001F\u007F\u2028\u2029]/;
|
|
1444
|
-
const recordValue = (name, value) => {
|
|
1445
|
-
const text = String(value);
|
|
1446
|
-
if (RECORD_CONTROL_BYTE.test(text)) {
|
|
1447
|
-
throw stop(`handoff record: the ${name} value carries a control character (newline/CR/NUL) — refusing to write a record whose fields could be forged by an injected line`);
|
|
1448
|
-
}
|
|
1449
|
-
// The parser `.trim()`s every value on read, and String.prototype.trim strips UNICODE whitespace
|
|
1450
|
-
// — so an edge space (a Unicode one is legal even in a git branch name) writes fine and reads
|
|
1451
|
-
// back as a DIFFERENT identity, stranding the worktree behind a record that no longer matches.
|
|
1452
|
-
if (text !== text.trim()) {
|
|
1453
|
-
throw stop(`handoff record: the ${name} value carries leading or trailing whitespace, which the record trims on read — the identity would change across a write→read round-trip: ${JSON.stringify(text)}`);
|
|
1454
|
-
}
|
|
1455
|
-
return text;
|
|
1456
|
-
};
|
|
1457
|
-
|
|
1458
|
-
// An OPTIONAL field is omitted when absent, never rendered as "null": a record written by an
|
|
1459
|
-
// earlier kit is re-composed from its PARSED form at every refresh (land --prepare), so a field
|
|
1460
|
-
// that kit never wrote must survive the round-trip as absence, not as a literal null string.
|
|
1461
|
-
const optionalField = (name, value) => (value == null ? [] : [`- ${name}: ${recordValue(name, value)}`]);
|
|
1462
|
-
|
|
1463
|
-
const composeProvisionRecordSection = ({ slug, branch, includes, nodeModules, vscode, install = null, sharedQueue = null, landing = null, prepared = null }) => [
|
|
1464
|
-
'## Provision record',
|
|
1465
|
-
'',
|
|
1466
|
-
`- slug: ${recordValue('slug', slug)}`,
|
|
1467
|
-
`- branch: ${recordValue('branch', branch)}`,
|
|
1468
|
-
...(includes.length === 0 ? ['- include: (none)'] : includes.map((p) => `- include: ${recordValue('include', p)}`)),
|
|
1469
|
-
`- node_modules: ${recordValue('node_modules', nodeModules)}`,
|
|
1470
|
-
`- vscode-settings: ${recordValue('vscode-settings', vscode)}`,
|
|
1471
|
-
...optionalField('install', install),
|
|
1472
|
-
...optionalField('shared-queue', sharedQueue),
|
|
1473
|
-
...optionalField('landing', landing),
|
|
1474
|
-
...optionalField('prepared-tree', prepared),
|
|
1475
|
-
'',
|
|
1476
|
-
// The rule says "at the absolute path above", so it ships only WITH that path: a record from an
|
|
1477
|
-
// earlier kit carries no shared-queue field, and a rule pointing at nothing is worse than silence.
|
|
1478
|
-
...(sharedQueue == null ? [] : [QUEUE_SHARED_RULE, '']),
|
|
1479
|
-
].join('\n');
|
|
1480
|
-
|
|
1481
|
-
export const composeHandoffStub = (fields) => [
|
|
1482
|
-
`# Handoff — ${fields.slug}`,
|
|
1483
|
-
'',
|
|
1484
|
-
'provisioned, nothing done yet',
|
|
1485
|
-
'',
|
|
1486
|
-
composeProvisionRecordSection(fields),
|
|
1487
|
-
].join('\n');
|
|
1488
|
-
|
|
1489
|
-
const ATX_SECTION_HEADING = /^ {0,3}#{1,2} /;
|
|
1490
|
-
|
|
1491
|
-
const locateProvisionRecordSection = (text) => {
|
|
1492
|
-
const source = String(text);
|
|
1493
|
-
const lines = [...source.matchAll(/.*(?:\r?\n|$)/g)].filter((match) => match[0] !== '');
|
|
1494
|
-
const headings = lines.filter((match) => match[0].replace(/\r?\n$/, '').trim() === '## Provision record');
|
|
1495
|
-
if (headings.length === 0) throw stop('handoff record: missing required "## Provision record" section');
|
|
1496
|
-
if (headings.length > 1) throw stop('handoff record: multiple "## Provision record" sections — the record is ambiguous');
|
|
1497
|
-
const start = headings[0].index;
|
|
1498
|
-
const nextHeading = lines.find((match) => match.index > start && ATX_SECTION_HEADING.test(match[0].replace(/\r?\n$/, '')));
|
|
1499
|
-
return { source, start, end: nextHeading?.index ?? source.length };
|
|
1500
|
-
};
|
|
1501
|
-
|
|
1502
|
-
// ONLY the required section is parsed, so decoy fields elsewhere cannot hijack identity.
|
|
1503
|
-
// Duplicated single-valued fields are ambiguous identity → typed STOP, never last-wins.
|
|
1504
|
-
export const parseProvisionRecord = (text) => {
|
|
1505
|
-
const section = locateProvisionRecordSection(text);
|
|
1506
|
-
const scan = section.source.slice(section.start, section.end).split('\n').slice(1);
|
|
1507
|
-
const record = { slug: null, branch: null, includes: [], nodeModules: null, vscode: null, install: null, sharedQueue: null, landing: null, prepared: null };
|
|
1508
|
-
const single = {
|
|
1509
|
-
slug: 'slug', branch: 'branch', node_modules: 'nodeModules',
|
|
1510
|
-
'vscode-settings': 'vscode', 'prepared-tree': 'prepared',
|
|
1511
|
-
install: 'install', 'shared-queue': 'sharedQueue', landing: 'landing',
|
|
1512
|
-
};
|
|
1513
|
-
const seen = new Set();
|
|
1514
|
-
for (const line of scan) {
|
|
1515
|
-
const m = line.match(/^- ([a-z_-]+): (.*)$/);
|
|
1516
|
-
if (!m) continue;
|
|
1517
|
-
const value = m[2].trim();
|
|
1518
|
-
if (m[1] === 'include') {
|
|
1519
|
-
if (value !== '(none)') record.includes.push(value);
|
|
1520
|
-
continue;
|
|
1521
|
-
}
|
|
1522
|
-
const key = single[m[1]];
|
|
1523
|
-
if (!key) continue;
|
|
1524
|
-
if (seen.has(m[1])) throw stop(`handoff record: duplicate "${m[1]}" field — the record is ambiguous`);
|
|
1525
|
-
seen.add(m[1]);
|
|
1526
|
-
record[key] = value;
|
|
1527
|
-
}
|
|
1528
|
-
return record;
|
|
1529
|
-
};
|
|
1530
|
-
|
|
1531
1374
|
// Derived from MAIN's root, so the satellite reads an absolute path and a command that already
|
|
1532
|
-
// cd-s back to main — neither is derivable from inside the worktree.
|
|
1375
|
+
// cd-s back to main — neither is derivable from inside the worktree. The landing COMMAND is its own
|
|
1376
|
+
// derivation because the cold-start prompt offers it as a runnable line while the record carries the
|
|
1377
|
+
// composed value; one source, so the two can never disagree.
|
|
1378
|
+
const landingCommand = ({ root, slug }) =>
|
|
1379
|
+
`${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`;
|
|
1380
|
+
|
|
1533
1381
|
const orientationFields = ({ root, slug }) => ({
|
|
1534
1382
|
sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
|
|
1535
|
-
landing:
|
|
1383
|
+
landing: composeLandingValue({ rule: LANDING_FROM_MAIN, command: landingCommand({ root, slug }) }),
|
|
1536
1384
|
});
|
|
1537
1385
|
|
|
1538
1386
|
// Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
|
|
@@ -1628,21 +1476,30 @@ const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal
|
|
|
1628
1476
|
|
|
1629
1477
|
// Validated BEFORE any git mutation — a bad --plan/--as never leaves a half-made worktree.
|
|
1630
1478
|
const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
1479
|
+
// The --as ARGUMENT is checked first, before any diagnostic that would render it: a refusal is read
|
|
1480
|
+
// in the same terminal the cold-start prompt is. The --plan path needs no separate refusal — every
|
|
1481
|
+
// message below renders it through displayValue, and a hostile path reaches the derived-name guard
|
|
1482
|
+
// anyway, where the refusal is a runtime STOP because the offending value is a filesystem name and
|
|
1483
|
+
// not an argument. JSON.stringify is not the guard for either: it escapes C0 and passes C1 and
|
|
1484
|
+
// U+2028/U+2029 straight through.
|
|
1485
|
+
if (asFlag !== null && hasControlByte(asFlag)) {
|
|
1486
|
+
throw usageStop(`--as carries a control character, which would forge a line wherever it is rendered: ${displayValue(asFlag)}`);
|
|
1487
|
+
}
|
|
1631
1488
|
if (asFlag !== null && (asFlag.includes('/') || asFlag.includes('\\') || !asFlag.endsWith('.md'))) {
|
|
1632
|
-
throw usageStop(`--as must be a basename ending in .md, got ${JSON.stringify(asFlag)}`);
|
|
1489
|
+
throw usageStop(`--as must be a basename ending in .md, got ${displayValue(JSON.stringify(asFlag))}`);
|
|
1633
1490
|
}
|
|
1634
1491
|
const srcAbs = resolve(root, planFlag);
|
|
1635
1492
|
const node = classifyNodeNoFollow(srcAbs, fs);
|
|
1636
|
-
if (node.kind === 'absent') throw stop(`--plan: not found: ${planFlag}`);
|
|
1637
|
-
if (node.kind === 'error') throw stop(`--plan: cannot inspect ${planFlag} (${node.error})`);
|
|
1638
|
-
if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${planFlag}`);
|
|
1493
|
+
if (node.kind === 'absent') throw stop(`--plan: not found: ${displayValue(planFlag)}`);
|
|
1494
|
+
if (node.kind === 'error') throw stop(`--plan: cannot inspect ${displayValue(planFlag)} (${node.error})`);
|
|
1495
|
+
if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${displayValue(planFlag)}`);
|
|
1639
1496
|
let srcReal;
|
|
1640
1497
|
try {
|
|
1641
1498
|
srcReal = fs.realpath(srcAbs);
|
|
1642
1499
|
} catch {
|
|
1643
|
-
throw stop(`--plan: not found: ${planFlag}`);
|
|
1500
|
+
throw stop(`--plan: not found: ${displayValue(planFlag)}`);
|
|
1644
1501
|
}
|
|
1645
|
-
if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${planFlag}`);
|
|
1502
|
+
if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${displayValue(planFlag)}`);
|
|
1646
1503
|
if (normalizeSlashes(dirname(srcReal)) === normalizeSlashes(join(rootReal, PLANS_REL)) && !isScratchPlanName(basename(srcReal))) {
|
|
1647
1504
|
throw stop(
|
|
1648
1505
|
`--plan names a bare (in-flight) plan inside MAIN's ${PLANS_REL} — the feature plan must live in the satellite ONLY, ` +
|
|
@@ -1651,10 +1508,14 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
|
1651
1508
|
);
|
|
1652
1509
|
}
|
|
1653
1510
|
const name = asFlag ?? basename(srcAbs);
|
|
1654
|
-
if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${name}`);
|
|
1511
|
+
if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${displayValue(name)}`);
|
|
1512
|
+
// The derived name too: without --as it is the source basename, which the checks above never saw.
|
|
1513
|
+
if (hasControlByte(name)) {
|
|
1514
|
+
throw stop(`the seeded plan name carries a control character, which would forge a line in the satellite's cold-start prompt: ${displayValue(name)}`);
|
|
1515
|
+
}
|
|
1655
1516
|
if (isScratchPlanName(name)) {
|
|
1656
1517
|
throw stop(
|
|
1657
|
-
`refusing to seed a scratch-class plan name (${name}) — the worktree's review-state would read it as "no plan ` +
|
|
1518
|
+
`refusing to seed a scratch-class plan name (${displayValue(name)}) — the worktree's review-state would read it as "no plan ` +
|
|
1658
1519
|
'in flight" and every council check would pass vacuously. Seed a bare name via --as <name>.md.',
|
|
1659
1520
|
);
|
|
1660
1521
|
}
|
|
@@ -1669,7 +1530,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL
|
|
|
1669
1530
|
return;
|
|
1670
1531
|
}
|
|
1671
1532
|
const src = readFileNoFollow(fs, srcAbs);
|
|
1672
|
-
if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${srcAbs}`);
|
|
1533
|
+
if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${displayValue(srcAbs)}`);
|
|
1673
1534
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1674
1535
|
fs.mkdir(dirname(dst));
|
|
1675
1536
|
writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
|
|
@@ -1877,17 +1738,156 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
|
|
|
1877
1738
|
// an earlier provision left — an install through it writes into MAIN, and the posture must never
|
|
1878
1739
|
// hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
|
|
1879
1740
|
// "nothing to install" must not ride an install instruction.
|
|
1880
|
-
const
|
|
1741
|
+
const SYMLINK_POSTURE_HEAD = 'the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN)';
|
|
1742
|
+
// Two DIFFERENT facts, never one wording: a link whose target was read and is not MAIN's, and a link
|
|
1743
|
+
// whose target could not be read at all. Claiming the second points elsewhere would state something
|
|
1744
|
+
// nothing established; both withhold the removal advice for the same reason.
|
|
1745
|
+
// Stated as the PROVEN fact and no more: the raw target does not equal the absolute path provision
|
|
1746
|
+
// writes. A relative target may still resolve to the same directory, and an absolute one may be a
|
|
1747
|
+
// provisioned link left behind by a MAIN that moved — neither is disproved here, and claiming the
|
|
1748
|
+
// link "points somewhere else" or "was not placed by this tool" would assert what was never checked.
|
|
1749
|
+
const FOREIGN_NODE_MODULES_LINK = 'node_modules here is a symlink whose raw target is not the absolute MAIN node_modules path this tool writes, so its ownership is unproven — nothing is claimed about what an install through it would write, and no removal is advised; inspect it before installing';
|
|
1750
|
+
const UNREADABLE_NODE_MODULES_LINK = 'node_modules here is a symlink whose target could not be read, so this tool cannot tell whether it points at MAIN — nothing is claimed about it and no removal is advised; inspect it before installing';
|
|
1751
|
+
const TRACKED_NODE_MODULES_LINK = 'node_modules here is a TRACKED symlink — its target is MAIN node_modules, but a tracked path is repository content the landing lane protects, so no removal is advised; take it up with the checkout that tracked it';
|
|
1752
|
+
const LANE_UNPROVEN_NODE_MODULES_LINK = 'node_modules here is a symlink whose target IS MAIN node_modules, but this tool could not establish that the path sits in the ignored lane, and only an ignored matching link is the one provision places — so nothing is claimed about it and no removal is advised';
|
|
1753
|
+
|
|
1754
|
+
// ONE wording for every not-ours verdict, so the report and the prompt can never describe the same
|
|
1755
|
+
// link differently. The foreign target is decoded FATALLY: bytes that are not text are quoted
|
|
1756
|
+
// nowhere, and fall to the unreadable answer rather than to a replaced string.
|
|
1757
|
+
const unverifiedLinkPosture = (ownership) => {
|
|
1758
|
+
if (ownership.verdict === 'tracked') return TRACKED_NODE_MODULES_LINK;
|
|
1759
|
+
if (ownership.verdict === 'lane-unproven') return `${LANE_UNPROVEN_NODE_MODULES_LINK} (${ownership.error})`;
|
|
1760
|
+
if (ownership.verdict === 'unreadable') return `${UNREADABLE_NODE_MODULES_LINK} (${ownership.error})`;
|
|
1761
|
+
const decoded = decodeTargetStrictly(ownership.target);
|
|
1762
|
+
return decoded === null
|
|
1763
|
+
? `${UNREADABLE_NODE_MODULES_LINK} (its target is not decodable text)`
|
|
1764
|
+
: `${FOREIGN_NODE_MODULES_LINK}: ${decoded}`;
|
|
1765
|
+
};
|
|
1766
|
+
|
|
1767
|
+
// Ownership is decided on the RAW TARGET BYTES, the same evidence cleanup binds on. A decoded string
|
|
1768
|
+
// is not that: two different byte sequences can decode to ONE string through UTF-8 replacement, and
|
|
1769
|
+
// the pair that collides would authorize removing a link this tool never placed. The outcome is
|
|
1770
|
+
// STRUCTURED because the three answers are different facts: ours, someone else's, or unknown — and
|
|
1771
|
+
// an unreadable link is the last of those, never a claim about where it points.
|
|
1772
|
+
const readLinkTarget = (fs, path) => {
|
|
1773
|
+
try {
|
|
1774
|
+
const raw = fs.readlink(path, { encoding: 'buffer' });
|
|
1775
|
+
return { target: Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw)) };
|
|
1776
|
+
} catch (err) {
|
|
1777
|
+
return { error: err?.code ?? 'fs error' };
|
|
1778
|
+
}
|
|
1779
|
+
};
|
|
1780
|
+
|
|
1781
|
+
// Fatal UTF-8: a target that is not decodable text is not a target this tool will quote. Returning
|
|
1782
|
+
// null keeps it out of the record and the prompt entirely, rather than quoting a replaced string.
|
|
1783
|
+
const FATAL_UTF8_TARGET = new TextDecoder('utf-8', { fatal: true });
|
|
1784
|
+
const decodeTargetStrictly = (buffer) => {
|
|
1785
|
+
try {
|
|
1786
|
+
return FATAL_UTF8_TARGET.decode(buffer);
|
|
1787
|
+
} catch {
|
|
1788
|
+
return null;
|
|
1789
|
+
}
|
|
1790
|
+
};
|
|
1791
|
+
|
|
1792
|
+
// The ONE ownership question, asked the same way by every lane that acts on that link:
|
|
1793
|
+
// 'ours' | 'foreign' | 'tracked' | 'unreadable', plus the raw target where one was read.
|
|
1794
|
+
//
|
|
1795
|
+
// Matching bytes are HALF the proof. The cleanup ownership rule states the other half — only a
|
|
1796
|
+
// matching link IN THE IGNORED LANE is provision-ephemeral — and it is the half that decides whether
|
|
1797
|
+
// removal may be advised at all: a TRACKED link at this path is repository content, and offering to
|
|
1798
|
+
// delete it would advise destroying something the landing lane protects. A lane the probe cannot
|
|
1799
|
+
// establish is not the ignored lane either.
|
|
1800
|
+
const nodeModulesLinkOwnership = ({ fs, git, nmPath, wtRoot, mainRoot }) => {
|
|
1801
|
+
const read = readLinkTarget(fs, nmPath);
|
|
1802
|
+
if (read.error !== undefined) return { verdict: 'unreadable', error: read.error };
|
|
1803
|
+
if (Buffer.compare(read.target, Buffer.from(join(mainRoot, NODE_MODULES_REL))) !== 0) {
|
|
1804
|
+
return { verdict: 'foreign', target: read.target };
|
|
1805
|
+
}
|
|
1806
|
+
const lane = probeOwnedLane({ git, wtRoot, rel: NODE_MODULES_REL });
|
|
1807
|
+
if (lane.lane === 'ignored') return { verdict: 'ours', target: read.target };
|
|
1808
|
+
if (lane.lane === 'tracked') return { verdict: 'tracked', target: read.target };
|
|
1809
|
+
// The target WAS read here — only the lane is unsettled — so this must not borrow the wording of a
|
|
1810
|
+
// failed target read. Its cause is the lane probe's own: an untracked path, or a probe that could
|
|
1811
|
+
// not answer at all.
|
|
1812
|
+
return {
|
|
1813
|
+
verdict: 'lane-unproven',
|
|
1814
|
+
error: lane.detail ?? `the path is ${lane.lane}, and only an ignored one is the link provision places`,
|
|
1815
|
+
target: read.target,
|
|
1816
|
+
};
|
|
1817
|
+
};
|
|
1818
|
+
const INSTALL_RUNNABLE_DESCRIPTION = 'this checkout installs its own dependencies — the command below runs in it';
|
|
1819
|
+
|
|
1820
|
+
// Three views of ONE probe, so the record and the cold-start prompt can never disagree about this
|
|
1821
|
+
// checkout: `posture` is the RECORD's field, byte-for-byte what it has always been; `description` is
|
|
1822
|
+
// the prose half with no command in it; `command` is the runnable half, or null where none exists.
|
|
1823
|
+
// The split is what keeps a runnable install out of an unattributed prompt line — the posture string
|
|
1824
|
+
// IS a command in the ordinary case, so rendering it as prose would offer an instruction nothing
|
|
1825
|
+
// attributes and no command parser can see.
|
|
1826
|
+
const resolveInstall = ({ wtRoot, mainRoot, dependencyFree, fs, git }) => {
|
|
1881
1827
|
const nmPath = join(wtRoot, 'node_modules');
|
|
1882
1828
|
const nm = lstatNoFollow(fs.lstat, nmPath);
|
|
1829
|
+
const removal = `rm ${shellQuoteArg(nmPath)}`;
|
|
1883
1830
|
if (nm !== null && nm.isSymbolicLink()) {
|
|
1831
|
+
// A symlink is not proof of THIS tool's link. The provisioned one points at MAIN's
|
|
1832
|
+
// node_modules; anything else is a node the session (or a later hand) put there, and claiming
|
|
1833
|
+
// "a symlink into MAIN" about it would state a live fact nothing checked — and then advise
|
|
1834
|
+
// removing something this tool never placed. Ownership is decided by the raw target bytes,
|
|
1835
|
+
// the same evidence cleanup binds on.
|
|
1836
|
+
const ownership = nodeModulesLinkOwnership({ fs, git, nmPath, wtRoot, mainRoot });
|
|
1837
|
+
if (ownership.verdict !== 'ours') {
|
|
1838
|
+
// The target reaches the record's own value guard and the prompt's, and each refuses a hostile
|
|
1839
|
+
// one by NAME — so it is decoded FATALLY here: a lossy decode would fold undecodable bytes to
|
|
1840
|
+
// U+FFFD and hand both guards a sanitized string, a silent pass where a typed STOP was
|
|
1841
|
+
// promised. Bytes that are not text at all get the same treatment as an unreadable link:
|
|
1842
|
+
// nothing is claimed about them. displayValue belongs in diagnostics only.
|
|
1843
|
+
const unverified = unverifiedLinkPosture(ownership);
|
|
1844
|
+
return { posture: unverified, description: unverified, command: null };
|
|
1845
|
+
}
|
|
1884
1846
|
const advice = resolveInstallAdvice({ wtRoot, fs });
|
|
1885
1847
|
const separator = advice.command === null ? ' — ' : ' && ';
|
|
1886
|
-
|
|
1848
|
+
// The REMOVAL is runnable even when no install command is derivable, so it rides the attributed
|
|
1849
|
+
// line either way and never sits loose inside prose.
|
|
1850
|
+
return {
|
|
1851
|
+
posture: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first: ${removal}${separator}${advice.instruction}`,
|
|
1852
|
+
description: advice.command === null
|
|
1853
|
+
? `${SYMLINK_POSTURE_HEAD} — for isolation remove it first with the command below; then ${NEUTRAL_INSTALL_ADVICE}`
|
|
1854
|
+
: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first and install; the command below does both`,
|
|
1855
|
+
command: advice.command === null ? removal : `${removal} && ${advice.command}`,
|
|
1856
|
+
};
|
|
1887
1857
|
}
|
|
1888
|
-
if (dependencyFree)
|
|
1889
|
-
|
|
1890
|
-
}
|
|
1858
|
+
if (dependencyFree) {
|
|
1859
|
+
return { posture: NO_DEPENDENCIES_POSTURE, description: NO_DEPENDENCIES_POSTURE, command: null };
|
|
1860
|
+
}
|
|
1861
|
+
const advice = resolveInstallAdvice({ wtRoot, fs });
|
|
1862
|
+
return advice.command === null
|
|
1863
|
+
? { posture: advice.instruction, description: advice.instruction, command: null }
|
|
1864
|
+
: { posture: advice.instruction, description: INSTALL_RUNNABLE_DESCRIPTION, command: advice.command };
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
// The satellite's cold-start prompt, composed from LIVE facts at both print sites (D16): provision
|
|
1869
|
+
// ends its report with it, and `prompt <slug>` re-prints it later from MAIN. The record is passed in
|
|
1870
|
+
// only so a value FROZEN at provision time can be named where it no longer matches.
|
|
1871
|
+
//
|
|
1872
|
+
// It PROBES NOTHING. The satellite-derived facts — the seeded plan and the install posture — are
|
|
1873
|
+
// arguments, because provision has already established both by the time it composes and a second
|
|
1874
|
+
// read there would be a fresh failure window after the work is done; `prompt` resolves them itself,
|
|
1875
|
+
// where a failure is the whole outcome of the run.
|
|
1876
|
+
const composeSatellitePromptFor = ({ root, wtRoot, slug, branch, record, plan, install }) => composeSatellitePrompt({
|
|
1877
|
+
slug,
|
|
1878
|
+
branch,
|
|
1879
|
+
worktreePath: wtRoot,
|
|
1880
|
+
plan,
|
|
1881
|
+
live: {
|
|
1882
|
+
sharedQueue: orientationFields({ root, slug }).sharedQueue,
|
|
1883
|
+
landingRule: LANDING_FROM_MAIN,
|
|
1884
|
+
landingCommand: landingCommand({ root, slug }),
|
|
1885
|
+
installPosture: install.posture,
|
|
1886
|
+
installDescription: install.description,
|
|
1887
|
+
installCommand: install.command,
|
|
1888
|
+
},
|
|
1889
|
+
record,
|
|
1890
|
+
});
|
|
1891
1891
|
|
|
1892
1892
|
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1893
1893
|
// The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
|
|
@@ -1898,6 +1898,16 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1898
1898
|
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1899
1899
|
const existing = lstatNoFollow(fs.lstat, dst);
|
|
1900
1900
|
if (existing !== null && existing.isSymbolicLink()) {
|
|
1901
|
+
// The unlink-first advice belongs to OUR link and to no other: for a link this tool never
|
|
1902
|
+
// placed it would offer to delete a node whose target it has not established, and the
|
|
1903
|
+
// cold-start prompt would then contradict the report in the same breath. Same ownership
|
|
1904
|
+
// question, same raw-bytes evidence.
|
|
1905
|
+
const ownership = nodeModulesLinkOwnership({ fs, git, nmPath: dst, wtRoot, mainRoot: root });
|
|
1906
|
+
if (ownership.verdict !== 'ours') {
|
|
1907
|
+
journalLink('kept');
|
|
1908
|
+
report.push(` node_modules: ${displayValue(unverifiedLinkPosture(ownership))}`);
|
|
1909
|
+
return 'install-printed-unverified-link';
|
|
1910
|
+
}
|
|
1901
1911
|
// isolation only exists BEFORE the link: an install through it would write into MAIN
|
|
1902
1912
|
const separator = install.command === null ? ' — ' : ' && ';
|
|
1903
1913
|
journalLink('kept');
|
|
@@ -2254,7 +2264,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2254
2264
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
2255
2265
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
2256
2266
|
throw stop(
|
|
2257
|
-
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
2267
|
+
`the worktree must hold EXACTLY ONE in-flight plan, found [${inFlight.map(displayValue).join(', ')}] — remove the extras (or re-seed) and re-run --resume`,
|
|
2258
2268
|
);
|
|
2259
2269
|
}
|
|
2260
2270
|
|
|
@@ -2278,6 +2288,33 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2278
2288
|
}
|
|
2279
2289
|
}
|
|
2280
2290
|
|
|
2291
|
+
// ONE probe, three views: the record takes the posture, the prompt takes the prose and the
|
|
2292
|
+
// runnable half. A second probe here could disagree with what the record is about to state.
|
|
2293
|
+
const install = resolveInstall({ wtRoot: targetPath, mainRoot: root, dependencyFree, fs, git });
|
|
2294
|
+
const fields = {
|
|
2295
|
+
slug,
|
|
2296
|
+
branch,
|
|
2297
|
+
includes: includesRecorded,
|
|
2298
|
+
nodeModules: nodeModulesMode,
|
|
2299
|
+
vscode: vscodeMode,
|
|
2300
|
+
install: install.posture,
|
|
2301
|
+
...orientationFields({ root, slug }),
|
|
2302
|
+
};
|
|
2303
|
+
// Composed BEFORE the record is written and before ANY output: the values are already established
|
|
2304
|
+
// (the seeded plan passed the EXACTLY-ONE check above, the install posture is the one going into
|
|
2305
|
+
// the record), so a composition failure lands where every other late provision failure lands —
|
|
2306
|
+
// with the record bytes untouched and no success line printed — instead of contradicting a success
|
|
2307
|
+
// message it would otherwise follow.
|
|
2308
|
+
const prompt = composeSatellitePromptFor({
|
|
2309
|
+
root,
|
|
2310
|
+
wtRoot: targetPath,
|
|
2311
|
+
slug,
|
|
2312
|
+
branch,
|
|
2313
|
+
record: fields,
|
|
2314
|
+
plan: seed.name,
|
|
2315
|
+
install,
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2281
2318
|
// The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
|
|
2282
2319
|
// the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
|
|
2283
2320
|
// (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
|
|
@@ -2289,15 +2326,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2289
2326
|
slug,
|
|
2290
2327
|
branch,
|
|
2291
2328
|
journal,
|
|
2292
|
-
fields
|
|
2293
|
-
slug,
|
|
2294
|
-
branch,
|
|
2295
|
-
includes: includesRecorded,
|
|
2296
|
-
nodeModules: nodeModulesMode,
|
|
2297
|
-
vscode: vscodeMode,
|
|
2298
|
-
install: resolveInstallPosture({ wtRoot: targetPath, dependencyFree, fs }),
|
|
2299
|
-
...orientationFields({ root, slug }),
|
|
2300
|
-
},
|
|
2329
|
+
fields,
|
|
2301
2330
|
fs,
|
|
2302
2331
|
report,
|
|
2303
2332
|
});
|
|
@@ -2312,6 +2341,10 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2312
2341
|
for (const line of report) log(line);
|
|
2313
2342
|
log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
|
|
2314
2343
|
log(`open it: code -n ${shellQuoteArg(targetPath)}`);
|
|
2344
|
+
// The report ENDS with the satellite's cold-start prompt: the facts a fresh session in that
|
|
2345
|
+
// checkout cannot derive are worth nothing if they are only re-derivable on request.
|
|
2346
|
+
log('');
|
|
2347
|
+
log(prompt);
|
|
2315
2348
|
return EXIT.ok;
|
|
2316
2349
|
};
|
|
2317
2350
|
|
|
@@ -2362,6 +2395,30 @@ export const runList = ({ cwd, git, deps, log }) => {
|
|
|
2362
2395
|
return EXIT.ok;
|
|
2363
2396
|
};
|
|
2364
2397
|
|
|
2398
|
+
// ── prompt ─────────────────────────────────────────────────────────────────────────────
|
|
2399
|
+
|
|
2400
|
+
// Read-only: it resolves the satellite, proves the handoff identity there, and prints. Nothing is
|
|
2401
|
+
// written, and MAIN is where it must run — the orientation it composes is MAIN's, so the
|
|
2402
|
+
// linked-worktree refusal in resolveRoots is the guard that keeps it honest.
|
|
2403
|
+
export const runPrompt = ({ argvSlug, cwd, git, deps, log }) => {
|
|
2404
|
+
const fs = fsOf(deps);
|
|
2405
|
+
const slug = validateSlug(argvSlug);
|
|
2406
|
+
const { root } = resolveRoots(cwd, git);
|
|
2407
|
+
const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
|
|
2408
|
+
const identity = readSatelliteIdentity({ entry, slug, fs });
|
|
2409
|
+
const wtRoot = entry.path;
|
|
2410
|
+
log(composeSatellitePromptFor({
|
|
2411
|
+
root,
|
|
2412
|
+
wtRoot,
|
|
2413
|
+
slug,
|
|
2414
|
+
branch: identity.branch,
|
|
2415
|
+
record: identity.record,
|
|
2416
|
+
plan: resolveSeededPlan({ wtRoot, readdir: fs.readdir }),
|
|
2417
|
+
install: resolveInstall({ wtRoot, mainRoot: root, dependencyFree: declaresNoDependencies({ wtRoot, fs }), fs, git }),
|
|
2418
|
+
}));
|
|
2419
|
+
return EXIT.ok;
|
|
2420
|
+
};
|
|
2421
|
+
|
|
2365
2422
|
// ── land + cleanup ────────────────────────────────────────────────────────────────────
|
|
2366
2423
|
|
|
2367
2424
|
const nulFields = (text) => String(text).split('\0').filter((field) => field !== '');
|
|
@@ -2433,57 +2490,6 @@ const withPrepareLock = ({ commonDir, fs, now }, action) => {
|
|
|
2433
2490
|
return result;
|
|
2434
2491
|
};
|
|
2435
2492
|
|
|
2436
|
-
const branchNameOf = (entry) => entry.branch?.replace(/^refs\/heads\//, '') ?? null;
|
|
2437
|
-
|
|
2438
|
-
const findSatelliteEntry = ({ root, slug, branch, git, fs }) => {
|
|
2439
|
-
const entries = listWorktrees(git, root).slice(1);
|
|
2440
|
-
const exactHandoff = [];
|
|
2441
|
-
for (const entry of entries) {
|
|
2442
|
-
if (entry.prunable) continue;
|
|
2443
|
-
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
2444
|
-
if (scan.state === 'ok' && scan.handoffs.includes(handoffBasename(slug))) exactHandoff.push(entry);
|
|
2445
|
-
}
|
|
2446
|
-
if (exactHandoff.length > 1) {
|
|
2447
|
-
throw stop(`multiple worktrees carry ${handoffBasename(slug)} — cleanup the duplicate identity before continuing`);
|
|
2448
|
-
}
|
|
2449
|
-
if (branch !== null) {
|
|
2450
|
-
const byBranch = entries.filter((entry) => entry.branch === `refs/heads/${branch}`);
|
|
2451
|
-
if (byBranch.length > 1) throw stop(`multiple worktrees claim branch ${branch}`);
|
|
2452
|
-
if (byBranch.length === 1) return byBranch[0];
|
|
2453
|
-
}
|
|
2454
|
-
if (exactHandoff.length === 1) return exactHandoff[0];
|
|
2455
|
-
const fallback = entries.filter((entry) => entry.branch === `refs/heads/${DEFAULT_BRANCH_PREFIX}${slug}`);
|
|
2456
|
-
if (fallback.length === 1) return fallback[0];
|
|
2457
|
-
throw stop(`no registered satellite worktree for ${slug}`);
|
|
2458
|
-
};
|
|
2459
|
-
|
|
2460
|
-
const readSatelliteIdentity = ({ entry, slug, expectedBranch, fs, abandon = false }) => {
|
|
2461
|
-
const name = handoffBasename(slug);
|
|
2462
|
-
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
2463
|
-
if (scan.state === 'ok' && scan.nonRegular.includes(name)) {
|
|
2464
|
-
throw stop(`handoff identity mismatch: ${name} is not a regular file`);
|
|
2465
|
-
}
|
|
2466
|
-
if (scan.state !== 'ok' || !scan.handoffs.includes(name)) {
|
|
2467
|
-
if (abandon) throw stop(`${name} is absent — force deletion is forbidden without the handoff identity`);
|
|
2468
|
-
throw stop(`handoff identity mismatch: expected ${name} in the satellite`);
|
|
2469
|
-
}
|
|
2470
|
-
if (scan.handoffs.length !== 1) {
|
|
2471
|
-
throw stop(`handoff identity mismatch: expected exactly ${name}, found [${scan.handoffs.join(', ')}]`);
|
|
2472
|
-
}
|
|
2473
|
-
const leaf = readFileNoFollow(fs, join(entry.path, PLANS_REL, name));
|
|
2474
|
-
if (!leaf.bytes) throw stop(`handoff identity mismatch: ${name} is not readable as a regular file`);
|
|
2475
|
-
const record = parseProvisionRecord(String(leaf.bytes));
|
|
2476
|
-
const liveBranch = branchNameOf(entry);
|
|
2477
|
-
const wantedBranch = expectedBranch ?? liveBranch;
|
|
2478
|
-
if (record.slug !== slug || record.branch !== wantedBranch || liveBranch !== wantedBranch) {
|
|
2479
|
-
throw stop(
|
|
2480
|
-
`handoff identity mismatch: expected slug ${slug} and branch ${wantedBranch}; ` +
|
|
2481
|
-
`record has slug ${record.slug ?? '(missing)'} and branch ${record.branch ?? '(missing)'}, live branch ${liveBranch ?? '(detached)'}`,
|
|
2482
|
-
);
|
|
2483
|
-
}
|
|
2484
|
-
return { record, path: join(entry.path, PLANS_REL, name), branch: wantedBranch };
|
|
2485
|
-
};
|
|
2486
|
-
|
|
2487
2493
|
const changedPaths = (git, args, cwd, label) =>
|
|
2488
2494
|
nulFields(gitRead(git, [...args, '-z', '--', ...TRANSFER_EXCLUSIONS], cwd, label).stdout);
|
|
2489
2495
|
|
|
@@ -2689,12 +2695,15 @@ const runSyncAdapter = ({ root, mainHead, transferPaths, git, fs, deps, report }
|
|
|
2689
2695
|
return delta;
|
|
2690
2696
|
};
|
|
2691
2697
|
|
|
2692
|
-
|
|
2698
|
+
// prepared-head rides the SAME record refresh as prepared-tree (D8): MAIN's HEAD at prepare time
|
|
2699
|
+
// is what lets the return rung tell a still-pending prepared set from an already-committed one —
|
|
2700
|
+
// a clean post-commit index reproduces the committed tree, so the tree OID alone cannot.
|
|
2701
|
+
const recordPreparedTree = ({ identity, slug, entry, prepared, preparedHead, fs }) => {
|
|
2693
2702
|
writeHandoffRecord({
|
|
2694
2703
|
wtRoot: entry.path,
|
|
2695
2704
|
slug,
|
|
2696
2705
|
branch: identity.branch,
|
|
2697
|
-
fields: { ...identity.record, prepared },
|
|
2706
|
+
fields: { ...identity.record, prepared, preparedHead },
|
|
2698
2707
|
fs,
|
|
2699
2708
|
report: [],
|
|
2700
2709
|
});
|
|
@@ -2714,12 +2723,12 @@ const dirtyMainStop = ({ root, git, record, porcelain }) => {
|
|
|
2714
2723
|
const hasTrackedUnstaged = trackedEntries.some((entry) => entry.code[1] !== ' ');
|
|
2715
2724
|
const mayReset = converged && !hasTrackedUnstaged;
|
|
2716
2725
|
const classification = converged
|
|
2717
|
-
? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${record.prepared}`
|
|
2726
|
+
? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${displayValue(record.prepared)}`
|
|
2718
2727
|
: record.prepared === null
|
|
2719
2728
|
? 'foreign staged work: no previous prepare OID is recorded'
|
|
2720
2729
|
: treeMatchesRecord
|
|
2721
|
-
? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${record.prepared}`
|
|
2722
|
-
: `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${record.prepared}`;
|
|
2730
|
+
? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${displayValue(record.prepared)}`
|
|
2731
|
+
: `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${displayValue(record.prepared)}`;
|
|
2723
2732
|
const leftoversReport = leftovers.length === 0
|
|
2724
2733
|
? []
|
|
2725
2734
|
: mayReset
|
|
@@ -2788,7 +2797,7 @@ export const runLand = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2788
2797
|
const syncDelta = runSyncAdapter({ root, mainHead, transferPaths, git, fs, deps, report });
|
|
2789
2798
|
const preparedTree = gitRead(git, ['write-tree'], root, 'cannot write the prepared main tree').stdout.trim();
|
|
2790
2799
|
try {
|
|
2791
|
-
recordPreparedTree({ identity, slug, entry, prepared: preparedTree, fs });
|
|
2800
|
+
recordPreparedTree({ identity, slug, entry, prepared: preparedTree, preparedHead: mainHead, fs });
|
|
2792
2801
|
} catch (error) {
|
|
2793
2802
|
throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
|
|
2794
2803
|
}
|
|
@@ -2876,7 +2885,7 @@ const registryRoots = () => {
|
|
|
2876
2885
|
const safeRecordedPath = (path) => {
|
|
2877
2886
|
const normalized = normalizeSlashes(String(path)).replace(/^\.\//, '').replace(/\/$/, '');
|
|
2878
2887
|
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
2879
|
-
throw stop(`handoff record carries an unsafe provision path: ${path}`);
|
|
2888
|
+
throw stop(`handoff record carries an unsafe provision path: ${displayValue(path)}`);
|
|
2880
2889
|
}
|
|
2881
2890
|
return normalized;
|
|
2882
2891
|
};
|
|
@@ -3196,12 +3205,13 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
3196
3205
|
export const parseArgs = (argv) => {
|
|
3197
3206
|
const [sub, ...rest] = argv;
|
|
3198
3207
|
if (sub === undefined || sub === '--help' || sub === '-h') return { sub: 'help' };
|
|
3199
|
-
if (!['provision', 'list', 'land', 'cleanup'].includes(sub)) {
|
|
3208
|
+
if (!['provision', 'list', 'prompt', 'land', 'cleanup'].includes(sub)) {
|
|
3200
3209
|
throw usageStop(`unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
|
|
3201
3210
|
}
|
|
3202
3211
|
const SUB_FLAGS = {
|
|
3203
3212
|
provision: ['--plan', '--as', '--dir', '--branch', '--include', '--install', '--resume'],
|
|
3204
3213
|
list: [],
|
|
3214
|
+
prompt: [],
|
|
3205
3215
|
land: ['--prepare'],
|
|
3206
3216
|
cleanup: ['--branch', '--abandon'],
|
|
3207
3217
|
};
|
|
@@ -3245,6 +3255,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
3245
3255
|
return runProvision({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
3246
3256
|
}
|
|
3247
3257
|
if (parsed.sub === 'list') return runList({ cwd, git, deps, log });
|
|
3258
|
+
if (parsed.sub === 'prompt') return runPrompt({ argvSlug: parsed.slug, cwd, git, deps, log });
|
|
3248
3259
|
if (parsed.sub === 'land') {
|
|
3249
3260
|
return runLand({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
3250
3261
|
}
|