@opengsd/gsd-core 1.4.0-rc.1 → 1.4.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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +23 -0
  2. package/GEMINI.md +53 -0
  3. package/agents/gsd-ai-researcher.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-domain-researcher.md +1 -1
  7. package/agents/gsd-eval-auditor.md +1 -1
  8. package/agents/gsd-eval-planner.md +1 -1
  9. package/agents/gsd-framework-selector.md +1 -1
  10. package/agents/gsd-nyquist-auditor.md +1 -1
  11. package/agents/gsd-pattern-mapper.md +1 -1
  12. package/agents/gsd-security-auditor.md +1 -1
  13. package/agents/gsd-ui-auditor.md +1 -1
  14. package/agents/gsd-ui-checker.md +1 -1
  15. package/agents/gsd-ui-researcher.md +1 -1
  16. package/agents/gsd-user-profiler.md +1 -1
  17. package/bin/install.js +1911 -354
  18. package/commands/gsd/autonomous.md +2 -0
  19. package/commands/gsd/execute-phase.md +2 -0
  20. package/commands/gsd/plan-phase.md +2 -0
  21. package/commands/gsd/progress.md +1 -0
  22. package/commands/gsd/stats.md +1 -0
  23. package/commands/gsd/update.md +3 -2
  24. package/gemini-extension.json +6 -0
  25. package/gsd-core/bin/check-latest-version.cjs +58 -4
  26. package/gsd-core/bin/lib/install-profiles.cjs +58 -0
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
  28. package/gsd-core/bin/lib/installer-migrations/000-first-time-baseline.cjs +1 -1
  29. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +67 -9
  30. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +56 -0
  31. package/gsd-core/bin/lib/runtime-homes.cjs +32 -11
  32. package/gsd-core/bin/lib/shell-command-projection.cjs +6 -0
  33. package/gsd-core/bin/lib/surface.cjs +54 -11
  34. package/gsd-core/workflows/help/modes/full.md +2 -1
  35. package/gsd-core/workflows/review.md +2 -2
  36. package/gsd-core/workflows/update.md +32 -5
  37. package/hooks/dist/gsd-config-reload.js +133 -0
  38. package/hooks/dist/gsd-cursor-post-tool.js +75 -0
  39. package/hooks/dist/gsd-cursor-session-start.js +52 -0
  40. package/hooks/dist/managed-hooks-registry.cjs +3 -0
  41. package/hooks/gsd-config-reload.js +133 -0
  42. package/hooks/gsd-cursor-post-tool.js +75 -0
  43. package/hooks/gsd-cursor-session-start.js +52 -0
  44. package/hooks/hooks.json +69 -0
  45. package/hooks/managed-hooks-registry.cjs +3 -0
  46. package/package.json +5 -1
  47. package/scripts/build-hooks.js +7 -0
  48. package/scripts/changeset/cli.cjs +53 -10
  49. package/scripts/ci-test-scope.cjs +120 -18
  50. package/scripts/issue-dedupe.cjs +278 -0
  51. package/scripts/lint-test-file-count.allowlist.json +1 -0
  52. package/scripts/release-notes/discord-release-summary.cjs +373 -0
  53. package/scripts/research-profiles.cjs +3 -3
  54. package/scripts/sync-manifest-versions.cjs +119 -0
@@ -26,6 +26,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
26
26
  };
27
27
  const node_fs_1 = __importDefault(require("node:fs"));
28
28
  const node_path_1 = __importDefault(require("node:path"));
29
+ const node_os_1 = __importDefault(require("node:os"));
29
30
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
30
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
31
32
  const installProfiles = require("./install-profiles.cjs");
@@ -33,7 +34,7 @@ const { readActiveProfile, resolveProfile, loadSkillsManifest, } = installProfil
33
34
  const clusters_cjs_1 = require("./clusters.cjs");
34
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
35
36
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
36
- const { findInstallSourceRoot } = runtimeArtifactLayout;
37
+ const { findInstallSourceRoot, getInstallExports } = runtimeArtifactLayout;
37
38
  const SURFACE_FILE_NAME = '.gsd-surface.json';
