@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.5

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 (84) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +28 -0
  7. package/bin/install.js +624 -456
  8. package/gsd-core/bin/gsd-tools.cjs +40 -1
  9. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  10. package/gsd-core/bin/lib/audit.cjs +6 -3
  11. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  12. package/gsd-core/bin/lib/capability-registry.cjs +325 -64
  13. package/gsd-core/bin/lib/capability-validator.cjs +1 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  15. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  16. package/gsd-core/bin/lib/commands.cjs +7 -5
  17. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  18. package/gsd-core/bin/lib/config.cjs +96 -0
  19. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  20. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  21. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  22. package/gsd-core/bin/lib/host-integration.cjs +16 -0
  23. package/gsd-core/bin/lib/init.cjs +74 -30
  24. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  25. package/gsd-core/bin/lib/install-engine.cjs +151 -11
  26. package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
  27. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  28. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  29. package/gsd-core/bin/lib/milestone.cjs +3 -3
  30. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  31. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  32. package/gsd-core/bin/lib/phase.cjs +78 -16
  33. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  34. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  35. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  36. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  37. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  38. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +13 -6
  39. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +17 -10
  41. package/gsd-core/bin/lib/runtime-homes.cjs +8 -0
  42. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +33 -25
  43. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  44. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  45. package/gsd-core/bin/lib/state.cjs +24 -24
  46. package/gsd-core/bin/lib/surface.cjs +30 -0
  47. package/gsd-core/bin/lib/uat.cjs +4 -1
  48. package/gsd-core/bin/lib/validate.cjs +15 -6
  49. package/gsd-core/bin/lib/verify.cjs +33 -37
  50. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  51. package/gsd-core/bin/shared/model-catalog.json +6 -6
  52. package/gsd-core/references/api-coverage.md +104 -0
  53. package/gsd-core/references/model-profiles.md +2 -2
  54. package/gsd-core/references/planning-config.md +2 -0
  55. package/gsd-core/references/specless-probe-fallback.md +172 -0
  56. package/gsd-core/templates/config.json +2 -1
  57. package/gsd-core/workflows/audit-fix.md +9 -1
  58. package/gsd-core/workflows/code-review-fix.md +7 -3
  59. package/gsd-core/workflows/code-review.md +4 -1
  60. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  61. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  62. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  63. package/gsd-core/workflows/execute-phase.md +1 -25
  64. package/gsd-core/workflows/plan-phase.md +31 -2
  65. package/gsd-core/workflows/quick.md +2 -2
  66. package/gsd-core/workflows/review.md +59 -13
  67. package/gsd-core/workflows/settings-advanced.md +5 -5
  68. package/gsd-core/workflows/settings.md +2 -2
  69. package/gsd-core/workflows/verify-phase.md +3 -2
  70. package/gsd-core/workflows/verify-work.md +38 -0
  71. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  72. package/hooks/dist/gsd-cursor-stop.js +48 -0
  73. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  74. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  75. package/hooks/dist/managed-hooks-registry.cjs +4 -0
  76. package/hooks/gsd-cursor-pre-tool.js +76 -0
  77. package/hooks/gsd-cursor-stop.js +48 -0
  78. package/hooks/gsd-cursor-subagent-start.js +50 -0
  79. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  80. package/hooks/managed-hooks-registry.cjs +4 -0
  81. package/package.json +2 -1
  82. package/scripts/build-hooks.js +5 -1
  83. package/scripts/lint-phase-id-drift.cjs +150 -0
  84. package/scripts/run-tests.cjs +21 -1
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ /**
6
+ * install-effort-resolver — install-time effort resolution (#443), extracted from
7
+ * the package-root `bin/install.js` (#2071).
8
+ *
9
+ * `gsd-tools effort sync` (src/commands.cts `cmdEffortSync`) must mirror what the
10
+ * installer writes — home `~/.gsd/defaults.json` merged with the project's
11
+ * `.planning/config.json` — which the runtime resolver (`resolveEffortInternal`
12
+ * via `loadConfig`) does NOT do. It previously reached those two functions via
13
+ * `require('../../../bin/install.js')`, but the installer never copies the
14
+ * package-root `bin/install.js` into a runtime home, so `effort sync` crashed with
15
+ * MODULE_NOT_FOUND in every installed runtime (#2071). Moving the logic here — a
16
+ * `src/*.cts` module compiled into the shipped `gsd-core/bin/lib/` tree — lets both
17
+ * the installer AND `effort sync` require it from a location that is always present,
18
+ * keeping a single source of truth (no duplication / drift).
19
+ *
20
+ * Pure with respect to config: `readGsdEffectiveEffortConfig` performs the config
21
+ * reads; `resolveInstallTimeEffort` is pure given a pre-merged effort object.
22
+ */
23
+ const node_fs_1 = __importDefault(require("node:fs"));
24
+ const node_path_1 = __importDefault(require("node:path"));
25
+ const node_os_1 = __importDefault(require("node:os"));
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-resolver.cjs is an export= CommonJS module
27
+ const modelResolver = require("./model-resolver.cjs");
28
+ const { EFFORT_SET: GSD_EFFORT_SET } = modelResolver;
29
+ /**
30
+ * #2517 — Read a single GSD config file (defaults.json or per-project
31
+ * config.json) into a plain object, returning null on missing/empty files
32
+ * and warning to stderr on JSON parse failures so silent corruption can't
33
+ * mask broken configs (review finding #5).
34
+ */
35
+ function _readGsdConfigFile(absPath, label) {
36
+ if (!node_fs_1.default.existsSync(absPath))
37
+ return null;
38
+ let raw;
39
+ try {
40
+ raw = node_fs_1.default.readFileSync(absPath, 'utf-8');
41
+ }
42
+ catch (err) {
43
+ process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
44
+ return null;
45
+ }
46
+ try {
47
+ return JSON.parse(raw);
48
+ }
49
+ catch (err) {
50
+ process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
51
+ return null;
52
+ }
53
+ }
54
+ // #443 — model-catalog and config-defaults.manifest.json exports needed only
55
+ // by effort-resolution code paths (resolveInstallTimeEffort /
56
+ // generateCodexAgentToml / Claude .md effort injection). Loaded lazily the
57
+ // first time they are needed so that requiring this module in test contexts that
58
+ // never trigger an install does NOT produce module-load-time side effects (the
59
+ // manifest read + hard throw) that could alter subprocess exit codes or stderr.
60
+ let _gsdEffortCatalogCache = null;
61
+ function _getGsdEffortCatalog() {
62
+ if (_gsdEffortCatalogCache)
63
+ return _gsdEffortCatalogCache;
64
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- model-catalog.cjs is an export= CommonJS module
65
+ const { AGENT_DEFAULT_TIERS, renderEffortForRuntime } = require('./model-catalog.cjs');
66
+ // This module lives in gsd-core/bin/lib/, so the shared manifest is one level
67
+ // up in gsd-core/bin/shared/ (bin/lib → bin → shared). (In bin/install.js this
68
+ // path was `.., gsd-core, bin, shared` relative to the package-root bin/.)
69
+ const manifestPath = node_path_1.default.join(__dirname, '..', 'shared', 'config-defaults.manifest.json');
70
+ let manifestData;
71
+ try {
72
+ manifestData = JSON.parse(node_fs_1.default.readFileSync(manifestPath, 'utf-8'));
73
+ }
74
+ catch (_err) {
75
+ // Fail loudly — a missing manifest is a broken install, not a soft degradation.
76
+ throw new Error(`gsd install: cannot load config-defaults.manifest.json at ${manifestPath}: ${_err.message}`);
77
+ }
78
+ const tierDefaults = (manifestData.effort &&
79
+ manifestData.effort.routing_tier_defaults &&
80
+ typeof manifestData.effort.routing_tier_defaults === 'object' &&
81
+ !Array.isArray(manifestData.effort.routing_tier_defaults))
82
+ ? manifestData.effort.routing_tier_defaults
83
+ : { light: 'low', standard: 'high', heavy: 'xhigh' }; // guard: unreachable if manifest is valid
84
+ const effortDefault = (manifestData.effort && typeof manifestData.effort.default === 'string')
85
+ ? manifestData.effort.default
86
+ : 'high'; // guard: unreachable if manifest is valid
87
+ _gsdEffortCatalogCache = {
88
+ AGENT_DEFAULT_TIERS,
89
+ renderEffortForRuntime,
90
+ EFFORT_MANIFEST_TIER_DEFAULTS: tierDefaults,
91
+ EFFORT_MANIFEST_DEFAULT: effortDefault,
92
+ };
93
+ return _gsdEffortCatalogCache;
94
+ }
95
+ /**
96
+ * #443 — Read the merged `effort` config block for install-time effort resolution.
97
+ *
98
+ * Probes the same config sources as readGsdRuntimeProfileResolver (per-project
99
+ * `.planning/config.json` wins over `~/.gsd/defaults.json`) but extracts the
100
+ * `effort` object instead of the model-profile fields.
101
+ *
102
+ * Returns the merged `effort` object or null when neither source defines one.
103
+ * The caller can pass this to resolveInstallTimeEffort() which is pure and
104
+ * requires no filesystem access beyond what this helper already performs.
105
+ *
106
+ * @param targetDir Runtime install root (walks up to find .planning/).
107
+ */
108
+ function readGsdEffectiveEffortConfig(targetDir = null) {
109
+ const homeDefaults = _readGsdConfigFile(node_path_1.default.join(node_os_1.default.homedir(), '.gsd', 'defaults.json'), '~/.gsd/defaults.json');
110
+ let projectConfig = null;
111
+ if (targetDir) {
112
+ let probeDir = node_path_1.default.resolve(targetDir);
113
+ for (let depth = 0; depth < 8; depth += 1) {
114
+ const candidate = node_path_1.default.join(probeDir, '.planning', 'config.json');
115
+ if (node_fs_1.default.existsSync(candidate)) {
116
+ projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
117
+ break;
118
+ }
119
+ const parent = node_path_1.default.dirname(probeDir);
120
+ if (parent === probeDir)
121
+ break;
122
+ probeDir = parent;
123
+ }
124
+ }
125
+ const homeEffort = (homeDefaults && homeDefaults.effort && typeof homeDefaults.effort === 'object' && !Array.isArray(homeDefaults.effort))
126
+ ? homeDefaults.effort
127
+ : null;
128
+ const projectEffort = (projectConfig && projectConfig.effort && typeof projectConfig.effort === 'object' && !Array.isArray(projectConfig.effort))
129
+ ? projectConfig.effort
130
+ : null;
131
+ if (!homeEffort && !projectEffort)
132
+ return null;
133
+ // Per-project wins on conflict within each sub-field. Merge field-by-field so
134
+ // a project config that only sets agent_overrides still inherits global
135
+ // routing_tier_defaults and default.
136
+ return {
137
+ ...(homeEffort || {}),
138
+ ...(projectEffort || {}),
139
+ // Deep-merge agent_overrides (project wins per-key)
140
+ agent_overrides: {
141
+ ...((homeEffort && homeEffort.agent_overrides) || {}),
142
+ ...((projectEffort && projectEffort.agent_overrides) || {}),
143
+ },
144
+ };
145
+ }
146
+ /**
147
+ * #443 — Resolve install-time effort for a given agent, using the same
148
+ * precedence chain as resolveEffortInternal() in core.cjs, but operating
149
+ * on a pre-loaded effortCfg object (no loadConfig side-effects at install).
150
+ *
151
+ * Precedence (mirrors resolveEffortInternal):
152
+ * 1. effortCfg.agent_overrides[agentName]
153
+ * 2. effortCfg.routing_tier_defaults[agentTier] (if effortCfg present)
154
+ * — OR manifest tier defaults when effortCfg is null
155
+ * 3. effortCfg.default
156
+ * 4. 'high' (hardcoded fallback)
157
+ *
158
+ * @param effortCfg Result of readGsdEffectiveEffortConfig().
159
+ * @param agentName e.g. 'gsd-planner'
160
+ * @returns Universal effort string (low/medium/high/xhigh/max/minimal)
161
+ */
162
+ function resolveInstallTimeEffort(effortCfg, agentName) {
163
+ // Validates each candidate against the canonical EFFORT_SET (sourced once
164
+ // from model-resolver.cjs) before accepting it, mirroring resolveEffortInternal
165
+ // exactly. Invalid values fall through to the next precedence layer; final
166
+ // fallback 'high'.
167
+ // Step 1: agent_overrides
168
+ if (effortCfg) {
169
+ const ao = effortCfg.agent_overrides;
170
+ if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
171
+ const v = ao[agentName];
172
+ if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
173
+ return v;
174
+ }
175
+ }
176
+ // Step 2: routing_tier_defaults keyed by the agent's catalog tier
177
+ const { AGENT_DEFAULT_TIERS, EFFORT_MANIFEST_TIER_DEFAULTS, EFFORT_MANIFEST_DEFAULT } = _getGsdEffortCatalog();
178
+ const agentTier = AGENT_DEFAULT_TIERS[agentName];
179
+ if (agentTier) {
180
+ if (effortCfg && effortCfg.routing_tier_defaults &&
181
+ typeof effortCfg.routing_tier_defaults === 'object' &&
182
+ !Array.isArray(effortCfg.routing_tier_defaults)) {
183
+ const v = effortCfg.routing_tier_defaults[agentTier];
184
+ if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
185
+ return v;
186
+ }
187
+ else if (!effortCfg) {
188
+ // No effort config — use manifest tier defaults
189
+ const v = EFFORT_MANIFEST_TIER_DEFAULTS[agentTier];
190
+ if (typeof v === 'string' && GSD_EFFORT_SET.has(v))
191
+ return v;
192
+ }
193
+ // effortCfg exists but has no routing_tier_defaults — fall through
194
+ }
195
+ // Step 3: effort.default
196
+ if (effortCfg) {
197
+ const d = effortCfg.default;
198
+ if (typeof d === 'string' && GSD_EFFORT_SET.has(d))
199
+ return d;
200
+ }
201
+ // Step 4: manifest default (sourced from config-defaults.manifest.json effort.default)
202
+ // If even the manifest default is invalid, fall back to 'high'.
203
+ if (typeof EFFORT_MANIFEST_DEFAULT === 'string' && GSD_EFFORT_SET.has(EFFORT_MANIFEST_DEFAULT)) {
204
+ return EFFORT_MANIFEST_DEFAULT;
205
+ }
206
+ return 'high';
207
+ }
208
+ module.exports = {
209
+ readGsdEffectiveEffortConfig,
210
+ resolveInstallTimeEffort,
211
+ _getGsdEffortCatalog,
212
+ _readGsdConfigFile,
213
+ };
@@ -26,6 +26,7 @@ const runtimeArtifactConversion = require("./runtime-artifact-conversion.cjs");
26
26
  const runtimeArtifactLayout = require("./runtime-artifact-layout.cjs");
