@opengsd/gsd-core 1.5.0-rc.2 → 1.5.0-rc.4

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 (131) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.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-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +47 -0
  13. package/agents/gsd-nyquist-auditor.md +1 -0
  14. package/agents/gsd-phase-researcher.md +1 -1
  15. package/agents/gsd-plan-checker.md +1 -1
  16. package/agents/gsd-planner.md +1 -1
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +1 -1
  19. package/agents/gsd-roadmapper.md +55 -2
  20. package/agents/gsd-security-auditor.md +1 -0
  21. package/agents/gsd-ui-auditor.md +1 -1
  22. package/agents/gsd-ui-checker.md +1 -1
  23. package/agents/gsd-ui-researcher.md +1 -1
  24. package/agents/gsd-verifier.md +13 -2
  25. package/bin/install.js +61 -64
  26. package/commands/gsd/mempalace-capture.md +71 -0
  27. package/commands/gsd/mempalace-recall.md +102 -0
  28. package/commands/gsd/ns-context.md +4 -2
  29. package/commands/gsd/progress.md +2 -1
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +277 -95
  32. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  33. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  35. package/gsd-core/bin/lib/capability-state.cjs +128 -21
  36. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  37. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  38. package/gsd-core/bin/lib/clusters.cjs +2 -0
  39. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  40. package/gsd-core/bin/lib/commands.cjs +33 -10
  41. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  42. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  43. package/gsd-core/bin/lib/config.cjs +81 -26
  44. package/gsd-core/bin/lib/core.cjs +5 -2
  45. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  46. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  47. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  48. package/gsd-core/bin/lib/init.cjs +36 -11
  49. package/gsd-core/bin/lib/install-profiles.cjs +57 -1
  50. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  51. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  52. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  53. package/gsd-core/bin/lib/phase.cjs +99 -23
  54. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  55. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  56. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  57. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  58. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  59. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  60. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
  61. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
  62. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  63. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  64. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  65. package/gsd-core/bin/lib/state-document.cjs +46 -1
  66. package/gsd-core/bin/lib/state.cjs +461 -94
  67. package/gsd-core/bin/lib/verify.cjs +92 -8
  68. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  69. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  70. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  71. package/gsd-core/bin/shared/model-catalog.json +1 -0
  72. package/gsd-core/references/edge-probe.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  74. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  75. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  76. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  77. package/gsd-core/references/prohibition-probe.md +248 -0
  78. package/gsd-core/templates/config.json +1 -1
  79. package/gsd-core/templates/spec.md +14 -0
  80. package/gsd-core/workflows/audit-milestone.md +5 -3
  81. package/gsd-core/workflows/autonomous.md +10 -5
  82. package/gsd-core/workflows/code-review-fix.md +9 -7
  83. package/gsd-core/workflows/code-review.md +8 -6
  84. package/gsd-core/workflows/complete-milestone.md +1 -5
  85. package/gsd-core/workflows/discuss-phase.md +14 -0
  86. package/gsd-core/workflows/execute-phase.md +86 -146
  87. package/gsd-core/workflows/execute-plan.md +21 -6
  88. package/gsd-core/workflows/help/modes/full.md +7 -1
  89. package/gsd-core/workflows/new-project.md +3 -3
  90. package/gsd-core/workflows/next.md +50 -2
  91. package/gsd-core/workflows/pause-work.md +7 -1
  92. package/gsd-core/workflows/plan-phase.md +91 -221
  93. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  94. package/gsd-core/workflows/pr-branch.md +4 -2
  95. package/gsd-core/workflows/profile-user.md +3 -1
  96. package/gsd-core/workflows/progress.md +58 -1
  97. package/gsd-core/workflows/quick.md +12 -8
  98. package/gsd-core/workflows/resume-project.md +17 -1
  99. package/gsd-core/workflows/review.md +19 -2
  100. package/gsd-core/workflows/secure-phase.md +4 -2
  101. package/gsd-core/workflows/settings-advanced.md +2 -0
  102. package/gsd-core/workflows/settings.md +27 -1
  103. package/gsd-core/workflows/ship.md +58 -5
  104. package/gsd-core/workflows/spec-phase.md +75 -0
  105. package/gsd-core/workflows/validate-phase.md +4 -2
  106. package/gsd-core/workflows/verify-phase.md +14 -4
  107. package/gsd-core/workflows/verify-work.md +27 -11
  108. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  109. package/hooks/dist/gsd-statusline.js +1 -1
  110. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  111. package/hooks/gsd-ensure-canonical-path.js +305 -0
  112. package/hooks/gsd-statusline.js +1 -1
  113. package/hooks/hooks.json +1 -0
  114. package/hooks/managed-hooks-registry.cjs +1 -0
  115. package/package.json +5 -4
  116. package/scripts/affected-tests-lib.cjs +16 -4
  117. package/scripts/build-hooks.js +7 -0
  118. package/scripts/changeset/new.cjs +17 -3
  119. package/scripts/fix-slash-commands.cjs +15 -3
  120. package/scripts/gen-capability-registry.cjs +373 -49
  121. package/scripts/gen-inventory-manifest.cjs +1 -4
  122. package/scripts/gen-loop-host-contract.cjs +55 -0
  123. package/scripts/issue-version-gate.cjs +140 -0
  124. package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
  125. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  126. package/scripts/lint-test-file-count.allowlist.json +14 -0
  127. package/scripts/mutation-matrix.cjs +108 -7
  128. package/scripts/pr-target-policy.cjs +63 -0
  129. package/scripts/release-tarball-smoke.cjs +7 -1
  130. package/scripts/research-profiles.cjs +5 -5
  131. package/scripts/run-tests.cjs +178 -17
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ //
4
+ // gsd-ensure-canonical-path — SessionStart hook (#997)
5
+ //
6
+ // PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
7
+ // hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
8
+ // templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
9
+ // environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
10
+ // In a classic `bin/install.js` install the canonical path is a real directory
11
+ // holding the bundled tree, so the includes resolve. In a Claude Code
12
+ // *marketplace plugin* install the plugin manager only unpacks the package
13
+ // into the version-pinned plugin cache and never runs `bin/install.js`, so
14
+ // `~/.claude/gsd-core/` is never created and every @-include resolves to
15
+ // nothing — every agent that depends on one fails (e.g. the executor).
16
+ //
17
+ // FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
18
+ // set and a bundled `gsd-core/` tree found beneath it), ensure
19
+ // `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
20
+ // references, templates, workflows) are symlinked to the plugin's bundled tree.
21
+ // This changes ZERO @-references, is a no-op in classic installs (where each
22
+ // subdir is already a real directory), preserves user-generated files
23
+ // (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
24
+ // after `claude plugin update` rotates the version dir, and uses Windows
25
+ // junctions for symlinks on win32.
26
+ //
27
+ // SECURITY: the resolved bundled-tree path and every per-subdir link target are
28
+ // kept strictly inside the resolved plugin root (realpath-normalised, prefix-
29
+ // checked). A real (non-symlink) file or directory already sitting at a managed
30
+ // link target is NEVER clobbered.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const os = require('os');
37
+
38
+ // Immutable, bundled subdirectories that the canonical path must expose. These
39
+ // are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
40
+ // User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
41
+ // NOT in this list and are never created, moved, or deleted by this hook.
42
+ const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
43
+
44
+ /**
45
+ * Resolve the canonical runtime config dir for the active runtime.
46
+ *
47
+ * Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
48
+ * gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
49
+ * canonical GSD tree always lives at `<configDir>/gsd-core`.
50
+ */
51
+ function resolveConfigDir(homeDir, env) {
52
+ const envDir = env.CLAUDE_CONFIG_DIR;
53
+ if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
54
+ return envDir;
55
+ }
56
+ return path.join(homeDir, '.claude');
57
+ }
58
+
59
+ /**
60
+ * Locate the bundled `gsd-core/` tree beneath a plugin root.
61
+ *
62
+ * Claude Code unpacks the package so the bundled tree sits at
63
+ * `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
64
+ * that directory, or null if it is absent / not a directory. Resolving with
65
+ * realpath collapses symlinks/.. so the subsequent containment check is sound.
66
+ */
67
+ function resolveBundledTree(pluginRoot) {
68
+ if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
69
+ return null;
70
+ }
71
+ let root;
72
+ try {
73
+ root = fs.realpathSync(pluginRoot);
74
+ } catch (_) {
75
+ return null; // plugin root does not exist
76
+ }
77
+ const bundled = path.join(root, 'gsd-core');
78
+ let bundledReal;
79
+ try {
80
+ // The bundled tree must be a real directory (or a symlink to one) that
81
+ // resolves to a path inside the plugin root. realpathSync throws ENOENT/
82
+ // ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
83
+ // check is needed. Reject anything that does not resolve to a directory.
84
+ bundledReal = fs.realpathSync(bundled);
85
+ if (!fs.statSync(bundledReal).isDirectory()) return null;
86
+ } catch (_) {
87
+ return null;
88
+ }
89
+ // SECURITY: the resolved bundled tree must stay inside the resolved plugin
90
+ // root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
91
+ // is rejected — we never link the canonical path at content we do not own.
92
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
93
+ if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
94
+ return null;
95
+ }
96
+ return bundledReal;
97
+ }
98
+
99
+ /**
100
+ * The fs.symlinkSync `type` to use for a directory link on a given platform.
101
+ *
102
+ * On Windows, unprivileged users cannot create symlinks but CAN create
103
+ * junctions; 'junction' requires an absolute target (we always pass one). On
104
+ * POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
105
+ * without a Windows host.
106
+ */
107
+ function dirLinkType(platform) {
108
+ return platform === 'win32' ? 'junction' : 'dir';
109
+ }
110
+
111
+ /**
112
+ * Create a directory symlink (junction on win32) from linkPath -> target.
113
+ * Throws on real failure so the caller records it.
114
+ */
115
+ function createDirLink(target, linkPath, platform) {
116
+ fs.symlinkSync(target, linkPath, dirLinkType(platform));
117
+ }
118
+
119
+ /**
120
+ * Does `linkPath` already correctly point at `expectedTarget`?
121
+ * Used to make the hook idempotent — a correct link is left untouched.
122
+ */
123
+ function linkPointsAt(linkPath, expectedTarget) {
124
+ try {
125
+ if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
126
+ const resolved = fs.realpathSync(linkPath);
127
+ return resolved === fs.realpathSync(expectedTarget);
128
+ } catch (_) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
135
+ *
136
+ * Pure, dependency-injected core so tests drive it with a fake home, fake
137
+ * plugin root, and explicit platform. Returns a structured result describing
138
+ * exactly what happened (never throws for ordinary conditions — only truly
139
+ * unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
140
+ * a hook failure never blocks a session).
141
+ *
142
+ * @param {object} opts
143
+ * @param {string} [opts.homeDir] home directory (default os.homedir())
144
+ * @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env)
145
+ * @param {string} [opts.platform] process.platform override (tests)
146
+ * @param {object} [opts.env] environment (default process.env)
147
+ * @returns {{status:string, canonicalDir?:string, bundledTree?:string,
148
+ * linked?:string[], prunedStale?:string[], preserved?:string[],
149
+ * skipped?:string[], reason?:string}}
150
+ */
151
+ function ensureCanonicalPath(opts = {}) {
152
+ const env = opts.env || process.env;
153
+ const homeDir = opts.homeDir || os.homedir();
154
+ const platform = opts.platform || process.platform;
155
+ const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT;
156
+
157
+ // Uniform result contract: every return carries the four action arrays so
158
+ // callers can read result.linked/etc without first switching on status.
159
+ const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] };
160
+
161
+ // No plugin context → classic/npm install or non-plugin runtime. No-op.
162
+ const bundledTree = resolveBundledTree(pluginRoot);
163
+ if (!bundledTree) {
164
+ return { status: 'noop', reason: 'no-plugin-bundle', ...empty };
165
+ }
166
+
167
+ const configDir = resolveConfigDir(homeDir, env);
168
+ const canonicalDir = path.join(configDir, 'gsd-core');
169
+
170
+ // Inspect the canonical path itself exactly once.
171
+ // - If it is a SYMLINK, the user (or another tool) deliberately pointed the
172
+ // canonical path elsewhere. We must NOT write managed links *through* that
173
+ // symlink into a directory we do not own — bail as a no-op.
174
+ // - If it is a REAL directory with at least one REAL (non-link) managed
175
+ // subdir, this is a classic `bin/install.js` install — leave it alone.
176
+ let canonicalStat = null;
177
+ try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; }
178
+
179
+ if (canonicalStat && canonicalStat.isSymbolicLink()) {
180
+ return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty };
181
+ }
182
+
183
+ if (canonicalStat && canonicalStat.isDirectory()) {
184
+ for (const sub of MANAGED_SUBDIRS) {
185
+ try {
186
+ const subSt = fs.lstatSync(path.join(canonicalDir, sub));
187
+ if (subSt.isDirectory() && !subSt.isSymbolicLink()) {
188
+ return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty };
189
+ }
190
+ } catch (_) { /* subdir absent — keep checking */ }
191
+ }
192
+ }
193
+
194
+ // Ensure the canonical directory exists (as a real directory). We never
195
+ // replace an existing real directory; recursive mkdir is a no-op if present.
196
+ try {
197
+ fs.mkdirSync(canonicalDir, { recursive: true });
198
+ } catch (e) {
199
+ return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty };
200
+ }
201
+
202
+ const linked = [];
203
+ const prunedStale = [];
204
+ const preserved = [];
205
+ const skipped = [];
206
+
207
+ // SECURITY: prefix used to confirm every per-subdir link target resolves
208
+ // strictly inside the bundled tree. Defence-in-depth against a tampered
209
+ // bundle that ships an internally-escaping symlink at <bundledTree>/<sub>.
210
+ const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep;
211
+
212
+ for (const sub of MANAGED_SUBDIRS) {
213
+ const target = path.join(bundledTree, sub);
214
+ // Only expose subdirs the bundle actually ships, AND only when the target
215
+ // resolves to a real directory that stays inside the bundled tree. A
216
+ // subdir whose realpath escapes the bundle (e.g. a planted symlink) is
217
+ // skipped — we never point the canonical path at content outside the
218
+ // validated plugin bundle.
219
+ let targetIsDir = false;
220
+ try {
221
+ const targetReal = fs.realpathSync(target);
222
+ // A NAMED subdir must resolve strictly BELOW the bundled tree root. We do
223
+ // NOT accept targetReal === bundledTree here: a subdir that self-links to
224
+ // the tree root would otherwise be exposed at the wrong level (e.g.
225
+ // `workflows` -> the whole tree), making `@.../workflows/foo` resolve to
226
+ // `<tree>/foo` instead of `<tree>/workflows/foo`.
227
+ targetIsDir = fs.statSync(targetReal).isDirectory()
228
+ && targetReal.startsWith(bundledWithSep);
229
+ } catch (_) { targetIsDir = false; }
230
+ if (!targetIsDir) {
231
+ skipped.push(sub);
232
+ continue;
233
+ }
234
+
235
+ const linkPath = path.join(canonicalDir, sub);
236
+
237
+ // Already a correct link → idempotent no-op.
238
+ if (linkPointsAt(linkPath, target)) {
239
+ linked.push(sub);
240
+ continue;
241
+ }
242
+
243
+ let existing = null;
244
+ try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; }
245
+
246
+ if (existing) {
247
+ // lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows
248
+ // junctions, so this single predicate identifies every GSD-managed link.
249
+ if (existing.isSymbolicLink()) {
250
+ // A GSD-managed link that is stale or points elsewhere (e.g. previous
251
+ // plugin version after `claude plugin update`). Prune and recreate.
252
+ try {
253
+ fs.unlinkSync(linkPath);
254
+ prunedStale.push(sub);
255
+ } catch (e) {
256
+ skipped.push(sub);
257
+ continue;
258
+ }
259
+ } else {
260
+ // A REAL file or directory the user (or a classic install) owns. NEVER
261
+ // clobber it — preserve it untouched. This is the USER-PROFILE.md /
262
+ // partially-real-canonical-dir safety case.
263
+ preserved.push(sub);
264
+ continue;
265
+ }
266
+ }
267
+
268
+ try {
269
+ createDirLink(target, linkPath, platform);
270
+ linked.push(sub);
271
+ } catch (e) {
272
+ skipped.push(sub);
273
+ }
274
+ }
275
+
276
+ return {
277
+ status: 'ensured',
278
+ canonicalDir,
279
+ bundledTree,
280
+ linked,
281
+ prunedStale,
282
+ preserved,
283
+ skipped,
284
+ };
285
+ }
286
+
287
+ module.exports = {
288
+ ensureCanonicalPath,
289
+ resolveBundledTree,
290
+ resolveConfigDir,
291
+ dirLinkType,
292
+ MANAGED_SUBDIRS,
293
+ };
294
+
295
+ // CLI entry: run on SessionStart. Never block the session — any unexpected
296
+ // failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep
297
+ // the hook silent in normal operation.
298
+ if (require.main === module) {
299
+ try {
300
+ ensureCanonicalPath();
301
+ } catch (_) {
302
+ // Best-effort: a canonical-path failure must never abort a session.
303
+ }
304
+ process.exit(0);
305
+ }
@@ -312,7 +312,7 @@ function runStatusline() {
312
312
  const totalCtx = data.context_window?.total_tokens || 1_000_000;
313
313
  const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10);
