@opengsd/gsd-core 1.4.3 → 1.5.0-rc.1

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 (149) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-executor.md +1 -1
  4. package/agents/gsd-plan-checker.md +11 -2
  5. package/agents/gsd-planner.md +11 -7
  6. package/agents/gsd-verifier.md +3 -3
  7. package/bin/install.js +825 -149
  8. package/commands/gsd/autonomous.md +5 -1
  9. package/commands/gsd/ns-manage.md +8 -1
  10. package/commands/gsd/ns-project.md +5 -0
  11. package/commands/gsd/ns-review.md +4 -1
  12. package/commands/gsd/ns-workflow.md +7 -1
  13. package/commands/gsd/plan-review-convergence.md +5 -4
  14. package/commands/gsd/surface.md +12 -5
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +198 -101
  17. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +417 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  20. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  21. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  22. package/gsd-core/bin/lib/configuration.cjs +4 -34
  23. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  24. package/gsd-core/bin/lib/core.cjs +57 -1817
  25. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  26. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  27. package/gsd-core/bin/lib/init.cjs +58 -12
  28. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  29. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  30. package/gsd-core/bin/lib/io.cjs +167 -0
  31. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  32. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  33. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  34. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  35. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  36. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  37. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  38. package/gsd-core/bin/lib/roadmap.cjs +4 -1
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +96 -9
  40. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +2 -1
  41. package/gsd-core/bin/lib/runtime-homes.cjs +40 -0
  42. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -0
  43. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  44. package/gsd-core/bin/lib/state.cjs +114 -2
  45. package/gsd-core/bin/lib/surface.cjs +66 -14
  46. package/gsd-core/bin/lib/verify.cjs +1 -1
  47. package/gsd-core/bin/shared/model-catalog.json +5 -0
  48. package/gsd-core/bin/shared/runtime-aliases.manifest.json +3 -0
  49. package/gsd-core/references/planner-reviews.md +5 -2
  50. package/gsd-core/templates/phase-prompt.md +7 -7
  51. package/gsd-core/templates/project.md +19 -2
  52. package/gsd-core/templates/summary-complex.md +1 -0
  53. package/gsd-core/templates/summary-minimal.md +1 -0
  54. package/gsd-core/templates/summary-standard.md +1 -0
  55. package/gsd-core/templates/summary.md +1 -0
  56. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  57. package/gsd-core/workflows/add-backlog.md +1 -1
  58. package/gsd-core/workflows/add-phase.md +1 -1
  59. package/gsd-core/workflows/add-tests.md +1 -1
  60. package/gsd-core/workflows/add-todo.md +1 -1
  61. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  62. package/gsd-core/workflows/audit-fix.md +1 -1
  63. package/gsd-core/workflows/audit-milestone.md +1 -1
  64. package/gsd-core/workflows/audit-uat.md +1 -1
  65. package/gsd-core/workflows/autonomous.md +86 -15
  66. package/gsd-core/workflows/check-todos.md +1 -1
  67. package/gsd-core/workflows/cleanup.md +1 -1
  68. package/gsd-core/workflows/code-review-fix.md +6 -4
  69. package/gsd-core/workflows/code-review.md +3 -2
  70. package/gsd-core/workflows/complete-milestone.md +11 -5
  71. package/gsd-core/workflows/debug.md +1 -1
  72. package/gsd-core/workflows/diagnose-issues.md +1 -1
  73. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  74. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  76. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  77. package/gsd-core/workflows/discuss-phase.md +8 -1
  78. package/gsd-core/workflows/do.md +1 -1
  79. package/gsd-core/workflows/docs-update.md +1 -1
  80. package/gsd-core/workflows/edit-phase.md +1 -1
  81. package/gsd-core/workflows/eval-review.md +4 -1
  82. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  83. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  84. package/gsd-core/workflows/execute-phase.md +8 -1
  85. package/gsd-core/workflows/execute-plan.md +1 -1
  86. package/gsd-core/workflows/explore.md +1 -1
  87. package/gsd-core/workflows/extract-learnings.md +1 -1
  88. package/gsd-core/workflows/forensics.md +1 -1
  89. package/gsd-core/workflows/graduation.md +1 -1
  90. package/gsd-core/workflows/health.md +1 -1
  91. package/gsd-core/workflows/help/modes/full.md +1 -1
  92. package/gsd-core/workflows/import.md +1 -1
  93. package/gsd-core/workflows/ingest-docs.md +1 -1
  94. package/gsd-core/workflows/insert-phase.md +1 -1
  95. package/gsd-core/workflows/list-workspaces.md +1 -1
  96. package/gsd-core/workflows/manager.md +1 -1
  97. package/gsd-core/workflows/map-codebase.md +1 -1
  98. package/gsd-core/workflows/milestone-summary.md +1 -1
  99. package/gsd-core/workflows/mvp-phase.md +1 -1
  100. package/gsd-core/workflows/new-milestone.md +1 -1
  101. package/gsd-core/workflows/new-project.md +1 -1
  102. package/gsd-core/workflows/new-workspace.md +1 -1
  103. package/gsd-core/workflows/next.md +1 -1
  104. package/gsd-core/workflows/pause-work.md +1 -1
  105. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  106. package/gsd-core/workflows/plan-phase.md +27 -2
  107. package/gsd-core/workflows/plan-review-convergence.md +60 -33
  108. package/gsd-core/workflows/plant-seed.md +1 -1
  109. package/gsd-core/workflows/profile-user.md +1 -1
  110. package/gsd-core/workflows/progress.md +1 -1
  111. package/gsd-core/workflows/quick.md +2 -2
  112. package/gsd-core/workflows/remove-phase.md +1 -1
  113. package/gsd-core/workflows/remove-workspace.md +1 -1
  114. package/gsd-core/workflows/resume-project.md +1 -1
  115. package/gsd-core/workflows/review.md +1 -1
  116. package/gsd-core/workflows/scan.md +1 -1
  117. package/gsd-core/workflows/secure-phase.md +1 -1
  118. package/gsd-core/workflows/settings-advanced.md +1 -1
  119. package/gsd-core/workflows/settings-integrations.md +1 -1
  120. package/gsd-core/workflows/settings.md +1 -1
  121. package/gsd-core/workflows/ship.md +8 -1
  122. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  123. package/gsd-core/workflows/sketch.md +1 -1
  124. package/gsd-core/workflows/spec-phase.md +1 -1
  125. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  126. package/gsd-core/workflows/spike.md +1 -1
  127. package/gsd-core/workflows/stats.md +1 -1
  128. package/gsd-core/workflows/thread.md +1 -1
  129. package/gsd-core/workflows/transition.md +1 -1
  130. package/gsd-core/workflows/ui-phase.md +1 -1
  131. package/gsd-core/workflows/ui-review.md +1 -1
  132. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  133. package/gsd-core/workflows/validate-phase.md +1 -1
  134. package/gsd-core/workflows/verify-phase.md +1 -1
  135. package/gsd-core/workflows/verify-work.md +8 -1
  136. package/package.json +7 -2
  137. package/scripts/base64-scan.sh +1 -1
  138. package/scripts/changeset/cli.cjs +8 -1
  139. package/scripts/changeset/lint.cjs +38 -2
  140. package/scripts/ci-test-scope.cjs +21 -10
  141. package/scripts/gen-capability-registry.cjs +1808 -0
  142. package/scripts/gen-loop-host-contract.cjs +471 -0
  143. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  144. package/scripts/lint-regression-test-names.cjs +117 -0
  145. package/scripts/lint-test-file-count.allowlist.json +12 -4
  146. package/scripts/prompt-injection-scan.sh +4 -4
  147. package/scripts/run-tests.cjs +79 -16
  148. package/scripts/secret-scan.sh +3 -3
  149. package/scripts/run-cross-platform-tests.cjs +0 -67