27
27
  const runtimeArtifactInstallPlan = require("./runtime-artifact-install-plan.cjs");
28
28
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
29
+ const installProfiles = require("./install-profiles.cjs");
29
30
  const { processAttribution } = runtimeArtifactConversion;
30
31
  // resolveRuntimeArtifactLayout: accessed via module ref (not destructured) so
31
32
  // test stubs that monkeypatch the module's exports are seen at call time.
@@ -51,6 +52,25 @@ const { getDirName } = runtimeNamePolicy;
51
52
  */
52
53
  const USER_OWNED_ARTIFACTS = ['USER-PROFILE.md'];
53
54
  // ---------------------------------------------------------------------------
55
+ // Host-behavior helpers
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * Host-specific install behaviors declared on the runtime descriptor
59
+ * (capabilities/<runtime>/capability.json -> runtime.hostBehaviors).
60
+ * Mirrors bin/install.js's `_hostBehaviors` (ADR-1239 / #2086/#2087). Returns
61
+ * {} for runtimes that declare none or if the registry fails to load, so
62
+ * every behavior branch degrades to the generic path by default.
63
+ */
64
+ function _hostBehaviors(runtime) {
65
+ try {
66
+ const reg = require('./capability-registry.cjs');
67
+ return (reg && reg.runtimes && reg.runtimes[runtime] && reg.runtimes[runtime].runtime && reg.runtimes[runtime].runtime.hostBehaviors) || {};
68
+ }
69
+ catch {
70
+ return {};
71
+ }
72
+ }
73
+ // ---------------------------------------------------------------------------
54
74
  // Conversion helpers
