@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,234 @@
1
+ /**
2
+ * Cline SDK binding — AgentPlugin + createAgentModel adapters
3
+ * (ADR-1239 Phase D / #2090).
4
+ *
5
+ * Two Context7-verified UPGRADES the file-convention projection ignored, now
6
+ * delivered through the negotiated `hookBus: host` + `modelMode: active`
7
+ * interface points:
8
+ *
9
+ * UPGRADE 1 — `AgentPlugin.hooks.beforeTool` planning-artifact guard.
10
+ * Re-implements the `.clinerules/hooks/PreToolUse` file-convention hook
11
+ * (issue #787) as a real Cline SDK AgentPlugin. Guard semantics are
12
+ * preserved EXACTLY: fail-open, cancel (skip) write-class calls targeting
13
+ * `.planning/`, pass through everything else. The SDK maps the file hook's
14
+ * `{cancel:true, errorMessage}` to `{skip:true, reason}` (beforeTool
15
+ * contract).
16
+ * Cite: https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
17
+ * https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
18
+ *
19
+ * UPGRADE 2 — `DefaultGateway.createAgentModel({providerId, modelId})`.
20
+ * Resolves GSD's per-subagent `model_overrides` / `model_profile_overrides`
21
+ * (already used for OpenCode/Codex passive hosts) into the createAgentModel
22
+ * call params for cline's active model mode. The host gateway owns the
23
+ * actual model instantiation; this binding resolves WHICH model an
24
+ * overridden subagent should use.
25
+ * Cite: https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
26
+ * https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
27
+ *
28
+ * This module is PURE (no I/O, no SDK import): the real `@cline/sdk` is a
29
+ * fast-moving package set not linked at build/test time, so the binding exposes
30
+ * the decision functions a host plugin/gateway would call. Tests drive payloads
31
+ * through them directly (same mock-the-SDK pattern as the VS Code reference
32
+ * binding, tests/fixtures/vscode-host-binding.cjs).
33
+ */
34
+ 'use strict';
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DEFAULT_CLINE_PROVIDER_ID = exports.clineGsdPlugin = exports.PLANNING_GUARD_REASON = exports.PLANNING_PATH_PATTERN = exports.WRITE_TOOL_PATTERN = void 0;
37
+ exports.evaluateBeforeTool = evaluateBeforeTool;
38
+ exports.inferProviderId = inferProviderId;
39
+ exports.resolveClineAgentModelParams = resolveClineAgentModelParams;
40
+ // ---------------------------------------------------------------------------
41
+ // UPGRADE 1 — beforeTool planning-artifact guard
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Write-class tool-verb detector. Matches the SAME regex as the #787
45
+ * PreToolUse file-convention hook so the guard behaves identically.
46
+ * Case-insensitive (the SDK delivers tool names in varying case).
47
+ */
48
+ exports.WRITE_TOOL_PATTERN = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/i;
49
+ /**
50
+ * `.planning/` path detector. Matches `.planning` preceded by start-of-string
51
+ * or a path separator (posix `/` or windows `\`) and followed by a separator or
52
+ * end-of-string — so `.planning-readme.txt` is NOT falsely matched. Mirrors the
53
+ * PreToolUse hook's `(^|[\\/])\.planning([\\/]|$)` exactly.
54
+ */
55
+ exports.PLANNING_PATH_PATTERN = /(^|[\\/])\.planning([\\/]|$)/;
56
+ /**
57
+ * The user-visible reason returned when a `.planning/` write is blocked.
58
+ * Preserves the PreToolUse hook's errorMessage text so the guard behaves
59
+ * identically to the user (cancel→skip, errorMessage→reason).
60
+ */
61
+ exports.PLANNING_GUARD_REASON = Object.freeze('GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.');
62
+ /**
63
+ * Path-bearing field-name detector. Only PATH-keyed field values are inspected,
64
+ * so a document that merely mentions ".planning/" in its body content is never
65
+ * falsely blocked. Mirrors the PreToolUse hook's PATH_KEY regex exactly.
66
+ */
67
+ const PATH_KEY_PATTERN = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i;
68
+ /**
69
+ * Collect PATH-bearing string field values from an object tree, mirroring the
70
+ * PreToolUse hook's bounded walk. Pure; never throws.
71
+ */
72
+ function collectPathValues(root) {
73
+ const paths = [];
74
+ const walk = (v, depth) => {
75
+ if (depth > 5 || paths.length > 64)
76
+ return;
77
+ if (Array.isArray(v)) {
78
+ for (const x of v)
79
+ walk(x, depth + 1);
80
+ return;
81
+ }
82
+ if (v && typeof v === 'object') {
83
+ const obj = v;
84
+ for (const k of Object.keys(obj)) {
85
+ const val = obj[k];
86
+ if (typeof val === 'string' && PATH_KEY_PATTERN.test(k)) {
87
+ paths.push(val);
88
+ }
89
+ else {
90
+ walk(val, depth + 1);
91
+ }
92
+ }
93
+ }
94
+ };
95
+ walk(root, 0);
96
+ return paths;
97
+ }
98
+ /**
99
+ * Resolve a tool name from a beforeTool payload's `tool` field, which may be a
100
+ * string or an object with a `name` property. Returns '' when absent (treated
101
+ * as non-write-class → allow, fail-open).
102
+ */
103
+ function resolveToolName(tool) {
104
+ if (!tool)
105
+ return '';
106
+ if (typeof tool === 'string')
107
+ return tool;
108
+ const name = tool.name;
109
+ return typeof name === 'string' ? name : '';
110
+ }
111
+ /**
112
+ * The pure guard decision: given a beforeTool payload, decide skip (cancel) or
113
+ * allow. Fail-OPEN — any malformed input, missing tool, or thrown error returns
114
+ * 'allow' (the guard never blocks on a defect, mirroring the PreToolUse hook).
115
+ *
116
+ * @returns `{decision:'skip', reason}` for a write-class call targeting
117
+ * `.planning/`; `{decision:'allow'}` for everything else.
118
+ */
119
+ function evaluateBeforeTool(payload) {
120
+ try {
121
+ if (!payload)
122
+ return { decision: 'allow' };
123
+ const toolName = resolveToolName(payload.tool);
124
+ if (!toolName)
125
+ return { decision: 'allow' };
126
+ const isWrite = exports.WRITE_TOOL_PATTERN.test(toolName);
127
+ if (!isWrite)
128
+ return { decision: 'allow' };
129
+ const paths = collectPathValues(payload.input);
130
+ const isPlanningPath = (s) => exports.PLANNING_PATH_PATTERN.test(s);
131
+ if (paths.some(isPlanningPath)) {
132
+ return { decision: 'skip', reason: exports.PLANNING_GUARD_REASON };
133
+ }
134
+ return { decision: 'allow' };
135
+ }
136
+ catch {
137
+ // Fail-open: a defect in the guard never blocks the user's operation.
138
+ return { decision: 'allow' };
139
+ }
140
+ }
141
+ /**
142
+ * The Cline `AgentPlugin` shape (Context7 /cline/cline). A plugin implements
143
+ * `setup({agentId})` returning `{hooks, tools}`. The `beforeTool` hook returns
144
+ * `{skip:true, reason}` to block or `undefined` to allow.
145
+ *
146
+ * This object is the portable plugin a host loads from `~/.cline/plugins/`
147
+ * (analogous to `.opencode/plugins/gsd-core.js`). Its `beforeTool` delegates to
148
+ * the pure `evaluateBeforeTool` so the decision logic is testable without the
149
+ * SDK linked.
150
+ */
151
+ exports.clineGsdPlugin = Object.freeze({
152
+ name: 'gsd-planning-guard',
153
+ setup(_ctx) {
154
+ return {
155
+ hooks: {
156
+ beforeTool(payload) {
157
+ const decision = evaluateBeforeTool(payload);
158
+ return decision.decision === 'skip' ? { skip: true, reason: decision.reason } : undefined;
159
+ },
160
+ },
161
+ };
162
+ },
163
+ });
164
+ // ---------------------------------------------------------------------------
165
+ // UPGRADE 2 — createAgentModel model-override resolution
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * The fallback provider id when a model id does not match a known provider
169
+ * family. Anthropic is cline's most common default; the host gateway retains
170
+ * the final say over provider resolution.
171
+ */
172
+ exports.DEFAULT_CLINE_PROVIDER_ID = 'anthropic';
173
+ /**
174
+ * Infer a `providerId` (the createAgentModel first arg) from a model id by
175
+ * matching known provider families. Returns DEFAULT_CLINE_PROVIDER_ID for an
176
+ * unrecognized or empty id (fail-safe — the gateway applies its own default).
177
+ *
178
+ * Pure string-prefix classification; does not validate the id is a real model.
179
+ */
180
+ function inferProviderId(modelId) {
181
+ if (typeof modelId !== 'string' || modelId.length === 0)
182
+ return exports.DEFAULT_CLINE_PROVIDER_ID;
183
+ const lower = modelId.toLowerCase();
184
+ if (lower.startsWith('claude'))
185
+ return 'anthropic';
186
+ if (lower.startsWith('gpt') || lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('o4'))
187
+ return 'openai';
188
+ if (lower.startsWith('gemini'))
189
+ return 'google';
190
+ if (lower.startsWith('deepseek'))
191
+ return 'deepseek';
192
+ return exports.DEFAULT_CLINE_PROVIDER_ID;
193
+ }
194
+ /**
195
+ * Resolve the createAgentModel call params for a cline subagent from GSD's model
196
+ * override config. Mirrors the precedence OpenCode/Codex use (passive hosts
197
+ * embed the resolved model into agent frontmatter); for cline's active model
198
+ * mode the same resolution flows to `gateway.createAgentModel(params)`.
199
+ *
200
+ * Precedence (matches GSD's model_overrides > model_profile_overrides contract):
201
+ * 1. `modelOverrides[agentType]` — direct per-agent override
202
+ * 2. `modelProfileOverrides[profile][agentType]` — profile-scoped override
203
+ * 3. null — no override; the host gateway applies its own default
204
+ *
205
+ * Pure; never throws. Non-string / empty override values are ignored (fail-safe).
206
+ *
207
+ * @returns the `{providerId, modelId}` for createAgentModel, or null when no
208
+ * override is configured (the gateway default applies — GSD does NOT
209
+ * call createAgentModel in that case).
210
+ */
211
+ function resolveClineAgentModelParams(args) {
212
+ const { agentType, modelOverrides, modelProfileOverrides, profile } = args;
213
+ if (!agentType || typeof agentType !== 'string')
214
+ return null;
215
+ // 1. Direct per-agent override wins.
216
+ if (modelOverrides && typeof modelOverrides === 'object') {
217
+ const direct = modelOverrides[agentType];
218
+ if (typeof direct === 'string' && direct.length > 0) {
219
+ return { providerId: inferProviderId(direct), modelId: direct };
220
+ }
221
+ }
222
+ // 2. Profile-scoped override.
223
+ if (modelProfileOverrides && typeof modelProfileOverrides === 'object' && profile) {
224
+ const profileEntry = modelProfileOverrides[profile];
225
+ if (profileEntry && typeof profileEntry === 'object') {
226
+ const profileModel = profileEntry[agentType];
227
+ if (typeof profileModel === 'string' && profileModel.length > 0) {
228
+ return { providerId: inferProviderId(profileModel), modelId: profileModel };
229
+ }
230
+ }
231
+ }
232
+ // 3. No override — gateway default applies.
233
+ return null;
234
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Imperative hook-bus adapter — descriptor-driven hooks.json binding
3
+ * (ADR-1239 Phase D / #2089).
4
+ *
5
+ * Generalizes the Cursor-specific `writeCursorHooksJson`/`removeCursorHooksJson`
6
+ * into a descriptor-driven hook-bus binding that reads the negotiated `hookBus`
7
+ * axis + the host's documented hook-event list (from
8
+ * `runtime.hostBehaviors.managedHookEvents`), NOT a hardcoded
9
+ * `sessionStart`/`postToolUse` pair.
10
+ *
11
+ * This module is PURE (no I/O): it resolves the event→script mapping and builds
12
+ * the hooks.json entry manifest. The actual file I/O (copying scripts, writing
13
+ * hooks.json) stays in `runtime-hooks-surface.cts`, which calls into the pure
14
+ * functions exported here. This separation makes the binding testable without a
15
+ * filesystem.
16
+ *
17
+ * Cursor hook-event universe (closed vocabulary per ADR-1239,
18
+ * https://cursor.com/docs/hooks):
19
+ * sessionStart, sessionEnd, preToolUse, postToolUse, subagentStart,
20
+ * subagentStop, beforeShellExecution, afterShellExecution,
21
+ * afterMCPExecution, afterFileEdit, preCompact, stop,
22
+ * beforeTabFileRead, afterTabFileEdit, workspaceOpen
23
+ *
24
+ * GSD registers for the 6 events in the portable floor + subagent lifecycle
25
+ * (AC4a upgrade, #2089):
26
+ * sessionStart, postToolUse, preToolUse, stop, subagentStart, subagentStop
27
+ */
28
+ 'use strict';
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.GSD_HOOK_MARKER = exports.CURSOR_EVENT_SCRIPT_MAP = exports.CURSOR_HOOK_EVENTS = void 0;
31
+ exports.resolveManagedHookEvents = resolveManagedHookEvents;
32
+ exports.resolveHookScripts = resolveHookScripts;
33
+ exports.buildHookBusEntries = buildHookBusEntries;
34
+ /**
35
+ * The full set of Cursor hook events GSD can register for.
36
+ * Frozen closed vocabulary — adding an event requires updating both this set
37
+ * and the event→script mapping below.
38
+ */
39
+ exports.CURSOR_HOOK_EVENTS = Object.freeze([
40
+ 'sessionStart',
41
+ 'postToolUse',
42
+ 'preToolUse',
43
+ 'stop',
44
+ 'subagentStart',
45
+ 'subagentStop',
46
+ ]);
47
+ /**
48
+ * Event → hook-script mapping. Each event maps to a standalone `.js` script
49
+ * under `hooks/` that Cursor invokes via `hooks.json`.
50
+ *
51
+ * Convention: `gsd-cursor-<kebab-event>.js`. The script files are authored in
52
+ * `hooks/` and copied to `<configDir>/hooks/` during install by
53
+ * `runtime-hooks-surface.cts`.
54
+ */
55
+ exports.CURSOR_EVENT_SCRIPT_MAP = Object.freeze({
56
+ sessionStart: 'gsd-cursor-session-start.js',
57
+ postToolUse: 'gsd-cursor-post-tool.js',
58
+ preToolUse: 'gsd-cursor-pre-tool.js',
59
+ stop: 'gsd-cursor-stop.js',
60
+ subagentStart: 'gsd-cursor-subagent-start.js',
61
+ subagentStop: 'gsd-cursor-subagent-stop.js',
62
+ });
63
+ /**
64
+ * The GSD-managed marker written into each hooks.json entry so the
65
+ * reconcile pass can distinguish GSD-owned entries from user-owned ones.
66
+ */
67
+ exports.GSD_HOOK_MARKER = 'gsd-managed';
68
+ /**
69
+ * Resolve the managed hook events from a runtime descriptor's
70
+ * `hostBehaviors.managedHookEvents` list. Falls back to the full
71
+ * `CURSOR_HOOK_EVENTS` set when the descriptor does not declare the list
72
+ * (backward-compat for descriptors predating #2089).
73
+ *
74
+ * Pure: no I/O, never throws. Unknown event names are silently filtered
75
+ * (fail-closed — an unrecognized event is never registered). Falls back to
76
+ * the full CURSOR_HOOK_EVENTS set when the descriptor is absent or all entries
77
+ * are unrecognized (ensures the portable-event floor is always covered).
78
+ *
79
+ * @param managedHookEvents - the descriptor's `hostBehaviors.managedHookEvents` array
80
+ * @returns a deduplicated, validated array of event names
81
+ */
82
+ function resolveManagedHookEvents(managedHookEvents) {
83
+ if (!Array.isArray(managedHookEvents) || managedHookEvents.length === 0) {
84
+ return exports.CURSOR_HOOK_EVENTS;
85
+ }
86
+ const valid = new Set(exports.CURSOR_HOOK_EVENTS);
87
+ const seen = new Set();
88
+ const result = [];
89
+ for (const ev of managedHookEvents) {
90
+ if (typeof ev === 'string' && valid.has(ev) && !seen.has(ev)) {
91
+ seen.add(ev);
92
+ result.push(ev);
93
+ }
94
+ }
95
+ return result.length > 0 ? result : exports.CURSOR_HOOK_EVENTS;
96
+ }
97
+ /**
98
+ * Build the list of hook script files that need to be copied for the given
99
+ * managed events. Each event maps to a script via `CURSOR_EVENT_SCRIPT_MAP`.
100
+ *
101
+ * Pure: returns a deduplicated array of script filenames.
102
+ *
103
+ * @param events - the managed event names (validated by `resolveManagedHookEvents`)
104
+ * @returns array of script filenames (e.g. `['gsd-cursor-session-start.js', ...]`)
105
+ */
106
+ function resolveHookScripts(events) {
107
+ const scripts = [];
108
+ const seen = new Set();
109
+ for (const ev of events) {
110
+ const script = exports.CURSOR_EVENT_SCRIPT_MAP[ev];
111
+ if (script && !seen.has(script)) {
112
+ seen.add(script);
113
+ scripts.push(script);
114
+ }
115
+ }
116
+ return scripts;
117
+ }
118
+ /**
119
+ * Build the hooks.json managed-entry manifest for the given events.
120
+ * Each entry is `{ type: 'command', command: <cmd>, [GSD_HOOK_MARKER]: true }`.
121
+ *
122
+ * The `command` string is built by the caller (it requires platform-specific
123
+ * node-runner resolution from `runtime-hooks-surface.cts`). This function
124
+ * receives a pre-built `event → command` map and attaches the marker.
125
+ *
126
+ * Pure: no I/O.
127
+ *
128
+ * @param events - the managed event names
129
+ * @param commands - a map of event → command string (built by the caller)
130
+ * @returns a map of event → managed entry, ready for hooks.json reconciliation
131
+ */
132
+ function buildHookBusEntries(events, commands) {
133
+ const entries = {};
134
+ for (const ev of events) {
135
+ const cmd = commands[ev];
136
+ if (cmd) {
137
+ entries[ev] = {
138
+ type: 'command',
139
+ command: cmd,
140
+ [exports.GSD_HOOK_MARKER]: true,
141
+ };
142
+ }
143
+ }
144
+ return entries;
145
+ }
@@ -434,6 +434,22 @@ const EXTENSION_EVENT_SURFACES = Object.freeze({
434
434
  opencode: Object.freeze([
435
435
  'session.created', 'session.idle', 'experimental.session.compacting',
436
436
  'tool.execute.before', 'tool.execute.after', 'file.edited',
437
+ // #2087 — additional documented plugin events GSD binds (opencode.ai/docs/plugins):
438
+ // permission decisions + session error surface.
439
+ 'permission.asked', 'permission.replied', 'session.error',
440
+ ]),
441
+ // #2091 — Hermes Agent real plugin hook vocabulary (13 events).
442
+ // Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/user-guide/features/hooks.md
443
+ // Replaces the borrowed `hookEvents: "claude"` 6-event surface that silently
444
+ // never fired on Hermes.
445
+ hermes: Object.freeze([
446
+ 'pre_tool_call', 'post_tool_call',
447
+ 'pre_llm_call', 'post_llm_call',
448
+ 'on_session_start', 'on_session_end',
449
+ 'on_session_finalize', 'on_session_reset',
450
+ 'subagent_start', 'subagent_stop',
451
+ 'pre_gateway_dispatch', 'pre_approval_request',
452
+ 'transform_tool_result',
437
453
  ]),
438
454
  pi: Object.freeze(['tool_call']),
439
455
  none: Object.freeze([]),
@@ -63,9 +63,9 @@ const { resolveModelInternal, resolveGranularityInternal, assertValidGranularity
63
63
  const { findPhaseInternal } = phaseLocator;
64
64
  const { getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, } = roadmapParser;
65
65
  const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
66
- const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId;
66
+ const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
67
67
  const { pruneOrphanedWorktrees } = worktreeSafety;
68
- const { planningPaths, planningDir, planningRoot, getActiveWorkstream, findContextMdIn, } = planningWorkspace;
68
+ const { planningPaths, planningDir, planningRoot, listAvailableWorkstreams, getActiveWorkstream, findContextMdIn, } = planningWorkspace;
69
69
  const { determinePhaseStatus } = commandsMod;
70
70
  const { extractFrontmatter } = frontmatterMod;
71
71
  const { readVerificationStatus } = verificationMod;
@@ -74,6 +74,35 @@ const { evaluateUatPassed } = uatPredicateMod;
74
74
  void stripShippedMilestones;
75
75
  // Accept all bold/colon variants of the Requirements header (#2769)
76
76
  const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m;
77
+ // #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
78
+ // code (e.g. 'MEM-01' → '01'), so a foreign-prefixed workstream/task id would
79
+ // collapse to its numeric suffix and resolve to an unrelated numeric phase via
80
+ // the dir/roadmap fallback. init plan-phase must require EXACT prefixed
81
+ // evidence — a phase dir whose token literally IS the prefixed query, or a
82
+ // roadmap entry literally headed with it — before accepting such a match. The
83
+ // configured project_code's own prefix (e.g. LKML-01 under project_code: LKML)
84
+ // is never foreign and always passes through.
85
+ function parsePhasePrefix(phase) {
86
+ const match = String(phase).match(/^([A-Z][A-Z0-9_]*)-(?=\d)/i);
87
+ return match ? match[1] : null;
88
+ }
89
+ function isForeignPrefixedPhaseQuery(phase, projectCode) {
90
+ const prefix = parsePhasePrefix(phase);
91
+ if (!prefix)
92
+ return false;
93
+ const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
94
+ return !configured || prefix.toUpperCase() !== configured.toUpperCase();
95
+ }
96
+ function phaseInfoMatchesExactPrefix(phaseInfo, phase) {
97
+ const num = phaseInfo?.['phase_number'];
98
+ const numStr = typeof num === 'string' ? num : (typeof num === 'number' ? String(num) : '');
99
+ return numStr.toUpperCase() === phase.toUpperCase();
100
+ }
101
+ function roadmapPhaseMatchesExactPrefix(roadmapPhase, phase) {
102
+ const sectionRaw = roadmapPhase?.['section'];
103
+ const section = typeof sectionRaw === 'string' ? sectionRaw : '';
104
+ return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section);
105
+ }
77
106
  function listPhaseSummaryFiles(phaseDir) {
78
107
  return scanPhasePlans(phaseDir)['summaryFiles'];
79
108
  }
@@ -332,8 +361,18 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
332
361
  error('phase required for init plan-phase');
333
362
  }
334
363
  const config = loadConfig(cwd);
364
+ const foreignPrefixedPhase = isForeignPrefixedPhaseQuery(phase, config.project_code);
335
365
  let phaseInfo = findPhaseInternal(cwd, phase);
336
- const roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
366
+ // #2056: a foreign-prefixed query must only match a phase whose token literally
367
+ // IS the prefixed query (e.g. a real 'MEM-01-*' dir); otherwise the numeric
368
+ // fallback ('MEM-01' → '01') would resolve to the unrelated numeric phase.
369
+ if (foreignPrefixedPhase && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) {
370
+ phaseInfo = null;
371
+ }
372
+ let roadmapPhase = getRoadmapPhaseInternal(cwd, phase);
373
+ if (foreignPrefixedPhase && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) {
374
+ roadmapPhase = null;
375
+ }
337
376
  if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
338
377
  phaseInfo = null;
339
378
  }
@@ -573,6 +612,9 @@ function cmdInitQuick(cwd, description, raw) {
573
612
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
574
613
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
575
614
  verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
615
+ // #2072: the quick review step spawns gsd-code-reviewer; resolve its own model
616
+ // so model_overrides / models.verification apply (was reusing executor_model).
617
+ reviewer_model: resolveModelInternal(cwd, 'gsd-code-reviewer'),
576
618
  commit_docs: config.commit_docs,
577
619
  branch_name: quickBranchName,
578
620
  quick_id: quickId,
@@ -874,8 +916,8 @@ function cmdInitMilestoneOp(cwd, raw) {
874
916
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
875
917
  const roadmapRaw = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
876
918
  const currentSection = extractCurrentMilestone(roadmapRaw, cwd);
877
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
878
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/gi;
919
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
920
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, 'gi');
879
921
  let m;
880
922
  while ((m = phasePattern.exec(currentSection)) !== null) {
881
923
  if (/^999(?:\.|$)/.test(m[1]))
@@ -896,7 +938,7 @@ function cmdInitMilestoneOp(cwd, raw) {
896
938
  for (const e of entries) {
897
939
  if (!e.isDirectory())
898
940
  continue;
899
- const m = stripProjectCodePrefix(e.name).match(/^(\d+[A-Z]?(?:\.\d+)*)/);
941
+ const m = stripProjectCodePrefix(e.name).match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`));
900
942
  if (!m)
901
943
  continue;
902
944
  diskPhaseDirs.set(canonicalizePhase(m[1]), e.name);
@@ -1024,13 +1066,13 @@ function cmdInitManager(cwd, raw) {
1024
1066
  }
1025
1067
  })();
1026
1068
  const _checkboxStates = new Map();
1027
- const _cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
1069
+ const _cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
1028
1070
  let _cbMatch;
1029
1071
  while ((_cbMatch = _cbPattern.exec(content)) !== null) {
1030
1072
  _checkboxStates.set(_cbMatch[2], _cbMatch[1].toLowerCase() === 'x');
1031
1073
  }
1032
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1033
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1074
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1075
+ const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
1034
1076
  const phases = [];
1035
1077
  let match;
1036
1078
  while ((match = phasePattern.exec(content)) !== null) {
@@ -1148,7 +1190,7 @@ function cmdInitManager(cwd, raw) {
1148
1190
  .filter((p) => p['phase_complete'] === true)
1149
1191
  .map((p) => normalizePhaseNumber(p['number'])));
1150
1192
  const phaseMap = new Map(phases.map((p) => [normalizePhaseNumber(p['number']), p]));
1151
- const _allCompletedPattern = /-\s*\[x\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
1193
+ const _allCompletedPattern = new RegExp(`-\\s*\\[x\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
1152
1194
  let _allMatch;
1153
1195
  while ((_allMatch = _allCompletedPattern.exec(rawContent)) !== null) {
1154
1196
  const phaseNum = normalizePhaseNumber(_allMatch[1]);
@@ -1180,7 +1222,7 @@ function cmdInitManager(cwd, raw) {
1180
1222
  phase['deps_satisfied'] = true;
1181
1223
  }
1182
1224
  else {
1183
- const depNums = phase['depends_on'].match(/\d+[A-Z]?(?:\.\d+)*/gi) || [];
1225
+ const depNums = phase['depends_on'].match(new RegExp(`${PHASE_NUMBER_TOKEN_SOURCE}`, 'gi')) || [];
1184
1226
  phase['deps_satisfied'] = depNums.every((n) => completedNums.has(normalizePhaseNumber(n)));
1185
1227
  phase['dep_phases'] = depNums;
1186
1228
  }
@@ -1319,18 +1361,7 @@ function cmdInitProgress(cwd, raw) {
1319
1361
  // reporting a stale root milestone. Require an explicit workstream instead.
1320
1362
  // Mirror planningDir's resolution (GSD_WORKSTREAM env > stored active pointer) so
1321
1363
  // an explicit --ws (which sets GSD_WORKSTREAM) satisfies the check.
1322
- const _wsRoot = node_path_1.default.join(planningRoot(cwd), 'workstreams');
1323
- let _availableWorkstreams = [];
1324
- try {
1325
- _availableWorkstreams = node_fs_1.default
1326
- .readdirSync(_wsRoot, { withFileTypes: true })
1327
- .filter((e) => e.isDirectory())
1328
- .map((e) => e.name)
1329
- .sort();
1330
- }
1331
- catch {
1332
- /* no workstreams dir → flat mode */
1333
- }
1364
+ const _availableWorkstreams = listAvailableWorkstreams(cwd);
1334
1365
  const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
1335
1366
  if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
1336
1367
  error(`init.progress requires a workstream in workstream mode — no active workstream is set, so root STATE.md (likely stale) would be reported. ` +
@@ -1346,14 +1377,14 @@ function cmdInitProgress(cwd, raw) {
1346
1377
  const roadmapCheckboxStates = new Map();
1347
1378
  try {
1348
1379
  const roadmapContent = extractCurrentMilestone(node_fs_1.default.readFileSync(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8'), cwd);
1349
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1350
- const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
1380
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
1381
+ const headingPattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
1351
1382
  let hm;
1352
1383
  while ((hm = headingPattern.exec(roadmapContent)) !== null) {
1353
1384
  roadmapPhaseNums.add(hm[1]);
1354
1385
  roadmapPhaseNames.set(hm[1], hm[2].replace(/\(INSERTED\)/i, '').trim());
1355
1386
  }
1356
- const cbPattern = /-\s*\[(x| )\]\s*.*Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s]/gi;
1387
+ const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})[:\\s]`, 'gi');
1357
1388
  let cbm;
1358
1389
  while ((cbm = cbPattern.exec(roadmapContent)) !== null) {
1359
1390
  roadmapCheckboxStates.set(cbm[2], cbm[1].toLowerCase() === 'x');
@@ -1371,14 +1402,14 @@ function cmdInitProgress(cwd, raw) {
1371
1402
  .map((e) => e.name)
1372
1403
  .filter(isDirInMilestone)
1373
1404
  .sort((a, b) => {
1374
- const pa = a.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1375
- const pb = b.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
1405
+ const pa = a.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
1406
+ const pb = b.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
1376
1407
  if (!pa || !pb)
1377
1408
  return a.localeCompare(b);
1378
1409
  return parseInt(pa[1], 10) - parseInt(pb[1], 10);
1379
1410
  });
1380
1411
  for (const dir of dirs) {
1381
- const dirMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
1412
+ const dirMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
1382
1413
  const phaseNumber = dirMatch ? dirMatch[1] : dir;
1383
1414
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
1384
1415
  seenPhaseNums.add(phaseNumber.replace(/^0+/, '') || '0');
@@ -1870,11 +1901,24 @@ function buildSkillManifest(cwd, skillsDir = null) {
1870
1901
  kind: 'skills',
1871
1902
  },
1872
1903
  {
1873
- root: '~/.codex/skills',
1904
+ // ADR-1239 upgrade 3 (#2088): Codex's canonical skill root is
1905
+ // $HOME/.agents/skills (per codex core-skills loader.rs), resolved via
1906
+ // the skills-kind `home` override in getGlobalSkillsBase.
1907
+ root: '~/.agents/skills',
1874
1908
  path: (0, runtime_homes_cjs_1.getGlobalSkillsBase)('codex'),
1875
1909
  scope: 'global',
1876
1910
  kind: 'skills',
1877
1911
  },
1912
+ {
1913
+ // Codex's deprecated fallback skill root ($CODEX_HOME/skills). Kept as a
1914
+ // discovery-only legacy root so pre-move installs remain inventoried;
1915
+ // GSD no longer installs here (#2088).
1916
+ root: '~/.codex/skills',
1917
+ path: node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)('codex'), 'skills'),
1918
+ scope: 'global',
1919
+ kind: 'skills',
1920
+ deprecated: true,
1921
+ },
1878
1922
  {
1879
1923
  root: '.claude/gsd-core/skills',
1880
1924
  path: node_path_1.default.join(node_os_1.default.homedir(), '.claude', 'gsd-core', 'skills'),