@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
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Unified capability-state resolver that composes the three toggle systems
6
6
  * (install profile, runtime surface, config activation) into one per-capability
7
- * view. ADDITIVE — install/surface/workflows are untouched; this resolver is
8
- * consumed by nothing yet (phase-6 wiring is out of scope).
7
+ * view. The loop resolver consumes this state so workflow dispatch and the
8
+ * `gsd-tools capability state` diagnostic share the same enablement answer.
9
9
  *
10
10
  * Exports (three things, mirroring loop-resolver):
11
11
  * resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd })
@@ -19,9 +19,9 @@
19
19
  * cmdCapabilityState is the I/O handler.
20
20
  *
21
21
  * Dependencies (leaf modules only — no core.cjs circular risk):
22
- * - node:path (used by _resolveActivationValue via loop-resolver)
22
+ * - node:path
23
23
  * - ./core.cjs (output, error)
24
- * - ./loop-resolver.cjs (_resolveActivationValue — reuse the export)
24
+ * - ./capability-activation.cjs (_resolveActivationValue)
25
25
  * - ./install-profiles.cjs (readActiveProfile, loadSkillsManifest, resolveProfile)
26
26
  * - ./surface.cjs (resolveSurface)
27
27
  * - ./config-loader.cjs (loadConfig)
@@ -36,8 +36,8 @@ const node_path_1 = __importDefault(require("node:path"));
36
36
  const core = require("./core.cjs");
37
37
  const { output: coreOutput, error: coreError } = core;
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
- const loopResolverMod = require("./loop-resolver.cjs");
40
- const { _resolveActivationValue } = loopResolverMod;
39
+ const activationMod = require("./capability-activation.cjs");
40
+ const { _resolveActivationValue } = activationMod;
41
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
42
  const configLoaderMod = require("./config-loader.cjs");
43
43
  const { loadConfig } = configLoaderMod;
@@ -133,6 +133,7 @@ function resolveCapabilityState(input) {
133
133
  else {
134
134
  surfaced = skills.every((s) => surfacedSkills.has(s));
135
135
  }
136
+ const enabled = installed && surfaced;
136
137
  // ── hooks ──────────────────────────────────────────────────────────────────
137
138
  // Collect from steps, gates, contributions. Each may have a `when` key.
138
139
  // Activation semantics (mirrors loop-resolver.isActive exactly):
@@ -149,21 +150,21 @@ function resolveCapabilityState(input) {
149
150
  const point = typeof h['point'] === 'string' ? h['point'] : '';
150
151
  // Carry the raw `when` value through for visibility
151
152
  const whenRaw = h['when'];
152
- let active;
153
+ let configured;
153
154
  if (whenRaw === undefined || whenRaw === null) {
154
155
  // No `when` field → unconditional, always active
155
- active = true;
156
+ configured = true;
156
157
  }
157
158
  else if (typeof whenRaw === 'string' && whenRaw.length > 0) {
158
159
  // Non-empty string `when` → resolve via _resolveActivationValue
159
- active = _resolveActivationValue(whenRaw, config, cwd, registry);
160
+ configured = _resolveActivationValue(whenRaw, config, cwd, registry);
160
161
  }
161
162
  else {
162
163
  // Present-but-empty-string or non-string `when` → malformed, inactive
163
164
  // (mirrors loop-resolver.isActive: `typeof when !== 'string' || when.length === 0` → false)
164
- active = false;
165
+ configured = false;
165
166
  }
166
- hooks.push({ point, kind, when: whenRaw, active });
167
+ hooks.push({ point, kind, when: whenRaw, configured, active: enabled && configured });
167
168
  }
168
169
  }
169
170
  const stepsRaw = capObj['steps'];
@@ -172,7 +173,7 @@ function resolveCapabilityState(input) {
172
173
  processHooks(Array.isArray(stepsRaw) ? stepsRaw : [], 'step');
173
174
  processHooks(Array.isArray(gatesRaw) ? gatesRaw : [], 'gate');
174
175
  processHooks(Array.isArray(contributionsRaw) ? contributionsRaw : [], 'contribution');
175
- results.push({ id: capId, tier, skills, installed, surfaced, hooks });
176
+ results.push({ id: capId, tier, skills, installed, surfaced, enabled, hooks });
176
177
  }