55
75
  // ---------------------------------------------------------------------------
56
76
  /**
@@ -231,14 +251,20 @@ function _copyStaged(stagedDir, destDir, kind, configDir, runtime) {
231
251
  if (configDir === undefined) {
232
252
  throw new Error('_copyStaged: configDir (install root) is required to confine writes — refusing to write');
233
253
  }
254
+ // The install root is normally configDir, but a kind may declare an alternate
255
+ // `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills -> $HOME/.agents) — in
256
+ // that case this defense-in-depth check must confine against the resolved
257
+ // alternate root instead, matching the upstream gate's own root selection in
258
+ // createRuntimeArtifactInstallPlan.
259
+ const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
234
260
  // Strict-subpath + NUL containment via the canonical gate (shared with the
235
261
  // layout-driven install plan); throws if destDir escapes the install root.
236
- // destDir here is an absolute path; path.resolve(configDir, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to configDir.
237
- const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, destDir);
238
- // Symlink-escape guard: reject if any path component between configDir and
239
- // destDir is a symlink that would redirect writes outside configDir.
240
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), resolvedDest)) {
241
- throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${configDir}" — refusing to write`);
262
+ // destDir here is an absolute path; path.resolve(installRoot, absoluteDest) returns it unchanged, so the gate's strict-subpath check still correctly confines it to installRoot.
263
+ const resolvedDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(installRoot, destDir);
264
+ // Symlink-escape guard: reject if any path component between the install root and
265
+ // destDir is a symlink that would redirect writes outside the install root.
266
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), resolvedDest)) {
267
+ throw new Error(`_copyStaged: destDir "${destDir}" contains a symlink escaping the install root "${installRoot}" — refusing to write`);
242
268
  }
243
269
  // Use the validated absolute path for the actual writes below.
244
270
  destDir = resolvedDest;
@@ -519,6 +545,15 @@ function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') {
519
545
  * @param resolveAttribution injection: (runtime) => attribution string | undefined
520
546
  */
