@sabaiway/agent-workflow-kit 14.25.1 → 14.26.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.
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { basename, dirname, isAbsolute, join, relative, resolve, win32 } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import {
9
+ BRIDGE_REVIEW_WRAPPERS, CLAUDE_DIR, KIT_GROUNDING_TOOL, RENDER_OWNED_REDLINE_RULES,
10
+ SAFE_DEFAULT_MODES, SETTINGS_FILE, SETTINGS_LOCAL_FILE, UNIVERSAL_READONLY_ALLOWLIST,
11
+ deriveBridgeTierAllowlist, deriveKitToolsAllowlist, mergeAutonomySettings,
12
+ writeAutonomyProfile, writeVelocityProfile,
13
+ } from './velocity-profile.mjs';
14
+ import { bundledSandboxRecipe } from './bridge-sandbox-recipe.mjs';
15
+ import { HOOK_FILE_REL, HOOKS_DIR, buildHookSettingsEntry, mergeHookEntry, writeGateHook } from './gate-hook.mjs';
16
+ import { writeMcp } from './mcp.mjs';
17
+ import { ENABLED_KEY, MCP_JSON_REL, SERVERS_KEY, SERVER_NAME, allowRulesFor, buildServerEntry } from './mcp-registration.mjs';
18
+ import { AGENTS_DIR, writeCheapAgents } from './cheap-agents.mjs';
19
+ import { planFor } from './jev-skill.mjs';
20
+ import { SKILL_FILES, claudeDirOf, skillTargets } from './jev-facts.mjs';
21
+ import { findOnPath } from './detect-backends.mjs';
22
+ import { consoleArgv } from './write-lanes.mjs';
23
+ import { isDirectRun } from './direct-run.mjs';
24
+ import { dirCovers as covers } from './declared-paths.mjs';
25
+
26
+ const TOOLS = dirname(fileURLToPath(import.meta.url));
27
+ const SKILL_VARIANTS = ['jev-skill', 'jev-skill.earlier'];
28
+ const VELOCITY_VARIANTS = ['velocity-core', 'kit-tools-tier', 'bridge-tier'];
29
+ const HOOK_VARIANTS = ['gate-hook', 'read-lane.missing'];
30
+ const MCP_VARIANTS = ['mcp-channel', 'mcp-channel.differing'];
31
+ const AGENT_VARIANTS = ['agents', 'executor-vehicle'];
32
+ const VARIANTS = [...VELOCITY_VARIANTS, 'autonomy-render', ...HOOK_VARIANTS, ...MCP_VARIANTS, ...AGENT_VARIANTS, ...SKILL_VARIANTS];
33
+ const CREDENTIALS = 'sandbox.credentials.envVars';
34
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
35
+ const stable = (value) => object(value)
36
+ ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
37
+ : Array.isArray(value) ? `[${value.map(stable).join(',')}]` : JSON.stringify(value);
38
+ const equal = (left, right) => stable(left) === stable(right);
39
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
40
+ const textValue = (value) => typeof value === 'string' ? value : JSON.stringify(value);
41
+
42
+ export const CHANGE_CLASSES = Object.freeze([
43
+ 'hosts reachable', 'paths writable', 'commands outside the sandbox',
44
+ 'prompts removed', 'commands the harness runs', 'nothing widened',
45
+ ]);
46
+ export const KEY_TABLE = Object.freeze({
47
+ 'sandbox.network.allowedDomains': 0,
48
+ 'sandbox.filesystem.allowWrite': 1,
49
+ 'sandbox.excludedCommands': 2,
50
+ 'permissions.allow': 3,
51
+ 'permissions.defaultMode': 3,
52
+ 'sandbox.autoAllowBashIfSandboxed': 3,
53
+ 'permissions.ask': 3,
54
+ 'permissions.deny': 3,
55
+ 'sandbox.enabled': 3,
56
+ 'hooks.PreToolUse': 4,
57
+ [ENABLED_KEY]: 4,
58
+ [SERVERS_KEY]: 4,
59
+ [CREDENTIALS]: 5,
60
+ });
61
+ const SCALARS = ['permissions.defaultMode', 'sandbox.enabled', 'sandbox.autoAllowBashIfSandboxed'];
62
+ const valid = (key, value, element = false) => {
63
+ if (key.startsWith(`${SERVERS_KEY}.`) || key === SERVERS_KEY) return object(value);
64
+ if (key === 'permissions.defaultMode') return typeof value === 'string';
65
+ if (SCALARS.includes(key)) return typeof value === 'boolean';
66
+ if (!element) return Array.isArray(value) && value.every((entry) => valid(key, entry, true));
67
+ if (key === CREDENTIALS) return object(value) && typeof value.name === 'string' && typeof value.mode === 'string';
68
+ return key === 'hooks.PreToolUse' ? object(value) : typeof value === 'string';
69
+ };
70
+ const keyOf = (key) => key.startsWith(`${SERVERS_KEY}.`) ? SERVERS_KEY : key;
71
+
72
+ export const diffFiles = (before, after) => {
73
+ const entries = [];
74
+ const scalar = (key, left, right) => {
75
+ if (equal(left, right)) return;
76
+ if (left === undefined) entries.push({ key, change: 'added', value: right });
77
+ else if (right === undefined) entries.push({ key, change: 'removed', value: left });
78
+ else entries.push({ key, change: 'altered', value: right, previous: left });
79
+ };
80
+ const walk = (left, right, key) => {
81
+ if (equal(left, right)) return;
82
+ const known = Object.hasOwn(KEY_TABLE, key);
83
+ if (known && [left, right].some((value) => value !== undefined && !valid(key, value))) {
84
+ scalar(key, left, right);
85
+ } else if (key === SERVERS_KEY) {
86
+ for (const name of new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})])) {
87
+ scalar(`${key}.${name}`, left?.[name], right?.[name]);
88
+ }
89
+ } else if (known && !SCALARS.includes(key)) {
90
+ const old = left ?? [];
91
+ const next = right ?? [];
92
+ const unpaired = old.filter((value) => !next.some((entry) => equal(value, entry)));
93
+ const changes = next.filter((value) => !old.some((entry) => equal(value, entry))).map((value) => {
94
+ const at = key === CREDENTIALS ? unpaired.findIndex((entry) => entry.name === value.name) : -1;
95
+ return at < 0 ? { key, change: 'added', value } : { key, change: 'altered', value, previous: unpaired.splice(at, 1)[0] };
96
+ });
97
+ for (const value of unpaired) entries.push({ key, change: 'removed', value });
98
+ entries.push(...changes);
99
+ } else if (!known && (key === '' || Object.keys(KEY_TABLE).some((path) => path.startsWith(`${key}.`)))
100
+ && [left, right].every((value) => value === undefined || object(value))) {
101
+ for (const name of new Set([...Object.keys(left ?? {}), ...Object.keys(right ?? {})])) {
102
+ walk(left?.[name], right?.[name], key ? `${key}.${name}` : name);
103
+ }
104
+ } else scalar(key, left, right);
105
+ };
106
+ walk(before ?? {}, after ?? {}, '');
107
+ const order = Object.keys(KEY_TABLE);
108
+ const rank = (key) => order.includes(keyOf(key)) ? order.indexOf(keyOf(key)) : order.length;
109
+ return entries.sort((a, b) => rank(a.key) - rank(b.key));
110
+ };
111
+
112
+ const classOf = ({ key, change, value }) => {
113
+ const base = keyOf(key);
114
+ if (!Object.hasOwn(KEY_TABLE, base)) return null;
115
+ let index = KEY_TABLE[base];
116
+ if (change === 'removed') index = ['permissions.ask', 'permissions.deny', CREDENTIALS].includes(key) ? 3 : 5;
117
+ else if (['permissions.ask', 'permissions.deny'].includes(key)) index = 5;
118
+ else if (key === 'permissions.defaultMode') index = value === 'acceptEdits' ? 3 : 5;
119
+ else if (key === 'sandbox.enabled') index = value === false ? 3 : 5;
120
+ else if (key === 'sandbox.autoAllowBashIfSandboxed') index = value === true ? 3 : 5;
121
+ return CHANGE_CLASSES[index];
122
+ };
123
+ export const judgeChange = ({ variant, files, scope }) => {
124
+ const entries = [];
125
+ const refused = [];
126
+ for (const { path, before, after } of files) {
127
+ for (const diff of diffFiles(before, after)) {
128
+ const entry = { ...diff, path, class: classOf(diff) };
129
+ entries.push(entry);
130
+ const { key, change } = diff;
131
+ const atKey = (data) => key.split('.').reduce((value, name) => value?.[name], data);
132
+ const wrongType = [atKey(before), atKey(after)].some((value) => value !== undefined && !valid(key, value));
133
+ const reason = entry.class === null ? 'unknown key' : wrongType ? 'wrong type'
134
+ : key === CREDENTIALS && change !== 'added' ? 'credentials entry removed or altered'
135
+ : !scope.admits(entry) ? `beyond the declared scope of ${variant}` : null;
136
+ if (reason) refused.push({ path, key, reason });
137
+ }
138
+ }
139
+ return { entries, refused };
140
+ };
141
+ const writerFacts = (facts, deps) => ({
142
+ ...deps, ...facts,
143
+ findWrapper: (name) => findOnPath(name, { getenv: facts.env }).state === 'present',
144
+ findOnPath: (name) => findOnPath(name, { getenv: facts.env }),
145
+ });
146
+
147
+ export const scopeOf = (variant, facts) => {
148
+ const allowed = {};
149
+ const set = (key, values) => {
150
+ allowed[key] = (value) => values.some((entry) => equal(entry, value));
151
+ };
152
+ if (VELOCITY_VARIANTS.includes(variant)) {
153
+ const allow = [...UNIVERSAL_READONLY_ALLOWLIST];
154
+ if (variant === 'kit-tools-tier') allow.push(...deriveKitToolsAllowlist({ projectDir: facts.root }));
155
+ if (variant === 'bridge-tier') {
156
+ const deps = writerFacts(facts, {});
157
+ const placed = BRIDGE_REVIEW_WRAPPERS.filter(deps.findWrapper);
158
+ allow.push(...deriveBridgeTierAllowlist({ findWrapper: deps.findWrapper,
159
+ groundingAbsPath: join(facts.toolsDir, '..', KIT_GROUNDING_TOOL) }).allow);
160
+ set('sandbox.excludedCommands', placed);
161
+ set('sandbox.network.allowedDomains', bundledSandboxRecipe(placed, deps).hosts);
162
+ }
163
+ set('permissions.allow', allow);
164
+ } else if (variant === 'autonomy-render') {
165
+ set('permissions.defaultMode', SAFE_DEFAULT_MODES);
166
+ set('sandbox.enabled', [true]);
167
+ set('sandbox.autoAllowBashIfSandboxed', [true, false]);
168
+ set('permissions.ask', RENDER_OWNED_REDLINE_RULES);
169
+ set('permissions.deny', RENDER_OWNED_REDLINE_RULES);
170
+ allowed[CREDENTIALS] = (value) => value.mode === 'deny';
171
+ } else if (HOOK_VARIANTS.includes(variant)) set('hooks.PreToolUse', [buildHookSettingsEntry()]);
172
+ else if (MCP_VARIANTS.includes(variant)) {
173
+ set(`${SERVERS_KEY}.${SERVER_NAME}`, [buildServerEntry(join(facts.toolsDir, 'mcp-server.mjs'))]);
174
+ set(ENABLED_KEY, [SERVER_NAME]);
175
+ set('permissions.allow', allowRulesFor());
176
+ }
177
+ const targets = SKILL_VARIANTS.includes(variant) ? skillTargets({ home: facts.home,
178
+ claudeDir: facts.claudeDir ?? claudeDirOf(facts.env, facts.home, facts.platform).dir }) : [];
179
+ const filePaths = HOOK_VARIANTS.includes(variant) ? [join(facts.root, HOOK_FILE_REL)] : [];
180
+ const directories = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render'
181
+ || HOOK_VARIANTS.includes(variant) || MCP_VARIANTS.includes(variant) || AGENT_VARIANTS.includes(variant)
182
+ ? [join(facts.root, CLAUDE_DIR)] : [];
183
+ if (HOOK_VARIANTS.includes(variant)) directories.push(join(facts.root, HOOKS_DIR));
184
+ if (AGENT_VARIANTS.includes(variant)) directories.push(join(facts.root, AGENTS_DIR));
185
+ for (const target of targets) {
186
+ for (let at = target.root; at !== target.base && covers(target.base, at); at = dirname(at)) directories.push(at);
187
+ filePaths.push(target.path);
188
+ }
189
+ return {
190
+ admits: ({ path, key, change, value }) => {
191
+ const correctFile = key.startsWith(`${SERVERS_KEY}.`) ? path === MCP_JSON_REL : path === SETTINGS_FILE;
192
+ const gains = key === 'hooks.PreToolUse' || key === ENABLED_KEY || key === CREDENTIALS;
193
+ return correctFile && (!gains || change === 'added') && Boolean(allowed[key]?.(value));
194
+ },
195
+ admitsPath: (path, directory = false) => directory ? directories.includes(path)
196
+ : filePaths.includes(path) || (AGENT_VARIANTS.includes(variant) && covers(join(facts.root, AGENTS_DIR), path)),
197
+ };
198
+ };
199
+
200
+ const jsonAt = (path) => existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null;
201
+ const halted = (lines) => Object.assign(new Error(lines.join('\n')), { code: 'CHECK_HALTED', lines });
202
+ const maskedFiles = (variant, facts) => {
203
+ const paths = VELOCITY_VARIANTS.includes(variant) || variant === 'autonomy-render' || HOOK_VARIANTS.includes(variant)
204
+ ? [SETTINGS_FILE, SETTINGS_LOCAL_FILE] : MCP_VARIANTS.includes(variant) ? [MCP_JSON_REL, SETTINGS_FILE] : [];
205
+ return paths.flatMap((path) => lstatSync(join(facts.root, path), { throwIfNoEntry: false })?.isCharacterDevice()
206
+ ? [`masked: ${path}`] : []);
207
+ };
208
+ export const plannedChange = (variant, facts, deps = {}) => {
209
+ const files = [];
210
+ const writes = [];
211
+ const temporaries = [];
212
+ const writerDeps = writerFacts(facts, deps);
213
+ const addFile = (path, before, after) => {
214
+ files.push({ path, before: before ?? null, after });
215
+ };
216
+ try {
217
+ const masks = maskedFiles(variant, facts);
218
+ if (masks.length) throw halted(masks);
219
+ if (VELOCITY_VARIANTS.includes(variant)) {
220
+ const plan = writeVelocityProfile({ cwd: facts.root, dryRun: true,
221
+ kitTools: variant === 'kit-tools-tier', bridgeTier: variant === 'bridge-tier' }, writerDeps);
222
+ const before = plan.projectSettings.data ?? {};
223
+ const after = structuredClone(before);
224
+ after.permissions = { ...before.permissions,
225
+ allow: [...(before.permissions?.allow ?? []), ...plan.toAdd, ...plan.tierToAdd, ...plan.bridgeToAdd] };
226
+ if (plan.excludedToAdd.length || plan.hostsToAdd.length) {
227
+ after.sandbox = { ...before.sandbox };
228
+ if (plan.excludedToAdd.length) after.sandbox.excludedCommands = [...(before.sandbox?.excludedCommands ?? []), ...plan.excludedToAdd];
229
+ if (plan.hostsToAdd.length) after.sandbox.network = { ...before.sandbox?.network,
230
+ allowedDomains: [...(before.sandbox?.network?.allowedDomains ?? []), ...plan.hostsToAdd] };
231
+ }
232
+ addFile(SETTINGS_FILE, plan.projectSettings.data, after);
233
+ } else if (variant === 'autonomy-render') {
234
+ const render = writeAutonomyProfile({ cwd: facts.root, apply: false }, writerDeps);
235
+ const before = jsonAt(join(facts.root, SETTINGS_FILE));
236
+ addFile(SETTINGS_FILE, before, mergeAutonomySettings(before, render));
237
+ } else if (HOOK_VARIANTS.includes(variant)) {
238
+ const plan = writeGateHook({ cwd: facts.root, dryRun: true }, writerDeps);
239
+ if (plan.wirePlanned) addFile(SETTINGS_FILE, plan.projectSettings.data, mergeHookEntry(plan.projectSettings.data));
240
+ if (plan.placePlanned) writes.push({ path: join(facts.root, HOOK_FILE_REL), bytes: plan.bundleContent });
241
+ } else if (MCP_VARIANTS.includes(variant)) {
242
+ const result = writeMcp({ cwd: facts.root, dryRun: true, replace: variant === 'mcp-channel.differing' }, writerDeps);
243
+ const { plan, registration } = result;
244
+ if (plan.writeMcpJson) addFile(MCP_JSON_REL, registration.mcpJson.data, JSON.parse(plan.mcpBody));
245
+ if (plan.writeSettings) addFile(SETTINGS_FILE, registration.settings.data, JSON.parse(plan.settingsBody));
246
+ } else if (AGENT_VARIANTS.includes(variant)) {
247
+ const plan = writeCheapAgents({ cwd: facts.root, dryRun: true }, writerDeps);
248
+ for (const item of plan.plan.filter(({ action }) => ['place', 're-derive'].includes(action))) {
249
+ writes.push({ path: item.abs, bytes: item.content });
250
+ }
251
+ } else if (SKILL_VARIANTS.includes(variant)) {
252
+ const plan = planFor({ ...writerDeps, lstat: lstatSync, readdir: readdirSync, readFile: readFileSync });
253
+ for (const target of plan.targets.filter(({ action }) => ['install', 'replace'].includes(action))) {
254
+ const stem = basename(target.path);
255
+ const bytes = Object.keys(SKILL_FILES).sort().map((name) => [name,
256
+ readFileSync(join(facts.toolsDir, '..', 'references', 'vendor', stem, SKILL_FILES[name]))]);
257
+ writes.push({ path: target.path, bytes: Buffer.concat(bytes.flatMap(([name, body]) => [Buffer.from(`${name}\0`), body])), directory: true });
258
+ temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-tmp-*`);
259
+ if (target.action === 'replace') temporaries.push(`temporary: ${dirname(target.root)}/.${stem}.kit-old-*`);
260
+ }
261
+ } else throw new Error(`unsupported variant: ${variant}`);
262
+ } catch (error) {
263
+ throw error.code === 'CHECK_HALTED' ? error : halted([error.message]);
264
+ }
265
+ const directories = new Set();
266
+ const changedFiles = files.filter(({ before, after }) => diffFiles(before, after).length);
267
+ for (const path of [...changedFiles.map(({ path }) => join(facts.root, path)), ...writes.map(({ path }) => path)]) {
268
+ for (let parent = dirname(path); !existsSync(parent); parent = dirname(parent)) directories.add(parent);
269
+ }
270
+ return { files, writes, temporaries, directories: [...directories] };
271
+ };
272
+
273
+ export const checkApply = (variant, facts, deps = {}) => {
274
+ const plan = plannedChange(variant, facts, deps);
275
+ const scope = scopeOf(variant, facts);
276
+ const { entries, refused } = judgeChange({ variant, files: plan.files, scope });
277
+ const lines = [];
278
+ const display = (path) => facts.root && covers(facts.root, path) ? relative(facts.root, path) : path;
279
+ for (const file of plan.files) {
280
+ const changes = entries.filter(({ path }) => path === file.path);
281
+ if (!changes.length) continue;
282
+ lines.push(`${file.path}:`);
283
+ for (const entry of changes) {
284
+ const old = entry.change === 'altered' ? `${textValue(entry.previous)} → ` : '';
285
+ lines.push(` ${entry.key}: ${entry.change} ${old}${textValue(entry.value)} — ${entry.class ?? 'unknown'}`);
286
+ }
287
+ }
288
+ const pathLine = (path, directory, line) => {
289
+ lines.push(line);
290
+ if (!scope.admitsPath(path, directory)) refused.push({ path: display(path), key: display(path),
291
+ reason: `beyond the declared scope of ${variant}` });
292
+ };
293
+ for (const write of plan.writes) pathLine(write.path, false, `writes: ${display(write.path)} sha256:${sha(write.bytes)}`);
294
+ for (const path of plan.directories) pathLine(path, true, `writes: ${display(path)}/ created`);
295
+ lines.push(...plan.temporaries);
296
+ if (refused.length) {
297
+ for (const entry of refused) lines.push(`refused: ${entry.path} ${entry.key} — ${entry.reason}`);
298
+ lines.push('nothing written');
299
+ } else lines.push(lines.length ? `scope: within the declared scope of ${variant}` : `nothing to change: ${variant} is already applied`);
300
+ const digest = sha(lines.join('\n'));
301
+ return { lines: [...lines, `digest: ${digest}`], digest, refused };
302
+ };
303
+
304
+ const USAGE = 'usage: apply-danger-check --variant <v> --cwd <root> [--claude-dir <abs>] [--apply --expect <digest>]';
305
+ const parse = (argv, platform) => {
306
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) return { help: true };
307
+ const args = {};
308
+ for (let index = 0; index < argv.length; index += 1) {
309
+ const flag = argv[index];
310
+ if (flag === '--apply') args.apply = true;
311
+ else if (['--variant', '--cwd', '--claude-dir', '--expect'].includes(flag)) {
312
+ const value = argv[index + 1];
313
+ if (!value || value.startsWith('-')) throw new Error(`${flag} takes a value`);
314
+ args[flag.slice(2)] = value;
315
+ index += 1;
316
+ } else throw new Error(`unknown argument: ${flag}`);
317
+ }
318
+ if (!VARIANTS.includes(args.variant) || !args.cwd) throw new Error('--variant and --cwd are required for a harness variant');
319
+ if (args['claude-dir'] && (!SKILL_VARIANTS.includes(args.variant)
320
+ || !(platform === 'win32' ? win32.isAbsolute : isAbsolute)(args['claude-dir']))) throw new Error('--claude-dir requires a skill variant and an absolute path');
321
+ if (Boolean(args.apply) !== Boolean(args.expect) || (args.expect && !/^[0-9a-f]{64}$/.test(args.expect))) {
322
+ throw new Error('--apply requires --expect with 64 lowercase hex characters');
323
+ }
324
+ args.root = resolve(args.cwd);
325
+ if (!statSync(args.root).isDirectory()) throw new Error('--cwd must be a directory');
326
+ return args;
327
+ };
328
+ export const main = (argv, io = {}) => {
329
+ const resolved = { log: console.log, error: console.error, env: process.env, home: homedir(),
330
+ platform: process.platform, toolsDir: TOOLS,
331
+ spawn: (argv, options) => spawnSync(argv[0], argv.slice(1), options), ...io };
332
+ let args;
333
+ try {
334
+ args = parse(argv, resolved.platform);
335
+ } catch (error) {
336
+ resolved.error(error.message);
337
+ return 2;
338
+ }
339
+ if (args.help) {
340
+ resolved.log(USAGE);
341
+ return 0;
342
+ }
343
+ const facts = { toolsDir: resolved.toolsDir, root: args.root, env: resolved.env, home: resolved.home,
344
+ platform: resolved.platform, claudeDir: args['claude-dir'] };
345
+ let result;
346
+ try {
347
+ result = checkApply(args.variant, facts);
348
+ } catch (error) {
349
+ for (const line of error.lines ?? [error.message]) resolved.log(line);
350
+ return 3;
351
+ }
352
+ for (const line of result.lines) resolved.log(line);
353
+ if (result.refused.length) return 1;
354
+ if (!args.apply) return 0;
355
+ if (args.expect !== result.digest) return 5;
356
+ const spawnFacts = facts.claudeDir ? { ...facts, env: { ...facts.env, CLAUDE_CONFIG_DIR: facts.claudeDir } } : facts;
357
+ const child = resolved.spawn(consoleArgv(args.variant, spawnFacts).apply,
358
+ { cwd: facts.root, env: resolved.env, stdio: 'inherit' });
359
+ if (child.status === 0) return 0;
360
+ resolved.error(`apply exited ${child.status}`);
361
+ return 6;
362
+ };
363
+
364
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -4,15 +4,16 @@ import { homedir } from 'node:os';
4
4
  import { basename, dirname, join, resolve, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline/promises';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { checkApply } from './apply-danger-check.mjs';
7
8
  import { resolveGitHooksPath } from './commit-guard.mjs';
8
9
  import { dirCovers } from './declared-paths.mjs';
9
10
  import { isDirectRun } from './direct-run.mjs';
10
- import { GIT_MAX_BUFFER } from './git-env.mjs';
11
+ import { GIT_MAX_BUFFER, stripGitLocationEnv } from './git-env.mjs';
11
12
  import { preflightInit } from './init-preflight.mjs';
12
13
  import { buildRecommendations, hostFacts } from './recommendations.mjs';
13
14
  import { compareSemver } from './semver-lite.mjs';
14
15
  import { EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
15
- import { LANE_CONSOLE, VARIANT_LANES, consoleArgv, isProtectedPath, laneOf, routeLine, writesOf } from './write-lanes.mjs';
16
+ import { LANE_HARNESS, VARIANT_LANES, consoleArgv, isEnvDependent, isProtectedPath, laneOf, writesOf } from './write-lanes.mjs';
16
17
 
17
18
  const INSIDE_LINE = 'you are inside an agent; run this in your own terminal';
18
19
  const APPLY_PROMPT = 'apply? y/N: ';
@@ -96,29 +97,21 @@ const readState = (path, io) => {
96
97
  if (stat.isSymbolicLink()) return `link:${io.readlink(path)}:${followedState(path, io)}`;
97
98
  return statState(path, stat, io);
98
99
  };
99
- const stateOf = (path, io) => {
100
+ const readStateOrElse = (path, io, onError) => {
100
101
  try {
101
102
  return readState(path, io);
102
103
  } catch (error) {
103
- throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
104
+ return onError(error);
104
105
  }
105
106
  };
107
+ const stateOf = (path, io) => readStateOrElse(path, io, (error) => {
108
+ throw new Error(`${path} could not be read: ${error.code ?? error.message}`);
109
+ });
106
110
  const recordPaths = (paths, io) => new Map([...new Set(paths)].map((path) => [path, stateOf(path, io)]));
107
- const findChanged = (record, io) => [...record].find(([path, state]) => {
108
- try {
109
- return readState(path, io) !== state;
110
- } catch {
111
- return true;
112
- }
113
- })?.[0];
111
+ const findChanged = (record, io) => [...record].find(([path, state]) =>
112
+ readStateOrElse(path, io, () => null) !== state)?.[0];
114
113
  // After an apply a path that cannot be read is recorded as such, so the next comparison stops the batch on it.
115
- const refreshState = (path, io) => {
116
- try {
117
- return readState(path, io);
118
- } catch (error) {
119
- return `unreadable:${error.code ?? error.message}`;
120
- }
121
- };
114
+ const refreshState = (path, io) => readStateOrElse(path, io, (error) => `unreadable:${error.code ?? error.message}`);
122
115
  const DIRECTORY = 'directory:';
123
116
  const entriesOf = (state) => (state === 'absent' ? [] : JSON.parse(state.slice(DIRECTORY.length)));
124
117
  // A recorded parent moves only by the written entry, so an entry created or removed beside it still stops the batch.
@@ -179,16 +172,13 @@ const reachesQueue = (variant, facts) => {
179
172
  const isMasked = (variant, context) => (writesOf(variant, context.facts) ?? [])
180
173
  .some((write) => [...context.masks].some((mask) => dirCovers(write, mask)));
181
174
 
182
- // context.items keeps the console items of the advisor's latest return, before the queue's filters.
183
175
  const getQueue = async (context) => {
184
- const { items } = await context.recommend(context.facts.root, { keySet: context.keySet });
185
- context.items = items.filter(({ variant }) => laneOf(variant) === LANE_CONSOLE);
176
+ context.items = (await context.recommend(context.facts.root, { keySet: context.keySet })).items;
186
177
  return context.items.filter(({ variant }) => reachesQueue(variant, context.facts) && !isMasked(variant, context));
187
178
  };
188
179
 
189
180
  const printNotPending = (context) => {
190
- const plain = routeLine('velocity-core');
191
- const variants = Object.keys(VARIANT_LANES).filter((variant) => laneOf(variant) === LANE_CONSOLE && routeLine(variant)?.length > plain.length);
181
+ const variants = Object.keys(VARIANT_LANES).filter(isEnvDependent);
192
182
  const keys = new Set();
193
183
  for (const variant of variants) {
194
184
  const key = variant.split('.')[0];
@@ -227,6 +217,16 @@ const previewQueue = async (context, queue) => {
227
217
  context.print(result.stdout);
228
218
  const tuple = result.stdout.match(/--apply\s+(\S+)/)?.[1];
229
219
  const facts = { ...context.facts, tuple };
220
+ if (laneOf(item.variant) === LANE_HARNESS) {
221
+ try {
222
+ const { lines, refused } = context.checkApply(item.variant, facts, {});
223
+ for (const line of lines) context.print(line);
224
+ if (refused.length > 0) throw new Error(refused.map(({ key }) => key).join(', '));
225
+ } catch (error) {
226
+ context.print(`not applied: ${item.variant} — the danger check refused: ${error.message}`);
227
+ continue;
228
+ }
229
+ }
230
230
  batch.push({ ...item, applyArgv: consoleArgv(item.variant, facts).apply, writes: writesOf(item.variant, facts) });
231
231
  printNote(context, item.key);
232
232
  }
@@ -295,7 +295,7 @@ const runPass = async (context, queue) => {
295
295
  const selectRoot = async (context) => {
296
296
  const { candidates } = findProject({ cwd: context.cwd, lstat: context.io.lstat });
297
297
  if (candidates.length === 0) {
298
- context.print("no agent-workflow project here — the project items apply when init runs from that project's folder");
298
+ context.print('no agent-workflow project here — run /agent-workflow-kit upgrade in your agent from that project, or deploy it there first: its yes applies the project items');
299
299
  return { root: null };
300
300
  }
301
301
  if (candidates.length === 1) return candidates[0];
@@ -317,7 +317,7 @@ const gateStamp = (candidate, context) => {
317
317
  if (compareSemver(stamp, EXPECTED_WORKFLOW_VERSION) === 1) {
318
318
  context.print(`this project's stamp ${stamp} is ahead of this kit's lineage ${EXPECTED_WORKFLOW_VERSION} — update the kit, then init again`);
319
319
  } else {
320
- context.print('run /agent-workflow-kit upgrade, then init again');
320
+ context.print('run /agent-workflow-kit upgrade in your agent: its yes applies the items');
321
321
  }
322
322
  return null;
323
323
  };
@@ -334,10 +334,8 @@ export const runInitProject = async (deps) => {
334
334
  print(INSIDE_LINE);
335
335
  return { code: 0 };
336
336
  }
337
- const spawnEnv = { ...deps.env };
338
- delete spawnEnv.GIT_DIR;
339
- delete spawnEnv.GIT_WORK_TREE;
340
- const context = { ...deps, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
337
+ const spawnEnv = stripGitLocationEnv(deps.env);
338
+ const context = { ...deps, checkApply: deps.checkApply ?? checkApply, io, print, spawnEnv, masks: new Set(), keySet: false, restart: false };
341
339
  const candidate = await selectRoot(context);
342
340
  if (candidate === null) return { code: 0 };
343
341
  if (!preflightInit({ ...deps, root: candidate.root, io }).ok) {
@@ -4,8 +4,8 @@
4
4
  // their own: it reads the key with no echo, verifies it with one request, saves it where the shell
5
5
  // reads it (bash, zsh, fish) or in the Windows user environment, and prints the restart step. It never
6
6
  // prints the key, never puts it in an argv, and refuses when stdin is not a terminal, so a
7
- // non-interactive run (the agent's) cannot reach the prompt — the mode doc keeps the rule that the
8
- // agent never runs it. It claims no connection: the guide's key mark, read after the restart, is the proof.
7
+ // non-interactive run cannot reach the prompt. The key never reaches the agent or the chat.
8
+ // It claims no connection: the guide's key mark, read after the restart, is the proof.
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { lstatSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
@@ -45,11 +45,11 @@ export const JEV_TEXT = Object.freeze([
45
45
  ]);
46
46
 
47
47
  const KEY_GET = 'Get a key at console.typesafe.ai and set it on every host the agent runs on.';
48
- const KEY_CONNECT = 'Connect it from a terminal of your own, never through the agent: the command asks for the key with no echo, checks it with one request and saves it for bash, zsh or fish in your shell\'s startup files, or on Windows as a user environment variable.';
48
+ const KEY_CONNECT = 'Connect it from a terminal of your own — the key never reaches the agent or the chat: the command asks for the key with no echo, checks it with one request and saves it for bash, zsh or fish in your shell\'s startup files, or on Windows as a user environment variable.';
49
49
  const KEY_RESTART = `Then ${RESTART_STEP}. Run /agent-workflow-kit jev again: the key mark should read set.`;
50
50
  const KEY_NEVER = 'Never paste the key into the chat or into a project file.';
51
51
  const KEY_NOT_SET = `key: ${KEY_VARIABLE} not set. A host setting that filters the environment of the agent's commands also hides it. If it still reads not set after that restart and no such setting applies, run the connect line again and follow its last line.`;
52
- const SKILL_OPTIONAL = 'Optional, for your own code and prompts — the vendor skill. The kit installs it pinned, one verified copy per agent skill root (Codex, Claude Code, Antigravity CLI); after your yes in the chat, run this in a terminal of your own:';
52
+ const SKILL_OPTIONAL = 'Optional, for your own code and prompts — the vendor skill. The kit installs it pinned, one verified copy per agent skill root (Codex, Claude Code, Antigravity CLI); if Recommendations offers this item, the agent applies it on your chat yes after its danger check; you can also run this yourself in a terminal of your own:';
53
53
  const NO_HOME = 'no home directory found: the kit\'s install needs one';
54
54
  const INSTALL = [
55
55
  'Or the vendor\'s own routes:',
@@ -49,7 +49,7 @@ import {
49
49
  } from './velocity-profile.mjs';
50
50
  import { loadAutonomy, isSparseSeedConfig, AUTONOMY_REL } from './autonomy-config.mjs';
51
51
  import { deriveDoctorPlan, TRUSTED_DIRS } from './autonomy-doctor.mjs';
52
- import { laneOf, routeLine } from './write-lanes.mjs';
52
+ import { laneOf, applySlot } from './write-lanes.mjs';
53
53
  import { detectBackends, findOnPath } from './detect-backends.mjs';
54
54
  import { isDirectRun } from './direct-run.mjs';
55
55
  import { ACTIVITIES, resolveActivityRecipe, composeReadiness, safeLine } from './recipes.mjs';
@@ -1574,7 +1574,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1574
1574
  const host = deps.jevHost ?? {};
1575
1575
  const place = placeOf({ env, platform: host.platform, hostname: host.hostname, container: host.container });
1576
1576
  const decline = `node ${q(toolPath('ack-write.mjs'))} --lane jev-connect --fingerprint ${JEV_CONNECT_DECLINE} --cwd ${q(root)}`;
1577
- add('jev-connect', WHATS['jev-connect'], routeLine('jev-connect'), 'jev-connect',
1577
+ add('jev-connect', WHATS['jev-connect'], applySlot('jev-connect', { toolsDir: HERE }).apply, 'jev-connect',
1578
1578
  `${place ? `run the apply in ${place}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${decline}`);
1579
1579
  } catch (err) {
1580
1580
  skip('jev-connect', err);
@@ -1582,7 +1582,7 @@ export const probeJevConnect = ({ root, deps, add, skip }) => {
1582
1582
  };
1583
1583
 
1584
1584
  // The Jev skill offer (contract: kit/jev-guide, part jev-offer): only once the key is set, so one jev item shows at a time.
1585
- // Each target is judged by the facts leaf's one state rule; the apply is the skill command the USER runs (HAND-APPLY).
1585
+ // Each target is judged by the facts leaf's one state rule; the item's apply comes from the lane leaf's checked slot.
1586
1586
  export const probeJevSkill = ({ root, deps, add, skip }) => {
1587
1587
  try {
1588
1588
  const env = deps.getenv ?? {};
@@ -1608,7 +1608,7 @@ export const probeJevSkill = ({ root, deps, add, skip }) => {
1608
1608
  const untouched = judged.filter(({ state }) => state === 'foreign')
1609
1609
  .map(({ path, reason }) => `left untouched: ${printable(path)} — ${printable(reason)}; `).join('');
1610
1610
  const record = `node ${q(toolPath('ack-write.mjs'))} --lane jev-skill --fingerprint ${decline} --cwd ${q(root)}`;
1611
- add('jev-skill', what, `HAND-APPLY: ${line}`, behind.length ? 'jev-skill.earlier' : 'jev-skill',
1611
+ add('jev-skill', what, line, behind.length ? 'jev-skill.earlier' : 'jev-skill',
1612
1612
  `${place ? `run the apply in ${place}; ` : ''}${untouched}${note ? `${note}; ` : ''}HAND-APPLY alternative (instead of the apply, never after it): decline the offer by recording it — ${record}`);
1613
1613
  } catch (err) {
1614
1614
  skip('jev-skill', err);
@@ -1686,7 +1686,13 @@ export const buildRecommendations = ({ cwd, deps = {} } = {}) => {
1686
1686
  skip(key, new Error(`item shape violation — ${problems.join('; ')}`));
1687
1687
  return false;
1688
1688
  }
1689
- items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key], apply: routeLine(variant) ?? apply, detail });
1689
+ const slot = applySlot(variant, {
1690
+ toolsDir: HERE, root, env: deps.getenv ?? {},
1691
+ home: deps.jevHost?.home ?? deps.home ?? '',
1692
+ platform: deps.jevHost?.platform ?? deps.platform,
1693
+ });
1694
+ items.push({ key, variant, severity: SEVERITIES[variant], lane: laneOf(variant), what, benefit: BENEFITS[key],
1695
+ check: slot?.check ?? null, apply: slot?.apply ?? apply, detail });
1690
1696
  return true;
1691
1697
  };
1692
1698
  // The per-run scratch a probe uses to tell a LATER probe what it actually did. Written by exactly
@@ -1736,6 +1742,7 @@ export const formatRecommendations = ({ items, skips }) => {
1736
1742
  lines.push(` benefit: ${item.benefit}`);
1737
1743
  lines.push(` lane: ${item.lane}`);
1738
1744
  if (item.detail) lines.push(` recipe: ${item.detail}`);
1745
+ if (item.check != null) lines.push(` check: ${item.check}`);
1739
1746
  lines.push(` apply: ${item.apply}`);
1740
1747
  });
1741
1748
  }
@@ -1752,9 +1759,9 @@ Usage:
1752
1759
 
1753
1760
  Computes the deterministic Recommendations section every kit upgrade ends with — VERDICT-FIRST:
1754
1761
  one composed verdict line opens every non-optimal render, then per item {severity · what is
1755
- sub-optimal · the benefit in one plain line · an optional \`recipe:\` line (the sandbox-lane live
1762
+ sub-optimal · the benefit in one plain line · the lane · an optional \`recipe:\` line (the sandbox-lane live
1756
1763
  recipe, the worktrees-dir hand-apply-first grant advice, or the agents hidden-mode reconcile
1757
- follow-up) · the exact consent-gated apply one-liner}. --cwd is
1764
+ follow-up) · the harness check line · the exact consent-gated apply one-liner}. --cwd is
1758
1765
  REQUIRED (the target project is explicit, never inferred from the shell's current directory). The
1759
1766
  section renders present-even-when-empty ("${RECOMMENDATIONS_EMPTY_LINE}"); a probe failure is a
1760
1767
  stated skipped-item line. Apply lines are cwd-independent (absolute tool paths, a pinned --cwd;