314
314
  const AUTO_COMPACT_BUFFER_PCT = acw > 0
315
- ? Math.min(100, (acw / totalCtx) * 100)
315
+ ? Math.min(100, Math.max(0, (1 - acw / totalCtx) * 100))
316
316
  : 16.5;
317
317
  let ctx = '';
318
318
  if (remaining != null) {
@@ -22,6 +22,7 @@ const MANAGED_HOOKS = [
22
22
  'gsd-context-monitor.js',
23
23
  'gsd-cursor-post-tool.js',
24
24
  'gsd-cursor-session-start.js',
25
+ 'gsd-ensure-canonical-path.js',
25
26
  'gsd-graphify-update.sh',
26
27
  'gsd-phase-boundary.sh',
27
28
  'gsd-prompt-guard.js',
@@ -0,0 +1,305 @@
1
+ #!/usr/bin/env node
2
+ // gsd-hook-version: {{GSD_VERSION}}
3
+ //
4
+ // gsd-ensure-canonical-path — SessionStart hook (#997)
5
+ //
6
+ // PROBLEM: GSD agents/commands/templates use markdown `@`-file-includes that
7
+ // hardcode the canonical path `@~/.claude/gsd-core/...` (references, workflows,
8
+ // templates, contexts, bin). Markdown @-includes expand `~` but do NOT expand
9
+ // environment variables, so `${CLAUDE_PLUGIN_ROOT}` cannot be used in them.
10
+ // In a classic `bin/install.js` install the canonical path is a real directory
11
+ // holding the bundled tree, so the includes resolve. In a Claude Code
12
+ // *marketplace plugin* install the plugin manager only unpacks the package
13
+ // into the version-pinned plugin cache and never runs `bin/install.js`, so
14
+ // `~/.claude/gsd-core/` is never created and every @-include resolves to
15
+ // nothing — every agent that depends on one fails (e.g. the executor).
16
+ //
17
+ // FIX: On SessionStart, when running under a plugin install (CLAUDE_PLUGIN_ROOT
18
+ // set and a bundled `gsd-core/` tree found beneath it), ensure
19
+ // `~/.claude/gsd-core/` exists and its immutable subdirs (bin, contexts,
20
+ // references, templates, workflows) are symlinked to the plugin's bundled tree.
21
+ // This changes ZERO @-references, is a no-op in classic installs (where each
22
+ // subdir is already a real directory), preserves user-generated files
23
+ // (USER-PROFILE.md, STATE.md, VERSION, …), prunes stale links so it self-heals
24
+ // after `claude plugin update` rotates the version dir, and uses Windows
25
+ // junctions for symlinks on win32.
26
+ //
27
+ // SECURITY: the resolved bundled-tree path and every per-subdir link target are
28
+ // kept strictly inside the resolved plugin root (realpath-normalised, prefix-
29
+ // checked). A real (non-symlink) file or directory already sitting at a managed
30
+ // link target is NEVER clobbered.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('fs');
35
+ const path = require('path');
36
+ const os = require('os');
37
+
38
+ // Immutable, bundled subdirectories that the canonical path must expose. These
39
+ // are the directories `@~/.claude/gsd-core/<subdir>/...` includes point into.
40
+ // User-generated artifacts (USER-PROFILE.md, STATE.md, VERSION, config, …) are
41
+ // NOT in this list and are never created, moved, or deleted by this hook.
42
+ const MANAGED_SUBDIRS = ['bin', 'contexts', 'references', 'templates', 'workflows'];
43
+
44
+ /**
45
+ * Resolve the canonical runtime config dir for the active runtime.
46
+ *
47
+ * Honours CLAUDE_CONFIG_DIR for custom/multi-account setups (mirrors
48
+ * gsd-check-update.js detectConfigDir), else falls back to ~/.claude. The
49
+ * canonical GSD tree always lives at `<configDir>/gsd-core`.
50
+ */
51
+ function resolveConfigDir(homeDir, env) {
52
+ const envDir = env.CLAUDE_CONFIG_DIR;
53
+ if (envDir && typeof envDir === 'string' && envDir.trim().length > 0) {
54
+ return envDir;
55
+ }
56
+ return path.join(homeDir, '.claude');
57
+ }
58
+
59
+ /**
60
+ * Locate the bundled `gsd-core/` tree beneath a plugin root.
61
+ *
62
+ * Claude Code unpacks the package so the bundled tree sits at
63
+ * `<pluginRoot>/gsd-core/`. Returns the absolute, realpath-normalised path to
64
+ * that directory, or null if it is absent / not a directory. Resolving with
65
+ * realpath collapses symlinks/.. so the subsequent containment check is sound.
66
+ */
67
+ function resolveBundledTree(pluginRoot) {
68
+ if (!pluginRoot || typeof pluginRoot !== 'string' || pluginRoot.trim().length === 0) {
69
+ return null;
70
+ }
71
+ let root;
72
+ try {
73
+ root = fs.realpathSync(pluginRoot);
74
+ } catch (_) {
75
+ return null; // plugin root does not exist
76
+ }
77
+ const bundled = path.join(root, 'gsd-core');
78
+ let bundledReal;
79
+ try {
80
+ // The bundled tree must be a real directory (or a symlink to one) that
81
+ // resolves to a path inside the plugin root. realpathSync throws ENOENT/
82
+ // ENOTDIR if <pluginRoot>/gsd-core is absent, so no separate existence
83
+ // check is needed. Reject anything that does not resolve to a directory.
84
+ bundledReal = fs.realpathSync(bundled);
85
+ if (!fs.statSync(bundledReal).isDirectory()) return null;
86
+ } catch (_) {
87
+ return null;
88
+ }
89
+ // SECURITY: the resolved bundled tree must stay inside the resolved plugin
90
+ // root. A crafted symlink at <pluginRoot>/gsd-core pointing outside the root
91
+ // is rejected — we never link the canonical path at content we do not own.
92
+ const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
93
+ if (bundledReal !== root && !bundledReal.startsWith(rootWithSep)) {
94
+ return null;
95
+ }
96
+ return bundledReal;
97
+ }
98
+
99
+ /**
100
+ * The fs.symlinkSync `type` to use for a directory link on a given platform.
101
+ *
102
+ * On Windows, unprivileged users cannot create symlinks but CAN create
103
+ * junctions; 'junction' requires an absolute target (we always pass one). On
104
+ * POSIX a 'dir' symlink is used. Exported so the win32 branch is unit-testable
105
+ * without a Windows host.
106
+ */
107
+ function dirLinkType(platform) {
108
+ return platform === 'win32' ? 'junction' : 'dir';
109
+ }
110
+
111
+ /**
112
+ * Create a directory symlink (junction on win32) from linkPath -> target.
113
+ * Throws on real failure so the caller records it.
114
+ */
115
+ function createDirLink(target, linkPath, platform) {
116
+ fs.symlinkSync(target, linkPath, dirLinkType(platform));
117
+ }
118
+
119
+ /**
120
+ * Does `linkPath` already correctly point at `expectedTarget`?
121
+ * Used to make the hook idempotent — a correct link is left untouched.
122
+ */
123
+ function linkPointsAt(linkPath, expectedTarget) {
124
+ try {
125
+ if (!fs.lstatSync(linkPath).isSymbolicLink()) return false;
126
+ const resolved = fs.realpathSync(linkPath);
127
+ return resolved === fs.realpathSync(expectedTarget);
128
+ } catch (_) {
129
+ return false;
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Ensure the canonical `~/.claude/gsd-core/` path exposes the bundled subdirs.
135
+ *
136
+ * Pure, dependency-injected core so tests drive it with a fake home, fake
137
+ * plugin root, and explicit platform. Returns a structured result describing
138
+ * exactly what happened (never throws for ordinary conditions — only truly
139
+ * unexpected I/O errors propagate, and the thin CLI wrapper swallows those so
140
+ * a hook failure never blocks a session).
141
+ *
142
+ * @param {object} opts
143
+ * @param {string} [opts.homeDir] home directory (default os.homedir())
144
+ * @param {string} [opts.pluginRoot] CLAUDE_PLUGIN_ROOT (default from env)
145
+ * @param {string} [opts.platform] process.platform override (tests)
146
+ * @param {object} [opts.env] environment (default process.env)
147
+ * @returns {{status:string, canonicalDir?:string, bundledTree?:string,
148
+ * linked?:string[], prunedStale?:string[], preserved?:string[],
149
+ * skipped?:string[], reason?:string}}
150
+ */
151
+ function ensureCanonicalPath(opts = {}) {
152
+ const env = opts.env || process.env;
153
+ const homeDir = opts.homeDir || os.homedir();
154
+ const platform = opts.platform || process.platform;
155
+ const pluginRoot = opts.pluginRoot !== undefined ? opts.pluginRoot : env.CLAUDE_PLUGIN_ROOT;
156
+
157
+ // Uniform result contract: every return carries the four action arrays so
158
+ // callers can read result.linked/etc without first switching on status.
159
+ const empty = { linked: [], prunedStale: [], preserved: [], skipped: [] };
160
+
161
+ // No plugin context → classic/npm install or non-plugin runtime. No-op.
162
+ const bundledTree = resolveBundledTree(pluginRoot);
163
+ if (!bundledTree) {
164
+ return { status: 'noop', reason: 'no-plugin-bundle', ...empty };
165
+ }
166
+
167
+ const configDir = resolveConfigDir(homeDir, env);
168
+ const canonicalDir = path.join(configDir, 'gsd-core');
169
+
170
+ // Inspect the canonical path itself exactly once.
171
+ // - If it is a SYMLINK, the user (or another tool) deliberately pointed the
172
+ // canonical path elsewhere. We must NOT write managed links *through* that
173
+ // symlink into a directory we do not own — bail as a no-op.
174
+ // - If it is a REAL directory with at least one REAL (non-link) managed
175
+ // subdir, this is a classic `bin/install.js` install — leave it alone.
176
+ let canonicalStat = null;
177
+ try { canonicalStat = fs.lstatSync(canonicalDir); } catch (_) { canonicalStat = null; }
178
+
179
+ if (canonicalStat && canonicalStat.isSymbolicLink()) {
180
+ return { status: 'noop', reason: 'canonical-is-symlink', canonicalDir, bundledTree, ...empty };
181
+ }
182
+
183
+ if (canonicalStat && canonicalStat.isDirectory()) {
184
+ for (const sub of MANAGED_SUBDIRS) {
185
+ try {
186
+ const subSt = fs.lstatSync(path.join(canonicalDir, sub));
187
+ if (subSt.isDirectory() && !subSt.isSymbolicLink()) {
188
+ return { status: 'noop', reason: 'classic-install', canonicalDir, bundledTree, ...empty };
189
+ }
190
+ } catch (_) { /* subdir absent — keep checking */ }
191
+ }
192
+ }
193
+
194
+ // Ensure the canonical directory exists (as a real directory). We never
195
+ // replace an existing real directory; recursive mkdir is a no-op if present.
196
+ try {
197
+ fs.mkdirSync(canonicalDir, { recursive: true });
198
+ } catch (e) {
199
+ return { status: 'error', reason: `mkdir-canonical: ${e.code || e.message}`, canonicalDir, bundledTree, ...empty };
200
+ }
201
+
202
+ const linked = [];
203
+ const prunedStale = [];
204
+ const preserved = [];
205
+ const skipped = [];
206
+
207
+ // SECURITY: prefix used to confirm every per-subdir link target resolves
208
+ // strictly inside the bundled tree. Defence-in-depth against a tampered
209
+ // bundle that ships an internally-escaping symlink at <bundledTree>/<sub>.
210
+ const bundledWithSep = bundledTree.endsWith(path.sep) ? bundledTree : bundledTree + path.sep;
211
+
212
+ for (const sub of MANAGED_SUBDIRS) {
213
+ const target = path.join(bundledTree, sub);
214
+ // Only expose subdirs the bundle actually ships, AND only when the target
215
+ // resolves to a real directory that stays inside the bundled tree. A
216
+ // subdir whose realpath escapes the bundle (e.g. a planted symlink) is
217
+ // skipped — we never point the canonical path at content outside the
218
+ // validated plugin bundle.
219
+ let targetIsDir = false;
220
+ try {
221
+ const targetReal = fs.realpathSync(target);
222
+ // A NAMED subdir must resolve strictly BELOW the bundled tree root. We do
223
+ // NOT accept targetReal === bundledTree here: a subdir that self-links to
224
+ // the tree root would otherwise be exposed at the wrong level (e.g.
225
+ // `workflows` -> the whole tree), making `@.../workflows/foo` resolve to
226
+ // `<tree>/foo` instead of `<tree>/workflows/foo`.
227
+ targetIsDir = fs.statSync(targetReal).isDirectory()
228
+ && targetReal.startsWith(bundledWithSep);
229
+ } catch (_) { targetIsDir = false; }
230
+ if (!targetIsDir) {
231
+ skipped.push(sub);
232
+ continue;
233
+ }
234
+
235
+ const linkPath = path.join(canonicalDir, sub);
236
+
237
+ // Already a correct link → idempotent no-op.
238
+ if (linkPointsAt(linkPath, target)) {
239
+ linked.push(sub);
240
+ continue;
241
+ }
242
+
243
+ let existing = null;
244
+ try { existing = fs.lstatSync(linkPath); } catch (_) { existing = null; }
245
+
246
+ if (existing) {
247
+ // lstat().isSymbolicLink() is true for BOTH POSIX symlinks and Windows
248
+ // junctions, so this single predicate identifies every GSD-managed link.
249
+ if (existing.isSymbolicLink()) {
250
+ // A GSD-managed link that is stale or points elsewhere (e.g. previous
251
+ // plugin version after `claude plugin update`). Prune and recreate.
252
+ try {
253
+ fs.unlinkSync(linkPath);
254
+ prunedStale.push(sub);
255
+ } catch (e) {
256
+ skipped.push(sub);
257
+ continue;
258
+ }
259
+ } else {
260
+ // A REAL file or directory the user (or a classic install) owns. NEVER
261
+ // clobber it — preserve it untouched. This is the USER-PROFILE.md /
262
+ // partially-real-canonical-dir safety case.
263
+ preserved.push(sub);
264
+ continue;
265
+ }
266
+ }
267
+
268
+ try {
269
+ createDirLink(target, linkPath, platform);
270
+ linked.push(sub);
271
+ } catch (e) {
272
+ skipped.push(sub);
273
+ }
274
+ }
275
+
276
+ return {
277
+ status: 'ensured',
278
+ canonicalDir,
279
+ bundledTree,
280
+ linked,
281
+ prunedStale,
282
+ preserved,
283
+ skipped,
284
+ };
285
+ }
286
+
287
+ module.exports = {
288
+ ensureCanonicalPath,
289
+ resolveBundledTree,
290
+ resolveConfigDir,
291
+ dirLinkType,
292
+ MANAGED_SUBDIRS,
293
+ };
294
+
295
+ // CLI entry: run on SessionStart. Never block the session — any unexpected
296
+ // failure is swallowed (best-effort self-heal). Emit nothing on stdout to keep
297
+ // the hook silent in normal operation.
298
+ if (require.main === module) {
299
+ try {
300
+ ensureCanonicalPath();
301
+ } catch (_) {
302
+ // Best-effort: a canonical-path failure must never abort a session.
303
+ }
304
+ process.exit(0);
305
+ }
@@ -312,7 +312,7 @@ function runStatusline() {
312
312
  const totalCtx = data.context_window?.total_tokens || 1_000_000;
313
313
  const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10);
314
314
  const AUTO_COMPACT_BUFFER_PCT = acw > 0
315
- ? Math.min(100, (acw / totalCtx) * 100)
315
+ ? Math.min(100, Math.max(0, (1 - acw / totalCtx) * 100))
316
316
  : 16.5;
317
317
  let ctx = '';
318
318
  if (remaining != null) {
package/hooks/hooks.json CHANGED
@@ -3,6 +3,7 @@
3
3
  "SessionStart": [
4
4
  {
5
5
  "hooks": [
6
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js\"", "timeout": 5 },
6
7
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
7
8
  ]
8
9
  }
@@ -22,6 +22,7 @@ const MANAGED_HOOKS = [
22
22
  'gsd-context-monitor.js',
23
23
  'gsd-cursor-post-tool.js',
24
24
  'gsd-cursor-session-start.js',
25
+ 'gsd-ensure-canonical-path.js',
25
26
  'gsd-graphify-update.sh',
26
27
  'gsd-phase-boundary.sh',
27
28
  'gsd-prompt-guard.js',