521
547
  function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined) {
548
+ // Combined-family runtimes (OpenCode/Kilo, ADR-1239 / #2087): route through
549
+ // the dedicated combined commands+skills+plugin orchestrator instead of the
550
+ // generic layout-driven loop below, mirroring the bespoke install path that
551
+ // previously lived inline in bin/install.js.
552
+ const behaviors = _hostBehaviors(runtime);
553
+ if (behaviors.combinedFamilyInstall) {
554
+ installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution, behaviors);
555
+ return;
556
+ }
522
557
  // Legacy cleanup before layout-driven writes
523
558
  _runLegacyInstallMigrations(runtime, configDir, scope);
524
559
  const layout = runtimeArtifactLayout.resolveRuntimeArtifactLayout(runtime, configDir, scope);
@@ -543,10 +578,16 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile, res
543
578
  throw new Error(`Install plan returned unknown artifact kind: ${item.kind}`);
544
579
  const dest = item.destDir;
545
580
  // Symlink-escape guard: reject before mkdir if dest (or any component
546
- // between configDir and dest) is a symlink pointing outside configDir.
547
- // mkdirSync follows symlinks, so this must run BEFORE the mkdir call.
548
- if (hasExistingSymlinkBetween(node_path_1.default.resolve(configDir), dest)) {
549
- throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${configDir}" — refusing to create`);
581
+ // between the install root and dest) is a symlink pointing outside that
582
+ // root. mkdirSync follows symlinks, so this must run BEFORE the mkdir
583
+ // call. The install root is normally configDir, but a kind may declare
584
+ // an alternate `home` (ADR-1239 upgrade 3 / #2088, e.g. Codex skills ->
585
+ // $HOME/.agents) — in that case the guard must check against the
586
+ // resolved alternate root instead, matching assertDestWithinConfigHome's
587
+ // own root selection in createRuntimeArtifactInstallPlan.
588
+ const installRoot = (kind && typeof kind.home === 'string' && kind.home !== '') ? kind.home : configDir;
589
+ if (hasExistingSymlinkBetween(node_path_1.default.resolve(installRoot), dest)) {
590
+ throw new Error(`installRuntimeArtifacts: destDir "${dest}" contains a symlink escaping the install root "${installRoot}" — refusing to create`);
550
591
  }
551
592
  node_fs_1.default.mkdirSync(dest, { recursive: true });
552
593
  if (kind.kind === 'skills' && node_fs_1.default.existsSync(dest)) {
@@ -638,7 +679,7 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
638
679
  const rawDir = rawCommandsDir;
639
680
  if (!rawDir || !node_fs_1.default.existsSync(rawDir))
640
681
  return 0;
641
- const converter = runtime === 'kilo'
682
+ const converter = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
642
683
  ? convertClaudeCommandToKiloSkill
643
684
  : convertClaudeCommandToOpencodeSkill;
644
685
  const dest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(targetDir, skillsKindEntry.destSubpath);
@@ -686,6 +727,102 @@ function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPre
686
727
  return count;
687
728
  }
688
729
  // ---------------------------------------------------------------------------
730
+ // installOpencodeFamilyCommands
731
+ // ---------------------------------------------------------------------------
732
+ /**
733
+ * Install the flattened commands surface for an OpenCode-family runtime
734
+ * (OpenCode/Kilo): commands/gsd/**\/*.md -> command/gsd-<...>.md, with
735
+ * per-runtime frontmatter conversion and path-prefix/attribution rewrites.
736
+ *
737
+ * Mirrors bin/install.js's copyFlattenedCommands VERBATIM (ADR-1239 /
738
+ * #2087), except attribution is resolved via the injected
739
+ * `resolveAttribution` callback instead of a module-level getCommitAttribution.
740
+ *
741
+ * @param runtime - 'opencode' or 'kilo'
742
+ * @param destDir - destination directory for flattened commands (recurses with the same destDir)
743
+ * @param srcDir - source directory to walk (commands/gsd/, recursing into subdirectories)
744
+ * @param pathPrefix - computed config-path prefix for body rewrites
745
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
746
+ * @param prefix - filename prefix accumulator (defaults to 'gsd'; grows on recursion)
747
+ */
748
+ function installOpencodeFamilyCommands(runtime, destDir, srcDir, pathPrefix, resolveAttribution = () => undefined, prefix = 'gsd') {
749
+ if (!node_fs_1.default.existsSync(srcDir))
750
+ return;
751
+ // Remove old gsd-*.md files before copying new ones
752
+ if (node_fs_1.default.existsSync(destDir)) {
753
+ for (const file of node_fs_1.default.readdirSync(destDir)) {
754
+ if (file.startsWith(`${prefix}-`) && file.endsWith('.md'))
755
+ node_fs_1.default.unlinkSync(node_path_1.default.join(destDir, file));
756
+ }
757
+ }
758
+ else {
759
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
760
+ }
761
+ for (const entry of node_fs_1.default.readdirSync(srcDir, { withFileTypes: true })) {
762
+ const srcPath = node_path_1.default.join(srcDir, entry.name);
763
+ if (entry.isDirectory()) {
764
+ installOpencodeFamilyCommands(runtime, destDir, srcPath, pathPrefix, resolveAttribution, `${prefix}-${entry.name}`);
765
+ }
766
+ else if (entry.name.endsWith('.md')) {
767
+ const baseName = entry.name.replace('.md', '');
768
+ const destName = `${prefix}-${baseName}.md`;
769
+ let content = node_fs_1.default.readFileSync(srcPath, 'utf8');
770
+ content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix);
771
+ content = processAttribution(content, resolveAttribution(runtime));
772
+ content = _hostBehaviors(runtime).frontmatterDialect === 'kilo'
773
+ ? runtimeArtifactConversion.convertClaudeToKiloFrontmatter(content)
774
+ : runtimeArtifactConversion.convertClaudeToOpencodeFrontmatter(content);
775
+ node_fs_1.default.writeFileSync(node_path_1.default.join(destDir, destName), content);
776
+ }
777
+ }
778
+ }
779
+ // ---------------------------------------------------------------------------
780
+ // installOpencodeFamilyArtifacts
781
+ // ---------------------------------------------------------------------------
782
+ /**
783
+ * Combined-family install orchestrator for OpenCode/Kilo (ADR-1239 / #2087).
784
+ * Stages the flattened commands surface + skills surface + (OpenCode only)
785
+ * native plugin adapter, mirroring the bespoke `else if (isOpencode ||
786
+ * isKilo)` block previously inlined in bin/install.js.
787
+ *
788
+ * @param runtime - 'opencode' or 'kilo'
789
+ * @param configDir - resolved runtime config directory
790
+ * @param scope - install scope ('global' | 'local')
791
+ * @param resolvedProfile - from resolveProfile() / resolveEffectiveProfile()
792
+ * @param resolveAttribution - injection: (runtime) => attribution string | undefined
793
+ * @param behaviors - the runtime's hostBehaviors descriptor (already resolved by the caller)
794
+ */
795
+ function installOpencodeFamilyArtifacts(runtime, configDir, scope, resolvedProfile, resolveAttribution = () => undefined, behaviors = {}) {
796
+ const isGlobal = scope === 'global';
797
+ // findInstallSourceRoot resolves DIRECTLY to the commands/gsd source dir
798
+ // (via the .gsd-source marker or a walk-up from __dirname) — every other
799
+ // call site in runtime-artifact-layout.cts feeds its return value straight
800
+ // into stageSkillsForProfile/stageSkillsForRuntimeAsSkills. The repo/package
801
+ // root (needed below for the native plugin source) is two levels up.
802
+ const commandsGsdDir = runtimeArtifactLayout.findInstallSourceRoot(configDir);
803
+ const src = node_path_1.default.dirname(node_path_1.default.dirname(commandsGsdDir));
804
+ const rawCommandsDir = installProfiles.stageSkillsForProfile(commandsGsdDir, resolvedProfile);
805
+ const pathPrefix = runtimeArtifactConversion._computePathPrefix({
806
+ isGlobal,
807
+ isOpencode: behaviors.skipHomePrefixSubstitution === true,
808
+ isWindowsHost: process.platform === 'win32',
809
+ resolvedTarget: node_path_1.default.resolve(configDir).replace(/\\/g, '/'),
810
+ homeDir: node_os_1.default.homedir().replace(/\\/g, '/'),
811
+ });
812
+ const commandDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, 'command');
813
+ installOpencodeFamilyCommands(runtime, commandDir, rawCommandsDir, pathPrefix, resolveAttribution);
814
+ installOpencodeFamilySkills(runtime, configDir, rawCommandsDir, pathPrefix, resolveAttribution);
815
+ const np = behaviors.nativePlugin;
816
+ if (np && np.source) {
817
+ const pluginSrc = node_path_1.default.join(src, np.source);
818
+ if (node_fs_1.default.existsSync(pluginSrc)) {
819
+ const destDir = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, np.dir);
820
+ node_fs_1.default.mkdirSync(destDir, { recursive: true });
821
+ node_fs_1.default.copyFileSync(pluginSrc, node_path_1.default.join(destDir, np.file));
822
+ }
823
+ }
824
+ }
825
+ // ---------------------------------------------------------------------------
689
826
  // uninstallRuntimeArtifacts