177
178
  // Deterministic sort by id for stable output across calls
178
179
  results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
@@ -222,7 +223,7 @@ function _resolveCommandsGsdDir() {
222
223
  * @param raw Whether to emit raw JSON (core.output raw mode)
223
224
  * @param _options Reserved for future use
224
225
  */
225
- function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
226
+ function resolveCapabilityRuntimeState(cwd, runtimeConfigDir) {
226
227
  const warnings = [];
227
228
  // Resolve runtimeConfigDir using the canonical runtime-homes resolver.
228
229
  // When not provided, getGlobalConfigDir(runtime) is called with 'claude'
@@ -277,7 +278,6 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
277
278
  // Genuine resolution failure — surface it so the caller is not misled.
278
279
  const msg = err instanceof Error ? err.message : String(err);
279
280
  warnings.push(`profile-resolution failed: ${msg}`);
280
- coreError(`capability state: profile resolution failed: ${msg}`);
281
281
  // Degrade to empty set (not '*') so installed=false is reported accurately.
282
282
  installedSkills = new Set();
283
283
  }
@@ -297,7 +297,6 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
297
297
  // Genuine surface resolution failure — surface it so the caller is not misled.
298
298
  const msg = err instanceof Error ? err.message : String(err);
299
299
  warnings.push(`surface-resolution failed: ${msg}`);
300
- coreError(`capability state: surface resolution failed: ${msg}`);
301
300
  surfacedSkills = new Set();
302
301
  }
303
302
  // ── Load config ───────────────────────────────────────────────────────────────
@@ -316,19 +315,33 @@ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
316
315
  config,
317
316
  cwd,
318
317
  });
