@opengsd/gsd-core 1.5.0-rc.1 → 1.5.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +3 -2
- package/agents/gsd-debug-session-manager.md +2 -1
- package/agents/gsd-debugger.md +4 -3
- package/agents/gsd-executor.md +16 -15
- package/agents/gsd-intel-updater.md +38 -41
- package/agents/gsd-phase-researcher.md +8 -8
- package/agents/gsd-plan-checker.md +12 -11
- package/agents/gsd-planner.md +21 -181
- package/agents/gsd-project-researcher.md +5 -4
- package/agents/gsd-research-synthesizer.md +2 -1
- package/agents/gsd-ui-researcher.md +2 -1
- package/agents/gsd-verifier.md +9 -8
- package/bin/install.js +205 -1402
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd_run +20 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1820 -3
- package/gsd-core/bin/lib/check-command-router.cjs +133 -2
- package/gsd-core/bin/lib/core.cjs +51 -1
- package/gsd-core/bin/lib/edge-probe.cjs +173 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
- package/gsd-core/bin/lib/intel.cjs +3 -3
- package/gsd-core/bin/lib/io.cjs +61 -6
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
- package/gsd-core/bin/lib/phase.cjs +17 -0
- package/gsd-core/bin/lib/probe-core.cjs +257 -0
- package/gsd-core/bin/lib/roadmap.cjs +40 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +51 -141
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +67 -29
- package/gsd-core/bin/lib/runtime-homes.cjs +137 -101
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/state-document.cjs +8 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
- package/gsd-core/bin/lib/update-context.cjs +4 -1
- package/gsd-core/bin/lib/verify.cjs +103 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
- package/gsd-core/bin/shared/model-catalog.json +11 -6
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
- package/gsd-core/references/edge-probe.md +261 -0
- package/gsd-core/references/planner-antipatterns.md +41 -0
- package/gsd-core/references/planner-guidance.md +186 -0
- package/gsd-core/templates/spec.md +12 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +26 -37
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +51 -16
- package/gsd-core/workflows/complete-milestone.md +1 -1
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +1 -1
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +9 -1
- package/gsd-core/workflows/new-project.md +9 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
- package/gsd-core/workflows/plan-phase.md +39 -27
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +1 -1
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +18 -14
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ship.md +1 -1
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +130 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +1 -1
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/update.md +2 -2
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +1 -1
- package/package.json +6 -3
- package/scripts/gen-capability-registry.cjs +498 -13
- package/scripts/lib/allowlist-ratchet.cjs +101 -1
- package/scripts/lint-test-file-count.allowlist.json +15 -2
- package/scripts/lint-windows-test-portability.cjs +178 -0
- package/scripts/research-profiles.cjs +10 -10
- package/scripts/run-tests.cjs +54 -13
- package/scripts/sync-next-version.cjs +133 -0
- package/scripts/sync-runtime-launcher.cjs +21 -5
- package/scripts/update-size-baseline.cjs +68 -0
- package/scripts/workflow-policy.cjs +42 -9
- package/scripts/workflow-size.cjs +90 -0
package/bin/install.js
CHANGED
|
@@ -37,7 +37,7 @@ const {
|
|
|
37
37
|
applyWorktreeBaseRef,
|
|
38
38
|
readBaseRefFromSettings,
|
|
39
39
|
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
|
|
40
|
-
const {
|
|
40
|
+
const { resolveInstallPlan } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs');
|
|
41
41
|
// Canonical set of hook files shipped to users. Imported here so writeManifest()
|
|
42
42
|
// records exactly the same set that build-hooks.js copies to hooks/dist/, making
|
|
43
43
|
// the manifest and the installed hooks/ dir structurally identical. Avoids the
|
|
@@ -45,6 +45,11 @@ const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-conf
|
|
|
45
45
|
const { HOOKS_TO_COPY: _HOOKS_TO_COPY } = require('../scripts/build-hooks.js');
|
|
46
46
|
const INSTALLED_HOOK_FILES = new Set(_HOOKS_TO_COPY);
|
|
47
47
|
|
|
48
|
+
// ADR-857 phase 5f-1: hook-surface writer functions extracted to a dedicated module.
|
|
49
|
+
// bin/install.js re-exports everything from hooksSurface so existing callers
|
|
50
|
+
// (require('../bin/install.js').writeCursorHooksJson etc.) continue to work.
|
|
51
|
+
const hooksSurface = require('../gsd-core/bin/lib/runtime-hooks-surface.cjs');
|
|
52
|
+
|
|
48
53
|
/**
|
|
49
54
|
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
|
|
50
55
|
* verbatim (only branding swaps, no namespace conversion), so retired
|
|
@@ -405,7 +410,7 @@ function selectRuntimesFromArgs(runtimeArgs) {
|
|
|
405
410
|
if (runtimeArgs.includes('--copilot')) selected.push('copilot');
|
|
406
411
|
if (runtimeArgs.includes('--antigravity')) selected.push('antigravity');
|
|
407
412
|
if (runtimeArgs.includes('--cursor')) selected.push('cursor');
|
|
408
|
-
if (runtimeArgs.includes('--windsurf')) selected.push('windsurf');
|
|
413
|
+
if (runtimeArgs.includes('--windsurf') || runtimeArgs.includes('--devin-desktop')) selected.push('windsurf');
|
|
409
414
|
if (runtimeArgs.includes('--augment')) selected.push('augment');
|
|
410
415
|
if (runtimeArgs.includes('--trae')) selected.push('trae');
|
|
411
416
|
if (runtimeArgs.includes('--qwen')) selected.push('qwen');
|
|
@@ -460,9 +465,9 @@ function getDirName(runtime) {
|
|
|
460
465
|
if (runtime === 'gemini') return '.gemini';
|
|
461
466
|
if (runtime === 'kilo') return '.kilo';
|
|
462
467
|
if (runtime === 'codex') return '.codex';
|
|
463
|
-
if (runtime === 'antigravity') return '.
|
|
468
|
+
if (runtime === 'antigravity') return '.agents';
|
|
464
469
|
if (runtime === 'cursor') return '.cursor';
|
|
465
|
-
if (runtime === 'windsurf') return '.
|
|
470
|
+
if (runtime === 'windsurf') return '.devin';
|
|
466
471
|
if (runtime === 'augment') return '.augment';
|
|
467
472
|
if (runtime === 'trae') return '.trae';
|
|
468
473
|
if (runtime === 'qwen') return '.qwen';
|
|
@@ -495,7 +500,7 @@ function getConfigDirFromHome(runtime, isGlobal) {
|
|
|
495
500
|
if (runtime === 'kilo') return "'.config', 'kilo'";
|
|
496
501
|
if (runtime === 'codex') return "'.codex'";
|
|
497
502
|
if (runtime === 'antigravity') {
|
|
498
|
-
if (!isGlobal) return "'.
|
|
503
|
+
if (!isGlobal) return "'.agents'";
|
|
499
504
|
const antigravityDir = resolveAntigravityGlobalDir();
|
|
500
505
|
const rel = path.relative(os.homedir(), antigravityDir);
|
|
501
506
|
const segments = rel.split(path.sep).filter(Boolean);
|
|
@@ -622,215 +627,22 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
|
|
|
622
627
|
return `${resolvedTarget}/`;
|
|
623
628
|
}
|
|
624
629
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
* Non-Homebrew installs (NVM, system node, Windows, etc.) are returned as-is.
|
|
639
|
-
*/
|
|
640
|
-
function normalizeNodePath(execPath, opts) {
|
|
641
|
-
if (!execPath) return execPath;
|
|
642
|
-
const env = (opts && opts.env) || process.env;
|
|
643
|
-
const existsSync = (opts && opts.existsSync) || fs.existsSync;
|
|
644
|
-
|
|
645
|
-
// fnm multishell shim: C:/Users/<u>/AppData/Local/fnm_multishells/<pid>_<ts>/node.exe
|
|
646
|
-
// These are per-shell-session ephemeral directories that fnm cleans up on shell exit.
|
|
647
|
-
// Probe the stable fnm alias paths instead so baked hook commands survive shell restarts.
|
|
648
|
-
// (#977)
|
|
649
|
-
// TODO: Volta (~/.volta/bin/node → ~/.volta/tools/image/node/<ver>/bin/node) and
|
|
650
|
-
// nvm-windows (AppData/Roaming/nvm/<ver>/node.exe) have analogous issues — future work.
|
|
651
|
-
const normalizedForMatch = execPath.replace(/\\/g, '/');
|
|
652
|
-
if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
|
|
653
|
-
const candidates = [];
|
|
654
|
-
if (env.FNM_DIR) {
|
|
655
|
-
// Preferred: alias installed directly under FNM_DIR/aliases/default/
|
|
656
|
-
candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`);
|
|
657
|
-
// POSIX layout (no .exe)
|
|
658
|
-
candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`);
|
|
659
|
-
}
|
|
660
|
-
if (env.APPDATA) {
|
|
661
|
-
// Fallback: fnm default location on Windows when FNM_DIR is not set
|
|
662
|
-
candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`);
|
|
663
|
-
}
|
|
664
|
-
for (const candidate of candidates) {
|
|
665
|
-
if (existsSync(candidate)) return candidate;
|
|
666
|
-
}
|
|
667
|
-
// No stable alias found — return raw path unchanged (graceful fallback)
|
|
668
|
-
return execPath;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// Intel Homebrew: /usr/local/Cellar/node/<version>/bin/node
|
|
672
|
-
// or /usr/local/Cellar/node@20/<version>/bin/node
|
|
673
|
-
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
674
|
-
return '/usr/local/bin/node';
|
|
675
|
-
}
|
|
676
|
-
// Apple Silicon Homebrew: /opt/homebrew/Cellar/node/<version>/bin/node
|
|
677
|
-
// or /opt/homebrew/Cellar/node@18/<version>/bin/node
|
|
678
|
-
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
679
|
-
return '/opt/homebrew/bin/node';
|
|
680
|
-
}
|
|
681
|
-
return execPath;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/**
|
|
685
|
-
* Resolve the absolute path to the node binary running the installer.
|
|
686
|
-
* Used as the runner for .js hooks so they execute in GUI/minimal-PATH
|
|
687
|
-
* runtimes (Gemini, Antigravity, Codex CLIs launched from a Finder
|
|
688
|
-
* shortcut etc.) where bare `node` is not on `/usr/bin:/bin:/usr/sbin:/sbin`
|
|
689
|
-
* and the hook would fail with `node: command not found` (#2979).
|
|
690
|
-
*
|
|
691
|
-
* Returns a forward-slash-normalized, double-quoted path so the emitted
|
|
692
|
-
* command is shell-safe across POSIX and Windows. `process.execPath`
|
|
693
|
-
* gives the absolute path of the node binary actively running the
|
|
694
|
-
* installer — that is the version the user just installed under, and
|
|
695
|
-
* the right default runtime for hooks invoked under the same install.
|
|
696
|
-
*
|
|
697
|
-
* When `process.execPath` is a versioned Homebrew Cellar path, the stable
|
|
698
|
-
* Homebrew symlink is returned instead to survive `brew upgrade node` (#3181).
|
|
699
|
-
*
|
|
700
|
-
* When `process.execPath` is an ephemeral fnm multishell shim, the stable fnm
|
|
701
|
-
* alias path is returned instead so managed hook commands survive shell restarts
|
|
702
|
-
* (#977). An optional `opts` bag (`{ env, existsSync }`) is accepted for
|
|
703
|
-
* testability; production callers omit it to get real env / fs.
|
|
704
|
-
*/
|
|
705
|
-
function resolveNodeRunner(opts) {
|
|
706
|
-
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
|
|
707
|
-
if (!execPath) return null;
|
|
708
|
-
const stablePath = normalizeNodePath(execPath, opts);
|
|
709
|
-
// JSON.stringify produces a properly escaped double-quoted shell token,
|
|
710
|
-
// safe for paths containing spaces or unusual characters.
|
|
711
|
-
return JSON.stringify(stablePath.replace(/\\/g, '/'));
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
/**
|
|
715
|
-
* Rewrite legacy `node .../gsd-*.js` command strings in settings.hooks to use
|
|
716
|
-
* the absolute Node binary path (#2979 follow-up: CR feedback on #3002).
|
|
717
|
-
*
|
|
718
|
-
* The original #2979 fix only emitted absolute paths for *newly registered*
|
|
719
|
-
* hooks. Pre-existing entries kept their bare `node ` prefix on reinstall,
|
|
720
|
-
* which left them broken under minimal-PATH GUI runtimes — exactly the
|
|
721
|
-
* failure mode the original fix was meant to close. This walker normalizes
|
|
722
|
-
* any managed-hook entry whose command starts with bare `node ` to
|
|
723
|
-
* `<absoluteRunner> <script>` while leaving non-managed and non-bare-node
|
|
724
|
-
* entries (user-authored hooks, shell scripts, etc.) untouched.
|
|
725
|
-
*
|
|
726
|
-
* Returns true if any entry was rewritten.
|
|
727
|
-
*/
|
|
728
|
-
function resolveBashRunner(opts) {
|
|
729
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
730
|
-
if (platform !== 'win32') return 'bash';
|
|
731
|
-
|
|
732
|
-
const env = (opts && opts.env) || process.env;
|
|
733
|
-
const exists = (opts && opts.existsSync) || fs.existsSync;
|
|
734
|
-
const candidates = [];
|
|
735
|
-
if (env.GSD_BASH_PATH) candidates.push(env.GSD_BASH_PATH);
|
|
736
|
-
if (env.ProgramFiles) candidates.push(path.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
|
|
737
|
-
if (env['ProgramFiles(x86)']) candidates.push(path.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
|
|
738
|
-
if (env.SystemDrive) {
|
|
739
|
-
candidates.push(path.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
|
|
740
|
-
candidates.push(path.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
for (const candidate of candidates) {
|
|
744
|
-
if (candidate && exists(candidate)) {
|
|
745
|
-
return JSON.stringify(candidate.replace(/\\/g, '/'));
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
return null;
|
|
749
|
-
}
|
|
630
|
+
// normalizeNodePath, resolveNodeRunner, resolveBashRunner, referencesHook are
|
|
631
|
+
// now owned by the runtime-hooks-surface module. Import them here so
|
|
632
|
+
// install.js callers continue to work and so there is a single implementation
|
|
633
|
+
// of these helpers.
|
|
634
|
+
const normalizeNodePath = hooksSurface.normalizeNodePath;
|
|
635
|
+
const resolveNodeRunner = hooksSurface.resolveNodeRunner;
|
|
636
|
+
const resolveBashRunner = hooksSurface.resolveBashRunner;
|
|
637
|
+
// referencesHook: pure predicate over hook entry objects, shared between
|
|
638
|
+
// install() and finishInstall() (ADR-857 phase 5f-1b).
|
|
639
|
+
const referencesHook = hooksSurface.referencesHook;
|
|
640
|
+
// applySettingsJsonHooks: mutates settings.hooks.* in place with all GSD-managed
|
|
641
|
+
// hook registrations for settings.json-surface runtimes (ADR-857 phase 5f-1b).
|
|
642
|
+
const applySettingsJsonHooks = hooksSurface.applySettingsJsonHooks;
|
|
750
643
|
|
|
751
644
|
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
752
|
-
|
|
753
|
-
if (!opts) opts = {};
|
|
754
|
-
const platform = opts.platform || process.platform;
|
|
755
|
-
let changed = false;
|
|
756
|
-
for (const entries of Object.values(settings.hooks)) {
|
|
757
|
-
if (!Array.isArray(entries)) continue;
|
|
758
|
-
for (const entry of entries) {
|
|
759
|
-
if (!entry || !Array.isArray(entry.hooks)) continue;
|
|
760
|
-
for (const h of entry.hooks) {
|
|
761
|
-
if (!h || typeof h.command !== 'string') continue;
|
|
762
|
-
// args-form entries have the script path in h.args[] and h.command is
|
|
763
|
-
// the launcher executable (not a managed hook command). These are
|
|
764
|
-
// intentional user wrappers — do not rewrite them. (#976)
|
|
765
|
-
if (Array.isArray(h.args) && h.args.length > 0) continue;
|
|
766
|
-
let trimmed = h.command.trim();
|
|
767
|
-
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
|
|
768
|
-
if (hadPowerShellCallOperator) {
|
|
769
|
-
trimmed = trimmed.replace(/^&\s+/, '').trim();
|
|
770
|
-
}
|
|
771
|
-
// Match two runner forms:
|
|
772
|
-
// 1. Legacy bare-node form: `node <script>` (#2979/#3002)
|
|
773
|
-
// 2. Cellar-path form: `"/usr/local/Cellar/node/<v>/bin/node" <script>`
|
|
774
|
-
// or `"/opt/homebrew/Cellar/node/<v>/bin/node" <script>` (#3181)
|
|
775
|
-
//
|
|
776
|
-
// Both patterns use the same script-token capture group so the rewrite
|
|
777
|
-
// is uniform. We detect the Cellar form by extracting the runner token
|
|
778
|
-
// and running it through normalizeNodePath.
|
|
779
|
-
//
|
|
780
|
-
// The previous shape used `trimmed.includes(<filename>)` which would
|
|
781
|
-
// false-positive on user-authored hooks whose path merely contained
|
|
782
|
-
// a managed filename as a substring (e.g.
|
|
783
|
-
// /home/me/scripts/wraps-gsd-check-update.js-and-more.js). #3002 CR.
|
|
784
|
-
const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
|
|
785
|
-
trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
|
|
786
|
-
if (!m) continue;
|
|
787
|
-
|
|
788
|
-
let runnerToken, scriptToken, scriptPath;
|
|
789
|
-
if (/^node\s+/.test(trimmed)) {
|
|
790
|
-
// bare-node form
|
|
791
|
-
runnerToken = 'node';
|
|
792
|
-
scriptToken = m[1];
|
|
793
|
-
scriptPath = m[2] || m[3] || m[4] || '';
|
|
794
|
-
} else {
|
|
795
|
-
// quoted/unquoted runner form — check whether runner is a Cellar path
|
|
796
|
-
runnerToken = m[1];
|
|
797
|
-
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
|
|
798
|
-
const stableRunner = normalizeNodePath(runnerPath);
|
|
799
|
-
// Process Cellar paths so they normalize to a stable symlink. On
|
|
800
|
-
// Windows, already-absolute runners still flow through the projection
|
|
801
|
-
// seam because some runtimes need additional wrapper policy while
|
|
802
|
-
// others must stay shell-neutral (#3362, #3413).
|
|
803
|
-
if (stableRunner === runnerPath && platform !== 'win32') continue;
|
|
804
|
-
scriptToken = m[5];
|
|
805
|
-
scriptPath = m[6] || m[7] || m[8] || '';
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
// Take the basename — match against MANAGED_HOOK_FILES by exact
|
|
809
|
-
// equality, not substring containment. Handles both forward and
|
|
810
|
-
// backslash separators (Windows).
|
|
811
|
-
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' })) continue;
|
|
812
|
-
|
|
813
|
-
const projectedCommand = projectLegacySettingsHookCommand({
|
|
814
|
-
absoluteRunner,
|
|
815
|
-
scriptPath,
|
|
816
|
-
scriptToken,
|
|
817
|
-
runtime: opts.runtime || 'generic',
|
|
818
|
-
platform,
|
|
819
|
-
});
|
|
820
|
-
if (!projectedCommand) continue;
|
|
821
|
-
|
|
822
|
-
// Skip only when the existing managed command already matches the
|
|
823
|
-
// desired runtime-aware projected shape. This preserves Gemini's
|
|
824
|
-
// required PowerShell prefix while still letting Claude strip stale
|
|
825
|
-
// prefixes on reinstall (#3413).
|
|
826
|
-
if (h.command === projectedCommand) continue;
|
|
827
|
-
|
|
828
|
-
h.command = projectedCommand;
|
|
829
|
-
changed = true;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
return changed;
|
|
645
|
+
return hooksSurface.rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts);
|
|
834
646
|
}
|
|
835
647
|
|
|
836
648
|
/**
|
|
@@ -856,22 +668,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
|
856
668
|
* @returns {string|null} The toml block to append, or null on missing runner.
|
|
857
669
|
*/
|
|
858
670
|
function buildCodexHookBlock(targetDir, opts) {
|
|
859
|
-
|
|
860
|
-
if (!absoluteRunner) return null;
|
|
861
|
-
const eol = (opts && opts.eol) || '\n';
|
|
862
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
863
|
-
const updateCheckScript = path.resolve(targetDir, 'hooks', 'gsd-check-update.js');
|
|
864
|
-
const commandValue = projectCodexHookTomlCommand({
|
|
865
|
-
absoluteRunner,
|
|
866
|
-
scriptPath: updateCheckScript,
|
|
867
|
-
platform,
|
|
868
|
-
});
|
|
869
|
-
return `${eol}# GSD Hooks${eol}` +
|
|
870
|
-
`[[hooks.SessionStart]]${eol}` +
|
|
871
|
-
`${eol}` +
|
|
872
|
-
`[[hooks.SessionStart.hooks]]${eol}` +
|
|
873
|
-
`type = "command"${eol}` +
|
|
874
|
-
`command = "${commandValue}"${eol}`;
|
|
671
|
+
return hooksSurface.buildCodexHookBlock(targetDir, opts);
|
|
875
672
|
}
|
|
876
673
|
|
|
877
674
|
/**
|
|
@@ -888,45 +685,7 @@ function buildCodexHookBlock(targetDir, opts) {
|
|
|
888
685
|
* @returns {{ content: string, changed: boolean }}
|
|
889
686
|
*/
|
|
890
687
|
function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
891
|
-
|
|
892
|
-
const platform = (opts && opts.platform) || process.platform;
|
|
893
|
-
let changed = false;
|
|
894
|
-
// Match `command = "node <scriptToken>"` lines where scriptToken is
|
|
895
|
-
// either an unquoted path (no spaces) or a toml-escaped quoted path.
|
|
896
|
-
// The whole RHS is a toml-double-quoted string; interior quotes are \".
|
|
897
|
-
// Examples we want to migrate:
|
|
898
|
-
// command = "node /Users/x/.codex/hooks/gsd-check-update.js"
|
|
899
|
-
// command = "node \"/Users/x/.codex/hooks/gsd-check-update.js\""
|
|
900
|
-
// Examples we must leave alone:
|
|
901
|
-
// command = "\"/usr/local/bin/node\" \"/path/to/gsd-check-update.js\"" ← already absolute
|
|
902
|
-
// command = "node /home/me/my-custom.js" ← user-owned filename
|
|
903
|
-
const updated = content.replace(
|
|
904
|
-
/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm,
|
|
905
|
-
(full, prefix, scriptToken, suffix) => {
|
|
906
|
-
// Extract the underlying script path from the captured token —
|
|
907
|
-
// either the bare token or the decoded inner content of \"...\".
|
|
908
|
-
const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
|
|
909
|
-
let scriptPath = scriptToken;
|
|
910
|
-
if (quoted) {
|
|
911
|
-
try {
|
|
912
|
-
scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
|
|
913
|
-
} catch {
|
|
914
|
-
scriptPath = quoted[1];
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' })) return full;
|
|
918
|
-
const desiredCommand = projectCodexHookTomlCommand({
|
|
919
|
-
absoluteRunner,
|
|
920
|
-
scriptPath,
|
|
921
|
-
platform,
|
|
922
|
-
});
|
|
923
|
-
const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
|
|
924
|
-
if (currentCommand === desiredCommand) return full;
|
|
925
|
-
changed = true;
|
|
926
|
-
return `${prefix}${desiredCommand}${suffix}`;
|
|
927
|
-
},
|
|
928
|
-
);
|
|
929
|
-
return { content: updated, changed };
|
|
688
|
+
return hooksSurface.rewriteLegacyCodexHookBlock(content, absoluteRunner, opts);
|
|
930
689
|
}
|
|
931
690
|
|
|
932
691
|
/**
|
|
@@ -949,83 +708,7 @@ function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
|
949
708
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
950
709
|
*/
|
|
951
710
|
function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
952
|
-
|
|
953
|
-
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
|
|
954
|
-
const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
|
|
955
|
-
const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
|
|
956
|
-
const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
|
|
957
|
-
let parsed = {};
|
|
958
|
-
let currentContent = null;
|
|
959
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
960
|
-
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
961
|
-
currentContent = raw;
|
|
962
|
-
if (raw.trim()) {
|
|
963
|
-
try {
|
|
964
|
-
parsed = JSON.parse(raw);
|
|
965
|
-
} catch (err) {
|
|
966
|
-
throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
}
|
|
970
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
971
|
-
|
|
972
|
-
const usesNestedHooksObject =
|
|
973
|
-
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
974
|
-
const hookTable = usesNestedHooksObject ? parsed.hooks : parsed;
|
|
975
|
-
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
976
|
-
|
|
977
|
-
let removedLegacy = false;
|
|
978
|
-
const sanitizedEntries = [];
|
|
979
|
-
for (const entry of eventEntries) {
|
|
980
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
|
981
|
-
const originalHooks = Array.isArray(entry.hooks) ? entry.hooks : [];
|
|
982
|
-
if (originalHooks.length === 0) {
|
|
983
|
-
sanitizedEntries.push(entry);
|
|
984
|
-
continue;
|
|
985
|
-
}
|
|
986
|
-
const keptHooks = originalHooks.filter((hook) => {
|
|
987
|
-
const cmd = hook && typeof hook === 'object' ? hook.command : null;
|
|
988
|
-
const managed = isManagedHookCommand(cmd, {
|
|
989
|
-
surface: 'codex-hooks-json',
|
|
990
|
-
includeLegacyAliases: true,
|
|
991
|
-
configDir: targetDir,
|
|
992
|
-
});
|
|
993
|
-
if (managed) removedLegacy = true;
|
|
994
|
-
return !managed;
|
|
995
|
-
});
|
|
996
|
-
if (keptHooks.length === 0) continue;
|
|
997
|
-
const nextEntry = { ...entry, hooks: keptHooks };
|
|
998
|
-
sanitizedEntries.push(nextEntry);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
if (managedCommand) {
|
|
1002
|
-
const hookEntry = { type: 'command', command: managedCommand };
|
|
1003
|
-
// #772: emit commandWindows so Codex picks the .cmd shim on Windows and
|
|
1004
|
-
// the POSIX command on other platforms — without requiring per-OS config
|
|
1005
|
-
// regeneration. Sourced from HookHandlerConfig.command_windows field in
|
|
1006
|
-
// codex-rs/config/src/hook_config.rs (alias: commandWindows).
|
|
1007
|
-
if (commandWindows) hookEntry.commandWindows = commandWindows;
|
|
1008
|
-
if (timeout !== undefined) hookEntry.timeout = timeout;
|
|
1009
|
-
const newEntry = { hooks: [hookEntry] };
|
|
1010
|
-
if (matcher !== undefined) newEntry.matcher = matcher;
|
|
1011
|
-
sanitizedEntries.push(newEntry);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
if (sanitizedEntries.length > 0) {
|
|
1015
|
-
hookTable[eventName] = sanitizedEntries;
|
|
1016
|
-
} else {
|
|
1017
|
-
delete hookTable[eventName];
|
|
1018
|
-
}
|
|
1019
|
-
if (usesNestedHooksObject) parsed.hooks = hookTable;
|
|
1020
|
-
|
|
1021
|
-
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
1022
|
-
const changed = currentContent !== nextContent;
|
|
1023
|
-
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
1024
|
-
if (shouldWrite) {
|
|
1025
|
-
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
|
|
711
|
+
return hooksSurface.reconcileCodexHooksJsonEvent(targetDir, eventName, opts);
|
|
1029
712
|
}
|
|
1030
713
|
|
|
1031
714
|
/**
|
|
@@ -1037,7 +720,7 @@ function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
1037
720
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1038
721
|
*/
|
|
1039
722
|
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
1040
|
-
return
|
|
723
|
+
return hooksSurface.reconcileCodexHooksJsonSessionStart(targetDir, opts);
|
|
1041
724
|
}
|
|
1042
725
|
|
|
1043
726
|
/**
|
|
@@ -1070,41 +753,7 @@ function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1070
753
|
* @returns {{ invocation: { interpreter: string, target: string }, cmdPath: string, hookCommand: string, render: { cmd: () => string } }|null}
|
|
1071
754
|
*/
|
|
1072
755
|
function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
1073
|
-
|
|
1074
|
-
// absoluteRunnerToken is JSON-quoted (e.g. '"C:/path/node.exe"'). Unwrap to
|
|
1075
|
-
// get the raw interpreter path for the invocation record and render output.
|
|
1076
|
-
let interpreter;
|
|
1077
|
-
try {
|
|
1078
|
-
interpreter = JSON.parse(absoluteRunnerToken);
|
|
1079
|
-
} catch {
|
|
1080
|
-
interpreter = absoluteRunnerToken;
|
|
1081
|
-
}
|
|
1082
|
-
// Normalise to forward slashes for cross-shell safety (same as other Windows
|
|
1083
|
-
// hook path normalisations in this codebase).
|
|
1084
|
-
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
|
|
1085
|
-
const scriptQuoted = JSON.stringify(targetAbs);
|
|
1086
|
-
// .cmd shim lives alongside the .js file, replacing the extension.
|
|
1087
|
-
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
|
|
1088
|
-
// The hook command written to hooks.json is just the .cmd path (double-quoted
|
|
1089
|
-
// for spaces-in-path safety). cmd.exe executes .cmd files natively via
|
|
1090
|
-
// CreateProcess — no runner prefix required.
|
|
1091
|
-
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
|
|
1092
|
-
const runnerQuoted = JSON.stringify(interpreter);
|
|
1093
|
-
return {
|
|
1094
|
-
invocation: { interpreter, target: scriptAbsPath },
|
|
1095
|
-
cmdPath,
|
|
1096
|
-
hookCommand,
|
|
1097
|
-
// Typed fields for IR-level assertions (CONTRIBUTING.md L558-L565).
|
|
1098
|
-
// These describe the render semantics in a structured way so tests can
|
|
1099
|
-
// assert on the generator contract without coupling to rendered text.
|
|
1100
|
-
eol: { cmd: '\r\n' }, // CRLF — canonical for cmd.exe .cmd files
|
|
1101
|
-
passthroughArgs: true, // the shim forwards all args via %*
|
|
1102
|
-
render: {
|
|
1103
|
-
// Use CRLF line endings for strict cmd.exe compatibility (LF-only
|
|
1104
|
-
// .cmd files work in modern Windows but CRLF is the canonical format).
|
|
1105
|
-
cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
|
|
1106
|
-
},
|
|
1107
|
-
};
|
|
756
|
+
return hooksSurface.buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken);
|
|
1108
757
|
}
|
|
1109
758
|
|
|
1110
759
|
/**
|
|
@@ -1135,69 +784,7 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
|
1135
784
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1136
785
|
*/
|
|
1137
786
|
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
1138
|
-
|
|
1139
|
-
const absoluteRunner = opts.absoluteRunner || null;
|
|
1140
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1141
|
-
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1142
|
-
|
|
1143
|
-
// Normalize backslashes to forward slashes so isManagedHookCommand can
|
|
1144
|
-
// match stored commands against configDir on Windows CI runners where
|
|
1145
|
-
// path.resolve returns backslash paths but the stored command may use
|
|
1146
|
-
// forward slashes (or vice versa). Forward-slash paths are always valid on
|
|
1147
|
-
// Windows for both Node.js and Codex, so this normalization is safe for all
|
|
1148
|
-
// platforms. (#772 — same fix applied to ensureCodexHooksJsonEvent.)
|
|
1149
|
-
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
1150
|
-
|
|
1151
|
-
// #772: compute the Windows .cmd shim path cross-platform so that
|
|
1152
|
-
// `commandWindows` can be emitted in hooks.json regardless of the host OS.
|
|
1153
|
-
// The .cmd path is always the .js script path with extension replaced.
|
|
1154
|
-
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
|
|
1155
|
-
|
|
1156
|
-
let managedCommand;
|
|
1157
|
-
if (platform === 'win32') {
|
|
1158
|
-
// #3426 fix: on Windows, write a .cmd shim and use its path as the hook
|
|
1159
|
-
// command. This avoids the MSYS bash.exe POSIX-exec failure when Codex's
|
|
1160
|
-
// hook dispatcher tries to run node.exe through the Git Bash exec layer.
|
|
1161
|
-
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
1162
|
-
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1163
|
-
try {
|
|
1164
|
-
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
1165
|
-
} catch (shimWriteErr) {
|
|
1166
|
-
// Shim write failed — do NOT fall back to the old "node.exe script.js"
|
|
1167
|
-
// command. That form triggers the `bash.exe: cannot execute binary file`
|
|
1168
|
-
// failure that #3426 exists to fix, so a silent fallback would silently
|
|
1169
|
-
// restore the original bug. Instead: warn loudly and skip the registration
|
|
1170
|
-
// for this runtime so the user sees an actionable message rather than a
|
|
1171
|
-
// successful install that fails at hook-dispatch time.
|
|
1172
|
-
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
1173
|
-
console.warn(
|
|
1174
|
-
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
|
|
1175
|
-
`Fix the write error (permissions? disk full?) and re-run the installer. ` +
|
|
1176
|
-
`Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`,
|
|
1177
|
-
);
|
|
1178
|
-
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1179
|
-
}
|
|
1180
|
-
managedCommand = shimIR.hookCommand;
|
|
1181
|
-
} else {
|
|
1182
|
-
managedCommand = projectManagedHookCommand({
|
|
1183
|
-
absoluteRunner,
|
|
1184
|
-
scriptPath,
|
|
1185
|
-
runtime: 'codex',
|
|
1186
|
-
platform,
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1191
|
-
|
|
1192
|
-
// #772: emit commandWindows — the .cmd shim path — but ONLY on Windows where
|
|
1193
|
-
// the shim was actually written. On POSIX, commandWindows is omitted to avoid
|
|
1194
|
-
// pointing Windows Codex at a non-existent .cmd file (the shim is only present
|
|
1195
|
-
// when install() ran natively on Windows and wrote it via buildCodexHookWindowsShimIR).
|
|
1196
|
-
const commandWindows = platform === 'win32'
|
|
1197
|
-
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
|
|
1198
|
-
: undefined;
|
|
1199
|
-
|
|
1200
|
-
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
|
|
787
|
+
return hooksSurface.ensureCodexHooksJsonSessionStart(targetDir, opts);
|
|
1201
788
|
}
|
|
1202
789
|
|
|
1203
790
|
/**
|
|
@@ -1224,50 +811,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
1224
811
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
1225
812
|
*/
|
|
1226
813
|
function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
1227
|
-
|
|
1228
|
-
const absoluteRunner = opts.absoluteRunner || null;
|
|
1229
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
1230
|
-
if (!absoluteRunner) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1231
|
-
|
|
1232
|
-
// Normalize backslashes to forward slashes so that isManagedHookCommand can
|
|
1233
|
-
// match the stored command against configDir on Windows. path.resolve on
|
|
1234
|
-
// Windows returns backslash paths, but when platform is not 'win32'
|
|
1235
|
-
// (e.g. platform: 'linux' in a test running on a Windows CI runner),
|
|
1236
|
-
// projectManagedHookCommand does not normalize them — producing a mismatch
|
|
1237
|
-
// between the stored command and the configDir-based hook-dir prefix used
|
|
1238
|
-
// for deduplication. Forward-slash paths are always valid on Windows (Node.js
|
|
1239
|
-
// and Codex both accept them), so normalizing here is safe for all platforms.
|
|
1240
|
-
const scriptPath = path.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
|
|
1241
|
-
|
|
1242
|
-
let managedCommand;
|
|
1243
|
-
if (platform === 'win32') {
|
|
1244
|
-
// #3426 fix pattern: on Windows, write a .cmd shim and use its path as the
|
|
1245
|
-
// hook command. The same bash.exe POSIX-exec failure that affects
|
|
1246
|
-
// gsd-check-update.js also affects gsd-context-monitor.js.
|
|
1247
|
-
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
1248
|
-
if (!shimIR) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1249
|
-
try {
|
|
1250
|
-
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
1251
|
-
} catch (shimWriteErr) {
|
|
1252
|
-
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
1253
|
-
console.warn(
|
|
1254
|
-
` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
|
|
1255
|
-
`Fix the write error (permissions? disk full?) and re-run the installer.`,
|
|
1256
|
-
);
|
|
1257
|
-
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1258
|
-
}
|
|
1259
|
-
managedCommand = shimIR.hookCommand;
|
|
1260
|
-
} else {
|
|
1261
|
-
managedCommand = projectManagedHookCommand({
|
|
1262
|
-
absoluteRunner,
|
|
1263
|
-
scriptPath,
|
|
1264
|
-
runtime: 'codex',
|
|
1265
|
-
platform,
|
|
1266
|
-
});
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
if (!managedCommand) return { changed: false, wrote: false, path: hooksJsonPath };
|
|
1270
|
-
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
|
|
814
|
+
return hooksSurface.ensureCodexHooksJsonEvent(targetDir, eventName, opts);
|
|
1271
815
|
}
|
|
1272
816
|
|
|
1273
817
|
/**
|
|
@@ -1277,11 +821,11 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
1277
821
|
* @param {string} eventName
|
|
1278
822
|
*/
|
|
1279
823
|
function removeCodexHooksJsonEvent(targetDir, eventName) {
|
|
1280
|
-
return
|
|
824
|
+
return hooksSurface.removeCodexHooksJsonEvent(targetDir, eventName);
|
|
1281
825
|
}
|
|
1282
826
|
|
|
1283
827
|
function removeCodexHooksJsonSessionStart(targetDir) {
|
|
1284
|
-
return
|
|
828
|
+
return hooksSurface.removeCodexHooksJsonSessionStart(targetDir);
|
|
1285
829
|
}
|
|
1286
830
|
|
|
1287
831
|
/**
|
|
@@ -1298,62 +842,7 @@ function removeCodexHooksJsonSessionStart(targetDir) {
|
|
|
1298
842
|
* runtime: target runtime name for shell projection policy.
|
|
1299
843
|
*/
|
|
1300
844
|
function buildHookCommand(configDir, hookName, opts) {
|
|
1301
|
-
|
|
1302
|
-
const platform = opts.platform || process.platform;
|
|
1303
|
-
const runtime = opts.runtime || 'generic';
|
|
1304
|
-
const isShellHook = hookName.endsWith('.sh');
|
|
1305
|
-
|
|
1306
|
-
// #166: Claude Code executes these hook commands inside a bash context on
|
|
1307
|
-
// Windows, so wrapping `.sh` hooks with an explicit `bash.exe` path can
|
|
1308
|
-
// trigger `bash.exe: ... cannot execute binary file`. Emit only the quoted
|
|
1309
|
-
// script path for Claude on Windows.
|
|
1310
|
-
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
|
|
1311
|
-
if (opts.portableHooks) {
|
|
1312
|
-
const portableBaseDir = projectPortableHookBaseDir({
|
|
1313
|
-
configDir,
|
|
1314
|
-
homeDir: os.homedir(),
|
|
1315
|
-
});
|
|
1316
|
-
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
|
|
1317
|
-
}
|
|
1318
|
-
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
// POSIX .sh hooks run under PATH-resolved `bash`: POSIX guarantees /bin/sh
|
|
1322
|
-
// but not /bin/bash, and distros like NixOS do not ship /bin/bash by default.
|
|
1323
|
-
// Windows Codex launches hooks from PowerShell/cmd environments where bare
|
|
1324
|
-
// `bash` may not be on PATH, so resolve Git Bash explicitly or return null so
|
|
1325
|
-
// callers skip registration instead of installing a known-broken hook (#3393).
|
|
1326
|
-
// .js hooks still need the absolute node path because GUI-launched runtimes
|
|
1327
|
-
// start with a minimal PATH that may not include nvm/Homebrew/Volta node
|
|
1328
|
-
// binaries (#2979).
|
|
1329
|
-
const nodeRunner = resolveNodeRunner();
|
|
1330
|
-
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
|
|
1331
|
-
// Runner resolvers return null when the executable path is unavailable.
|
|
1332
|
-
// Fall through with null so callers can skip registration with a warning
|
|
1333
|
-
// instead of emitting a command that recreates the original hook failure.
|
|
1334
|
-
if (runner === null) return null;
|
|
1335
|
-
|
|
1336
|
-
if (opts.portableHooks) {
|
|
1337
|
-
const portableBaseDir = projectPortableHookBaseDir({
|
|
1338
|
-
configDir,
|
|
1339
|
-
homeDir: os.homedir(),
|
|
1340
|
-
});
|
|
1341
|
-
return projectManagedHookCommand({
|
|
1342
|
-
absoluteRunner: runner,
|
|
1343
|
-
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
|
|
1344
|
-
runtime: opts.runtime || 'generic',
|
|
1345
|
-
platform,
|
|
1346
|
-
});
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
// Default: absolute path with forward slashes (Windows-safe, fixes #2045/#2046).
|
|
1350
|
-
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
|
1351
|
-
return projectManagedHookCommand({
|
|
1352
|
-
absoluteRunner: runner,
|
|
1353
|
-
scriptPath: hooksPath,
|
|
1354
|
-
runtime,
|
|
1355
|
-
platform,
|
|
1356
|
-
});
|
|
845
|
+
return hooksSurface.buildHookCommand(configDir, hookName, opts);
|
|
1357
846
|
}
|
|
1358
847
|
|
|
1359
848
|
/**
|
|
@@ -1720,6 +1209,52 @@ function injectEffortFrontmatter(content, effortValue) {
|
|
|
1720
1209
|
return `${before}effort: ${effortValue}${eol}${after}`;
|
|
1721
1210
|
}
|
|
1722
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* #767 — Inject `disallowedTools: <value>` into the YAML frontmatter of a Claude .md agent.
|
|
1214
|
+
* Mirrors injectEffortFrontmatter: idempotent (skips if disallowedTools: already present),
|
|
1215
|
+
* inserts immediately before the closing `---`. Claude-only — never call for other runtimes,
|
|
1216
|
+
* which break on unknown frontmatter keys.
|
|
1217
|
+
*/
|
|
1218
|
+
function injectDisallowedToolsFrontmatter(content, disallowedValue) {
|
|
1219
|
+
// Detect the dominant EOL from the first line (the opening `---`).
|
|
1220
|
+
// If the very first `---` is followed by \r\n, treat the whole file as CRLF.
|
|
1221
|
+
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
|
|
1222
|
+
|
|
1223
|
+
// Build a frontmatter-matching regex that tolerates an optional \r before
|
|
1224
|
+
// each \n, so we handle both LF and CRLF files without needing to normalise
|
|
1225
|
+
// the whole content.
|
|
1226
|
+
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
|
|
1227
|
+
const match = fmRe.exec(content);
|
|
1228
|
+
if (!match) return content; // no YAML frontmatter — leave unchanged
|
|
1229
|
+
|
|
1230
|
+
// Idempotency guard: don't insert a second disallowedTools: line.
|
|
1231
|
+
const fmBody = match[1]; // content between the two `---` lines
|
|
1232
|
+
if (/^disallowedTools:/m.test(fmBody)) return content;
|
|
1233
|
+
|
|
1234
|
+
// Locate the exact position of the closing `---` line so we can insert
|
|
1235
|
+
// before it using a simple string splice.
|
|
1236
|
+
const openLen = 3 + eol.length; // "---" + eol
|
|
1237
|
+
const closingStart = match.index + openLen + fmBody.length;
|
|
1238
|
+
|
|
1239
|
+
const before = content.slice(0, closingStart);
|
|
1240
|
+
const after = content.slice(closingStart);
|
|
1241
|
+
return `${before}disallowedTools: ${disallowedValue}${eol}${after}`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// #767 — Read-only verifier/auditor agents get a Claude-Code disallowedTools deny-list.
|
|
1245
|
+
// Group A (pure read-only) deny Write,Edit,MultiEdit. Group B report-writers Write one
|
|
1246
|
+
// output file so they deny only Edit,MultiEdit. gsd-nyquist-auditor is intentionally
|
|
1247
|
+
// excluded (it legitimately uses Write AND Edit to create/patch test files).
|
|
1248
|
+
const READONLY_AGENT_DISALLOWED_TOOLS = {
|
|
1249
|
+
'gsd-plan-checker': 'Write, Edit, MultiEdit',
|
|
1250
|
+
'gsd-integration-checker': 'Write, Edit, MultiEdit',
|
|
1251
|
+
'gsd-ui-checker': 'Write, Edit, MultiEdit',
|
|
1252
|
+
'gsd-verifier': 'Edit, MultiEdit',
|
|
1253
|
+
'gsd-doc-verifier': 'Edit, MultiEdit',
|
|
1254
|
+
'gsd-eval-auditor': 'Edit, MultiEdit',
|
|
1255
|
+
'gsd-ui-auditor': 'Edit, MultiEdit',
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1723
1258
|
/**
|
|
1724
1259
|
* #2517 — Read a single GSD config file (defaults.json or per-project
|
|
1725
1260
|
* config.json) into a plain object, returning null on missing/empty files
|
|
@@ -2202,12 +1737,13 @@ function convertClaudeToCopilotContent(content, isGlobal = false) {
|
|
|
2202
1737
|
return c;
|
|
2203
1738
|
}
|
|
2204
1739
|
|
|
1740
|
+
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
|
2205
1741
|
/**
|
|
2206
1742
|
* Convert a Claude command (.md) to a Copilot skill (SKILL.md).
|
|
2207
1743
|
* Transforms frontmatter only — body passes through with CONV-06/07 applied.
|
|
2208
1744
|
* Skills keep original tool names (no mapping) per CONTEXT.md decision.
|
|
2209
1745
|
*/
|
|
2210
|
-
function convertClaudeCommandToCopilotSkill(content, skillName, isGlobal = false) {
|
|
1746
|
+
function convertClaudeCommandToCopilotSkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
|
2211
1747
|
const converted = convertClaudeToCopilotContent(content, isGlobal);
|
|
2212
1748
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
2213
1749
|
if (!frontmatter) return converted;
|
|
@@ -2675,8 +2211,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
|
|
|
2675
2211
|
/**
|
|
2676
2212
|
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
|
|
2677
2213
|
* Path mappings depend on install mode:
|
|
2678
|
-
* Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.
|
|
2679
|
-
* Local: ~/.claude/ → .
|
|
2214
|
+
* Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
|
|
2215
|
+
* Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
|
|
2680
2216
|
* Applied to ALL Antigravity content (skills, agents, engine files).
|
|
2681
2217
|
* @param {string} content - Source content to convert
|
|
2682
2218
|
* @param {boolean} [isGlobal=false] - Whether this is a global install
|
|
@@ -2690,14 +2226,14 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
|
|
2690
2226
|
c = c.replace(/\$HOME\/\.claude\b/g, '$HOME/.gemini/antigravity');
|
|
2691
2227
|
c = c.replace(/~\/\.claude\b/g, '~/.gemini/antigravity');
|
|
2692
2228
|
} else {
|
|
2693
|
-
c = c.replace(/\$HOME\/\.claude\//g, '.
|
|
2694
|
-
c = c.replace(/~\/\.claude\//g, '.
|
|
2229
|
+
c = c.replace(/\$HOME\/\.claude\//g, '.agents/');
|
|
2230
|
+
c = c.replace(/~\/\.claude\//g, '.agents/');
|
|
2695
2231
|
// Bare form (no trailing slash) — must come after slash form to avoid double-replace
|
|
2696
|
-
c = c.replace(/\$HOME\/\.claude\b/g, '.
|
|
2697
|
-
c = c.replace(/~\/\.claude\b/g, '.
|
|
2232
|
+
c = c.replace(/\$HOME\/\.claude\b/g, '.agents');
|
|
2233
|
+
c = c.replace(/~\/\.claude\b/g, '.agents');
|
|
2698
2234
|
}
|
|
2699
|
-
c = c.replace(/\.\/\.claude\//g, './.
|
|
2700
|
-
c = c.replace(/\.claude\//g, '.
|
|
2235
|
+
c = c.replace(/\.\/\.claude\//g, './.agents/');
|
|
2236
|
+
c = c.replace(/\.claude\//g, '.agents/');
|
|
2701
2237
|
// Command name conversion (all gsd: references → gsd-)
|
|
2702
2238
|
c = c.replace(/gsd:/g, 'gsd-');
|
|
2703
2239
|
// Runtime-neutral agent name replacement (#766)
|
|
@@ -2705,12 +2241,13 @@ function convertClaudeToAntigravityContent(content, isGlobal = false) {
|
|
|
2705
2241
|
return c;
|
|
2706
2242
|
}
|
|
2707
2243
|
|
|
2244
|
+
// isGlobal is the 5th positional arg (3rd/4th are runtime/cmdNames passed by the skills wrapper). See runtime-artifact-layout skillsKind.
|
|
2708
2245
|
/**
|
|
2709
2246
|
* Convert a Claude command (.md) to an Antigravity skill (SKILL.md).
|
|
2710
2247
|
* Transforms frontmatter to minimal name + description only.
|
|
2711
2248
|
* Body passes through with path/command conversions applied.
|
|
2712
2249
|
*/
|
|
2713
|
-
function convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal = false) {
|
|
2250
|
+
function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = null, _cmdNames = null, isGlobal = false) {
|
|
2714
2251
|
const converted = convertClaudeToAntigravityContent(content, isGlobal);
|
|
2715
2252
|
const { frontmatter, body } = extractFrontmatterAndBody(converted);
|
|
2716
2253
|
if (!frontmatter) return converted;
|
|
@@ -2969,18 +2506,20 @@ function convertClaudeToWindsurfMarkdown(content) {
|
|
|
2969
2506
|
// Replace subagent_type from Claude to Windsurf format
|
|
2970
2507
|
converted = converted.replace(/subagent_type="general-purpose"/g, 'subagent_type="generalPurpose"');
|
|
2971
2508
|
converted = converted.replace(/\$ARGUMENTS\b/g, '{{GSD_ARGS}}');
|
|
2972
|
-
// Replace project-level Claude conventions with Windsurf equivalents
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
converted = converted.replace(
|
|
2976
|
-
converted = converted.replace(
|
|
2977
|
-
converted = converted.replace(
|
|
2978
|
-
converted = converted.replace(
|
|
2979
|
-
converted = converted.replace(/\.claude\//g, '.
|
|
2509
|
+
// Replace project-level Claude conventions with Windsurf/Devin equivalents
|
|
2510
|
+
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
|
|
2511
|
+
// Legacy .windsurf/ is still recognized on read but new installs use .devin/.
|
|
2512
|
+
converted = converted.replace(/`\.\/CLAUDE\.md`/g, '`.devin/rules`');
|
|
2513
|
+
converted = converted.replace(/\.\/CLAUDE\.md/g, '.devin/rules');
|
|
2514
|
+
converted = converted.replace(/`CLAUDE\.md`/g, '`.devin/rules`');
|
|
2515
|
+
converted = converted.replace(/\bCLAUDE\.md\b/g, '.devin/rules');
|
|
2516
|
+
converted = converted.replace(/\.claude\/skills\//g, '.devin/skills/');
|
|
2517
|
+
converted = converted.replace(/\.\/\.claude\//g, './.devin/');
|
|
2518
|
+
converted = converted.replace(/\.claude\//g, '.devin/');
|
|
2980
2519
|
// Bare forms (no trailing slash) — after slash forms to avoid double-rewrite.
|
|
2981
2520
|
// Use negative lookahead (?![\w-]) to preserve .claude-plugin and .claudeignore.
|
|
2982
|
-
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.
|
|
2983
|
-
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.
|
|
2521
|
+
converted = converted.replace(/~\/\.claude(?![\w-])/g, '~/.devin');
|
|
2522
|
+
converted = converted.replace(/\$HOME\/\.claude(?![\w-])/g, '$HOME/.devin');
|
|
2984
2523
|
// Environment variable name rewrite
|
|
2985
2524
|
converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'WINDSURF_CONFIG_DIR');
|
|
2986
2525
|
// Remove Claude Code-specific bug workarounds before brand replacement
|
|
@@ -5671,36 +5210,14 @@ function rewriteTomlKeyLines(content, matches, key) {
|
|
|
5671
5210
|
return rewritten;
|
|
5672
5211
|
}
|
|
5673
5212
|
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
* _cleanTmpFiles() can scope cleanup to files this installer process actually
|
|
5683
|
-
* created, avoiding accidental deletion of unrelated tools' temp files.
|
|
5684
|
-
*/
|
|
5685
|
-
let __atomicWriteCounter = 0;
|
|
5686
|
-
// Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
|
|
5687
|
-
const __atomicWrittenTmps = new Set();
|
|
5688
|
-
function atomicWriteFileSync(target, data, options) {
|
|
5689
|
-
__atomicWriteCounter += 1;
|
|
5690
|
-
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
|
5691
|
-
__atomicWrittenTmps.add(tmp);
|
|
5692
|
-
try {
|
|
5693
|
-
fs.writeFileSync(tmp, data, options);
|
|
5694
|
-
fs.renameSync(tmp, target);
|
|
5695
|
-
// Successful rename: the tmp path no longer exists, but leave it in the
|
|
5696
|
-
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
|
5697
|
-
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
|
5698
|
-
} catch (e) {
|
|
5699
|
-
// Best-effort cleanup of the partial temp file; never mask the real error.
|
|
5700
|
-
try { fs.rmSync(tmp, { force: true }); } catch (_) { /* ignore */ }
|
|
5701
|
-
throw e;
|
|
5702
|
-
}
|
|
5703
|
-
}
|
|
5213
|
+
// atomicWriteFileSync and __atomicWrittenTmps are now owned by the
|
|
5214
|
+
// runtime-hooks-surface module and imported here so both install.js's
|
|
5215
|
+
// direct config.toml writes and the module's Cursor/Codex hooks.json
|
|
5216
|
+
// writes share the SAME tracking Set. _cleanTmpFiles() below reads
|
|
5217
|
+
// hooksSurface.__atomicWrittenTmps to scope cleanup to installer-owned
|
|
5218
|
+
// temps only.
|
|
5219
|
+
const atomicWriteFileSync = hooksSurface.atomicWriteFileSync;
|
|
5220
|
+
const __atomicWrittenTmps = hooksSurface.__atomicWrittenTmps;
|
|
5704
5221
|
|
|
5705
5222
|
/**
|
|
5706
5223
|
* Merge GSD config block into an existing or new config.toml.
|
|
@@ -5748,7 +5265,7 @@ function mergeCodexConfig(configPath, gsdBlock) {
|
|
|
5748
5265
|
|
|
5749
5266
|
/**
|
|
5750
5267
|
* Repair config.toml files corrupted by pre-#1346 GSD installs.
|
|
5751
|
-
* Non-boolean keys (e.g. model = "gpt-5.
|
|
5268
|
+
* Non-boolean keys (e.g. model = "gpt-5.4") that ended up under [features]
|
|
5752
5269
|
* are relocated before the [features] header so Codex can parse them correctly.
|
|
5753
5270
|
* Returns the content unchanged if no trapped keys are found.
|
|
5754
5271
|
*/
|
|
@@ -6038,23 +5555,12 @@ const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
|
|
6038
5555
|
* engine layout, not the (separate) #782 Cline skills directory.
|
|
6039
5556
|
*/
|
|
6040
5557
|
function buildClineRulesBody() {
|
|
6041
|
-
return
|
|
6042
|
-
'# GSD Core — Git. Ship. Done.',
|
|
6043
|
-
'',
|
|
6044
|
-
'- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
|
|
6045
|
-
' the user runs a `/gsd-*` command.',
|
|
6046
|
-
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
6047
|
-
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
6048
|
-
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
6049
|
-
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
6050
|
-
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
6051
|
-
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
6052
|
-
].join('\n') + '\n';
|
|
5558
|
+
return hooksSurface.buildClineRulesBody();
|
|
6053
5559
|
}
|
|
6054
5560
|
|
|
6055
5561
|
/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */
|
|
6056
5562
|
function buildClineAgentsMdBody() {
|
|
6057
|
-
return
|
|
5563
|
+
return hooksSurface.buildClineAgentsMdBody();
|
|
6058
5564
|
}
|
|
6059
5565
|
|
|
6060
5566
|
/**
|
|
@@ -6070,53 +5576,7 @@ function buildClineAgentsMdBody() {
|
|
|
6070
5576
|
* hook bug can never wedge the user. No dependency on the #782 skills work.
|
|
6071
5577
|
*/
|
|
6072
5578
|
function buildClinePreToolUseHook() {
|
|
6073
|
-
return
|
|
6074
|
-
'use strict';
|
|
6075
|
-
/* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
|
|
6076
|
-
* Protocol: JSON on stdin -> JSON decision on stdout.
|
|
6077
|
-
* Honored fields: { cancel, errorMessage, contextModification }.
|
|
6078
|
-
* Fails open: any error allows the operation. */
|
|
6079
|
-
let raw = '';
|
|
6080
|
-
process.stdin.setEncoding('utf8');
|
|
6081
|
-
process.stdin.on('data', (c) => { raw += c; });
|
|
6082
|
-
process.stdin.on('end', () => {
|
|
6083
|
-
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
|
|
6084
|
-
let input;
|
|
6085
|
-
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
|
|
6086
|
-
try {
|
|
6087
|
-
const tool = String(
|
|
6088
|
-
input.toolName || input.tool_name || input.tool ||
|
|
6089
|
-
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
|
|
6090
|
-
).toLowerCase();
|
|
6091
|
-
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
|
|
6092
|
-
// Collect only PATH-bearing field values (not free-form content), so a doc
|
|
6093
|
-
// that merely mentions ".planning/" in its body is never falsely blocked.
|
|
6094
|
-
const paths = [];
|
|
6095
|
-
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
|
6096
|
-
const walk = (v, depth) => {
|
|
6097
|
-
if (depth > 5 || paths.length > 64) return;
|
|
6098
|
-
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
6099
|
-
if (v && typeof v === 'object') {
|
|
6100
|
-
for (const k of Object.keys(v)) {
|
|
6101
|
-
const val = v[k];
|
|
6102
|
-
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
|
|
6103
|
-
else walk(val, depth + 1);
|
|
6104
|
-
}
|
|
6105
|
-
}
|
|
6106
|
-
};
|
|
6107
|
-
walk(input, 0);
|
|
6108
|
-
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
|
|
6109
|
-
if (isWrite && paths.some(isPlanningPath)) {
|
|
6110
|
-
return process.stdout.write(JSON.stringify({
|
|
6111
|
-
cancel: true,
|
|
6112
|
-
errorMessage:
|
|
6113
|
-
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
|
|
6114
|
-
}));
|
|
6115
|
-
}
|
|
6116
|
-
} catch { /* fall through to allow */ }
|
|
6117
|
-
return allow();
|
|
6118
|
-
});
|
|
6119
|
-
`;
|
|
5579
|
+
return hooksSurface.buildClinePreToolUseHook();
|
|
6120
5580
|
}
|
|
6121
5581
|
|
|
6122
5582
|
/**
|
|
@@ -6124,31 +5584,7 @@ process.stdin.on('end', () => {
|
|
|
6124
5584
|
* any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent.
|
|
6125
5585
|
*/
|
|
6126
5586
|
function mergeGsdAgentsMd(filePath, gsdContent) {
|
|
6127
|
-
|
|
6128
|
-
|
|
6129
|
-
if (!fs.existsSync(filePath)) {
|
|
6130
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
6131
|
-
fs.writeFileSync(filePath, gsdBlock + '\n');
|
|
6132
|
-
return;
|
|
6133
|
-
}
|
|
6134
|
-
|
|
6135
|
-
const existing = fs.readFileSync(filePath, 'utf8');
|
|
6136
|
-
const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
|
|
6137
|
-
const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
|
|
6138
|
-
|
|
6139
|
-
if (openIndex !== -1 && closeIndex !== -1) {
|
|
6140
|
-
const before = existing.substring(0, openIndex).trimEnd();
|
|
6141
|
-
const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
|
|
6142
|
-
let newContent = '';
|
|
6143
|
-
if (before) newContent += before + '\n\n';
|
|
6144
|
-
newContent += gsdBlock;
|
|
6145
|
-
if (after) newContent += '\n\n' + after;
|
|
6146
|
-
newContent += '\n';
|
|
6147
|
-
fs.writeFileSync(filePath, newContent);
|
|
6148
|
-
return;
|
|
6149
|
-
}
|
|
6150
|
-
|
|
6151
|
-
fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
|
|
5587
|
+
return hooksSurface.mergeGsdAgentsMd(filePath, gsdContent);
|
|
6152
5588
|
}
|
|
6153
5589
|
|
|
6154
5590
|
/**
|
|
@@ -6178,53 +5614,7 @@ function stripGsdFromAgentsMd(content) {
|
|
|
6178
5614
|
* caller can hash-track them).
|
|
6179
5615
|
*/
|
|
6180
5616
|
function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
6181
|
-
|
|
6182
|
-
const clinerulesDir = path.join(targetDir, '.clinerules');
|
|
6183
|
-
|
|
6184
|
-
// Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a
|
|
6185
|
-
// file and a directory, so the legacy file must be removed first. The legacy
|
|
6186
|
-
// file is GSD-authored (the installer wrote its full contents with no user
|
|
6187
|
-
// merge surface), so replacing it with the newer directory form is the
|
|
6188
|
-
// intended upgrade. Use lstat so a symlink is unlinked in place rather than
|
|
6189
|
-
// followed (which would write GSD files through the link into an external dir).
|
|
6190
|
-
try {
|
|
6191
|
-
if (fs.existsSync(clinerulesDir)) {
|
|
6192
|
-
const st = fs.lstatSync(clinerulesDir);
|
|
6193
|
-
if (st.isFile() || st.isSymbolicLink()) {
|
|
6194
|
-
fs.unlinkSync(clinerulesDir);
|
|
6195
|
-
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
|
|
6196
|
-
}
|
|
6197
|
-
}
|
|
6198
|
-
} catch { /* best-effort migration */ }
|
|
6199
|
-
|
|
6200
|
-
fs.mkdirSync(clinerulesDir, { recursive: true });
|
|
6201
|
-
fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
|
|
6202
|
-
written.push('.clinerules/gsd.md');
|
|
6203
|
-
console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
|
|
6204
|
-
|
|
6205
|
-
const hooksDir = path.join(clinerulesDir, 'hooks');
|
|
6206
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
6207
|
-
const hookPath = path.join(hooksDir, 'PreToolUse');
|
|
6208
|
-
fs.writeFileSync(hookPath, buildClinePreToolUseHook());
|
|
6209
|
-
try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ }
|
|
6210
|
-
written.push('.clinerules/hooks/PreToolUse');
|
|
6211
|
-
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
|
|
6212
|
-
|
|
6213
|
-
// Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md
|
|
6214
|
-
// (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber
|
|
6215
|
-
// a user's or another tool's AGENTS.md. Tracked via markers (like copilot),
|
|
6216
|
-
// not the per-configDir manifest, since it lives outside configDir.
|
|
6217
|
-
if (isGlobalInstall) {
|
|
6218
|
-
try {
|
|
6219
|
-
const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md');
|
|
6220
|
-
mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
|
|
6221
|
-
console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
|
|
6222
|
-
} catch (err) {
|
|
6223
|
-
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
|
|
6224
|
-
}
|
|
6225
|
-
}
|
|
6226
|
-
|
|
6227
|
-
return written;
|
|
5617
|
+
return hooksSurface.writeClineArtifacts(targetDir, isGlobalInstall);
|
|
6228
5618
|
}
|
|
6229
5619
|
|
|
6230
5620
|
// ── Cursor hooks.json reconciler (issue #777) ────────────────────────────────
|
|
@@ -6254,11 +5644,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
|
6254
5644
|
* @returns {object} Cursor hook entry object
|
|
6255
5645
|
*/
|
|
6256
5646
|
function buildCursorHookEntry(scriptPath) {
|
|
6257
|
-
return
|
|
6258
|
-
type: 'command',
|
|
6259
|
-
command: scriptPath.replace(/\\/g, '/'),
|
|
6260
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
6261
|
-
};
|
|
5647
|
+
return hooksSurface.buildCursorHookEntry(scriptPath);
|
|
6262
5648
|
}
|
|
6263
5649
|
|
|
6264
5650
|
/**
|
|
@@ -6269,7 +5655,7 @@ function buildCursorHookEntry(scriptPath) {
|
|
|
6269
5655
|
* @returns {boolean}
|
|
6270
5656
|
*/
|
|
6271
5657
|
function isManagedCursorHookEntry(entry) {
|
|
6272
|
-
return
|
|
5658
|
+
return hooksSurface.isManagedCursorHookEntry(entry);
|
|
6273
5659
|
}
|
|
6274
5660
|
|
|
6275
5661
|
/**
|
|
@@ -6290,74 +5676,7 @@ function isManagedCursorHookEntry(entry) {
|
|
|
6290
5676
|
* @returns {{ changed: boolean, wrote: boolean, path: string }}
|
|
6291
5677
|
*/
|
|
6292
5678
|
function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
6293
|
-
|
|
6294
|
-
let currentContent = null;
|
|
6295
|
-
|
|
6296
|
-
if (fs.existsSync(hooksJsonPath)) {
|
|
6297
|
-
const raw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
6298
|
-
currentContent = raw;
|
|
6299
|
-
if (raw.trim()) {
|
|
6300
|
-
try {
|
|
6301
|
-
parsed = JSON.parse(raw);
|
|
6302
|
-
} catch (err) {
|
|
6303
|
-
throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
6304
|
-
}
|
|
6305
|
-
}
|
|
6306
|
-
}
|
|
6307
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
6308
|
-
|
|
6309
|
-
// Cursor's canonical hooks.json schema is { "version": 1, "hooks": { ... } }.
|
|
6310
|
-
// GSD always writes (and migrates to) the nested shape so Cursor reads it correctly.
|
|
6311
|
-
// The flat shape { "sessionStart": [...] } is accepted on read for backwards compat
|
|
6312
|
-
// with manually-written files, but the output always uses the nested form.
|
|
6313
|
-
const hasNestedHooksObject =
|
|
6314
|
-
parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks);
|
|
6315
|
-
if (!hasNestedHooksObject) {
|
|
6316
|
-
// Migrate flat shape (or empty {}) to nested: lift event keys into hooks:{}.
|
|
6317
|
-
const eventKeys = ['sessionStart', 'postToolUse'];
|
|
6318
|
-
const lifted = {};
|
|
6319
|
-
for (const k of eventKeys) {
|
|
6320
|
-
if (Array.isArray(parsed[k])) {
|
|
6321
|
-
lifted[k] = parsed[k];
|
|
6322
|
-
delete parsed[k];
|
|
6323
|
-
}
|
|
6324
|
-
}
|
|
6325
|
-
parsed.hooks = lifted;
|
|
6326
|
-
}
|
|
6327
|
-
if (!parsed.version) parsed.version = 1;
|
|
6328
|
-
const hookTable = parsed.hooks;
|
|
6329
|
-
|
|
6330
|
-
// Events GSD manages.
|
|
6331
|
-
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
|
6332
|
-
const entries = managedEntries || {};
|
|
6333
|
-
|
|
6334
|
-
for (const event of MANAGED_EVENTS) {
|
|
6335
|
-
const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
|
|
6336
|
-
// Strip all prior GSD-managed entries for this event.
|
|
6337
|
-
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
|
6338
|
-
const newEntry = entries[event] || null;
|
|
6339
|
-
if (newEntry) {
|
|
6340
|
-
hookTable[event] = [...userOwned, newEntry];
|
|
6341
|
-
} else {
|
|
6342
|
-
// Remove-only: keep user entries, or delete the key if it would be empty.
|
|
6343
|
-
if (userOwned.length > 0) {
|
|
6344
|
-
hookTable[event] = userOwned;
|
|
6345
|
-
} else {
|
|
6346
|
-
delete hookTable[event];
|
|
6347
|
-
}
|
|
6348
|
-
}
|
|
6349
|
-
}
|
|
6350
|
-
|
|
6351
|
-
// hookTable is parsed.hooks (always nested now); no reassignment needed.
|
|
6352
|
-
// Write only if content changed or if we're creating the file for the first time.
|
|
6353
|
-
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
6354
|
-
const changed = currentContent !== nextContent;
|
|
6355
|
-
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
6356
|
-
if (shouldWrite) {
|
|
6357
|
-
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
6358
|
-
}
|
|
6359
|
-
|
|
6360
|
-
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
|
5679
|
+
return hooksSurface.reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
6361
5680
|
}
|
|
6362
5681
|
|
|
6363
5682
|
/**
|
|
@@ -6373,65 +5692,7 @@ function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
|
6373
5692
|
* @returns {{ hooksJsonPath: string, changed: boolean }}
|
|
6374
5693
|
*/
|
|
6375
5694
|
function writeCursorHooksJson(targetDir, src, opts) {
|
|
6376
|
-
|
|
6377
|
-
const hooksDir = path.join(targetDir, 'hooks');
|
|
6378
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
6379
|
-
|
|
6380
|
-
// Copy the two GSD-managed hook scripts from the GSD source hooks/ directory.
|
|
6381
|
-
// Apply the same /gsd:/gi → gsd- rewrite used by copyWithPathReplacement for Cursor
|
|
6382
|
-
// JS files, so the installed hook scripts contain no /gsd: colon refs (bug-376 2b).
|
|
6383
|
-
// Track which scripts were successfully installed so we never register a hook entry
|
|
6384
|
-
// that references a script that wasn't copied (dangling command guard).
|
|
6385
|
-
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
|
6386
|
-
const srcHooksDir = path.join(src, 'hooks');
|
|
6387
|
-
const installedScripts = new Set();
|
|
6388
|
-
for (const script of hookScripts) {
|
|
6389
|
-
const srcPath = path.join(srcHooksDir, script);
|
|
6390
|
-
const destPath = path.join(hooksDir, script);
|
|
6391
|
-
if (fs.existsSync(srcPath)) {
|
|
6392
|
-
let content = fs.readFileSync(srcPath, 'utf8');
|
|
6393
|
-
// Rewrite /gsd:<cmd> → gsd-<cmd> so installed hook scripts are consistent
|
|
6394
|
-
// with the Cursor convention (no colon-form slash commands in agent context).
|
|
6395
|
-
content = content.replace(/gsd:/gi, 'gsd-');
|
|
6396
|
-
fs.writeFileSync(destPath, content);
|
|
6397
|
-
try { fs.chmodSync(destPath, 0o755); } catch { /* Windows: ignore chmod */ }
|
|
6398
|
-
installedScripts.add(script);
|
|
6399
|
-
}
|
|
6400
|
-
}
|
|
6401
|
-
|
|
6402
|
-
// Build command strings using the same buildHookCommand helper used by other runtimes.
|
|
6403
|
-
// buildHookCommand resolves the node runner + emits "<runner>" "<targetDir>/hooks/<name>".
|
|
6404
|
-
const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
|
6405
|
-
// buildHookCommand('gsd-cursor-session-start.js', ...): sessionStart → context injection
|
|
6406
|
-
// Only register the hook entry if the script was actually installed (dangling guard).
|
|
6407
|
-
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
|
6408
|
-
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
|
6409
|
-
: null;
|
|
6410
|
-
// buildHookCommand('gsd-cursor-post-tool.js', ...): postToolUse → STATE.md update monitor
|
|
6411
|
-
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
|
6412
|
-
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
|
6413
|
-
: null;
|
|
6414
|
-
|
|
6415
|
-
// Build managed entries; skip events whose command couldn't be resolved (e.g. no node).
|
|
6416
|
-
const managedEntries = {};
|
|
6417
|
-
if (sessionStartCmd) {
|
|
6418
|
-
managedEntries.sessionStart = {
|
|
6419
|
-
type: 'command',
|
|
6420
|
-
command: sessionStartCmd,
|
|
6421
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
6422
|
-
};
|
|
6423
|
-
}
|
|
6424
|
-
if (postToolCmd) {
|
|
6425
|
-
managedEntries.postToolUse = {
|
|
6426
|
-
type: 'command',
|
|
6427
|
-
command: postToolCmd,
|
|
6428
|
-
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
6429
|
-
};
|
|
6430
|
-
}
|
|
6431
|
-
|
|
6432
|
-
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
|
6433
|
-
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
6434
|
-
return { hooksJsonPath, changed: result.changed };
|
|
5695
|
+
return hooksSurface.writeCursorHooksJson(targetDir, src, opts);
|
|
6435
5696
|
}
|
|
6436
5697
|
|
|
6437
5698
|
/**
|
|
@@ -6442,31 +5703,7 @@ function writeCursorHooksJson(targetDir, src, opts) {
|
|
|
6442
5703
|
* @returns {{ changed: boolean }}
|
|
6443
5704
|
*/
|
|
6444
5705
|
function removeCursorHooksJson(targetDir) {
|
|
6445
|
-
|
|
6446
|
-
if (!fs.existsSync(hooksJsonPath)) return { changed: false };
|
|
6447
|
-
const result = reconcileCursorHooksJson(hooksJsonPath, null);
|
|
6448
|
-
// If the resulting file has no meaningful hook content, remove it.
|
|
6449
|
-
// A file is "empty" if it contains only the scaffolding (version, empty hooks
|
|
6450
|
-
// object, or a bare {}) with no user-authored hook entries.
|
|
6451
|
-
if (result.changed) {
|
|
6452
|
-
try {
|
|
6453
|
-
const contentRaw = fs.readFileSync(hooksJsonPath, 'utf8');
|
|
6454
|
-
const parsed = JSON.parse(contentRaw);
|
|
6455
|
-
// reconcileCursorHooksJson always writes the nested { version, hooks:{} } shape.
|
|
6456
|
-
// The file is "empty" when there are no remaining hook events with entries.
|
|
6457
|
-
const hookTable = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks))
|
|
6458
|
-
? parsed.hooks
|
|
6459
|
-
: {};
|
|
6460
|
-
const hasAnyEvents = Object.keys(hookTable).some(
|
|
6461
|
-
(k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0,
|
|
6462
|
-
);
|
|
6463
|
-
if (!hasAnyEvents) {
|
|
6464
|
-
fs.unlinkSync(hooksJsonPath);
|
|
6465
|
-
return { changed: true };
|
|
6466
|
-
}
|
|
6467
|
-
} catch { /* best-effort: leave the file */ }
|
|
6468
|
-
}
|
|
6469
|
-
return { changed: result.changed };
|
|
5706
|
+
return hooksSurface.removeCursorHooksJson(targetDir);
|
|
6470
5707
|
}
|
|
6471
5708
|
|
|
6472
5709
|
/**
|
|
@@ -6484,19 +5721,7 @@ function removeCursorHooksJson(targetDir) {
|
|
|
6484
5721
|
* @returns {object} Copilot hooks-configuration object
|
|
6485
5722
|
*/
|
|
6486
5723
|
function buildCopilotHookConfig() {
|
|
6487
|
-
return
|
|
6488
|
-
version: 1,
|
|
6489
|
-
hooks: {
|
|
6490
|
-
sessionStart: [
|
|
6491
|
-
{
|
|
6492
|
-
type: 'command',
|
|
6493
|
-
bash: GSD_COPILOT_SESSION_HOOK_BASH,
|
|
6494
|
-
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
|
|
6495
|
-
timeoutSec: 10,
|
|
6496
|
-
},
|
|
6497
|
-
],
|
|
6498
|
-
},
|
|
6499
|
-
};
|
|
5724
|
+
return hooksSurface.buildCopilotHookConfig();
|
|
6500
5725
|
}
|
|
6501
5726
|
|
|
6502
5727
|
/**
|
|
@@ -6513,11 +5738,7 @@ function buildCopilotHookConfig() {
|
|
|
6513
5738
|
* @returns {string} The path the hook config was written to
|
|
6514
5739
|
*/
|
|
6515
5740
|
function writeCopilotHookConfig(targetDir) {
|
|
6516
|
-
|
|
6517
|
-
fs.mkdirSync(hooksDir, { recursive: true });
|
|
6518
|
-
const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE);
|
|
6519
|
-
fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
|
|
6520
|
-
return hookPath;
|
|
5741
|
+
return hooksSurface.writeCopilotHookConfig(targetDir);
|
|
6521
5742
|
}
|
|
6522
5743
|
|
|
6523
5744
|
/**
|
|
@@ -7565,8 +6786,9 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = '
|
|
|
7565
6786
|
* @param {string} stagedDir
|
|
7566
6787
|
* @param {string} runtime
|
|
7567
6788
|
* @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string
|
|
6789
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7568
6790
|
*/
|
|
7569
|
-
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
6791
|
+
function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
|
|
7570
6792
|
if (!fs.existsSync(stagedDir)) return;
|
|
7571
6793
|
|
|
7572
6794
|
// Walk all SKILL.md files under stagedDir
|
|
@@ -7577,7 +6799,7 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
7577
6799
|
walkAndRewrite(fullPath);
|
|
7578
6800
|
} else if (entry.name.endsWith('.md')) {
|
|
7579
6801
|
let content = fs.readFileSync(fullPath, 'utf8');
|
|
7580
|
-
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
|
|
6802
|
+
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
|
|
7581
6803
|
fs.writeFileSync(fullPath, content);
|
|
7582
6804
|
}
|
|
7583
6805
|
}
|
|
@@ -7598,9 +6820,10 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) {
|
|
|
7598
6820
|
* @param {string} stagedDir directory of staged flat .md command files (may be source dir)
|
|
7599
6821
|
* @param {string} runtime
|
|
7600
6822
|
* @param {string} pathPrefix
|
|
6823
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7601
6824
|
* @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup)
|
|
7602
6825
|
*/
|
|
7603
|
-
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) {
|
|
6826
|
+
function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal = false) {
|
|
7604
6827
|
if (!fs.existsSync(stagedDir)) return stagedDir;
|
|
7605
6828
|
// Always copy to a temp dir — stageSkillsForProfile() returns the original source
|
|
7606
6829
|
// dir on full/default profile (skills === '*'), so writing in-place would corrupt the
|
|
@@ -7610,7 +6833,7 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
|
|
7610
6833
|
for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) {
|
|
7611
6834
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
7612
6835
|
let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8');
|
|
7613
|
-
content = _applyRuntimeRewrites(content, runtime, pathPrefix);
|
|
6836
|
+
content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal);
|
|
7614
6837
|
// For augment commands, apply the markdown conversion so tool references
|
|
7615
6838
|
// and skill paths use Augment equivalents.
|
|
7616
6839
|
if (runtime === 'augment') {
|
|
@@ -7632,9 +6855,10 @@ function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathP
|
|
|
7632
6855
|
* @param {string} content
|
|
7633
6856
|
* @param {string} runtime
|
|
7634
6857
|
* @param {string} pathPrefix trailing-slash string
|
|
6858
|
+
* @param {boolean} [isGlobal=false] true when the install is a global (home-dir) install
|
|
7635
6859
|
* @returns {string}
|
|
7636
6860
|
*/
|
|
7637
|
-
function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
6861
|
+
function _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal = false) {
|
|
7638
6862
|
const dirName = getDirName(runtime);
|
|
7639
6863
|
const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
|
|
7640
6864
|
|
|
@@ -7671,18 +6895,30 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) {
|
|
|
7671
6895
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7672
6896
|
break;
|
|
7673
6897
|
|
|
7674
|
-
case 'windsurf':
|
|
6898
|
+
case 'windsurf': {
|
|
7675
6899
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
7676
6900
|
content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
|
|
7677
6901
|
content = content.replace(/\.\/\.claude\//g, `./${dirName}/`);
|
|
7678
6902
|
// Bare forms (no trailing slash) — use (?![\w-]) instead of \b so that
|
|
7679
6903
|
// .claude-plugin / .claudeignore are NOT corrupted (the \b word-boundary
|
|
7680
|
-
// fires between 'e' and '-', which rewrites .claude-plugin → .
|
|
6904
|
+
// fires between 'e' and '-', which rewrites .claude-plugin → .devin-plugin).
|
|
7681
6905
|
content = content.replace(/~\/\.claude(?![\w-])/g, normalizedPathPrefix);
|
|
7682
6906
|
content = content.replace(/\$HOME\/\.claude(?![\w-])/g, normalizedPathPrefix);
|
|
7683
6907
|
content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix);
|
|
6908
|
+
// Stage-1 converter rewrites .claude/skills/ → .devin/skills/ (workspace-relative
|
|
6909
|
+
// form). For global installs the real path is pathPrefix + skills/, so fix that up
|
|
6910
|
+
// here using the real isGlobal flag (threaded from installRuntimeArtifacts scope,
|
|
6911
|
+
// not derived from pathPrefix substring which misclassifies custom config dirs).
|
|
6912
|
+
// For local installs, the relative .devin/ form is correct — leave it. (#1085)
|
|
6913
|
+
if (isGlobal) {
|
|
6914
|
+
content = content.replace(/\.devin\/skills\//g, `${pathPrefix}skills/`);
|
|
6915
|
+
content = content.replace(/\.\/\.devin\//g, pathPrefix);
|
|
6916
|
+
content = content.replace(/~\/\.devin(?![\w-])/g, normalizedPathPrefix);
|
|
6917
|
+
content = content.replace(/\$HOME\/\.devin(?![\w-])/g, normalizedPathPrefix);
|
|
6918
|
+
}
|
|
7684
6919
|
content = processAttribution(content, getCommitAttribution(runtime));
|
|
7685
6920
|
break;
|
|
6921
|
+
}
|
|
7686
6922
|
|
|
7687
6923
|
case 'augment':
|
|
7688
6924
|
content = content.replace(/~\/\.claude\//g, pathPrefix);
|
|
@@ -8114,11 +7350,12 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) {
|
|
|
8114
7350
|
// stagedForCopy: the directory to copy from (may differ from staged if rewrites
|
|
8115
7351
|
// produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace).
|
|
8116
7352
|
let stagedForCopy = staged;
|
|
7353
|
+
const isGlobal = scope === 'global';
|
|
8117
7354
|
if (kind.kind === 'skills' || kind.kind === 'kimi-agents') {
|
|
8118
|
-
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix);
|
|
7355
|
+
applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix, isGlobal);
|
|
8119
7356
|
} else if (kind.kind === 'commands') {
|
|
8120
7357
|
// Returns a temp dir with rewritten content so source files are never mutated.
|
|
8121
|
-
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix);
|
|
7358
|
+
stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix, isGlobal);
|
|
8122
7359
|
}
|
|
8123
7360
|
// applyRuntimeContentRewritesForCommandsInPlace() returns a fresh mkdtemp dir under
|
|
8124
7361
|
// os.tmpdir() (gsd-cmd-rewrites-*); remove it once copied so it does not accumulate (#856).
|
|
@@ -8453,11 +7690,12 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
|
|
8453
7690
|
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Cursor');
|
|
8454
7691
|
fs.writeFileSync(destPath, jsContent);
|
|
8455
7692
|
} else if (isWindsurf && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
|
|
8456
|
-
// For Windsurf, also convert Claude references in JS/CJS utility scripts
|
|
7693
|
+
// For Windsurf/Devin, also convert Claude references in JS/CJS utility scripts.
|
|
7694
|
+
// Workspace skills install to .devin/ (Devin Desktop preferred dir, #1085).
|
|
8457
7695
|
let jsContent = fs.readFileSync(srcPath, 'utf8');
|
|
8458
7696
|
jsContent = jsContent.replace(/gsd:/gi, 'gsd-');
|
|
8459
|
-
jsContent = jsContent.replace(/\.claude\/skills\//g, '.
|
|
8460
|
-
jsContent = jsContent.replace(/CLAUDE\.md/g, '.
|
|
7697
|
+
jsContent = jsContent.replace(/\.claude\/skills\//g, '.devin/skills/');
|
|
7698
|
+
jsContent = jsContent.replace(/CLAUDE\.md/g, '.devin/rules');
|
|
8461
7699
|
jsContent = jsContent.replace(/\bClaude Code\b/g, 'Windsurf');
|
|
8462
7700
|
fs.writeFileSync(destPath, jsContent);
|
|
8463
7701
|
} else if (isTrae && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) {
|
|
@@ -10166,7 +9404,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10166
9404
|
const isHermes = runtime === 'hermes';
|
|
10167
9405
|
const isCodebuddy = runtime === 'codebuddy';
|
|
10168
9406
|
const isCline = runtime === 'cline';
|
|
10169
|
-
const
|
|
9407
|
+
const plan = resolveInstallPlan(runtime);
|
|
10170
9408
|
const dirName = getDirName(runtime);
|
|
10171
9409
|
const src = path.join(__dirname, '..');
|
|
10172
9410
|
|
|
@@ -10220,6 +9458,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
10220
9458
|
// Get the target directory based on runtime and install type.
|
|
10221
9459
|
// Cline local installs write to the project root (like Claude Code) — .clinerules
|
|
10222
9460
|
// lives at the root, not inside a .cline/ subdirectory.
|
|
9461
|
+
// #791: antigravity local installs write to .agents/ (canonical). The legacy .agent/
|
|
9462
|
+
// directory is recognized by RUNTIME_DIRS (update-context) and _LEGACY_SCAN_SUBDIR_NAMES
|
|
9463
|
+
// but NOT auto-removed here; legacy .agent/ gsd artifacts are recognized but not
|
|
9464
|
+
// auto-removed on reinstall (dual-read fallback per issue #791 spec).
|
|
10223
9465
|
const targetDir = isGlobal
|
|
10224
9466
|
? getGlobalConfigDir(runtime, explicitConfigDir)
|
|
10225
9467
|
: isCline
|
|
@@ -11010,6 +10252,8 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11010
10252
|
const _universalEffort = resolveInstallTimeEffort(_effortCfg, _agentName);
|
|
11011
10253
|
const _renderedEffort = _getGsdEffortCatalog().renderEffortForRuntime('claude', _universalEffort).value;
|
|
11012
10254
|
content = injectEffortFrontmatter(content, _renderedEffort);
|
|
10255
|
+
const _disallowedTools = READONLY_AGENT_DISALLOWED_TOOLS[_agentName];
|
|
10256
|
+
if (_disallowedTools) content = injectDisallowedToolsFrontmatter(content, _disallowedTools);
|
|
11013
10257
|
}
|
|
11014
10258
|
// #3677 — normalize retired `/gsd:<cmd>` colon refs in the agent body
|
|
11015
10259
|
// to the canonical hyphen form `/gsd-<cmd>` for hyphen-`name:`
|
|
@@ -11307,7 +10551,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11307
10551
|
throw _earlyInstallErr;
|
|
11308
10552
|
}
|
|
11309
10553
|
|
|
11310
|
-
if (
|
|
10554
|
+
if (plan.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) {
|
|
11311
10555
|
// Capture pre-install snapshots before ANY GSD mutation
|
|
11312
10556
|
// (#2760 fix 3). On post-write schema-validation failure OR any throw
|
|
11313
10557
|
// during the mutation sequence (write failure, merge throw, etc.) we
|
|
@@ -11686,7 +10930,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11686
10930
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11687
10931
|
}
|
|
11688
10932
|
|
|
11689
|
-
if (
|
|
10933
|
+
if (plan.installSurface === 'copilot-instructions') {
|
|
11690
10934
|
// Generate copilot-instructions.md
|
|
11691
10935
|
const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md');
|
|
11692
10936
|
const instructionsPath = path.join(targetDir, 'copilot-instructions.md');
|
|
@@ -11716,7 +10960,7 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11716
10960
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11717
10961
|
}
|
|
11718
10962
|
|
|
11719
|
-
if (
|
|
10963
|
+
if (plan.installSurface === 'cursor-hooks-json') {
|
|
11720
10964
|
// #777: Cursor v2.4+ supports hooks.json. Register sessionStart + postToolUse.
|
|
11721
10965
|
// Hook scripts are copied to <targetDir>/hooks/ and referenced by hooks.json.
|
|
11722
10966
|
const cursorHookResult = writeCursorHooksJson(targetDir, src, {});
|
|
@@ -11731,13 +10975,13 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11731
10975
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11732
10976
|
}
|
|
11733
10977
|
|
|
11734
|
-
if (
|
|
10978
|
+
if (plan.installSurface === 'profile-marker-only') {
|
|
11735
10979
|
// Windsurf/Trae/Kimi use artifact-only surfaces — no config.toml or settings.json hooks needed.
|
|
11736
10980
|
persistActiveProfileMarker();
|
|
11737
10981
|
return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir };
|
|
11738
10982
|
}
|
|
11739
10983
|
|
|
11740
|
-
if (
|
|
10984
|
+
if (plan.installSurface === 'cline-rules') {
|
|
11741
10985
|
// Cline uses the `.clinerules/` directory form (issue #787): GSD rules live
|
|
11742
10986
|
// at .clinerules/gsd.md and a PreToolUse lifecycle hook at
|
|
11743
10987
|
// .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md.
|
|
@@ -11750,8 +10994,12 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11750
10994
|
}
|
|
11751
10995
|
|
|
11752
10996
|
// Configure statusline and hooks in settings.json (or settings.local.json for local Claude installs).
|
|
11753
|
-
//
|
|
11754
|
-
|
|
10997
|
+
// ADR-857 phase 5f-2: drive the hook event dialect from the registry descriptor.
|
|
10998
|
+
// runtimes with hookEvents='gemini' use AfterTool/BeforeTool; all others use PostToolUse/PreToolUse.
|
|
10999
|
+
// Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} — identical to the old check.
|
|
11000
|
+
// A missing registry or missing descriptor defaults to 'not gemini' → PostToolUse (safe).
|
|
11001
|
+
const _hookEventsDialect = plan.hookEvents;
|
|
11002
|
+
const postToolEvent = _hookEventsDialect === 'gemini' ? 'AfterTool' : 'PostToolUse';
|
|
11755
11003
|
// #338: local Claude installs write to settings.local.json (Claude Code's per-user/gitignored slot)
|
|
11756
11004
|
// so engineer-specific absolute paths (Node binary, home dir) never land in the repo-shared
|
|
11757
11005
|
// settings.json. Global installs and all other runtimes continue to use settings.json.
|
|
@@ -11925,476 +11173,27 @@ function install(isGlobal, runtime = 'claude', options = {}) {
|
|
|
11925
11173
|
}
|
|
11926
11174
|
}
|
|
11927
11175
|
|
|
11928
|
-
//
|
|
11929
|
-
//
|
|
11930
|
-
//
|
|
11931
|
-
|
|
11932
|
-
|
|
11933
|
-
|
|
11934
|
-
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
11939
|
-
|
|
11940
|
-
|
|
11941
|
-
|
|
11942
|
-
|
|
11943
|
-
|
|
11944
|
-
|
|
11945
|
-
|
|
11946
|
-
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
const hasGsdUpdateHook = settings.hooks.SessionStart.some(entry =>
|
|
11950
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-check-update'))
|
|
11951
|
-
);
|
|
11952
|
-
|
|
11953
|
-
// Guard: only register if the hook file was actually installed (#1754).
|
|
11954
|
-
// When hooks/dist/ is missing from the npm package (as in v1.32.0), the
|
|
11955
|
-
// copy step produces no files but the registration step ran unconditionally,
|
|
11956
|
-
// causing "hook error" on every tool invocation.
|
|
11957
|
-
const checkUpdateFile = path.join(targetDir, 'hooks', 'gsd-check-update.js');
|
|
11958
|
-
if (!hasGsdUpdateHook && fs.existsSync(checkUpdateFile) && updateCheckCommand) {
|
|
11959
|
-
settings.hooks.SessionStart.push({
|
|
11960
|
-
hooks: [
|
|
11961
|
-
{
|
|
11962
|
-
type: 'command',
|
|
11963
|
-
command: updateCheckCommand
|
|
11964
|
-
}
|
|
11965
|
-
]
|
|
11966
|
-
});
|
|
11967
|
-
console.log(` ${green}✓${reset} Configured update check hook`);
|
|
11968
|
-
} else if (!hasGsdUpdateHook && !fs.existsSync(checkUpdateFile)) {
|
|
11969
|
-
console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
|
|
11970
|
-
}
|
|
11971
|
-
|
|
11972
|
-
// Configure post-tool hook for context window monitoring
|
|
11973
|
-
if (!settings.hooks[postToolEvent]) {
|
|
11974
|
-
settings.hooks[postToolEvent] = [];
|
|
11975
|
-
}
|
|
11976
|
-
|
|
11977
|
-
const hasContextMonitorHook = settings.hooks[postToolEvent].some(entry =>
|
|
11978
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
|
|
11979
|
-
);
|
|
11980
|
-
|
|
11981
|
-
const contextMonitorFile = path.join(targetDir, 'hooks', 'gsd-context-monitor.js');
|
|
11982
|
-
if (!hasContextMonitorHook && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
11983
|
-
settings.hooks[postToolEvent].push({
|
|
11984
|
-
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
|
|
11985
|
-
hooks: [
|
|
11986
|
-
{
|
|
11987
|
-
type: 'command',
|
|
11988
|
-
command: contextMonitorCommand,
|
|
11989
|
-
timeout: 10
|
|
11990
|
-
}
|
|
11991
|
-
]
|
|
11992
|
-
});
|
|
11993
|
-
console.log(` ${green}✓${reset} Configured context window monitor hook`);
|
|
11994
|
-
} else if (!hasContextMonitorHook && !fs.existsSync(contextMonitorFile)) {
|
|
11995
|
-
console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
|
|
11996
|
-
} else {
|
|
11997
|
-
// Migrate existing context monitor hooks: add matcher and timeout if missing
|
|
11998
|
-
for (const entry of settings.hooks[postToolEvent]) {
|
|
11999
|
-
if (entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))) {
|
|
12000
|
-
let migrated = false;
|
|
12001
|
-
if (!entry.matcher) {
|
|
12002
|
-
entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
|
|
12003
|
-
migrated = true;
|
|
12004
|
-
}
|
|
12005
|
-
for (const h of entry.hooks) {
|
|
12006
|
-
if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) {
|
|
12007
|
-
h.timeout = 10;
|
|
12008
|
-
migrated = true;
|
|
12009
|
-
}
|
|
12010
|
-
}
|
|
12011
|
-
if (migrated) {
|
|
12012
|
-
console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
|
|
12013
|
-
}
|
|
12014
|
-
}
|
|
12015
|
-
}
|
|
12016
|
-
}
|
|
12017
|
-
|
|
12018
|
-
// Configure PreToolUse hook for prompt injection detection
|
|
12019
|
-
// Gemini and Antigravity use BeforeTool instead of PreToolUse for pre-tool hooks
|
|
12020
|
-
const preToolEvent = (runtime === 'gemini' || runtime === 'antigravity') ? 'BeforeTool' : 'PreToolUse';
|
|
12021
|
-
if (!settings.hooks[preToolEvent]) {
|
|
12022
|
-
settings.hooks[preToolEvent] = [];
|
|
12023
|
-
}
|
|
12024
|
-
|
|
12025
|
-
const hasPromptGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
12026
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-prompt-guard'))
|
|
12027
|
-
);
|
|
12028
|
-
|
|
12029
|
-
const promptGuardFile = path.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
|
|
12030
|
-
if (!hasPromptGuardHook && fs.existsSync(promptGuardFile) && promptGuardCommand) {
|
|
12031
|
-
settings.hooks[preToolEvent].push({
|
|
12032
|
-
matcher: 'Write|Edit',
|
|
12033
|
-
hooks: [
|
|
12034
|
-
{
|
|
12035
|
-
type: 'command',
|
|
12036
|
-
command: promptGuardCommand,
|
|
12037
|
-
timeout: 5
|
|
12038
|
-
}
|
|
12039
|
-
]
|
|
12040
|
-
});
|
|
12041
|
-
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
|
|
12042
|
-
} else if (!hasPromptGuardHook && !fs.existsSync(promptGuardFile)) {
|
|
12043
|
-
console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
|
|
12044
|
-
}
|
|
12045
|
-
|
|
12046
|
-
// Configure PreToolUse hook for read-before-edit guidance (#1628)
|
|
12047
|
-
// Prevents infinite retry loops when non-Claude models attempt to edit
|
|
12048
|
-
// files without reading them first. Advisory-only — does not block.
|
|
12049
|
-
const hasReadGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
12050
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-guard'))
|
|
12051
|
-
);
|
|
12052
|
-
|
|
12053
|
-
const readGuardFile = path.join(targetDir, 'hooks', 'gsd-read-guard.js');
|
|
12054
|
-
if (!hasReadGuardHook && fs.existsSync(readGuardFile) && readGuardCommand) {
|
|
12055
|
-
settings.hooks[preToolEvent].push({
|
|
12056
|
-
matcher: 'Write|Edit',
|
|
12057
|
-
hooks: [
|
|
12058
|
-
{
|
|
12059
|
-
type: 'command',
|
|
12060
|
-
command: readGuardCommand,
|
|
12061
|
-
timeout: 5
|
|
12062
|
-
}
|
|
12063
|
-
]
|
|
12064
|
-
});
|
|
12065
|
-
console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
|
|
12066
|
-
} else if (!hasReadGuardHook && !fs.existsSync(readGuardFile)) {
|
|
12067
|
-
console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
|
|
12068
|
-
}
|
|
12069
|
-
|
|
12070
|
-
// Configure PostToolUse hook for read-time prompt injection scanning (#2201)
|
|
12071
|
-
// Scans content returned by the Read tool for injection patterns, including
|
|
12072
|
-
// summarisation-specific patterns that survive context compression.
|
|
12073
|
-
const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some(entry =>
|
|
12074
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-read-injection-scanner'))
|
|
12075
|
-
);
|
|
12076
|
-
|
|
12077
|
-
const readInjectionScannerFile = path.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
|
|
12078
|
-
if (!hasReadInjectionScannerHook && fs.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
|
|
12079
|
-
settings.hooks[postToolEvent].push({
|
|
12080
|
-
matcher: 'Read',
|
|
12081
|
-
hooks: [
|
|
12082
|
-
{
|
|
12083
|
-
type: 'command',
|
|
12084
|
-
command: readInjectionScannerCommand,
|
|
12085
|
-
timeout: 5
|
|
12086
|
-
}
|
|
12087
|
-
]
|
|
12088
|
-
});
|
|
12089
|
-
console.log(` ${green}✓${reset} Configured read injection scanner hook`);
|
|
12090
|
-
} else if (!hasReadInjectionScannerHook && !fs.existsSync(readInjectionScannerFile)) {
|
|
12091
|
-
console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
|
|
12092
|
-
}
|
|
12093
|
-
|
|
12094
|
-
// Community hooks — registered on install but opt-in at runtime.
|
|
12095
|
-
// Each hook checks .planning/config.json for hooks.community: true
|
|
12096
|
-
// and exits silently (no-op) if not enabled. This lets users enable
|
|
12097
|
-
// them per-project by adding: "hooks": { "community": true }
|
|
12098
|
-
|
|
12099
|
-
// Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
|
|
12100
|
-
// Detects file edits outside GSD workflow context and advises using
|
|
12101
|
-
// /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
|
|
12102
|
-
// unsafe Bash commands that violate worktree-agent isolation.
|
|
12103
|
-
const workflowGuardCommand = isGlobal
|
|
12104
|
-
? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
|
|
12105
|
-
: localCmd('gsd-workflow-guard.js');
|
|
12106
|
-
const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
|
|
12107
|
-
const workflowGuardHookEntry = settings.hooks[preToolEvent].find(entry =>
|
|
12108
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-workflow-guard'))
|
|
12109
|
-
);
|
|
12110
|
-
const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
|
|
12111
|
-
|
|
12112
|
-
const workflowGuardFile = path.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
|
|
12113
|
-
if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
|
|
12114
|
-
workflowGuardHookEntry.matcher = workflowGuardMatcher;
|
|
12115
|
-
console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
|
|
12116
|
-
} else if (!hasWorkflowGuardHook && fs.existsSync(workflowGuardFile) && workflowGuardCommand) {
|
|
12117
|
-
settings.hooks[preToolEvent].push({
|
|
12118
|
-
matcher: workflowGuardMatcher,
|
|
12119
|
-
hooks: [
|
|
12120
|
-
{
|
|
12121
|
-
type: 'command',
|
|
12122
|
-
command: workflowGuardCommand,
|
|
12123
|
-
timeout: 5
|
|
12124
|
-
}
|
|
12125
|
-
]
|
|
12126
|
-
});
|
|
12127
|
-
console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
|
|
12128
|
-
} else if (!hasWorkflowGuardHook && !fs.existsSync(workflowGuardFile)) {
|
|
12129
|
-
console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
|
|
12130
|
-
}
|
|
12131
|
-
|
|
12132
|
-
// Configure PreToolUse hook for worktree absolute-path safety (#260)
|
|
12133
|
-
// Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
|
|
12134
|
-
// outside the current worktree root. Prevents executor agents from
|
|
12135
|
-
// accidentally writing to the main checkout when running in isolation="worktree".
|
|
12136
|
-
const worktreePathGuardCommand = isGlobal
|
|
12137
|
-
? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
|
|
12138
|
-
: localCmd('gsd-worktree-path-guard.js');
|
|
12139
|
-
const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some(entry =>
|
|
12140
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-worktree-path-guard'))
|
|
12141
|
-
);
|
|
12142
|
-
const worktreePathGuardFile = path.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
|
|
12143
|
-
if (!hasWorktreePathGuardHook && fs.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
|
|
12144
|
-
settings.hooks[preToolEvent].push({
|
|
12145
|
-
matcher: 'Write|Edit|MultiEdit',
|
|
12146
|
-
hooks: [
|
|
12147
|
-
{
|
|
12148
|
-
type: 'command',
|
|
12149
|
-
command: worktreePathGuardCommand,
|
|
12150
|
-
timeout: 5
|
|
12151
|
-
}
|
|
12152
|
-
]
|
|
12153
|
-
});
|
|
12154
|
-
console.log(` ${green}✓${reset} Configured worktree path guard hook`);
|
|
12155
|
-
} else if (!hasWorktreePathGuardHook && !fs.existsSync(worktreePathGuardFile)) {
|
|
12156
|
-
console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
|
|
12157
|
-
}
|
|
12158
|
-
|
|
12159
|
-
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
|
|
12160
|
-
const validateCommitCommand = isGlobal
|
|
12161
|
-
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
|
|
12162
|
-
: localShellCmd('gsd-validate-commit.sh');
|
|
12163
|
-
const hasValidateCommitHook = settings.hooks[preToolEvent].some(entry =>
|
|
12164
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-validate-commit'))
|
|
12165
|
-
);
|
|
12166
|
-
// Guard: only register if the .sh file was actually installed. If the npm package
|
|
12167
|
-
// omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
|
|
12168
|
-
// causes a hook error on every Bash tool invocation.
|
|
12169
|
-
const validateCommitFile = path.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
|
|
12170
|
-
if (!hasValidateCommitHook && fs.existsSync(validateCommitFile) && validateCommitCommand) {
|
|
12171
|
-
settings.hooks[preToolEvent].push({
|
|
12172
|
-
matcher: 'Bash',
|
|
12173
|
-
hooks: [
|
|
12174
|
-
{
|
|
12175
|
-
type: 'command',
|
|
12176
|
-
command: validateCommitCommand,
|
|
12177
|
-
timeout: 5
|
|
12178
|
-
}
|
|
12179
|
-
]
|
|
12180
|
-
});
|
|
12181
|
-
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
|
|
12182
|
-
} else if (!hasValidateCommitHook && !fs.existsSync(validateCommitFile)) {
|
|
12183
|
-
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
|
|
12184
|
-
} else if (!hasValidateCommitHook && !validateCommitCommand) {
|
|
12185
|
-
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
|
|
12186
|
-
}
|
|
12187
|
-
|
|
12188
|
-
// Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
|
|
12189
|
-
// PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
|
|
12190
|
-
// on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
|
|
12191
|
-
// .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
|
|
12192
|
-
const graphifyUpdateCommand = isGlobal
|
|
12193
|
-
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
|
|
12194
|
-
: localShellCmd('gsd-graphify-update.sh');
|
|
12195
|
-
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some(entry =>
|
|
12196
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-graphify-update'))
|
|
12197
|
-
);
|
|
12198
|
-
const graphifyUpdateFile = path.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
|
|
12199
|
-
if (!hasGraphifyUpdateHook && fs.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
|
|
12200
|
-
settings.hooks[postToolEvent].push({
|
|
12201
|
-
matcher: 'Bash',
|
|
12202
|
-
hooks: [
|
|
12203
|
-
{
|
|
12204
|
-
type: 'command',
|
|
12205
|
-
command: graphifyUpdateCommand,
|
|
12206
|
-
timeout: 5
|
|
12207
|
-
}
|
|
12208
|
-
]
|
|
12209
|
-
});
|
|
12210
|
-
console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
|
|
12211
|
-
} else if (!hasGraphifyUpdateHook && !fs.existsSync(graphifyUpdateFile)) {
|
|
12212
|
-
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
|
|
12213
|
-
} else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
|
|
12214
|
-
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
|
|
12215
|
-
}
|
|
12216
|
-
|
|
12217
|
-
// Configure session state orientation hook (opt-in)
|
|
12218
|
-
const sessionStateCommand = isGlobal
|
|
12219
|
-
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
|
|
12220
|
-
: localShellCmd('gsd-session-state.sh');
|
|
12221
|
-
const hasSessionStateHook = settings.hooks.SessionStart.some(entry =>
|
|
12222
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-session-state'))
|
|
12223
|
-
);
|
|
12224
|
-
const sessionStateFile = path.join(targetDir, 'hooks', 'gsd-session-state.sh');
|
|
12225
|
-
if (!hasSessionStateHook && fs.existsSync(sessionStateFile) && sessionStateCommand) {
|
|
12226
|
-
settings.hooks.SessionStart.push({
|
|
12227
|
-
hooks: [
|
|
12228
|
-
{
|
|
12229
|
-
type: 'command',
|
|
12230
|
-
command: sessionStateCommand
|
|
12231
|
-
}
|
|
12232
|
-
]
|
|
12233
|
-
});
|
|
12234
|
-
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
|
|
12235
|
-
} else if (!hasSessionStateHook && !fs.existsSync(sessionStateFile)) {
|
|
12236
|
-
console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
|
|
12237
|
-
} else if (!hasSessionStateHook && !sessionStateCommand) {
|
|
12238
|
-
console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
|
|
12239
|
-
}
|
|
12240
|
-
|
|
12241
|
-
// Configure phase boundary detection hook (opt-in)
|
|
12242
|
-
const phaseBoundaryCommand = isGlobal
|
|
12243
|
-
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
|
|
12244
|
-
: localShellCmd('gsd-phase-boundary.sh');
|
|
12245
|
-
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some(entry =>
|
|
12246
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-phase-boundary'))
|
|
12247
|
-
);
|
|
12248
|
-
const phaseBoundaryFile = path.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
|
|
12249
|
-
if (!hasPhaseBoundaryHook && fs.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
|
|
12250
|
-
settings.hooks[postToolEvent].push({
|
|
12251
|
-
matcher: 'Write|Edit',
|
|
12252
|
-
hooks: [
|
|
12253
|
-
{
|
|
12254
|
-
type: 'command',
|
|
12255
|
-
command: phaseBoundaryCommand,
|
|
12256
|
-
timeout: 5
|
|
12257
|
-
}
|
|
12258
|
-
]
|
|
12259
|
-
});
|
|
12260
|
-
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
|
|
12261
|
-
} else if (!hasPhaseBoundaryHook && !fs.existsSync(phaseBoundaryFile)) {
|
|
12262
|
-
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
|
|
12263
|
-
} else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
12264
|
-
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
12265
|
-
}
|
|
12266
|
-
|
|
12267
|
-
// ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
|
|
12268
|
-
// Claude Code (since #770) and Qwen Code (since #788) both support these
|
|
12269
|
-
// three lifecycle events. Wire gsd-context-monitor so agents get context-
|
|
12270
|
-
// headroom warnings at subagent completion, model stop, and pre-compaction
|
|
12271
|
-
// (the most critical moment to surface headroom info).
|
|
12272
|
-
//
|
|
12273
|
-
// SubagentStop — subagent lifecycle completion (context headroom tracking)
|
|
12274
|
-
// Stop — model stop / final-response moment (context headroom)
|
|
12275
|
-
// PreCompact — fires before conversation compaction (most critical
|
|
12276
|
-
// moment to surface context headroom warnings)
|
|
12277
|
-
//
|
|
12278
|
-
// Note: UserPromptSubmit is NOT wired here. That event carries the raw
|
|
12279
|
-
// user prompt text, not a tool invocation, so gsd-prompt-guard (which
|
|
12280
|
-
// exits unless tool_name is Write/Edit) would be a silent no-op. A
|
|
12281
|
-
// dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
|
|
12282
|
-
if (isQwen || runtime === 'claude') {
|
|
12283
|
-
const runtimeLabel = isQwen ? 'Qwen Code' : 'Claude Code';
|
|
12284
|
-
// SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
12285
|
-
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
|
12286
|
-
if (!settings.hooks[event]) {
|
|
12287
|
-
settings.hooks[event] = [];
|
|
12288
|
-
}
|
|
12289
|
-
const alreadyHasContextMonitor = settings.hooks[event].some(entry =>
|
|
12290
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
|
|
12291
|
-
);
|
|
12292
|
-
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
12293
|
-
settings.hooks[event].push({
|
|
12294
|
-
hooks: [
|
|
12295
|
-
{
|
|
12296
|
-
type: 'command',
|
|
12297
|
-
command: contextMonitorCommand,
|
|
12298
|
-
timeout: 10
|
|
12299
|
-
}
|
|
12300
|
-
]
|
|
12301
|
-
});
|
|
12302
|
-
console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
|
|
12303
|
-
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
12304
|
-
console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
|
|
12305
|
-
}
|
|
12306
|
-
}
|
|
12307
|
-
}
|
|
12308
|
-
// ── end SubagentStop / Stop / PreCompact events ────────────────────────────
|
|
12309
|
-
|
|
12310
|
-
// ── Gemini-only extended hook events (#776) ───────────────────────────────
|
|
12311
|
-
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
|
|
12312
|
-
// gsd previously did not register. Three high-value events are added here:
|
|
12313
|
-
//
|
|
12314
|
-
// BeforeAgent — fires after user submits a prompt, before the agent
|
|
12315
|
-
// plans. Wire gsd-context-monitor for context headroom
|
|
12316
|
-
// awareness at prompt time.
|
|
12317
|
-
// AfterAgent — fires once per turn after the model generates its final
|
|
12318
|
-
// response. Wire gsd-context-monitor to track headroom
|
|
12319
|
-
// after each agent turn completes.
|
|
12320
|
-
// BeforeModel — fires before each LLM call (per-turn, not per-session).
|
|
12321
|
-
// Wire gsd-context-monitor for per-turn context injection
|
|
12322
|
-
// — more precise than session-start-only injection.
|
|
12323
|
-
//
|
|
12324
|
-
// All three reuse gsd-context-monitor.js — no new hook files needed.
|
|
12325
|
-
// The `decision:"deny"` retry capability of AfterAgent is intentionally
|
|
12326
|
-
// left to the hook script to implement when triggered (gsd-context-monitor
|
|
12327
|
-
// exits 0 / advisory-only today; an active quality gate is a follow-on).
|
|
12328
|
-
//
|
|
12329
|
-
// Note: BeforeToolSelection is NOT wired. That event does not map to a
|
|
12330
|
-
// gsd hook use case at this time; deferred to a follow-on issue.
|
|
12331
|
-
//
|
|
12332
|
-
// Guard: isGemini is defined at the top of install() (line ~8696).
|
|
12333
|
-
if (isGemini) {
|
|
12334
|
-
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
|
12335
|
-
if (!Array.isArray(settings.hooks[geminiEvent])) {
|
|
12336
|
-
settings.hooks[geminiEvent] = [];
|
|
12337
|
-
}
|
|
12338
|
-
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some(entry =>
|
|
12339
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-context-monitor'))
|
|
12340
|
-
);
|
|
12341
|
-
if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
12342
|
-
settings.hooks[geminiEvent].push({
|
|
12343
|
-
hooks: [
|
|
12344
|
-
{
|
|
12345
|
-
type: 'command',
|
|
12346
|
-
command: contextMonitorCommand,
|
|
12347
|
-
timeout: 10
|
|
12348
|
-
}
|
|
12349
|
-
]
|
|
12350
|
-
});
|
|
12351
|
-
console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
|
|
12352
|
-
} else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) {
|
|
12353
|
-
console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
|
|
12354
|
-
}
|
|
12355
|
-
}
|
|
12356
|
-
}
|
|
12357
|
-
// ── end Gemini-only extended hook events ──────────────────────────────────
|
|
12358
|
-
|
|
12359
|
-
// ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
|
|
12360
|
-
// Claude Code fires FileChanged when a watched file changes on disk. Wire
|
|
12361
|
-
// gsd-config-reload.js to reload the gsd config context whenever the user
|
|
12362
|
-
// edits .planning/config.json mid-session, eliminating the need to restart.
|
|
12363
|
-
//
|
|
12364
|
-
// The matcher "config.json" watches for changes to any file named config.json
|
|
12365
|
-
// (Claude Code matches by filename, not full path). The hook exits silently
|
|
12366
|
-
// when the changed file is not the gsd config.
|
|
12367
|
-
//
|
|
12368
|
-
// Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
|
|
12369
|
-
// verified; extend in a follow-on if empirically confirmed.
|
|
12370
|
-
if (runtime === 'claude') {
|
|
12371
|
-
if (!settings.hooks.FileChanged) {
|
|
12372
|
-
settings.hooks.FileChanged = [];
|
|
12373
|
-
}
|
|
12374
|
-
const configReloadFile = path.join(targetDir, 'hooks', 'gsd-config-reload.js');
|
|
12375
|
-
const alreadyHasConfigReload = settings.hooks.FileChanged.some(entry =>
|
|
12376
|
-
entry.hooks && entry.hooks.some(h => referencesHook(h, 'gsd-config-reload'))
|
|
12377
|
-
);
|
|
12378
|
-
if (!alreadyHasConfigReload && fs.existsSync(configReloadFile) && configReloadCommand) {
|
|
12379
|
-
settings.hooks.FileChanged.push({
|
|
12380
|
-
matcher: 'config.json',
|
|
12381
|
-
hooks: [
|
|
12382
|
-
{
|
|
12383
|
-
type: 'command',
|
|
12384
|
-
command: configReloadCommand,
|
|
12385
|
-
timeout: 8
|
|
12386
|
-
}
|
|
12387
|
-
]
|
|
12388
|
-
});
|
|
12389
|
-
console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
|
|
12390
|
-
} else if (!alreadyHasConfigReload && !fs.existsSync(configReloadFile)) {
|
|
12391
|
-
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
|
|
12392
|
-
} else if (!alreadyHasConfigReload && !configReloadCommand) {
|
|
12393
|
-
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
|
|
12394
|
-
}
|
|
12395
|
-
}
|
|
12396
|
-
// ── end FileChanged hook ────────────────────────────────────────────────────
|
|
12397
|
-
}
|
|
11176
|
+
// Register all GSD-managed hook entries into settings.hooks.* for runtimes
|
|
11177
|
+
// that use the settings.json hook surface (ADR-857 phase 5f-1b).
|
|
11178
|
+
// settings is mutated in place by applySettingsJsonHooks.
|
|
11179
|
+
applySettingsJsonHooks(settings, {
|
|
11180
|
+
runtime,
|
|
11181
|
+
isGlobal,
|
|
11182
|
+
targetDir,
|
|
11183
|
+
postToolEvent,
|
|
11184
|
+
hookEvents: _hookEventsDialect,
|
|
11185
|
+
extendedHookEvents: plan.extendedHookEvents,
|
|
11186
|
+
hooksSurface: plan.hooksSurface,
|
|
11187
|
+
updateCheckCommand,
|
|
11188
|
+
contextMonitorCommand,
|
|
11189
|
+
promptGuardCommand,
|
|
11190
|
+
readGuardCommand,
|
|
11191
|
+
readInjectionScannerCommand,
|
|
11192
|
+
configReloadCommand,
|
|
11193
|
+
hookOpts,
|
|
11194
|
+
localCmd,
|
|
11195
|
+
localShellCmd,
|
|
11196
|
+
});
|
|
12398
11197
|
|
|
12399
11198
|
// ── Gemini hooksConfig.enabled check (#776) ───────────────────────────────
|
|
12400
11199
|
// Detect `hooksConfig.enabled: false` in the already-loaded settings object
|
|
@@ -12509,9 +11308,9 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
12509
11308
|
const isWindsurf = runtime === 'windsurf';
|
|
12510
11309
|
const isTrae = runtime === 'trae';
|
|
12511
11310
|
const isCline = runtime === 'cline';
|
|
12512
|
-
const
|
|
11311
|
+
const plan = resolveInstallPlan(runtime);
|
|
12513
11312
|
|
|
12514
|
-
if (shouldInstallStatusline &&
|
|
11313
|
+
if (shouldInstallStatusline && plan.writesSharedSettings && !isOpencode) {
|
|
12515
11314
|
if (!isGlobal && !forceStatusline) {
|
|
12516
11315
|
// Local installs skip statusLine by default: repo settings.json takes precedence over
|
|
12517
11316
|
// profile-level settings.json in Claude Code, so writing here would silently clobber
|
|
@@ -12537,7 +11336,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
12537
11336
|
// settings.json hooks block — opencode/kilo/codex/cursor/windsurf/trae/
|
|
12538
11337
|
// cline either lack the surface or use a different config schema.
|
|
12539
11338
|
const { shouldInstallBanner, bannerCommand } = bannerOpts;
|
|
12540
|
-
if (shouldInstallBanner && settings &&
|
|
11339
|
+
if (shouldInstallBanner && settings && plan.writesSharedSettings && !isOpencode) {
|
|
12541
11340
|
if (!bannerCommand) {
|
|
12542
11341
|
console.warn(` ${yellow}⚠${reset} Skipped update banner registration — Node executable path unavailable. See #2979 / #3002.`);
|
|
12543
11342
|
} else {
|
|
@@ -12577,17 +11376,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS
|
|
|
12577
11376
|
// {type: 'command', command: null} items that the runtime hook schema
|
|
12578
11377
|
// rejects at parse time. validateHookFields filters those out so the file
|
|
12579
11378
|
// we write is always schema-valid.
|
|
12580
|
-
if (settingsPath && settings &&
|
|
11379
|
+
if (settingsPath && settings && plan.writesSharedSettings) {
|
|
12581
11380
|
writeSettings(settingsPath, validateHookFields(settings));
|
|
12582
11381
|
}
|
|
12583
11382
|
|
|
12584
11383
|
// Configure OpenCode permissions
|
|
12585
|
-
if (
|
|
11384
|
+
if (plan.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) {
|
|
12586
11385
|
configureOpencodePermissions(isGlobal, configDir);
|
|
12587
11386
|
}
|
|
12588
11387
|
|
|
12589
11388
|
// Configure Kilo permissions
|
|
12590
|
-
if (
|
|
11389
|
+
if (plan.finishPermissionWriter === 'kilo') {
|
|
12591
11390
|
configureKiloPermissions(isGlobal, configDir);
|
|
12592
11391
|
}
|
|
12593
11392
|
|
|
@@ -13126,9 +11925,11 @@ const _LEGACY_SCAN_SUBDIR_NAMES = [
|
|
|
13126
11925
|
'.codex',
|
|
13127
11926
|
'.copilot',
|
|
13128
11927
|
'.github', // copilot local form
|
|
13129
|
-
'.
|
|
11928
|
+
'.agents', // antigravity local form (canonical, #791)
|
|
11929
|
+
'.agent', // antigravity local form (legacy, backward-compat)
|
|
13130
11930
|
'.cursor',
|
|
13131
|
-
'.
|
|
11931
|
+
'.devin', // windsurf local form (canonical, #1085; Devin Desktop preferred dir)
|
|
11932
|
+
'.windsurf', // windsurf local form (legacy, backward-compat with pre-#1085 installs)
|
|
13132
11933
|
'.codeium/windsurf',
|
|
13133
11934
|
'.augment',
|
|
13134
11935
|
'.trae',
|
|
@@ -13447,6 +12248,8 @@ module.exports = {
|
|
|
13447
12248
|
buildHookCommand,
|
|
13448
12249
|
normalizeNodePath,
|
|
13449
12250
|
resolveNodeRunner,
|
|
12251
|
+
referencesHook,
|
|
12252
|
+
applySettingsJsonHooks,
|
|
13450
12253
|
rewriteLegacyManagedNodeHookCommands,
|
|
13451
12254
|
buildCodexHookBlock,
|
|
13452
12255
|
rewriteLegacyCodexHookBlock,
|