690
827
  // ---------------------------------------------------------------------------
691
828
  /**
@@ -740,6 +877,9 @@ module.exports = {
740
877
  installRuntimeArtifacts,
741
878
  uninstallRuntimeArtifacts,
742
879
  installOpencodeFamilySkills,
880
+ installOpencodeFamilyCommands,
881
+ installOpencodeFamilyArtifacts,
882
+ _hostBehaviors,
743
883
  _copyStaged,
744
884
  hasExistingSymlinkBetween,
745
885
  preserveUserArtifacts,
@@ -37,7 +37,11 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
37
37
  'hooks/gsd-config-reload.js',
38
38
  'hooks/gsd-context-monitor.js',
39
39
  'hooks/gsd-cursor-post-tool.js',
40
+ 'hooks/gsd-cursor-pre-tool.js',
40
41
  'hooks/gsd-cursor-session-start.js',
42
+ 'hooks/gsd-cursor-stop.js',
43
+ 'hooks/gsd-cursor-subagent-start.js',
44
+ 'hooks/gsd-cursor-subagent-stop.js',
41
45
  'hooks/gsd-ensure-canonical-path.js',
42
46
  'hooks/gsd-graphify-update.sh',
43
47
  'hooks/gsd-phase-boundary.sh',
@@ -401,6 +401,25 @@ function renderLoopHooks(resolved) {
401
401
  * Missing <capId> value → coreError + non-zero exit.
402
402
  * Unknown/inactive capId → `false` (not an error).
403
403
  */