318
+ return {
319
+ runtimeConfigDir: resolvedConfigDir,
320
+ warnings,
321
+ registry,
322
+ config,
323
+ capabilities: result.capabilities,
324
+ };
325
+ }
326
+ function cmdCapabilityState(cwd, runtimeConfigDir, raw, _options = {}) {
327
+ const result = resolveCapabilityRuntimeState(cwd, runtimeConfigDir);
328
+ for (const warning of result.warnings) {
329
+ coreError(`capability state: ${warning}`);
330
+ }
319
331
  // Build envelope — include warnings array only when non-empty so the nominal
320
332
  // path keeps the output clean and callers can check `warnings` for degraded state.
321
333
  const envelope = {
322
- runtimeConfigDir: resolvedConfigDir,
334
+ runtimeConfigDir: result.runtimeConfigDir,
323
335
  capabilities: result.capabilities,
324
336
  };
325
- if (warnings.length > 0) {
326
- envelope.warnings = warnings;
337
+ if (result.warnings.length > 0) {
338
+ envelope.warnings = result.warnings;
327
339
  }
328
340
  coreOutput(envelope, raw);
329
341
  }
330
342
  module.exports = {
331
343
  resolveCapabilityState,
344
+ resolveCapabilityRuntimeState,
332
345
  cmdCapabilityState,
333
346
  // Exported for tests
334
347
  _resolveCommandsGsdDir,
@@ -18,8 +18,14 @@ const { output, error, ERROR_REASON } = core;
18
18
  const decisions_cjs_1 = require("./decisions.cjs");
19
19
  const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
20
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
+ const verifyModule = require("./verify.cjs");
22
+ const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule;
23
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
21
24
  const roadmapModule = require("./roadmap.cjs");
22
25
  const { getRoadmapPhaseWithFallback } = roadmapModule;
26
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
27
+ const gapCheckerModule = require("./gap-checker.cjs");
28
+ const { runGapAnalysis } = gapCheckerModule;
23
29
  // ─── Helpers ──────────────────────────────────────────────────────────────────
24
30
  function normalizePhrase(text) {
25
31
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -439,6 +445,297 @@ function cmdUiPlanGate(projectDir, args, raw) {
439
445
  }
440
446
  output(computeUiPlanGate(projectDir, phase), raw, undefined);
441
447
  }
448
+ // ─── ui-safety-gate ───────────────────────────────────────────────────────────
449
+ /**
450
+ * ui-safety-gate: post-wave check that verifies UI-changed files conform to
451
+ * the active UI-SPEC for the phase. Called after each wave by execute:wave:post.
452
+ *
453
+ * Returns JSON: { frontend: boolean, hasUiFiles: boolean, hasUiSpec: boolean, block: boolean, message?: string }
454
+ * block = frontend && hasUiFiles && !hasUiSpec
455
+ *
456
+ * Args: check ui-safety-gate <phase>
457
+ * Invocable as: gsd_run check ui-safety-gate <phase>
458
+ * or gsd_run check ui.safety-gate <phase> (dots normalized to hyphens)
459
+ *
460
+ * Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
461
+ * Checks whether any files changed in recent git history match frontend file patterns.
462
+ * Also checks whether a *-UI-SPEC.md exists in the phase directory (same as ui-plan-gate).
463
+ *
464
+ * Limitation: uses git diff HEAD~1..HEAD which covers only the last commit; in a
465
+ * multi-plan wave the wave-start commit would be more accurate but is not yet stored
466
+ * in the wave manifest. This is tracked as a known limitation.
467
+ */
468
+ const UI_FILE_EXTENSIONS_RE = /\.(tsx|jsx|css|scss|sass|less|vue|svelte|html)$/i;
469
+ const UI_PATH_PATTERNS_RE = /\/(components|pages|views|screens|layouts|ui|frontend)\//i;
470
+ /**
471
+ * Pure logic for ui-safety-gate — exposed for direct behavioral testing.
472
+ *
473
+ * Given a projectDir and phase number:
474
+ * (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
475
+ * same lookup as computeUiPlanGate — to determine if this is a frontend phase.
476
+ * (b) Runs checkUiPresence (frontend detection) — no reimplementation.
477
+ * (c) Checks git diff HEAD~1..HEAD for UI file changes in the current worktree.
478
+ * (d) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
479
+ *
480
+ * Returns: { frontend, hasUiFiles, hasUiSpec, block, message?, phaseLookupFailed? }
481
+ * block = frontend && hasUiFiles && !hasUiSpec
482
+ * phaseLookupFailed = ROADMAP.md present but phase header not found
483
+ */
484
+ function computeUiSafetyGate(projectDir, phase) {
485
+ // (a) Read the phase section text (same two-pass lookup as computeUiPlanGate)
486
+ let phaseSection = '';
487
+ let phaseLookupFailed;
488
+ try {
489
+ const section = getRoadmapPhaseWithFallback(projectDir, phase);
490
+ if (section === null) {
491
+ const planDir = typeof core['planningDir'] === 'function'
492
+ ? core['planningDir'](projectDir)
493
+ : node_path_1.default.join(projectDir, '.planning');
494
+ const roadmapPath = node_path_1.default.join(planDir, 'ROADMAP.md');
495
+ if (node_fs_1.default.existsSync(roadmapPath)) {
496
+ phaseLookupFailed = true;
497
+ }
498
+ }
499
+ else {
500
+ phaseSection = section;
501
+ }
502
+ }
503
+ catch { /* roadmap read failure → treat as empty (non-frontend) */ }
504
+ // (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
505
+ const presenceResult = (0, ui_safety_gate_cjs_1.checkUiPresence)(phaseSection);
506
+ const frontend = presenceResult.hasUI;
507
+ // (c) Check whether any UI files were changed in recent git commits
508
+ // Uses git diff HEAD~1..HEAD to detect frontend file changes since last commit.
509
+ // Known limitation: multi-plan waves may need the wave-start commit for full coverage.
510
+ let hasUiFiles = false;
511
+ try {
512
+ const changed = (0, node_child_process_1.execFileSync)('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], {
513
+ cwd: projectDir,
514
+ encoding: 'utf-8',
515
+ maxBuffer: 2 * 1024 * 1024,
516
+ windowsHide: true,
517
+ });
518
+ hasUiFiles = changed.split('\n').some((f) => f.trim() && (UI_FILE_EXTENSIONS_RE.test(f) || UI_PATH_PATTERNS_RE.test(f)));
519
+ }
520
+ catch { /* git unavailable or no prior commit — treat as no UI files changed */ }
521
+ // (d) Resolve phase directory and check for *-UI-SPEC.md (same as computeUiPlanGate)
522
+ const coreModule = core;
523
+ let phaseDir = '';
524
+ try {
525
+ const findPhase = coreModule['findPhaseInternal'];
526
+ if (typeof findPhase === 'function') {
527
+ const result = findPhase(projectDir, phase);
528
+ if (result && typeof result === 'object') {
529
+ const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
530
+ if (relDir) {
531
+ phaseDir = node_path_1.default.resolve(projectDir, relDir);
532
+ }
533
+ }
534
+ else if (typeof result === 'string') {
535
+ phaseDir = result;
536
+ }
537
+ }
538
+ }
539
+ catch { /* phase dir lookup failure → hasUiSpec=false */ }
540
+ const uiSpecPath = findUiSpecInDir(phaseDir);
541
+ const hasUiSpec = uiSpecPath !== '';
542
+ // block only when: this is a frontend phase AND UI files were changed AND no UI-SPEC exists
543
+ const block = frontend && hasUiFiles && !hasUiSpec;
544
+ const result = { frontend, hasUiFiles, hasUiSpec, block };
545
+ if (block) {
546
+ result.message = `UI files changed in this wave but no UI-SPEC.md exists for Phase ${phase}. ` +
547
+ `Run /gsd:ui-phase ${phase} to generate the design contract before continuing.`;
548
+ }
549
+ if (phaseLookupFailed)
550
+ result.phaseLookupFailed = true;
551
+ return result;
552
+ }
553
+ function cmdUiSafetyGate(projectDir, args, raw) {
554
+ // args[0] = 'check', args[1] = 'ui-safety-gate', args[2] = phase
555
+ const phase = args[2] || '';
556
+ if (!phase) {
557
+ error('ui-safety-gate requires a phase argument: check ui-safety-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
558
+ return;
559
+ }
560
+ output(computeUiSafetyGate(projectDir, phase), raw, undefined);
561
+ }
562
+ function cmdTddReviewCheckpoint(projectDir, args, raw) {
563
+ // args[0] = 'check', args[1] = 'tdd-review-checkpoint' (normalized), args[2] = phase
564
+ const phase = args[2] || '';
565
+ if (!phase) {
566
+ error('tdd.review-checkpoint requires a phase argument: check tdd.review-checkpoint <phase>', ERROR_REASON.SDK_MISSING_ARG);
567
+ return;
568
+ }
569
+ // Resolve phase directory
570
+ const coreModule = core;
571
+ let phaseDir = '';
572
+ try {
573
+ const findPhase = coreModule['findPhaseInternal'];
574
+ if (typeof findPhase === 'function') {
575
+ const result = findPhase(projectDir, phase);
576
+ if (result && typeof result === 'object') {
577
+ const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
578
+ if (relDir)
579
+ phaseDir = node_path_1.default.resolve(projectDir, relDir);
580
+ }
581
+ else if (typeof result === 'string') {
582
+ phaseDir = result;
583
+ }
584
+ }
585
+ }
586
+ catch { /* phase dir lookup failure */ }
587
+ // Find all PLAN.md files with type: tdd in frontmatter
588
+ const tddPlanFiles = [];
589
+ if (phaseDir) {
590
+ try {
591
+ const files = node_fs_1.default.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
592
+ for (const file of files) {
593
+ const planPath = node_path_1.default.join(phaseDir, file);
594
+ const content = readIfExists(planPath);
595
+ // Check frontmatter for type: tdd
596
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
597
+ if (frontmatterMatch) {
598
+ const fm = frontmatterMatch[1];
599
+ if (/^type:\s*tdd\s*$/m.test(fm)) {
600
+ tddPlanFiles.push(planPath);
601
+ }
602
+ }
603
+ }
604
+ }
605
+ catch { /* directory read failure */ }
606
+ }
607
+ if (tddPlanFiles.length === 0) {
608
+ const result = {
609
+ // Uniform gate contract: block = violations > 0 (advisory; never truly blocks).
610
+ block: false,
611
+ passed: true,
612
+ tddPlans: 0,
613
+ violations: 0,
614
+ table: '',
615
+ rows: [],
616
+ message: `No type:tdd plans found in phase ${phase}. TDD review skipped.`,
617
+ };
618
+ // Pass undefined as rawValue so --raw emits JSON (not plain text).
619
+ // The human-readable report is carried in `result.message` for the
620
+ // dispatch's advisory branch to surface.
621
+ output(result, raw, undefined);
622
+ return;
623
+ }
624
+ // For each TDD plan, extract the plan ID (padded plan number) and check git log
625
+ const rows = [];
626
+ for (const planPath of tddPlanFiles) {
627
+ // Extract plan ID from filename (e.g. "01-02-PLAN.md" → "01-02", or "03-PLAN.md" → "03")
628
+ const basename = node_path_1.default.basename(planPath, '-PLAN.md');
629
+ // planId for commit grep: phase-plan format, e.g. "01-02"
630
+ const planId = basename;
631
+ // Check for RED gate commit: test({planId}):
632
+ let red = false;
633
+ let green = false;
634
+ let refactor = false;
635
+ try {
636
+ const redCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
637
+ red = redCommit.trim().length > 0;
638
+ }
639
+ catch { /* git unavailable or no match */ }
640
+ try {
641
+ const greenCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
642
+ green = greenCommit.trim().length > 0;
643
+ }
644
+ catch { /* git unavailable or no match */ }
645
+ try {
646
+ const refactorCommit = (0, node_child_process_1.execFileSync)('git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'], { cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true });
647
+ refactor = refactorCommit.trim().length > 0;
648
+ }
649
+ catch { /* git unavailable or no match */ }
650
+ const missing = [];
651
+ if (!red)
652
+ missing.push('RED');
653
+ if (!green)
654
+ missing.push('GREEN');
655
+ const status = missing.length === 0 ? 'Pass' : 'FAIL';
656
+ rows.push({ planId, red, green, refactor, status, missing });
657
+ }
658
+ const violations = rows.filter(r => r.status === 'FAIL').length;
659
+ // Build review table
660
+ const sep = '━'.repeat(53);
661
+ const tableHeader = '| Plan | RED | GREEN | REFACTOR | Status |';
662
+ const tableDivider = '|------|-----|-------|----------|--------|';
663
+ const tableRows = rows.map(r => `| ${r.planId.padEnd(4)} | ${r.red ? ' ✓ ' : ' ✗ '} | ${r.green ? ' ✓ ' : ' ✗ '} | ${r.refactor ? ' ✓ ' : ' — '} | ${r.status.padEnd(6)} |`);
664
+ let table = [
665
+ sep,
666
+ ` TDD REVIEW — Phase ${phase}`,
667
+ sep,
668
+ '',
669
+ `TDD Plans: ${tddPlanFiles.length} | Gate violations: ${violations}`,
670
+ '',
671
+ tableHeader,
672
+ tableDivider,
673
+ ...tableRows,
674
+ ].join('\n');
675
+ if (violations > 0) {
676
+ table += '\n\n⚠ Gate violations are advisory — review before advancing.';
677
+ for (const r of rows.filter(row => row.status === 'FAIL')) {
678
+ table += `\n Plan ${r.planId} missing: ${r.missing.join(', ')} gate commit(s).`;
679
+ table += `\n Expected commit pattern: test(${r.planId}): ... → feat(${r.planId}): ...`;
680
+ }
681
+ }
682
+ const result = {
683
+ // Uniform gate contract: block = violations > 0.
684
+ // This gate is advisory (blocking: false in capability.json) so block:true
685
+ // only surfaces as a warning, never halts. Kept here so the host-loop
686
+ // dispatch can read a single consistent `block` field.
687
+ block: violations > 0,
688
+ passed: true,
689
+ tddPlans: tddPlanFiles.length,
690
+ violations,
691
+ table,
692
+ rows,
693
+ // Human-readable report in `message` so the dispatch's advisory branch
694
+ // can surface it. --raw emits JSON (rawValue=undefined), not plain text.
695
+ message: table,
696
+ };
697
+ // Pass undefined as rawValue so --raw emits JSON (not the raw table text).
698
+ // The review table is carried in `result.message` and `result.table` so
699
+ // the host-loop dispatch's advisory branch can surface it.
700
+ output(result, raw, undefined);
701
+ }
702
+ // ─── gap-analysis-plan-post ───────────────────────────────────────────────────
703
+ /**
704
+ * gap-analysis-plan-post: non-blocking advisory check that runs the post-planning
705
+ * gap analysis after all PLAN.md files are generated for a phase.
706
+ *
707
+ * Cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md
708
+ * against the concatenated text of all *-PLAN.md files, emitting a coverage table.
709
+ *
710
+ * This gate is always advisory (passed: true) — it never blocks phase advancement.
711
+ *
712
+ * Args: check gap-analysis.plan-post <phase-dir> [phase-req-ids]
713
+ * Invocable as: gsd_run check gap-analysis.plan-post <phase-dir> [phase-req-ids]
714
+ */
715
+ function cmdGapAnalysisPlanPost(projectDir, args, raw) {
716
+ // args[0] = 'check', args[1] = 'gap-analysis-plan-post' (normalized), args[2] = phaseDir, args[3] = phaseReqIds
717
+ const phaseDir = args[2] || '';
718
+ if (!phaseDir) {
719
+ error('gap-analysis.plan-post requires a phase-dir argument: check gap-analysis.plan-post <phase-dir> [phase-req-ids]', ERROR_REASON.SDK_MISSING_ARG);
720
+ return;
721
+ }
722
+ const phaseReqIds = args[3] ?? undefined;
723
+ const result = runGapAnalysis(projectDir, phaseDir, { phaseReqIds });
724
+ // Uniform gate contract: block = false (gap-analysis is always advisory, never blocks).
725
+ // `message` carries the human-readable gap analysis report so the dispatch's
726
+ // advisory branch can surface it. --raw emits JSON (rawValue=undefined), not
727
+ // plain markdown text.
728
+ output({
729
+ block: false,
730
+ passed: true,
731
+ enabled: result.enabled,
732
+ table: result.table,
733
+ summary: result.summary,
734
+ counts: result.counts,
735
+ // Human-readable report in `message` for the host-loop advisory branch.
736
+ message: result.table || result.summary || '',
737
+ }, raw, undefined);
738
+ }
442
739
  function routeCheckCommand({ args, cwd, raw }) {
443
740
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
444
741
  // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
@@ -464,11 +761,41 @@ function routeCheckCommand({ args, cwd, raw }) {
464
761
  cmdUiPlanGate(cwd, args, raw);
465
762
  return;
466
763
  }
467
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
764
+ if (subcommand === 'gap-analysis-plan-post') {
765
+ cmdGapAnalysisPlanPost(cwd, args, raw);
766
+ return;
767
+ }
768
+ if (subcommand === 'tdd-review-checkpoint') {
769
+ cmdTddReviewCheckpoint(cwd, args, raw);
770
+ return;
771
+ }
772
+ if (subcommand === 'ui-safety-gate') {
773
+ cmdUiSafetyGate(cwd, args, raw);
774
+ return;
775
+ }
776
+ if (subcommand === 'verify-schema-drift') {
777
+ // Delegates to verify.schema-drift — drift capability gate at execute:wave:post (blocking).
778
+ // Dot-to-hyphen normalization means query "verify.schema-drift" routes here.
779
+ // Honor GSD_SKIP_SCHEMA_CHECK=true to bypass the gate (preserves the original inline gate behavior).
780
+ const phaseArg = typeof args[2] === 'string' ? args[2] : '';
781
+ const skipSchemaCheck = process.env['GSD_SKIP_SCHEMA_CHECK'] === 'true';
782
+ cmdVerifySchemaDrift(cwd, phaseArg, skipSchemaCheck, raw);
783
+ return;
784
+ }
785
+ if (subcommand === 'verify-codebase-drift') {
786
+ // Delegates to verify.codebase-drift — drift capability gate at execute:wave:post (non-blocking).
787
+ // Dot-to-hyphen normalization means query "verify.codebase-drift" routes here.
788
+ cmdVerifyCodebaseDrift(cwd, raw);
789
+ return;
790
+ }
791
+ error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
468
792
  }
469
793
  module.exports = {
470
794
  routeCheckCommand,
471
795
  decisionMentioned,
472
796
  extractPlanDesignatedSections,
473
797
  computeUiPlanGate,
798
+ computeUiSafetyGate,
799
+ cmdGapAnalysisPlanPost,
800
+ cmdTddReviewCheckpoint,
474
801
  };
@@ -112,6 +112,8 @@ exports.CLUSTERS = Object.freeze({
112
112
  'review-backlog',
113
113
  'debug',
114
114
  'extract-learnings',
115
+ 'mempalace-recall',
116
+ 'mempalace-capture',
115
117
  'surface',
116
118
  ]),
117
119
  });
