@sabaiway/agent-workflow-kit 14.31.0 → 14.33.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 +80 -0
- package/README.md +3 -3
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/recommendations.md +5 -5
- package/references/modes/upgrade.md +1 -1
- package/references/modes/velocity.md +7 -5
- package/references/modes/worktrees.md +10 -9
- package/references/shared/deploy-tail.md +1 -1
- package/tools/apply-danger-check.mjs +44 -8
- package/tools/git-hooks.mjs +199 -0
- package/tools/init-project.mjs +30 -5
- package/tools/recommendations.mjs +87 -75
- package/tools/velocity-profile.mjs +118 -10
- package/tools/worktrees.mjs +28 -10
- package/tools/write-lanes.mjs +27 -6
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
writeAutonomyProfile, writeVelocityProfile,
|
|
13
13
|
} from './velocity-profile.mjs';
|
|
14
14
|
import { KIT_GROUNDING_TOOL, bundledSandboxRecipe, usedBridges } from './bridge-sandbox-recipe.mjs';
|
|
15
|
-
import { mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
|
|
15
|
+
import { homeRelative, mergeAllowWrite, stateDirsOf } from './bridge-state-dirs.mjs';
|
|
16
|
+
import { nearestExistingDir, worktreesGrantDir, worktreesGrantHoldsHome } from './worktrees.mjs';
|
|
16
17
|
import { loadConfig } from './orchestration-config.mjs';
|
|
17
18
|
import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
|
|
18
19
|
import { STATE_BLOCK_HOOK_FILE_REL, buildStopHookEntry, mergeStopEntry, writeStateBlockHook } from './state-block-hook.mjs';
|
|
@@ -23,6 +24,7 @@ import { planFor } from './jev-skill.mjs';
|
|
|
23
24
|
import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
|
|
24
25
|
import { findOnPath } from './detect-backends.mjs';
|
|
25
26
|
import { consoleArgv } from './write-lanes.mjs';
|
|
27
|
+
import { BUNDLED_SCRIPT_NAMES, INSTALLER_REL, SCRIPTS_REL, formatPlan, planGitHooks } from './git-hooks.mjs';
|
|
26
28
|
import { isDirectRun } from './direct-run.mjs';
|
|
27
29
|
import { dirCovers as covers } from './declared-paths.mjs';
|
|
28
30
|
|
|
@@ -34,8 +36,10 @@ const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing', ...RESEED_VARIANTS];
|
|
|
34
36
|
const STOP = 'state-block';
|
|
35
37
|
const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
|
|
36
38
|
const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
|
|
39
|
+
const GIT_VARIANTS = ['enforcement', 'commit-guard'];
|
|
40
|
+
const WORKTREES = 'worktrees-dir';
|
|
37
41
|
const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, STOP, ...MCP_VARIANTS, ...AGENT_VARIANTS,
|
|
38
|
-
...SKILL_VARIANTS];
|
|
42
|
+
...SKILL_VARIANTS, ...GIT_VARIANTS, WORKTREES];
|
|
39
43
|
const HOOK_KEYS = ['hooks.PreToolUse', 'hooks.Stop'];
|
|
40
44
|
const CREDENTIALS = 'sandbox.credentials.envVars';
|
|
41
45
|
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -179,7 +183,12 @@ export const scopeOf = (variant, facts, deps = {}) => {
|
|
|
179
183
|
set('permissions.ask', RENDER_OWNED_REDLINE_RULES);
|
|
180
184
|
set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
|
|
181
185
|
allowed[CREDENTIALS] = (value) => value.mode === 'deny';
|
|
182
|
-
}
|
|
186
|
+
}
|
|
187
|
+
const grantDir = variant === WORKTREES ? worktreesGrantDir(facts.root, facts) : null;
|
|
188
|
+
if (grantDir !== null && !worktreesGrantHoldsHome(grantDir, facts.home, facts)) {
|
|
189
|
+
set('sandbox.filesystem.allowWrite', [homeRelative(grantDir, facts.home)]);
|
|
190
|
+
}
|
|
191
|
+
if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
|
|
183
192
|
else if (variant === STOP) set('hooks.Stop', [buildStopHookEntry()]);
|
|
184
193
|
else if (MCP_VARIANTS.includes(variant)) {
|
|
185
194
|
set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
|
|
@@ -193,14 +202,25 @@ export const scopeOf = (variant, facts, deps = {}) => {
|
|
|
193
202
|
const directories = SKILL_VARIANTS.includes(variant) ? [] : [join(facts.root, CLAUDE_DIR)];
|
|
194
203
|
if (HOOK_VARIANTS.includes(variant) || variant === STOP) directories.push(join(facts.root, HOOKS_DIR));
|
|
195
204
|
if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
|
|
205
|
+
const existingAncestor = grantDir === null ? null : nearestExistingDir(grantDir, facts);
|
|
206
|
+
for (let at = grantDir; at !== existingAncestor; at = dirname(at)) directories.push(at);
|
|
196
207
|
for (const target of targets) {
|
|
197
208
|
for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
|
|
198
209
|
filePaths.push(target.path);
|
|
199
210
|
}
|
|
211
|
+
const scripts = join(facts.root, SCRIPTS_REL);
|
|
212
|
+
if (GIT_VARIANTS.includes(variant)) {
|
|
213
|
+
directories.splice(0, directories.length, scripts);
|
|
214
|
+
filePaths.push(...BUNDLED_SCRIPT_NAMES.map((name) => join(scripts, name)));
|
|
215
|
+
}
|
|
216
|
+
const runs = GIT_VARIANTS.includes(variant) ? ['node', INSTALLER_REL,
|
|
217
|
+
...(variant === 'commit-guard' ? ['--commit-guard', join(facts.toolsDir, 'commit-guard.mjs')] : [])] : null;
|
|
200
218
|
return {
|
|
219
|
+
admitsRun: (argv) => runs !== null && equal(argv, runs),
|
|
201
220
|
admits: ({ path, key, change, value }) => {
|
|
202
|
-
const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL
|
|
203
|
-
|
|
221
|
+
const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL
|
|
222
|
+
: path === (variant === WORKTREES ? SETTINGS_LOCAL_FILE : SETTINGS_FILE);
|
|
223
|
+
const gains = variant === 'bridge-tier' || variant === WORKTREES || HOOK_KEYS.includes(key) || key === ENABLED_KEY || key === CREDENTIALS
|
|
204
224
|
|| (VELOCITY_VARIANTS.includes(variant) && key === 'permissions.ask');
|
|
205
225
|
return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
|
|
206
226
|
},
|
|
@@ -213,7 +233,7 @@ const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8'
|
|
|
213
233
|
const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
|
|
214
234
|
const maskedFiles = (variant, facts) => {
|
|
215
235
|
const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
|
|
216
|
-
|| variant === STOP ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
|
|
236
|
+
|| variant === STOP || variant === WORKTREES ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
|
|
217
237
|
return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
|
|
218
238
|
? [`masked: ${path}`] : []);
|
|
219
239
|
};
|
|
@@ -221,6 +241,8 @@ export const plannedChange = (variant, facts, deps = {}) => {
|
|
|
221
241
|
const files = [];
|
|
222
242
|
const writes = [];
|
|
223
243
|
const temporaries = [];
|
|
244
|
+
const created = [];
|
|
245
|
+
let runs = null;
|
|
224
246
|
const writerDeps = writerFacts(facts, deps);
|
|
225
247
|
const addFile = (path, before, after) => {
|
|
226
248
|
files.push({ path, before: before ?? null, after });
|
|
@@ -280,6 +302,15 @@ export const plannedChange = (variant, facts, deps = {}) => {
|
|
|
280
302
|
temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-tmp-*`);
|
|
281
303
|
if (target.action === 'replace') temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-old-*`);
|
|
282
304
|
}
|
|
305
|
+
} else if (GIT_VARIANTS.includes(variant)) {
|
|
306
|
+
const plan = (deps.planGitHooks ?? planGitHooks)({ root: facts.root, variant, toolsDir: facts.toolsDir });
|
|
307
|
+
for (const copy of plan.copies.filter(({ action }) => action === 'created')) writes.push({ path: copy.path, bytes: copy.bytes });
|
|
308
|
+
runs = { argv: plan.runs.shown, line: formatPlan(plan).at(-1) };
|
|
309
|
+
} else if (variant === WORKTREES) {
|
|
310
|
+
const plan = writeVelocityProfile({ cwd: facts.root, dryRun: true, worktrees: true }, writerDeps);
|
|
311
|
+
const before = plan.localSettings.data ?? null;
|
|
312
|
+
addFile(SETTINGS_LOCAL_FILE, before, mergeAllowWrite(before ?? {}, plan.worktreesDirToAdd));
|
|
313
|
+
created.push(...plan.worktreesDirsToCreate);
|
|
283
314
|
} else throw new Error(`unsupported variant: ${variant}`);
|
|
284
315
|
} catch (error) {
|
|
285
316
|
throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
|
|
@@ -289,7 +320,8 @@ export const plannedChange = (variant, facts, deps = {}) => {
|
|
|
289
320
|
for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
|
|
290
321
|
for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
|
|
291
322
|
}
|
|
292
|
-
|
|
323
|
+
for (const path of created) directories.add(path);
|
|
324
|
+
return { files, writes, temporaries, directories: [...directories], runs };
|
|
293
325
|
};
|
|
294
326
|
|
|
295
327
|
export const checkApply = (variant, facts, deps = {}) => {
|
|
@@ -318,6 +350,10 @@ export const checkApply = (variant, facts, deps = {}) => {
|
|
|
318
350
|
}
|
|
319
351
|
for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
|
|
320
352
|
lines.push(...plan.temporaries);
|
|
353
|
+
if (plan.runs) {
|
|
354
|
+
lines.push(plan.runs.line);
|
|
355
|
+
if (!scope.admitsRun(plan.runs.argv)) refused.push({ path: 'runs:', key: plan.runs.argv.join(' '), reason: `beyond the declared scope of ${variant}` });
|
|
356
|
+
}
|
|
321
357
|
if (refused.length) {
|
|
322
358
|
for (const entry of refused) lines.push(`refused: ${entry.path} ${entry.key} — ${entry.reason}`);
|
|
323
359
|
lines.push('nothing written');
|
|
@@ -369,7 +405,7 @@ export const main = (argv, io = {}) => {
|
|
|
369
405
|
platform: resolved.platform, claudeDir: args['claude-dir'] };
|
|
370
406
|
let result;
|
|
371
407
|
try {
|
|
372
|
-
result = checkApply(args.variant, facts, { detect: resolved.detect });
|
|
408
|
+
result = checkApply(args.variant, facts, { detect: resolved.detect, planGitHooks: resolved.planGitHooks });
|
|
373
409
|
} catch (error) {
|
|
374
410
|
for (const line of error.lines ?? [error.message]) resolved.log(line);
|
|
375
411
|
return 3;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// git-hooks.mjs — the thin writer of the two git-hook items (`enforcement`, `commit-guard`): it copies
|
|
3
|
+
// the bundled scripts create-only and spawns the project's own scripts/install-git-hooks.mjs, which
|
|
4
|
+
// decides the hook at the write. The leaf decides nothing about the hook: the danger check binds the
|
|
5
|
+
// copies and the installer bytes this dry run answers, never git's hooks dir.
|
|
6
|
+
import { lstatSync, readFileSync, readdirSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { writeProjectFileCreateOnly } from './atomic-write.mjs';
|
|
12
|
+
import { NODE_EVIDENCE_SCRIPTS } from './node-evidence.mjs';
|
|
13
|
+
import { shellQuoteArg } from './repo-lex.mjs';
|
|
14
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
15
|
+
|
|
16
|
+
const TOOLS = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
export const SCRIPTS_REL = 'scripts';
|
|
18
|
+
export const INSTALLER_REL = 'scripts/install-git-hooks.mjs';
|
|
19
|
+
const bundleDir = (toolsDir) => join(toolsDir, '..', 'references', 'scripts');
|
|
20
|
+
export const BUNDLED_SCRIPT_NAMES = Object.freeze(readdirSync(bundleDir(TOOLS)).filter((name) => name.endsWith('.mjs')).sort());
|
|
21
|
+
|
|
22
|
+
const EXIT_OK = 0;
|
|
23
|
+
const EXIT_INCOMPLETE = 1;
|
|
24
|
+
const EXIT_USAGE = 2;
|
|
25
|
+
const EXIT_REFUSED = 3;
|
|
26
|
+
const USAGE = 'usage: git-hooks (--enforcement | --commit-guard) [--cwd <root>] [--apply]';
|
|
27
|
+
const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
28
|
+
const usageError = (message) => Object.assign(new Error(message), { exitCode: EXIT_USAGE });
|
|
29
|
+
const refusal = (message) => Object.assign(new Error(message), { exitCode: EXIT_REFUSED });
|
|
30
|
+
|
|
31
|
+
const nodeAt = (path, lstat) => {
|
|
32
|
+
try {
|
|
33
|
+
return lstat(path);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error?.code === 'ENOENT') return null;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const isPlainDir = (node) => node !== null && node.isDirectory() && !node.isSymbolicLink();
|
|
40
|
+
const isFile = (node) => node !== null && node.isFile() && !node.isSymbolicLink();
|
|
41
|
+
|
|
42
|
+
// The dry run: what the apply copies and the installer it runs, with the bytes of each.
|
|
43
|
+
export const planGitHooks = ({ root, variant, toolsDir = TOOLS, lstat = lstatSync, readFile = readFileSync }) => {
|
|
44
|
+
const scripts = join(root, SCRIPTS_REL);
|
|
45
|
+
const scriptsNode = nodeAt(scripts, lstat);
|
|
46
|
+
if (scriptsNode !== null && !isPlainDir(scriptsNode)) throw refusal(`${scripts} is not a plain directory`);
|
|
47
|
+
const kinds = new Map();
|
|
48
|
+
const names = variant === 'enforcement' ? BUNDLED_SCRIPT_NAMES : [INSTALLER_REL.slice(SCRIPTS_REL.length + 1)];
|
|
49
|
+
for (const name of scriptsNode === null ? [] : names) {
|
|
50
|
+
const path = join(scripts, name);
|
|
51
|
+
const node = nodeAt(path, lstat);
|
|
52
|
+
if (node !== null && !isFile(node)) throw refusal(`${path} is not a regular file`);
|
|
53
|
+
kinds.set(name, node === null ? 'absent' : 'file');
|
|
54
|
+
}
|
|
55
|
+
const installer = join(root, INSTALLER_REL);
|
|
56
|
+
const deployed = NODE_EVIDENCE_SCRIPTS.some((name) => kinds.get(name) === 'file');
|
|
57
|
+
const copies = variant === 'enforcement' && !deployed ? BUNDLED_SCRIPT_NAMES.map((name) => {
|
|
58
|
+
if (kinds.get(name) === 'file') return { name, path: join(scripts, name), action: 'kept' };
|
|
59
|
+
return { name, path: join(scripts, name), action: 'created', bytes: readFile(join(bundleDir(toolsDir), name)) };
|
|
60
|
+
}) : [];
|
|
61
|
+
const placed = copies.find(({ path, action }) => path === installer && action === 'created');
|
|
62
|
+
if (!placed && !isFile(nodeAt(installer, lstat))) throw refusal(`${installer} is absent — nothing here places the hook installer`);
|
|
63
|
+
const guard = variant === 'commit-guard' ? ['--commit-guard', join(toolsDir, 'commit-guard.mjs')] : [];
|
|
64
|
+
return {
|
|
65
|
+
root,
|
|
66
|
+
variant,
|
|
67
|
+
scriptsCreated: scriptsNode === null,
|
|
68
|
+
copies,
|
|
69
|
+
runs: { argv: ['node', installer, ...guard], shown: ['node', INSTALLER_REL, ...guard], bytes: placed ? placed.bytes : readFile(installer) },
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const formatPlan = (plan) => [
|
|
74
|
+
...plan.copies.map(({ name, action, bytes }) => `copy: ${SCRIPTS_REL}/${name} ${action}${action === 'created' ? ` sha256:${sha(bytes)}` : ''}`),
|
|
75
|
+
`runs: ${plan.runs.shown.map(shellQuoteArg).join(' ')} sha256:${sha(plan.runs.bytes)}`,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// Removes exactly the files this run created, then scripts/ when this run created it and it is empty;
|
|
79
|
+
// every path it cannot remove is answered for the caller to name.
|
|
80
|
+
const rollBack = (plan, created, io) => {
|
|
81
|
+
const scripts = join(plan.root, SCRIPTS_REL);
|
|
82
|
+
const left = [];
|
|
83
|
+
const dirNow = () => nodeAt(scripts, io.lstat);
|
|
84
|
+
for (const path of created) {
|
|
85
|
+
const dir = dirNow();
|
|
86
|
+
const node = nodeAt(path, io.lstat);
|
|
87
|
+
if (!isPlainDir(dir) || dir.ino !== created.dirIno || !isFile(node)) {
|
|
88
|
+
left.push(path);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
io.unlink(path);
|
|
93
|
+
} catch {
|
|
94
|
+
left.push(path);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (plan.scriptsCreated && created.dirIno !== undefined) {
|
|
98
|
+
const dir = dirNow();
|
|
99
|
+
let empty = false;
|
|
100
|
+
try {
|
|
101
|
+
empty = isPlainDir(dir) && dir.ino === created.dirIno && io.readdir(scripts).length === 0;
|
|
102
|
+
} catch {
|
|
103
|
+
empty = false;
|
|
104
|
+
}
|
|
105
|
+
if (empty) {
|
|
106
|
+
try {
|
|
107
|
+
io.rmdir(scripts);
|
|
108
|
+
} catch {
|
|
109
|
+
left.push(scripts);
|
|
110
|
+
}
|
|
111
|
+
} else if (!isPlainDir(dir) || dir.ino !== created.dirIno) left.push(scripts);
|
|
112
|
+
}
|
|
113
|
+
return left;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const applyGitHooks = (plan, io = {}) => {
|
|
117
|
+
const resolved = { lstat: lstatSync, unlink: unlinkSync, rmdir: rmdirSync, readdir: readdirSync, env: process.env,
|
|
118
|
+
log: console.log, error: console.error, writeFile: writeProjectFileCreateOnly,
|
|
119
|
+
spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
|
|
120
|
+
const created = [];
|
|
121
|
+
const leftovers = [];
|
|
122
|
+
const fail = (lines) => {
|
|
123
|
+
for (const line of lines) resolved.error(line);
|
|
124
|
+
for (const path of [...rollBack(plan, created, resolved), ...leftovers]) resolved.error(`not removed: ${path}`);
|
|
125
|
+
return EXIT_INCOMPLETE;
|
|
126
|
+
};
|
|
127
|
+
const scripts = join(plan.root, SCRIPTS_REL);
|
|
128
|
+
const noteDir = () => {
|
|
129
|
+
const dir = nodeAt(scripts, resolved.lstat);
|
|
130
|
+
if (created.dirIno === undefined && isPlainDir(dir)) created.dirIno = dir.ino;
|
|
131
|
+
};
|
|
132
|
+
for (const copy of plan.copies.filter(({ action }) => action === 'created')) {
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = resolved.writeFile(plan.root, `${SCRIPTS_REL}/${copy.name}`, copy.bytes);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
noteDir();
|
|
138
|
+
return fail([error?.message ?? String(error)]);
|
|
139
|
+
}
|
|
140
|
+
noteDir();
|
|
141
|
+
if (result.tmpLeftBehind) leftovers.push(result.tmpLeftBehind);
|
|
142
|
+
if (!result.created) return fail([`${copy.path} appeared after the check — the file found there stands`]);
|
|
143
|
+
created.push(copy.path);
|
|
144
|
+
}
|
|
145
|
+
const child = resolved.spawn(plan.runs.argv, { cwd: plan.root, env: resolved.env, stdio: 'inherit' });
|
|
146
|
+
if (child.status !== 0) return fail([`installer exited ${child.status ?? child.signal ?? child.error?.code}`]);
|
|
147
|
+
for (const path of leftovers) resolved.error(`not removed: ${path}`);
|
|
148
|
+
return EXIT_OK;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const parseArgs = (argv) => {
|
|
152
|
+
if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
|
|
153
|
+
const args = { apply: false, cwd: undefined, variants: [] };
|
|
154
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
155
|
+
const flag = argv[index];
|
|
156
|
+
if (flag === '--apply') args.apply = true;
|
|
157
|
+
else if (flag === '--enforcement' || flag === '--commit-guard') args.variants.push(flag.slice(2));
|
|
158
|
+
else if (flag === '--cwd') {
|
|
159
|
+
const value = argv[index + 1];
|
|
160
|
+
if (value === undefined || value.startsWith('-')) throw usageError('--cwd takes a value');
|
|
161
|
+
args.cwd = value;
|
|
162
|
+
index += 1;
|
|
163
|
+
} else throw usageError(`unknown argument: ${flag}`);
|
|
164
|
+
}
|
|
165
|
+
if (args.variants.length !== 1) throw usageError('pass exactly one of --enforcement and --commit-guard');
|
|
166
|
+
return { help: false, apply: args.apply, cwd: args.cwd, variant: args.variants[0] };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export const main = (argv, io = {}) => {
|
|
170
|
+
const resolved = { log: console.log, error: console.error, cwd: process.cwd(), toolsDir: TOOLS, ...io };
|
|
171
|
+
let args;
|
|
172
|
+
try {
|
|
173
|
+
args = parseArgs(argv);
|
|
174
|
+
if (args.help) {
|
|
175
|
+
resolved.log(USAGE);
|
|
176
|
+
return EXIT_OK;
|
|
177
|
+
}
|
|
178
|
+
args.root = resolve(resolved.cwd, args.cwd ?? '.');
|
|
179
|
+
if (!statSync(args.root, { throwIfNoEntry: false })?.isDirectory()) throw usageError(`--cwd is not a directory: ${args.root}`);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
resolved.error(error.message);
|
|
182
|
+
resolved.error(USAGE);
|
|
183
|
+
return EXIT_USAGE;
|
|
184
|
+
}
|
|
185
|
+
let plan;
|
|
186
|
+
try {
|
|
187
|
+
plan = planGitHooks({ root: args.root, variant: args.variant, toolsDir: resolved.toolsDir });
|
|
188
|
+
} catch (error) {
|
|
189
|
+
resolved.error(error.message);
|
|
190
|
+
return EXIT_REFUSED;
|
|
191
|
+
}
|
|
192
|
+
if (!args.apply) {
|
|
193
|
+
for (const line of formatPlan(plan)) resolved.log(line);
|
|
194
|
+
return EXIT_OK;
|
|
195
|
+
}
|
|
196
|
+
return applyGitHooks(plan, resolved);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
package/tools/init-project.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { preflightInit } from './init-preflight.mjs';
|
|
|
13
13
|
import { buildRecommendations, hostFacts } from './recommendations.mjs';
|
|
14
14
|
import { compareSemver } from './semver-lite.mjs';
|
|
15
15
|
import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
|
|
16
|
-
import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, laneOf, writesOf } from './write-lanes.mjs';
|
|
16
|
+
import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, isRejudged, laneOf, writesOf } from './write-lanes.mjs';
|
|
17
17
|
|
|
18
18
|
const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
|
|
19
19
|
const APPLY_PROMPT = 'apply? y/N: ';
|
|
@@ -217,17 +217,20 @@ const previewQueue = async (context, queue) => {
|
|
|
217
217
|
context.print(result.stdout);
|
|
218
218
|
const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
|
|
219
219
|
const facts = { ...context.facts, tuple };
|
|
220
|
+
let checkDigest = null;
|
|
220
221
|
if (laneOf(item.variant) === LANE_HARNESS) {
|
|
221
222
|
try {
|
|
222
|
-
const { lines, refused } = context.checkApply(item.variant, facts, {});
|
|
223
|
+
const { lines, refused, digest } = context.checkApply(item.variant, facts, {});
|
|
223
224
|
for (const line of lines) context.print(line);
|
|
224
225
|
if (refused.length > 0) throw new Error(refused.map(({ key }) => key).join(', '));
|
|
226
|
+
checkDigest = digest;
|
|
225
227
|
} catch (error) {
|
|
226
228
|
context.print(`not applied: ${item.variant} — the danger check refused: ${error.message}`);
|
|
227
229
|
continue;
|
|
228
230
|
}
|
|
229
231
|
}
|
|
230
|
-
batch.push({ ...item,
|
|
232
|
+
batch.push({ ...item, checkFacts: facts, checkDigest, applyArgv: consoleArgv(item.variant, facts).apply,
|
|
233
|
+
writes: writesOf(item.variant, facts) });
|
|
231
234
|
printNote(context, item.key);
|
|
232
235
|
}
|
|
233
236
|
for (const item of queue.filter(({ variant }) => consoleArgv(variant, context.facts) === null)) {
|
|
@@ -254,11 +257,24 @@ const takeRecord = (context, queue) => {
|
|
|
254
257
|
}
|
|
255
258
|
};
|
|
256
259
|
|
|
260
|
+
// A rejudged item applies only while its check, run again now, answers the digest its preview showed; else the reason.
|
|
261
|
+
const rejudgeFailure = (context, item) => {
|
|
262
|
+
try {
|
|
263
|
+
const { refused, digest } = context.checkApply(item.variant, item.checkFacts, {});
|
|
264
|
+
if (refused.length > 0) return `it refuses ${refused.map(({ key }) => key).join(', ')}`;
|
|
265
|
+
return digest === item.checkDigest ? null : 'its digest moved';
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return String(error?.message || error);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
257
271
|
const runPass = async (context, queue) => {
|
|
258
272
|
const record = takeRecord(context, queue);
|
|
259
273
|
if (record === null) return { code: 1, successes: 0, variants: [] };
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
274
|
+
const previewed = await previewQueue(context, queue);
|
|
275
|
+
if (previewed.length === 0) return { code: 0, successes: 0, variants: [] };
|
|
276
|
+
// Rejudged items go first: an earlier writer of the batch (one creating .claude/) would move their check.
|
|
277
|
+
const batch = [...previewed.filter(({ variant }) => isRejudged(variant)), ...previewed.filter(({ variant }) => !isRejudged(variant))];
|
|
262
278
|
const before = new Map(record);
|
|
263
279
|
const outcome = { code: 0, successes: 0, variants: batch.map((item) => item.variant) };
|
|
264
280
|
context.print(RUN_NONE);
|
|
@@ -271,6 +287,15 @@ const runPass = async (context, queue) => {
|
|
|
271
287
|
outcome.code = 1;
|
|
272
288
|
break;
|
|
273
289
|
}
|
|
290
|
+
const failure = isRejudged(item.variant) ? rejudgeFailure(context, item) : null;
|
|
291
|
+
if (failure !== null) {
|
|
292
|
+
context.print(`not applied: ${item.variant} — its danger check no longer answers the previewed result: ${failure}`);
|
|
293
|
+
for (const later of batch.slice(index + 1)) {
|
|
294
|
+
context.print(`not applied: ${later.variant} — an earlier item's danger check no longer answers the previewed result`);
|
|
295
|
+
}
|
|
296
|
+
outcome.code = 1;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
274
299
|
const result = await launch(context, item.applyArgv, ['inherit', 'pipe', 'inherit']);
|
|
275
300
|
if (result.stdout) context.print(result.stdout);
|
|
276
301
|
const success = result.valid && (result.status === 0 || (result.status === 5 && result.stdout.includes('install completed')));
|