404
+ // #2009: a capability id surfaced inside the runnable `gsd capability remove <id>`
405
+ // remediation must match the canonical kebab-case id shape (identical to
406
+ // capability-consent.cts / capability-ledger.cts) before it is embedded — a raw
407
+ // overlay directory name is attacker-controlled and can carry shell/markdown
408
+ // metacharacters (backticks, ';', '|', '$()'). An id that fails this check is
409
+ // withheld and no runnable command is rendered for it.
410
+ const LOAD_FAIL_CAP_ID_RE = /^[a-z][a-z0-9-]*$/;
411
+ // #2009: neutralize control chars, newlines, and backticks from a third-party
412
+ // load-failure reason so a malicious manifest cannot break out of the warning
413
+ // line or inject markdown / prompt content into the surfaced message.
414
+ function sanitizeLoadFailReason(reason) {
415
+ const cleaned = String(reason)
416
+ // Strip C0 control chars, DEL, and backticks; collapse remaining whitespace.
417
+ .replace(/[\x00-\x1F\x7F`]/g, ' ')
418
+ .replace(/\s+/g, " ")
419
+ .trim()
420
+ .slice(0, 300);
421
+ return cleaned || '(no reason given)';
422
+ }
404
423
  function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
405
424
  if (!point) {
406
425
  coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
@@ -456,26 +475,54 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
456
475
  coreError(msg);
457
476
  return;
458
477
  }
459
- // ── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
460
- // For every skipped overlay capability that declared a gate at this point,
461
- // inject a synthetic BLOCKING gate into the resolved output so the loop HALTS
462
- // rather than silently proceeding as if the gate had passed. step/contribution
463
- // overlays that were skipped are left open (skip-open is correct for them).
478
+ // ── ADR-1244 D2: load-failed capability gates FAIL OPEN with a loud warning ────
479
+ // Decision (#2009): a capability that failed to LOAD must not block the loop.
480
+ // The prior behavior injected a BLOCKING synthetic gate (blocking:true,
481
+ // onError:'halt') at every point where the skipped cap declared a gate, so a
482
+ // single incompatible capability halted every ship:pre / verify:post
483
+ // project-wide for a load error unrelated to what the gate checked — with no
484
+ // remediation surfaced. We now fail OPEN: no gate is injected (the loop proceeds
485
+ // and `--active-cap <failed-cap>` correctly reports it inactive), and a loud
486
+ // warning is emitted instead — to STDERR (which the operator, or the agent
487
+ // running the command, actually sees regardless of how the host workflow
488
+ // consumes stdout) AND in the envelope's `warnings` channel for structured
489
+ // consumers. The warning names the load reason and the exact
490
+ // `gsd capability remove <id>` remediation so the operator can clear the broken
491
+ // capability. blockedGates is still recorded by the loader; only the consequence
492
+ // changes from block to warn. step/contribution overlays were already skip-open.
493
+ //
494
+ // The gate injection was dropped rather than made non-blocking because no host
495
+ // workflow generically surfaces an arbitrary gate's message at ship:pre /
496
+ // verify:post (consumers dispatch on specific capIds / ref.skills), and the
497
+ // generic gate consumers expect an object-shaped `check`, not a prose string —
498
+ // so an injected advisory gate would be silently dropped or mis-dispatched. A
499
+ // stderr warning is the channel that is actually surfaced. (See #2009 review.)
464
500
  const overlayMeta = registry['_overlay'];
501
+ const loadFailWarnings = [];
465
502
  if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) {
466
503
  for (const blocked of overlayMeta.blockedGates) {
467
- if (blocked.point === point) {
468
- const syntheticGate = {
469
- capId: blocked.capId,
470
- kind: 'gate',
471
- blocking: true,
472
- onError: 'halt',
473
- check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`,
474
- };
475
- resolved.activeHooks.push(syntheticGate);
476
- }
504
+ if (blocked.point !== point)
505
+ continue;
506
+ // Security (#2009 review): capId/reason come from a third-party manifest or
507
+ // directory name. Validate capId before embedding it in the runnable
508
+ // remediation command; withhold it (no runnable command) if it is not a
509
+ // canonical id. Strip control chars/backticks from reason.
510
+ const idValid = LOAD_FAIL_CAP_ID_RE.test(String(blocked.capId));
511
+ const capLabel = idValid
512
+ ? `"${blocked.capId}"`
513
+ : 'with an invalid id (withheld) under .gsd/capabilities/';
514
+ const remediation = idValid
515
+ ? `Run \`gsd capability remove ${blocked.capId}\` to remove it, or fix the load error.`
516
+ : 'Remove the offending capability directory under .gsd/capabilities/, or fix the load error.';
517
+ loadFailWarnings.push(`capability ${capLabel} failed to load (${sanitizeLoadFailReason(blocked.reason)}); ` +
518
+ `its gate at ${point} is SKIPPED and NOT enforced (failing open). ${remediation}`);
477
519
  }
