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

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 (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-mempalace-curator.md +47 -0
  3. package/bin/install.js +25 -7
  4. package/commands/gsd/mempalace-capture.md +71 -0
  5. package/commands/gsd/mempalace-recall.md +102 -0
  6. package/commands/gsd/ns-context.md +4 -2
  7. package/gemini-extension.json +1 -1
  8. package/gsd-core/bin/gsd-tools.cjs +110 -92
  9. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  10. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  11. package/gsd-core/bin/lib/capability-state.cjs +31 -18
  12. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  13. package/gsd-core/bin/lib/clusters.cjs +2 -0
  14. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  15. package/gsd-core/bin/lib/commands.cjs +33 -10
  16. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  17. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  18. package/gsd-core/bin/lib/config.cjs +1 -2
  19. package/gsd-core/bin/lib/core.cjs +5 -2
  20. package/gsd-core/bin/lib/init.cjs +8 -5
  21. package/gsd-core/bin/lib/install-profiles.cjs +2 -1
  22. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  23. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  24. package/gsd-core/bin/lib/phase.cjs +71 -11
  25. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  26. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  27. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  28. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1700 -0
  29. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +20 -28
  30. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  31. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  32. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  33. package/gsd-core/bin/lib/state-document.cjs +46 -1
  34. package/gsd-core/bin/lib/state.cjs +221 -35
  35. package/gsd-core/bin/lib/verify.cjs +19 -4
  36. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  37. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  38. package/gsd-core/bin/shared/model-catalog.json +1 -0
  39. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  40. package/gsd-core/templates/config.json +1 -1
  41. package/gsd-core/workflows/audit-milestone.md +5 -3
  42. package/gsd-core/workflows/autonomous.md +10 -5
  43. package/gsd-core/workflows/code-review-fix.md +9 -7
  44. package/gsd-core/workflows/code-review.md +8 -6
  45. package/gsd-core/workflows/discuss-phase.md +14 -0
  46. package/gsd-core/workflows/execute-phase.md +82 -143
  47. package/gsd-core/workflows/help/modes/full.md +3 -1
  48. package/gsd-core/workflows/new-project.md +3 -3
  49. package/gsd-core/workflows/plan-phase.md +89 -221
  50. package/gsd-core/workflows/profile-user.md +3 -1
  51. package/gsd-core/workflows/progress.md +58 -1
  52. package/gsd-core/workflows/quick.md +9 -6
  53. package/gsd-core/workflows/review.md +19 -2
  54. package/gsd-core/workflows/secure-phase.md +4 -2
  55. package/gsd-core/workflows/settings-advanced.md +2 -0
  56. package/gsd-core/workflows/ship.md +57 -0
  57. package/gsd-core/workflows/validate-phase.md +4 -2
  58. package/gsd-core/workflows/verify-work.md +27 -11
  59. package/package.json +3 -2
  60. package/scripts/affected-tests-lib.cjs +16 -4
  61. package/scripts/gen-capability-registry.cjs +359 -48
  62. package/scripts/gen-inventory-manifest.cjs +1 -4
  63. package/scripts/gen-loop-host-contract.cjs +55 -0
  64. package/scripts/issue-version-gate.cjs +140 -0
  65. package/scripts/lint-allow-test-rule-refs.allowlist.json +326 -0
  66. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  67. package/scripts/lint-test-file-count.allowlist.json +8 -0
  68. package/scripts/release-tarball-smoke.cjs +7 -1
  69. package/scripts/run-tests.cjs +74 -14
@@ -16,14 +16,43 @@
16
16
  * the prior hand-written .cjs; only types are added.
17
17
  */
18
18
  const configuration_cjs_1 = require("./configuration.cjs");
19
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
20
+ const capabilityRegistry = require('./capability-registry.cjs');
21
+ function isCapabilityConfigKey(keyPath) {
22
+ if (typeof keyPath !== 'string')
23
+ return false;
24
+ const schema = capabilityRegistry.configSchema;
25
+ if (!schema || typeof schema !== 'object')
26
+ return false;
27
+ return Object.prototype.hasOwnProperty.call(schema, keyPath);
28
+ }
19
29
  /**
20
- * Returns true if keyPath is a valid config key (exact, dynamic pattern, or runtime state).
30
+ * Returns true for keys owned by the central schema adapter rather than a
31
+ * federated Capability config slice.
21
32
  */
22
- function isValidConfigKey(keyPath) {
33
+ function isCentralConfigKey(keyPath) {
34
+ if (typeof keyPath !== 'string')
35
+ return false;
23
36
  if (configuration_cjs_1.VALID_CONFIG_KEYS.has(keyPath))
24
37
  return true;
25
38
  if (configuration_cjs_1.RUNTIME_STATE_KEYS.has(keyPath))
26
39
  return true;
27
40
  return configuration_cjs_1.DYNAMIC_KEY_PATTERNS.some((p) => p.test(keyPath));
28
41
  }
29
- module.exports = { VALID_CONFIG_KEYS: configuration_cjs_1.VALID_CONFIG_KEYS, RUNTIME_STATE_KEYS: configuration_cjs_1.RUNTIME_STATE_KEYS, DYNAMIC_KEY_PATTERNS: configuration_cjs_1.DYNAMIC_KEY_PATTERNS, isValidConfigKey };
42
+ /**
43
+ * Returns true if keyPath is a valid central, runtime-state, dynamic, or
44
+ * federated Capability config key.
45
+ */
46
+ function isValidConfigKey(keyPath) {
47
+ if (isCentralConfigKey(keyPath))
48
+ return true;
49
+ return isCapabilityConfigKey(keyPath);
50
+ }
51
+ module.exports = {
52
+ VALID_CONFIG_KEYS: configuration_cjs_1.VALID_CONFIG_KEYS,
53
+ RUNTIME_STATE_KEYS: configuration_cjs_1.RUNTIME_STATE_KEYS,
54
+ DYNAMIC_KEY_PATTERNS: configuration_cjs_1.DYNAMIC_KEY_PATTERNS,
55
+ isCapabilityConfigKey,
56
+ isCentralConfigKey,
57
+ isValidConfigKey,
58
+ };
@@ -206,7 +206,6 @@ function buildNewProjectConfig(userChoices) {
206
206
  ui_phase: true,
207
207
  ui_safety_gate: true,
208
208
  ai_integration_phase: true,
209
- tdd_mode: false,
210
209
  human_verify_mode: 'end-of-phase',
211
210
  text_mode: false,
212
211
  research_before_questions: false,
@@ -234,7 +233,7 @@ function buildNewProjectConfig(userChoices) {
234
233
  project_code: null,
235
234
  phase_naming: 'sequential',
236
235
  agent_skills: {},
237
- claude_md_path: './CLAUDE.md',
236
+ claude_md_path: './.claude/CLAUDE.md',
238
237
  plan_review: {
239
238
  source_grounding: true,
240
239
  source_grounding_authority: 'grep',
@@ -47,7 +47,7 @@ const { loadConfig, isGitIgnored, CONFIG_DEFAULTS, _warnUnknownProfileOverrides,
47
47
  // ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
48
48
  // eslint-disable-next-line @typescript-eslint/no-require-imports
49
49
  const modelResolverModule = require("./model-resolver.cjs");
50
- const { resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, } = modelResolverModule;
50
+ const { resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, _resetModelPolicyWarningCacheForTests, } = modelResolverModule;
51
51
  // ─── Path helpers ────────────────────────────────────────────────────────────
52
52
  // toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
53
53
  // The destructured bindings above (from coreUtilsModule) make them available to
@@ -305,7 +305,10 @@ module.exports = {
305
305
  resolveTierEntry,
306
306
  resolveModelPolicy,
307
307
  KNOWN_PROVIDERS: model_catalog_cjs_1.KNOWN_PROVIDERS,
308
- _resetRuntimeWarningCacheForTests,
308
+ _resetRuntimeWarningCacheForTests: () => {
309
+ _resetRuntimeWarningCacheForTests();
310
+ _resetModelPolicyWarningCacheForTests();
311
+ },
309
312
  pathExistsInternal,
310
313
  gitWorktreeInfoInternal,
311
314
  generateSlugInternal,
@@ -182,10 +182,11 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
182
182
  ? reqMatch[1].replace(/[\[\]]/g, '').split(',').map((s) => s.trim()).filter(Boolean).join(', ')
183
183
  : null;
184
184
  const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
185
+ const wf = (config.workflow ?? {});
185
186
  const result = {
186
187
  executor_model: resolveModelInternal(cwd, 'gsd-executor'),
187
188
  verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
188
- tdd_mode: options['tdd'] || config.tdd_mode || false,
189
+ tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
189
190
  commit_docs: config.commit_docs,
190
191
  sub_repos: config.sub_repos,
191
192
  parallelization: config.parallelization,
@@ -300,15 +301,16 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
300
301
  const granularityOverride = options['granularity'];
301
302
  assertValidGranularityOverride(granularityOverride, error);
302
303
  const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
304
+ const wf = (config.workflow ?? {});
303
305
  const result = {
304
306
  researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
305
307
  planner_model: resolveModelInternal(cwd, 'gsd-planner'),
306
308
  checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
307
- tdd_mode: options['tdd'] || config.tdd_mode || false,
309
+ tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
308
310
  granularity,
309
- research_enabled: config.research,
311
+ research_enabled: wf['research'],
310
312
  plan_checker_enabled: config.plan_checker,
311
- nyquist_validation_enabled: config.nyquist_validation,
313
+ nyquist_validation_enabled: wf['nyquist_validation'],
312
314
  commit_docs: config.commit_docs,
313
315
  text_mode: config.text_mode,
314
316
  auto_advance: !!(config.auto_advance),
@@ -507,12 +509,13 @@ function cmdInitNewMilestone(cwd, raw) {
507
509
  catch {
508
510
  /* intentionally empty */
509
511
  }
512
+ const wf = (config.workflow ?? {});
510
513
  const result = {
511
514
  researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
512
515
  synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
513
516
  roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
514
517
  commit_docs: config.commit_docs,
515
- research_enabled: config.research,
518
+ research_enabled: wf['research'],
516
519
  current_milestone: milestone['version'],
517
520
  current_milestone_name: milestone['name'],
518
521
  latest_completed_milestone: latestCompleted?.version || null,
@@ -111,7 +111,8 @@ function parseCallsAgents(content) {
111
111
  * `gsd-*` agent name references. Agent stems are stored under the special
112
112
  * key `_calls_agents_<stem>` so they don't conflict with skill stems.
113
113
  */
114
- function loadSkillsManifest(commandsDir) {
114
+ const DEFAULT_COMMANDS_DIR = node_path_1.default.resolve(__dirname, '..', '..', '..', 'commands', 'gsd');
115
+ function loadSkillsManifest(commandsDir = DEFAULT_COMMANDS_DIR) {
115
116
  const manifest = new Map();
116
117
  if (!node_fs_1.default.existsSync(commandsDir))
117
118
  return manifest;
@@ -6,7 +6,9 @@
6
6
  * filters the materialized Capability Registry by config activation and returns
7
7
  * the active hooks as a JSON envelope with a rendered-markdown field.
8
8
  *
9
- * REGISTRY-ONLY: no workflow calls this yet (phase-6 cutover is out of scope).
9
+ * Consumed live by the landed phase-6 loop-hook cutovers: plan-phase.md / autonomous.md
10
+ * at plan:pre (ui-phase) and autonomous.md at verify:post (ui-review). Further per-feature
11
+ * cutovers are ongoing.
10
12
  *
11
13
  * Command surface: gsd-tools loop render-hooks <point>
12
14
  *
@@ -38,6 +40,9 @@ const { output: coreOutput, error: coreError } = core;
38
40
  const configLoaderModule = require("./config-loader.cjs");
39
41
  const { loadConfig } = configLoaderModule;
40
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ const capabilityStateModule = require("./capability-state.cjs");
44
+ const { resolveCapabilityRuntimeState } = capabilityStateModule;
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
41
46
  const planningWorkspaceMod = require("./planning-workspace.cjs");
42
47
  const { planningDir, planningRoot } = planningWorkspaceMod;
43
48
  // ─── Canonical points (derived from LOOP_HOST_CONTRACT — authoritative 12) ───
@@ -213,7 +218,7 @@ function _resolveActivationValue(dotKey, config, cwd, registry) {
213
218
  * resolved against `config`; active iff truthy. Inactive hooks are filtered out.
214
219
  */
215
220
  function resolveLoopHooks(input) {
216
- const { point, registry, config, cwd } = input;
221
+ const { point, registry, config, cwd, capabilityStatesById } = input;
217
222
  // Validate point
218
223
  const canonicalPoints = _getCanonicalPoints(registry);
219
224
  if (!canonicalPoints.includes(point)) {
@@ -243,31 +248,112 @@ function resolveLoopHooks(input) {
243
248
  return false;
244
249
  return _resolveActivationValue(when, config, cwd, registry);
245
250
  }
251
+ function isCapabilityEnabled(capId) {
252
+ if (!capabilityStatesById)
253
+ return true;
254
+ const state = capabilityStatesById instanceof Map
255
+ ? capabilityStatesById.get(capId)
256
+ : capabilityStatesById[capId];
257
+ if (!state)
258
+ return false;
259
+ return state.enabled !== false;
260
+ }
246
261
  // Helper: safe string array
247
262
  function toStringArray(v) {
248
263
  if (!Array.isArray(v))
249
264
  return [];
250
265
  return v.filter((x) => typeof x === 'string');
251
266
  }
267
+ function toFragment(v) {
268
+ if (!v || typeof v !== 'object' || Array.isArray(v))
269
+ return undefined;
270
+ const raw = v;
271
+ const fragment = {};
272
+ if (typeof raw.inline === 'string')
273
+ fragment.inline = raw.inline;
274
+ if (typeof raw.path === 'string')
275
+ fragment.path = raw.path;
276
+ return Object.keys(fragment).length > 0 ? fragment : undefined;
277
+ }
278
+ /**
279
+ * Resolve declared configValues for a contribution hook.
280
+ * The hook may carry `configValues: { alias: "dotted.key", ... }`.
281
+ * Each key is resolved using the same four-level precedence as activation resolution,
282
+ * but returning the raw value (not coerced to boolean) so numeric/string config values
283
+ * are preserved (e.g. security_asvs_level: 2, security_block_on: "medium").
284
+ */
285
+ function resolveConfigValues(hook) {
286
+ const raw = hook['configValues'];
287
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
288
+ return undefined;
289
+ const rawMap = raw;
290
+ const resolved = {};
291
+ for (const [alias, dotKey] of Object.entries(rawMap)) {
292
+ // Prototype-pollution guard (inline literal, CodeQL barrier)
293
+ if (alias === '__proto__' || alias === 'constructor' || alias === 'prototype')
294
+ continue;
295
+ if (typeof dotKey !== 'string')
296
+ continue;
297
+ // Level 1: loadConfig result
298
+ const fromConfig = _getNestedConfigValue(config, dotKey);
299
+ if (fromConfig.found) {
300
+ resolved[alias] = fromConfig.value;
301
+ continue;
302
+ }
303
+ // Level 2 + 3: raw config.json files
304
+ if (cwd) {
305
+ const wsConfigPath = node_path_1.default.join(planningDir(cwd), 'config.json');
306
+ const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
307
+ const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
308
+ if (fromWs.found) {
309
+ resolved[alias] = fromWs.value;
310
+ continue;
311
+ }
312
+ if (wsConfigPath !== rootConfigPath) {
313
+ const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
314
+ if (fromRoot.found) {
315
+ resolved[alias] = fromRoot.value;
316
+ continue;
317
+ }
318
+ }
319
+ }
320
+ // Level 4: registry configSchema default
321
+ const schemaEntry = registry['configSchema']?.[dotKey];
322
+ if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
323
+ const def = schemaEntry['default'];
324
+ if (def !== undefined) {
325
+ resolved[alias] = def;
326
+ continue;
327
+ }
328
+ }
329
+ // Level 5: absent → undefined (omit from resolved map)
330
+ }
331
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
332
+ }
252
333
  // Process steps
253
334
  const stepsRaw = entryMap['steps'];
254
335
  const steps = Array.isArray(stepsRaw) ? stepsRaw : [];
255
336
  for (const hook of steps) {
256
337
  if (!hook || typeof hook !== 'object')
257
338
  continue;
339
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
340
+ if (!isCapabilityEnabled(capId))
341
+ continue;
258
342
  if (!isActive(hook))
259
343
  continue;
260
- const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
261
344
  const ref = (typeof hook['ref'] === 'object' && hook['ref'] !== null)
262
345
  ? hook['ref']
263
346
  : undefined;
264
347
  const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
348
+ const fragment = toFragment(hook['fragment']);
265
349
  const produces = toStringArray(hook['produces']);
266
350
  const consumes = toStringArray(hook['consumes']);
267
351
  const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
268
352
  const active = { capId, kind: 'step' };
269
353
  if (ref !== undefined)
270
354
  active.ref = ref;
355
+ if (fragment !== undefined)
356
+ active.fragment = fragment;
271
357
  if (when !== undefined)
272
358
  active.when = when;
273
359
  if (produces.length > 0)
@@ -284,17 +370,23 @@ function resolveLoopHooks(input) {
284
370
  for (const hook of contributions) {
285
371
  if (!hook || typeof hook !== 'object')
286
372
  continue;
373
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
374
+ if (!isCapabilityEnabled(capId))
375
+ continue;
287
376
  if (!isActive(hook))
288
377
  continue;
289
- const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
290
378
  const into = typeof hook['into'] === 'string' ? hook['into'] : undefined;
379
+ const fragment = toFragment(hook['fragment']);
291
380
  const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
292
381
  const produces = toStringArray(hook['produces']);
293
382
  const consumes = toStringArray(hook['consumes']);
294
383
  const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
384
+ const configValuesResolved = resolveConfigValues(hook);
295
385
  const active = { capId, kind: 'contribution' };
296
386
  if (into !== undefined)
297
387
  active.into = into;
388
+ if (fragment !== undefined)
389
+ active.fragment = fragment;
298
390
  if (when !== undefined)
299
391
  active.when = when;
300
392
  if (produces.length > 0)
@@ -303,6 +395,8 @@ function resolveLoopHooks(input) {
303
395
  active.consumes = consumes;
304
396
  if (onError !== undefined)
305
397
  active.onError = onError;
398
+ if (configValuesResolved !== undefined)
399
+ active.configValues = configValuesResolved;
306
400
  activeHooks.push(active);
307
401
  }
308
402
  // Process gates
@@ -311,9 +405,11 @@ function resolveLoopHooks(input) {
311
405
  for (const hook of gates) {
312
406
  if (!hook || typeof hook !== 'object')
313
407
  continue;
408
+ const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
409
+ if (!isCapabilityEnabled(capId))
410
+ continue;
314
411
  if (!isActive(hook))
315
412
  continue;
316
- const capId = typeof hook['capId'] === 'string' ? hook['capId'] : '';
317
413
  const when = typeof hook['when'] === 'string' ? hook['when'] : undefined;
318
414
  const check = hook['check'] !== undefined ? hook['check'] : undefined;
319
415
  const blocking = typeof hook['blocking'] === 'boolean' ? hook['blocking'] : undefined;
@@ -352,7 +448,9 @@ function renderLoopHooks(resolved) {
352
448
  stepOrdinal += 1;
353
449
  const refStr = hook.ref?.skill
354
450
  ? `skill:${hook.ref.skill}`
355
- : JSON.stringify(hook.ref ?? {});
451
+ : hook.ref?.agent
452
+ ? `agent:${hook.ref.agent}`
453
+ : JSON.stringify(hook.ref ?? {});
356
454
  lines.push(`### Step ${stepOrdinal}: ${refStr} (${hook.capId})`);
357
455
  if (hook.produces && hook.produces.length > 0) {
358
456
  lines.push(`- produces: ${hook.produces.join(', ')}`);
@@ -366,10 +464,24 @@ function renderLoopHooks(resolved) {
366
464
  if (hook.onError) {
367
465
  lines.push(`- onError: ${hook.onError}`);
368
466
  }
467
+ if (hook.fragment?.inline) {
468
+ lines.push('');
469
+ lines.push(hook.fragment.inline);
470
+ }
471
+ else if (hook.fragment?.path) {
472
+ lines.push('');
473
+ lines.push(`_Step fragment path is declared but not rendered by loop-resolver: ${hook.fragment.path}_`);
474
+ }
369
475
  lines.push('');
370
476
  }
371
477
  else if (hook.kind === 'contribution') {
372
- lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}"/>`);
478
+ lines.push(`<contribution from="${hook.capId}" into="${hook.into ?? '(unset)'}">`);
479
+ if (hook.fragment?.inline) {
480
+ lines.push(hook.fragment.inline);
481
+ }
482
+ else if (hook.fragment?.path) {
483
+ lines.push(`_Contribution fragment path is declared but not rendered by loop-resolver: ${hook.fragment.path}_`);
484
+ }
373
485
  if (hook.produces && hook.produces.length > 0) {
374
486
  lines.push(`- produces: ${hook.produces.join(', ')}`);
375
487
  }
@@ -379,6 +491,10 @@ function renderLoopHooks(resolved) {
379
491
  if (hook.when) {
380
492
  lines.push(`- when: \`${hook.when}\``);
381
493
  }
494
+ if (hook.onError) {
495
+ lines.push(`- onError: ${hook.onError}`);
496
+ }
497
+ lines.push('</contribution>');
382
498
  lines.push('');
383
499
  }
384
500
  else if (hook.kind === 'gate') {
@@ -417,34 +533,59 @@ function renderLoopHooks(resolved) {
417
533
  * merged-object-from-untrusted-keys security concern and correctly handles
418
534
  * pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
419
535
  * yet exposed through loadConfig's whitelist.
536
+ *
537
+ * --active-cap <capId>: when present, resolves hooks for <point> exactly as the
538
+ * normal path does, then prints exactly `true` (if any resolved activeHook has
539
+ * capId === <capId>) or `false` followed by a single newline, and exits 0.
540
+ * No JSON envelope is emitted — output is clean for shell $(…) capture.
541
+ * Missing <capId> value → coreError + non-zero exit.
542
+ * Unknown/inactive capId → `false` (not an error).
420
543
  */
421
- function cmdLoopRenderHooks(cwd, point, raw, _options = {}) {
544
+ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
422
545
  if (!point) {
423
546
  coreError('loop render-hooks requires a <point> argument. Valid points: ' + CANONICAL_POINTS.join(', '));
424
547
  return;
425
548
  }
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);
549
+ // --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
550
+ const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
551
+ if (activeCapId !== undefined && activeCapId === '') {
552
+ coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
553
+ return;
554
+ }
555
+ const runtimeConfigDir = typeof options['configDir'] === 'string'
556
+ ? options['configDir']
557
+ : undefined;
558
+ const state = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
559
+ const registry = state.registry;
560
+ const config = state.config || loadConfig(cwd);
561
+ const capabilityStatesById = new Map();
562
+ for (const cap of state.capabilities || []) {
563
+ capabilityStatesById.set(cap.id, cap);
564
+ }
433
565
  let resolved;
434
566
  try {
435
- resolved = resolveLoopHooks({ point, registry, config, cwd });
567
+ resolved = resolveLoopHooks({ point, registry, config, cwd, capabilityStatesById });
436
568
  }
437
569
  catch (err) {
438
570
  const msg = (err instanceof Error) ? err.message : String(err);
439
571
  coreError(msg);
440
572
  return;
441
573
  }
574
+ // --active-cap mode: print exactly 'true' or 'false' with no envelope
575
+ if (activeCapId !== undefined) {
576
+ const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
577
+ process.stdout.write(isActive ? 'true\n' : 'false\n');
578
+ return;
579
+ }
442
580
  const rendered = renderLoopHooks(resolved);
443
581
  const envelope = {
444
582
  point: resolved.point,
445
583
  activeHooks: resolved.activeHooks,
446
584
  rendered,
447
585
  };
586
+ if (state.warnings && state.warnings.length > 0) {
587
+ envelope.warnings = state.warnings;
588
+ }
448
589
  coreOutput(envelope, raw);
449
590
  }
450
591
  module.exports = {
@@ -55,6 +55,30 @@ function _resolveRuntimeTier(config, tier) {
55
55
  overrides: config['model_profile_overrides'],
56
56
  });
57
57
  }
58
+ // Reverse of the Claude tier-default IDs, plus the Fable alias which Claude
59
+ // Code's Agent tool accepts but which is not a GSD model-profile tier (#1133).
60
+ const CLAUDE_POLICY_ID_TO_ALIAS = {
61
+ ...Object.fromEntries(Object.entries(model_catalog_cjs_1.MODEL_ALIAS_MAP)
62
+ .filter((e) => typeof e[1] === 'string')
63
+ .map(([aliasName, id]) => [id, aliasName])),
64
+ 'claude-fable-5': 'fable',
65
+ };
66
+ const CLAUDE_AGENT_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
67
+ // Dedupe stderr warnings so repeated agent resolutions don't spam (#1133).
68
+ const _modelPolicyUnmappableWarned = new Set();
69
+ function warnModelPolicyUnmappable(agentType, policyModel, tier) {
70
+ const key = `${agentType}::${policyModel}::${tier}`;
71
+ if (_modelPolicyUnmappableWarned.has(key))
72
+ return;
73
+ _modelPolicyUnmappableWarned.add(key);
74
+ // MUST go to stderr — resolve-model's JSON result is parsed from stdout.
75
+ process.stderr.write(`gsd: warning — model_policy resolved "${policyModel}" for ${agentType}, ` +
76
+ `but it has no Claude agent alias; using "${tier}" instead.\n`);
77
+ }
78
+ // Test-only: reset the model_policy warn-dedupe cache between cases (#1133).
79
+ function _resetModelPolicyWarningCacheForTests() {
80
+ _modelPolicyUnmappableWarned.clear();
81
+ }
58
82
  /**
59
83
  * #49 — Provider-neutral model policy preset resolution.
60
84
  */
@@ -132,15 +156,32 @@ function resolveModelInternal(cwd, agentType) {
132
156
  : (profile === 'inherit'
133
157
  ? 'inherit'
134
158
  : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
135
- // 2.5. model_policy preset (#49)
159
+ // 2.5. model_policy preset (#49, #1133)
136
160
  const configRuntime = config['runtime'];
137
- if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
161
+ if (tier && tier !== 'inherit') {
162
+ const onClaude = !configRuntime || configRuntime === 'claude';
163
+ const effectiveRuntime = configRuntime || 'claude';
138
164
  const mergedPolicy = config['model_policy']
139
- ? { ...config['model_policy'], runtime: configRuntime }
165
+ ? { ...config['model_policy'], runtime: effectiveRuntime }
140
166
  : null;
141
167
  const policyModel = resolveModelPolicy(mergedPolicy, tier);
142
- if (policyModel)
143
- return policyModel;
168
+ if (policyModel) {
169
+ // Non-Claude runtimes take full model IDs verbatim (unchanged behavior).
170
+ if (!onClaude)
171
+ return policyModel;
172
+ // Claude Code's Agent tool takes tier aliases (opus/sonnet/haiku/fable),
173
+ // not full model IDs — map the policy-resolved ID back to an alias (#1133).
174
+ const aliasForId = CLAUDE_POLICY_ID_TO_ALIAS[policyModel];
175
+ if (aliasForId)
176
+ return aliasForId;
177
+ // The policy value may already be a bare Claude agent alias (e.g. "fable").
178
+ if (CLAUDE_AGENT_ALIASES.has(policyModel))
179
+ return policyModel;
180
+ // No Claude alias for this ID (e.g. a pinned minor version like
181
+ // claude-opus-4-5). Warn once and fall through to the tier alias rather
182
+ // than returning an ID Claude Code cannot spawn.
183
+ warnModelPolicyUnmappable(agentType, policyModel, tier);
184
+ }
144
185
  }
145
186
  // 3. Runtime-aware resolution (#2517)
146
187
  if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
@@ -413,6 +454,7 @@ module.exports = {
413
454
  resolveTierEntry,
414
455
  resolveModelPolicy,
415
456
  resolveModelInternal,
457
+ _resetModelPolicyWarningCacheForTests,
416
458
  VALID_GRANULARITIES,
417
459
  resolveGranularityInternal,
418
460
  assertValidGranularityOverride,
@@ -32,6 +32,7 @@ const stateMod = require("./state.cjs");
32
32
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
33
33
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
34
34
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
35
+ const clock_cjs_1 = require("./clock.cjs");
35
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- uat-predicate.cjs is an export= CommonJS module
36
37
  const uatPredicate = require("./uat-predicate.cjs");
37
38
  const { evaluateUatPassed } = uatPredicate;
@@ -1019,7 +1020,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1019
1020
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1020
1021
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1021
1022
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1022
- const today = new Date().toISOString().split('T')[0];
1023
+ const today = clock_cjs_1.realClock.today();
1023
1024
  const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
1024
1025
  if (!phaseInfoRaw) {
1025
1026
  error(`Phase ${phaseNum} not found`);
@@ -1049,9 +1050,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1049
1050
  }
1050
1051
  for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
1051
1052
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1052
- if (/status: human_needed/.test(content))
1053
+ // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1054
+ // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
1055
+ // A full-text regex like /status: gaps_found/ matches the substring inside
1056
+ // `previous_status: gaps_found`, producing spurious warnings even when the
1057
+ // current frontmatter status is `passed`.
1058
+ const verFm = extractFrontmatter(content);
1059
+ // Normalise to lower-case so `status: Passed` (title-case) is not missed.
1060
+ const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
1061
+ if (verStatus === 'human_needed')
1053
1062
  warnings.push(`${file}: needs human verification`);
1054
- if (/status: gaps_found/.test(content))
1063
+ if (verStatus === 'gaps_found')
1055
1064
  warnings.push(`${file}: has unresolved gaps`);
1056
1065
  }
1057
1066
  }
@@ -1074,15 +1083,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1074
1083
  const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1075
1084
  roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
1076
1085
  const cells = fullRow.split('|').slice(1, -1);
1086
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1077
1087
  if (cells.length === 5) {
1078
1088
  cells[2] = ` ${summaryCount}/${planCount} `;
1079
1089
  cells[3] = ' Complete ';
1080
- cells[4] = ` ${today} `;
1090
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1091
+ const existingDate5 = cells[4].trim();
1092
+ cells[4] = dateShape.test(existingDate5) ? cells[4] : ` ${today} `;
1081
1093
  }
1082
1094
  else if (cells.length === 4) {
1083
1095
  cells[1] = ` ${summaryCount}/${planCount} `;
1084
1096
  cells[2] = ' Complete ';
1085
- cells[3] = ` ${today} `;
1097
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1098
+ const existingDate4 = cells[3].trim();
1099
+ cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
1086
1100
  }
1087
1101
  return '|' + cells.join('|') + '|';
1088
1102
  });
@@ -1123,13 +1137,59 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1123
1137
  reqContent = reqContent.replace(new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), '$1 Complete $2');
1124
1138
  }
1125
1139
  }
1140
+ // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
1141
+ // Requirements under headings whose text contains "deferred", "backlog",
1142
+ // "future", or "v2" (case-insensitive) are explicitly out of current scope
1143
+ // and must not be flagged as missing from the Traceability table.
1144
+ //
1145
+ // Strategy: walk lines, track heading depth, and toggle a "deferred" flag
1146
+ // when a heading matching the pattern is encountered. A sub-heading (higher
1147
+ // depth) that is ITSELF in a deferred parent remains deferred unless it
1148
+ // opens a same-or-shallower heading that does NOT match the pattern.
1149
+ // Lines inside fenced code blocks (``` or ~~~) are treated as content, not
1150
+ // headings, to avoid false deferred-section detection from code examples.
1151
+ const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
1126
1152
  const bodyReqIds = [];
1127
- const bodyReqPattern = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
1128
- let bodyMatch;
1129
- while ((bodyMatch = bodyReqPattern.exec(reqContent)) !== null) {
1130
- const id = bodyMatch[1];
1131
- if (!bodyReqIds.includes(id))
1132
- bodyReqIds.push(id);
1153
+ // deferredDepth: the heading level that opened the current deferred block,
1154
+ // or 0 when we are in an active section.
1155
+ let deferredDepth = 0;
1156
+ let inFence = false;
1157
+ for (const line of reqContent.split(/\r?\n/)) {
1158
+ // Track fenced code blocks (``` or ~~~).
1159
+ if (/^\s*(?:```|~~~)/.test(line)) {
1160
+ inFence = !inFence;
1161
+ continue;
1162
+ }
1163
+ if (inFence)
1164
+ continue; // ignore content inside a code fence
1165
+ const headingM = line.match(/^(#{1,6})\s+(.*)/);
1166
+ if (headingM) {
1167
+ const depth = headingM[1].length;
1168
+ const text = headingM[2];
1169
+ if (deferredDepth > 0 && depth > deferredDepth) {
1170
+ // Sub-heading inside a deferred block: stays deferred regardless of name.
1171
+ continue;
1172
+ }
1173
+ // Heading at same level or shallower than current deferred opener,
1174
+ // or no active deferred block yet.
1175
+ if (DEFERRED_HEADING_RE.test(text)) {
1176
+ deferredDepth = depth; // enter a deferred block
1177
+ }
1178
+ else {
1179
+ deferredDepth = 0; // back in an active section
1180
+ }
1181
+ continue;
1182
+ }
1183
+ if (deferredDepth > 0)
1184
+ continue; // skip content in deferred sections
1185
+ // Collect bold REQ-ID patterns from active-section lines.
1186
+ const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
1187
+ let bodyMatch;
1188
+ while ((bodyMatch = reqPat.exec(line)) !== null) {
1189
+ const id = bodyMatch[1];
1190
+ if (!bodyReqIds.includes(id))
1191
+ bodyReqIds.push(id);
1192
+ }
1133
1193
  }
1134
1194
  const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
1135
1195
  const traceabilitySection = traceabilityHeadingMatch