@opengsd/gsd-core 1.7.0-rc.5 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-executor.md +2 -1
- package/agents/gsd-security-auditor.md +13 -15
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +975 -196
- package/commands/gsd/mempalace-capture.md +27 -1
- package/commands/gsd/surface.md +6 -6
- package/gsd-core/bin/gsd-tools.cjs +63 -2
- package/gsd-core/bin/lib/api-coverage.cjs +3 -4
- package/gsd-core/bin/lib/audit.cjs +7 -6
- package/gsd-core/bin/lib/capability-registry.cjs +503 -87
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- package/gsd-core/bin/lib/check-command-router.cjs +1 -1
- package/gsd-core/bin/lib/clock.cjs +19 -0
- package/gsd-core/bin/lib/commands.cjs +48 -9
- package/gsd-core/bin/lib/config-loader.cjs +6 -2
- package/gsd-core/bin/lib/config.cjs +12 -0
- package/gsd-core/bin/lib/core-utils.cjs +8 -2
- package/gsd-core/bin/lib/drift.cjs +4 -4
- package/gsd-core/bin/lib/frontmatter.cjs +22 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +2 -1
- package/gsd-core/bin/lib/host-integration.cjs +33 -8
- package/gsd-core/bin/lib/init.cjs +60 -53
- package/gsd-core/bin/lib/install-engine.cjs +93 -22
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +2 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +342 -0
- package/gsd-core/bin/lib/markdown-table.cjs +698 -0
- package/gsd-core/bin/lib/mcp-server.cjs +18 -7
- package/gsd-core/bin/lib/milestone.cjs +217 -31
- package/gsd-core/bin/lib/phase-command-router.cjs +50 -2
- package/gsd-core/bin/lib/phase-lifecycle.cjs +62 -36
- package/gsd-core/bin/lib/phase-locator.cjs +23 -2
- package/gsd-core/bin/lib/phase.cjs +436 -61
- package/gsd-core/bin/lib/plan-scan.cjs +3 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
- package/gsd-core/bin/lib/roadmap-parser.cjs +218 -13
- package/gsd-core/bin/lib/roadmap.cjs +100 -49
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +242 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +24 -14
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +22 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +526 -29
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/schema-detect.cjs +2 -1
- package/gsd-core/bin/lib/security.cjs +7 -37
- package/gsd-core/bin/lib/shell-command-projection.cjs +176 -27
- package/gsd-core/bin/lib/smart-entry.cjs +4 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- package/gsd-core/bin/lib/state-transition.cjs +100 -45
- package/gsd-core/bin/lib/state.cjs +391 -126
- package/gsd-core/bin/lib/surface.cjs +12 -8
- package/gsd-core/bin/lib/template.cjs +2 -1
- package/gsd-core/bin/lib/uat.cjs +54 -8
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +23 -1
- package/gsd-core/bin/lib/verify.cjs +4 -3
- package/gsd-core/bin/lib/workstream.cjs +3 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +1 -1
- package/gsd-core/bin/lib/write-set.cjs +38 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -3
- package/gsd-core/references/checkpoints.md +12 -0
- package/gsd-core/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- package/gsd-core/workflows/debug.md +2 -0
- package/gsd-core/workflows/execute-phase.md +5 -3
- package/gsd-core/workflows/fast.md +8 -22
- package/gsd-core/workflows/plan-phase.md +6 -0
- package/gsd-core/workflows/progress.md +2 -2
- package/gsd-core/workflows/quick.md +2 -0
- package/gsd-core/workflows/review.md +42 -3
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -4
- package/gsd-core/workflows/ship.md +8 -2
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/hooks/dist/gsd-statusline.js +164 -14
- package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-statusline.js +164 -14
- package/hooks/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +10 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +3 -0
- package/scripts/ci-test-scope.cjs +39 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +35 -35
- package/scripts/gen-install-tree-fixtures.cjs +75 -0
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-table-schema-drift.cjs +157 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/validate-registry.cjs +117 -0
- package/skills/gsd-mempalace-capture/SKILL.md +27 -1
- package/skills/gsd-surface/SKILL.md +6 -6
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- package/vscode/package.json +96 -0
|
@@ -31,7 +31,7 @@ const node_os_1 = __importDefault(require("node:os"));
|
|
|
31
31
|
const imperative_hook_bus_cjs_1 = require("./host-integration-adapters/imperative-hook-bus.cjs");
|
|
32
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
33
|
const shellCmdProjection = require("./shell-command-projection.cjs");
|
|
34
|
-
const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
|
|
34
|
+
const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, escapeTomlDoubleQuotedString, } = shellCmdProjection;
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
36
|
// Terminal color constants (mirrors install.js for console output parity)
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
@@ -53,6 +53,23 @@ const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' +
|
|
|
53
53
|
const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
|
|
54
54
|
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
|
55
55
|
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
|
56
|
+
// #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static,
|
|
57
|
+
// deterministic advisory (no branching/no-op-style, matching sessionStart's
|
|
58
|
+
// tone) so the emitted hooks/gsd-session.json stays golden-trackable — no
|
|
59
|
+
// node-runner invocation, no filesystem probing beyond what sessionStart
|
|
60
|
+
// already does.
|
|
61
|
+
const GSD_COPILOT_PRE_TOOL_MSG = 'GSD: confirm this tool use is in scope for the active phase before proceeding.';
|
|
62
|
+
const GSD_COPILOT_PRE_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
|
63
|
+
const GSD_COPILOT_PRE_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`;
|
|
64
|
+
const GSD_COPILOT_POST_TOOL_MSG = 'GSD: review the tool result against the active phase before continuing.';
|
|
65
|
+
const GSD_COPILOT_POST_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
|
66
|
+
const GSD_COPILOT_POST_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`;
|
|
67
|
+
const GSD_COPILOT_PROMPT_SUBMIT_MSG = 'GSD: check this request against .planning/STATE.md scope before acting.';
|
|
68
|
+
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
|
69
|
+
const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`;
|
|
70
|
+
const GSD_COPILOT_SESSION_END_MSG = 'GSD: update .planning/STATE.md with the session outcome before ending.';
|
|
71
|
+
const GSD_COPILOT_SESSION_END_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
|
72
|
+
const GSD_COPILOT_SESSION_END_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`;
|
|
56
73
|
// ---------------------------------------------------------------------------
|
|
57
74
|
// Cursor hook constants
|
|
58
75
|
// ---------------------------------------------------------------------------
|
|
@@ -76,6 +93,28 @@ const CURSOR_MANAGED_EVENTS = imperative_hook_bus_cjs_1.CURSOR_HOOK_EVENTS;
|
|
|
76
93
|
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
|
|
77
94
|
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
|
78
95
|
// ---------------------------------------------------------------------------
|
|
96
|
+
// Descriptor-driven runtime title lookup (ADR-1239 / #2092)
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
/**
|
|
99
|
+
* Console-log label for a runtime, sourced from the capability registry's
|
|
100
|
+
* `title` field (capabilities/<runtime>/capability.json). Folded from a
|
|
101
|
+
* hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ?
|
|
102
|
+
* 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves
|
|
103
|
+
* to the same 'Qwen Code' / 'Claude Code' values for those two runtimes.
|
|
104
|
+
* Falls back to the raw runtime id if the registry can't be loaded or the
|
|
105
|
+
* runtime has no title.
|
|
106
|
+
*/
|
|
107
|
+
function _capabilityTitle(runtime) {
|
|
108
|
+
try {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
110
|
+
const reg = require('./capability-registry.cjs');
|
|
111
|
+
return reg?.runtimes?.[runtime]?.title || runtime;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return runtime;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
79
118
|
// atomicWriteFileSync — shared canonical implementation.
|
|
80
119
|
//
|
|
81
120
|
// __atomicWrittenTmps is exported so bin/install.js can merge it into its
|
|
@@ -228,7 +267,7 @@ function normalizeNodePath(execPath, opts) {
|
|
|
228
267
|
return execPath;
|
|
229
268
|
const env = (opts && opts.env) || process.env;
|
|
230
269
|
const existsSync = (opts && opts.existsSync) || node_fs_1.default.existsSync;
|
|
231
|
-
const normalizedForMatch =
|
|
270
|
+
const normalizedForMatch = shellCmdProjection.posixNormalize(execPath);
|
|
232
271
|
if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
|
|
233
272
|
const candidates = [];
|
|
234
273
|
if (env.FNM_DIR) {
|
|
@@ -244,11 +283,16 @@ function normalizeNodePath(execPath, opts) {
|
|
|
244
283
|
}
|
|
245
284
|
return execPath;
|
|
246
285
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
286
|
+
// Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew
|
|
287
|
+
// /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at
|
|
288
|
+
// <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node, then deletes prior versions on
|
|
289
|
+
// `brew upgrade node`. Rewrite to the stable <prefix>/bin/node symlink, which
|
|
290
|
+
// survives the upgrade. Derive <prefix> from the path itself (more reliable
|
|
291
|
+
// than HOMEBREW_PREFIX env — the path IS the install location) so every layout
|
|
292
|
+
// is covered by one branch instead of one per known prefix (#2185).
|
|
293
|
+
const homebrewMatch = normalizedForMatch.match(/^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i);
|
|
294
|
+
if (homebrewMatch) {
|
|
295
|
+
return `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`;
|
|
252
296
|
}
|
|
253
297
|
// mise pins a concrete node version at <data>/installs/node/<ver>/bin/node
|
|
254
298
|
// (Windows: <data>/installs/node/<ver>/node.exe). Node realpaths
|
|
@@ -272,7 +316,7 @@ function resolveNodeRunner(opts) {
|
|
|
272
316
|
if (!execPath)
|
|
273
317
|
return null;
|
|
274
318
|
const stablePath = normalizeNodePath(execPath, opts);
|
|
275
|
-
return JSON.stringify(
|
|
319
|
+
return JSON.stringify(shellCmdProjection.posixNormalize(stablePath));
|
|
276
320
|
}
|
|
277
321
|
function resolveBashRunner(opts) {
|
|
278
322
|
const platform = (opts && opts.platform) || process.platform;
|
|
@@ -293,7 +337,7 @@ function resolveBashRunner(opts) {
|
|
|
293
337
|
}
|
|
294
338
|
for (const candidate of candidates) {
|
|
295
339
|
if (candidate && exists(candidate)) {
|
|
296
|
-
return JSON.stringify(
|
|
340
|
+
return JSON.stringify(shellCmdProjection.posixNormalize(candidate));
|
|
297
341
|
}
|
|
298
342
|
}
|
|
299
343
|
return null;
|
|
@@ -333,7 +377,7 @@ function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
|
333
377
|
}
|
|
334
378
|
else {
|
|
335
379
|
_runnerToken = m[1];
|
|
336
|
-
const runnerPath = (m[2] || m[3] || m[4] || '')
|
|
380
|
+
const runnerPath = shellCmdProjection.posixNormalize(m[2] || m[3] || m[4] || '');
|
|
337
381
|
const stableRunner = normalizeNodePath(runnerPath);
|
|
338
382
|
if (stableRunner === runnerPath && platform !== 'win32')
|
|
339
383
|
continue;
|
|
@@ -523,10 +567,10 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
|
523
567
|
catch {
|
|
524
568
|
interpreter = absoluteRunnerToken;
|
|
525
569
|
}
|
|
526
|
-
const targetAbs =
|
|
570
|
+
const targetAbs = shellCmdProjection.posixNormalize(scriptAbsPath);
|
|
527
571
|
const scriptQuoted = JSON.stringify(targetAbs);
|
|
528
572
|
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
|
|
529
|
-
const hookCommand = JSON.stringify(
|
|
573
|
+
const hookCommand = JSON.stringify(shellCmdProjection.posixNormalize(cmdPath));
|
|
530
574
|
const runnerQuoted = JSON.stringify(interpreter);
|
|
531
575
|
return {
|
|
532
576
|
invocation: { interpreter, target: scriptAbsPath },
|
|
@@ -545,7 +589,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
545
589
|
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
546
590
|
if (!absoluteRunner)
|
|
547
591
|
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
548
|
-
const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js')
|
|
592
|
+
const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js'));
|
|
549
593
|
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
|
|
550
594
|
let managedCommand;
|
|
551
595
|
if (platform === 'win32') {
|
|
@@ -575,7 +619,7 @@ function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
|
575
619
|
if (!managedCommand)
|
|
576
620
|
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
577
621
|
const commandWindows = platform === 'win32'
|
|
578
|
-
? JSON.stringify(
|
|
622
|
+
? JSON.stringify(shellCmdProjection.posixNormalize(cmdShimPath))
|
|
579
623
|
: undefined;
|
|
580
624
|
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
|
|
581
625
|
}
|
|
@@ -585,7 +629,7 @@ function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
|
585
629
|
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
586
630
|
if (!absoluteRunner)
|
|
587
631
|
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
588
|
-
const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js')
|
|
632
|
+
const scriptPath = shellCmdProjection.posixNormalize(node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js'));
|
|
589
633
|
let managedCommand;
|
|
590
634
|
if (platform === 'win32') {
|
|
591
635
|
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
@@ -628,6 +672,7 @@ function buildHookCommand(configDir, hookName, opts) {
|
|
|
628
672
|
opts = {};
|
|
629
673
|
const platform = opts.platform || process.platform;
|
|
630
674
|
const runtime = opts.runtime || 'generic';
|
|
675
|
+
const hookShell = opts.hookShell;
|
|
631
676
|
const isShellHook = hookName.endsWith('.sh');
|
|
632
677
|
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
|
|
633
678
|
if (opts.portableHooks) {
|
|
@@ -637,7 +682,7 @@ function buildHookCommand(configDir, hookName, opts) {
|
|
|
637
682
|
});
|
|
638
683
|
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
|
|
639
684
|
}
|
|
640
|
-
return JSON.stringify(
|
|
685
|
+
return JSON.stringify(shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName);
|
|
641
686
|
}
|
|
642
687
|
const nodeRunner = resolveNodeRunner();
|
|
643
688
|
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
|
|
@@ -653,14 +698,16 @@ function buildHookCommand(configDir, hookName, opts) {
|
|
|
653
698
|
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
|
|
654
699
|
runtime: opts.runtime || 'generic',
|
|
655
700
|
platform,
|
|
701
|
+
hookShell,
|
|
656
702
|
});
|
|
657
703
|
}
|
|
658
|
-
const hooksPath =
|
|
704
|
+
const hooksPath = shellCmdProjection.posixNormalize(configDir) + '/hooks/' + hookName;
|
|
659
705
|
return projectManagedHookCommand({
|
|
660
706
|
absoluteRunner: runner,
|
|
661
707
|
scriptPath: hooksPath,
|
|
662
708
|
runtime,
|
|
663
709
|
platform,
|
|
710
|
+
hookShell,
|
|
664
711
|
});
|
|
665
712
|
}
|
|
666
713
|
// ---------------------------------------------------------------------------
|
|
@@ -805,7 +852,7 @@ function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
|
805
852
|
function buildCursorHookEntry(scriptPath) {
|
|
806
853
|
return {
|
|
807
854
|
type: 'command',
|
|
808
|
-
command:
|
|
855
|
+
command: shellCmdProjection.posixNormalize(scriptPath),
|
|
809
856
|
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
810
857
|
};
|
|
811
858
|
}
|
|
@@ -932,6 +979,193 @@ function removeCursorHooksJson(targetDir) {
|
|
|
932
979
|
return { changed: result.changed };
|
|
933
980
|
}
|
|
934
981
|
// ---------------------------------------------------------------------------
|
|
982
|
+
// Windsurf/Cascade hook functions (ADR-1239 / #2100 Stage 2 — HOOK-BRIDGE)
|
|
983
|
+
//
|
|
984
|
+
// Cascade (Windsurf's agent) hooks.json format is DISTINCT from Cursor's:
|
|
985
|
+
// { "hooks": { "<event>": [ { "command": "<shell cmd>", ... } ] } }
|
|
986
|
+
// Each entry carries a bare `command` STRING (a shell command line) — not
|
|
987
|
+
// Cursor's `{ type: 'command', command: <cmd> }` wrapper — and there is no
|
|
988
|
+
// top-level `version` field. Docs (reference): https://docs.windsurf.com/llms-full.txt ,
|
|
989
|
+
// https://docs.devin.ai/desktop/cascade/hooks
|
|
990
|
+
//
|
|
991
|
+
// Cascade blocks via EXIT CODE 2 (+ a stderr reason), not Cursor's stdout-JSON
|
|
992
|
+
// `{ block: true, reason }` form — so the two hook scripts installed here
|
|
993
|
+
// (hooks/gsd-windsurf-pre-write.js, hooks/gsd-windsurf-pre-command.js) speak a
|
|
994
|
+
// different protocol than the Cursor scripts, even though the surrounding
|
|
995
|
+
// install/reconcile infra mirrors writeCursorHooksJson/removeCursorHooksJson.
|
|
996
|
+
//
|
|
997
|
+
// Only 2 of GSD's 6 Cursor-parity hook events have a Cascade counterpart with
|
|
998
|
+
// BLOCKING semantics: pre_write_code and pre_run_command. Cascade has no
|
|
999
|
+
// context-injection channel (no `additional_context`-style advisory
|
|
1000
|
+
// response), so the 4 advisory events GSD registers on Cursor (sessionStart,
|
|
1001
|
+
// postToolUse, stop, subagentStart/subagentStop) are deliberately NOT ported.
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
const GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT = 'gsd-windsurf-pre-write.js';
|
|
1004
|
+
const GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT = 'gsd-windsurf-pre-command.js';
|
|
1005
|
+
const GSD_WINDSURF_HOOK_MARKER = 'gsd-managed';
|
|
1006
|
+
/** The 2 Cascade hook events GSD wires with blocking (exit-code-2) guards. */
|
|
1007
|
+
const WINDSURF_HOOK_EVENTS = Object.freeze(['pre_write_code', 'pre_run_command']);
|
|
1008
|
+
/** Event → hook-script mapping (mirrors CURSOR_EVENT_SCRIPT_MAP's convention). */
|
|
1009
|
+
const WINDSURF_EVENT_SCRIPT_MAP = Object.freeze({
|
|
1010
|
+
pre_write_code: GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
|
1011
|
+
pre_run_command: GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
|
1012
|
+
});
|
|
1013
|
+
/** All GSD-managed Windsurf hook scripts (used by uninstall cleanup). */
|
|
1014
|
+
const GSD_WINDSURF_HOOK_SCRIPTS = [
|
|
1015
|
+
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
|
1016
|
+
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
|
1017
|
+
];
|
|
1018
|
+
/**
|
|
1019
|
+
* Build a single Cascade hooks.json managed entry. Cascade's entry shape has
|
|
1020
|
+
* no `type` field (unlike Cursor's `{ type: 'command', command }`) — just a
|
|
1021
|
+
* bare `command` shell string plus the GSD marker.
|
|
1022
|
+
*/
|
|
1023
|
+
function buildWindsurfHookEntry(command) {
|
|
1024
|
+
return {
|
|
1025
|
+
command,
|
|
1026
|
+
[GSD_WINDSURF_HOOK_MARKER]: true,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
function isManagedWindsurfHookEntry(entry) {
|
|
1030
|
+
return Boolean(entry && typeof entry === 'object' && entry[GSD_WINDSURF_HOOK_MARKER]);
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Reconcile GSD's managed Cascade hook entries into `<targetDir>/hooks.json`,
|
|
1034
|
+
* preserving any user-owned entries. Mirrors reconcileCursorHooksJson's
|
|
1035
|
+
* merge/no-write-when-unchanged semantics, adapted to Cascade's flatter
|
|
1036
|
+
* `{ hooks: { <event>: [...] } }` shape (no `version` field, no legacy
|
|
1037
|
+
* top-level-array lift — Cascade's hooks.json is a brand-new surface with no
|
|
1038
|
+
* prior shape to migrate from).
|
|
1039
|
+
*/
|
|
1040
|
+
function reconcileWindsurfHooksJson(hooksJsonPath, managedEntries) {
|
|
1041
|
+
let parsed = {};
|
|
1042
|
+
let currentContent = null;
|
|
1043
|
+
if (node_fs_1.default.existsSync(hooksJsonPath)) {
|
|
1044
|
+
const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
|
|
1045
|
+
currentContent = raw;
|
|
1046
|
+
if (raw.trim()) {
|
|
1047
|
+
try {
|
|
1048
|
+
parsed = JSON.parse(raw);
|
|
1049
|
+
}
|
|
1050
|
+
catch (err) {
|
|
1051
|
+
throw new Error(`Windsurf hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1056
|
+
parsed = {};
|
|
1057
|
+
const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
|
1058
|
+
if (!hasNestedHooksObject)
|
|
1059
|
+
parsed['hooks'] = {};
|
|
1060
|
+
const hookTable = parsed['hooks'];
|
|
1061
|
+
const entries = managedEntries || {};
|
|
1062
|
+
for (const event of WINDSURF_HOOK_EVENTS) {
|
|
1063
|
+
const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
|
|
1064
|
+
const userOwned = existing.filter((e) => !isManagedWindsurfHookEntry(e));
|
|
1065
|
+
const newEntry = entries[event] || null;
|
|
1066
|
+
if (newEntry) {
|
|
1067
|
+
hookTable[event] = [...userOwned, newEntry];
|
|
1068
|
+
}
|
|
1069
|
+
else if (userOwned.length > 0) {
|
|
1070
|
+
hookTable[event] = userOwned;
|
|
1071
|
+
}
|
|
1072
|
+
else {
|
|
1073
|
+
delete hookTable[event];
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// Avoid writing an empty `{ "hooks": {} }` artifact.
|
|
1077
|
+
if (Object.keys(hookTable).length === 0)
|
|
1078
|
+
delete parsed['hooks'];
|
|
1079
|
+
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
1080
|
+
const changed = currentContent !== nextContent;
|
|
1081
|
+
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
1082
|
+
if (shouldWrite) {
|
|
1083
|
+
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
1084
|
+
}
|
|
1085
|
+
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Write GSD-managed Cascade lifecycle hooks into `<targetDir>/hooks.json`.
|
|
1089
|
+
* Both managed hook scripts (gsd-windsurf-pre-write.js,
|
|
1090
|
+
* gsd-windsurf-pre-command.js) are copied from the GSD hooks/ source to
|
|
1091
|
+
* `<targetDir>/hooks/` first, so the hooks.json entries never reference a
|
|
1092
|
+
* script that wasn't installed. Mirrors writeCursorHooksJson's structure;
|
|
1093
|
+
* `buildHookCommand` is runtime-agnostic (it already returns a plain shell
|
|
1094
|
+
* command string), so it is reused as-is with `runtime: 'windsurf'` — only
|
|
1095
|
+
* the hooks.json ENTRY shape (buildWindsurfHookEntry) and the reconcile
|
|
1096
|
+
* function differ from Cursor's.
|
|
1097
|
+
*
|
|
1098
|
+
* @param targetDir - The Windsurf config dir (global: ~/.codeium/windsurf; local: .windsurf)
|
|
1099
|
+
* @param src - The GSD install source root (for copying hook scripts)
|
|
1100
|
+
* @param opts - `{ platform? }`
|
|
1101
|
+
* @returns `{ hooksJsonPath, changed }`
|
|
1102
|
+
*/
|
|
1103
|
+
function writeWindsurfHooksJson(targetDir, src, opts) {
|
|
1104
|
+
opts = opts || {};
|
|
1105
|
+
const hooksDir = node_path_1.default.join(targetDir, 'hooks');
|
|
1106
|
+
node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
|
|
1107
|
+
const srcHooksDir = node_path_1.default.join(src, 'hooks');
|
|
1108
|
+
const installedScripts = new Set();
|
|
1109
|
+
for (const script of GSD_WINDSURF_HOOK_SCRIPTS) {
|
|
1110
|
+
const srcPath = node_path_1.default.join(srcHooksDir, script);
|
|
1111
|
+
const destPath = node_path_1.default.join(hooksDir, script);
|
|
1112
|
+
if (node_fs_1.default.existsSync(srcPath)) {
|
|
1113
|
+
let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
|
|
1114
|
+
content = content.replace(/gsd:/gi, 'gsd-');
|
|
1115
|
+
node_fs_1.default.writeFileSync(destPath, content);
|
|
1116
|
+
try {
|
|
1117
|
+
node_fs_1.default.chmodSync(destPath, 0o755);
|
|
1118
|
+
}
|
|
1119
|
+
catch { /* Windows: ignore chmod */ }
|
|
1120
|
+
installedScripts.add(script);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const hookOpts = { runtime: 'windsurf', platform: opts.platform || process.platform };
|
|
1124
|
+
const commands = {};
|
|
1125
|
+
for (const ev of WINDSURF_HOOK_EVENTS) {
|
|
1126
|
+
const script = WINDSURF_EVENT_SCRIPT_MAP[ev];
|
|
1127
|
+
commands[ev] = (script && installedScripts.has(script)) ? buildHookCommand(targetDir, script, hookOpts) : null;
|
|
1128
|
+
}
|
|
1129
|
+
const managedEntries = {};
|
|
1130
|
+
for (const ev of WINDSURF_HOOK_EVENTS) {
|
|
1131
|
+
const cmd = commands[ev];
|
|
1132
|
+
if (cmd)
|
|
1133
|
+
managedEntries[ev] = buildWindsurfHookEntry(cmd);
|
|
1134
|
+
}
|
|
1135
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
1136
|
+
const result = reconcileWindsurfHooksJson(hooksJsonPath, managedEntries);
|
|
1137
|
+
return { hooksJsonPath, changed: result.changed };
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Remove all GSD-managed Cascade hook entries from hooks.json. User-owned
|
|
1141
|
+
* entries are preserved. If the file becomes empty, it is removed.
|
|
1142
|
+
*
|
|
1143
|
+
* @param targetDir - The Windsurf config dir
|
|
1144
|
+
* @returns `{ changed }`
|
|
1145
|
+
*/
|
|
1146
|
+
function removeWindsurfHooksJson(targetDir) {
|
|
1147
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
1148
|
+
if (!node_fs_1.default.existsSync(hooksJsonPath))
|
|
1149
|
+
return { changed: false };
|
|
1150
|
+
const result = reconcileWindsurfHooksJson(hooksJsonPath, null);
|
|
1151
|
+
if (result.changed) {
|
|
1152
|
+
try {
|
|
1153
|
+
const contentRaw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
|
|
1154
|
+
const parsed = JSON.parse(contentRaw);
|
|
1155
|
+
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
|
|
1156
|
+
? parsed['hooks']
|
|
1157
|
+
: {};
|
|
1158
|
+
const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
|
|
1159
|
+
if (!hasAnyEvents) {
|
|
1160
|
+
node_fs_1.default.unlinkSync(hooksJsonPath);
|
|
1161
|
+
return { changed: true };
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
catch { /* best-effort: leave the file */ }
|
|
1165
|
+
}
|
|
1166
|
+
return { changed: result.changed };
|
|
1167
|
+
}
|
|
1168
|
+
// ---------------------------------------------------------------------------
|
|
935
1169
|
// Copilot hook functions
|
|
936
1170
|
// ---------------------------------------------------------------------------
|
|
937
1171
|
function buildCopilotHookConfig() {
|
|
@@ -946,6 +1180,41 @@ function buildCopilotHookConfig() {
|
|
|
946
1180
|
timeoutSec: 10,
|
|
947
1181
|
},
|
|
948
1182
|
],
|
|
1183
|
+
// #2099 UPGRADE 1: multi-event hook bus — preToolUse (worktree/read-safety
|
|
1184
|
+
// advisory), postToolUse (context-monitor advisory), userPromptSubmitted
|
|
1185
|
+
// (prompt-guard advisory), sessionEnd (session-finalize advisory).
|
|
1186
|
+
preToolUse: [
|
|
1187
|
+
{
|
|
1188
|
+
type: 'command',
|
|
1189
|
+
bash: GSD_COPILOT_PRE_TOOL_HOOK_BASH,
|
|
1190
|
+
powershell: GSD_COPILOT_PRE_TOOL_HOOK_PWSH,
|
|
1191
|
+
timeoutSec: 10,
|
|
1192
|
+
},
|
|
1193
|
+
],
|
|
1194
|
+
postToolUse: [
|
|
1195
|
+
{
|
|
1196
|
+
type: 'command',
|
|
1197
|
+
bash: GSD_COPILOT_POST_TOOL_HOOK_BASH,
|
|
1198
|
+
powershell: GSD_COPILOT_POST_TOOL_HOOK_PWSH,
|
|
1199
|
+
timeoutSec: 10,
|
|
1200
|
+
},
|
|
1201
|
+
],
|
|
1202
|
+
userPromptSubmitted: [
|
|
1203
|
+
{
|
|
1204
|
+
type: 'command',
|
|
1205
|
+
bash: GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH,
|
|
1206
|
+
powershell: GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH,
|
|
1207
|
+
timeoutSec: 10,
|
|
1208
|
+
},
|
|
1209
|
+
],
|
|
1210
|
+
sessionEnd: [
|
|
1211
|
+
{
|
|
1212
|
+
type: 'command',
|
|
1213
|
+
bash: GSD_COPILOT_SESSION_END_HOOK_BASH,
|
|
1214
|
+
powershell: GSD_COPILOT_SESSION_END_HOOK_PWSH,
|
|
1215
|
+
timeoutSec: 10,
|
|
1216
|
+
},
|
|
1217
|
+
],
|
|
949
1218
|
},
|
|
950
1219
|
};
|
|
951
1220
|
}
|
|
@@ -970,7 +1239,13 @@ function applySettingsJsonHooks(settings, opts) {
|
|
|
970
1239
|
// register settings.json hooks; runtimes with hooksSurface === 'none'
|
|
971
1240
|
// (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
|
|
972
1241
|
// the old !isOpencode && !isKilo check.
|
|
973
|
-
|
|
1242
|
+
// #2095: kimi's hooksSurface is 'kimi-hooks-toml' — it registers hooks into
|
|
1243
|
+
// its own native config.toml via writeKimiHooksToml, not settings.json (kimi
|
|
1244
|
+
// never writes settings.json at all: writesSharedSettings stays false). This
|
|
1245
|
+
// guard must also skip kimi's surface so applySettingsJsonHooks doesn't log
|
|
1246
|
+
// misleading "Configured ..." console messages for a settings object that
|
|
1247
|
+
// finishInstall() will never persist for kimi.
|
|
1248
|
+
if (hooksSurface !== 'none' && hooksSurface !== 'kimi-hooks-toml') {
|
|
974
1249
|
if (!settings.hooks) {
|
|
975
1250
|
settings.hooks = {};
|
|
976
1251
|
}
|
|
@@ -1272,12 +1547,18 @@ function applySettingsJsonHooks(settings, opts) {
|
|
|
1272
1547
|
else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
1273
1548
|
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
1274
1549
|
}
|
|
1275
|
-
// ── Extended hook events: SubagentStop / Stop / PreCompact
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1550
|
+
// ── Extended hook events: SubagentStop / Stop / PreCompact / SubagentStart
|
|
1551
|
+
// (#788 + #770 + #2092) ────────────────────────────────────────────────
|
|
1552
|
+
// Claude Code (since #770) and Qwen Code (since #788) both support the
|
|
1553
|
+
// SubagentStop / Stop / PreCompact lifecycle events. Qwen Code additionally
|
|
1554
|
+
// supports SubagentStart (#2092 Phase B, Upgrade 2). Wire gsd-context-
|
|
1555
|
+
// monitor so agents get context-headroom warnings at subagent start,
|
|
1556
|
+
// subagent completion, model stop, and pre-compaction (the most critical
|
|
1557
|
+
// moment to surface headroom info).
|
|
1280
1558
|
//
|
|
1559
|
+
// SubagentStart — subagent lifecycle start (context headroom tracking;
|
|
1560
|
+
// qwen-only today — no other runtime declares it in
|
|
1561
|
+
// extendedHookEvents)
|
|
1281
1562
|
// SubagentStop — subagent lifecycle completion (context headroom tracking)
|
|
1282
1563
|
// Stop — model stop / final-response moment (context headroom)
|
|
1283
1564
|
// PreCompact — fires before conversation compaction (most critical
|
|
@@ -1287,11 +1568,15 @@ function applySettingsJsonHooks(settings, opts) {
|
|
|
1287
1568
|
// user prompt text, not a tool invocation, so gsd-prompt-guard (which
|
|
1288
1569
|
// exits unless tool_name is Write/Edit) would be a silent no-op. A
|
|
1289
1570
|
// dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
|
|
1290
|
-
// SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
1291
|
-
// Guard is
|
|
1571
|
+
// SubagentStart, SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
1572
|
+
// Guard is descriptor-driven: only events present in extendedEvents are wired,
|
|
1573
|
+
// so this loop is a no-op for every runtime that doesn't list SubagentStart.
|
|
1292
1574
|
{
|
|
1293
|
-
|
|
1294
|
-
|
|
1575
|
+
// Descriptor-driven (ADR-1239 / #2092): folded from a hardcoded
|
|
1576
|
+
// `runtime === 'qwen' ? ... : ...` ternary into a capability-title
|
|
1577
|
+
// lookup (see _capabilityTitle above).
|
|
1578
|
+
const runtimeLabel = _capabilityTitle(runtime);
|
|
1579
|
+
for (const event of ['SubagentStop', 'Stop', 'PreCompact', 'SubagentStart']) {
|
|
1295
1580
|
if (!extendedEvents.includes(event))
|
|
1296
1581
|
continue;
|
|
1297
1582
|
if (!settings.hooks[event]) {
|
|
@@ -1315,7 +1600,7 @@ function applySettingsJsonHooks(settings, opts) {
|
|
|
1315
1600
|
}
|
|
1316
1601
|
}
|
|
1317
1602
|
}
|
|
1318
|
-
// ── end SubagentStop / Stop / PreCompact events
|
|
1603
|
+
// ── end SubagentStop / Stop / PreCompact / SubagentStart events ────────────
|
|
1319
1604
|
// ── Extended hook events (#776; Gemini runtime removed #1928) ──────────────
|
|
1320
1605
|
// The Gemini-3-backend dialect exposes several hook events beyond
|
|
1321
1606
|
// BeforeTool/AfterTool. These were added for the now-removed Gemini CLI
|
|
@@ -1412,6 +1697,199 @@ function applySettingsJsonHooks(settings, opts) {
|
|
|
1412
1697
|
@typescript-eslint/no-unsafe-assignment */
|
|
1413
1698
|
}
|
|
1414
1699
|
// ---------------------------------------------------------------------------
|
|
1700
|
+
// Kimi hooks.toml (#2095 EoS/kimi Upgrade 1 — native hook bus)
|
|
1701
|
+
//
|
|
1702
|
+
// Kimi CLI reads lifecycle hooks from a flat `[[hooks]]` array in its own
|
|
1703
|
+
// config.toml (moonshotai.github.io/kimi-cli/en/customization/hooks.html),
|
|
1704
|
+
// not from settings.json. Unlike every other hooksSurface writer above, this
|
|
1705
|
+
// file lives OUTSIDE the runtime's GSD configDir: kimi's configDir is the
|
|
1706
|
+
// generic Agent-Skills root (~/.config/agents by default), while config.toml
|
|
1707
|
+
// is a sibling at ~/.kimi (KIMI_SHARE_DIR override), resolved by
|
|
1708
|
+
// resolveKimiHooksTomlDir in runtime-homes.cts. Callers resolve that path and
|
|
1709
|
+
// pass it in explicitly — this module never reaches into runtime-homes.cjs
|
|
1710
|
+
// itself, keeping the same configDir/targetDir-passed-in shape every other
|
|
1711
|
+
// writer in this file uses.
|
|
1712
|
+
//
|
|
1713
|
+
// GSD-owned [[hooks]] entries are wrapped in marker comments so a reinstall
|
|
1714
|
+
// can find-and-replace only GSD's own block, leaving any user-authored
|
|
1715
|
+
// [[hooks]] entries elsewhere in the file untouched — mirrors the marker
|
|
1716
|
+
// approach stripStaleGsdHookBlocks uses for Codex's config.toml, simplified
|
|
1717
|
+
// to plain string slicing since this block is a flat, self-contained span
|
|
1718
|
+
// (no nested per-key structural TOML parsing is needed).
|
|
1719
|
+
// ---------------------------------------------------------------------------
|
|
1720
|
+
const KIMI_HOOKS_TOML_MARKER_BEGIN = '# GSD Hooks BEGIN — managed by GSD, do not edit between these markers';
|
|
1721
|
+
const KIMI_HOOKS_TOML_MARKER_END = '# GSD Hooks END';
|
|
1722
|
+
function buildKimiHookEntryToml(spec) {
|
|
1723
|
+
if (!spec.command)
|
|
1724
|
+
return null;
|
|
1725
|
+
const lines = ['[[hooks]]', `event = "${spec.event}"`];
|
|
1726
|
+
if (spec.matcher) {
|
|
1727
|
+
lines.push(`matcher = "${escapeTomlDoubleQuotedString(spec.matcher)}"`);
|
|
1728
|
+
}
|
|
1729
|
+
lines.push(`command = "${escapeTomlDoubleQuotedString(spec.command)}"`);
|
|
1730
|
+
if (typeof spec.timeout === 'number') {
|
|
1731
|
+
lines.push(`timeout = ${spec.timeout}`);
|
|
1732
|
+
}
|
|
1733
|
+
return lines.join('\n');
|
|
1734
|
+
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Build the full marker-delimited GSD [[hooks]] block for kimi's config.toml,
|
|
1737
|
+
* or null when no GSD hook resolved to a usable command (hooks/ missing, or
|
|
1738
|
+
* the node/bash runner could not be resolved — mirrors the #1754/#3002
|
|
1739
|
+
* defensive guards applySettingsJsonHooks applies per-hook above).
|
|
1740
|
+
*
|
|
1741
|
+
* Event -> hook mapping mirrors applySettingsJsonHooks' settings.json wiring
|
|
1742
|
+
* 1:1 by GSD hook script (update check, session-state, phase-boundary,
|
|
1743
|
+
* graphify, context monitor, prompt/read/workflow/worktree guards, commit
|
|
1744
|
+
* validation). Kimi's 13 lifecycle events include exact-name equivalents for
|
|
1745
|
+
* every Claude-dialect event GSD currently wires (SessionStart, PreToolUse,
|
|
1746
|
+
* PostToolUse, Stop, PreCompact, SubagentStart, SubagentStop) — see
|
|
1747
|
+
* moonshotai.github.io/kimi-cli/en/customization/hooks.html.
|
|
1748
|
+
*
|
|
1749
|
+
* Matcher translation (best-effort — Kimi's tool-name vocabulary is
|
|
1750
|
+
* confirmed distinct from Claude's by the upstream hooks doc's own examples):
|
|
1751
|
+
* Bash -> Shell, Write -> WriteFile, Edit/MultiEdit -> StrReplaceFile.
|
|
1752
|
+
* Read -> ReadFile follows the same WriteFile/StrReplaceFile naming
|
|
1753
|
+
* convention but is not independently doc-confirmed. Claude's Agent|Task
|
|
1754
|
+
* (subagent-dispatch) matcher segment has no confirmed Kimi tool name and is
|
|
1755
|
+
* dropped rather than guessed — gsd-context-monitor's PostToolUse entry runs
|
|
1756
|
+
* unmatched (all tools) instead, which only widens when it fires, it never
|
|
1757
|
+
* narrows incorrectly.
|
|
1758
|
+
*/
|
|
1759
|
+
function buildKimiHooksTomlBlock(targetDir, opts) {
|
|
1760
|
+
const { hookOpts } = opts;
|
|
1761
|
+
const cmd = (hookName) => {
|
|
1762
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.join(targetDir, 'hooks', hookName)))
|
|
1763
|
+
return null;
|
|
1764
|
+
return buildHookCommand(targetDir, hookName, hookOpts);
|
|
1765
|
+
};
|
|
1766
|
+
const specs = [
|
|
1767
|
+
// SessionStart — unmatched (session-level; no tool_name to filter on).
|
|
1768
|
+
{ event: 'SessionStart', command: cmd('gsd-check-update.js') },
|
|
1769
|
+
{ event: 'SessionStart', command: cmd('gsd-session-state.sh') },
|
|
1770
|
+
// PreToolUse
|
|
1771
|
+
{ event: 'PreToolUse', command: cmd('gsd-prompt-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
|
|
1772
|
+
{ event: 'PreToolUse', command: cmd('gsd-read-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
|
|
1773
|
+
{ event: 'PreToolUse', command: cmd('gsd-worktree-path-guard.js'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
|
|
1774
|
+
{ event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: 5 },
|
|
1775
|
+
{ event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: 5 },
|
|
1776
|
+
// PostToolUse
|
|
1777
|
+
{ event: 'PostToolUse', command: cmd('gsd-context-monitor.js'), timeout: 10 },
|
|
1778
|
+
{ event: 'PostToolUse', command: cmd('gsd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
|
|
1779
|
+
{ event: 'PostToolUse', command: cmd('gsd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
|
|
1780
|
+
{ event: 'PostToolUse', command: cmd('gsd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
|
|
1781
|
+
// Extended lifecycle events — context-headroom tracking (unmatched).
|
|
1782
|
+
{ event: 'Stop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
|
|
1783
|
+
{ event: 'PreCompact', command: cmd('gsd-context-monitor.js'), timeout: 10 },
|
|
1784
|
+
{ event: 'SubagentStart', command: cmd('gsd-context-monitor.js'), timeout: 10 },
|
|
1785
|
+
{ event: 'SubagentStop', command: cmd('gsd-context-monitor.js'), timeout: 10 },
|
|
1786
|
+
];
|
|
1787
|
+
const entries = specs
|
|
1788
|
+
.map(buildKimiHookEntryToml)
|
|
1789
|
+
.filter((entry) => entry !== null);
|
|
1790
|
+
if (entries.length === 0)
|
|
1791
|
+
return null;
|
|
1792
|
+
return [KIMI_HOOKS_TOML_MARKER_BEGIN, '', entries.join('\n\n'), '', KIMI_HOOKS_TOML_MARKER_END].join('\n');
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* Strip a previously-written GSD [[hooks]] block from kimi's config.toml
|
|
1796
|
+
* content. Pure string function (no fs access) so install, uninstall, and
|
|
1797
|
+
* tests share one strip implementation. Returns null when stripping leaves
|
|
1798
|
+
* nothing but whitespace (the file was GSD-only), so the caller can unlink
|
|
1799
|
+
* it instead of writing an empty file.
|
|
1800
|
+
*/
|
|
1801
|
+
function stripKimiHooksTomlBlock(content) {
|
|
1802
|
+
const beginIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_BEGIN);
|
|
1803
|
+
if (beginIdx === -1) {
|
|
1804
|
+
return content.trim() === '' ? null : content;
|
|
1805
|
+
}
|
|
1806
|
+
const endMarkerIdx = content.indexOf(KIMI_HOOKS_TOML_MARKER_END, beginIdx);
|
|
1807
|
+
if (endMarkerIdx === -1) {
|
|
1808
|
+
// Malformed marker pair — BEGIN present but no END after it (missing END,
|
|
1809
|
+
// or an END that only appears earlier in the file, before BEGIN). Never
|
|
1810
|
+
// fall back to content.length here: that would slice to EOF and destroy
|
|
1811
|
+
// every user section that follows. Leave the content untouched instead;
|
|
1812
|
+
// a subsequent writeKimiHooksToml call will append a fresh, well-formed
|
|
1813
|
+
// block rather than silently deleting user data.
|
|
1814
|
+
return content;
|
|
1815
|
+
}
|
|
1816
|
+
const endIdx = endMarkerIdx + KIMI_HOOKS_TOML_MARKER_END.length;
|
|
1817
|
+
// Swallow blank lines immediately surrounding the block so repeated
|
|
1818
|
+
// strip+rewrite cycles never accumulate blank lines.
|
|
1819
|
+
let sliceStart = beginIdx;
|
|
1820
|
+
while (sliceStart > 0 && (content[sliceStart - 1] === '\n' || content[sliceStart - 1] === '\r'))
|
|
1821
|
+
sliceStart -= 1;
|
|
1822
|
+
let sliceEnd = endIdx;
|
|
1823
|
+
while (sliceEnd < content.length && (content[sliceEnd] === '\n' || content[sliceEnd] === '\r'))
|
|
1824
|
+
sliceEnd += 1;
|
|
1825
|
+
const before = content.slice(0, sliceStart);
|
|
1826
|
+
const after = content.slice(sliceEnd);
|
|
1827
|
+
// Blank-line swallowing above consumes every newline flanking the block,
|
|
1828
|
+
// including the one required to keep the surrounding user sections on
|
|
1829
|
+
// separate lines. If the block sat BETWEEN two user sections (content
|
|
1830
|
+
// survives on both sides), concatenating `before` + `after` directly would
|
|
1831
|
+
// glue the last line of the earlier section onto the first line of the
|
|
1832
|
+
// later one. Reinsert a blank-line separator in that case; when only one
|
|
1833
|
+
// side has content (block at file start or EOF), no separator is needed —
|
|
1834
|
+
// that matches the pre-existing idempotent behavior for those shapes.
|
|
1835
|
+
const result = before.trim() !== '' && after.trim() !== ''
|
|
1836
|
+
? `${before}\n\n${after}`
|
|
1837
|
+
: before + after;
|
|
1838
|
+
return result.trim() === '' ? null : result;
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Idempotently (re)write kimi's GSD-owned [[hooks]] block into its native
|
|
1842
|
+
* config.toml at `configPath` (resolved by the caller via
|
|
1843
|
+
* resolveKimiHooksTomlDir). No-ops (`{changed:false}`) when the computed
|
|
1844
|
+
* block is byte-identical to what's already on disk, so reinstalls don't
|
|
1845
|
+
* touch the file's mtime for no reason.
|
|
1846
|
+
*/
|
|
1847
|
+
function writeKimiHooksToml(configPath, targetDir, opts) {
|
|
1848
|
+
const existing = node_fs_1.default.existsSync(configPath) ? node_fs_1.default.readFileSync(configPath, 'utf8') : '';
|
|
1849
|
+
const stripped = stripKimiHooksTomlBlock(existing) ?? '';
|
|
1850
|
+
const block = buildKimiHooksTomlBlock(targetDir, opts);
|
|
1851
|
+
const entryCount = block ? (block.match(/\[\[hooks\]\]/g) || []).length : 0;
|
|
1852
|
+
if (!block) {
|
|
1853
|
+
if (stripped === existing)
|
|
1854
|
+
return { changed: false, path: configPath, entryCount: 0 };
|
|
1855
|
+
if (stripped.trim() === '') {
|
|
1856
|
+
if (node_fs_1.default.existsSync(configPath))
|
|
1857
|
+
node_fs_1.default.unlinkSync(configPath);
|
|
1858
|
+
}
|
|
1859
|
+
else {
|
|
1860
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
|
|
1861
|
+
atomicWriteFileSync(configPath, stripped, 'utf8');
|
|
1862
|
+
}
|
|
1863
|
+
return { changed: true, path: configPath, entryCount: 0 };
|
|
1864
|
+
}
|
|
1865
|
+
const separator = stripped.trim() === '' ? '' : (stripped.endsWith('\n') ? '\n' : '\n\n');
|
|
1866
|
+
const next = stripped.trim() === '' ? `${block}\n` : `${stripped}${separator}${block}\n`;
|
|
1867
|
+
if (next === existing)
|
|
1868
|
+
return { changed: false, path: configPath, entryCount };
|
|
1869
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(configPath), { recursive: true });
|
|
1870
|
+
atomicWriteFileSync(configPath, next, 'utf8');
|
|
1871
|
+
return { changed: true, path: configPath, entryCount };
|
|
1872
|
+
}
|
|
1873
|
+
/**
|
|
1874
|
+
* Uninstall-time counterpart to writeKimiHooksToml: strips the GSD block and
|
|
1875
|
+
* deletes the file if nothing but GSD's own block was ever in it.
|
|
1876
|
+
*/
|
|
1877
|
+
function removeKimiHooksToml(configPath) {
|
|
1878
|
+
if (!node_fs_1.default.existsSync(configPath))
|
|
1879
|
+
return { changed: false };
|
|
1880
|
+
const existing = node_fs_1.default.readFileSync(configPath, 'utf8');
|
|
1881
|
+
const stripped = stripKimiHooksTomlBlock(existing);
|
|
1882
|
+
if (stripped === existing)
|
|
1883
|
+
return { changed: false };
|
|
1884
|
+
if (stripped === null || stripped.trim() === '') {
|
|
1885
|
+
node_fs_1.default.unlinkSync(configPath);
|
|
1886
|
+
}
|
|
1887
|
+
else {
|
|
1888
|
+
atomicWriteFileSync(configPath, stripped, 'utf8');
|
|
1889
|
+
}
|
|
1890
|
+
return { changed: true };
|
|
1891
|
+
}
|
|
1892
|
+
// ---------------------------------------------------------------------------
|
|
1415
1893
|
// referencesHook
|
|
1416
1894
|
//
|
|
1417
1895
|
// Pure predicate — checks whether a hook entry object references a managed
|
|
@@ -1458,6 +1936,18 @@ module.exports = {
|
|
|
1458
1936
|
GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT,
|
|
1459
1937
|
GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT,
|
|
1460
1938
|
GSD_CURSOR_HOOK_MARKER,
|
|
1939
|
+
// Windsurf/Cascade
|
|
1940
|
+
buildWindsurfHookEntry,
|
|
1941
|
+
isManagedWindsurfHookEntry,
|
|
1942
|
+
reconcileWindsurfHooksJson,
|
|
1943
|
+
writeWindsurfHooksJson,
|
|
1944
|
+
removeWindsurfHooksJson,
|
|
1945
|
+
WINDSURF_HOOK_EVENTS,
|
|
1946
|
+
WINDSURF_EVENT_SCRIPT_MAP,
|
|
1947
|
+
GSD_WINDSURF_PRE_WRITE_HOOK_SCRIPT,
|
|
1948
|
+
GSD_WINDSURF_PRE_COMMAND_HOOK_SCRIPT,
|
|
1949
|
+
GSD_WINDSURF_HOOK_SCRIPTS,
|
|
1950
|
+
GSD_WINDSURF_HOOK_MARKER,
|
|
1461
1951
|
// Copilot
|
|
1462
1952
|
buildCopilotHookConfig,
|
|
1463
1953
|
writeCopilotHookConfig,
|
|
@@ -1473,6 +1963,13 @@ module.exports = {
|
|
|
1473
1963
|
// Codex TOML
|
|
1474
1964
|
buildCodexHookBlock,
|
|
1475
1965
|
rewriteLegacyCodexHookBlock,
|
|
1966
|
+
// Kimi hooks.toml
|
|
1967
|
+
buildKimiHooksTomlBlock,
|
|
1968
|
+
stripKimiHooksTomlBlock,
|
|
1969
|
+
writeKimiHooksToml,
|
|
1970
|
+
removeKimiHooksToml,
|
|
1971
|
+
KIMI_HOOKS_TOML_MARKER_BEGIN,
|
|
1972
|
+
KIMI_HOOKS_TOML_MARKER_END,
|
|
1476
1973
|
// Shared
|
|
1477
1974
|
buildHookCommand,
|
|
1478
1975
|
applySettingsJsonHooks,
|