@opengsd/gsd-core 1.2.0 → 1.3.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja-JP.md +6 -5
  2. package/README.ko-KR.md +6 -5
  3. package/README.md +10 -45
  4. package/README.pt-BR.md +5 -4
  5. package/README.zh-CN.md +6 -5
  6. package/agents/gsd-ai-researcher.md +1 -1
  7. package/agents/gsd-debug-session-manager.md +1 -1
  8. package/agents/gsd-doc-writer.md +7 -6
  9. package/agents/gsd-domain-researcher.md +1 -1
  10. package/agents/gsd-eval-planner.md +1 -1
  11. package/agents/gsd-phase-researcher.md +1 -1
  12. package/agents/gsd-ui-researcher.md +1 -1
  13. package/agents/gsd-verifier.md +1 -1
  14. package/assets/gsd-logo-2000-transparent.png +0 -0
  15. package/assets/gsd-logo-2000-transparent.svg +17 -0
  16. package/assets/gsd-logo-2000.png +0 -0
  17. package/assets/gsd-logo-2000.svg +21 -0
  18. package/assets/terminal.svg +68 -0
  19. package/bin/install.js +86 -44
  20. package/commands/gsd/review.md +2 -1
  21. package/get-shit-done/bin/gsd-tools.cjs +37 -1
  22. package/get-shit-done/bin/lib/command-aliases.cjs +16 -0
  23. package/get-shit-done/bin/lib/commands.cjs +109 -5
  24. package/get-shit-done/bin/lib/config-types.cjs +19 -0
  25. package/get-shit-done/bin/lib/core.cjs +455 -63
  26. package/get-shit-done/bin/lib/gap-checker.cjs +56 -7
  27. package/get-shit-done/bin/lib/installer-migration-report.cjs +1 -0
  28. package/get-shit-done/bin/lib/milestone.cjs +59 -1
  29. package/get-shit-done/bin/lib/model-catalog.cjs +17 -0
  30. package/get-shit-done/bin/lib/phase-lifecycle.cjs +0 -1
  31. package/get-shit-done/bin/lib/review-reviewer-selection.cjs +1 -0
  32. package/get-shit-done/bin/lib/roadmap-command-router.cjs +134 -0
  33. package/get-shit-done/bin/lib/roadmap-upgrade.cjs +569 -0
  34. package/get-shit-done/bin/lib/roadmap.cjs +6 -5
  35. package/get-shit-done/bin/lib/semver-compare.cjs +34 -27
  36. package/get-shit-done/bin/lib/shell-command-projection.cjs +35 -0
  37. package/get-shit-done/bin/lib/state-document.cjs +0 -4
  38. package/get-shit-done/bin/lib/state.cjs +41 -6
  39. package/get-shit-done/bin/lib/ui-safety-gate.cjs +111 -0
  40. package/get-shit-done/bin/lib/validate.cjs +43 -17
  41. package/get-shit-done/bin/lib/verify.cjs +107 -4
  42. package/get-shit-done/bin/shared/config-defaults.manifest.json +1 -0
  43. package/get-shit-done/bin/shared/config-schema.manifest.json +12 -1
  44. package/get-shit-done/bin/shared/model-catalog.json +27 -0
  45. package/get-shit-done/references/checkpoints.md +1 -1
  46. package/get-shit-done/references/planner-human-verify-mode.md +1 -1
  47. package/get-shit-done/references/ui-brand.md +4 -2
  48. package/get-shit-done/workflows/audit-fix.md +1 -1
  49. package/get-shit-done/workflows/audit-milestone.md +2 -0
  50. package/get-shit-done/workflows/autonomous.md +7 -5
  51. package/get-shit-done/workflows/cleanup.md +43 -3
  52. package/get-shit-done/workflows/code-review-fix.md +3 -3
  53. package/get-shit-done/workflows/code-review.md +2 -0
  54. package/get-shit-done/workflows/debug.md +6 -1
  55. package/get-shit-done/workflows/diagnose-issues.md +2 -0
  56. package/get-shit-done/workflows/discuss-phase/modes/advisor.md +1 -1
  57. package/get-shit-done/workflows/discuss-phase-assumptions.md +2 -2
  58. package/get-shit-done/workflows/docs-update.md +18 -4
  59. package/get-shit-done/workflows/eval-review.md +1 -1
  60. package/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  61. package/get-shit-done/workflows/execute-phase.md +21 -11
  62. package/get-shit-done/workflows/execute-plan.md +1 -1
  63. package/get-shit-done/workflows/explore.md +2 -0
  64. package/get-shit-done/workflows/help/modes/default.md +3 -3
  65. package/get-shit-done/workflows/help/modes/full.md +5 -5
  66. package/get-shit-done/workflows/import.md +2 -0
  67. package/get-shit-done/workflows/ingest-docs.md +2 -2
  68. package/get-shit-done/workflows/manager.md +2 -2
  69. package/get-shit-done/workflows/map-codebase.md +2 -0
  70. package/get-shit-done/workflows/new-milestone.md +2 -2
  71. package/get-shit-done/workflows/new-project.md +2 -2
  72. package/get-shit-done/workflows/plan-phase.md +16 -15
  73. package/get-shit-done/workflows/plan-review-convergence.md +3 -3
  74. package/get-shit-done/workflows/quick.md +5 -3
  75. package/get-shit-done/workflows/review.md +109 -1
  76. package/get-shit-done/workflows/scan.md +2 -0
  77. package/get-shit-done/workflows/secure-phase.md +2 -0
  78. package/get-shit-done/workflows/settings-advanced.md +195 -5
  79. package/get-shit-done/workflows/settings.md +15 -1
  80. package/get-shit-done/workflows/ui-phase.md +2 -2
  81. package/get-shit-done/workflows/ui-review.md +1 -1
  82. package/get-shit-done/workflows/validate-phase.md +2 -0
  83. package/get-shit-done/workflows/verify-work.md +2 -2
  84. package/hooks/dist/gsd-worktree-path-guard.js +169 -0
  85. package/hooks/gsd-worktree-path-guard.js +169 -0
  86. package/hooks/managed-hooks-registry.cjs +1 -0
  87. package/package.json +9 -6
  88. package/scripts/build-hooks.js +1 -0
  89. package/scripts/ci-test-scope.cjs +10 -0
  90. package/scripts/run-tests.cjs +25 -1
  91. package/scripts/lint-shared-module-handsync.cjs +0 -388
  92. package/scripts/shared-module-handsync-allowlist.json +0 -183
