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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +1 -1
  3. package/agents/gsd-assumptions-analyzer.md +1 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-code-reviewer.md +1 -1
  6. package/agents/gsd-codebase-mapper.md +1 -1
  7. package/agents/gsd-debugger.md +1 -1
  8. package/agents/gsd-doc-writer.md +1 -1
  9. package/agents/gsd-eval-auditor.md +1 -1
  10. package/agents/gsd-executor.md +1 -1
  11. package/agents/gsd-integration-checker.md +1 -1
  12. package/agents/gsd-mempalace-curator.md +47 -0
  13. package/agents/gsd-nyquist-auditor.md +1 -0
  14. package/agents/gsd-phase-researcher.md +1 -1
  15. package/agents/gsd-plan-checker.md +1 -1
  16. package/agents/gsd-planner.md +1 -1
  17. package/agents/gsd-project-researcher.md +1 -1
  18. package/agents/gsd-research-synthesizer.md +1 -1
  19. package/agents/gsd-roadmapper.md +55 -2
  20. package/agents/gsd-security-auditor.md +1 -0
  21. package/agents/gsd-ui-auditor.md +1 -1
  22. package/agents/gsd-ui-checker.md +1 -1
  23. package/agents/gsd-ui-researcher.md +1 -1
  24. package/agents/gsd-verifier.md +13 -2
  25. package/bin/install.js +61 -64
  26. package/commands/gsd/mempalace-capture.md +71 -0
  27. package/commands/gsd/mempalace-recall.md +102 -0
  28. package/commands/gsd/ns-context.md +4 -2
  29. package/commands/gsd/progress.md +2 -1
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +277 -95
  32. package/gsd-core/bin/lib/active-workstream-store.cjs +6 -0
  33. package/gsd-core/bin/lib/capability-activation.cjs +86 -0
  34. package/gsd-core/bin/lib/capability-registry.cjs +1468 -11
  35. package/gsd-core/bin/lib/capability-state.cjs +128 -21
  36. package/gsd-core/bin/lib/capability-writer.cjs +354 -0
  37. package/gsd-core/bin/lib/check-command-router.cjs +328 -1
  38. package/gsd-core/bin/lib/clusters.cjs +2 -0
  39. package/gsd-core/bin/lib/command-roster.cjs +19 -0
  40. package/gsd-core/bin/lib/commands.cjs +33 -10
  41. package/gsd-core/bin/lib/config-loader.cjs +7 -8
  42. package/gsd-core/bin/lib/config-schema.cjs +32 -3
  43. package/gsd-core/bin/lib/config.cjs +81 -26
  44. package/gsd-core/bin/lib/core.cjs +5 -2
  45. package/gsd-core/bin/lib/edge-probe.cjs +25 -2
  46. package/gsd-core/bin/lib/frontmatter.cjs +53 -1
  47. package/gsd-core/bin/lib/git-base-branch.cjs +194 -0
  48. package/gsd-core/bin/lib/init.cjs +36 -11
  49. package/gsd-core/bin/lib/install-profiles.cjs +57 -1
  50. package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
  51. package/gsd-core/bin/lib/loop-resolver.cjs +157 -16
  52. package/gsd-core/bin/lib/model-resolver.cjs +47 -5
  53. package/gsd-core/bin/lib/phase.cjs +99 -23
  54. package/gsd-core/bin/lib/plan-drift-guard.cjs +117 -0
  55. package/gsd-core/bin/lib/probe-core.cjs +117 -1
  56. package/gsd-core/bin/lib/profile-output.cjs +45 -4
  57. package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +138 -0
  58. package/gsd-core/bin/lib/roadmap-parser.cjs +13 -3
  59. package/gsd-core/bin/lib/roadmap.cjs +97 -7
  60. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +1946 -0
  61. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +54 -30
  62. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +27 -19
  63. package/gsd-core/bin/lib/runtime-homes.cjs +26 -20
  64. package/gsd-core/bin/lib/state-command-router.cjs +15 -3
  65. package/gsd-core/bin/lib/state-document.cjs +46 -1
  66. package/gsd-core/bin/lib/state.cjs +461 -94
  67. package/gsd-core/bin/lib/verify.cjs +92 -8
  68. package/gsd-core/bin/lib/worktree-safety.cjs +2 -1
  69. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -2
  70. package/gsd-core/bin/shared/config-schema.manifest.json +0 -18
  71. package/gsd-core/bin/shared/model-catalog.json +1 -0
  72. package/gsd-core/references/edge-probe.md +11 -0
  73. package/gsd-core/references/loop-hook-dispatch.md +61 -0
  74. package/gsd-core/references/prohibition-probe-fixtures/01-streak-reminder/expected.json +14 -0
  75. package/gsd-core/references/prohibition-probe-fixtures/02-clean-utility/expected.json +4 -0
  76. package/gsd-core/references/prohibition-probe-fixtures/03-multi-prohibition/expected.json +32 -0
  77. package/gsd-core/references/prohibition-probe.md +248 -0
  78. package/gsd-core/templates/config.json +1 -1
  79. package/gsd-core/templates/spec.md +14 -0
  80. package/gsd-core/workflows/audit-milestone.md +5 -3
  81. package/gsd-core/workflows/autonomous.md +10 -5
  82. package/gsd-core/workflows/code-review-fix.md +9 -7
  83. package/gsd-core/workflows/code-review.md +8 -6
  84. package/gsd-core/workflows/complete-milestone.md +1 -5
  85. package/gsd-core/workflows/discuss-phase.md +14 -0
  86. package/gsd-core/workflows/execute-phase.md +86 -146
  87. package/gsd-core/workflows/execute-plan.md +21 -6
  88. package/gsd-core/workflows/help/modes/full.md +7 -1
  89. package/gsd-core/workflows/new-project.md +3 -3
  90. package/gsd-core/workflows/next.md +50 -2
  91. package/gsd-core/workflows/pause-work.md +7 -1
  92. package/gsd-core/workflows/plan-phase.md +91 -221
  93. package/gsd-core/workflows/plan-review-convergence.md +14 -4
  94. package/gsd-core/workflows/pr-branch.md +4 -2
  95. package/gsd-core/workflows/profile-user.md +3 -1
  96. package/gsd-core/workflows/progress.md +58 -1
  97. package/gsd-core/workflows/quick.md +12 -8
  98. package/gsd-core/workflows/resume-project.md +17 -1
  99. package/gsd-core/workflows/review.md +19 -2
  100. package/gsd-core/workflows/secure-phase.md +4 -2
  101. package/gsd-core/workflows/settings-advanced.md +2 -0
  102. package/gsd-core/workflows/settings.md +27 -1
  103. package/gsd-core/workflows/ship.md +58 -5
  104. package/gsd-core/workflows/spec-phase.md +75 -0
  105. package/gsd-core/workflows/validate-phase.md +4 -2
  106. package/gsd-core/workflows/verify-phase.md +14 -4
  107. package/gsd-core/workflows/verify-work.md +27 -11
  108. package/hooks/dist/gsd-ensure-canonical-path.js +305 -0
  109. package/hooks/dist/gsd-statusline.js +1 -1
  110. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  111. package/hooks/gsd-ensure-canonical-path.js +305 -0
  112. package/hooks/gsd-statusline.js +1 -1
  113. package/hooks/hooks.json +1 -0
  114. package/hooks/managed-hooks-registry.cjs +1 -0
  115. package/package.json +5 -4
  116. package/scripts/affected-tests-lib.cjs +16 -4
  117. package/scripts/build-hooks.js +7 -0
  118. package/scripts/changeset/new.cjs +17 -3
  119. package/scripts/fix-slash-commands.cjs +15 -3
  120. package/scripts/gen-capability-registry.cjs +373 -49
  121. package/scripts/gen-inventory-manifest.cjs +1 -4
  122. package/scripts/gen-loop-host-contract.cjs +55 -0
  123. package/scripts/issue-version-gate.cjs +140 -0
  124. package/scripts/lint-allow-test-rule-refs.allowlist.json +327 -0
  125. package/scripts/lint-allow-test-rule-refs.cjs +162 -0
  126. package/scripts/lint-test-file-count.allowlist.json +14 -0
  127. package/scripts/mutation-matrix.cjs +108 -7
  128. package/scripts/pr-target-policy.cjs +63 -0
  129. package/scripts/release-tarball-smoke.cjs +7 -1
  130. package/scripts/research-profiles.cjs +5 -5
  131. package/scripts/run-tests.cjs +178 -17