38
39
  /**
39
40
  * Read the surface state from a runtime config directory.
@@ -210,8 +211,29 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) {
210
211
  }
211
212
  const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
212
213
  const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap);
214
+ // Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites
215
+ // so SKILL.md bodies reference the install target (pathPrefix), not the
216
+ // converter's default ~/.claude paths (#813). Computed lazily so command-only
217
+ // runtimes do not trigger the install.js require.
218
+ let pathPrefix = null;
213
219
  for (const kind of layout.kinds) {
214
220
  const staged = kind.stage(resolved);
221
+ if (kind.kind === 'skills') {
222
+ const installExports = getInstallExports();
223
+ if (pathPrefix === null) {
224
+ const scope = layout.scope ?? 'global';
225
+ const resolvedTarget = node_path_1.default.resolve(layout.configDir).replace(/\\/g, '/');
226
+ const homeDir = node_os_1.default.homedir().replace(/\\/g, '/');
227
+ pathPrefix = installExports.computePathPrefix({
228
+ isGlobal: scope === 'global',
229
+ isOpencode: layout.runtime === 'opencode',
230
+ isWindowsHost: process.platform === 'win32',
231
+ resolvedTarget,
232
+ homeDir,
233
+ });
234
+ }
235
+ installExports.applyRuntimeContentRewritesInPlace(staged, layout.runtime, pathPrefix);
236
+ }
215
237
  const dest = node_path_1.default.join(layout.configDir, kind.destSubpath);
216
238
  _syncGsdDir(staged, dest, kind, skillManifest);
217
239
  }
@@ -335,20 +357,41 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest) {
335
357
  pruneSkillDirs(destDir, stagedDirs, kindPrefix, manifest);
336
358
  }
337
359
  else {
338
- // commands / agents kind: work with .md files
339
- const stagedFiles = new Set(node_fs_1.default.readdirSync(stagedDir).filter(f => f.endsWith('.md')));
340
- // Copy files from staged to dest (overwrite to keep content current)
360
+ // commands / agents kind: mirror installRuntimeArtifacts (_copyStaged /
361
+ // _removeGsdEntries in bin/install.js) so surface produces the SAME files as a
362
+ // fresh install (#816). Flat command dirs (opencode/cursor/augment/kilo) take
363
+ // the gsd- prefix on copy; namespaced command dirs (commands/gsd) and agents
364
+ // keep their staged names. Copying staged names verbatim previously diverged
365
+ // from install and orphaned the installed gsd-*.md files, and the unscoped
366
+ // prune deleted user-owned command files.
367
+ //
368
+ // NOTE: the destName rule below intentionally mirrors bin/install.js
369
+ // `_copyStaged` (the `namespacedByDir` decision). Keep them in sync.
370
+ const destLast = (typeof kind === 'object' && kind !== null && kind.destSubpath)
371
+ ? node_path_1.default.basename(kind.destSubpath)
372
+ : '';
373
+ const prefixStem = kindPrefix ? kindPrefix.replace(/-$/, '') : '';
374
+ const namespacedByDir = kindName === 'commands' && destLast === prefixStem;
375
+ const stagedFiles = node_fs_1.default.readdirSync(stagedDir).filter(f => f.endsWith('.md'));
376
+ const stagedDestNames = new Set();
341
377
  for (const file of stagedFiles) {
342
- node_fs_1.default.copyFileSync(node_path_1.default.join(stagedDir, file), node_path_1.default.join(destDir, file));
378
+ const destName = (kindName === 'agents' || namespacedByDir)
379
+ ? file
380
+ : `${kindPrefix}${file.slice(0, -3)}.md`;
381
+ node_fs_1.default.copyFileSync(node_path_1.default.join(stagedDir, file), node_path_1.default.join(destDir, destName));
382
+ stagedDestNames.add(destName);
343
383
  }
344
- // Remove gsd-only files from dest that aren't in staged set
345
- // For commands dir: all .md files are gsd skills
346
- // For agents dir: only gsd-* files
347
- const destEntries = node_fs_1.default.readdirSync(destDir).filter(f => f.endsWith('.md'));
348
- for (const file of destEntries) {
384
+ // Prune stale GSD-owned files not in the staged set, preserving user-owned files
385
+ // (mirrors install's prefix-scoped _removeGsdEntries):
386
+ // - agents: only gsd-* are GSD-owned
387
+ // - flat command dirs: only `${kindPrefix}`-prefixed are GSD-owned
388
+ // - namespaced command dirs: the whole dir is GSD-owned
389
+ for (const file of node_fs_1.default.readdirSync(destDir).filter(f => f.endsWith('.md'))) {
349
390
  if (kindName === 'agents' && !file.startsWith('gsd-'))
350
391
  continue;
351
- if (!stagedFiles.has(file)) {
392
+ if (kindName === 'commands' && !namespacedByDir && kindPrefix && !file.startsWith(kindPrefix))
393
+ continue;
394
+ if (!stagedDestNames.has(file)) {
352
395
  try {
353
396
  node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
354
397
  }
@@ -550,11 +550,12 @@ Usage: `/gsd:help --full`
550
550
  Usage: `/gsd:help debug`
551
551
  Usage: `/gsd:help --brief debug`
552
552
 
553
- **`/gsd:update [--sync] [--reapply]`**
553
+ **`/gsd:update [--sync] [--reapply] [--next | --rc]`**
554
554
  Update GSD to latest version with changelog preview.
555
555
 
556
556
  - `--sync` — sync managed GSD skills across runtime roots (replaces the former `gsd-sync-skills`)
557
557
  - `--reapply` — reapply local modifications after an update (replaces the former `gsd-reapply-patches`)
558
+ - `--next` (alias `--rc`) — install/refresh from the `@next` RC dist-tag instead of `@latest` (ADR #660); omit for the stable channel
558
559
 
559
560
  - Shows installed vs latest version comparison
560
561
  - Displays changelog entries for versions you've missed
@@ -248,9 +248,9 @@ fi
248
248
  **Codex:**
249
249
  ```bash
250
250
  if [ -n "$CODEX_MODEL" ] && [ "$CODEX_MODEL" != "null" ]; then
251
- cat /tmp/gsd-review-prompt-{phase}.md | codex exec --model "$CODEX_MODEL" --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md
251
+ cat /tmp/gsd-review-prompt-{phase}.md | codex exec --ephemeral --dangerously-bypass-hook-trust --model "$CODEX_MODEL" --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md
252
252
  else
253
- cat /tmp/gsd-review-prompt-{phase}.md | codex exec --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md
253
+ cat /tmp/gsd-review-prompt-{phase}.md | codex exec --ephemeral --dangerously-bypass-hook-trust --skip-git-repo-check - 2>/dev/null > /tmp/gsd-review-codex-{phase}.md
254
254
  fi
255
255
  ```
256
256
 
@@ -75,6 +75,25 @@ If multiple runtime installs are detected and the invoking runtime cannot be det
75
75
  **If VERSION file missing (version resolves to `0.0.0`):** report the installed version as Unknown and proceed to install (treated as `0.0.0` for comparison).
76
76
  </step>
77
77
 
78
+ <step name="parse_update_channel">
79
+ Determine the release channel from `$ARGUMENTS`. This selects which npm dist-tag the entire update flow targets — `latest` (stable) by default, or `next` (the RC channel established by ADR #660) when the user opts in with `--next`/`--rc`:
80
+
81
+ ```bash
82
+ case " $ARGUMENTS " in
83
+ *" --next "*|*" --rc "*)
84
+ TAG="next"
85
+ CHANNEL_LABEL="next (RC)"
86
+ ;;
87
+ *)
88
+ TAG="latest"
89
+ CHANNEL_LABEL="latest (stable)"
90
+ ;;
91
+ esac
92
+ ```
93
+
94
+ `TAG` is restricted to `latest`/`next` by `check-latest-version.cjs` (it rejects any other value with exit 2), so no arbitrary dist-tag can leak through. Omitting `--next`/`--rc` reproduces the prior behavior exactly: `TAG=latest`.
95
+ </step>
96
+
78
97
  <step name="check_latest_version">
79
98
  Check npm for latest version via the deterministic script. **Do NOT run `npm view` or `npm search` directly** — the package name must come from the script, not from a free choice at execution time. (#2992: LLM-driven prescriptions of npm package names produced wrong-package queries; moving the package name into a script constant closes that gap.)
80
99
 
@@ -91,7 +110,7 @@ if [ -z "$GSD_DIR" ]; then
91
110
  LATEST_VERSION=""
92
111
  LATEST_REASON="no_install_detected"
93
112
  else
94
- LATEST_RESULT="$(node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json 2>/dev/null)"
113
+ LATEST_RESULT="$(node "$GSD_DIR/gsd-core/bin/check-latest-version.cjs" --json --tag "$TAG" 2>/dev/null)"
95
114
  LATEST_STATUS=$?
96
115
  # #2993 CR: when node is missing or the script doesn't exist, LATEST_RESULT
97
116
  # is empty and piping it to `jq` produces a parse error on stderr while
@@ -114,7 +133,7 @@ fi
114
133
  ```text
115
134
  Couldn't check for updates (reason: {LATEST_REASON}, exit: {LATEST_STATUS}).
116
135
 
117
- To update manually: `npx -y --package=@opengsd/gsd-core@latest -- gsd-core --global`
136
+ To update manually: `npx -y --package=@opengsd/gsd-core@{TAG} -- gsd-core --global`
118
137
  ```
119
138
 
120
139
  Exit.
@@ -123,6 +142,14 @@ Exit.
123
142
  <step name="compare_versions">
124
143
  Compare installed vs latest:
125
144
 
145
+ **Only when `TAG=next`** (the user passed `--next`/`--rc`), prepend a channel banner so they know they are leaving the stable line — add this line immediately after the `**Latest:**` line in whichever output block renders:
146
+
147
+ **Channel:** {CHANNEL_LABEL}
148
+
149
+ On the default stable channel (`TAG=latest`), do NOT add a channel line — the output must match the prior stable behavior exactly.
150
+
151
+ When `TAG=next`, the "latest" value is the release candidate published under `@next` (e.g. `1.4.0-rc.1`). Apply standard semver precedence for prereleases (`1.4.0-rc.1` is newer than `1.3.1` but older than the final `1.4.0`). Do NOT treat an `-rc.N` suffix as a dev install or as "behind" — offer it as an available update.
152
+
126
153
  **If installed == latest:**
127
154
  ```
128
155
  ## GSD Update
@@ -327,17 +354,17 @@ RUNTIME_FLAG="--$TARGET_RUNTIME"
327
354
 
328
355
  **If LOCAL install:**
329
356
  ```bash
330
- npx -y --package=@opengsd/gsd-core@latest -- gsd-core "$RUNTIME_FLAG" --local
357
+ npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core "$RUNTIME_FLAG" --local
331
358
  ```
332
359
 
333
360
  **If GLOBAL install:**
334
361
  ```bash
335
- npx -y --package=@opengsd/gsd-core@latest -- gsd-core "$RUNTIME_FLAG" --global
362
+ npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core "$RUNTIME_FLAG" --global
336
363
  ```
337
364
 
338
365
  **If UNKNOWN install:**
339
366
  ```bash
340
- npx -y --package=@opengsd/gsd-core@latest -- gsd-core --claude --global
367
+ npx -y --package=@opengsd/gsd-core@"$TAG" -- gsd-core --claude --global
341
368
  ```
342
369
 
343
370
  Capture output. If install fails, show error and exit.
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // gsd-config-reload.js — FileChanged hook: hot-reload GSD config context
4
+ // Fires when .planning/config.json is modified, created, or deleted.
5
+ //
6
+ // When the user edits .planning/config.json mid-session, this hook reads the
7
+ // updated config and injects a summary as additionalContext so the agent knows
8
+ // the new configuration without requiring a session restart.
9
+ //
10
+ // Input (from Claude Code):
11
+ // { session_id, cwd, hook_event_name: "FileChanged",
12
+ // file_path: "/abs/path/.planning/config.json", event: "change"|"add"|"unlink" }
13
+ //
14
+ // Output:
15
+ // { hookSpecificOutput: { hookEventName: "FileChanged", additionalContext: "..." } }
16
+ // or exits 0 silently (if config absent, unreadable, or event is "unlink").
17
+ //
18
+ // Enabled for all Claude Code installs. This hook is always-on — it is a
19
+ // no-op when .planning/config.json is absent (ENOENT → exit 0).
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ let input = '';
25
+ // Timeout guard: if stdin does not close within 8s exit silently rather than
26
+ // hanging until Claude Code kills the process and reports "hook error".
27
+ const stdinTimeout = setTimeout(() => process.exit(0), 8000);
28
+ process.stdin.setEncoding('utf8');
29
+ process.stdin.on('data', chunk => (input += chunk));
30
+ process.stdin.on('end', () => {
31
+ clearTimeout(stdinTimeout);
32
+ try {
33
+ const data = JSON.parse(input);
34
+ const event = data.event; // "change" | "add" | "unlink"
35
+ const filePath = data.file_path || '';
36
+ const cwd = data.cwd || process.cwd();
37
+
38
+ // Only handle the GSD planning config — verify both basename and that the
39
+ // resolved path is .planning/config.json relative to cwd. The hook
40
+ // matcher ('config.json') fires on any watched config.json; this guard
41
+ // ensures an unrelated config.json in node_modules/ or elsewhere does not
42
+ // inject spurious additionalContext.
43
+ const basename = path.basename(filePath);
44
+ if (basename !== 'config.json') {
45
+ process.exit(0);
46
+ }
47
+ const expectedPath = path.resolve(cwd, '.planning', 'config.json');
48
+ if (path.resolve(filePath) !== expectedPath) {
49
+ process.exit(0);
50
+ }
51
+
52
+ // On unlink (deletion) emit a brief notice and exit
53
+ if (event === 'unlink') {
54
+ process.stdout.write(JSON.stringify({
55
+ hookSpecificOutput: {
56
+ hookEventName: 'FileChanged',
57
+ additionalContext:
58
+ 'GSD config (.planning/config.json) was deleted. ' +
59
+ 'Falling back to built-in defaults for this session.',
60
+ },
61
+ }));
62
+ process.exit(0);
63
+ }
64
+
65
+ // Read the updated config file
66
+ let config;
67
+ try {
68
+ const raw = fs.readFileSync(filePath, 'utf8');
69
+ config = JSON.parse(raw);
70
+ } catch (e) {
71
+ if (e && e.code === 'ENOENT') process.exit(0);
72
+ // Malformed JSON — inform the agent without crashing
73
+ process.stdout.write(JSON.stringify({
74
+ hookSpecificOutput: {
75
+ hookEventName: 'FileChanged',
76
+ additionalContext:
77
+ 'GSD config (.planning/config.json) was modified but could not be parsed. ' +
78
+ 'Check the file for JSON syntax errors.',
79
+ },
80
+ }));
81
+ process.exit(0);
82
+ }
83
+
84
+ // Build a concise summary of key config fields the agent cares about
85
+ const lines = ['GSD config reloaded (.planning/config.json updated):'];
86
+
87
+ if (config.runtime) lines.push(` runtime: ${config.runtime}`);
88
+ if (config.mode) lines.push(` mode: ${config.mode}`);
89
+
90
+ // hooks section (opt-in toggles agents act on)
91
+ if (config.hooks && typeof config.hooks === 'object') {
92
+ const hookKeys = Object.entries(config.hooks)
93
+ .filter(([, v]) => v !== undefined)
94
+ .map(([k, v]) => `${k}=${v}`)
95
+ .join(', ');
96
+ if (hookKeys) lines.push(` hooks: { ${hookKeys} }`);
97
+ }
98
+
99
+ // workflow section (key toggles)
100
+ if (config.workflow && typeof config.workflow === 'object') {
101
+ const wfKeys = Object.entries(config.workflow)
102
+ .filter(([, v]) => v !== undefined)
103
+ .map(([k, v]) => `${k}=${v}`)
104
+ .join(', ');
105
+ if (wfKeys) lines.push(` workflow: { ${wfKeys} }`);
106
+ }
107
+
108
+ // model overrides (agents use these)
109
+ if (config.models && typeof config.models === 'object') {
110
+ const modelKeys = Object.entries(config.models)
111
+ .filter(([, v]) => v !== undefined)
112
+ .map(([k, v]) => `${k}=${v}`)
113
+ .join(', ');
114
+ if (modelKeys) lines.push(` models: { ${modelKeys} }`);
115
+ }
116
+
117
+ if (lines.length === 1) {
118
+ // No notable fields — still confirm the reload happened
119
+ lines.push(' (no notable keys changed)');
120
+ }
121
+
122
+ const additionalContext = lines.join('\n');
123
+ process.stdout.write(JSON.stringify({
124
+ hookSpecificOutput: {
125
+ hookEventName: 'FileChanged',
126
+ additionalContext,
127
+ },
128
+ }));
129
+ } catch (e) {
130
+ // Silent fail — never block the session on a config reload error
131
+ process.exit(0);
132
+ }
133
+ });
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // gsd-cursor-post-tool.js — Cursor postToolUse hook (issue #777)
4
+ //
5
+ // Cursor invokes this script after each tool call completes.
6
+ // Protocol: JSON from Cursor on stdin; JSON response on stdout.
7
+ //
8
+ // Input schema (cursor postToolUse):
9
+ // { tool_name, tool_input, tool_output, duration,
10
+ // conversation_id, generation_id, model, hook_event_name,
11
+ // cursor_version, workspace_roots, user_email, transcript_path }
12
+ //
13
+ // Output schema (cursor postToolUse):
14
+ // { additional_context?: string } ← injected as context after the tool use
15
+ //
16
+ // Behaviour:
17
+ // - After a write-class tool that targets .planning/, reminds the agent
18
+ // to keep STATE.md current.
19
+ // - Fails open: any error silently exits 0.
20
+ //
21
+ // Cursor docs: https://cursor.com/docs/hooks
22
+
23
+ 'use strict';
24
+
25
+ const WRITE_TOOL_RE = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
26
+ const PATH_KEY_RE = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
27
+ const PLANNING_PATH_RE = /(^|[\\/])\.planning([\\/]|$)/;
28
+
29
+ let raw = '';
30
+ const stdinTimeout = setTimeout(() => {
31
+ // Timeout guard: exit silently rather than hanging.
32
+ process.exit(0);
33
+ }, 10000);
34
+
35
+ process.stdin.setEncoding('utf8');
36
+ process.stdin.on('data', (chunk) => { raw += chunk; });
37
+ process.stdin.on('end', () => {
38
+ clearTimeout(stdinTimeout);
39
+ try {
40
+ let input;
41
+ try { input = JSON.parse(raw || '{}'); } catch { process.stdout.write(JSON.stringify({})); return; }
42
+
43
+ const toolName = String(
44
+ input.tool_name || input.toolName || ''
45
+ ).toLowerCase();
46
+
47
+ const isWrite = WRITE_TOOL_RE.test(toolName);
48
+ if (!isWrite) { process.stdout.write(JSON.stringify({})); return; }
49
+
50
+ // Collect only PATH-bearing field values (not free-form content).
51
+ const paths = [];
52
+ const walk = (v, depth) => {
53
+ if (depth > 5 || paths.length > 64) return;
54
+ if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; }
55
+ if (v && typeof v === 'object') {
56
+ for (const k of Object.keys(v)) {
57
+ const val = v[k];
58
+ if (typeof val === 'string' && PATH_KEY_RE.test(k)) paths.push(val);
59
+ else walk(val, depth + 1);
60
+ }
61
+ }
62
+ };
63
+ walk(input.tool_input || input.toolInput || {}, 0);
64
+
65
+ if (paths.some((p) => PLANNING_PATH_RE.test(p))) {
66
+ process.stdout.write(JSON.stringify({
67
+ additional_context:
68
+ 'GSD: .planning/ artifact updated — ensure STATE.md reflects the latest phase and progress.',
69
+ }));
70
+ return;
71
+ }
72
+ } catch { /* fall through to empty response */ }
73
+
74
+ process.stdout.write(JSON.stringify({}));
75
+ });
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // gsd-cursor-session-start.js — Cursor sessionStart hook (issue #777)
4
+ //
5
+ // Cursor invokes this script at the start of each agent session.
6
+ // Protocol: JSON from Cursor on stdin; JSON response on stdout.
7
+ //
8
+ // Input schema (cursor sessionStart):
9
+ // { session_id, is_background_agent, composer_mode, conversation_id,
10
+ // generation_id, model, hook_event_name, cursor_version,
11
+ // workspace_roots, user_email, transcript_path }
12
+ //
13
+ // Output schema (cursor sessionStart):
14
+ // { additional_context?: string } ← injected into the session as context
15
+ //
16
+ // Behaviour:
17
+ // - If .planning/STATE.md is present, injects a brief state reminder.
18
+ // - If absent, nudges the user toward /gsd:new-project.
19
+ // - Fails open: any error silently exits 0 so a hook bug never wedges Cursor.
20
+ //
21
+ // Cursor docs: https://cursor.com/docs/hooks
22
+
23
+ 'use strict';
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const MSG_PRESENT =
29
+ 'GSD: .planning/STATE.md is present — review the current phase and any blockers before acting.';
30
+ const MSG_ABSENT =
31
+ 'GSD: no .planning/ workflow found — run /gsd:new-project to start a tracked workflow.';
32
+
33
+ let raw = '';
34
+ const stdinTimeout = setTimeout(() => {
35
+ // Timeout guard: exit silently rather than hanging.
36
+ process.exit(0);
37
+ }, 10000);
38
+
39
+ process.stdin.setEncoding('utf8');
40
+ process.stdin.on('data', (chunk) => { raw += chunk; });
41
+ process.stdin.on('end', () => {
42
+ clearTimeout(stdinTimeout);
43
+ try {
44
+ const statePath = path.join(process.cwd(), '.planning', 'STATE.md');
45
+ const statePresent = fs.existsSync(statePath);
46
+ const msg = statePresent ? MSG_PRESENT : MSG_ABSENT;
47
+ process.stdout.write(JSON.stringify({ additional_context: msg }));
48
+ } catch {
49
+ // Fail open — never block a Cursor session because of a GSD hook error.
50
+ process.stdout.write(JSON.stringify({}));
51
+ }
52
+ });
@@ -18,7 +18,10 @@
18
18
  const MANAGED_HOOKS = [
19
19
  'gsd-check-update-worker.js',
20
20
  'gsd-check-update.js',
21
+ 'gsd-config-reload.js',
21
22
  'gsd-context-monitor.js',
23
+ 'gsd-cursor-post-tool.js',
24
+ 'gsd-cursor-session-start.js',
22
25
  'gsd-graphify-update.sh',
23
26
  'gsd-phase-boundary.sh',
24
27
  'gsd-prompt-guard.js',
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ // gsd-config-reload.js — FileChanged hook: hot-reload GSD config context
4
+ // Fires when .planning/config.json is modified, created, or deleted.
5
+ //
6
+ // When the user edits .planning/config.json mid-session, this hook reads the
7
+ // updated config and injects a summary as additionalContext so the agent knows
8
+ // the new configuration without requiring a session restart.
9
+ //
10
+ // Input (from Claude Code):
11
+ // { session_id, cwd, hook_event_name: "FileChanged",
12
+ // file_path: "/abs/path/.planning/config.json", event: "change"|"add"|"unlink" }
13
+ //
14
+ // Output:
15
+ // { hookSpecificOutput: { hookEventName: "FileChanged", additionalContext: "..." } }
16
+ // or exits 0 silently (if config absent, unreadable, or event is "unlink").
17
+ //
18
+ // Enabled for all Claude Code installs. This hook is always-on — it is a
19
+ // no-op when .planning/config.json is absent (ENOENT → exit 0).
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+
24
+ let input = '';
25
+ // Timeout guard: if stdin does not close within 8s exit silently rather than
26
+ // hanging until Claude Code kills the process and reports "hook error".
27
+ const stdinTimeout = setTimeout(() => process.exit(0), 8000);
28
+ process.stdin.setEncoding('utf8');
29
+ process.stdin.on('data', chunk => (input += chunk));
30
+ process.stdin.on('end', () => {
31
+ clearTimeout(stdinTimeout);
32
+ try {
33
+ const data = JSON.parse(input);
34
+ const event = data.event; // "change" | "add" | "unlink"
35
+ const filePath = data.file_path || '';
36
+ const cwd = data.cwd || process.cwd();
37
+
38
+ // Only handle the GSD planning config — verify both basename and that the
39
+ // resolved path is .planning/config.json relative to cwd. The hook
40
+ // matcher ('config.json') fires on any watched config.json; this guard
41
+ // ensures an unrelated config.json in node_modules/ or elsewhere does not
42
+ // inject spurious additionalContext.
43
+ const basename = path.basename(filePath);
44
+ if (basename !== 'config.json') {
45
+ process.exit(0);
46
+ }
47
+ const expectedPath = path.resolve(cwd, '.planning', 'config.json');
48
+ if (path.resolve(filePath) !== expectedPath) {
49
+ process.exit(0);
50
+ }
51
+
52
+ // On unlink (deletion) emit a brief notice and exit
53
+ if (event === 'unlink') {
54
+ process.stdout.write(JSON.stringify({
55
+ hookSpecificOutput: {
56
+ hookEventName: 'FileChanged',
57
+ additionalContext:
58
+ 'GSD config (.planning/config.json) was deleted. ' +
59
+ 'Falling back to built-in defaults for this session.',
60
+ },
61
+ }));
62
+ process.exit(0);
63
+ }
64
+
65
+ // Read the updated config file
66
+ let config;
67
+ try {
68
+ const raw = fs.readFileSync(filePath, 'utf8');
69
+ config = JSON.parse(raw);
70
+ } catch (e) {
71
+ if (e && e.code === 'ENOENT') process.exit(0);
72
+ // Malformed JSON — inform the agent without crashing
73
+ process.stdout.write(JSON.stringify({
74
+ hookSpecificOutput: {
75
+ hookEventName: 'FileChanged',
76
+ additionalContext:
77
+ 'GSD config (.planning/config.json) was modified but could not be parsed. ' +
78
+ 'Check the file for JSON syntax errors.',
79
+ },
80
+ }));
81
+ process.exit(0);
82
+ }
83
+
84
+ // Build a concise summary of key config fields the agent cares about
85
+ const lines = ['GSD config reloaded (.planning/config.json updated):'];
86
+
87
+ if (config.runtime) lines.push(` runtime: ${config.runtime}`);
88
+ if (config.mode) lines.push(` mode: ${config.mode}`);
89
+
90
+ // hooks section (opt-in toggles agents act on)
91
+ if (config.hooks && typeof config.hooks === 'object') {
92
+ const hookKeys = Object.entries(config.hooks)
93
+ .filter(([, v]) => v !== undefined)
94
+ .map(([k, v]) => `${k}=${v}`)
95
+ .join(', ');
96
+ if (hookKeys) lines.push(` hooks: { ${hookKeys} }`);
97
+ }
98
+
99
+ // workflow section (key toggles)
100
+ if (config.workflow && typeof config.workflow === 'object') {
101
+ const wfKeys = Object.entries(config.workflow)
102
+ .filter(([, v]) => v !== undefined)
103
+ .map(([k, v]) => `${k}=${v}`)
104
+ .join(', ');
105
+ if (wfKeys) lines.push(` workflow: { ${wfKeys} }`);
106
+ }
107
+
108
+ // model overrides (agents use these)
109
+ if (config.models && typeof config.models === 'object') {
110
+ const modelKeys = Object.entries(config.models)
111
+ .filter(([, v]) => v !== undefined)
112
+ .map(([k, v]) => `${k}=${v}`)
113
+ .join(', ');
114
+ if (modelKeys) lines.push(` models: { ${modelKeys} }`);
115
+ }
116
+
117
+ if (lines.length === 1) {
118
+ // No notable fields — still confirm the reload happened
119
+ lines.push(' (no notable keys changed)');
120
+ }
121
+
122
+ const additionalContext = lines.join('\n');
123
+ process.stdout.write(JSON.stringify({
124
+ hookSpecificOutput: {
125
+ hookEventName: 'FileChanged',
126
+ additionalContext,
127
+ },
128
+ }));
129
+ } catch (e) {
130
+ // Silent fail — never block the session on a config reload error
131
+ process.exit(0);
132
+ }
133
+ });