@@ -41,6 +41,39 @@ function formatHookCommandForRuntime(command, opts = {}) {
41
41
  return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command;
42
42
  }
43
43
 
44
+ // #166/#580: Claude Code on Windows executes hook command strings inside Git
45
+ // Bash. A `.sh` hook wrapped with an explicit bash.exe path makes bash try to
46
+ // exec bash itself ("C:/.../bash.exe: cannot execute binary file"). Both install
47
+ // paths — global (buildHookCommand) and local (buildLocalShellHookCommand) — must
48
+ // drop the bash runner in this case and emit only the anchored script path.
49
+ // Centralized here so the two paths cannot silently drift apart again: the local
50
+ // path missed this guard and reintroduced the #166/#377 failure (#580).
51
+ function shellHookOmitsBashRunner({ platform = process.platform, runtime = 'generic', isShellHook = false } = {}) {
52
+ return platform === 'win32' && runtime === 'claude' && isShellHook;
53
+ }
54
+
55
+ // Builds the command string for a local-install managed `.sh` hook. Mirrors the
56
+ // global buildHookCommand path but uses the $CLAUDE_PROJECT_DIR-anchored prefix
57
+ // instead of an absolute configDir. On Claude/Windows the bash runner is dropped
58
+ // (see shellHookOmitsBashRunner) and the anchored script path is emitted alone —
59
+ // matching the global path. Elsewhere the resolved bash runner is required; a
60
+ // null runner yields null so callers skip registration instead of emitting a
61
+ // broken hook (#3393).
62
+ function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner, runtime = 'generic', platform = process.platform }) {
63
+ if (!localPrefix || !hookFile) return null;
64
+ const scriptPath = `${localPrefix}/hooks/${hookFile}`;
65
+ if (shellHookOmitsBashRunner({ platform, runtime, isShellHook: true })) {
66
+ return formatHookCommandForRuntime(scriptPath, { platform, runtime });
67
+ }
68
+ if (!bashRunner) return null;
69
+ return projectShellCommandText({
70
+ runnerToken: bashRunner,
71
+ argTokens: [scriptPath],
72
+ runtime,
73
+ platform,
74
+ });
75
+ }
76
+
44
77
  /**
45
78
  * Project a managed hook script path token for serialized shell commands.
46
79
  * Windows managed hook commands normalize to forward slashes so the same path
@@ -476,6 +509,8 @@ function platformEnsureDir(dirPath) {
476
509
  module.exports = {
477
510
  hookCommandNeedsPowerShellCallOperator,
478
511
  formatHookCommandForRuntime,
512
+ shellHookOmitsBashRunner,
513
+ buildLocalShellHookCommand,
479
514
  formatManagedHookScriptToken,
480
515
  projectLocalHookPrefix,
481
516
  projectPortableHookBaseDir,
@@ -238,10 +238,6 @@ function isStateTemplateDefault(field, value) {
238
238
  function stateReplaceFieldIfTemplate(content, field, knownDefaults, newValue) {
239
239
  if (newValue === null || newValue === undefined) return content;
240
240
  const existing = stateExtractField(content, field);
241
- // Build a temporary KNOWN_TEMPLATE_DEFAULTS-compatible lookup so we can reuse
242
- // the isStateTemplateDefault logic for the provided knownDefaults array.
243
- const tempField = '__tmp__';
244
- const tempDefaults = { [tempField]: knownDefaults || [] };
245
241
  // Inline check: absent/blank → always write; in list → write; else → skip.
246
242
  if (existing === null || existing === undefined || existing.trim() === '') {
247
243
  return stateReplaceField(content, field, newValue) || content;
@@ -4,7 +4,7 @@
4
4
 
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
- const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
7
+ const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, extractCurrentMilestone, output, error } = require('./core.cjs');
8
8
  const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
9
9
  const { planningDir, planningPaths } = require('./planning-workspace.cjs');
10
10
  const { realClock } = require('./clock.cjs');
@@ -886,9 +886,31 @@ function buildStateFrontmatter(bodyContent, cwd) {
886
886
  diskTotalSummaries += summaryCount;
887
887
  if (completed) diskCompletedPhases++;
888
888
  }
889
+ // Count phase headings from ROADMAP using a digit-containing pattern
890
+ // that matches both numeric phases (01, 05.1) and project-code phases
891
+ // (PROJ-42, CK-05) but excludes pure-word section headers like
892
+ // `## Phase Overview:` or `## Phase Details:` — single source of
893
+ // truth for total_phases (#549).
894
+ let roadmapPhaseCount = 0;
895
+ try {
896
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
897
+ const roadmapRaw = platformReadSync(roadmapPath);
898
+ if (roadmapRaw !== null) {
899
+ const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
900
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
901
+ let m;
902
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
903
+ // Only count tokens that contain at least one digit — excludes
904
+ // pure-word section headings (Overview, Details) while keeping
905
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
906
+ if (/\d/.test(m[1])) roadmapPhaseCount++;
907
+ }
908
+ }
909
+ } catch { /* fall through: phaseDirs.length used as sole count */ }
910
+
889
911
  cached = {
890
- totalPhases: isDirInMilestone.phaseCount > 0
891
- ? Math.max(phaseDirs.length, isDirInMilestone.phaseCount)
912
+ totalPhases: roadmapPhaseCount > 0
913
+ ? Math.max(phaseDirs.length, roadmapPhaseCount)
892
914
  : phaseDirs.length,
893
915
  completedPhases: diskCompletedPhases,
894
916
  totalPlans: diskTotalPlans,
@@ -1651,9 +1673,22 @@ function cmdStateSync(cwd, options, raw) {
1651
1673
  // Mirrors the logic in buildStateFrontmatter so both report consistent percents (#3242 Bug B).
1652
1674
  let syncTotalPhases = null;
1653
1675
  try {
1654
- const isDirInMilestone = getMilestonePhaseFilter(cwd);
1655
- if (isDirInMilestone.phaseCount > 0) {
1656
- syncTotalPhases = Math.max(entries.length, isDirInMilestone.phaseCount);
1676
+ let roadmapPhaseCount = 0;
1677
+ const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
1678
+ const roadmapRaw = platformReadSync(roadmapPath);
1679
+ if (roadmapRaw !== null) {
1680
+ const roadmapScope = extractCurrentMilestone(roadmapRaw, cwd);
1681
+ const phaseHeadingPattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
1682
+ let m;
1683
+ while ((m = phaseHeadingPattern.exec(roadmapScope)) !== null) {
1684
+ // Only count tokens that contain at least one digit — excludes
1685
+ // pure-word section headings (Overview, Details) while keeping
1686
+ // numeric phases (01, 05.1) and project-code IDs (PROJ-42).
1687
+ if (/\d/.test(m[1])) roadmapPhaseCount++;
1688
+ }
1689
+ }
1690
+ if (roadmapPhaseCount > 0) {
1691
+ syncTotalPhases = Math.max(entries.length, roadmapPhaseCount);
1657
1692
  } else {
1658
1693
  syncTotalPhases = entries.length;
1659
1694
  }
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * UI Safety Gate — shell-free implementation (#3706, #3718)
5
+ *
6
+ * Replaces the bash shell-based one-liner that silently degraded on Windows
7
+ * PowerShell / cmd.exe because the locale env-var prefix was not recognised.
8
+ * This module runs inside Node.js — no shell dependency, works identically
9
+ * on bash, Git-Bash, PowerShell, and cmd.exe.
10
+ *
11
+ * Word-boundary anchoring:
12
+ * (^|[^a-zA-Z0-9])(TOKEN)([^a-zA-Z0-9]|$)
13
+ * Equivalent to POSIX ERE [^[:alnum:]] — matches tokens only when they are not
14
+ * interior substrings of alphanumeric compound words (e.g. "microfrontend" is NOT
15
+ * matched; "micro-frontend" and "micro frontend" ARE matched).
16
+ *
17
+ * Public API:
18
+ * checkUiPresence(text: string): { hasUI: boolean, tokens: string[] }
19
+ *
20
+ * CLI usage — reads phase-section text from STDIN to avoid ARG_MAX limits:
21
+ * echo "$PHASE_SECTION" | node get-shit-done/bin/lib/ui-safety-gate.cjs
22
+ * echo $? → 0 if UI tokens found, 1 if not, 2 on usage error
23
+ *
24
+ * Exit codes mirror grep: 0 = match found, 1 = no match, 2 = usage error.
25
+ *
26
+ * Canonical location: get-shit-done/bin/lib/ui-safety-gate.cjs (#448)
27
+ * This path is deployed by the GSD installer to $RUNTIME_DIR/get-shit-done/bin/lib/.
28
+ * bin/lib/ui-safety-gate.cjs (root) is retained for source-repo and npm usage.
29
+ */
30
+
31
+ const UI_TOKENS = [
32
+ 'UI',
33
+ 'interface',
34
+ 'frontend',
35
+ 'component',
36
+ 'layout',
37
+ 'page',
38
+ 'screen',
39
+ 'view',
40
+ 'form',
41
+ 'dashboard',
42
+ 'widget',
43
+ ];
44
+
45
+ /**
46
+ * Built once at module load — no per-call compilation overhead.
47
+ * ASCII word boundaries — matches the original ASCII-grep intent of #3706.
48
+ * Note: JS [a-zA-Z0-9] is ASCII-only and NOT equivalent to POSIX [[:alnum:]],
49
+ * which is locale-sensitive and includes accented characters.
50
+ */
51
+ const UI_GATE_PATTERN = new RegExp(
52
+ '(^|[^a-zA-Z0-9])(' + UI_TOKENS.join('|') + ')([^a-zA-Z0-9]|$)',
53
+ 'i'
54
+ );
55
+
56
+ // Global-flagged variant for extracting ALL matches per line (matchAll).
57
+ const UI_GATE_PATTERN_GLOBAL = new RegExp(UI_GATE_PATTERN.source, 'gi');
58
+
59
+ /**
60
+ * Check a roadmap phase section string for frontend UI indicators.
61
+ *
62
+ * @param {string} text - The roadmap phase section content (may be multi-line, CRLF or LF).
63
+ * @returns {{ hasUI: boolean, tokens: string[] }}
64
+ * hasUI — true if any UI token was matched as a standalone word.
65
+ * tokens — matched token strings (lowercased), deduplicated.
66
+ */
67
+ function checkUiPresence(text) {
68
+ if (typeof text !== 'string') {
69
+ return { hasUI: false, tokens: [] };
70
+ }
71
+
72
+ // Normalise CRLF so the pattern sees consistent line boundaries.
73
+ const normalised = text.replace(/\r\n/g, '\n');
74
+
75
+ const found = new Set();
76
+ for (const line of normalised.split('\n')) {
77
+ // Reset lastIndex before each line so the global pattern restarts from 0.
78
+ UI_GATE_PATTERN_GLOBAL.lastIndex = 0;
79
+ for (const m of line.matchAll(UI_GATE_PATTERN_GLOBAL)) {
80
+ found.add(m[2].toLowerCase());
81
+ }
82
+ }
83
+
84
+ return { hasUI: found.size > 0, tokens: [...found] };
85
+ }
86
+
87
+ module.exports = { checkUiPresence, UI_TOKENS };
88
+
89
+ // ── CLI entry point ─────────────────────────────────────────────────────────
90
+ // Reads phase-section text from STDIN (not argv) to avoid OS ARG_MAX limits.
91
+ // Invoked by workflow .md bash blocks as: echo "$PHASE_SECTION" | node .../ui-safety-gate.cjs
92
+ // Exit 0 = UI found, 1 = no UI, 2 = startup error.
93
+
94
+ if (require.main === module) {
95
+ // Collect stdin chunks asynchronously.
96
+ const chunks = [];
97
+ process.stdin.setEncoding('utf-8');
98
+
99
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
100
+
101
+ process.stdin.on('end', () => {
102
+ const input = chunks.join('');
103
+ const result = checkUiPresence(input);
104
+ process.exit(result.hasUI ? 0 : 1);
105
+ });
106
+
107
+ process.stdin.on('error', (err) => {
108
+ process.stderr.write(`ERROR: ui-safety-gate.cjs stdin read failed: ${err.message}\n`);
109
+ process.exit(2);
110
+ });
111
+ }
@@ -29,8 +29,13 @@
29
29
  */
30
30
 
31
31
  // ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
32
- const phaseDirNameRe = /^\d{2,}(?:\.\d+)*-[\w-]+$/;
33
- const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i;
32
+ // Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
33
+ // deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
34
+ const phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
35
+ // Extracts the full phase token from a directory name, including milestone-prefixed
36
+ // multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
37
+ // Greedily captures all leading all-digit segments before the first letter-start segment.
38
+ const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
34
39
  const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
35
40
 
36
41
  // ── Issue #26: I001 canonicalization ────────────────────────────────────────
@@ -41,26 +46,46 @@ function canonicalPlanStem(stem) {
41
46
 
42
47
  // ── Issue #6: phase variant helpers (W006/W007) ──────────────────────────────
43
48
  function phaseVariants(phase) {
49
+ const variants = new Set([phase]);
50
+ const dotIdx = phase.indexOf('.');
51
+ const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx);
52
+ const tail = dotIdx === -1 ? '' : phase.slice(dotIdx);
44
53
 
45
- const variants = new Set([phase]);
46
- const dotIdx = phase.indexOf('.');
47
- const head = dotIdx === -1 ? phase : phase.slice(0, dotIdx);
48
- const tail = dotIdx === -1 ? '' : phase.slice(dotIdx);
49
- const headMatch = head.match(/^(\d+)([A-Z]?)$/i);
50
- if (!headMatch)
51
- return variants;
52
- const numericHead = headMatch[1];
53
- const letterSuffix = headMatch[2] || '';
54
- variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`);
55
- variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`);
56
- return variants;
57
-
54
+ // Milestone-prefixed IDs: M-NN or M-N-N. Add padding-normalized variant.
55
+ // e.g. "2-01" → also "02-01"; "02-01" → also "2-01"
56
+ const milestoneHeadMatch = head.match(/^(\d+)((?:-\d+)+)([A-Z]?)$/i);
57
+ if (milestoneHeadMatch) {
58
+ const major = milestoneHeadMatch[1];
59
+ const subSegs = milestoneHeadMatch[2]; // e.g. "-01" or "-04-01"
60
+ const letter = milestoneHeadMatch[3] || '';
61
+ const paddedMajor = major.padStart(2, '0');
62
+ const unpaddedMajor = String(parseInt(major, 10));
63
+ // Pad/unpad sub-segments individually
64
+ const paddedSubs = subSegs.slice(1).split('-').map(s => s.padStart(2, '0')).join('-');
65
+ const unpaddedSubs = subSegs.slice(1).split('-').map(s => String(parseInt(s, 10))).join('-');
66
+ variants.add(`${paddedMajor}-${paddedSubs}${letter}${tail}`);
67
+ variants.add(`${unpaddedMajor}-${unpaddedSubs}${letter}${tail}`);
68
+ variants.add(`${unpaddedMajor}-${paddedSubs}${letter}${tail}`);
69
+ variants.add(`${paddedMajor}-${unpaddedSubs}${letter}${tail}`);
70
+ return variants;
71
+ }
72
+
73
+ // Plain numeric/decimal IDs: "1", "01", "12A", "12.1"
74
+ const headMatch = head.match(/^(\d+)([A-Z]?)$/i);
75
+ if (!headMatch) return variants;
76
+ const numericHead = headMatch[1];
77
+ const letterSuffix = headMatch[2] || '';
78
+ variants.add(`${String(parseInt(numericHead, 10))}${letterSuffix}${tail}`);
79
+ variants.add(`${numericHead.padStart(2, '0')}${letterSuffix}${tail}`);
80
+ return variants;
58
81
  }
59
82
 
60
83
  function buildRoadmapPhaseVariants(roadmapContent) {
61
84
  const roadmapPhases = new Set();
62
85
  const roadmapPhaseVariants = new Set();
63
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
86
+ // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:),
87
+ // and bracket-prefixed (### [GSD] Phase 2-01:) headings.
88
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
64
89
  let m;
65
90
  while ((m = phasePattern.exec(roadmapContent)) !== null) {
66
91
  roadmapPhases.add(m[1]);
@@ -71,7 +96,8 @@ function buildRoadmapPhaseVariants(roadmapContent) {
71
96
 
72
97
  function buildNotStartedPhaseVariants(roadmapContent) {
73
98
  const notStartedPhases = new Set();
74
- const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+(\d+[A-Z]?(?:\.\d+)*)[:\s*]/gi;
99
+ // Also matches milestone-prefixed and bracket-prefixed checklist items.
100
+ const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi;
75
101
  let um;
76
102
  while ((um = uncheckedPattern.exec(roadmapContent)) !== null) {
77
103
  for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant);
@@ -12,7 +12,7 @@ const {
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const os = require('os');
15
- const { loadConfig, normalizePhaseName, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
15
+ const { loadConfig, normalizePhaseName, phaseTokenMatches, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
16
16
  const { execGit, platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
17
17
  const { PACKAGE_NAME } = require('./package-identity.cjs');
18
18
  const { planningDir } = require('./planning-workspace.cjs');
@@ -507,6 +507,34 @@ function collectDiskPhases(planBase) {
507
507
  return diskPhases;
508
508
  }
509
509
 
510
+ // W021: phase ID integer prefix doesn't match its enclosing milestone section
511
+ // Only fires when phase_id_convention is 'milestone-prefixed' (opt-in).
512
+ // Returns array of mismatch objects: { phaseId, foundInMilestone, expectedMilestone }
513
+ function checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId }) {
514
+ const mismatches = [];
515
+ // Find all milestone sections (## vN.N or ## [code] vN.N)
516
+ const sections = [];
517
+ const sectionRx = /^#{1,3}\s+(?:\[[^\]]+\]\s*)?.*v(\d+\.\d+)/gim;
518
+ let m;
519
+ while ((m = sectionRx.exec(roadmapContent)) !== null) {
520
+ if (sections.length > 0) sections[sections.length - 1].end = m.index;
521
+ sections.push({ version: `v${m[1]}`, start: m.index, end: roadmapContent.length });
522
+ }
523
+ for (const section of sections) {
524
+ const content = roadmapContent.slice(section.start, section.end);
525
+ const phaseRx = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
526
+ let pm;
527
+ while ((pm = phaseRx.exec(content)) !== null) {
528
+ const phaseId = pm[1];
529
+ const expectedMilestone = getMilestoneFromPhaseId(phaseId);
530
+ if (expectedMilestone !== null && expectedMilestone !== section.version) {
531
+ mismatches.push({ phaseId, foundInMilestone: section.version, expectedMilestone });
532
+ }
533
+ }
534
+ }
535
+ return mismatches;
536
+ }
537
+
510
538
  function cmdValidateConsistency(cwd, raw) {
511
539
  const planBase = planningDir(cwd);
512
540
  const roadmapPath = path.join(planBase, 'ROADMAP.md');
@@ -527,7 +555,9 @@ function cmdValidateConsistency(cwd, raw) {
527
555
  // stripped). Used for the "in ROADMAP but not on disk" check — we only require
528
556
  // disk dirs for the active milestone's phases.
529
557
  const roadmapPhases = new Set();
530
- const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
558
+ // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), and
559
+ // milestone-prefixed (Phase 2-01:) headings, including bracket-prefixed form.
560
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
531
561
  let m;
532
562
  while ((m = phasePattern.exec(roadmapContent)) !== null) {
533
563
  roadmapPhases.add(m[1]);
@@ -539,7 +569,7 @@ function cmdValidateConsistency(cwd, raw) {
539
569
  // though it is absent from the active-milestone scope. Without this, narrowing
540
570
  // the scope (#501) would flag every shipped phase dir as a spurious orphan.
541
571
  const fullRoadmapPhases = new Set();
542
- const fullPhasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi;
572
+ const fullPhasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi;
543
573
  let fm;
544
574
  while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) {
545
575
  fullRoadmapPhases.add(fm[1]);
@@ -558,8 +588,12 @@ function cmdValidateConsistency(cwd, raw) {
558
588
  // Check: phases on disk but not in ROADMAP (compared against the FULL roadmap
559
589
  // so shipped-milestone phase dirs are not flagged as orphans — #501)
560
590
  for (const p of diskPhases) {
591
+ // For plain numeric IDs, also try the unpadded form (e.g. "02" → "2").
592
+ // For milestone-prefixed IDs (e.g. "02-01"), use normalizePhaseName to
593
+ // canonicalize padding before comparing with the ROADMAP entries.
594
+ const normalized = normalizePhaseName(p);
561
595
  const unpadded = String(parseInt(p, 10));
562
- if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(unpadded)) {
596
+ if (!fullRoadmapPhases.has(p) && !fullRoadmapPhases.has(normalized) && !fullRoadmapPhases.has(unpadded)) {
563
597
  warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
564
598
  }
565
599
  }
@@ -1059,6 +1093,31 @@ function cmdValidateHealth(cwd, options, raw) {
1059
1093
  }
1060
1094
  } catch { /* git worktree not available or not a git repo — skip silently */ }
1061
1095
 
1096
+ // ─── Check 11b: Phase ID / milestone-section mismatch (W021) ─────────────
1097
+ // Only active when phase_id_convention === 'milestone-prefixed' in config.json.
1098
+ try {
1099
+ const phaseConvention = (() => {
1100
+ if (!fs.existsSync(configPath)) return null;
1101
+ try {
1102
+ const configRaw = fs.readFileSync(configPath, 'utf-8');
1103
+ const configParsed = JSON.parse(configRaw);
1104
+ return configParsed.phase_id_convention || null;
1105
+ } catch { return null; }
1106
+ })();
1107
+ if (phaseConvention === 'milestone-prefixed') {
1108
+ if (fs.existsSync(roadmapPath)) {
1109
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
1110
+ const { getMilestoneFromPhaseId } = require('./core.cjs');
1111
+ const mismatches = checkMilestonePrefixMismatches(roadmapContent, { getMilestoneFromPhaseId });
1112
+ for (const m of mismatches) {
1113
+ addIssue('warning', 'W021',
1114
+ `Phase ${m.phaseId}: integer prefix implies ${m.expectedMilestone} but listed under ${m.foundInMilestone}`,
1115
+ 'Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default)');
1116
+ }
1117
+ }
1118
+ }
1119
+ } catch { /* W021 check is advisory — skip on error */ }
1120
+
1062
1121
  // ─── Check 12: MILESTONES.md / archive snapshot drift (#2446) ─────────────
1063
1122
  const milestonesPath = path.join(planBase, 'MILESTONES.md');
1064
1123
  const milestonesArchiveDir = path.join(planBase, 'milestones');
@@ -1107,6 +1166,50 @@ function cmdValidateHealth(cwd, options, raw) {
1107
1166
  }
1108
1167
  } catch { /* artifact check is advisory — skip on error */ }
1109
1168
 
1169
+ // ─── Check 14: milestone-status vs. roadmap-progress incoherence (W021) ───
1170
+ try {
1171
+ if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) {
1172
+ const stateRaw = fs.readFileSync(statePath, 'utf-8');
1173
+ const statusMatch = stateRaw.match(/^status:\s*(.+)/im);
1174
+ const stateStatus = statusMatch ? statusMatch[1].trim().toLowerCase() : '';
1175
+ const isMarkedComplete = /milestone complete|archived/.test(stateStatus);
1176
+ if (isMarkedComplete) {
1177
+ // Inline phase scan: read roadmap, extract current milestone section,
1178
+ // then check for phases with no directory on disk (unstarted).
1179
+ const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');
1180
+ const scopedContent = extractCurrentMilestone(roadmapRaw, cwd);
1181
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
1182
+ const unstarted = [];
1183
+ let pm;
1184
+ const planningWorkspace = require('./planning-workspace.cjs');
1185
+ const phasesDir2 = planningWorkspace.planningPaths(cwd).phases;
1186
+ const phaseDirNames2 = (() => {
1187
+ try {
1188
+ return fs.readdirSync(phasesDir2, { withFileTypes: true })
1189
+ .filter(e => e.isDirectory()).map(e => e.name);
1190
+ } catch { return []; }
1191
+ })();
1192
+ while ((pm = phasePattern.exec(scopedContent)) !== null) {
1193
+ const phaseNum = pm[1];
1194
+ const normalized = normalizePhaseName(phaseNum);
1195
+ // Phase is unstarted (disk_status: no_directory) if no directory
1196
+ // with a matching token exists on disk. Use phaseTokenMatches (same
1197
+ // helper as roadmap.analyze) to correctly handle decimal (2.1) and
1198
+ // letter-suffix (12A) phase IDs without false positives.
1199
+ const hasDirectory = phaseDirNames2.some(d => phaseTokenMatches(d, normalized));
1200
+ if (!hasDirectory) {
1201
+ unstarted.push(phaseNum);
1202
+ }
1203
+ }
1204
+ if (unstarted.length > 0) {
1205
+ addIssue('warning', 'W021',
1206
+ `STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`,
1207
+ 'Run validate consistency or re-run complete-milestone after verifying all phases are done');
1208
+ }
1209
+ }
1210
+ }
1211
+ } catch { /* W021 check is advisory — skip on error */ }
1212
+
1110
1213
  // ─── Perform repairs if requested ─────────────────────────────────────────
1111
1214
  const repairActions = [];
1112
1215
  if (options.repair && repairs.length > 0) {
@@ -11,6 +11,7 @@
11
11
  "context_window": 200000,
12
12
  "phase_naming": "sequential",
13
13
  "project_code": null,
14
+ "phase_id_convention": null,
14
15
  "mode": "interactive",
15
16
  "claude_md_path": "./CLAUDE.md",
16
17
  "git": {
@@ -83,6 +83,7 @@
83
83
  "features.global_learnings",
84
84
  "learnings.max_inject",
85
85
  "project_code",
86
+ "phase_id_convention",
86
87
  "phase_naming",
87
88
  "manager.flags.discuss",
88
89
  "manager.flags.plan",
@@ -100,7 +101,12 @@
100
101
  "effort.default",
101
102
  "fast_mode.enabled",
102
103
  "plan_review.source_grounding",
103
- "plan_review.source_grounding_authority"
104
+ "plan_review.source_grounding_authority",
105
+ "model_policy.provider",
106
+ "model_policy.budget",
107
+ "model_policy.high",
108
+ "model_policy.medium",
109
+ "model_policy.low"
104
110
  ],
105
111
  "runtimeStateKeys": [
106
112
  "workflow._auto_chain_active"
@@ -170,6 +176,11 @@
170
176
  "topLevel": "review",
171
177
  "source": "^review\\.max_prompt_tokens_per_reviewer\\.[a-zA-Z0-9_-]+$",
172
178
  "description": "review.max_prompt_tokens_per_reviewer.<reviewer-slug>"
179
+ },
180
+ {
181
+ "topLevel": "model_policy",
182
+ "source": "^model_policy\\.runtime_tiers\\.[a-zA-Z0-9_-]+\\.(opus|sonnet|haiku)$",
183
+ "description": "model_policy.runtime_tiers.<runtime>.<opus|sonnet|haiku>"
173
184
  }
174
185
  ]
175
186
  }
@@ -83,6 +83,33 @@
83
83
  "haiku": null
84
84
  }
85
85
  },
86
+ "providerPresets": {
87
+ "anthropic": {
88
+ "opus": { "low": { "model": "claude-opus-4-5" }, "medium": { "model": "claude-opus-4-8" }, "high": { "model": "claude-opus-4-8" } },
89
+ "sonnet": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-sonnet-4-6" }, "high": { "model": "claude-opus-4-8" } },
90
+ "haiku": { "low": { "model": "claude-haiku-4-5" }, "medium": { "model": "claude-haiku-4-5" }, "high": { "model": "claude-sonnet-4-6" } }
91
+ },
92
+ "openai": {
93
+ "opus": { "low": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" }, "medium": { "model": "gpt-5.5", "reasoning_effort": "high" }, "high": { "model": "gpt-5.5", "reasoning_effort": "xhigh" } },
94
+ "sonnet": { "low": { "model": "gpt-5.4-mini", "reasoning_effort": "low" }, "medium": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" }, "high": { "model": "gpt-5.5", "reasoning_effort": "medium" } },
95
+ "haiku": { "low": { "model": "gpt-5.4-mini", "reasoning_effort": "minimal" }, "medium": { "model": "gpt-5.4-mini", "reasoning_effort": "medium" }, "high": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" } }
96
+ },
97
+ "google": {
98
+ "opus": { "low": { "model": "gemini-2.5-flash-lite" }, "medium": { "model": "gemini-3-flash" }, "high": { "model": "gemini-3-pro" } },
99
+ "sonnet": { "low": { "model": "gemini-2.5-flash-lite" }, "medium": { "model": "gemini-3-flash" }, "high": { "model": "gemini-3-flash" } },
100
+ "haiku": { "low": { "model": "gemini-2.5-flash-lite" }, "medium": { "model": "gemini-2.5-flash-lite" }, "high": { "model": "gemini-3-flash" } }
101
+ },
102
+ "qwen": {
103
+ "opus": { "low": { "model": "qwen3-coder-plus" }, "medium": { "model": "qwen3-max-2026-01-23" }, "high": { "model": "qwen3-max-2026-01-23" } },
104
+ "sonnet": { "low": { "model": "qwen3-coder-next" }, "medium": { "model": "qwen3-coder-plus" }, "high": { "model": "qwen3-max-2026-01-23" } },
105
+ "haiku": { "low": { "model": "qwen3-coder-next" }, "medium": { "model": "qwen3-coder-next" }, "high": { "model": "qwen3-coder-plus" } }
106
+ },
107
+ "generic": {
108
+ "opus": { "low": null, "medium": null, "high": null },
109
+ "sonnet": { "low": null, "medium": null, "high": null },
110
+ "haiku": { "low": null, "medium": null, "high": null }
111
+ }
112
+ },
86
113
  "agents": {
87
114
  "gsd-planner": { "golden": "opus", "balanced": "opus", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" },
88
115
  "gsd-roadmapper": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" },
@@ -18,7 +18,7 @@ Plans execute autonomously. Checkpoints formalize interaction points where human
18
18
 
19
19
  **When:** Claude completed automated work, human confirms it works correctly.
20
20
 
21
- > **Default mode (#3309): `workflow.human_verify_mode = end-of-phase`.** New projects do NOT halt mid-flight at `checkpoint:human-verify`. The planner suppresses those task emissions and embeds the verification details into the relevant `auto` task's `<verify><human-check>` block; the verifier harvests every `<verify><human-check>` at end-of-phase (Step 8) and consolidates them into the existing `human_needed` → HUMAN-UAT.md flow in `workflows/execute-phase.md`. The user reviews everything in one batch.
21
+ > **Default mode (#3309): `workflow.human_verify_mode = end-of-phase`.** New projects do NOT halt mid-flight at `checkpoint:human-verify`. The planner suppresses those task emissions and embeds the verification details into the relevant `auto` task's `<verify><human-check>` block; the verifier harvests every `<verify><human-check>` at end-of-phase (Step 8) and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` flow in `workflows/execute-phase.md`. The user reviews everything in one batch.
22
22
  >
23
23
  > **Why this is the default:** every mid-flight halt costs a full executor cold-start (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on respawn) because subagent context is discarded across the pause. A plan with N human-verify checkpoints pays the cold-start cost N+1 times — measured at "tens of thousands of tokens" per round-trip on real projects.
24
24
  >
@@ -27,7 +27,7 @@ Instead, fold each would-be verification step into the relevant `auto` task usin
27
27
  </task>
28
28
  ```
29
29
 
30
- The verifier (Step 8) harvests every `<verify><human-check>` block at end-of-phase and consolidates them into the existing `human_needed` → HUMAN-UAT.md path in `workflows/execute-phase.md`. The user reviews everything in one batch instead of paying a cold-start cost per item.
30
+ The verifier (Step 8) harvests every `<verify><human-check>` block at end-of-phase and consolidates them into the existing `human_needed` → `{phase_num}-UAT.md` path in `workflows/execute-phase.md`. The user reviews everything in one batch instead of paying a cold-start cost per item.
31
31
 
32
32
  ### `mid-flight` (opt-back-in — pre-#3309 behavior)
33
33
 
@@ -83,10 +83,12 @@ Plans: 3/5 complete
83
83
 
84
84
  ## Spawning Indicators
85
85
 
86
+ **Liveness convention:** Every spawn announcement must carry the canonical phrase `runs in a subagent` inline so users know that silence during a subagent run is expected. Without this, a healthy 1–5 minute agent looks identical to a frozen session. Single spawns use the singular form; parallel spawns use the plural form.
87
+
86
88
  ```
87
- ◆ Spawning researcher...
89
+ ◆ Spawning researcher... (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)
88
90
 
89
- ◆ Spawning 4 researchers in parallel...
91
+ ◆ Spawning 4 researchers in parallel... (each runs in a subagent — no output until they return, ~1–5 min; expected, not a freeze)
90
92
  → Stack research
91
93
  → Features research
92
94
  → Architecture research
@@ -94,7 +94,7 @@ final output — do not proceed to fixing.
94
94
  <step name="fix-loop">
95
95
  For each **auto-fixable** finding (up to `--max`, ordered by severity desc):
96
96
 
97
- **a. Spawn executor agent:**
97
+ **a. Spawn executor agent** (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)**:**
98
98
  ```
99
99
  Agent(
100
100
  prompt="Fix finding {ID}: {description}. Files: {file_refs}. Make the minimal change to resolve this specific finding. Do not refactor surrounding code.",
@@ -67,6 +67,8 @@ With phase context collected:
67
67
 
68
68
  Extract `MILESTONE_REQ_IDS` from REQUIREMENTS.md traceability table — all REQ-IDs assigned to phases in this milestone.
69
69
 
70
+ Print: "Spawning integration checker (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze)"
71
+
70
72
  ```
71
73
  Agent(
72
74
  prompt="Check cross-phase integration and E2E flows.