478
520
  }
521
+ // Emit loudly to stderr in EVERY output mode (including --active-cap), so a
522
+ // skipped gate is never silently invisible to the operator/agent.
523
+ for (const w of loadFailWarnings) {
524
+ process.stderr.write(`gsd: warning — ${w}\n`);
525
+ }
479
526
  // --active-cap mode: print exactly 'true' or 'false' with no envelope
480
527
  if (activeCapId !== undefined) {
481
528
  const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
@@ -488,8 +535,12 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
488
535
  activeHooks: resolved.activeHooks,
489
536
  rendered,
490
537
  };
491
- if (state.warnings && state.warnings.length > 0) {
492
- envelope.warnings = state.warnings;
538
+ // Surface capability-state warnings and the #2009 load-failure fail-open
539
+ // warnings together in the structured `warnings` channel (in addition to the
540
+ // stderr emission above, which is the channel host workflows actually see).
541
+ const combinedWarnings = [...(state.warnings || []), ...loadFailWarnings];
542
+ if (combinedWarnings.length > 0) {
543
+ envelope.warnings = combinedWarnings;
493
544
  }
494
545
  coreOutput(envelope, raw);
495
546
  }
@@ -17,6 +17,7 @@ exports.collectSections = collectSections;
17
17
  exports.collectSection = collectSection;
18
18
  exports.iterateBullets = iterateBullets;
19
19
  exports.extractTaggedBlocks = extractTaggedBlocks;
20
+ exports.stripTaggedBlocks = stripTaggedBlocks;
20
21
  exports.replaceSection = replaceSection;
21
22
  // ─── stripFencedCode ──────────────────────────────────────────────────────────
