@opengsd/gsd-core 1.4.4 → 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/README.md +3 -3
- 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 +17 -16
- package/agents/gsd-intel-updater.md +38 -41
- package/agents/gsd-phase-researcher.md +8 -8
- package/agents/gsd-plan-checker.md +23 -13
- package/agents/gsd-planner.md +32 -188
- 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 +12 -11
- package/bin/install.js +965 -1486
- package/commands/gsd/autonomous.md +5 -1
- package/commands/gsd/ns-manage.md +8 -1
- package/commands/gsd/ns-project.md +5 -0
- package/commands/gsd/ns-review.md +4 -1
- package/commands/gsd/ns-workflow.md +7 -1
- package/commands/gsd/plan-review-convergence.md +5 -4
- package/commands/gsd/surface.md +12 -5
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +198 -101
- package/gsd-core/bin/gsd_run +20 -0
- package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
- package/gsd-core/bin/lib/capability-registry.cjs +2234 -0
- package/gsd-core/bin/lib/capability-state.cjs +336 -0
- package/gsd-core/bin/lib/check-command-router.cjs +133 -2
- package/gsd-core/bin/lib/cli-exit.cjs +22 -3
- package/gsd-core/bin/lib/config-loader.cjs +716 -0
- package/gsd-core/bin/lib/configuration.cjs +4 -34
- package/gsd-core/bin/lib/core-utils.cjs +198 -0
- package/gsd-core/bin/lib/core.cjs +107 -1817
- 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/federated-config.cjs +182 -0
- package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
- package/gsd-core/bin/lib/init.cjs +58 -12
- package/gsd-core/bin/lib/install-profiles.cjs +157 -3
- package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
- package/gsd-core/bin/lib/intel.cjs +3 -3
- package/gsd-core/bin/lib/io.cjs +222 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
- package/gsd-core/bin/lib/model-resolver.cjs +426 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
- package/gsd-core/bin/lib/phase-id.cjs +215 -0
- package/gsd-core/bin/lib/phase-locator.cjs +148 -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/profile-pipeline.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
- package/gsd-core/bin/lib/roadmap.cjs +44 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +92 -95
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +68 -29
- package/gsd-core/bin/lib/runtime-homes.cjs +163 -87
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +2 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
- package/gsd-core/bin/lib/state-document.cjs +8 -0
- package/gsd-core/bin/lib/state.cjs +114 -2
- package/gsd-core/bin/lib/surface.cjs +66 -14
- 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 +104 -1
- 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 +5 -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/references/planner-reviews.md +5 -2
- package/gsd-core/templates/phase-prompt.md +7 -7
- package/gsd-core/templates/project.md +19 -2
- package/gsd-core/templates/spec.md +12 -0
- package/gsd-core/templates/summary-complex.md +1 -0
- package/gsd-core/templates/summary-minimal.md +1 -0
- package/gsd-core/templates/summary-standard.md +1 -0
- package/gsd-core/templates/summary.md +1 -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 +111 -51
- 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 +6 -4
- package/gsd-core/workflows/code-review.md +53 -17
- package/gsd-core/workflows/complete-milestone.md +11 -5
- 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 +8 -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 +4 -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 +8 -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/help/modes/full.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 +65 -28
- package/gsd-core/workflows/plan-review-convergence.md +60 -33
- 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 +2 -2
- 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 +7 -7
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ship.md +8 -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 +8 -1
- package/package.json +11 -3
- package/scripts/base64-scan.sh +1 -1
- package/scripts/changeset/cli.cjs +8 -1
- package/scripts/changeset/lint.cjs +38 -2
- package/scripts/ci-test-scope.cjs +21 -10
- package/scripts/gen-capability-registry.cjs +2293 -0
- package/scripts/gen-loop-host-contract.cjs +471 -0
- package/scripts/lib/allowlist-ratchet.cjs +101 -1
- package/scripts/lint-regression-test-names.allowlist.json +269 -0
- package/scripts/lint-regression-test-names.cjs +117 -0
- package/scripts/lint-test-file-count.allowlist.json +25 -4
- package/scripts/lint-windows-test-portability.cjs +178 -0
- package/scripts/prompt-injection-scan.sh +4 -4
- package/scripts/research-profiles.cjs +10 -10
- package/scripts/run-tests.cjs +133 -29
- package/scripts/secret-scan.sh +3 -3
- 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/scripts/run-cross-platform-tests.cjs +0 -67
|
@@ -0,0 +1,1439 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Runtime Hooks Surface Module — hook-surface writer functions extracted from
|
|
7
|
+
* bin/install.js (ADR-857 phase 5f-1).
|
|
8
|
+
*
|
|
9
|
+
* Owns the lifecycle writer functions for hook surfaces managed by GSD on four
|
|
10
|
+
* runtimes:
|
|
11
|
+
* Cline: writeClineArtifacts + supporting helpers/constants
|
|
12
|
+
* Cursor: buildCursorHookEntry, isManagedCursorHookEntry,
|
|
13
|
+
* reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson
|
|
14
|
+
* Copilot: buildCopilotHookConfig, writeCopilotHookConfig
|
|
15
|
+
* Codex hooks.json: ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent,
|
|
16
|
+
* reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart,
|
|
17
|
+
* removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart,
|
|
18
|
+
* buildCodexHookWindowsShimIR, buildCodexHookBlock, rewriteLegacyCodexHookBlock
|
|
19
|
+
* Shared: buildHookCommand, rewriteLegacyManagedNodeHookCommands
|
|
20
|
+
*
|
|
21
|
+
* BEHAVIOR-PRESERVING RELOCATION: all logic is copied verbatim from
|
|
22
|
+
* bin/install.js. No behavior change, no descriptor reads, no new IO.
|
|
23
|
+
*
|
|
24
|
+
* bin/install.js re-exports every symbol from this module so existing
|
|
25
|
+
* consumers that do require('../bin/install.js').writeCursorHooksJson
|
|
26
|
+
* (etc.) continue to work unchanged.
|
|
27
|
+
*/
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
30
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
31
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
|
+
const shellCmdProjection = require("./shell-command-projection.cjs");
|
|
33
|
+
const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, } = shellCmdProjection;
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Terminal color constants (mirrors install.js for console output parity)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const green = '\x1b[32m';
|
|
38
|
+
const yellow = '\x1b[33m';
|
|
39
|
+
const reset = '\x1b[0m';
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Codex config.toml constants (subset needed by this module)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Copilot hook constants
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
const GSD_COPILOT_HOOK_FILE = 'gsd-session.json';
|
|
47
|
+
const GSD_COPILOT_SESSION_MSG_PRESENT = 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.';
|
|
48
|
+
const GSD_COPILOT_SESSION_MSG_ABSENT = 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.';
|
|
49
|
+
const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' +
|
|
50
|
+
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` +
|
|
51
|
+
`printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`;
|
|
52
|
+
const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' +
|
|
53
|
+
`{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` +
|
|
54
|
+
`else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`;
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Cursor hook constants
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js';
|
|
59
|
+
const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js';
|
|
60
|
+
const GSD_CURSOR_HOOK_MARKER = 'gsd-managed';
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Cline / AGENTS.md constants
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const GSD_AGENTS_MD_MARKER = '<!-- GSD Configuration — managed by gsd-core installer -->';
|
|
65
|
+
const GSD_AGENTS_MD_CLOSE_MARKER = '<!-- End GSD Configuration -->';
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// atomicWriteFileSync — shared canonical implementation.
|
|
68
|
+
//
|
|
69
|
+
// __atomicWrittenTmps is exported so bin/install.js can merge it into its
|
|
70
|
+
// _cleanTmpFiles() scan, ensuring that atomic writes performed by this
|
|
71
|
+
// module (Cursor hooks.json, Codex hooks.json shims) participate in the
|
|
72
|
+
// same temp-file cleanup as writes performed directly by install.js.
|
|
73
|
+
//
|
|
74
|
+
// Every temp path written is recorded in the Set so _cleanTmpFiles() can
|
|
75
|
+
// scope cleanup to files this installer process actually created, avoiding
|
|
76
|
+
// accidental deletion of unrelated tools' temp files.
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
let __atomicWriteCounter = 0;
|
|
79
|
+
// Set<string> — absolute paths of .tmp-<pid>-<n> files this process created.
|
|
80
|
+
const __atomicWrittenTmps = new Set();
|
|
81
|
+
function atomicWriteFileSync(target, data, options) {
|
|
82
|
+
__atomicWriteCounter += 1;
|
|
83
|
+
const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`;
|
|
84
|
+
__atomicWrittenTmps.add(tmp);
|
|
85
|
+
try {
|
|
86
|
+
node_fs_1.default.writeFileSync(tmp, data, options);
|
|
87
|
+
node_fs_1.default.renameSync(tmp, target);
|
|
88
|
+
// Successful rename: the tmp path no longer exists, but leave it in the
|
|
89
|
+
// Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
|
|
90
|
+
// lingers (e.g. a rename succeeded but left a stale entry on some FS).
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
try {
|
|
94
|
+
node_fs_1.default.rmSync(tmp, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch { /* ignore */ }
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// parseTomlValue + findMultilineBasicStringClose
|
|
102
|
+
// (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state)
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
function findMultilineBasicStringClose(line, startIndex) {
|
|
105
|
+
let i = startIndex;
|
|
106
|
+
while (i < line.length) {
|
|
107
|
+
if (line.startsWith('"""', i) && (i === 0 || line[i - 1] !== '\\')) {
|
|
108
|
+
return i;
|
|
109
|
+
}
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
return -1;
|
|
113
|
+
}
|
|
114
|
+
function parseTomlValue(text, i) {
|
|
115
|
+
// Skip leading whitespace.
|
|
116
|
+
while (i < text.length && (text[i] === ' ' || text[i] === '\t')) {
|
|
117
|
+
i += 1;
|
|
118
|
+
}
|
|
119
|
+
if (i >= text.length) {
|
|
120
|
+
throw new Error('expected value, got end of input');
|
|
121
|
+
}
|
|
122
|
+
const ch = text[i];
|
|
123
|
+
// Basic string
|
|
124
|
+
if (ch === '"') {
|
|
125
|
+
if (text.startsWith('"""', i)) {
|
|
126
|
+
const close = findMultilineBasicStringClose(text, i + 3);
|
|
127
|
+
if (close === -1) {
|
|
128
|
+
throw new Error('unterminated multi-line basic string');
|
|
129
|
+
}
|
|
130
|
+
const raw = text.slice(i + 3, close);
|
|
131
|
+
return { value: raw.replace(/^\r?\n/, ''), end: close + 3 };
|
|
132
|
+
}
|
|
133
|
+
let j = i + 1;
|
|
134
|
+
let out = '';
|
|
135
|
+
while (j < text.length) {
|
|
136
|
+
const c = text[j];
|
|
137
|
+
if (c === '\\') {
|
|
138
|
+
const next = text[j + 1];
|
|
139
|
+
if (next === 'n') {
|
|
140
|
+
out += '\n';
|
|
141
|
+
j += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (next === 't') {
|
|
145
|
+
out += '\t';
|
|
146
|
+
j += 2;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (next === 'r') {
|
|
150
|
+
out += '\r';
|
|
151
|
+
j += 2;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (next === '\\') {
|
|
155
|
+
out += '\\';
|
|
156
|
+
j += 2;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (next === '"') {
|
|
160
|
+
out += '"';
|
|
161
|
+
j += 2;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (next === '/') {
|
|
165
|
+
out += '/';
|
|
166
|
+
j += 2;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
out += next === undefined ? '' : next;
|
|
170
|
+
j += 2;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (c === '"') {
|
|
174
|
+
return { value: out, end: j + 1 };
|
|
175
|
+
}
|
|
176
|
+
out += c;
|
|
177
|
+
j += 1;
|
|
178
|
+
}
|
|
179
|
+
throw new Error('unterminated basic string');
|
|
180
|
+
}
|
|
181
|
+
// Literal string
|
|
182
|
+
if (ch === '\'') {
|
|
183
|
+
if (text.startsWith("'''", i)) {
|
|
184
|
+
const close = text.indexOf("'''", i + 3);
|
|
185
|
+
if (close === -1)
|
|
186
|
+
throw new Error('unterminated multi-line literal string');
|
|
187
|
+
return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 };
|
|
188
|
+
}
|
|
189
|
+
const close = text.indexOf('\'', i + 1);
|
|
190
|
+
if (close === -1)
|
|
191
|
+
throw new Error('unterminated literal string');
|
|
192
|
+
return { value: text.slice(i + 1, close), end: close + 1 };
|
|
193
|
+
}
|
|
194
|
+
// Boolean
|
|
195
|
+
if (text.startsWith('true', i))
|
|
196
|
+
return { value: true, end: i + 4 };
|
|
197
|
+
if (text.startsWith('false', i))
|
|
198
|
+
return { value: false, end: i + 5 };
|
|
199
|
+
// Number (integer or float, simplified)
|
|
200
|
+
const numMatch = text.slice(i).match(/^[+-]?(?:0x[0-9a-fA-F_]+|0o[0-7_]+|0b[01_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?|inf|nan)/);
|
|
201
|
+
if (numMatch) {
|
|
202
|
+
const raw = numMatch[0];
|
|
203
|
+
const cleaned = raw.replace(/_/g, '');
|
|
204
|
+
const num = Number(cleaned);
|
|
205
|
+
return { value: isNaN(num) ? cleaned : num, end: i + raw.length };
|
|
206
|
+
}
|
|
207
|
+
// Datetime (simplified passthrough)
|
|
208
|
+
const dtMatch = text.slice(i).match(/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?/);
|
|
209
|
+
if (dtMatch) {
|
|
210
|
+
return { value: dtMatch[0], end: i + dtMatch[0].length };
|
|
211
|
+
}
|
|
212
|
+
throw new Error(`parseTomlValue: unexpected character '${ch}' at position ${i}`);
|
|
213
|
+
}
|
|
214
|
+
function normalizeNodePath(execPath, opts) {
|
|
215
|
+
if (!execPath)
|
|
216
|
+
return execPath;
|
|
217
|
+
const env = (opts && opts.env) || process.env;
|
|
218
|
+
const existsSync = (opts && opts.existsSync) || node_fs_1.default.existsSync;
|
|
219
|
+
const normalizedForMatch = execPath.replace(/\\/g, '/');
|
|
220
|
+
if (/\/fnm_multishells\/[0-9]+_[0-9]+\/node(\.exe)?$/i.test(normalizedForMatch)) {
|
|
221
|
+
const candidates = [];
|
|
222
|
+
if (env.FNM_DIR) {
|
|
223
|
+
candidates.push(`${env.FNM_DIR}/aliases/default/node.exe`);
|
|
224
|
+
candidates.push(`${env.FNM_DIR}/aliases/default/bin/node`);
|
|
225
|
+
}
|
|
226
|
+
if (env.APPDATA) {
|
|
227
|
+
candidates.push(`${env.APPDATA}/fnm/aliases/default/node.exe`);
|
|
228
|
+
}
|
|
229
|
+
for (const candidate of candidates) {
|
|
230
|
+
if (candidate && existsSync(candidate))
|
|
231
|
+
return candidate;
|
|
232
|
+
}
|
|
233
|
+
return execPath;
|
|
234
|
+
}
|
|
235
|
+
if (/^\/usr\/local\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
236
|
+
return '/usr/local/bin/node';
|
|
237
|
+
}
|
|
238
|
+
if (/^\/opt\/homebrew\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/.test(execPath)) {
|
|
239
|
+
return '/opt/homebrew/bin/node';
|
|
240
|
+
}
|
|
241
|
+
return execPath;
|
|
242
|
+
}
|
|
243
|
+
function resolveNodeRunner(opts) {
|
|
244
|
+
const execPath = typeof process.execPath === 'string' ? process.execPath : '';
|
|
245
|
+
if (!execPath)
|
|
246
|
+
return null;
|
|
247
|
+
const stablePath = normalizeNodePath(execPath, opts);
|
|
248
|
+
return JSON.stringify(stablePath.replace(/\\/g, '/'));
|
|
249
|
+
}
|
|
250
|
+
function resolveBashRunner(opts) {
|
|
251
|
+
const platform = (opts && opts.platform) || process.platform;
|
|
252
|
+
if (platform !== 'win32')
|
|
253
|
+
return 'bash';
|
|
254
|
+
const env = (opts && opts.env) || process.env;
|
|
255
|
+
const exists = (opts && opts.existsSync) || node_fs_1.default.existsSync;
|
|
256
|
+
const candidates = [];
|
|
257
|
+
if (env.GSD_BASH_PATH)
|
|
258
|
+
candidates.push(env.GSD_BASH_PATH);
|
|
259
|
+
if (env.ProgramFiles)
|
|
260
|
+
candidates.push(node_path_1.default.win32.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe'));
|
|
261
|
+
if (env['ProgramFiles(x86)'])
|
|
262
|
+
candidates.push(node_path_1.default.win32.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'));
|
|
263
|
+
if (env.SystemDrive) {
|
|
264
|
+
candidates.push(node_path_1.default.win32.join(env.SystemDrive, 'Program Files', 'Git', 'bin', 'bash.exe'));
|
|
265
|
+
candidates.push(node_path_1.default.win32.join(env.SystemDrive, 'Program Files (x86)', 'Git', 'bin', 'bash.exe'));
|
|
266
|
+
}
|
|
267
|
+
for (const candidate of candidates) {
|
|
268
|
+
if (candidate && exists(candidate)) {
|
|
269
|
+
return JSON.stringify(candidate.replace(/\\/g, '/'));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
function rewriteLegacyManagedNodeHookCommands(settings, absoluteRunner, opts) {
|
|
275
|
+
if (!settings || !settings.hooks || !absoluteRunner)
|
|
276
|
+
return false;
|
|
277
|
+
if (!opts)
|
|
278
|
+
opts = {};
|
|
279
|
+
const platform = opts.platform || process.platform;
|
|
280
|
+
let changed = false;
|
|
281
|
+
for (const entries of Object.values(settings.hooks)) {
|
|
282
|
+
if (!Array.isArray(entries))
|
|
283
|
+
continue;
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (!entry || !Array.isArray(entry.hooks))
|
|
286
|
+
continue;
|
|
287
|
+
for (const h of entry.hooks) {
|
|
288
|
+
if (!h || typeof h.command !== 'string')
|
|
289
|
+
continue;
|
|
290
|
+
if (Array.isArray(h.args) && h.args.length > 0)
|
|
291
|
+
continue;
|
|
292
|
+
let trimmed = h.command.trim();
|
|
293
|
+
const hadPowerShellCallOperator = platform === 'win32' && /^&\s+/.test(trimmed);
|
|
294
|
+
if (hadPowerShellCallOperator) {
|
|
295
|
+
trimmed = trimmed.replace(/^&\s+/, '').trim();
|
|
296
|
+
}
|
|
297
|
+
const m = trimmed.match(/^node\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/) ||
|
|
298
|
+
trimmed.match(/^("([^"]+)"|'([^']+)'|(\S+))\s+("([^"]+)"|'([^']+)'|(\S+))\s*$/);
|
|
299
|
+
if (!m)
|
|
300
|
+
continue;
|
|
301
|
+
let _runnerToken, scriptToken, scriptPath;
|
|
302
|
+
if (/^node\s+/.test(trimmed)) {
|
|
303
|
+
_runnerToken = 'node';
|
|
304
|
+
scriptToken = m[1];
|
|
305
|
+
scriptPath = m[2] || m[3] || m[4] || '';
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
_runnerToken = m[1];
|
|
309
|
+
const runnerPath = (m[2] || m[3] || m[4] || '').replace(/\\/g, '/');
|
|
310
|
+
const stableRunner = normalizeNodePath(runnerPath);
|
|
311
|
+
if (stableRunner === runnerPath && platform !== 'win32')
|
|
312
|
+
continue;
|
|
313
|
+
scriptToken = m[5];
|
|
314
|
+
scriptPath = m[6] || m[7] || m[8] || '';
|
|
315
|
+
}
|
|
316
|
+
if (!isManagedHookBasename(scriptPath, { surface: 'settings-json' }))
|
|
317
|
+
continue;
|
|
318
|
+
const projectedCommand = projectLegacySettingsHookCommand({
|
|
319
|
+
absoluteRunner,
|
|
320
|
+
scriptPath,
|
|
321
|
+
scriptToken,
|
|
322
|
+
runtime: opts.runtime || 'generic',
|
|
323
|
+
platform,
|
|
324
|
+
});
|
|
325
|
+
if (!projectedCommand)
|
|
326
|
+
continue;
|
|
327
|
+
if (h.command === projectedCommand)
|
|
328
|
+
continue;
|
|
329
|
+
h.command = projectedCommand;
|
|
330
|
+
changed = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return changed;
|
|
335
|
+
}
|
|
336
|
+
function buildCodexHookBlock(targetDir, opts) {
|
|
337
|
+
const absoluteRunner = opts && opts.absoluteRunner;
|
|
338
|
+
if (!absoluteRunner)
|
|
339
|
+
return null;
|
|
340
|
+
const eol = (opts && opts.eol) || '\n';
|
|
341
|
+
const platform = (opts && opts.platform) || process.platform;
|
|
342
|
+
const updateCheckScript = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js');
|
|
343
|
+
const commandValue = projectCodexHookTomlCommand({
|
|
344
|
+
absoluteRunner,
|
|
345
|
+
scriptPath: updateCheckScript,
|
|
346
|
+
platform,
|
|
347
|
+
});
|
|
348
|
+
return `${eol}# GSD Hooks${eol}` +
|
|
349
|
+
`[[hooks.SessionStart]]${eol}` +
|
|
350
|
+
`${eol}` +
|
|
351
|
+
`[[hooks.SessionStart.hooks]]${eol}` +
|
|
352
|
+
`type = "command"${eol}` +
|
|
353
|
+
`command = "${commandValue}"${eol}`;
|
|
354
|
+
}
|
|
355
|
+
function rewriteLegacyCodexHookBlock(content, absoluteRunner, opts) {
|
|
356
|
+
if (!content || !absoluteRunner)
|
|
357
|
+
return { content, changed: false };
|
|
358
|
+
const platform = (opts && opts.platform) || process.platform;
|
|
359
|
+
let changed = false;
|
|
360
|
+
const updated = content.replace(/^(command\s*=\s*")node\s+((?:\\"[^"]+\\"|\S+))("\s*)$/gm, (full, prefix, scriptToken, suffix) => {
|
|
361
|
+
const quoted = scriptToken.match(/^\\"([\s\S]+)\\"$/);
|
|
362
|
+
let scriptPath = scriptToken;
|
|
363
|
+
if (quoted) {
|
|
364
|
+
try {
|
|
365
|
+
scriptPath = String(parseTomlValue(`"${quoted[1]}"`, 0).value);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
scriptPath = quoted[1];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (!isManagedHookBasename(scriptPath, { surface: 'codex-toml' }))
|
|
372
|
+
return full;
|
|
373
|
+
const desiredCommand = projectCodexHookTomlCommand({
|
|
374
|
+
absoluteRunner,
|
|
375
|
+
scriptPath,
|
|
376
|
+
platform,
|
|
377
|
+
});
|
|
378
|
+
const currentCommand = `${prefix}${scriptToken}${suffix}`.replace(/^(command\s*=\s*")|("\s*)$/g, '');
|
|
379
|
+
if (currentCommand === desiredCommand)
|
|
380
|
+
return full;
|
|
381
|
+
changed = true;
|
|
382
|
+
return `${prefix}${desiredCommand}${suffix}`;
|
|
383
|
+
});
|
|
384
|
+
return { content: updated, changed };
|
|
385
|
+
}
|
|
386
|
+
function reconcileCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
387
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
388
|
+
const managedCommand = typeof opts.managedCommand === 'string' ? opts.managedCommand : null;
|
|
389
|
+
const commandWindows = typeof opts.commandWindows === 'string' ? opts.commandWindows : null;
|
|
390
|
+
const matcher = typeof opts.matcher === 'string' ? opts.matcher : undefined;
|
|
391
|
+
const timeout = typeof opts.timeout === 'number' ? opts.timeout : undefined;
|
|
392
|
+
let parsed = {};
|
|
393
|
+
let currentContent = null;
|
|
394
|
+
if (node_fs_1.default.existsSync(hooksJsonPath)) {
|
|
395
|
+
const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
|
|
396
|
+
currentContent = raw;
|
|
397
|
+
if (raw.trim()) {
|
|
398
|
+
try {
|
|
399
|
+
parsed = JSON.parse(raw);
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
throw new Error(`hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
407
|
+
parsed = {};
|
|
408
|
+
const usesNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
|
409
|
+
const hookTable = usesNestedHooksObject ? parsed['hooks'] : parsed;
|
|
410
|
+
const eventEntries = Array.isArray(hookTable[eventName]) ? hookTable[eventName] : [];
|
|
411
|
+
let removedLegacy = false;
|
|
412
|
+
const sanitizedEntries = [];
|
|
413
|
+
for (const entry of eventEntries) {
|
|
414
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
415
|
+
continue;
|
|
416
|
+
const entryObj = entry;
|
|
417
|
+
const originalHooks = Array.isArray(entryObj['hooks']) ? entryObj['hooks'] : [];
|
|
418
|
+
if (originalHooks.length === 0) {
|
|
419
|
+
sanitizedEntries.push(entry);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const keptHooks = originalHooks.filter((hook) => {
|
|
423
|
+
const cmd = hook && typeof hook === 'object' ? hook['command'] : null;
|
|
424
|
+
const managed = isManagedHookCommand(cmd, {
|
|
425
|
+
surface: 'codex-hooks-json',
|
|
426
|
+
includeLegacyAliases: true,
|
|
427
|
+
configDir: targetDir,
|
|
428
|
+
});
|
|
429
|
+
if (managed)
|
|
430
|
+
removedLegacy = true;
|
|
431
|
+
return !managed;
|
|
432
|
+
});
|
|
433
|
+
if (keptHooks.length === 0)
|
|
434
|
+
continue;
|
|
435
|
+
const nextEntry = { ...entryObj, hooks: keptHooks };
|
|
436
|
+
sanitizedEntries.push(nextEntry);
|
|
437
|
+
}
|
|
438
|
+
if (managedCommand) {
|
|
439
|
+
const hookEntry = { type: 'command', command: managedCommand };
|
|
440
|
+
if (commandWindows)
|
|
441
|
+
hookEntry['commandWindows'] = commandWindows;
|
|
442
|
+
if (timeout !== undefined)
|
|
443
|
+
hookEntry['timeout'] = timeout;
|
|
444
|
+
const newEntry = { hooks: [hookEntry] };
|
|
445
|
+
if (matcher !== undefined)
|
|
446
|
+
newEntry['matcher'] = matcher;
|
|
447
|
+
sanitizedEntries.push(newEntry);
|
|
448
|
+
}
|
|
449
|
+
if (sanitizedEntries.length > 0) {
|
|
450
|
+
hookTable[eventName] = sanitizedEntries;
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
delete hookTable[eventName];
|
|
454
|
+
}
|
|
455
|
+
if (usesNestedHooksObject)
|
|
456
|
+
parsed['hooks'] = hookTable;
|
|
457
|
+
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
458
|
+
const changed = currentContent !== nextContent;
|
|
459
|
+
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
460
|
+
if (shouldWrite) {
|
|
461
|
+
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
462
|
+
}
|
|
463
|
+
return { changed: changed || removedLegacy, wrote: shouldWrite, path: hooksJsonPath };
|
|
464
|
+
}
|
|
465
|
+
function reconcileCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
466
|
+
return reconcileCodexHooksJsonEvent(targetDir, 'SessionStart', opts);
|
|
467
|
+
}
|
|
468
|
+
function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
|
|
469
|
+
if (!absoluteRunnerToken)
|
|
470
|
+
return null;
|
|
471
|
+
let interpreter;
|
|
472
|
+
try {
|
|
473
|
+
interpreter = JSON.parse(absoluteRunnerToken);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
interpreter = absoluteRunnerToken;
|
|
477
|
+
}
|
|
478
|
+
const targetAbs = scriptAbsPath.replace(/\\/g, '/');
|
|
479
|
+
const scriptQuoted = JSON.stringify(targetAbs);
|
|
480
|
+
const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
|
|
481
|
+
const hookCommand = JSON.stringify(cmdPath.replace(/\\/g, '/'));
|
|
482
|
+
const runnerQuoted = JSON.stringify(interpreter);
|
|
483
|
+
return {
|
|
484
|
+
invocation: { interpreter, target: scriptAbsPath },
|
|
485
|
+
cmdPath,
|
|
486
|
+
hookCommand,
|
|
487
|
+
eol: { cmd: '\r\n' },
|
|
488
|
+
passthroughArgs: true,
|
|
489
|
+
render: {
|
|
490
|
+
cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function ensureCodexHooksJsonSessionStart(targetDir, opts = {}) {
|
|
495
|
+
const platform = opts.platform || process.platform;
|
|
496
|
+
const absoluteRunner = opts.absoluteRunner || null;
|
|
497
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
498
|
+
if (!absoluteRunner)
|
|
499
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
500
|
+
const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/');
|
|
501
|
+
const cmdShimPath = scriptPath.replace(/\.js$/, '.cmd');
|
|
502
|
+
let managedCommand;
|
|
503
|
+
if (platform === 'win32') {
|
|
504
|
+
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
505
|
+
if (!shimIR)
|
|
506
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
507
|
+
try {
|
|
508
|
+
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
509
|
+
}
|
|
510
|
+
catch (shimWriteErr) {
|
|
511
|
+
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
512
|
+
console.warn(` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed: ${reason}. ` +
|
|
513
|
+
`Fix the write error (permissions? disk full?) and re-run the installer. ` +
|
|
514
|
+
`Do NOT use the legacy node.exe command path — it triggers the #3426 bash.exe POSIX-exec failure.`);
|
|
515
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
516
|
+
}
|
|
517
|
+
managedCommand = shimIR.hookCommand;
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
managedCommand = projectManagedHookCommand({
|
|
521
|
+
absoluteRunner,
|
|
522
|
+
scriptPath,
|
|
523
|
+
runtime: 'codex',
|
|
524
|
+
platform,
|
|
525
|
+
}) ?? undefined;
|
|
526
|
+
}
|
|
527
|
+
if (!managedCommand)
|
|
528
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
529
|
+
const commandWindows = platform === 'win32'
|
|
530
|
+
? JSON.stringify(cmdShimPath.replace(/\\/g, '/'))
|
|
531
|
+
: undefined;
|
|
532
|
+
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand, commandWindows });
|
|
533
|
+
}
|
|
534
|
+
function ensureCodexHooksJsonEvent(targetDir, eventName, opts = {}) {
|
|
535
|
+
const platform = opts.platform || process.platform;
|
|
536
|
+
const absoluteRunner = opts.absoluteRunner || null;
|
|
537
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
538
|
+
if (!absoluteRunner)
|
|
539
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
540
|
+
const scriptPath = node_path_1.default.resolve(targetDir, 'hooks', 'gsd-context-monitor.js').replace(/\\/g, '/');
|
|
541
|
+
let managedCommand;
|
|
542
|
+
if (platform === 'win32') {
|
|
543
|
+
const shimIR = buildCodexHookWindowsShimIR(scriptPath, absoluteRunner);
|
|
544
|
+
if (!shimIR)
|
|
545
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
546
|
+
try {
|
|
547
|
+
atomicWriteFileSync(shimIR.cmdPath, shimIR.render.cmd(), 'utf8');
|
|
548
|
+
}
|
|
549
|
+
catch (shimWriteErr) {
|
|
550
|
+
const reason = shimWriteErr && shimWriteErr.message ? shimWriteErr.message : String(shimWriteErr);
|
|
551
|
+
console.warn(` ${yellow}⚠${reset} Codex Windows hook NOT installed — .cmd shim write failed for ${eventName}: ${reason}. ` +
|
|
552
|
+
`Fix the write error (permissions? disk full?) and re-run the installer.`);
|
|
553
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
554
|
+
}
|
|
555
|
+
managedCommand = shimIR.hookCommand;
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
managedCommand = projectManagedHookCommand({
|
|
559
|
+
absoluteRunner,
|
|
560
|
+
scriptPath,
|
|
561
|
+
runtime: 'codex',
|
|
562
|
+
platform,
|
|
563
|
+
}) ?? undefined;
|
|
564
|
+
}
|
|
565
|
+
if (!managedCommand)
|
|
566
|
+
return { changed: false, wrote: false, path: hooksJsonPath };
|
|
567
|
+
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand, timeout: 10 });
|
|
568
|
+
}
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// removeCodexHooksJsonEvent / removeCodexHooksJsonSessionStart
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
function removeCodexHooksJsonEvent(targetDir, eventName) {
|
|
573
|
+
return reconcileCodexHooksJsonEvent(targetDir, eventName, { managedCommand: null });
|
|
574
|
+
}
|
|
575
|
+
function removeCodexHooksJsonSessionStart(targetDir) {
|
|
576
|
+
return reconcileCodexHooksJsonSessionStart(targetDir, { managedCommand: null });
|
|
577
|
+
}
|
|
578
|
+
function buildHookCommand(configDir, hookName, opts) {
|
|
579
|
+
if (!opts)
|
|
580
|
+
opts = {};
|
|
581
|
+
const platform = opts.platform || process.platform;
|
|
582
|
+
const runtime = opts.runtime || 'generic';
|
|
583
|
+
const isShellHook = hookName.endsWith('.sh');
|
|
584
|
+
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook })) {
|
|
585
|
+
if (opts.portableHooks) {
|
|
586
|
+
const portableBaseDir = projectPortableHookBaseDir({
|
|
587
|
+
configDir,
|
|
588
|
+
homeDir: node_os_1.default.homedir(),
|
|
589
|
+
});
|
|
590
|
+
return JSON.stringify(`${portableBaseDir}/hooks/${hookName}`);
|
|
591
|
+
}
|
|
592
|
+
return JSON.stringify(configDir.replace(/\\/g, '/') + '/hooks/' + hookName);
|
|
593
|
+
}
|
|
594
|
+
const nodeRunner = resolveNodeRunner();
|
|
595
|
+
const runner = isShellHook ? resolveBashRunner(opts) : nodeRunner;
|
|
596
|
+
if (runner === null)
|
|
597
|
+
return null;
|
|
598
|
+
if (opts.portableHooks) {
|
|
599
|
+
const portableBaseDir = projectPortableHookBaseDir({
|
|
600
|
+
configDir,
|
|
601
|
+
homeDir: node_os_1.default.homedir(),
|
|
602
|
+
});
|
|
603
|
+
return projectManagedHookCommand({
|
|
604
|
+
absoluteRunner: runner,
|
|
605
|
+
scriptPath: `${portableBaseDir}/hooks/${hookName}`,
|
|
606
|
+
runtime: opts.runtime || 'generic',
|
|
607
|
+
platform,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
const hooksPath = configDir.replace(/\\/g, '/') + '/hooks/' + hookName;
|
|
611
|
+
return projectManagedHookCommand({
|
|
612
|
+
absoluteRunner: runner,
|
|
613
|
+
scriptPath: hooksPath,
|
|
614
|
+
runtime,
|
|
615
|
+
platform,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
// Cline helpers
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
function buildClineRulesBody() {
|
|
622
|
+
return [
|
|
623
|
+
'# GSD Core — Git. Ship. Done.',
|
|
624
|
+
'',
|
|
625
|
+
'- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when',
|
|
626
|
+
' the user runs a `/gsd-*` command.',
|
|
627
|
+
'- GSD agents live in `agents/`. Use the matching agent when spawning subagents.',
|
|
628
|
+
'- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.',
|
|
629
|
+
'- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.',
|
|
630
|
+
'- Do not apply GSD workflows unless the user explicitly asks for them.',
|
|
631
|
+
'- When a GSD command triggers a deliverable (feature, fix, docs), offer the next',
|
|
632
|
+
' step to the user using Cline\'s ask_user tool after completing it.',
|
|
633
|
+
].join('\n') + '\n';
|
|
634
|
+
}
|
|
635
|
+
function buildClineAgentsMdBody() {
|
|
636
|
+
return buildClineRulesBody();
|
|
637
|
+
}
|
|
638
|
+
function buildClinePreToolUseHook() {
|
|
639
|
+
return `#!/usr/bin/env node
|
|
640
|
+
'use strict';
|
|
641
|
+
/* GSD-managed Cline PreToolUse hook — gsd-core issue #787.
|
|
642
|
+
* Protocol: JSON on stdin -> JSON decision on stdout.
|
|
643
|
+
* Honored fields: { cancel, errorMessage, contextModification }.
|
|
644
|
+
* Fails open: any error allows the operation. */
|
|
645
|
+
let raw = '';
|
|
646
|
+
process.stdin.setEncoding('utf8');
|
|
647
|
+
process.stdin.on('data', (c) => { raw += c; });
|
|
648
|
+
process.stdin.on('end', () => {
|
|
649
|
+
const allow = () => process.stdout.write(JSON.stringify({ cancel: false }));
|
|
650
|
+
let input;
|
|
651
|
+
try { input = JSON.parse(raw || '{}'); } catch { return allow(); }
|
|
652
|
+
try {
|
|
653
|
+
const tool = String(
|
|
654
|
+
input.toolName || input.tool_name || input.tool ||
|
|
655
|
+
(input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || ''
|
|
656
|
+
).toLowerCase();
|
|
657
|
+
const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool);
|
|
658
|
+
// Collect only PATH-bearing field values (not free-form content), so a doc
|
|
659
|
+
// that merely mentions ".planning/" in its body is never falsely blocked.
|
|
660
|
+
const paths = [];
|
|
661
|
+
const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
|
|
662
|
+
const walk = (v, depth) => {
|
|
663
|
+
if (depth > 5 || paths.length > 64) return;
|
|
664
|
+
if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
|
|
665
|
+
if (v && typeof v === 'object') {
|
|
666
|
+
for (const k of Object.keys(v)) {
|
|
667
|
+
const val = v[k];
|
|
668
|
+
if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val);
|
|
669
|
+
else walk(val, depth + 1);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
walk(input, 0);
|
|
674
|
+
const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s);
|
|
675
|
+
if (isWrite && paths.some(isPlanningPath)) {
|
|
676
|
+
return process.stdout.write(JSON.stringify({
|
|
677
|
+
cancel: true,
|
|
678
|
+
errorMessage:
|
|
679
|
+
'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.',
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
682
|
+
} catch { /* fall through to allow */ }
|
|
683
|
+
return allow();
|
|
684
|
+
});
|
|
685
|
+
`;
|
|
686
|
+
}
|
|
687
|
+
function mergeGsdAgentsMd(filePath, gsdContent) {
|
|
688
|
+
const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER;
|
|
689
|
+
if (!node_fs_1.default.existsSync(filePath)) {
|
|
690
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
691
|
+
node_fs_1.default.writeFileSync(filePath, gsdBlock + '\n');
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const existing = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
695
|
+
const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER);
|
|
696
|
+
const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER);
|
|
697
|
+
if (openIndex !== -1 && closeIndex !== -1) {
|
|
698
|
+
const before = existing.substring(0, openIndex).trimEnd();
|
|
699
|
+
const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart();
|
|
700
|
+
let newContent = '';
|
|
701
|
+
if (before)
|
|
702
|
+
newContent += before + '\n\n';
|
|
703
|
+
newContent += gsdBlock;
|
|
704
|
+
if (after)
|
|
705
|
+
newContent += '\n\n' + after;
|
|
706
|
+
newContent += '\n';
|
|
707
|
+
node_fs_1.default.writeFileSync(filePath, newContent);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
node_fs_1.default.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n');
|
|
711
|
+
}
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// writeClineArtifacts
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
function writeClineArtifacts(targetDir, isGlobalInstall) {
|
|
716
|
+
const written = [];
|
|
717
|
+
const clinerulesDir = node_path_1.default.join(targetDir, '.clinerules');
|
|
718
|
+
try {
|
|
719
|
+
if (node_fs_1.default.existsSync(clinerulesDir)) {
|
|
720
|
+
const st = node_fs_1.default.lstatSync(clinerulesDir);
|
|
721
|
+
if (st.isFile() || st.isSymbolicLink()) {
|
|
722
|
+
node_fs_1.default.unlinkSync(clinerulesDir);
|
|
723
|
+
console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
catch { /* best-effort migration */ }
|
|
728
|
+
node_fs_1.default.mkdirSync(clinerulesDir, { recursive: true });
|
|
729
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(clinerulesDir, 'gsd.md'), buildClineRulesBody());
|
|
730
|
+
written.push('.clinerules/gsd.md');
|
|
731
|
+
console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`);
|
|
732
|
+
const hooksDir = node_path_1.default.join(clinerulesDir, 'hooks');
|
|
733
|
+
node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
|
|
734
|
+
const hookPath = node_path_1.default.join(hooksDir, 'PreToolUse');
|
|
735
|
+
node_fs_1.default.writeFileSync(hookPath, buildClinePreToolUseHook());
|
|
736
|
+
try {
|
|
737
|
+
node_fs_1.default.chmodSync(hookPath, 0o755);
|
|
738
|
+
}
|
|
739
|
+
catch { /* Windows: hooks unsupported anyway */ }
|
|
740
|
+
written.push('.clinerules/hooks/PreToolUse');
|
|
741
|
+
console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`);
|
|
742
|
+
if (isGlobalInstall) {
|
|
743
|
+
try {
|
|
744
|
+
const agentsPath = node_path_1.default.join(node_os_1.default.homedir(), '.agents', 'AGENTS.md');
|
|
745
|
+
mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody());
|
|
746
|
+
console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`);
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
return written;
|
|
753
|
+
}
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
// Cursor hook functions
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
function buildCursorHookEntry(scriptPath) {
|
|
758
|
+
return {
|
|
759
|
+
type: 'command',
|
|
760
|
+
command: scriptPath.replace(/\\/g, '/'),
|
|
761
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
function isManagedCursorHookEntry(entry) {
|
|
765
|
+
return Boolean(entry && typeof entry === 'object' && entry[GSD_CURSOR_HOOK_MARKER]);
|
|
766
|
+
}
|
|
767
|
+
function reconcileCursorHooksJson(hooksJsonPath, managedEntries) {
|
|
768
|
+
let parsed = {};
|
|
769
|
+
let currentContent = null;
|
|
770
|
+
if (node_fs_1.default.existsSync(hooksJsonPath)) {
|
|
771
|
+
const raw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
|
|
772
|
+
currentContent = raw;
|
|
773
|
+
if (raw.trim()) {
|
|
774
|
+
try {
|
|
775
|
+
parsed = JSON.parse(raw);
|
|
776
|
+
}
|
|
777
|
+
catch (err) {
|
|
778
|
+
throw new Error(`Cursor hooks.json parse failed: ${err && err.message ? err.message : String(err)}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
783
|
+
parsed = {};
|
|
784
|
+
const hasNestedHooksObject = parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']);
|
|
785
|
+
if (!hasNestedHooksObject) {
|
|
786
|
+
const eventKeys = ['sessionStart', 'postToolUse'];
|
|
787
|
+
const lifted = {};
|
|
788
|
+
for (const k of eventKeys) {
|
|
789
|
+
if (Array.isArray(parsed[k])) {
|
|
790
|
+
lifted[k] = parsed[k];
|
|
791
|
+
delete parsed[k];
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
parsed['hooks'] = lifted;
|
|
795
|
+
}
|
|
796
|
+
if (!parsed['version'])
|
|
797
|
+
parsed['version'] = 1;
|
|
798
|
+
const hookTable = parsed['hooks'];
|
|
799
|
+
const MANAGED_EVENTS = ['sessionStart', 'postToolUse'];
|
|
800
|
+
const entries = managedEntries || {};
|
|
801
|
+
for (const event of MANAGED_EVENTS) {
|
|
802
|
+
const existing = Array.isArray(hookTable[event]) ? hookTable[event] : [];
|
|
803
|
+
const userOwned = existing.filter((e) => !isManagedCursorHookEntry(e));
|
|
804
|
+
const newEntry = entries[event] || null;
|
|
805
|
+
if (newEntry) {
|
|
806
|
+
hookTable[event] = [...userOwned, newEntry];
|
|
807
|
+
}
|
|
808
|
+
else {
|
|
809
|
+
if (userOwned.length > 0) {
|
|
810
|
+
hookTable[event] = userOwned;
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
delete hookTable[event];
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
const nextContent = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
818
|
+
const changed = currentContent !== nextContent;
|
|
819
|
+
const shouldWrite = changed && (currentContent !== null || Object.keys(parsed).length > 0);
|
|
820
|
+
if (shouldWrite) {
|
|
821
|
+
atomicWriteFileSync(hooksJsonPath, nextContent, 'utf8');
|
|
822
|
+
}
|
|
823
|
+
return { changed: changed, wrote: shouldWrite, path: hooksJsonPath };
|
|
824
|
+
}
|
|
825
|
+
function writeCursorHooksJson(targetDir, src, opts) {
|
|
826
|
+
opts = opts || {};
|
|
827
|
+
const hooksDir = node_path_1.default.join(targetDir, 'hooks');
|
|
828
|
+
node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
|
|
829
|
+
const hookScripts = [GSD_CURSOR_SESSION_HOOK_SCRIPT, GSD_CURSOR_POST_TOOL_HOOK_SCRIPT];
|
|
830
|
+
const srcHooksDir = node_path_1.default.join(src, 'hooks');
|
|
831
|
+
const installedScripts = new Set();
|
|
832
|
+
for (const script of hookScripts) {
|
|
833
|
+
const srcPath = node_path_1.default.join(srcHooksDir, script);
|
|
834
|
+
const destPath = node_path_1.default.join(hooksDir, script);
|
|
835
|
+
if (node_fs_1.default.existsSync(srcPath)) {
|
|
836
|
+
let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
|
|
837
|
+
content = content.replace(/gsd:/gi, 'gsd-');
|
|
838
|
+
node_fs_1.default.writeFileSync(destPath, content);
|
|
839
|
+
try {
|
|
840
|
+
node_fs_1.default.chmodSync(destPath, 0o755);
|
|
841
|
+
}
|
|
842
|
+
catch { /* Windows: ignore chmod */ }
|
|
843
|
+
installedScripts.add(script);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const hookOpts = { runtime: 'cursor', platform: opts.platform || process.platform };
|
|
847
|
+
const sessionStartCmd = installedScripts.has('gsd-cursor-session-start.js')
|
|
848
|
+
? buildHookCommand(targetDir, 'gsd-cursor-session-start.js', hookOpts)
|
|
849
|
+
: null;
|
|
850
|
+
const postToolCmd = installedScripts.has('gsd-cursor-post-tool.js')
|
|
851
|
+
? buildHookCommand(targetDir, 'gsd-cursor-post-tool.js', hookOpts)
|
|
852
|
+
: null;
|
|
853
|
+
const managedEntries = {};
|
|
854
|
+
if (sessionStartCmd) {
|
|
855
|
+
managedEntries['sessionStart'] = {
|
|
856
|
+
type: 'command',
|
|
857
|
+
command: sessionStartCmd,
|
|
858
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (postToolCmd) {
|
|
862
|
+
managedEntries['postToolUse'] = {
|
|
863
|
+
type: 'command',
|
|
864
|
+
command: postToolCmd,
|
|
865
|
+
[GSD_CURSOR_HOOK_MARKER]: true,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
869
|
+
const result = reconcileCursorHooksJson(hooksJsonPath, managedEntries);
|
|
870
|
+
return { hooksJsonPath, changed: result.changed };
|
|
871
|
+
}
|
|
872
|
+
function removeCursorHooksJson(targetDir) {
|
|
873
|
+
const hooksJsonPath = node_path_1.default.join(targetDir, 'hooks.json');
|
|
874
|
+
if (!node_fs_1.default.existsSync(hooksJsonPath))
|
|
875
|
+
return { changed: false };
|
|
876
|
+
const result = reconcileCursorHooksJson(hooksJsonPath, null);
|
|
877
|
+
if (result.changed) {
|
|
878
|
+
try {
|
|
879
|
+
const contentRaw = node_fs_1.default.readFileSync(hooksJsonPath, 'utf8');
|
|
880
|
+
const parsed = JSON.parse(contentRaw);
|
|
881
|
+
const hookTable = (parsed['hooks'] && typeof parsed['hooks'] === 'object' && !Array.isArray(parsed['hooks']))
|
|
882
|
+
? parsed['hooks']
|
|
883
|
+
: {};
|
|
884
|
+
const hasAnyEvents = Object.keys(hookTable).some((k) => Array.isArray(hookTable[k]) && hookTable[k].length > 0);
|
|
885
|
+
if (!hasAnyEvents) {
|
|
886
|
+
node_fs_1.default.unlinkSync(hooksJsonPath);
|
|
887
|
+
return { changed: true };
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
catch { /* best-effort: leave the file */ }
|
|
891
|
+
}
|
|
892
|
+
return { changed: result.changed };
|
|
893
|
+
}
|
|
894
|
+
// ---------------------------------------------------------------------------
|
|
895
|
+
// Copilot hook functions
|
|
896
|
+
// ---------------------------------------------------------------------------
|
|
897
|
+
function buildCopilotHookConfig() {
|
|
898
|
+
return {
|
|
899
|
+
version: 1,
|
|
900
|
+
hooks: {
|
|
901
|
+
sessionStart: [
|
|
902
|
+
{
|
|
903
|
+
type: 'command',
|
|
904
|
+
bash: GSD_COPILOT_SESSION_HOOK_BASH,
|
|
905
|
+
powershell: GSD_COPILOT_SESSION_HOOK_PWSH,
|
|
906
|
+
timeoutSec: 10,
|
|
907
|
+
},
|
|
908
|
+
],
|
|
909
|
+
},
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function writeCopilotHookConfig(targetDir) {
|
|
913
|
+
const hooksDir = node_path_1.default.join(targetDir, 'hooks');
|
|
914
|
+
node_fs_1.default.mkdirSync(hooksDir, { recursive: true });
|
|
915
|
+
const hookPath = node_path_1.default.join(hooksDir, GSD_COPILOT_HOOK_FILE);
|
|
916
|
+
node_fs_1.default.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n');
|
|
917
|
+
return hookPath;
|
|
918
|
+
}
|
|
919
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
920
|
+
function applySettingsJsonHooks(settings, opts) {
|
|
921
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access,
|
|
922
|
+
@typescript-eslint/no-unsafe-call,
|
|
923
|
+
@typescript-eslint/no-unsafe-assignment */
|
|
924
|
+
const { runtime, isGlobal, targetDir, postToolEvent, hookEvents, extendedHookEvents, hooksSurface, updateCheckCommand, contextMonitorCommand, promptGuardCommand, readGuardCommand, readInjectionScannerCommand, configReloadCommand, hookOpts, localCmd, localShellCmd, } = opts;
|
|
925
|
+
// ADR-857 phase 5f-3: extended hook events are now driven by the registry
|
|
926
|
+
// descriptor field rather than hardcoded runtime-name checks.
|
|
927
|
+
const extendedEvents = Array.isArray(extendedHookEvents) ? extendedHookEvents : [];
|
|
928
|
+
// ADR-857 phase 5g drive 3: hook-skip guard is driven by the hooksSurface
|
|
929
|
+
// descriptor field. Only runtimes with hooksSurface === 'settings-json'
|
|
930
|
+
// register settings.json hooks; runtimes with hooksSurface === 'none'
|
|
931
|
+
// (opencode, kilo) are skipped. Equivalence: hooksSurface !== 'none' iff
|
|
932
|
+
// the old !isOpencode && !isKilo check.
|
|
933
|
+
if (hooksSurface !== 'none') {
|
|
934
|
+
if (!settings.hooks) {
|
|
935
|
+
settings.hooks = {};
|
|
936
|
+
}
|
|
937
|
+
if (!settings.hooks.SessionStart) {
|
|
938
|
+
settings.hooks.SessionStart = [];
|
|
939
|
+
}
|
|
940
|
+
const hasGsdUpdateHook = settings.hooks.SessionStart.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-check-update')));
|
|
941
|
+
// Guard: only register if the hook file was actually installed (#1754).
|
|
942
|
+
// When hooks/dist/ is missing from the npm package (as in v1.32.0), the
|
|
943
|
+
// copy step produces no files but the registration step ran unconditionally,
|
|
944
|
+
// causing "hook error" on every tool invocation.
|
|
945
|
+
const checkUpdateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-check-update.js');
|
|
946
|
+
if (!hasGsdUpdateHook && node_fs_1.default.existsSync(checkUpdateFile) && updateCheckCommand) {
|
|
947
|
+
settings.hooks.SessionStart.push({
|
|
948
|
+
hooks: [
|
|
949
|
+
{
|
|
950
|
+
type: 'command',
|
|
951
|
+
command: updateCheckCommand
|
|
952
|
+
}
|
|
953
|
+
]
|
|
954
|
+
});
|
|
955
|
+
console.log(` ${green}✓${reset} Configured update check hook`);
|
|
956
|
+
}
|
|
957
|
+
else if (!hasGsdUpdateHook && !node_fs_1.default.existsSync(checkUpdateFile)) {
|
|
958
|
+
console.warn(` ${yellow}⚠${reset} Skipped update check hook — gsd-check-update.js not found at target`);
|
|
959
|
+
}
|
|
960
|
+
// Configure post-tool hook for context window monitoring
|
|
961
|
+
if (!settings.hooks[postToolEvent]) {
|
|
962
|
+
settings.hooks[postToolEvent] = [];
|
|
963
|
+
}
|
|
964
|
+
const hasContextMonitorHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
|
|
965
|
+
const contextMonitorFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-context-monitor.js');
|
|
966
|
+
if (!hasContextMonitorHook && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
967
|
+
settings.hooks[postToolEvent].push({
|
|
968
|
+
matcher: 'Bash|Edit|Write|MultiEdit|Agent|Task',
|
|
969
|
+
hooks: [
|
|
970
|
+
{
|
|
971
|
+
type: 'command',
|
|
972
|
+
command: contextMonitorCommand,
|
|
973
|
+
timeout: 10
|
|
974
|
+
}
|
|
975
|
+
]
|
|
976
|
+
});
|
|
977
|
+
console.log(` ${green}✓${reset} Configured context window monitor hook`);
|
|
978
|
+
}
|
|
979
|
+
else if (!hasContextMonitorHook && !node_fs_1.default.existsSync(contextMonitorFile)) {
|
|
980
|
+
console.warn(` ${yellow}⚠${reset} Skipped context monitor hook — gsd-context-monitor.js not found at target`);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
// Migrate existing context monitor hooks: add matcher and timeout if missing
|
|
984
|
+
for (const entry of settings.hooks[postToolEvent]) {
|
|
985
|
+
if (entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor'))) {
|
|
986
|
+
let migrated = false;
|
|
987
|
+
if (!entry.matcher) {
|
|
988
|
+
entry.matcher = 'Bash|Edit|Write|MultiEdit|Agent|Task';
|
|
989
|
+
migrated = true;
|
|
990
|
+
}
|
|
991
|
+
for (const h of entry.hooks) {
|
|
992
|
+
if (referencesHook(h, 'gsd-context-monitor') && !h.timeout) {
|
|
993
|
+
h.timeout = 10;
|
|
994
|
+
migrated = true;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (migrated) {
|
|
998
|
+
console.log(` ${green}✓${reset} Updated context monitor hook (added matcher + timeout)`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// Configure PreToolUse hook for prompt injection detection
|
|
1004
|
+
// ADR-857 phase 5f-2: drive dialect from opts.hookEvents (registry descriptor).
|
|
1005
|
+
// hookEvents='gemini' → BeforeTool; all others → PreToolUse.
|
|
1006
|
+
// Equivalence: hookEvents='gemini' iff runtime∈{gemini,antigravity} (same as old check).
|
|
1007
|
+
const preToolEvent = hookEvents === 'gemini' ? 'BeforeTool' : 'PreToolUse';
|
|
1008
|
+
if (!settings.hooks[preToolEvent]) {
|
|
1009
|
+
settings.hooks[preToolEvent] = [];
|
|
1010
|
+
}
|
|
1011
|
+
const hasPromptGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-prompt-guard')));
|
|
1012
|
+
const promptGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-prompt-guard.js');
|
|
1013
|
+
if (!hasPromptGuardHook && node_fs_1.default.existsSync(promptGuardFile) && promptGuardCommand) {
|
|
1014
|
+
settings.hooks[preToolEvent].push({
|
|
1015
|
+
matcher: 'Write|Edit',
|
|
1016
|
+
hooks: [
|
|
1017
|
+
{
|
|
1018
|
+
type: 'command',
|
|
1019
|
+
command: promptGuardCommand,
|
|
1020
|
+
timeout: 5
|
|
1021
|
+
}
|
|
1022
|
+
]
|
|
1023
|
+
});
|
|
1024
|
+
console.log(` ${green}✓${reset} Configured prompt injection guard hook`);
|
|
1025
|
+
}
|
|
1026
|
+
else if (!hasPromptGuardHook && !node_fs_1.default.existsSync(promptGuardFile)) {
|
|
1027
|
+
console.warn(` ${yellow}⚠${reset} Skipped prompt guard hook — gsd-prompt-guard.js not found at target`);
|
|
1028
|
+
}
|
|
1029
|
+
// Configure PreToolUse hook for read-before-edit guidance (#1628)
|
|
1030
|
+
// Prevents infinite retry loops when non-Claude models attempt to edit
|
|
1031
|
+
// files without reading them first. Advisory-only — does not block.
|
|
1032
|
+
const hasReadGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-read-guard')));
|
|
1033
|
+
const readGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-read-guard.js');
|
|
1034
|
+
if (!hasReadGuardHook && node_fs_1.default.existsSync(readGuardFile) && readGuardCommand) {
|
|
1035
|
+
settings.hooks[preToolEvent].push({
|
|
1036
|
+
matcher: 'Write|Edit',
|
|
1037
|
+
hooks: [
|
|
1038
|
+
{
|
|
1039
|
+
type: 'command',
|
|
1040
|
+
command: readGuardCommand,
|
|
1041
|
+
timeout: 5
|
|
1042
|
+
}
|
|
1043
|
+
]
|
|
1044
|
+
});
|
|
1045
|
+
console.log(` ${green}✓${reset} Configured read-before-edit guard hook`);
|
|
1046
|
+
}
|
|
1047
|
+
else if (!hasReadGuardHook && !node_fs_1.default.existsSync(readGuardFile)) {
|
|
1048
|
+
console.warn(` ${yellow}⚠${reset} Skipped read guard hook — gsd-read-guard.js not found at target`);
|
|
1049
|
+
}
|
|
1050
|
+
// Configure PostToolUse hook for read-time prompt injection scanning (#2201)
|
|
1051
|
+
// Scans content returned by the Read tool for injection patterns, including
|
|
1052
|
+
// summarisation-specific patterns that survive context compression.
|
|
1053
|
+
const hasReadInjectionScannerHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-read-injection-scanner')));
|
|
1054
|
+
const readInjectionScannerFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-read-injection-scanner.js');
|
|
1055
|
+
if (!hasReadInjectionScannerHook && node_fs_1.default.existsSync(readInjectionScannerFile) && readInjectionScannerCommand) {
|
|
1056
|
+
settings.hooks[postToolEvent].push({
|
|
1057
|
+
matcher: 'Read',
|
|
1058
|
+
hooks: [
|
|
1059
|
+
{
|
|
1060
|
+
type: 'command',
|
|
1061
|
+
command: readInjectionScannerCommand,
|
|
1062
|
+
timeout: 5
|
|
1063
|
+
}
|
|
1064
|
+
]
|
|
1065
|
+
});
|
|
1066
|
+
console.log(` ${green}✓${reset} Configured read injection scanner hook`);
|
|
1067
|
+
}
|
|
1068
|
+
else if (!hasReadInjectionScannerHook && !node_fs_1.default.existsSync(readInjectionScannerFile)) {
|
|
1069
|
+
console.warn(` ${yellow}⚠${reset} Skipped read injection scanner hook — gsd-read-injection-scanner.js not found at target`);
|
|
1070
|
+
}
|
|
1071
|
+
// Community hooks — registered on install but opt-in at runtime.
|
|
1072
|
+
// Each hook checks .planning/config.json for hooks.community: true
|
|
1073
|
+
// and exits silently (no-op) if not enabled. This lets users enable
|
|
1074
|
+
// them per-project by adding: "hooks": { "community": true }
|
|
1075
|
+
// Configure workflow guard hook (opt-in via hooks.workflow_guard: true)
|
|
1076
|
+
// Detects file edits outside GSD workflow context and advises using
|
|
1077
|
+
// /gsd-quick or /gsd-fast for state-tracked changes. Also hard-blocks
|
|
1078
|
+
// unsafe Bash commands that violate worktree-agent isolation.
|
|
1079
|
+
const workflowGuardCommand = isGlobal
|
|
1080
|
+
? buildHookCommand(targetDir, 'gsd-workflow-guard.js', hookOpts)
|
|
1081
|
+
: localCmd('gsd-workflow-guard.js');
|
|
1082
|
+
const workflowGuardMatcher = 'Bash|Edit|Write|MultiEdit';
|
|
1083
|
+
const workflowGuardHookEntry = settings.hooks[preToolEvent].find((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-workflow-guard')));
|
|
1084
|
+
const hasWorkflowGuardHook = Boolean(workflowGuardHookEntry);
|
|
1085
|
+
const workflowGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-workflow-guard.js');
|
|
1086
|
+
if (hasWorkflowGuardHook && workflowGuardHookEntry.matcher !== workflowGuardMatcher) {
|
|
1087
|
+
workflowGuardHookEntry.matcher = workflowGuardMatcher;
|
|
1088
|
+
console.log(` ${green}✓${reset} Updated workflow guard hook matcher`);
|
|
1089
|
+
}
|
|
1090
|
+
else if (!hasWorkflowGuardHook && node_fs_1.default.existsSync(workflowGuardFile) && workflowGuardCommand) {
|
|
1091
|
+
settings.hooks[preToolEvent].push({
|
|
1092
|
+
matcher: workflowGuardMatcher,
|
|
1093
|
+
hooks: [
|
|
1094
|
+
{
|
|
1095
|
+
type: 'command',
|
|
1096
|
+
command: workflowGuardCommand,
|
|
1097
|
+
timeout: 5
|
|
1098
|
+
}
|
|
1099
|
+
]
|
|
1100
|
+
});
|
|
1101
|
+
console.log(` ${green}✓${reset} Configured workflow guard hook (opt-in via hooks.workflow_guard)`);
|
|
1102
|
+
}
|
|
1103
|
+
else if (!hasWorkflowGuardHook && !node_fs_1.default.existsSync(workflowGuardFile)) {
|
|
1104
|
+
console.warn(` ${yellow}⚠${reset} Skipped workflow guard hook — gsd-workflow-guard.js not found at target`);
|
|
1105
|
+
}
|
|
1106
|
+
// Configure PreToolUse hook for worktree absolute-path safety (#260)
|
|
1107
|
+
// Hard-blocks Edit/Write/MultiEdit tool calls with absolute paths that resolve
|
|
1108
|
+
// outside the current worktree root. Prevents executor agents from
|
|
1109
|
+
// accidentally writing to the main checkout when running in isolation="worktree".
|
|
1110
|
+
const worktreePathGuardCommand = isGlobal
|
|
1111
|
+
? buildHookCommand(targetDir, 'gsd-worktree-path-guard.js', hookOpts)
|
|
1112
|
+
: localCmd('gsd-worktree-path-guard.js');
|
|
1113
|
+
const hasWorktreePathGuardHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-worktree-path-guard')));
|
|
1114
|
+
const worktreePathGuardFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-worktree-path-guard.js');
|
|
1115
|
+
if (!hasWorktreePathGuardHook && node_fs_1.default.existsSync(worktreePathGuardFile) && worktreePathGuardCommand) {
|
|
1116
|
+
settings.hooks[preToolEvent].push({
|
|
1117
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
1118
|
+
hooks: [
|
|
1119
|
+
{
|
|
1120
|
+
type: 'command',
|
|
1121
|
+
command: worktreePathGuardCommand,
|
|
1122
|
+
timeout: 5
|
|
1123
|
+
}
|
|
1124
|
+
]
|
|
1125
|
+
});
|
|
1126
|
+
console.log(` ${green}✓${reset} Configured worktree path guard hook`);
|
|
1127
|
+
}
|
|
1128
|
+
else if (!hasWorktreePathGuardHook && !node_fs_1.default.existsSync(worktreePathGuardFile)) {
|
|
1129
|
+
console.warn(` ${yellow}⚠${reset} Skipped worktree path guard hook — gsd-worktree-path-guard.js not found at target`);
|
|
1130
|
+
}
|
|
1131
|
+
// Configure commit validation hook (Conventional Commits enforcement, opt-in)
|
|
1132
|
+
const validateCommitCommand = isGlobal
|
|
1133
|
+
? buildHookCommand(targetDir, 'gsd-validate-commit.sh', hookOpts)
|
|
1134
|
+
: localShellCmd('gsd-validate-commit.sh');
|
|
1135
|
+
const hasValidateCommitHook = settings.hooks[preToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-validate-commit')));
|
|
1136
|
+
// Guard: only register if the .sh file was actually installed. If the npm package
|
|
1137
|
+
// omitted the file (as happened in v1.32.0, bug #1817), registering a missing hook
|
|
1138
|
+
// causes a hook error on every Bash tool invocation.
|
|
1139
|
+
const validateCommitFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-validate-commit.sh');
|
|
1140
|
+
if (!hasValidateCommitHook && node_fs_1.default.existsSync(validateCommitFile) && validateCommitCommand) {
|
|
1141
|
+
settings.hooks[preToolEvent].push({
|
|
1142
|
+
matcher: 'Bash',
|
|
1143
|
+
hooks: [
|
|
1144
|
+
{
|
|
1145
|
+
type: 'command',
|
|
1146
|
+
command: validateCommitCommand,
|
|
1147
|
+
timeout: 5
|
|
1148
|
+
}
|
|
1149
|
+
]
|
|
1150
|
+
});
|
|
1151
|
+
console.log(` ${green}✓${reset} Configured commit validation hook (opt-in via config)`);
|
|
1152
|
+
}
|
|
1153
|
+
else if (!hasValidateCommitHook && !node_fs_1.default.existsSync(validateCommitFile)) {
|
|
1154
|
+
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — gsd-validate-commit.sh not found at target`);
|
|
1155
|
+
}
|
|
1156
|
+
else if (!hasValidateCommitHook && !validateCommitCommand) {
|
|
1157
|
+
console.warn(` ${yellow}⚠${reset} Skipped commit validation hook — Bash executable path unavailable (#3393)`);
|
|
1158
|
+
}
|
|
1159
|
+
// Configure graphify auto-update hook (opt-in via graphify.auto_update; default false, #3347).
|
|
1160
|
+
// PostToolUse Bash matcher — fires after git commit/merge/pull/rebase --continue/cherry-pick
|
|
1161
|
+
// on the default branch, dispatches `graphify update .` in a detached subprocess. No-op unless
|
|
1162
|
+
// .planning/config.json has BOTH graphify.enabled=true AND graphify.auto_update=true.
|
|
1163
|
+
const graphifyUpdateCommand = isGlobal
|
|
1164
|
+
? buildHookCommand(targetDir, 'gsd-graphify-update.sh', hookOpts)
|
|
1165
|
+
: localShellCmd('gsd-graphify-update.sh');
|
|
1166
|
+
const hasGraphifyUpdateHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-graphify-update')));
|
|
1167
|
+
const graphifyUpdateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-graphify-update.sh');
|
|
1168
|
+
if (!hasGraphifyUpdateHook && node_fs_1.default.existsSync(graphifyUpdateFile) && graphifyUpdateCommand) {
|
|
1169
|
+
settings.hooks[postToolEvent].push({
|
|
1170
|
+
matcher: 'Bash',
|
|
1171
|
+
hooks: [
|
|
1172
|
+
{
|
|
1173
|
+
type: 'command',
|
|
1174
|
+
command: graphifyUpdateCommand,
|
|
1175
|
+
timeout: 5
|
|
1176
|
+
}
|
|
1177
|
+
]
|
|
1178
|
+
});
|
|
1179
|
+
console.log(` ${green}✓${reset} Configured graphify auto-update hook (opt-in via graphify.auto_update)`);
|
|
1180
|
+
}
|
|
1181
|
+
else if (!hasGraphifyUpdateHook && !node_fs_1.default.existsSync(graphifyUpdateFile)) {
|
|
1182
|
+
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target`);
|
|
1183
|
+
}
|
|
1184
|
+
else if (!hasGraphifyUpdateHook && !graphifyUpdateCommand) {
|
|
1185
|
+
console.warn(` ${yellow}⚠${reset} Skipped graphify auto-update hook — Bash executable path unavailable (#3393)`);
|
|
1186
|
+
}
|
|
1187
|
+
// Configure session state orientation hook (opt-in)
|
|
1188
|
+
const sessionStateCommand = isGlobal
|
|
1189
|
+
? buildHookCommand(targetDir, 'gsd-session-state.sh', hookOpts)
|
|
1190
|
+
: localShellCmd('gsd-session-state.sh');
|
|
1191
|
+
const hasSessionStateHook = settings.hooks.SessionStart.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-session-state')));
|
|
1192
|
+
const sessionStateFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-session-state.sh');
|
|
1193
|
+
if (!hasSessionStateHook && node_fs_1.default.existsSync(sessionStateFile) && sessionStateCommand) {
|
|
1194
|
+
settings.hooks.SessionStart.push({
|
|
1195
|
+
hooks: [
|
|
1196
|
+
{
|
|
1197
|
+
type: 'command',
|
|
1198
|
+
command: sessionStateCommand
|
|
1199
|
+
}
|
|
1200
|
+
]
|
|
1201
|
+
});
|
|
1202
|
+
console.log(` ${green}✓${reset} Configured session state orientation hook (opt-in via config)`);
|
|
1203
|
+
}
|
|
1204
|
+
else if (!hasSessionStateHook && !node_fs_1.default.existsSync(sessionStateFile)) {
|
|
1205
|
+
console.warn(` ${yellow}⚠${reset} Skipped session state hook — gsd-session-state.sh not found at target`);
|
|
1206
|
+
}
|
|
1207
|
+
else if (!hasSessionStateHook && !sessionStateCommand) {
|
|
1208
|
+
console.warn(` ${yellow}⚠${reset} Skipped session state hook — Bash executable path unavailable (#3393)`);
|
|
1209
|
+
}
|
|
1210
|
+
// Configure phase boundary detection hook (opt-in)
|
|
1211
|
+
const phaseBoundaryCommand = isGlobal
|
|
1212
|
+
? buildHookCommand(targetDir, 'gsd-phase-boundary.sh', hookOpts)
|
|
1213
|
+
: localShellCmd('gsd-phase-boundary.sh');
|
|
1214
|
+
const hasPhaseBoundaryHook = settings.hooks[postToolEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-phase-boundary')));
|
|
1215
|
+
const phaseBoundaryFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-phase-boundary.sh');
|
|
1216
|
+
if (!hasPhaseBoundaryHook && node_fs_1.default.existsSync(phaseBoundaryFile) && phaseBoundaryCommand) {
|
|
1217
|
+
settings.hooks[postToolEvent].push({
|
|
1218
|
+
matcher: 'Write|Edit',
|
|
1219
|
+
hooks: [
|
|
1220
|
+
{
|
|
1221
|
+
type: 'command',
|
|
1222
|
+
command: phaseBoundaryCommand,
|
|
1223
|
+
timeout: 5
|
|
1224
|
+
}
|
|
1225
|
+
]
|
|
1226
|
+
});
|
|
1227
|
+
console.log(` ${green}✓${reset} Configured phase boundary detection hook (opt-in via config)`);
|
|
1228
|
+
}
|
|
1229
|
+
else if (!hasPhaseBoundaryHook && !node_fs_1.default.existsSync(phaseBoundaryFile)) {
|
|
1230
|
+
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — gsd-phase-boundary.sh not found at target`);
|
|
1231
|
+
}
|
|
1232
|
+
else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) {
|
|
1233
|
+
console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`);
|
|
1234
|
+
}
|
|
1235
|
+
// ── Extended hook events: SubagentStop / Stop / PreCompact (#788 + #770) ──
|
|
1236
|
+
// Claude Code (since #770) and Qwen Code (since #788) both support these
|
|
1237
|
+
// three lifecycle events. Wire gsd-context-monitor so agents get context-
|
|
1238
|
+
// headroom warnings at subagent completion, model stop, and pre-compaction
|
|
1239
|
+
// (the most critical moment to surface headroom info).
|
|
1240
|
+
//
|
|
1241
|
+
// SubagentStop — subagent lifecycle completion (context headroom tracking)
|
|
1242
|
+
// Stop — model stop / final-response moment (context headroom)
|
|
1243
|
+
// PreCompact — fires before conversation compaction (most critical
|
|
1244
|
+
// moment to surface context headroom warnings)
|
|
1245
|
+
//
|
|
1246
|
+
// Note: UserPromptSubmit is NOT wired here. That event carries the raw
|
|
1247
|
+
// user prompt text, not a tool invocation, so gsd-prompt-guard (which
|
|
1248
|
+
// exits unless tool_name is Write/Edit) would be a silent no-op. A
|
|
1249
|
+
// dedicated handler for UserPromptSubmit is deferred to a follow-on issue.
|
|
1250
|
+
// SubagentStop, Stop, PreCompact — route through the context monitor.
|
|
1251
|
+
// Guard is now descriptor-driven: only events present in extendedEvents are wired.
|
|
1252
|
+
{
|
|
1253
|
+
const runtimeLabel = runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? 'Claude Code' : runtime;
|
|
1254
|
+
for (const event of ['SubagentStop', 'Stop', 'PreCompact']) {
|
|
1255
|
+
if (!extendedEvents.includes(event))
|
|
1256
|
+
continue;
|
|
1257
|
+
if (!settings.hooks[event]) {
|
|
1258
|
+
settings.hooks[event] = [];
|
|
1259
|
+
}
|
|
1260
|
+
const alreadyHasContextMonitor = settings.hooks[event].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
|
|
1261
|
+
if (!alreadyHasContextMonitor && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
1262
|
+
settings.hooks[event].push({
|
|
1263
|
+
hooks: [
|
|
1264
|
+
{
|
|
1265
|
+
type: 'command',
|
|
1266
|
+
command: contextMonitorCommand,
|
|
1267
|
+
timeout: 10
|
|
1268
|
+
}
|
|
1269
|
+
]
|
|
1270
|
+
});
|
|
1271
|
+
console.log(` ${green}✓${reset} Configured ${event} context monitor hook (${runtimeLabel})`);
|
|
1272
|
+
}
|
|
1273
|
+
else if (!alreadyHasContextMonitor && !node_fs_1.default.existsSync(contextMonitorFile)) {
|
|
1274
|
+
console.warn(` ${yellow}⚠${reset} Skipped ${event} hook — gsd-context-monitor.js not found at target`);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
// ── end SubagentStop / Stop / PreCompact events ────────────────────────────
|
|
1279
|
+
// ── Gemini-only extended hook events (#776) ───────────────────────────────
|
|
1280
|
+
// Gemini CLI exposes several hook events beyond BeforeTool/AfterTool that
|
|
1281
|
+
// gsd previously did not register. Three high-value events are added here:
|
|
1282
|
+
//
|
|
1283
|
+
// BeforeAgent — fires after user submits a prompt, before the agent
|
|
1284
|
+
// plans. Wire gsd-context-monitor for context headroom
|
|
1285
|
+
// awareness at prompt time.
|
|
1286
|
+
// AfterAgent — fires once per turn after the model generates its final
|
|
1287
|
+
// response. Wire gsd-context-monitor to track headroom
|
|
1288
|
+
// after each agent turn completes.
|
|
1289
|
+
// BeforeModel — fires before each LLM call (per-turn, not per-session).
|
|
1290
|
+
// Wire gsd-context-monitor for per-turn context injection
|
|
1291
|
+
// — more precise than session-start-only injection.
|
|
1292
|
+
//
|
|
1293
|
+
// All three reuse gsd-context-monitor.js — no new hook files needed.
|
|
1294
|
+
// The `decision:"deny"` retry capability of AfterAgent is intentionally
|
|
1295
|
+
// left to the hook script to implement when triggered (gsd-context-monitor
|
|
1296
|
+
// exits 0 / advisory-only today; an active quality gate is a follow-on).
|
|
1297
|
+
//
|
|
1298
|
+
// Note: BeforeToolSelection is NOT wired. That event does not map to a
|
|
1299
|
+
// gsd hook use case at this time; deferred to a follow-on issue.
|
|
1300
|
+
//
|
|
1301
|
+
// Guard is now descriptor-driven: only events present in extendedEvents are wired.
|
|
1302
|
+
for (const geminiEvent of ['BeforeAgent', 'AfterAgent', 'BeforeModel']) {
|
|
1303
|
+
if (!extendedEvents.includes(geminiEvent))
|
|
1304
|
+
continue;
|
|
1305
|
+
if (!Array.isArray(settings.hooks[geminiEvent])) {
|
|
1306
|
+
settings.hooks[geminiEvent] = [];
|
|
1307
|
+
}
|
|
1308
|
+
const alreadyHasContextMonitor = settings.hooks[geminiEvent].some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-context-monitor')));
|
|
1309
|
+
if (!alreadyHasContextMonitor && node_fs_1.default.existsSync(contextMonitorFile) && contextMonitorCommand) {
|
|
1310
|
+
settings.hooks[geminiEvent].push({
|
|
1311
|
+
hooks: [
|
|
1312
|
+
{
|
|
1313
|
+
type: 'command',
|
|
1314
|
+
command: contextMonitorCommand,
|
|
1315
|
+
timeout: 10
|
|
1316
|
+
}
|
|
1317
|
+
]
|
|
1318
|
+
});
|
|
1319
|
+
console.log(` ${green}✓${reset} Configured ${geminiEvent} context monitor hook (Gemini)`);
|
|
1320
|
+
}
|
|
1321
|
+
else if (!alreadyHasContextMonitor && !node_fs_1.default.existsSync(contextMonitorFile)) {
|
|
1322
|
+
console.warn(` ${yellow}⚠${reset} Skipped ${geminiEvent} hook — gsd-context-monitor.js not found at target`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
// ── end Gemini-only extended hook events ──────────────────────────────────
|
|
1326
|
+
// ── FileChanged hook: hot-reload gsd config on .planning/config.json edits ─
|
|
1327
|
+
// Claude Code fires FileChanged when a watched file changes on disk. Wire
|
|
1328
|
+
// gsd-config-reload.js to reload the gsd config context whenever the user
|
|
1329
|
+
// edits .planning/config.json mid-session, eliminating the need to restart.
|
|
1330
|
+
//
|
|
1331
|
+
// The matcher "config.json" watches for changes to any file named config.json
|
|
1332
|
+
// (Claude Code matches by filename, not full path). The hook exits silently
|
|
1333
|
+
// when the changed file is not the gsd config.
|
|
1334
|
+
//
|
|
1335
|
+
// Scoped to Claude Code only: Qwen Code's FileChanged support is not yet
|
|
1336
|
+
// verified; extend in a follow-on if empirically confirmed.
|
|
1337
|
+
if (extendedEvents.includes('FileChanged')) {
|
|
1338
|
+
if (!settings.hooks.FileChanged) {
|
|
1339
|
+
settings.hooks.FileChanged = [];
|
|
1340
|
+
}
|
|
1341
|
+
const configReloadFile = node_path_1.default.join(targetDir, 'hooks', 'gsd-config-reload.js');
|
|
1342
|
+
const alreadyHasConfigReload = settings.hooks.FileChanged.some((entry) => entry.hooks && entry.hooks.some((h) => referencesHook(h, 'gsd-config-reload')));
|
|
1343
|
+
if (!alreadyHasConfigReload && node_fs_1.default.existsSync(configReloadFile) && configReloadCommand) {
|
|
1344
|
+
settings.hooks.FileChanged.push({
|
|
1345
|
+
matcher: 'config.json',
|
|
1346
|
+
hooks: [
|
|
1347
|
+
{
|
|
1348
|
+
type: 'command',
|
|
1349
|
+
command: configReloadCommand,
|
|
1350
|
+
timeout: 8
|
|
1351
|
+
}
|
|
1352
|
+
]
|
|
1353
|
+
});
|
|
1354
|
+
console.log(` ${green}✓${reset} Configured FileChanged config-reload hook (Claude Code)`);
|
|
1355
|
+
}
|
|
1356
|
+
else if (!alreadyHasConfigReload && !node_fs_1.default.existsSync(configReloadFile)) {
|
|
1357
|
+
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — gsd-config-reload.js not found at target`);
|
|
1358
|
+
}
|
|
1359
|
+
else if (!alreadyHasConfigReload && !configReloadCommand) {
|
|
1360
|
+
console.warn(` ${yellow}⚠${reset} Skipped FileChanged hook — Node executable path unavailable`);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
// ── end FileChanged hook ────────────────────────────────────────────────────
|
|
1364
|
+
}
|
|
1365
|
+
/* eslint-enable @typescript-eslint/no-unsafe-member-access,
|
|
1366
|
+
@typescript-eslint/no-unsafe-call,
|
|
1367
|
+
@typescript-eslint/no-unsafe-assignment */
|
|
1368
|
+
}
|
|
1369
|
+
// ---------------------------------------------------------------------------
|
|
1370
|
+
// referencesHook
|
|
1371
|
+
//
|
|
1372
|
+
// Pure predicate — checks whether a hook entry object references a managed
|
|
1373
|
+
// hook by name. Covers all three registration shapes used by GSD:
|
|
1374
|
+
// • plain command string (standard form)
|
|
1375
|
+
// • args array (command+args / wrapped-launcher form used by windowless
|
|
1376
|
+
// launchers on Windows and some custom PATH-less environments) (#976)
|
|
1377
|
+
// • url field (type:"http" local-server routing form) (#1004)
|
|
1378
|
+
// Without covering all three, an http-form or args-form entry is invisible
|
|
1379
|
+
// and a stock string-command entry is appended on every install/update,
|
|
1380
|
+
// running the hook twice.
|
|
1381
|
+
//
|
|
1382
|
+
// Originally declared inside install()/finishInstall() as a local function;
|
|
1383
|
+
// promoted here so applySettingsJsonHooks() and finishInstall() share one
|
|
1384
|
+
// copy (ADR-857 phase 5f-1b).
|
|
1385
|
+
// ---------------------------------------------------------------------------
|
|
1386
|
+
function referencesHook(h, hookName) {
|
|
1387
|
+
const cmd = h['command'];
|
|
1388
|
+
const args = h['args'];
|
|
1389
|
+
const url = h['url'];
|
|
1390
|
+
return (typeof cmd === 'string' && cmd.includes(hookName)) ||
|
|
1391
|
+
(Array.isArray(args) && args.some(a => typeof a === 'string' && a.includes(hookName))) ||
|
|
1392
|
+
(typeof url === 'string' && url.includes(hookName));
|
|
1393
|
+
}
|
|
1394
|
+
module.exports = {
|
|
1395
|
+
// Cline
|
|
1396
|
+
buildClineRulesBody,
|
|
1397
|
+
buildClineAgentsMdBody,
|
|
1398
|
+
buildClinePreToolUseHook,
|
|
1399
|
+
mergeGsdAgentsMd,
|
|
1400
|
+
writeClineArtifacts,
|
|
1401
|
+
GSD_AGENTS_MD_MARKER,
|
|
1402
|
+
GSD_AGENTS_MD_CLOSE_MARKER,
|
|
1403
|
+
// Cursor
|
|
1404
|
+
buildCursorHookEntry,
|
|
1405
|
+
isManagedCursorHookEntry,
|
|
1406
|
+
reconcileCursorHooksJson,
|
|
1407
|
+
writeCursorHooksJson,
|
|
1408
|
+
removeCursorHooksJson,
|
|
1409
|
+
GSD_CURSOR_SESSION_HOOK_SCRIPT,
|
|
1410
|
+
GSD_CURSOR_POST_TOOL_HOOK_SCRIPT,
|
|
1411
|
+
GSD_CURSOR_HOOK_MARKER,
|
|
1412
|
+
// Copilot
|
|
1413
|
+
buildCopilotHookConfig,
|
|
1414
|
+
writeCopilotHookConfig,
|
|
1415
|
+
GSD_COPILOT_HOOK_FILE,
|
|
1416
|
+
// Codex hooks.json
|
|
1417
|
+
reconcileCodexHooksJsonEvent,
|
|
1418
|
+
reconcileCodexHooksJsonSessionStart,
|
|
1419
|
+
ensureCodexHooksJsonSessionStart,
|
|
1420
|
+
ensureCodexHooksJsonEvent,
|
|
1421
|
+
removeCodexHooksJsonEvent,
|
|
1422
|
+
removeCodexHooksJsonSessionStart,
|
|
1423
|
+
buildCodexHookWindowsShimIR,
|
|
1424
|
+
// Codex TOML
|
|
1425
|
+
buildCodexHookBlock,
|
|
1426
|
+
rewriteLegacyCodexHookBlock,
|
|
1427
|
+
// Shared
|
|
1428
|
+
buildHookCommand,
|
|
1429
|
+
applySettingsJsonHooks,
|
|
1430
|
+
referencesHook,
|
|
1431
|
+
rewriteLegacyManagedNodeHookCommands,
|
|
1432
|
+
normalizeNodePath,
|
|
1433
|
+
resolveNodeRunner,
|
|
1434
|
+
resolveBashRunner,
|
|
1435
|
+
// Atomic write seam (shared with bin/install.js so all writes participate
|
|
1436
|
+
// in install.js's _cleanTmpFiles() scoped temp-cleanup).
|
|
1437
|
+
atomicWriteFileSync,
|
|
1438
|
+
__atomicWrittenTmps,
|
|
1439
|
+
};
|