@@ -0,0 +1,19 @@
1
+ 'use strict';
2
+ /**
3
+ * Command Roster Module
4
+ *
5
+ * Read-only helper for discovering canonical commands/gsd command stems and
6
+ * applying the shared GSD slash-command namespace transform.
7
+ */
8
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
9
+ const slashCommandTransformer = require('../../../scripts/fix-slash-commands.cjs');
10
+ function readGsdCommandNames() {
11
+ return slashCommandTransformer.readCmdNames();
12
+ }
13
+ module.exports = {
14
+ readGsdCommandNames,
15
+ transformContentToHyphen: slashCommandTransformer.transformContentToHyphen,
16
+ transformContent: slashCommandTransformer.transformContent,
17
+ buildPattern: slashCommandTransformer.buildPattern,
18
+ buildColonPattern: slashCommandTransformer.buildColonPattern,
19
+ };
@@ -44,11 +44,19 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
44
44
  const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
45
45
  if (verificationFile) {
46
46
  const content = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, verificationFile)) || '';
47
- if (/status:\s*passed/i.test(content))
47
+ // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false
48
+ // matches from historical body metadata such as `previous_status: gaps_found`.
49
+ // Full-text regexes like /status:\s*gaps_found/ match the substring inside
50
+ // `previous_status: gaps_found`, producing incorrect phase status labels.
51
+ const fm = extractFrontmatter(content);
52
+ // Normalise to lower-case to preserve the prior case-insensitive behaviour
53
+ // while reading only the frontmatter `status` key (not the full body text).
54
+ const fmStatus = typeof fm['status'] === 'string' ? fm['status'].trim().toLowerCase() : '';
55
+ if (fmStatus === 'passed')
48
56
  return 'Complete';