@@ -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;
@@ -504,6 +505,60 @@ function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converte
504
505
  ensureExitCleanup();
505
506
  return stageDir;
506
507
  }
508
+ /**
509
+ * Stage a converted copy of the agents directory for a given runtime.
510
+ *
511
+ * Analogous to `stageCommandsForRuntimeFlat` but for agent `.md` files. Each
512
+ * source `.md` is passed through `converter` and written as a flat `${name}.md`
513
+ * file in the staging directory. Agent filenames are kept verbatim (no prefix
514
+ * added here — the prefix is already embedded in agent stems, e.g. `gsd-planner.md`).
515
+ *
516
+ * This is used by the descriptor-driven `dispatchKindEntry` when an `agents` kind
517
+ * entry carries a non-null converter (ADR-457 / #1173). When `converter` is null,
518
+ * `agentsKind` falls back to the existing raw-copy path (`stageAgentsForProfile`).
519
+ *
520
+ * For the `full` profile (`skills === '*'`), all `.md` files are staged.
521
+ * For tiered profiles, only agents whose full stem is in `resolvedProfile.agents`
522
+ * are staged (mirrors `stageAgentsForProfile` behaviour).
523
+ *
524
+ * @param srcAgentsDir source agents directory (e.g. agents/)
525
+ * @param resolvedProfile profile filter from resolveProfile()
526
+ * @param converter (content: string) → string pure per-file converter
527
+ */
528
+ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, converter) {
529
+ if (!node_fs_1.default.existsSync(srcAgentsDir))
530
+ return srcAgentsDir;
531
+ const stageDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd-profile-runtime-agents-'));
532
+ try {
533
+ const entries = node_fs_1.default.readdirSync(srcAgentsDir, { withFileTypes: true });
534
+ for (const entry of entries) {
535
+ if (!entry.isFile())
536
+ continue;
537
+ if (!entry.name.endsWith('.md'))
538
+ continue;
539
+ // For tiered profiles, gate by agent stem (full filename without extension).
540
+ if (resolvedProfile.skills !== '*') {
541
+ const stem = entry.name.slice(0, -3);
542
+ if (!(resolvedProfile.agents instanceof Set && resolvedProfile.agents.has(stem))) {
543
+ continue;
544
+ }
545
+ }
546
+ const content = node_fs_1.default.readFileSync(node_path_1.default.join(srcAgentsDir, entry.name), 'utf8');
547
+ const converted = converter(content);
548
+ node_fs_1.default.writeFileSync(node_path_1.default.join(stageDir, entry.name), converted, 'utf8');
549
+ }
550
+ }
551
+ catch (err) {
552
+ try {
553
+ node_fs_1.default.rmSync(stageDir, { recursive: true, force: true });
554
+ }
555
+ catch { /* best-effort */ }
556
+ throw err;
557
+ }
558
+ STAGED_DIRS.add(stageDir);
559
+ ensureExitCleanup();
560
+ return stageDir;
561
+ }
507
562
  /**
508
563
  * Stage converted command files as flat `.md` files.
509
564
  *
@@ -722,6 +777,7 @@ module.exports = {
722
777
  mostRestrictiveProfile,
723
778
  stageSkillsForProfile,
724
779
  stageAgentsForProfile,
780
+ stageAgentsForRuntimeWithConverter,
725
781
  stageSkillsForRuntimeAsSkills,
726
782
  stageCommandsForRuntimeFlat,
727
783
  STAGED_DIRS,
@@ -38,6 +38,7 @@ exports.BUNDLED_GSD_HOOK_FILES = Object.freeze(new Set([
38
38
  'hooks/gsd-context-monitor.js',
39
39
  'hooks/gsd-cursor-post-tool.js',
40
40
  'hooks/gsd-cursor-session-start.js',
41
+ 'hooks/gsd-ensure-canonical-path.js',
41
42
  'hooks/gsd-graphify-update.sh',
42
43
  'hooks/gsd-phase-boundary.sh',
43
44
  'hooks/gsd-prompt-guard.js',
@@ -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;
@@ -559,16 +560,29 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
559
560
  _dirName = `${prefix}${_newPhaseId}-${slug}`;
560
561
  }
561
562
  else {
562
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
563
- let maxPhase = 0;
563
+ // Collect all phase numbers visible in the current-milestone content.
564
+ // Three sources are scanned so that a phase in ANY representation
565
+ // (section header, roadmap bullet, or on-disk directory) is counted:
566
+ // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
567
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
568
+ // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
569
+ // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
570
+ // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
571
+ // are counted and cannot collide with a freshly-added phase. (#1229)
572
+ const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
573
+ const usedPhaseNums = new Set();
564
574
  let m;
565
- while ((m = phasePattern.exec(content)) !== null) {
575
+ while ((m = headerPattern.exec(content)) !== null) {
566
576
  const num = parseInt(m[1], 10);
567
- if (num === 999)
568
- continue;
569
- if (num > maxPhase)
570
- maxPhase = num;
577
+ if (num !== 999)
578
+ usedPhaseNums.add(num);
579
+ }
580
+ while ((m = bulletPattern.exec(content)) !== null) {
581
+ const num = parseInt(m[1], 10);
582
+ if (num !== 999)
583
+ usedPhaseNums.add(num);
571
584
  }
585
+ // 3) On-disk phase directories (e.g. phases/11-foo/ with no header yet)
572
586
  const phasesOnDisk = node_path_1.default.join(planningDir(cwd), 'phases');
573
587
  if (node_fs_1.default.existsSync(phasesOnDisk)) {
574
588
  const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
@@ -577,13 +591,16 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
577
591
  if (!match)
578
592
  continue;
579
593
  const num = parseInt(match[1], 10);
580
- if (num === 999)
581
- continue;
582
- if (num > maxPhase)
583
- maxPhase = num;
594
+ if (num !== 999)
595
+ usedPhaseNums.add(num);
584
596
  }
585
597
  }
586
- _newPhaseId = maxPhase + 1;
598
+ // phase.add appends after the highest *used* number. Collecting numbers from
599
+ // section headers, roadmap bullets, AND on-disk dirs above is what prevents the
600
+ // #1229 collision (a bullet-only Phase N is now counted), so max+1 cannot reuse
601
+ // an existing number.
602
+ const maxUsed = usedPhaseNums.size > 0 ? Math.max(...usedPhaseNums) : 0;
603
+ _newPhaseId = maxUsed + 1;
587
604
  const paddedNum = String(_newPhaseId).padStart(2, '0');
588
605
  _dirName = `${prefix}${paddedNum}-${slug}`;
589
606
  }
@@ -1019,7 +1036,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1019
1036
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1020
1037
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1021
1038
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
1022
- const today = new Date().toISOString().split('T')[0];
1039
+ const today = clock_cjs_1.realClock.today();
1023
1040
  const phaseInfoRaw = findPhaseInternal(cwd, phaseNum);
1024
1041
  if (!phaseInfoRaw) {
1025
1042
  error(`Phase ${phaseNum} not found`);
@@ -1049,9 +1066,17 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1049
1066
  }
1050
1067
  for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
1051
1068
  const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1052
- if (/status: human_needed/.test(content))
1069
+ // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1070
+ // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
1071
+ // A full-text regex like /status: gaps_found/ matches the substring inside
1072
+ // `previous_status: gaps_found`, producing spurious warnings even when the
1073
+ // current frontmatter status is `passed`.
1074
+ const verFm = extractFrontmatter(content);
1075
+ // Normalise to lower-case so `status: Passed` (title-case) is not missed.
1076
+ const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
1077
+ if (verStatus === 'human_needed')
1053
1078
  warnings.push(`${file}: needs human verification`);
1054
- if (/status: gaps_found/.test(content))
1079
+ if (verStatus === 'gaps_found')
1055
1080
  warnings.push(`${file}: has unresolved gaps`);
1056
1081
  }
1057
1082
  }
@@ -1074,15 +1099,20 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1074
1099
  const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1075
1100
  roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
1076
1101
  const cells = fullRow.split('|').slice(1, -1);
1102
+ const dateShape = /^\d{4}-\d{2}-\d{2}$/;
1077
1103
  if (cells.length === 5) {
1078
1104
  cells[2] = ` ${summaryCount}/${planCount} `;
1079
1105
  cells[3] = ' Complete ';
1080
- cells[4] = ` ${today} `;
1106
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1107
+ const existingDate5 = cells[4].trim();
1108
+ cells[4] = dateShape.test(existingDate5) ? cells[4] : ` ${today} `;
1081
1109
  }
1082
1110
  else if (cells.length === 4) {
1083
1111
  cells[1] = ` ${summaryCount}/${planCount} `;
1084
1112
  cells[2] = ' Complete ';
1085
- cells[3] = ` ${today} `;
1113
+ // Preserve only a valid ISO date (#1161: idempotent; self-heal garbage)
1114
+ const existingDate4 = cells[3].trim();
1115
+ cells[3] = dateShape.test(existingDate4) ? cells[3] : ` ${today} `;
1086
1116
  }
1087
1117
  return '|' + cells.join('|') + '|';
1088
1118
  });
@@ -1123,13 +1153,59 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1123
1153
  reqContent = reqContent.replace(new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'), '$1 Complete $2');
1124
1154
  }
1125
1155
  }
1156
+ // #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
1157
+ // Requirements under headings whose text contains "deferred", "backlog",
1158
+ // "future", or "v2" (case-insensitive) are explicitly out of current scope
1159
+ // and must not be flagged as missing from the Traceability table.
1160
+ //
1161
+ // Strategy: walk lines, track heading depth, and toggle a "deferred" flag
1162
+ // when a heading matching the pattern is encountered. A sub-heading (higher
1163
+ // depth) that is ITSELF in a deferred parent remains deferred unless it
1164
+ // opens a same-or-shallower heading that does NOT match the pattern.
1165
+ // Lines inside fenced code blocks (``` or ~~~) are treated as content, not
1166
+ // headings, to avoid false deferred-section detection from code examples.
1167
+ const DEFERRED_HEADING_RE = /\b(?:deferred|backlog|future|v\d+)\b/i;
1126
1168
  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);
1169
+ // deferredDepth: the heading level that opened the current deferred block,
1170
+ // or 0 when we are in an active section.
1171
+ let deferredDepth = 0;
1172
+ let inFence = false;
1173
+ for (const line of reqContent.split(/\r?\n/)) {
1174
+ // Track fenced code blocks (``` or ~~~).
1175
+ if (/^\s*(?:```|~~~)/.test(line)) {
1176
+ inFence = !inFence;
1177
+ continue;
1178
+ }
1179
+ if (inFence)
1180
+ continue; // ignore content inside a code fence
1181
+ const headingM = line.match(/^(#{1,6})\s+(.*)/);
1182
+ if (headingM) {
1183
+ const depth = headingM[1].length;
1184
+ const text = headingM[2];
1185
+ if (deferredDepth > 0 && depth > deferredDepth) {
1186
+ // Sub-heading inside a deferred block: stays deferred regardless of name.
1187
+ continue;
1188
+ }
1189
+ // Heading at same level or shallower than current deferred opener,
1190
+ // or no active deferred block yet.
1191
+ if (DEFERRED_HEADING_RE.test(text)) {
1192
+ deferredDepth = depth; // enter a deferred block
1193
+ }
1194
+ else {
1195
+ deferredDepth = 0; // back in an active section
1196
+ }
1197
+ continue;
1198
+ }
1199
+ if (deferredDepth > 0)
1200
+ continue; // skip content in deferred sections
1201
+ // Collect bold REQ-ID patterns from active-section lines.
1202
+ const reqPat = /\*\*([A-Z][A-Z0-9]*-\d+)\*\*/g;
1203
+ let bodyMatch;
1204
+ while ((bodyMatch = reqPat.exec(line)) !== null) {
1205
+ const id = bodyMatch[1];
1206
+ if (!bodyReqIds.includes(id))
1207
+ bodyReqIds.push(id);
1208
+ }
1133
1209
  }
1134
1210
  const traceabilityHeadingMatch = reqContent.match(/^#{1,6}\s+Traceability\b/im);
1135
1211
  const traceabilitySection = traceabilityHeadingMatch