@sabaiway/agent-workflow-kit 5.10.0 → 5.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +152 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +13 -2
- package/references/hooks/state-block-guard.mjs +14 -2
- 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/references/scripts/archive-changelog.mjs +14 -3
- package/references/scripts/archive-decisions.mjs +14 -3
- package/references/scripts/archive-issues.mjs +14 -3
- package/references/scripts/check-docs-size.mjs +14 -3
- package/references/scripts/migrate-gates.mjs +13 -2
- package/tools/ack-write.mjs +3 -3
- package/tools/advisor-matrix.mjs +165 -0
- package/tools/autonomy-doctor.mjs +2 -3
- package/tools/bridge-settings.mjs +2 -3
- package/tools/cheap-agents.mjs +3 -3
- package/tools/commands.mjs +4 -5
- package/tools/commit-guard.mjs +77 -20
- package/tools/core-evidence.mjs +12 -3
- package/tools/coverage-check.mjs +2 -3
- package/tools/delegation.mjs +2 -3
- package/tools/detect-backends.mjs +2 -3
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +69 -16
- package/tools/family-registry.mjs +3 -3
- package/tools/flow-adoption-mint.mjs +70 -0
- package/tools/flow-append.mjs +309 -0
- package/tools/flow-chain-state.mjs +91 -0
- package/tools/flow-check-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +22 -8
- package/tools/flow-delta-proof.mjs +307 -0
- package/tools/flow-record.mjs +1 -1
- package/tools/flow-store-read.mjs +3 -3
- package/tools/flow-store.mjs +35 -812
- package/tools/flow-subset-budget.mjs +81 -0
- package/tools/flow-writer.mjs +3 -3
- package/tools/gate-hook.mjs +3 -3
- package/tools/gates-init.mjs +3 -3
- package/tools/grounding.mjs +2 -3
- package/tools/hide-footprint.mjs +2 -3
- package/tools/inject-methodology.mjs +2 -3
- package/tools/lens-region.mjs +2 -3
- package/tools/manifest/validate.mjs +2 -3
- package/tools/migrate-adr-store.mjs +3 -3
- package/tools/observation-builder.mjs +123 -0
- package/tools/path-inventory.mjs +2 -3
- package/tools/procedures.mjs +3 -3
- package/tools/receipt-deadline.mjs +2 -3
- package/tools/recipes.mjs +2 -3
- package/tools/recommendations.mjs +3 -3
- package/tools/release-scan.mjs +2 -3
- package/tools/repo-search.mjs +2 -3
- package/tools/review-state.mjs +3 -3
- package/tools/run-gates.mjs +2 -3
- package/tools/sandbox-masks.mjs +3 -3
- package/tools/satellite-locator.mjs +179 -0
- package/tools/set-autonomy.mjs +2 -3
- package/tools/set-flow.mjs +3 -3
- package/tools/set-recipe.mjs +2 -3
- package/tools/setup-backends.mjs +3 -3
- package/tools/store-append.mjs +2 -2
- package/tools/uninstall.mjs +2 -3
- package/tools/velocity-profile.mjs +3 -3
- 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 +311 -300
package/tools/worktrees.mjs
CHANGED
|
@@ -13,31 +13,47 @@ import {
|
|
|
13
13
|
closeSync, constants as fsC,
|
|
14
14
|
} from 'node:fs';
|
|
15
15
|
import { join, dirname, basename, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
16
|
-
import {
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { spawnSync } from 'node:child_process';
|
|
18
18
|
import { randomBytes } from 'node:crypto';
|
|
19
19
|
import {
|
|
20
20
|
KIT_OWN_PATHS, KNOWN_FOOTPRINT, expandGlob, normalizeSlashes, isDirPattern, isGlobPattern,
|
|
21
21
|
patternToProbe,
|
|
22
22
|
} from './known-footprint.mjs';
|
|
23
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
23
24
|
import { isScratchPlanName, plansInFlight, PLANS_REL, shellQuoteArg } from './review-state.mjs';
|
|
24
25
|
import { writeContainedFileAtomic } from './atomic-write.mjs';
|
|
25
26
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
26
27
|
import { isFinalCapableDeclaration } from './run-gates.mjs';
|
|
28
|
+
import {
|
|
29
|
+
WORKTREES_STOP, stop, EXIT, handoffBasename, recordValue, hasControlByte, displayValue,
|
|
30
|
+
composeProvisionRecordSection, composeLandingValue, composeHandoffStub,
|
|
31
|
+
locateProvisionRecordSection, parseProvisionRecord,
|
|
32
|
+
} from './worktrees-record.mjs';
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_BRANCH_PREFIX, listWorktrees, classifyNodeNoFollow, scanPlansDir, findSatelliteEntry,
|
|
35
|
+
readSatelliteIdentity,
|
|
36
|
+
} from './satellite-locator.mjs';
|
|
37
|
+
import { composeSatellitePrompt, resolveSeededPlan } from './worktree-prompt.mjs';
|
|
38
|
+
|
|
39
|
+
// The record format and the satellite locator now live in leaves the dispatch side reads too — but
|
|
40
|
+
// every name they took away is re-exported here, so no import site and no asserted error `code`
|
|
41
|
+
// moved when they left.
|
|
42
|
+
export {
|
|
43
|
+
WORKTREES_STOP, stop, EXIT, handoffBasename, QUEUE_SHARED_RULE, composeHandoffStub,
|
|
44
|
+
parseProvisionRecord,
|
|
45
|
+
} from './worktrees-record.mjs';
|
|
46
|
+
export {
|
|
47
|
+
DEFAULT_BRANCH_PREFIX, parseWorktreeList, findSatelliteEntry, readSatelliteIdentity,
|
|
48
|
+
} from './satellite-locator.mjs';
|
|
27
49
|
|
|
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
50
|
const usageStop = (message) => stop(message, { exitCode: EXIT.usage });
|
|
32
51
|
const errorText = (error) => String(error?.message ?? error).replace(/^\[agent-workflow-kit\] /, '');
|
|
33
52
|
const composeFailure = (primary, secondaryName, secondary) =>
|
|
34
53
|
stop(`${errorText(primary)}; ${secondaryName} failed: ${errorText(secondary)}`);
|
|
35
54
|
|
|
36
|
-
export const EXIT = Object.freeze({ ok: 0, stop: 1, usage: 2 });
|
|
37
55
|
export const CONFIG_REL = 'docs/ai/worktrees.json';
|
|
38
56
|
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
57
|
const WORKTREES_TOOL_ABS = fileURLToPath(import.meta.url);
|
|
42
58
|
const WORKTREES_TOOL_DIR = dirname(WORKTREES_TOOL_ABS);
|
|
43
59
|
|
|
@@ -67,6 +83,10 @@ const USAGE = [
|
|
|
67
83
|
` the ${CONFIG_REL} "parentDir" setting when present). --install only PRINTS the`,
|
|
68
84
|
' install command. --resume completes a half-done provision (identity-checked).',
|
|
69
85
|
' list show every worktree of this repo: slug, path, branch, base OID, dirty, handoff.',
|
|
86
|
+
' prompt <slug>',
|
|
87
|
+
' re-print the satellite cold-start prompt for a provisioned worktree: where it is,',
|
|
88
|
+
' what MAIN answers now (derived LIVE, a stale record value is named), the handoff as',
|
|
89
|
+
' the one return channel, and the bars. Read-only — it writes nothing.',
|
|
70
90
|
' land <slug> --prepare',
|
|
71
91
|
' stage the satellite diff onto a clean main (no commit — the commit stays a',
|
|
72
92
|
' dialogue ask). Refuses divergence, incomplete satellite state, or a dirty main.',
|
|
@@ -74,13 +94,21 @@ const USAGE = [
|
|
|
74
94
|
' remove a LANDED worktree (fail-closed verification); --abandon is the one',
|
|
75
95
|
' destructive arm and destroys unlanded work.',
|
|
76
96
|
'',
|
|
77
|
-
'The slug is REQUIRED and positional on provision/land/cleanup: lowercase letters, digits,',
|
|
97
|
+
'The slug is REQUIRED and positional on provision/prompt/land/cleanup: lowercase letters, digits,',
|
|
78
98
|
'hyphens, max 64 chars, letter/digit first. Exit codes: 0 ok / 1 refusal / 2 usage.',
|
|
79
99
|
].join('\n');
|
|
80
100
|
|
|
81
101
|
// ── deps + git plumbing (every seam injectable for hermetic tests) ─────────────────────
|
|
82
102
|
|
|
83
|
-
const fsOf = (deps) =>
|
|
103
|
+
const fsOf = (deps) => {
|
|
104
|
+
const fs = fsSeams(deps);
|
|
105
|
+
// The ONE content read, bound to THIS seam set and handed to the locator leaf, which owns no read
|
|
106
|
+
// door of its own — one body, one place, provable by the tripwires below it.
|
|
107
|
+
fs.readFileNoFollow = (abs) => readFileNoFollow(fs, abs);
|
|
108
|
+
return fs;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const fsSeams = (deps) => ({
|
|
84
112
|
lstat: deps.lstat ?? lstatSync,
|
|
85
113
|
mkdir: deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true })),
|
|
86
114
|
mkdirPlain: deps.mkdirPlain ?? mkdirSync,
|
|
@@ -133,43 +161,9 @@ const lstatNoFollow = (lstat, path) => {
|
|
|
133
161
|
}
|
|
134
162
|
};
|
|
135
163
|
|
|
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
|
-
};
|
|
164
|
+
// classifyNodeNoFollow moved to satellite-locator.mjs; the ONE content-read door stays HERE, and the
|
|
165
|
+
// locator receives it through its injected fs seam — a second body anywhere is exactly what the
|
|
166
|
+
// door tripwires exist to prevent.
|
|
173
167
|
|
|
174
168
|
// The ONE content-read door: no-follow lstat, then an O_NOFOLLOW|O_NONBLOCK descriptor with an
|
|
175
169
|
// fstat recheck — a node swapped after the lstat can neither follow a link nor block on a FIFO.
|
|
@@ -541,36 +535,8 @@ export const realpathThroughExistingParent = (target, deps = {}) => {
|
|
|
541
535
|
|
|
542
536
|
// ── roots + worktree registry ──────────────────────────────────────────────────────────
|
|
543
537
|
|
|
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
|
-
};
|
|
538
|
+
// parseWorktreeList and listWorktrees moved to satellite-locator.mjs with the resolver that needs
|
|
539
|
+
// them; both are re-exported above, so every caller and test import site is unchanged.
|
|
574
540
|
|
|
575
541
|
// The MAIN worktree is the first `git worktree list --porcelain -z` entry; provision/land/cleanup
|
|
576
542
|
// refuse to run from inside a linked worktree.
|
|
@@ -994,33 +960,8 @@ const assertTargetOutsideSources = ({ targetReal, sources }) => {
|
|
|
994
960
|
|
|
995
961
|
// ── the shared plans-chain scanner (resume identity + list ride the SAME no-follow walk) ─
|
|
996
962
|
|
|
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
|
-
};
|
|
963
|
+
// scanPlansDir moved to satellite-locator.mjs — the resolver is its heaviest caller, and the
|
|
964
|
+
// dispatch side needs the same walk to find a satellite without importing this tool.
|
|
1024
965
|
|
|
1025
966
|
// Resume writes NOTHING before this: the existing handoff must be the live identity.
|
|
1026
967
|
const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
|
|
@@ -1030,24 +971,24 @@ const assertResumeHandoffIdentity = ({ wtRoot, slug, branch, fs }) => {
|
|
|
1030
971
|
}
|
|
1031
972
|
if (scan.state === 'absent') return;
|
|
1032
973
|
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`);
|
|
974
|
+
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
975
|
}
|
|
1035
976
|
if (scan.handoffs.length === 0) return;
|
|
1036
977
|
if (scan.handoffs.length > 1) {
|
|
1037
|
-
throw stop(`--resume: multiple handoff files found (${scan.handoffs.join(', ')}) — exactly one may exist`);
|
|
978
|
+
throw stop(`--resume: multiple handoff files found (${scan.handoffs.map(displayValue).join(', ')}) — exactly one may exist`);
|
|
1038
979
|
}
|
|
1039
980
|
const name = scan.handoffs[0];
|
|
1040
981
|
if (name !== handoffBasename(slug)) {
|
|
1041
|
-
throw stop(`--resume identity mismatch: the existing handoff is ${name}, the live slug is ${slug} (${handoffBasename(slug)})`);
|
|
982
|
+
throw stop(`--resume identity mismatch: the existing handoff is ${displayValue(name)}, the live slug is ${slug} (${handoffBasename(slug)})`);
|
|
1042
983
|
}
|
|
1043
984
|
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`);
|
|
985
|
+
if (!rf.bytes) throw stop(`--resume: the handoff ${displayValue(name)} is not readable as a regular file — fix it before resuming`);
|
|
1045
986
|
const record = parseProvisionRecord(String(rf.bytes));
|
|
1046
987
|
if (record.slug !== slug) {
|
|
1047
|
-
throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug
|
|
988
|
+
throw stop(`--resume identity mismatch: the handoff record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}`);
|
|
1048
989
|
}
|
|
1049
990
|
if (record.branch !== branch) {
|
|
1050
|
-
throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch
|
|
991
|
+
throw stop(`--resume identity mismatch: the handoff record branch is ${record.branch === null ? '(missing)' : displayValue(record.branch)}, the live branch is ${branch}`);
|
|
1051
992
|
}
|
|
1052
993
|
};
|
|
1053
994
|
|
|
@@ -1056,12 +997,12 @@ const assertResumePlanCompatibility = ({ wtRoot, seedName, fs }) => {
|
|
|
1056
997
|
if (inFlight.length === 0 || (inFlight.length === 1 && inFlight[0] === seedName)) return;
|
|
1057
998
|
if (inFlight.length === 1) {
|
|
1058
999
|
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`,
|
|
1000
|
+
`--resume plan mismatch: found [${displayValue(inFlight[0])}], expected [${displayValue(seedName)}] or no in-flight plan — ` +
|
|
1001
|
+
`re-run with --as ${displayValue(inFlight[0])}, or remove the existing plan by hand`,
|
|
1061
1002
|
);
|
|
1062
1003
|
}
|
|
1063
1004
|
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`,
|
|
1005
|
+
`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
1006
|
);
|
|
1066
1007
|
};
|
|
1067
1008
|
|
|
@@ -1222,8 +1163,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
|
|
|
1222
1163
|
const rf = readFileNoFollow(fs, join(wtRoot, PLANS_REL, handoffBasename(slug)));
|
|
1223
1164
|
if (!rf.bytes) return { binds: false, reason: 'the handoff is not readable as a regular file' };
|
|
1224
1165
|
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
|
|
1166
|
+
if (record.slug !== slug) return { binds: false, reason: `the record slug is ${record.slug === null ? '(missing)' : displayValue(record.slug)}, the live slug is ${slug}` };
|
|
1167
|
+
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
1168
|
return { binds: true, reason: null };
|
|
1228
1169
|
} catch (err) {
|
|
1229
1170
|
return { binds: false, reason: errorText(err) };
|
|
@@ -1287,8 +1228,8 @@ const assertPlansChainCleanOnResume = ({ git, root, wtRoot, slug, branch, rels,
|
|
|
1287
1228
|
// The orientation facts a fresh satellite session cannot derive from its own checkout. They are
|
|
1288
1229
|
// CONSTANTS so the doc-parity registry can pin the mode doc to the exact strings the tool emits.
|
|
1289
1230
|
export const QUEUE_BASENAME = 'queue.md';
|
|
1290
|
-
|
|
1291
|
-
|
|
1231
|
+
// QUEUE_SHARED_RULE travels with the composer that emits it (worktrees-record.mjs) and is
|
|
1232
|
+
// re-exported above; LANDING_FROM_MAIN stays where its only user is.
|
|
1292
1233
|
export const LANDING_FROM_MAIN = 'landing runs FROM MAIN, never from this worktree';
|
|
1293
1234
|
export const NO_DEPENDENCIES_POSTURE = 'no install needed — the project declares no dependencies';
|
|
1294
1235
|
// The recorded node_modules mode for that same verdict: provision neither advised nor created a
|
|
@@ -1431,108 +1372,16 @@ const verifyPlacedPaths = ({ git, wtRoot, members }) => {
|
|
|
1431
1372
|
if (failures.length > 0) throw stop(composeOwnedVerifyStop(failures));
|
|
1432
1373
|
};
|
|
1433
1374
|
|
|
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
1375
|
// 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.
|
|
1376
|
+
// cd-s back to main — neither is derivable from inside the worktree. The landing COMMAND is its own
|
|
1377
|
+
// derivation because the cold-start prompt offers it as a runnable line while the record carries the
|
|
1378
|
+
// composed value; one source, so the two can never disagree.
|
|
1379
|
+
const landingCommand = ({ root, slug }) =>
|
|
1380
|
+
`${composeOwnToolPrefix(root)} land ${shellQuoteArg(slug)} --prepare`;
|
|
1381
|
+
|
|
1533
1382
|
const orientationFields = ({ root, slug }) => ({
|
|
1534
1383
|
sharedQueue: join(root, PLANS_REL, QUEUE_BASENAME),
|
|
1535
|
-
landing:
|
|
1384
|
+
landing: composeLandingValue({ rule: LANDING_FROM_MAIN, command: landingCommand({ root, slug }) }),
|
|
1536
1385
|
});
|
|
1537
1386
|
|
|
1538
1387
|
// Pre-mutation gate for everything the record will carry. `sharedQueue`/`landing` are derived from
|
|
@@ -1628,21 +1477,30 @@ const writeHandoffRecord = ({ wtRoot, slug, branch, fields, fs, report, journal
|
|
|
1628
1477
|
|
|
1629
1478
|
// Validated BEFORE any git mutation — a bad --plan/--as never leaves a half-made worktree.
|
|
1630
1479
|
const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
1480
|
+
// The --as ARGUMENT is checked first, before any diagnostic that would render it: a refusal is read
|
|
1481
|
+
// in the same terminal the cold-start prompt is. The --plan path needs no separate refusal — every
|
|
1482
|
+
// message below renders it through displayValue, and a hostile path reaches the derived-name guard
|
|
1483
|
+
// anyway, where the refusal is a runtime STOP because the offending value is a filesystem name and
|
|
1484
|
+
// not an argument. JSON.stringify is not the guard for either: it escapes C0 and passes C1 and
|
|
1485
|
+
// U+2028/U+2029 straight through.
|
|
1486
|
+
if (asFlag !== null && hasControlByte(asFlag)) {
|
|
1487
|
+
throw usageStop(`--as carries a control character, which would forge a line wherever it is rendered: ${displayValue(asFlag)}`);
|
|
1488
|
+
}
|
|
1631
1489
|
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)}`);
|
|
1490
|
+
throw usageStop(`--as must be a basename ending in .md, got ${displayValue(JSON.stringify(asFlag))}`);
|
|
1633
1491
|
}
|
|
1634
1492
|
const srcAbs = resolve(root, planFlag);
|
|
1635
1493
|
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}`);
|
|
1494
|
+
if (node.kind === 'absent') throw stop(`--plan: not found: ${displayValue(planFlag)}`);
|
|
1495
|
+
if (node.kind === 'error') throw stop(`--plan: cannot inspect ${displayValue(planFlag)} (${node.error})`);
|
|
1496
|
+
if (node.kind !== 'regular-file') throw stop(`--plan must be a regular non-symlink file: ${displayValue(planFlag)}`);
|
|
1639
1497
|
let srcReal;
|
|
1640
1498
|
try {
|
|
1641
1499
|
srcReal = fs.realpath(srcAbs);
|
|
1642
1500
|
} catch {
|
|
1643
|
-
throw stop(`--plan: not found: ${planFlag}`);
|
|
1501
|
+
throw stop(`--plan: not found: ${displayValue(planFlag)}`);
|
|
1644
1502
|
}
|
|
1645
|
-
if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${planFlag}`);
|
|
1503
|
+
if (!isInside(rootReal, srcReal)) throw stop(`--plan must resolve inside the main repo: ${displayValue(planFlag)}`);
|
|
1646
1504
|
if (normalizeSlashes(dirname(srcReal)) === normalizeSlashes(join(rootReal, PLANS_REL)) && !isScratchPlanName(basename(srcReal))) {
|
|
1647
1505
|
throw stop(
|
|
1648
1506
|
`--plan names a bare (in-flight) plan inside MAIN's ${PLANS_REL} — the feature plan must live in the satellite ONLY, ` +
|
|
@@ -1651,10 +1509,14 @@ const validateSeedPlan = ({ root, rootReal, planFlag, asFlag, fs }) => {
|
|
|
1651
1509
|
);
|
|
1652
1510
|
}
|
|
1653
1511
|
const name = asFlag ?? basename(srcAbs);
|
|
1654
|
-
if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${name}`);
|
|
1512
|
+
if (!name.endsWith('.md')) throw stop(`the seeded plan name must end in .md: ${displayValue(name)}`);
|
|
1513
|
+
// The derived name too: without --as it is the source basename, which the checks above never saw.
|
|
1514
|
+
if (hasControlByte(name)) {
|
|
1515
|
+
throw stop(`the seeded plan name carries a control character, which would forge a line in the satellite's cold-start prompt: ${displayValue(name)}`);
|
|
1516
|
+
}
|
|
1655
1517
|
if (isScratchPlanName(name)) {
|
|
1656
1518
|
throw stop(
|
|
1657
|
-
`refusing to seed a scratch-class plan name (${name}) — the worktree's review-state would read it as "no plan ` +
|
|
1519
|
+
`refusing to seed a scratch-class plan name (${displayValue(name)}) — the worktree's review-state would read it as "no plan ` +
|
|
1658
1520
|
'in flight" and every council check would pass vacuously. Seed a bare name via --as <name>.md.',
|
|
1659
1521
|
);
|
|
1660
1522
|
}
|
|
@@ -1669,7 +1531,7 @@ const writeSeedPlan = ({ wtRoot, srcAbs, name, fs, report, journal = NO_JOURNAL
|
|
|
1669
1531
|
return;
|
|
1670
1532
|
}
|
|
1671
1533
|
const src = readFileNoFollow(fs, srcAbs);
|
|
1672
|
-
if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${srcAbs}`);
|
|
1534
|
+
if (!src.bytes) throw stop(`--plan: not readable as a regular file: ${displayValue(srcAbs)}`);
|
|
1673
1535
|
guardDst(fs, wtRoot, dirname(dst));
|
|
1674
1536
|
fs.mkdir(dirname(dst));
|
|
1675
1537
|
writeContainedFileAtomic(wtRoot, dst, String(src.bytes), fs, { stop: (m) => stop(m) });
|
|
@@ -1877,17 +1739,156 @@ const declaresNoDependencies = ({ wtRoot, fs }) => {
|
|
|
1877
1739
|
// an earlier provision left — an install through it writes into MAIN, and the posture must never
|
|
1878
1740
|
// hide that). Only then may a PROVEN dependency-free checkout short-circuit: a verdict of
|
|
1879
1741
|
// "nothing to install" must not ride an install instruction.
|
|
1880
|
-
const
|
|
1742
|
+
const SYMLINK_POSTURE_HEAD = 'the provisioned node_modules is a symlink into MAIN (an install through it writes into MAIN)';
|
|
1743
|
+
// Two DIFFERENT facts, never one wording: a link whose target was read and is not MAIN's, and a link
|
|
1744
|
+
// whose target could not be read at all. Claiming the second points elsewhere would state something
|
|
1745
|
+
// nothing established; both withhold the removal advice for the same reason.
|
|
1746
|
+
// Stated as the PROVEN fact and no more: the raw target does not equal the absolute path provision
|
|
1747
|
+
// writes. A relative target may still resolve to the same directory, and an absolute one may be a
|
|
1748
|
+
// provisioned link left behind by a MAIN that moved — neither is disproved here, and claiming the
|
|
1749
|
+
// link "points somewhere else" or "was not placed by this tool" would assert what was never checked.
|
|
1750
|
+
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';
|
|
1751
|
+
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';
|
|
1752
|
+
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';
|
|
1753
|
+
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';
|
|
1754
|
+
|
|
1755
|
+
// ONE wording for every not-ours verdict, so the report and the prompt can never describe the same
|
|
1756
|
+
// link differently. The foreign target is decoded FATALLY: bytes that are not text are quoted
|
|
1757
|
+
// nowhere, and fall to the unreadable answer rather than to a replaced string.
|
|
1758
|
+
const unverifiedLinkPosture = (ownership) => {
|
|
1759
|
+
if (ownership.verdict === 'tracked') return TRACKED_NODE_MODULES_LINK;
|
|
1760
|
+
if (ownership.verdict === 'lane-unproven') return `${LANE_UNPROVEN_NODE_MODULES_LINK} (${ownership.error})`;
|
|
1761
|
+
if (ownership.verdict === 'unreadable') return `${UNREADABLE_NODE_MODULES_LINK} (${ownership.error})`;
|
|
1762
|
+
const decoded = decodeTargetStrictly(ownership.target);
|
|
1763
|
+
return decoded === null
|
|
1764
|
+
? `${UNREADABLE_NODE_MODULES_LINK} (its target is not decodable text)`
|
|
1765
|
+
: `${FOREIGN_NODE_MODULES_LINK}: ${decoded}`;
|
|
1766
|
+
};
|
|
1767
|
+
|
|
1768
|
+
// Ownership is decided on the RAW TARGET BYTES, the same evidence cleanup binds on. A decoded string
|
|
1769
|
+
// is not that: two different byte sequences can decode to ONE string through UTF-8 replacement, and
|
|
1770
|
+
// the pair that collides would authorize removing a link this tool never placed. The outcome is
|
|
1771
|
+
// STRUCTURED because the three answers are different facts: ours, someone else's, or unknown — and
|
|
1772
|
+
// an unreadable link is the last of those, never a claim about where it points.
|
|
1773
|
+
const readLinkTarget = (fs, path) => {
|
|
1774
|
+
try {
|
|
1775
|
+
const raw = fs.readlink(path, { encoding: 'buffer' });
|
|
1776
|
+
return { target: Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw)) };
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
return { error: err?.code ?? 'fs error' };
|
|
1779
|
+
}
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
// Fatal UTF-8: a target that is not decodable text is not a target this tool will quote. Returning
|
|
1783
|
+
// null keeps it out of the record and the prompt entirely, rather than quoting a replaced string.
|
|
1784
|
+
const FATAL_UTF8_TARGET = new TextDecoder('utf-8', { fatal: true });
|
|
1785
|
+
const decodeTargetStrictly = (buffer) => {
|
|
1786
|
+
try {
|
|
1787
|
+
return FATAL_UTF8_TARGET.decode(buffer);
|
|
1788
|
+
} catch {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
};
|
|
1792
|
+
|
|
1793
|
+
// The ONE ownership question, asked the same way by every lane that acts on that link:
|
|
1794
|
+
// 'ours' | 'foreign' | 'tracked' | 'unreadable', plus the raw target where one was read.
|
|
1795
|
+
//
|
|
1796
|
+
// Matching bytes are HALF the proof. The cleanup ownership rule states the other half — only a
|
|
1797
|
+
// matching link IN THE IGNORED LANE is provision-ephemeral — and it is the half that decides whether
|
|
1798
|
+
// removal may be advised at all: a TRACKED link at this path is repository content, and offering to
|
|
1799
|
+
// delete it would advise destroying something the landing lane protects. A lane the probe cannot
|
|
1800
|
+
// establish is not the ignored lane either.
|
|
1801
|
+
const nodeModulesLinkOwnership = ({ fs, git, nmPath, wtRoot, mainRoot }) => {
|
|
1802
|
+
const read = readLinkTarget(fs, nmPath);
|
|
1803
|
+
if (read.error !== undefined) return { verdict: 'unreadable', error: read.error };
|
|
1804
|
+
if (Buffer.compare(read.target, Buffer.from(join(mainRoot, NODE_MODULES_REL))) !== 0) {
|
|
1805
|
+
return { verdict: 'foreign', target: read.target };
|
|
1806
|
+
}
|
|
1807
|
+
const lane = probeOwnedLane({ git, wtRoot, rel: NODE_MODULES_REL });
|
|
1808
|
+
if (lane.lane === 'ignored') return { verdict: 'ours', target: read.target };
|
|
1809
|
+
if (lane.lane === 'tracked') return { verdict: 'tracked', target: read.target };
|
|
1810
|
+
// The target WAS read here — only the lane is unsettled — so this must not borrow the wording of a
|
|
1811
|
+
// failed target read. Its cause is the lane probe's own: an untracked path, or a probe that could
|
|
1812
|
+
// not answer at all.
|
|
1813
|
+
return {
|
|
1814
|
+
verdict: 'lane-unproven',
|
|
1815
|
+
error: lane.detail ?? `the path is ${lane.lane}, and only an ignored one is the link provision places`,
|
|
1816
|
+
target: read.target,
|
|
1817
|
+
};
|
|
1818
|
+
};
|
|
1819
|
+
const INSTALL_RUNNABLE_DESCRIPTION = 'this checkout installs its own dependencies — the command below runs in it';
|
|
1820
|
+
|
|
1821
|
+
// Three views of ONE probe, so the record and the cold-start prompt can never disagree about this
|
|
1822
|
+
// checkout: `posture` is the RECORD's field, byte-for-byte what it has always been; `description` is
|
|
1823
|
+
// the prose half with no command in it; `command` is the runnable half, or null where none exists.
|
|
1824
|
+
// The split is what keeps a runnable install out of an unattributed prompt line — the posture string
|
|
1825
|
+
// IS a command in the ordinary case, so rendering it as prose would offer an instruction nothing
|
|
1826
|
+
// attributes and no command parser can see.
|
|
1827
|
+
const resolveInstall = ({ wtRoot, mainRoot, dependencyFree, fs, git }) => {
|
|
1881
1828
|
const nmPath = join(wtRoot, 'node_modules');
|
|
1882
1829
|
const nm = lstatNoFollow(fs.lstat, nmPath);
|
|
1830
|
+
const removal = `rm ${shellQuoteArg(nmPath)}`;
|
|
1883
1831
|
if (nm !== null && nm.isSymbolicLink()) {
|
|
1832
|
+
// A symlink is not proof of THIS tool's link. The provisioned one points at MAIN's
|
|
1833
|
+
// node_modules; anything else is a node the session (or a later hand) put there, and claiming
|
|
1834
|
+
// "a symlink into MAIN" about it would state a live fact nothing checked — and then advise
|
|
1835
|
+
// removing something this tool never placed. Ownership is decided by the raw target bytes,
|
|
1836
|
+
// the same evidence cleanup binds on.
|
|
1837
|
+
const ownership = nodeModulesLinkOwnership({ fs, git, nmPath, wtRoot, mainRoot });
|
|
1838
|
+
if (ownership.verdict !== 'ours') {
|
|
1839
|
+
// The target reaches the record's own value guard and the prompt's, and each refuses a hostile
|
|
1840
|
+
// one by NAME — so it is decoded FATALLY here: a lossy decode would fold undecodable bytes to
|
|
1841
|
+
// U+FFFD and hand both guards a sanitized string, a silent pass where a typed STOP was
|
|
1842
|
+
// promised. Bytes that are not text at all get the same treatment as an unreadable link:
|
|
1843
|
+
// nothing is claimed about them. displayValue belongs in diagnostics only.
|
|
1844
|
+
const unverified = unverifiedLinkPosture(ownership);
|
|
1845
|
+
return { posture: unverified, description: unverified, command: null };
|
|
1846
|
+
}
|
|
1884
1847
|
const advice = resolveInstallAdvice({ wtRoot, fs });
|
|
1885
1848
|
const separator = advice.command === null ? ' — ' : ' && ';
|
|
1886
|
-
|
|
1849
|
+
// The REMOVAL is runnable even when no install command is derivable, so it rides the attributed
|
|
1850
|
+
// line either way and never sits loose inside prose.
|
|
1851
|
+
return {
|
|
1852
|
+
posture: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first: ${removal}${separator}${advice.instruction}`,
|
|
1853
|
+
description: advice.command === null
|
|
1854
|
+
? `${SYMLINK_POSTURE_HEAD} — for isolation remove it first with the command below; then ${NEUTRAL_INSTALL_ADVICE}`
|
|
1855
|
+
: `${SYMLINK_POSTURE_HEAD} — for isolation remove it first and install; the command below does both`,
|
|
1856
|
+
command: advice.command === null ? removal : `${removal} && ${advice.command}`,
|
|
1857
|
+
};
|
|
1887
1858
|
}
|
|
1888
|
-
if (dependencyFree)
|
|
1889
|
-
|
|
1890
|
-
}
|
|
1859
|
+
if (dependencyFree) {
|
|
1860
|
+
return { posture: NO_DEPENDENCIES_POSTURE, description: NO_DEPENDENCIES_POSTURE, command: null };
|
|
1861
|
+
}
|
|
1862
|
+
const advice = resolveInstallAdvice({ wtRoot, fs });
|
|
1863
|
+
return advice.command === null
|
|
1864
|
+
? { posture: advice.instruction, description: advice.instruction, command: null }
|
|
1865
|
+
: { posture: advice.instruction, description: INSTALL_RUNNABLE_DESCRIPTION, command: advice.command };
|
|
1866
|
+
};
|
|
1867
|
+
|
|
1868
|
+
|
|
1869
|
+
// The satellite's cold-start prompt, composed from LIVE facts at both print sites (D16): provision
|
|
1870
|
+
// ends its report with it, and `prompt <slug>` re-prints it later from MAIN. The record is passed in
|
|
1871
|
+
// only so a value FROZEN at provision time can be named where it no longer matches.
|
|
1872
|
+
//
|
|
1873
|
+
// It PROBES NOTHING. The satellite-derived facts — the seeded plan and the install posture — are
|
|
1874
|
+
// arguments, because provision has already established both by the time it composes and a second
|
|
1875
|
+
// read there would be a fresh failure window after the work is done; `prompt` resolves them itself,
|
|
1876
|
+
// where a failure is the whole outcome of the run.
|
|
1877
|
+
const composeSatellitePromptFor = ({ root, wtRoot, slug, branch, record, plan, install }) => composeSatellitePrompt({
|
|
1878
|
+
slug,
|
|
1879
|
+
branch,
|
|
1880
|
+
worktreePath: wtRoot,
|
|
1881
|
+
plan,
|
|
1882
|
+
live: {
|
|
1883
|
+
sharedQueue: orientationFields({ root, slug }).sharedQueue,
|
|
1884
|
+
landingRule: LANDING_FROM_MAIN,
|
|
1885
|
+
landingCommand: landingCommand({ root, slug }),
|
|
1886
|
+
installPosture: install.posture,
|
|
1887
|
+
installDescription: install.description,
|
|
1888
|
+
installCommand: install.command,
|
|
1889
|
+
},
|
|
1890
|
+
record,
|
|
1891
|
+
});
|
|
1891
1892
|
|
|
1892
1893
|
const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyFree, git, fs, report, journal = NO_JOURNAL }) => {
|
|
1893
1894
|
// The lane places ONLY a symlink, so the kind gate admits only a symlink at this path: a
|
|
@@ -1898,6 +1899,16 @@ const provisionNodeModules = ({ root, rootReal, wtRoot, installFlag, dependencyF
|
|
|
1898
1899
|
const dst = join(wtRoot, NODE_MODULES_REL);
|
|
1899
1900
|
const existing = lstatNoFollow(fs.lstat, dst);
|
|
1900
1901
|
if (existing !== null && existing.isSymbolicLink()) {
|
|
1902
|
+
// The unlink-first advice belongs to OUR link and to no other: for a link this tool never
|
|
1903
|
+
// placed it would offer to delete a node whose target it has not established, and the
|
|
1904
|
+
// cold-start prompt would then contradict the report in the same breath. Same ownership
|
|
1905
|
+
// question, same raw-bytes evidence.
|
|
1906
|
+
const ownership = nodeModulesLinkOwnership({ fs, git, nmPath: dst, wtRoot, mainRoot: root });
|
|
1907
|
+
if (ownership.verdict !== 'ours') {
|
|
1908
|
+
journalLink('kept');
|
|
1909
|
+
report.push(` node_modules: ${displayValue(unverifiedLinkPosture(ownership))}`);
|
|
1910
|
+
return 'install-printed-unverified-link';
|
|
1911
|
+
}
|
|
1901
1912
|
// isolation only exists BEFORE the link: an install through it would write into MAIN
|
|
1902
1913
|
const separator = install.command === null ? ' — ' : ' && ';
|
|
1903
1914
|
journalLink('kept');
|
|
@@ -2254,7 +2265,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2254
2265
|
const inFlight = plansInFlight(targetPath, fs.readdir);
|
|
2255
2266
|
if (inFlight.length !== 1 || inFlight[0] !== seed.name) {
|
|
2256
2267
|
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`,
|
|
2268
|
+
`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
2269
|
);
|
|
2259
2270
|
}
|
|
2260
2271
|
|
|
@@ -2278,6 +2289,33 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2278
2289
|
}
|
|
2279
2290
|
}
|
|
2280
2291
|
|
|
2292
|
+
// ONE probe, three views: the record takes the posture, the prompt takes the prose and the
|
|
2293
|
+
// runnable half. A second probe here could disagree with what the record is about to state.
|
|
2294
|
+
const install = resolveInstall({ wtRoot: targetPath, mainRoot: root, dependencyFree, fs, git });
|
|
2295
|
+
const fields = {
|
|
2296
|
+
slug,
|
|
2297
|
+
branch,
|
|
2298
|
+
includes: includesRecorded,
|
|
2299
|
+
nodeModules: nodeModulesMode,
|
|
2300
|
+
vscode: vscodeMode,
|
|
2301
|
+
install: install.posture,
|
|
2302
|
+
...orientationFields({ root, slug }),
|
|
2303
|
+
};
|
|
2304
|
+
// Composed BEFORE the record is written and before ANY output: the values are already established
|
|
2305
|
+
// (the seeded plan passed the EXACTLY-ONE check above, the install posture is the one going into
|
|
2306
|
+
// the record), so a composition failure lands where every other late provision failure lands —
|
|
2307
|
+
// with the record bytes untouched and no success line printed — instead of contradicting a success
|
|
2308
|
+
// message it would otherwise follow.
|
|
2309
|
+
const prompt = composeSatellitePromptFor({
|
|
2310
|
+
root,
|
|
2311
|
+
wtRoot: targetPath,
|
|
2312
|
+
slug,
|
|
2313
|
+
branch,
|
|
2314
|
+
record: fields,
|
|
2315
|
+
plan: seed.name,
|
|
2316
|
+
install,
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2281
2319
|
// The record refresh runs LAST, after the in-flight check and the verify, in BOTH lanes —
|
|
2282
2320
|
// the record attests only a VERIFIED provision; a failed run leaves the prior record bytes
|
|
2283
2321
|
// (the stub on a failed first provision). On resume the generic kept-worktree NOTE does not
|
|
@@ -2289,15 +2327,7 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2289
2327
|
slug,
|
|
2290
2328
|
branch,
|
|
2291
2329
|
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
|
-
},
|
|
2330
|
+
fields,
|
|
2301
2331
|
fs,
|
|
2302
2332
|
report,
|
|
2303
2333
|
});
|
|
@@ -2312,6 +2342,10 @@ const finishProvision = ({ root, rootReal, targetPath, slug, branch, flags, seed
|
|
|
2312
2342
|
for (const line of report) log(line);
|
|
2313
2343
|
log(`[worktrees] provisioned ${slug} at ${targetPath} (branch ${branch}, base ${base})`);
|
|
2314
2344
|
log(`open it: code -n ${shellQuoteArg(targetPath)}`);
|
|
2345
|
+
// The report ENDS with the satellite's cold-start prompt: the facts a fresh session in that
|
|
2346
|
+
// checkout cannot derive are worth nothing if they are only re-derivable on request.
|
|
2347
|
+
log('');
|
|
2348
|
+
log(prompt);
|
|
2315
2349
|
return EXIT.ok;
|
|
2316
2350
|
};
|
|
2317
2351
|
|
|
@@ -2362,6 +2396,30 @@ export const runList = ({ cwd, git, deps, log }) => {
|
|
|
2362
2396
|
return EXIT.ok;
|
|
2363
2397
|
};
|
|
2364
2398
|
|
|
2399
|
+
// ── prompt ─────────────────────────────────────────────────────────────────────────────
|
|
2400
|
+
|
|
2401
|
+
// Read-only: it resolves the satellite, proves the handoff identity there, and prints. Nothing is
|
|
2402
|
+
// written, and MAIN is where it must run — the orientation it composes is MAIN's, so the
|
|
2403
|
+
// linked-worktree refusal in resolveRoots is the guard that keeps it honest.
|
|
2404
|
+
export const runPrompt = ({ argvSlug, cwd, git, deps, log }) => {
|
|
2405
|
+
const fs = fsOf(deps);
|
|
2406
|
+
const slug = validateSlug(argvSlug);
|
|
2407
|
+
const { root } = resolveRoots(cwd, git);
|
|
2408
|
+
const entry = findSatelliteEntry({ root, slug, branch: null, git, fs });
|
|
2409
|
+
const identity = readSatelliteIdentity({ entry, slug, fs });
|
|
2410
|
+
const wtRoot = entry.path;
|
|
2411
|
+
log(composeSatellitePromptFor({
|
|
2412
|
+
root,
|
|
2413
|
+
wtRoot,
|
|
2414
|
+
slug,
|
|
2415
|
+
branch: identity.branch,
|
|
2416
|
+
record: identity.record,
|
|
2417
|
+
plan: resolveSeededPlan({ wtRoot, readdir: fs.readdir }),
|
|
2418
|
+
install: resolveInstall({ wtRoot, mainRoot: root, dependencyFree: declaresNoDependencies({ wtRoot, fs }), fs, git }),
|
|
2419
|
+
}));
|
|
2420
|
+
return EXIT.ok;
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2365
2423
|
// ── land + cleanup ────────────────────────────────────────────────────────────────────
|
|
2366
2424
|
|
|
2367
2425
|
const nulFields = (text) => String(text).split('\0').filter((field) => field !== '');
|
|
@@ -2433,57 +2491,6 @@ const withPrepareLock = ({ commonDir, fs, now }, action) => {
|
|
|
2433
2491
|
return result;
|
|
2434
2492
|
};
|
|
2435
2493
|
|
|
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
2494
|
const changedPaths = (git, args, cwd, label) =>
|
|
2488
2495
|
nulFields(gitRead(git, [...args, '-z', '--', ...TRANSFER_EXCLUSIONS], cwd, label).stdout);
|
|
2489
2496
|
|
|
@@ -2689,12 +2696,15 @@ const runSyncAdapter = ({ root, mainHead, transferPaths, git, fs, deps, report }
|
|
|
2689
2696
|
return delta;
|
|
2690
2697
|
};
|
|
2691
2698
|
|
|
2692
|
-
|
|
2699
|
+
// prepared-head rides the SAME record refresh as prepared-tree (D8): MAIN's HEAD at prepare time
|
|
2700
|
+
// is what lets the return rung tell a still-pending prepared set from an already-committed one —
|
|
2701
|
+
// a clean post-commit index reproduces the committed tree, so the tree OID alone cannot.
|
|
2702
|
+
const recordPreparedTree = ({ identity, slug, entry, prepared, preparedHead, fs }) => {
|
|
2693
2703
|
writeHandoffRecord({
|
|
2694
2704
|
wtRoot: entry.path,
|
|
2695
2705
|
slug,
|
|
2696
2706
|
branch: identity.branch,
|
|
2697
|
-
fields: { ...identity.record, prepared },
|
|
2707
|
+
fields: { ...identity.record, prepared, preparedHead },
|
|
2698
2708
|
fs,
|
|
2699
2709
|
report: [],
|
|
2700
2710
|
});
|
|
@@ -2714,12 +2724,12 @@ const dirtyMainStop = ({ root, git, record, porcelain }) => {
|
|
|
2714
2724
|
const hasTrackedUnstaged = trackedEntries.some((entry) => entry.code[1] !== ' ');
|
|
2715
2725
|
const mayReset = converged && !hasTrackedUnstaged;
|
|
2716
2726
|
const classification = converged
|
|
2717
|
-
? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${record.prepared}`
|
|
2727
|
+
? `converged re-run: current staged write-tree matches the previous prepare's recorded OID ${displayValue(record.prepared)}`
|
|
2718
2728
|
: record.prepared === null
|
|
2719
2729
|
? 'foreign staged work: no previous prepare OID is recorded'
|
|
2720
2730
|
: 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}`;
|
|
2731
|
+
? `foreign staged work: the index has no staged delta against HEAD, although its write-tree matches the recorded OID ${displayValue(record.prepared)}`
|
|
2732
|
+
: `foreign staged work: current staged write-tree differs from the previous prepare's recorded OID ${displayValue(record.prepared)}`;
|
|
2723
2733
|
const leftoversReport = leftovers.length === 0
|
|
2724
2734
|
? []
|
|
2725
2735
|
: mayReset
|
|
@@ -2788,7 +2798,7 @@ export const runLand = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
2788
2798
|
const syncDelta = runSyncAdapter({ root, mainHead, transferPaths, git, fs, deps, report });
|
|
2789
2799
|
const preparedTree = gitRead(git, ['write-tree'], root, 'cannot write the prepared main tree').stdout.trim();
|
|
2790
2800
|
try {
|
|
2791
|
-
recordPreparedTree({ identity, slug, entry, prepared: preparedTree, fs });
|
|
2801
|
+
recordPreparedTree({ identity, slug, entry, prepared: preparedTree, preparedHead: mainHead, fs });
|
|
2792
2802
|
} catch (error) {
|
|
2793
2803
|
throw withRollbackFailures(error, rollbackMain({ root, mainHead, git, fs }));
|
|
2794
2804
|
}
|
|
@@ -2876,7 +2886,7 @@ const registryRoots = () => {
|
|
|
2876
2886
|
const safeRecordedPath = (path) => {
|
|
2877
2887
|
const normalized = normalizeSlashes(String(path)).replace(/^\.\//, '').replace(/\/$/, '');
|
|
2878
2888
|
if (!normalized || isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
2879
|
-
throw stop(`handoff record carries an unsafe provision path: ${path}`);
|
|
2889
|
+
throw stop(`handoff record carries an unsafe provision path: ${displayValue(path)}`);
|
|
2880
2890
|
}
|
|
2881
2891
|
return normalized;
|
|
2882
2892
|
};
|
|
@@ -3196,12 +3206,13 @@ export const runCleanup = ({ argvSlug, flags, cwd, git, deps, log }) => {
|
|
|
3196
3206
|
export const parseArgs = (argv) => {
|
|
3197
3207
|
const [sub, ...rest] = argv;
|
|
3198
3208
|
if (sub === undefined || sub === '--help' || sub === '-h') return { sub: 'help' };
|
|
3199
|
-
if (!['provision', 'list', 'land', 'cleanup'].includes(sub)) {
|
|
3209
|
+
if (!['provision', 'list', 'prompt', 'land', 'cleanup'].includes(sub)) {
|
|
3200
3210
|
throw usageStop(`unknown subcommand ${JSON.stringify(sub)}\n${USAGE}`);
|
|
3201
3211
|
}
|
|
3202
3212
|
const SUB_FLAGS = {
|
|
3203
3213
|
provision: ['--plan', '--as', '--dir', '--branch', '--include', '--install', '--resume'],
|
|
3204
3214
|
list: [],
|
|
3215
|
+
prompt: [],
|
|
3205
3216
|
land: ['--prepare'],
|
|
3206
3217
|
cleanup: ['--branch', '--abandon'],
|
|
3207
3218
|
};
|
|
@@ -3245,6 +3256,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
3245
3256
|
return runProvision({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
3246
3257
|
}
|
|
3247
3258
|
if (parsed.sub === 'list') return runList({ cwd, git, deps, log });
|
|
3259
|
+
if (parsed.sub === 'prompt') return runPrompt({ argvSlug: parsed.slug, cwd, git, deps, log });
|
|
3248
3260
|
if (parsed.sub === 'land') {
|
|
3249
3261
|
return runLand({ argvSlug: parsed.slug, flags: parsed.flags, cwd, git, deps, log });
|
|
3250
3262
|
}
|
|
@@ -3255,5 +3267,4 @@ export const runCli = (argv, deps = {}) => {
|
|
|
3255
3267
|
}
|
|
3256
3268
|
};
|
|
3257
3269
|
|
|
3258
|
-
|
|
3259
|
-
if (isDirectRun) process.exitCode = runCli(process.argv.slice(2));
|
|
3270
|
+
if (isDirectRun(import.meta.url)) process.exitCode = runCli(process.argv.slice(2));
|