@@ -0,0 +1,460 @@
1
+ "use strict";
2
+ /**
3
+ * Loop Resolver — ADR-857 phase 3c registry-consuming query
4
+ *
5
+ * Given a loop point (one of the 12 canonical points from loop-host-contract.cjs),
6
+ * filters the materialized Capability Registry by config activation and returns
7
+ * the active hooks as a JSON envelope with a rendered-markdown field.
8
+ *
9
+ * REGISTRY-ONLY: no workflow calls this yet (phase-6 cutover is out of scope).
10
+ *
11
+ * Command surface: gsd-tools loop render-hooks <point>
12
+ *
13
+ * Exports (three things):
14
+ * resolveLoopHooks({ point, registry, config }) → { point, activeHooks }
15
+ * renderLoopHooks(resolved) → markdown string
16
+ * cmdLoopRenderHooks(cwd, point, raw, options) — I/O entry point
17
+ *
18
+ * Both pure functions (resolveLoopHooks, renderLoopHooks) take explicit
19
+ * registry/config arguments so they are trivially testable without I/O.
20
+ *
21
+ * Dependencies (leaf modules only — no core.cjs circular risk):
22
+ * - node:fs / node:path (raw config.json read for capability-key activation)
23
+ * - ./config-loader.cjs (loadConfig)
24
+ * - ./planning-workspace.cjs (planningDir — to locate config.json)
25
+ * - ./core.cjs (output, error)
26
+ * - loop-host-contract.cjs (CANONICAL_POINTS via LOOP_HOST_CONTRACT)
27
+ * - capability-registry.cjs (byLoopPoint, consumed at call time)
28
+ */
29
+ var __importDefault = (this && this.__importDefault) || function (mod) {
30
+ return (mod && mod.__esModule) ? mod : { "default": mod };
31
+ };
32
+ const node_fs_1 = __importDefault(require("node:fs"));
33
+ const node_path_1 = __importDefault(require("node:path"));
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const core = require("./core.cjs");
36
+ const { output: coreOutput, error: coreError } = core;
37
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
38
+ const configLoaderModule = require("./config-loader.cjs");
39
+ const { loadConfig } = configLoaderModule;
40
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
+ const planningWorkspaceMod = require("./planning-workspace.cjs");
42
+ const { planningDir, planningRoot } = planningWorkspaceMod;
43
+ // ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
44
+ // FIX 2: Derive the authoritative canonical set from LOOP_HOST_CONTRACT so it
45
+ // cannot drift from the host contract. CANONICAL_POINTS_FALLBACK is kept as an
46
+ // alias for backward compatibility in tests and exports.
47
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
48
+ const _loopHostContract = require('./loop-host-contract.cjs');
49
+ const CANONICAL_POINTS = (() => {
50
+ try {
51
+ const contract = _loopHostContract.LOOP_HOST_CONTRACT;
52
+ if (Array.isArray(contract)) {
53
+ const pts = [];
54
+ for (const step of contract) {
55
+ if (step && Array.isArray(step.points)) {
56
+ for (const p of step.points) {
57
+ if (typeof p === 'string')
58
+ pts.push(p);
59
+ }
60
+ }
61
+ }
62
+ if (pts.length > 0)
63
+ return pts;
64
+ }
65
+ }
66
+ catch { /* fall through to hardcoded fallback */ }
67
+ return [
68
+ 'discuss:pre',
69
+ 'discuss:post',
70
+ 'plan:pre',
71
+ 'plan:post',
72
+ 'execute:pre',
73
+ 'execute:wave:pre',
74
+ 'execute:wave:post',
75
+ 'execute:post',
76
+ 'verify:pre',
77
+ 'verify:post',
78
+ 'ship:pre',
79
+ 'ship:post',
80
+ ];
81
+ })();
82
+ // Alias for backward compatibility (tests import this name)
83
+ const CANONICAL_POINTS_FALLBACK = CANONICAL_POINTS;
84
+ // FIX 2: _getCanonicalPoints now returns the authoritative CANONICAL_POINTS set
85
+ // derived from LOOP_HOST_CONTRACT — not the registry's byLoopPoint keys.
86
+ // The registry's byLoopPoint is only used to READ hooks, not to define valid points.
87
+ function _getCanonicalPoints(_registry) {
88
+ return CANONICAL_POINTS;
89
+ }
90
+ // ─── Prototype-pollution guard (inline literal, CodeQL barrier) ───────────────
91
+ /**
92
+ * Traverse a dotted config key through a nested config object.
93
+ * E.g. "workflow.ui_phase" in { workflow: { ui_phase: true } } → { found: true, value: true }
94
+ * Returns { found: false } if any segment is a forbidden key or not an own property.
95
+ */
96
+ function _getNestedConfigValue(config, dotKey) {
97
+ const segments = dotKey.split('.');
98
+ let current = config;
99
+ for (const seg of segments) {
100
+ // Inline literal prototype-pollution guard (CodeQL barrier)
101
+ if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
102
+ return { found: false, value: undefined };
103
+ }
104
+ if (typeof current !== 'object' || current === null) {
105
+ return { found: false, value: undefined };
106
+ }
107
+ const cur = current;
108
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
109
+ return { found: false, value: undefined };
110
+ }
111
+ current = cur[seg];
112
+ }
113
+ return { found: true, value: current };
114
+ }
115
+ // ─── Single-key activation resolver (FIX 1) ───────────────────────────────────
116
+ /**
117
+ * Warn-once set for raw config.json parse errors.
118
+ * Avoids noisy per-call stderr from a single malformed file.
119
+ */
120
+ const _warnedRawConfigPaths = new Set();
121
+ /**
122
+ * Read a raw config.json file and perform a guarded nested-lookup of a single
123
+ * dotted key. Returns { found: false } if the file is missing (ENOENT) or if
124
+ * the key is absent/forbidden. On a genuine JSON parse error: warns once to
125
+ * stderr and returns { found: false } — never throws.
126
+ */
127
+ function _readRawConfigKey(filePath, dotKey) {
128
+ try {
129
+ const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
130
+ let parsed;
131
+ try {
132
+ parsed = JSON.parse(raw);
133
+ }
134
+ catch {
135
+ if (!_warnedRawConfigPaths.has(filePath)) {
136
+ _warnedRawConfigPaths.add(filePath);
137
+ try {
138
+ process.stderr.write(`gsd-tools: warning: failed to parse ${filePath} as JSON — skipping for activation resolution\n`);
139
+ }
140
+ catch { /* stderr might be closed */ }
141
+ }
142
+ return { found: false, value: undefined };
143
+ }
144
+ return _getNestedConfigValue(parsed, dotKey);
145
+ }
146
+ catch {
147
+ // ENOENT (missing file) is expected → skip silently. All other errors → also skip (defensive).
148
+ return { found: false, value: undefined };
149
+ }
150
+ }
151
+ /**
152
+ * FIX 1: Resolve the effective value for a hook's `when` key using the
153
+ * four-level precedence:
154
+ *
155
+ * 1. loadConfig result (`config` arg) — guarded nested-lookup of the dotted key.
156
+ * This is the post-cutover federated path (covers keys that loadConfig now exposes).
157
+ * 2. Raw workstream `.planning/.../config.json` — guarded single-key lookup.
158
+ * Workstream wins over root (mirrors loadConfig inheritance).
159
+ * 3. Raw root `.planning/config.json` — guarded single-key lookup.
160
+ * 4. `registry.configSchema[when]?.default` — schema default.
161
+ * A `default: true` hook is active out-of-the-box without any config.
162
+ * 5. Absent → inactive (return false).
163
+ *
164
+ * Never constructs a merged object from raw JSON keys — only reads the single
165
+ * leaf value at the guarded dotted path. Prototype-pollution sink is eliminated.
166
+ */
167
+ function _resolveActivationValue(dotKey, config, cwd, registry) {
168
+ // Level 1: loadConfig result
169
+ const fromConfig = _getNestedConfigValue(config, dotKey);
170
+ if (fromConfig.found)
171
+ return Boolean(fromConfig.value);
172
+ // Level 2 + 3: raw config.json files (only when cwd is available)
173
+ if (cwd) {
174
+ // Level 2: workstream config (planningDir respects GSD_WORKSTREAM env)
175
+ const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
176
+ // Level 3: root config (planningRoot = cwd/.planning always)
177
+ const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
178
+ // Workstream wins over root (mirroring loadConfig root→workstream precedence:
179
+ // workstream overlays root, so workstream value takes precedence).
180
+ const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
181
+ if (fromWs.found)
182
+ return Boolean(fromWs.value);
183
+ // Only read root if it differs from the workstream path (avoids double-read
184
+ // when no workstream is active and both paths resolve to the same file).
185
+ if (wsConfigPath !== rootConfigPath) {
186
+ const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
187
+ if (fromRoot.found)
188
+ return Boolean(fromRoot.value);
189
+ }
190
+ }
191
+ // Level 4: registry configSchema default
192
+ const schemaEntry = registry['configSchema']?.[dotKey];
193
+ if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
194
+ const def = schemaEntry['default'];
195
+ if (def !== undefined)
196
+ return Boolean(def);
197
+ }
198
+ // Level 5: absent → inactive
199
+ return false;
200
+ }
201
+ // ─── Pure resolver ─────────────────────────────────────────────────────────────
202
+ /**
203
+ * Pure resolver: given a point, registry, and config, returns the active hooks.
204
+ *
205
+ * Throws if `point` is not one of the 12 canonical points (caller converts to
206
+ * core.error). Never throws for malformed registry/hook entries — skips and
207
+ * continues.
208
+ *
209
+ * Ordering: steps first, then contributions, then gates. Within each array,
210
+ * the materialized registry order is preserved.
211
+ *
212
+ * Activation: a hook with no `when` is always active. With `when` (dotted key),
213
+ * resolved against `config`; active iff truthy. Inactive hooks are filtered out.
214
+ */
215
+ function resolveLoopHooks(input) {
216
+ const { point, registry, config, cwd } = input;
217
+ // Validate point
218
+ const canonicalPoints = _getCanonicalPoints(registry);
219
+ if (!canonicalPoints.includes(point)) {
220
+ throw new Error(`Invalid loop point: "${point}". Valid points: ${canonicalPoints.join(', ')}`);
221
+ }
222
+ // Guard: registry missing byLoopPoint
223
+ const byLoopPoint = registry['byLoopPoint'];
224
+ if (!byLoopPoint || typeof byLoopPoint !== 'object' || Array.isArray(byLoopPoint)) {
225
+ return { point, activeHooks: [] };
226
+ }
227
+ const byLoopPointMap = byLoopPoint;
228
+ // Guard: point missing in registry
229
+ const entry = byLoopPointMap[point];
230
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
231
+ return { point, activeHooks: [] };
232
+ }
233
+ const entryMap = entry;
234
+ const activeHooks = [];
235
+ // Helper: check activation using single-key precedence resolver (FIX 1 + FIX 3)
236
+ function isActive(hook) {
237
+ const when = hook['when'];
238
+ // No `when` → unconditional hook, always active
239
+ if (when === undefined || when === null)
240
+ return true;
241
+ // FIX 3: `when` present but not a non-empty string → malformed registry data → INACTIVE
242
+ if (typeof when !== 'string' || when.length === 0)
243
+ return false;
244
+ return _resolveActivationValue(when, config, cwd, registry);
245
+ }
246
+ // Helper: safe string array
247
+ function toStringArray(v) {
248
+ if (!Array.isArray(v))
249
+ return [];
250
+ return v.filter((x) => typeof x === 'string');
251
+ }
252
+ // Process steps
253
+ const stepsRaw = entryMap['steps'];
254
+ const steps = Array.isArray(stepsRaw) ? stepsRaw : [];
255
+ for (const hook of steps) {
256
+ if (!hook || typeof hook !== 'object')
257
+ continue;
258
+ if (!isActive(hook))
259
+ continue;
260
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
261
+ const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
262
+ ? hook['ref']
263
+ : undefined;
264
+ const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
265
+ const produces = toStringArray(hook['produces']);
266
+ const consumes = toStringArray(hook['consumes']);
267
+ const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
268
+ const active = { capId, kind: 'step' };
269
+ if (ref !== undefined)
270
+ active.ref = ref;
271
+ if (when !== undefined)
272
+ active.when = when;
273
+ if (produces.length > 0)
274
+ active.produces = produces;
275
+ if (consumes.length > 0)
276
+ active.consumes = consumes;
277
+ if (onError !== undefined)
278
+ active.onError = onError;
279
+ activeHooks.push(active);
280
+ }
281
+ // Process contributions
282
+ const contributionsRaw = entryMap['contributions'];
283
+ const contributions = Array.isArray(contributionsRaw) ? contributionsRaw : [];
284
+ for (const hook of contributions) {
285
+ if (!hook || typeof hook !== 'object')
286
+ continue;
287
+ if (!isActive(hook))
288
+ continue;
289
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
290
+ const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
291
+ const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
292
+ const produces = toStringArray(hook['produces']);
293
+ const consumes = toStringArray(hook['consumes']);
294
+ const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
295
+ const active = { capId, kind: 'contribution' };
296
+ if (into !== undefined)
297
+ active.into = into;
298
+ if (when !== undefined)
299
+ active.when = when;
300
+ if (produces.length > 0)
301
+ active.produces = produces;
302
+ if (consumes.length > 0)
303
+ active.consumes = consumes;
304
+ if (onError !== undefined)
305
+ active.onError = onError;
306
+ activeHooks.push(active);
307
+ }
308
+ // Process gates
309
+ const gatesRaw = entryMap['gates'];
310
+ const gates = Array.isArray(gatesRaw) ? gatesRaw : [];
311
+ for (const hook of gates) {
312
+ if (!hook || typeof hook !== 'object')
313
+ continue;
314
+ if (!isActive(hook))
315
+ continue;
316
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
317
+ const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
318
+ const check = hook['check'] !== undefined ? hook['check'] : undefined;
319
+ const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
320
+ const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
321
+ const active = { capId, kind: 'gate' };
322
+ if (when !== undefined)
323
+ active.when = when;
324
+ if (check !== undefined)
325
+ active.check = check;
326
+ if (blocking !== undefined)
327
+ active.blocking = blocking;
328
+ if (onError !== undefined)
329
+ active.onError = onError;
330
+ activeHooks.push(active);
331
+ }
332
+ return { point, activeHooks };
333
+ }
334
+ // ─── Pure renderer ─────────────────────────────────────────────────────────────
335
+ /**
336
+ * Pure renderer: given a resolved result, returns a deterministic markdown string.
337
+ *
338
+ * Empty active set → returns a "no active hooks" placeholder line.
339
+ * Steps: heading with ordinal + skill ref + capId, produces/consumes lines.
340
+ * Contributions: labeled block.
341
+ * Gates: check name, blocking flag, onError.
342
+ */
343
+ function renderLoopHooks(resolved) {
344
+ const { point, activeHooks } = resolved;
345
+ if (activeHooks.length === 0) {
346
+ return `_No active hooks at ${point}._`;
347
+ }
348
+ const lines = [];
349
+ let stepOrdinal = 0;
350
+ for (const hook of activeHooks) {
351
+ if (hook.kind === 'step') {
352
+ stepOrdinal += 1;
353
+ const refStr = hook.ref?.skill
354
+ ? `skill:${hook.ref.skill}`
355
+ : JSON.stringify(hook.ref ?? {});
356
+ lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`);
357
+ if (hook.produces && hook.produces.length > 0) {
358
+ lines.push(`- produces: ${hook.produces.join(', ')}`);
359
+ }
360
+ if (hook.consumes && hook.consumes.length > 0) {
361
+ lines.push(`- consumes: ${hook.consumes.join(', ')}`);
362
+ }
363
+ if (hook.when) {
364
+ lines.push(`- when: \`${hook.when}\``);
365
+ }
366
+ if (hook.onError) {
367
+ lines.push(`- onError: ${hook.onError}`);
368
+ }
369
+ lines.push('');
370
+ }
371
+ else if (hook.kind === 'contribution') {
372
+ lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}"/>`);
373
+ if (hook.produces && hook.produces.length > 0) {
374
+ lines.push(`- produces: ${hook.produces.join(', ')}`);
375
+ }
376
+ if (hook.consumes && hook.consumes.length > 0) {
377
+ lines.push(`- consumes: ${hook.consumes.join(', ')}`);
378
+ }
379
+ if (hook.when) {
380
+ lines.push(`- when: \`${hook.when}\``);
381
+ }
382
+ lines.push('');
383
+ }
384
+ else if (hook.kind === 'gate') {
385
+ let checkStr = '(none)';
386
+ if (hook.check !== undefined && hook.check !== null) {
387
+ checkStr = typeof hook.check === 'object'
388
+ ? JSON.stringify(hook.check)
389
+ : typeof hook.check === 'string' || typeof hook.check === 'number' || typeof hook.check === 'boolean'
390
+ ? String(hook.check)
391
+ : '(complex)';
392
+ }
393
+ lines.push(`**Gate** (${hook.capId}): check=${checkStr}, blocking=${String(hook.blocking ?? false)}, onError=${hook.onError ?? 'skip'}`);
394
+ if (hook.when) {
395
+ lines.push(`- when: \`${hook.when}\``);
396
+ }
397
+ lines.push('');
398
+ }
399
+ }
400
+ // Trim trailing blank line
401
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
402
+ lines.pop();
403
+ }
404
+ return lines.join('\n');
405
+ }
406
+ // ─── I/O command handler ───────────────────────────────────────────────────────
407
+ /**
408
+ * Command entry point: load registry + config, resolve + render, emit envelope.
409
+ *
410
+ * Envelope: { point, activeHooks, rendered }
411
+ * On invalid point, emits core.error instead of throwing.
412
+ *
413
+ * Config note: FIX 1 replaced _loadMergedConfig (whole-config deep-merge) with a
414
+ * per-hook single-key activation resolver (_resolveActivationValue). The resolver
415
+ * checks loadConfig result first, then raw config.json files directly (workstream
416
+ * then root), then the registry's configSchema default. This eliminates the
417
+ * merged-object-from-untrusted-keys security concern and correctly handles
418
+ * pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
419
+ * yet exposed through loadConfig's whitelist.
420
+ */
421
+ function cmdLoopRenderHooks(cwd, point, raw, _options = {}) {
422
+ if (!point) {
423
+ coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
424
+ return;
425
+ }
426
+ // Load registry at call time (generated file, not at module load time)
427
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
428
+ const registry = require('./capability-registry.cjs');
429
+ // FIX 1: Pass loadConfig result as `config` (level 1 of precedence);
430
+ // raw config.json reads (levels 2+3) happen per-hook inside _resolveActivationValue
431
+ // via the `cwd` argument passed to resolveLoopHooks.
432
+ const config = loadConfig(cwd);
433
+ let resolved;
434
+ try {
435
+ resolved = resolveLoopHooks({ point, registry, config, cwd });
436
+ }
437
+ catch (err) {
438
+ const msg = (err instanceof Error) ? err.message : String(err);
439
+ coreError(msg);
440
+ return;
441
+ }
442
+ const rendered = renderLoopHooks(resolved);
443
+ const envelope = {
444
+ point: resolved.point,
445
+ activeHooks: resolved.activeHooks,
446
+ rendered,
447
+ };
448
+ coreOutput(envelope, raw);
449
+ }
450
+ module.exports = {
451
+ resolveLoopHooks,
452
+ renderLoopHooks,
453
+ cmdLoopRenderHooks,
454
+ // Exported for tests
455
+ _getNestedConfigValue,
456
+ _resolveActivationValue,
457
+ _readRawConfigKey,
458
+ CANONICAL_POINTS_FALLBACK,
459
+ CANONICAL_POINTS,
460
+ };