22
23
  /**
@@ -407,25 +408,26 @@ function iterateBullets(sectionText) {
407
408
  * fenced code blocks itself. If a `<tagName>` block appears inside a fenced code
408
409
  * block and should be excluded, the caller should apply `stripFencedCode` first.
409
410
  *
410
- * **Nested tags are NOT supported.** The underlying regex uses a non-greedy
411
- * `[\s\S]*?` match, which means it closes at the FIRST `</tagName>` encountered.
412
- * Given `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns
413
- * `['<x>inner']` — the inner `<x>` is captured as literal text, and the second
414
- * `</x>` is left unmatched (or matched as a second block with empty inner text
415
- * if another `<x>` follows). Callers that need to handle nested tags must
416
- * pre-process the input or use a proper XML/HTML parser.
411
+ * **Nested tags are NOT supported.** The body scan terminates at the NEXT
412
+ * opening of the same tag (the ReDoS-safe boundary, #2128). Given
413
+ * `<x><x>inner</x></x>`, `extractTaggedBlocks(content, 'x')` returns `['inner']`
414
+ * — the well-formed inner block; the unterminated outer `<x>` is skipped.
415
+ * Callers that need true nesting must use a proper XML/HTML parser.
416
+ *
417
+ * `allowAttributes` (default `false`): when `true`, the opening tag may carry
418
+ * bounded attributes (`<tag foo="x">`) — needed for `<task type="…">` blocks.
419
+ * Leave `false` for tags that must match exactly (e.g. `<decisions>`), and never
420
+ * enable it for a tag where an attributed form is semantically distinct.
417
421
  *
418
422
  * Generalises `decisions.cts`'s bespoke `matchAll(/<decisions>([\s\S]*?)<\/decisions>/g)`
419
423
  * so tier T1 can drop its own copy (tracked duplication until T1 lands).
420
424
  */
421
- function extractTaggedBlocks(content, tagName) {
425
+ function extractTaggedBlocks(content, tagName, allowAttributes = false) {
422
426
  if (typeof content !== 'string' || content.length === 0)
423
427
  return [];
424
428
  if (typeof tagName !== 'string' || tagName.length === 0)
425
429
  return [];
426
- // Escape the tag name for safe interpolation into a RegExp.
427
- const escapedTag = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
428
- const pattern = new RegExp(`<${escapedTag}>([\\s\\S]*?)</${escapedTag}>`, 'g');
430
+ const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
429
431
  const results = [];
430
432
  let match;
431
433
  while ((match = pattern.exec(content)) !== null) {
@@ -433,6 +435,43 @@ function extractTaggedBlocks(content, tagName) {
433
435
  }
434
436
  return results;
435
437
  }
438
+ /**
439
+ * Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
440
+ * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
441
+ *
442
+ * Safety: the body terminates at the NEXT opening of this tag (stop-at-next-open)
443
+ * instead of lazily rescanning the whole remaining document for a `</tag>` that
444
+ * may never appear — so a document full of unclosed `<tag>` openings scans
445
+ * LINEARLY, not quadratically (#2128). Group 1 is the block body.
446
+ *
447
+ * `allowAttributes`: when `true`, the opener accepts bounded attributes
448
+ * (`<tag foo="x">`) and the body boundary is `<tag` followed by a space or `>`.
449
+ * When `false`, the opener is the EXACT `<tag>` and the boundary is exact `<tag>`,
450
+ * so an attributed `<tag foo>` is neither an opener nor a boundary — it is body
451
+ * content. That exact form is load-bearing for `<details>` stripping: `<details
452
+ * open>` marks the ACTIVE milestone and must be preserved, not stripped (#557).
453
+ */
454
+ function taggedBlockPattern(tagName, flags, allowAttributes) {
455
+ const esc = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
456
+ const open = allowAttributes ? `<${esc}(?:\\s[^>]{0,1000})?>` : `<${esc}>`;
457
+ const boundary = allowAttributes ? `<${esc}[\\s>]` : `<${esc}>`;
458
+ return new RegExp(`${open}((?:(?!${boundary})[\\s\\S])*?)</${esc}>`, flags);
459
+ }
460
+ /**
461
+ * Remove every `<tagName>…</tagName>` block (opening tag, body, and closing tag)
462
+ * from `content`. The ReDoS-safe counterpart to `extractTaggedBlocks` — same
463
+ * hardened pattern, `.replace(…, '')` instead of body extraction. `allowAttributes`
464
+ * defaults to `false` so `<details open>` (active milestone) is preserved (#557);
465
+ * case-insensitive by default (matching the `<details>` strip call sites), pass
466
+ * `caseSensitive` to force exact-case matching.
467
+ */
468
+ function stripTaggedBlocks(content, tagName, allowAttributes = false, caseSensitive = false) {
469
+ if (typeof content !== 'string' || content.length === 0)
470
+ return '';
471
+ if (typeof tagName !== 'string' || tagName.length === 0)
472
+ return content;
473
+ return content.replace(taggedBlockPattern(tagName, caseSensitive ? 'g' : 'gi', allowAttributes), '');
474
+ }
436
475
  // ─── replaceSection ───────────────────────────────────────────────────────────
437
476
  /**
438
477
  * Splice `newBody` in place of a section's body and return the resulting