@sabaiway/agent-workflow-kit 1.28.0 → 1.30.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 +93 -0
- package/README.md +4 -1
- package/SKILL.md +67 -6
- package/bridges/antigravity-cli-bridge/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +145 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +169 -1
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/codex-cli-bridge/SKILL.md +1 -1
- package/bridges/codex-cli-bridge/bin/codex-review.sh +120 -3
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +132 -0
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/contracts.md +1 -0
- package/references/hooks/gate-approve.mjs +300 -0
- package/references/templates/agent_rules.md +3 -2
- package/references/templates/handover.md +1 -0
- package/tools/commands.mjs +24 -3
- package/tools/detect-backends.mjs +2 -0
- package/tools/family-registry.mjs +25 -0
- package/tools/gate-hook.mjs +387 -0
- package/tools/grounding.mjs +263 -0
- package/tools/known-footprint.mjs +1 -0
- package/tools/presentation.mjs +1 -0
- package/tools/procedures.mjs +50 -5
- package/tools/recipes.mjs +78 -12
- package/tools/renderers.mjs +6 -0
- package/tools/review-state.mjs +395 -0
- package/tools/set-recipe.mjs +15 -5
- package/tools/uninstall.mjs +175 -26
- package/tools/velocity-profile.mjs +21 -3
- package/tools/view-model.mjs +18 -1
package/tools/set-recipe.mjs
CHANGED
|
@@ -25,7 +25,7 @@ import { readFileSync, lstatSync } from 'node:fs';
|
|
|
25
25
|
import { homedir } from 'node:os';
|
|
26
26
|
import { pathToFileURL } from 'node:url';
|
|
27
27
|
import { detectBackends } from './detect-backends.mjs';
|
|
28
|
-
import { resolveActivityRecipe } from './recipes.mjs';
|
|
28
|
+
import { resolveActivityRecipe, composeActiveRecipeLine } from './recipes.mjs';
|
|
29
29
|
import {
|
|
30
30
|
CONFIG_REL,
|
|
31
31
|
fail,
|
|
@@ -91,7 +91,7 @@ const effectiveLine = (e) =>
|
|
|
91
91
|
? `effective here: ${e.effective} (requested ${e.degradedFrom} → degraded: ${e.reason})`
|
|
92
92
|
: `effective here: ${e.effective}`;
|
|
93
93
|
|
|
94
|
-
const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody }) => {
|
|
94
|
+
const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody, activeLine }) => {
|
|
95
95
|
const lines = [];
|
|
96
96
|
if (wrote) lines.push(`wrote ${CONFIG_REL}`);
|
|
97
97
|
else if (changed.length) lines.push(`set-recipe — preview (nothing written; re-run with --write to apply)`);
|
|
@@ -102,6 +102,12 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
|
|
|
102
102
|
for (const e of unchanged) lines.push(` ${e.activity}.${e.slot}: already ${valueLabel(e.from)} (no change)`);
|
|
103
103
|
for (const w of warnings) lines.push(` ⚠ ${w}`);
|
|
104
104
|
if (wrote && fileBody) lines.push('', `${CONFIG_REL} now reads:`, fileBody.replace(/\n$/, ''));
|
|
105
|
+
// The post-write discovery echo (AD-038): after every successful write, paste the freshly composed
|
|
106
|
+
// active-recipe line verbatim + the one-line handover reminder — the writer is the ONE surface that
|
|
107
|
+
// changes the config, so the change is never announced anywhere else.
|
|
108
|
+
if (wrote && activeLine) {
|
|
109
|
+
lines.push('', activeLine, `refresh the "Active recipes:" slot line in docs/ai/handover.md with the line above.`);
|
|
110
|
+
}
|
|
105
111
|
if (!wrote) {
|
|
106
112
|
if (!changed.length) lines.push(' no changes — nothing to write.');
|
|
107
113
|
else if (willWrite) lines.push('', `would write ${CONFIG_REL} — re-run with --write to apply.`);
|
|
@@ -109,12 +115,15 @@ const formatHuman = ({ changed, unchanged, warnings, willWrite, wrote, fileBody
|
|
|
109
115
|
return lines.join('\n');
|
|
110
116
|
};
|
|
111
117
|
|
|
112
|
-
const buildJson = ({ changed, unchanged, warnings, writtenPath, noop }) => ({
|
|
118
|
+
const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine }) => ({
|
|
113
119
|
changed: changed.map((e) => ({ activity: e.activity, slot: e.slot, from: e.from, to: e.to, effective: e.effective, degradedFrom: e.degradedFrom ?? null, reason: e.reason ?? null })),
|
|
114
120
|
unchanged: unchanged.map((e) => ({ activity: e.activity, slot: e.slot, recipe: e.from })),
|
|
115
121
|
writtenPath: writtenPath ?? null,
|
|
116
122
|
noop,
|
|
117
123
|
warnings,
|
|
124
|
+
// ADDITIVE: the machine-composed active-recipe line, present only after a successful write (the
|
|
125
|
+
// human render pastes the same line) — the output stays one parseable JSON object either way.
|
|
126
|
+
activeLine: activeLine ?? null,
|
|
118
127
|
});
|
|
119
128
|
|
|
120
129
|
const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
|
|
@@ -199,9 +208,10 @@ export const main = (argv, ctx = {}) => {
|
|
|
199
208
|
validateConfig(after); // defensive re-validate immediately before the write
|
|
200
209
|
const { writtenPath } = writeConfig(cwd, after, ctx);
|
|
201
210
|
const fileBody = serializeConfig(after);
|
|
211
|
+
const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, detection);
|
|
202
212
|
const stdout = json
|
|
203
|
-
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false }), null, 2)
|
|
204
|
-
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody });
|
|
213
|
+
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
|
|
214
|
+
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
|
|
205
215
|
return { code: 0, stdout, stderr: '' };
|
|
206
216
|
} catch (err) {
|
|
207
217
|
return { code: err.exitCode ?? 1, stdout: '', stderr: `set-recipe: ${err.message}` };
|
package/tools/uninstall.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// module is unit-testable without the real filesystem. Dependency-free, Node >= 18. No side effects on
|
|
23
23
|
// import (the isDirectRun idiom).
|
|
24
24
|
|
|
25
|
-
import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, realpathSync } from 'node:fs';
|
|
25
|
+
import { existsSync, statSync, lstatSync, readlinkSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
26
26
|
import { join, resolve, dirname, basename, isAbsolute } from 'node:path';
|
|
27
27
|
import { pathToFileURL } from 'node:url';
|
|
28
28
|
import os from 'node:os';
|
|
@@ -30,6 +30,7 @@ import { surveyFamily, surveyProject, FAMILY_MEMBERS, classifyMember, OK } from
|
|
|
30
30
|
import { removeTreeManaged, unlinkManaged, MANAGED_LINK_CONFLICT } from './fs-safe.mjs';
|
|
31
31
|
import { deriveLinks } from './setup-backends.mjs';
|
|
32
32
|
import { hideFootprint, excludePath } from './hide-footprint.mjs';
|
|
33
|
+
import { HOOK_FILE_REL as GATE_HOOK_FILE_REL, readBundledHook } from './gate-hook.mjs';
|
|
33
34
|
|
|
34
35
|
// ── surface classes ────────────────────────────────────────────────────────────
|
|
35
36
|
export const SAFE_REMOVE = 'safe-remove';
|
|
@@ -57,6 +58,7 @@ const fsOf = (deps = {}) => ({
|
|
|
57
58
|
lstat: deps.lstat ?? lstatSync,
|
|
58
59
|
readlink: deps.readlink ?? readlinkSync,
|
|
59
60
|
readFile: deps.readFile ?? readFileSync,
|
|
61
|
+
readdir: deps.readdir ?? readdirSync,
|
|
60
62
|
realpath: deps.realpath ?? realpathSync,
|
|
61
63
|
readManifest: deps.readManifest ?? ((skillDir) => JSON.parse(readFileSync(join(skillDir, 'capability.json'), 'utf8'))),
|
|
62
64
|
});
|
|
@@ -186,16 +188,17 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
|
|
|
186
188
|
}
|
|
187
189
|
}
|
|
188
190
|
|
|
189
|
-
const
|
|
190
|
-
const settings = (() => {
|
|
191
|
+
const readRaw = (p) => {
|
|
191
192
|
try {
|
|
192
|
-
return fs.exists(
|
|
193
|
+
return fs.exists(p) ? String(fs.readFile(p, 'utf8')) : null;
|
|
193
194
|
} catch {
|
|
194
195
|
return null;
|
|
195
196
|
}
|
|
196
|
-
}
|
|
197
|
+
};
|
|
198
|
+
const settingsPath = join(dir, '.claude/settings.json');
|
|
199
|
+
const settings = readRaw(settingsPath);
|
|
197
200
|
const settingsSeams = detectSettingsSeams(settings);
|
|
198
|
-
if (settingsSeams.attribution || settingsSeams.permissions) {
|
|
201
|
+
if (settingsSeams.attribution || settingsSeams.permissions || settingsSeams.gateHook) {
|
|
199
202
|
items.push({
|
|
200
203
|
surface: 'settings',
|
|
201
204
|
path: settingsPath,
|
|
@@ -205,6 +208,66 @@ export const buildPlan = ({ family, project = null, projectDir = null, member =
|
|
|
205
208
|
});
|
|
206
209
|
}
|
|
207
210
|
|
|
211
|
+
// The gate-approval hook (Mode: hook). Hooks merge from project AND local settings (the Claude
|
|
212
|
+
// Code contract), so the wired probe checks BOTH files; a local entry is user-authored → its own
|
|
213
|
+
// REPORT_ONLY edit item (uninstall never writes settings — either file). The placed FILE is
|
|
214
|
+
// removable ONLY when the entry is absent from BOTH (hand-removed) AND the content is
|
|
215
|
+
// byte-identical to the current bundle — never create the wired-but-missing state this tool
|
|
216
|
+
// itself would warn about; while any entry is present, all surfaces are reported as one bundle
|
|
217
|
+
// (edit settings first, re-run to remove the file).
|
|
218
|
+
const localSettingsPath = join(dir, '.claude/settings.local.json');
|
|
219
|
+
const localSeams = detectSettingsSeams(readRaw(localSettingsPath));
|
|
220
|
+
if (localSeams.gateHook) {
|
|
221
|
+
items.push({
|
|
222
|
+
surface: 'settings',
|
|
223
|
+
path: localSettingsPath,
|
|
224
|
+
class: REPORT_ONLY,
|
|
225
|
+
reason: 'a PreToolUse entry wiring the kit-placed gate-approval hook is present in this file — remove it by hand to unwire (the tool never edits settings)',
|
|
226
|
+
hand: `edit ${shq(localSettingsPath)} → remove the PreToolUse entry whose command runs ${GATE_HOOK_FILE_REL} (keep the rest of your settings)`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const gateHookPath = join(dir, GATE_HOOK_FILE_REL);
|
|
230
|
+
const gateHookStat = lstatNoFollow(gateHookPath, fs.lstat);
|
|
231
|
+
// lstat no-follow BEFORE reading: a symlink (or any non-regular file) at the placed path is
|
|
232
|
+
// never a kit-placed hook we remove — reading through it would classify a symlink-to-bundle as
|
|
233
|
+
// SAFE_REMOVE and only removeTreeManaged would catch it at mutate time, AFTER earlier removals.
|
|
234
|
+
// Report it, never remove it.
|
|
235
|
+
if (gateHookStat !== null && (gateHookStat.isSymbolicLink() || !gateHookStat.isFile())) {
|
|
236
|
+
items.push({
|
|
237
|
+
surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
|
|
238
|
+
reason: 'a file exists at the gate-approval hook path but is a symlink or not a regular file — left untouched; remove by hand if you want it gone',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const gateHookContent = gateHookStat !== null && gateHookStat.isFile() && !gateHookStat.isSymbolicLink() ? readRaw(gateHookPath) : null;
|
|
242
|
+
if (gateHookContent != null) {
|
|
243
|
+
const wired = settingsSeams.gateHook || localSeams.gateHook;
|
|
244
|
+
const bundle = (() => {
|
|
245
|
+
try {
|
|
246
|
+
return readBundledHook(deps);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
})();
|
|
251
|
+
if (wired) {
|
|
252
|
+
items.push({
|
|
253
|
+
surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
|
|
254
|
+
reason: 'the gate-approval hook file is still WIRED in Claude settings — edit the settings entry first (see the settings item above), then re-run uninstall to remove the file (never leaves a wired-but-missing hook)',
|
|
255
|
+
});
|
|
256
|
+
} else if (bundle != null && gateHookContent === bundle) {
|
|
257
|
+
items.push({
|
|
258
|
+
surface: 'gate-hook', path: gateHookPath, class: SAFE_REMOVE, expectedContent: bundle,
|
|
259
|
+
reason: 'kit-placed gate-approval hook — byte-identical to the current bundle and not wired in either settings file',
|
|
260
|
+
});
|
|
261
|
+
} else {
|
|
262
|
+
items.push({
|
|
263
|
+
surface: 'gate-hook', path: gateHookPath, class: REPORT_ONLY,
|
|
264
|
+
reason: bundle == null
|
|
265
|
+
? 'could not read the kit bundle to verify this hook file — left untouched; remove by hand if you want it gone'
|
|
266
|
+
: 'not byte-identical to the current kit bundle (customized, or from another kit version) — left untouched; remove by hand if you want it gone',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
208
271
|
for (const rel of REPORT_PATHS) {
|
|
209
272
|
const p = join(dir, rel);
|
|
210
273
|
if (fs.exists(p)) {
|
|
@@ -235,7 +298,7 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
|
|
|
235
298
|
// we leave them untouched and proceed with what IS ours (the per-item posture of setup-backends —
|
|
236
299
|
// a stray foreign wrapper never blocks removing the rest). They are NEVER mutated.
|
|
237
300
|
const reported = plan.items.filter((i) => i.class === REPORT_ONLY || i.class === STOP);
|
|
238
|
-
const result = { removed: [], unlinked: [], unhidden: false, hookRemoved: false, reported, applied: false, dryRun: !!opts.dryRun };
|
|
301
|
+
const result = { removed: [], unlinked: [], unhidden: false, hookRemoved: false, gateHookRemoved: false, reported, applied: false, dryRun: !!opts.dryRun };
|
|
239
302
|
|
|
240
303
|
// Preview / awaiting consent → show the plan (formatPlan), mutate NOTHING, abort NOTHING.
|
|
241
304
|
if (opts.dryRun || !opts.yes) return result;
|
|
@@ -262,6 +325,20 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
|
|
|
262
325
|
const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return ''; } })();
|
|
263
326
|
if (!content.includes(HOOK_MARKER_SUFFIX)) conflicts.push(`${item.path} no longer carries our marker`);
|
|
264
327
|
}
|
|
328
|
+
} else if (item.surface === 'gate-hook') {
|
|
329
|
+
// Same AD-011 recheck as the marker hook: the file must STILL be a regular (non-symlink)
|
|
330
|
+
// file, byte-identical to the bundle, AND still unwired — a symlink swapped in, a divergence,
|
|
331
|
+
// or a new settings entry since the plan ⇒ zero mutations. lstat no-follow FIRST: a symlink
|
|
332
|
+
// read through by fs.readFile would masquerade as the bundle and slip past this guard.
|
|
333
|
+
const st = lstatNoFollow(item.path, fs.lstat);
|
|
334
|
+
if (st !== null) {
|
|
335
|
+
if (st.isSymbolicLink() || !st.isFile()) conflicts.push(`${item.path} is no longer a regular file (symlink or special file)`);
|
|
336
|
+
else {
|
|
337
|
+
const content = (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })();
|
|
338
|
+
if (content !== item.expectedContent) conflicts.push(`${item.path} no longer matches the kit bundle`);
|
|
339
|
+
else if (gateHookWiredNow(plan.projectDir, fs)) conflicts.push(`${item.path} became wired in Claude settings since the plan`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
265
342
|
} else if (item.surface === 'fence') {
|
|
266
343
|
// Validate the unhide WITHOUT writing — a malformed managed block throws here, before any mutation,
|
|
267
344
|
// so the fence can never blow up AFTER wrappers/skills were already removed (codex #2).
|
|
@@ -303,10 +380,44 @@ export const executePlan = (plan, opts = {}, deps = {}) => {
|
|
|
303
380
|
result.hookRemoved = true;
|
|
304
381
|
}
|
|
305
382
|
}
|
|
383
|
+
for (const item of mutable.filter((i) => i.surface === 'gate-hook')) {
|
|
384
|
+
// Bundle-identity + unwired re-verified at mutate time too (the TOCTOU posture above), lstat
|
|
385
|
+
// no-follow FIRST so a symlink swapped in cannot be read-through as the bundle and removed: a
|
|
386
|
+
// file that changed, turned into a symlink, or got wired between preflight and now is left
|
|
387
|
+
// untouched, never removed.
|
|
388
|
+
const st = lstatNoFollow(item.path, fs.lstat);
|
|
389
|
+
const content = st !== null && st.isFile() && !st.isSymbolicLink()
|
|
390
|
+
? (() => { try { return String(fs.readFile(item.path, 'utf8')); } catch { return null; } })()
|
|
391
|
+
: null;
|
|
392
|
+
if (content != null && content === item.expectedContent && !gateHookWiredNow(plan.projectDir, fs)) {
|
|
393
|
+
rmFile(item.path);
|
|
394
|
+
result.gateHookRemoved = true;
|
|
395
|
+
// A `.claude/hooks/` dir left EMPTY by that removal is removed too (clean footprint); a dir
|
|
396
|
+
// with anything else in it is untouched.
|
|
397
|
+
const hooksDir = dirname(item.path);
|
|
398
|
+
const leftover = (() => { try { return fs.readdir(hooksDir); } catch { return null; } })();
|
|
399
|
+
if (leftover != null && leftover.length === 0) removeTree(hooksDir, dirname(hooksDir), deps);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
306
402
|
result.applied = true;
|
|
307
403
|
return result;
|
|
308
404
|
};
|
|
309
405
|
|
|
406
|
+
// Is the gate-approval hook wired NOW (either settings file)? Probed by the placed-path substring
|
|
407
|
+
// (the same broad detectSettingsSeams posture). Fail-CLOSED: an unreadable settings file counts as
|
|
408
|
+
// wired — "cannot prove unwired" must preserve the file, never remove it.
|
|
409
|
+
const gateHookWiredNow = (projectDir, fs) => {
|
|
410
|
+
if (!projectDir) return false;
|
|
411
|
+
const probe = (p) => {
|
|
412
|
+
try {
|
|
413
|
+
return fs.exists(p) ? settingsMentionsGateHook(String(fs.readFile(p, 'utf8'))) : false;
|
|
414
|
+
} catch {
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
return probe(join(projectDir, '.claude/settings.json')) || probe(join(projectDir, '.claude/settings.local.json'));
|
|
419
|
+
};
|
|
420
|
+
|
|
310
421
|
// ── report ───────────────────────────────────────────────────────────────────
|
|
311
422
|
const CLASS_LABEL = { [SAFE_REMOVE]: 'remove', [MANAGED_MARKER]: 'reverse', [REPORT_ONLY]: 'KEEP (do by hand)', [STOP]: 'STOP (left untouched)' };
|
|
312
423
|
|
|
@@ -314,42 +425,80 @@ const CLASS_LABEL = { [SAFE_REMOVE]: 'remove', [MANAGED_MARKER]: 'reverse', [REP
|
|
|
314
425
|
// or shell metacharacters can't misbehave when the user pastes it (codex #4).
|
|
315
426
|
const shq = (p) => `'${String(p).replace(/'/g, "'\\''")}'`;
|
|
316
427
|
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
|
|
428
|
+
// Does any STRING VALUE anywhere in a parsed settings tree contain the placed hook path? Recursive
|
|
429
|
+
// so the probe reads the DECODED command (JSON `\/` and `/` escapes are already resolved by
|
|
430
|
+
// JSON.parse) — a raw-text substring scan would miss a validly-escaped `.claude\/hooks\/…` entry
|
|
431
|
+
// and wrongly read the hook as unwired.
|
|
432
|
+
const jsonStringContains = (value, needle) => {
|
|
433
|
+
if (typeof value === 'string') return value.includes(needle);
|
|
434
|
+
if (Array.isArray(value)) return value.some((v) => jsonStringContains(v, needle));
|
|
435
|
+
if (value !== null && typeof value === 'object') return Object.values(value).some((v) => jsonStringContains(v, needle));
|
|
436
|
+
return false;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// Is the gate-approval hook wired anywhere in a settings blob? Parse when possible and scan the
|
|
440
|
+
// DECODED string values (catches `\/` / `/` escapes); on malformed JSON fall back to a raw
|
|
441
|
+
// substring probe. Deliberately BROADER than the writer's exact-command check (any string mentioning
|
|
442
|
+
// the placed path counts): over-detection preserves the hook file, under-detection could remove a
|
|
443
|
+
// still-wired one — so the fallback errs toward wired.
|
|
444
|
+
const settingsMentionsGateHook = (settings) => {
|
|
445
|
+
const parsed = (() => { try { return JSON.parse(settings); } catch { return null; } })();
|
|
446
|
+
return parsed !== null && typeof parsed === 'object'
|
|
447
|
+
? jsonStringContains(parsed, GATE_HOOK_FILE_REL)
|
|
448
|
+
: settings.includes(GATE_HOOK_FILE_REL);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// Detect the three settings.json seams the family may have written: the attribution edit
|
|
452
|
+
// (`includeCoAuthoredBy`), the velocity profile (`permissions.defaultMode` / `permissions.allow`),
|
|
453
|
+
// and the gate-approval hook wiring (a PreToolUse entry running the placed hook file — probed by
|
|
454
|
+
// the placed path across DECODED string values, deliberately BROADER than the writer's exact-command
|
|
455
|
+
// check: a hand-edited variant still counts as wired, so the file is preserved rather than removed).
|
|
456
|
+
// Parse when possible (accurate); on malformed JSON fall back to a substring probe for the
|
|
457
|
+
// attribution key so it is still surfaced (no silent miss). The velocity writer stores NO ownership
|
|
458
|
+
// marker, so the permissions seam is reported NON-COMMITTALLY — never a false ownership claim,
|
|
459
|
+
// never auto-removed.
|
|
322
460
|
const detectSettingsSeams = (settings) => {
|
|
323
|
-
if (settings == null) return { attribution: false, permissions: false };
|
|
461
|
+
if (settings == null) return { attribution: false, permissions: false, gateHook: false };
|
|
462
|
+
const gateHook = settingsMentionsGateHook(settings);
|
|
324
463
|
const parsed = (() => { try { return JSON.parse(settings); } catch { return null; } })();
|
|
325
464
|
if (parsed == null || typeof parsed !== 'object') {
|
|
326
|
-
// Malformed / JSONC settings.json (comments, trailing commas) — probe
|
|
327
|
-
//
|
|
465
|
+
// Malformed / JSONC settings.json (comments, trailing commas) — probe the seams by substring so
|
|
466
|
+
// none is silently missed (over-reporting REPORT_ONLY is safe; it is never auto-removed).
|
|
328
467
|
return {
|
|
329
468
|
attribution: settings.includes('includeCoAuthoredBy'),
|
|
330
469
|
permissions: settings.includes('"permissions"') && (settings.includes('"defaultMode"') || settings.includes('"allow"')),
|
|
470
|
+
gateHook,
|
|
331
471
|
};
|
|
332
472
|
}
|
|
333
473
|
const perms = parsed.permissions;
|
|
334
474
|
const permissions = perms != null && typeof perms === 'object'
|
|
335
475
|
&& (Object.prototype.hasOwnProperty.call(perms, 'defaultMode') || Object.prototype.hasOwnProperty.call(perms, 'allow'));
|
|
336
|
-
return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions };
|
|
476
|
+
return { attribution: Object.prototype.hasOwnProperty.call(parsed, 'includeCoAuthoredBy'), permissions, gateHook };
|
|
337
477
|
};
|
|
338
478
|
|
|
339
479
|
const ATTRIBUTION_REASON = 'we set "includeCoAuthoredBy": false here — review/remove that key by hand (the file may hold your own settings)';
|
|
340
480
|
const PERMISSIONS_REASON = 'a "permissions.defaultMode" and/or "permissions.allow" key is present in this file — if the velocity profile seeded them, review/remove by hand (no ownership marker is stored); otherwise leave them';
|
|
481
|
+
const GATE_HOOK_SEAM_REASON = `a PreToolUse entry wiring the kit-placed gate-approval hook (${GATE_HOOK_FILE_REL}) is present — remove that entry by hand to unwire it (the tool never edits settings)`;
|
|
341
482
|
|
|
342
483
|
const settingsSeamReason = (seams) =>
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
484
|
+
[
|
|
485
|
+
...(seams.attribution ? [ATTRIBUTION_REASON] : []),
|
|
486
|
+
...(seams.permissions ? [PERMISSIONS_REASON] : []),
|
|
487
|
+
...(seams.gateHook ? [GATE_HOOK_SEAM_REASON] : []),
|
|
488
|
+
].join('. Also: ');
|
|
489
|
+
|
|
490
|
+
const settingsSeamHand = (seams, p) => {
|
|
491
|
+
const clauses = [
|
|
492
|
+
...(seams.attribution ? ['remove the "includeCoAuthoredBy" entry'] : []),
|
|
493
|
+
...(seams.permissions
|
|
494
|
+
? [seams.attribution
|
|
495
|
+
? 'review "permissions.defaultMode"/"permissions.allow" (if the velocity profile seeded them)'
|
|
496
|
+
: 'if the velocity profile seeded "permissions.defaultMode"/"permissions.allow", review/remove them by hand']
|
|
497
|
+
: []),
|
|
498
|
+
...(seams.gateHook ? [`remove the PreToolUse entry whose command runs ${GATE_HOOK_FILE_REL}`] : []),
|
|
499
|
+
];
|
|
500
|
+
return `edit ${shq(p)} → ${clauses.join(' and ')} (keep the rest of your settings)`;
|
|
501
|
+
};
|
|
353
502
|
|
|
354
503
|
// The "do this by hand" line for a report-only surface. A settings.json item carries its own `hand`
|
|
355
504
|
// guidance (an EDIT, never an `rm` — deleting it would lose the user's own settings, codex #4);
|
|
@@ -89,13 +89,31 @@ export const SHELL_READONLY = Object.freeze([
|
|
|
89
89
|
// commands; but the RUNTIME residual (redirection, command substitution, a kept command's own
|
|
90
90
|
// `--output=<file>` write flag) means a seeded read-only entry is a TRUST-POSTURE convenience, NOT
|
|
91
91
|
// a sandbox. velocity never ADDS commit/push/publish as allow rules and keeps acceptEdits opt-in;
|
|
92
|
-
//
|
|
93
|
-
// PreToolUse hook (
|
|
92
|
+
// runtime closure is not something settings-level allow rules can enforce — the residual guard
|
|
93
|
+
// ships as the opt-in PreToolUse hook (Mode: hook, tools/gate-hook.mjs; probe record in AD-037).
|
|
94
94
|
export const SHELL_METACHARACTERS = Object.freeze([
|
|
95
95
|
'&', '|', ';', '<', '>', '$', '`', '(', ')',
|
|
96
96
|
'\n', '\r', '\t', '\\', '{', '}', '*', '?', '#', '~', '!',
|
|
97
97
|
]);
|
|
98
98
|
|
|
99
|
+
// The RUNTIME residual documented above, as data: the exact write-redirection / command-
|
|
100
|
+
// substitution / bounded write-flag forms a settings-level allow rule cannot see. Consumed by
|
|
101
|
+
// the PreToolUse gate hook's residual guard — the placed hook (references/hooks/gate-approve.mjs)
|
|
102
|
+
// bakes a frozen COPY (a placed file cannot import the kit), drift-guarded by
|
|
103
|
+
// test/gate-hook-core-parity.test.mjs alongside UNIVERSAL_READONLY_ALLOWLIST.
|
|
104
|
+
export const RUNTIME_RESIDUAL_FORMS = Object.freeze({
|
|
105
|
+
writeRedirections: Object.freeze(['>', '>>', '1>', '2>', '&>', '>|']),
|
|
106
|
+
// `$(…)` + backtick + process substitution `<(…)` all RUN a nested command (`>(…)` is caught by
|
|
107
|
+
// the `>` redirection scan). Bare `<` is input redirection (reads a file — read-only commands may
|
|
108
|
+
// already do that), so it is deliberately NOT here.
|
|
109
|
+
commandSubstitutions: Object.freeze(['$(', '`', '<(']),
|
|
110
|
+
// The `--output` write-flag family, matched as a raw SUBSTRING of the whole command (never a
|
|
111
|
+
// whitespace-token check): the hook sees the pre-shell command string, so `--output=f`,
|
|
112
|
+
// `"--output=f"`, `'--output' f`, and `\--output` must all trip it — over-asking on a benign
|
|
113
|
+
// `--output-indicator` is the safe direction (never under-allow a real write flag through quotes).
|
|
114
|
+
boundedWriteFlags: Object.freeze(['--output']),
|
|
115
|
+
});
|
|
116
|
+
|
|
99
117
|
export const VELOCITY_OFFCORE = 'VELOCITY_OFFCORE';
|
|
100
118
|
export const VELOCITY_NON_READONLY = 'VELOCITY_NON_READONLY';
|
|
101
119
|
export const VELOCITY_INVALID_ARGUMENT = 'VELOCITY_INVALID_ARGUMENT';
|
|
@@ -137,7 +155,7 @@ const MUTATING_SCRIPT_HOOK_PATTERN = /^(pre|post)/iu;
|
|
|
137
155
|
// refusal is named, tested, and produces a clear message).
|
|
138
156
|
const MUTATING_ALLOW_COMMAND_PATTERN = /^(?:git\s+(?:commit|push)|npm\s+publish)(?:\s|$)/iu;
|
|
139
157
|
const RESIDUAL_NOTICE =
|
|
140
|
-
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the
|
|
158
|
+
'residual: seeded read-only allow entries are a trust-posture convenience, NOT a sandbox; settings-level rules cannot inspect runtime redirection/command-substitution/--output writes; commit/push/publish are never allowlisted (a DIRECT invocation still ASKs, but the runtime residual is not closed here); the residual guard ships as the opt-in PreToolUse hook — Mode: hook (/agent-workflow-kit hook).';
|
|
141
159
|
|
|
142
160
|
const USAGE = `usage: velocity-profile [--dry-run | --apply] [--accept-edits] [--cwd <dir>] [--help]
|
|
143
161
|
|
package/tools/view-model.mjs
CHANGED
|
@@ -65,8 +65,25 @@ const velocityVm = (v) => {
|
|
|
65
65
|
return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const hookVm = (h) => {
|
|
69
|
+
if (!h) return null;
|
|
70
|
+
if (h.error) return { error: h.error };
|
|
71
|
+
return {
|
|
72
|
+
wired: Boolean(h.wired),
|
|
73
|
+
filePlaced: Boolean(h.filePlaced),
|
|
74
|
+
declarationPresent: Boolean(h.declarationPresent),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
68
78
|
const settingsVm = (s) =>
|
|
69
|
-
s
|
|
79
|
+
s
|
|
80
|
+
? {
|
|
81
|
+
recipes: recipesVm(s.recipes),
|
|
82
|
+
attribution: attributionVm(s.attribution),
|
|
83
|
+
velocity: velocityVm(s.velocity),
|
|
84
|
+
hook: hookVm(s.hook),
|
|
85
|
+
}
|
|
86
|
+
: null;
|
|
70
87
|
|
|
71
88
|
const projectVm = (p) =>
|
|
72
89
|
p
|