49
- if (/status:\s*human_needed/i.test(content))
57
+ if (fmStatus === 'human_needed')
50
58
  return 'Needs Review';
51
- if (/status:\s*gaps_found/i.test(content))
59
+ if (fmStatus === 'gaps_found')
52
60
  return 'Executed';
53
61
  // Verification exists but unrecognized status — treat as executed
54
62
  return 'Executed';
@@ -505,13 +513,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
505
513
  /**
506
514
  * Route a list of changed files to their sub-repo prefixes.
507
515
  *
508
- * Bucket sub-repos by their first path segment. Any file that matches a
516
+ * Bucket sub-repos by their first path segment (#311). Any file that matches a
509
517
  * sub-repo prefix must share that sub-repo's first segment, so we only scan
510
- * the (small) bucket for the file's first segment instead of all sub-repos
511
- * — O(F + R) expected vs the prior O(F*R) find-in-loop. Candidates stay in
512
- * sub-repo array order, preserving the original first-match semantics
513
- * (incl. multi-segment sub-repos like "vendor/pkg", which resolve via the
514
- * inner startsWith). (#311)
518
+ * the (small) same-first-segment bucket instead of all sub-repos. Within that
519
+ * bucket all candidates are scanned to find the longest (most-specific)
520
+ * matching prefix, so nested sub_repos (e.g. ['packages', 'packages/core'])
521
+ * route to the deepest match regardless of sub_repos array order (#391).
515
522
  *
516
523
  * @param files - changed file paths (relative to project root)
517
524
  * @param subRepos - sub-repo path prefixes from config.sub_repos
@@ -531,7 +538,23 @@ function groupFilesBySubrepo(files, subRepos) {
531
538
  const unmatched = [];
532
539
  for (const file of files) {
533
540
  const candidates = reposByFirstSeg.get(file.split('/')[0]);
534
- const match = candidates ? candidates.find(repo => file.startsWith(repo + '/')) : undefined;
541
+ // Select the longest (most-specific) matching sub-repo prefix so nested
542
+ // sub_repos (e.g. ['packages', 'packages/core']) route correctly regardless
543
+ // of array order. (#391) String() guards the length read so non-string
544
+ // entries never throw, matching the tolerance of the prior `.find` path.
545
+ let match;
546
+ let matchLen = -1;
547
+ if (candidates) {
548
+ for (const repo of candidates) {
549
+ if (file.startsWith(repo + '/')) {
550
+ const repoLen = String(repo).length;
551
+ if (repoLen > matchLen) {
552
+ match = repo;
553
+ matchLen = repoLen;
554
+ }
555
+ }
556
+ }
557
+ }
535
558
  if (match) {
536
559
  (grouped[match] ||= []).push(file);
537
560
  }
@@ -38,7 +38,7 @@ const { detectSubRepos } = coreUtilsModule;
38
38
  const configuration_cjs_1 = require("./configuration.cjs");
39
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
40
  const configSchema = require("./config-schema.cjs");
41
- const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isValidConfigKey: _isValidConfigKeyFn } = configSchema;
41
+ const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS, isCentralConfigKey: _isCentralConfigKeyFn } = configSchema;
42
42
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
43
43
  // ─── Federated Config (ADR-857 phase 3b) ─────────────────────────────────────
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -331,7 +331,7 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
331
331
  return baseConfig;
332
332
  const _fedOverlay = mergeFederatedConfig({
333
333
  configSchema: _fedRegistrySchema,
334
- isCentralKey: (key) => _isValidConfigKeyFn(key),
334
+ isCentralKey: (key) => _isCentralConfigKeyFn(key),
335
335
  userConfig,
336
336
  });
337
337
  // True no-op: if no federated keys, return UNCHANGED (byte-identical, no clone)
@@ -472,7 +472,7 @@ function loadConfig(cwd, options = {}) {
472
472
  // Internal keys loadConfig reads but config-set doesn't expose
473
473
  'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
474
474
  // Deprecated keys (still accepted for migration, not in config-set)
475
- 'depth', 'multiRepo', 'branching_strategy',
475
+ 'depth', 'multiRepo', 'branching_strategy', 'research',
476
476
  ]);
477
477
  // FIX 3: Compute federated overlay BEFORE the unknown-key warning, so that
478
478
  // federated top-level keys are added to KNOWN_TOP_LEVEL before the check runs.
@@ -483,7 +483,7 @@ function loadConfig(cwd, options = {}) {
483
483
  if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
484
484
  const _earlyOverlay = mergeFederatedConfig({
485
485
  configSchema: _fedRegistrySchemaEarly,
486
- isCentralKey: (key) => _isValidConfigKeyFn(key),
486
+ isCentralKey: (key) => _isCentralConfigKeyFn(key),
487
487
  userConfig: parsed,
488
488
  });
489
489
  _preWarningFedValidKeys = _earlyOverlay.validKeys;
@@ -554,7 +554,6 @@ function loadConfig(cwd, options = {}) {
554
554
  brave_search: get('brave_search') ?? defaults.brave_search,
555
555
  firecrawl: get('firecrawl') ?? defaults.firecrawl,
556
556
  exa_search: get('exa_search') ?? defaults.exa_search,
557
- tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
558
557
  mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
559
558
  text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
560
559
  auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
@@ -608,7 +607,7 @@ function loadConfig(cwd, options = {}) {
608
607
  if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
609
608
  const _fedOverlay = mergeFederatedConfig({
610
609
  configSchema: _fedRegistrySchema,
611
- isCentralKey: (key) => _isValidConfigKeyFn(key),
610
+ isCentralKey: (key) => _isCentralConfigKeyFn(key),
612
611
  userConfig: parsed,
613
612
  });
614
613
  // Apply dotted-path values (e.g. "workflow.ui_phase" → _baseConfig.workflow.ui_phase)
@@ -634,8 +633,8 @@ function loadConfig(cwd, options = {}) {
634
633
  return loadConfig(cwd, { workstream: null });
635
634
  }
636
635
  // FIX 2: Apply the federated overlay on the no-config path.
637
- // With the current registry (all keys central), _applyFederatedOverlay returns
638
- // `defaults` UNCHANGED (true no-op, preserves byte-identical output).
636
+ // Migrated Capability keys are surfaced from the generated registry even
637
+ // when the project has no config.json, so schema defaults still apply.
639
638
  try {
640
639
  return _applyFederatedOverlay(defaults, {});
641
640
  }