@opengsd/gsd-core 1.2.0-rc.1 → 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
@@ -7,7 +7,7 @@ const os = require('os');
7
7
  const path = require('path');
8
8
  const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
9
9
  const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = require('./model-profiles.cjs');
10
- const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } = require('./model-catalog.cjs');
10
+ const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } = require('./model-catalog.cjs');
11
11
  const {
12
12
  resolveWorktreeContext,
13
13
  parseWorktreePorcelain: parseWorktreePorcelainPolicy,
@@ -514,6 +514,11 @@ function loadConfig(cwd, options = {}) {
514
514
  // unknown runtime/tier names at load time, not silently (review finding #10).
515
515
  runtime: parsed.runtime || null,
516
516
  model_profile_overrides: parsed.model_profile_overrides || null,
517
+ // #49 — provider-neutral model policy presets.
518
+ // model_policy is read flat (not via get()) for the same reason as
519
+ // model_profile_overrides: it is a top-level config key per CONFIGURATION.md,
520
+ // and nested resolution would introduce edge cases without benefit.
521
+ model_policy: parsed.model_policy || null,
517
522
  // #443 — effort/fast_mode: pass through from config.json; resolvers handle
518
523
  // defaults + tier lookups internally.
519
524
  effort: parsed.effort || null,
@@ -668,6 +673,18 @@ function normalizePhaseName(phase) {
668
673
  const str = String(phase);
669
674
  // Strip optional project_code prefix (e.g., 'CK-01' → '01')
670
675
  const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
676
+ // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
677
+ // Examples: '2-01', '02-01', '2-4-1', '02-04-01'.
678
+ // Must be tested BEFORE the plain numeric path so '2-01' → '02-01', not '02'.
679
+ // Pattern: at least two dash-separated all-digit segments (letter/decimal suffix on last).
680
+ const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
681
+ if (milestoneMatch) {
682
+ const major = milestoneMatch[1].padStart(2, '0');
683
+ // Each sub-segment gets zero-padded to at least 2 digits.
684
+ const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
685
+ const suffix = milestoneMatch[3] || '';
686
+ return `${major}-${subSegments.join('-')}${suffix}`;
687
+ }
671
688
  // Standard numeric phases: 1, 01, 12A, 12.1
672
689
  const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
673
690
  if (match) {
@@ -683,6 +700,32 @@ function normalizePhaseName(phase) {
683
700
  return str;
684
701
  }
685
702
 
703
+ function getMilestoneFromPhaseId(phaseId) {
704
+ const str = String(phaseId);
705
+ const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
706
+ const m = stripped.match(/^0*(\d+)-\d/);
707
+ if (!m) return null;
708
+ const major = parseInt(m[1], 10);
709
+ if (major === 0 || major === 999) return null;
710
+ return `v${major}.0`;
711
+ }
712
+
713
+ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
714
+ const str = String(phaseId);
715
+ const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
716
+ const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
717
+ if (!m) return null;
718
+ const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
719
+ const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
720
+ const sub = subParts.join('-');
721
+ const slug = phaseName
722
+ ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
723
+ : '';
724
+ const parts = [milestone, sub, slug].filter(Boolean);
725
+ const base = parts.join('-');
726
+ return projectCode ? `${projectCode}-${base}` : base;
727
+ }
728
+
686
729
  /**
687
730
  * Render a regex source fragment matching a phase number against ROADMAP/STATE
688
731
  * prose regardless of zero-padding on either side. Skills pass the resolved
@@ -699,6 +742,24 @@ function normalizePhaseName(phase) {
699
742
  */
700
743
  function phaseMarkdownRegexSource(phaseNum) {
701
744
  const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
745
+
746
+ // Milestone-prefixed IDs: M-NN or M-N-N (deep). Each numeric segment is padding-tolerant.
747
+ // Pattern: one or more dash-separated all-digit groups (last may have letter/decimal suffix).
748
+ const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
749
+ if (milestoneSegments && milestoneSegments[2]) {
750
+ // Has at least one dash-separated segment — treat as milestone-prefixed
751
+ const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
752
+ const subParts = milestoneSegments[2].slice(1).split('-'); // drop leading '-'
753
+ const subFragments = subParts.map(s => {
754
+ const unpadded = s.replace(/^0+/, '') || '0';
755
+ return `0*${escapeRegex(unpadded)}`;
756
+ });
757
+ const suffix = milestoneSegments[3] || '';
758
+ const suffixFragment = suffix ? escapeRegex(suffix) : '';
759
+ return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
760
+ }
761
+
762
+ // Plain numeric phase: 1, 01, 12A, 12.1
702
763
  const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
703
764
  if (!match) return escapeRegex(phaseNum);
704
765
 
@@ -729,8 +790,35 @@ function phaseMarkdownRegexSourceExact(phaseNum) {
729
790
 
730
791
  function comparePhaseNum(a, b) {
731
792
  // Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
732
- const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
733
- const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
793
+ const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
794
+ const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
795
+
796
+ // Milestone-prefixed IDs: one or more dash-separated all-digit segments.
797
+ // e.g. '02-10', '2-01', '02-04-01'. Compare segment by segment numerically.
798
+ // A string matches this form when it starts with digits and has at least one '-digit' group.
799
+ const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
800
+ const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
801
+
802
+ if (milestoneA && milestoneB) {
803
+ const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
804
+ const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
805
+ const maxSegs = Math.max(segsA.length, segsB.length);
806
+ for (let i = 0; i < maxSegs; i++) {
807
+ const av = segsA[i] !== undefined ? segsA[i] : 0;
808
+ const bv = segsB[i] !== undefined ? segsB[i] : 0;
809
+ if (av !== bv) return av - bv;
810
+ }
811
+ // Segments equal — compare any trailing letter/decimal suffix
812
+ const sufA = milestoneA[3] || '';
813
+ const sufB = milestoneB[3] || '';
814
+ if (sufA !== sufB) return sufA < sufB ? -1 : 1;
815
+ return 0;
816
+ }
817
+
818
+ // If one is milestone-prefixed and the other is not, milestone-prefixed sorts first
819
+ // (they come from different conventions; preserve caller's intent by string comparison).
820
+ if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
821
+
734
822
  const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
735
823
  const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
736
824
  // If either is non-numeric (custom ID), fall back to string comparison
@@ -761,20 +849,59 @@ function comparePhaseNum(a, b) {
761
849
 
762
850
  /**
763
851
  * Extract the phase token from a directory name.
764
- * Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
765
- * Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
852
+ * A token is the leading all-numeric (or project-code-prefixed) run of dash-separated
853
+ * segments, up to but not including the first segment that starts with a letter after the
854
+ * optional code prefix. The last numeric segment may carry a letter suffix (e.g. 12A) or
855
+ * decimal suffix (e.g. 999.6).
856
+ *
857
+ * Examples:
858
+ * '01-name' → '01'
859
+ * '02-01-setup' → '02-01' (milestone-prefixed 2-segment)
860
+ * '02-04-01-deep' → '02-04-01' (deep 3-segment)
861
+ * 'CK-01-name' → 'CK-01' (project-code-prefixed)
862
+ * 'GSD-02-01-setup' → 'GSD-02-01' (code-prefixed milestone)
863
+ * 'GSD-02-04-01-deep' → 'GSD-02-04-01'
864
+ * '1009A-name' → '1009A'
865
+ * '999.6-name' → '999.6'
866
+ * 'PROJ-42-name' → 'PROJ-42' (custom ID)
766
867
  */
767
868
  function extractPhaseToken(dirName) {
768
- // Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
769
- const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
770
- if (codePrefixed) return codePrefixed[1];
771
- // Try plain numeric: 01-name, 1009A-name, 999.6-name
772
- const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
773
- if (numeric) return numeric[1];
774
- // Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
775
- const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
776
- if (custom) return custom[1];
777
- return dirName;
869
+ // Optional project-code prefix: 1–6 uppercase letters followed by a digit segment.
870
+ const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
871
+ let prefix = '';
872
+ let rest = dirName;
873
+ if (codePrefixMatch) {
874
+ // Distinguish code prefix (e.g. GSD-, CK-) from purely numeric-looking start.
875
+ // The prefix must be all-uppercase-letter (already guaranteed by [A-Z]{1,6}) and
876
+ // the first char after '-' must be a digit so we don't swallow PROJ-42-name prematurely.
877
+ prefix = codePrefixMatch[1] + '-';
878
+ rest = codePrefixMatch[2];
879
+ }
880
+
881
+ // Greedily consume all leading all-digit segments (possibly with A-Z letter or .N suffix on the last).
882
+ // Stop when a segment starts with a letter (that is not a continuation of the last digit segment).
883
+ const segments = rest.split('-');
884
+ const tokenSegments = [];
885
+ for (let i = 0; i < segments.length; i++) {
886
+ const seg = segments[i];
887
+ if (/^\d/.test(seg)) {
888
+ // Numeric segment (possibly trailing letter or .N suffix on last) — always include
889
+ tokenSegments.push(seg);
890
+ } else {
891
+ // First letter-start segment after digits → name portion starts here
892
+ break;
893
+ }
894
+ }
895
+
896
+ if (tokenSegments.length === 0) {
897
+ // No leading numeric segment — could be a custom ID like PROJ-42
898
+ // If we stripped a code prefix, return the full original (prefix stripped the code but rest is numeric handled above)
899
+ // For purely letter-start directory with no code prefix (shouldn't normally happen), return as-is
900
+ return dirName;
901
+ }
902
+
903
+ // The last numeric segment may have a letter suffix (1009A) or decimal (.6) already included.
904
+ return prefix + tokenSegments.join('-');
778
905
  }
779
906
 
780
907
  /**
@@ -811,13 +938,13 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
811
938
  const match = dirs.find(d => phaseTokenMatches(d, normalized));
812
939
  if (!match) return null;
813
940
 
814
- // Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
815
- const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
816
- || match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
817
- || match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
818
- || [null, match, null];
819
- const phaseNumber = dirMatch ? dirMatch[1] : normalized;
820
- const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
941
+ // Extract phase number and name using extractPhaseToken for correctness with all ID forms
942
+ // including deep milestone-prefixed (02-04-01-deep → 02-04-01 / deep) and code-prefixed.
943
+ const phaseToken = extractPhaseToken(match);
944
+ const phaseNumber = phaseToken || normalized;
945
+ // phase_name is everything after the token (strip leading '-')
946
+ const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
947
+ const phaseName = afterToken || null;
821
948
  const phaseDir = path.join(baseDir, match);
822
949
  const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
823
950
  const plans = unsortedPlans.sort();
@@ -973,8 +1100,8 @@ function extractCurrentMilestone(content, cwd) {
973
1100
 
974
1101
  // 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
975
1102
  if (!version) {
976
- // Check for 🚧 in-progress marker
977
- const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
1103
+ // Check for 🚧 or 🔄 in-progress marker in bold version line
1104
+ const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
978
1105
  if (inProgressMatch) {
979
1106
  version = 'v' + inProgressMatch[1];
980
1107
  }
@@ -984,19 +1111,57 @@ function extractCurrentMilestone(content, cwd) {
984
1111
 
985
1112
  // 3. Find the section matching this version
986
1113
  // Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
1114
+ // Also match <summary> tags that contain the version (milestone in <details open>).
1115
+ // Exclude phase headings (e.g. "### Phase 1: v1.3 migration") so that a phase title
1116
+ // that mentions the milestone version does not bypass the <summary> fallback.
987
1117
  const escapedVersion = escapeRegex(version);
988
1118
  const sectionPattern = new RegExp(
989
- `(^#{1,3}\\s+.*${escapedVersion}\\b[^\\n]*)`,
1119
+ `(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
990
1120
  'gmi'
991
1121
  );
992
- const allMatches = [...content.matchAll(sectionPattern)];
1122
+ const summaryPattern = new RegExp(
1123
+ `<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
1124
+ 'i'
1125
+ );
1126
+ const headingMatches = [...content.matchAll(sectionPattern)];
1127
+
1128
+ // If the version appears only inside a <summary> tag (not in a heading),
1129
+ // locate the enclosing <details open> block and treat its content as the
1130
+ // current milestone section instead of falling through to stripShippedMilestones().
1131
+ if (headingMatches.length === 0) {
1132
+ const summaryMatch = content.match(summaryPattern);
1133
+ if (summaryMatch) {
1134
+ // Find the <details ...> opening tag that precedes this <summary>
1135
+ const summaryIdx = content.indexOf(summaryMatch[0]);
1136
+ const beforeSummary = content.slice(0, summaryIdx);
1137
+ const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
1138
+ if (detailsOpenIdx !== -1) {
1139
+ // Find the matching </details> closing tag
1140
+ const afterDetails = content.slice(detailsOpenIdx);
1141
+ const closingMatch = afterDetails.match(/<\/details>/i);
1142
+ const detailsEnd = closingMatch
1143
+ ? detailsOpenIdx + closingMatch.index + '</details>'.length
1144
+ : content.length;
1145
+ // Preamble: everything before the first milestone-level section
1146
+ const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
1147
+ const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
1148
+ const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
1149
+ const preamble = content.slice(0, preambleCutoff)
1150
+ .replace(/<details>[\s\S]*?<\/details>/gi, '')
1151
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
1152
+ .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
1153
+ return preamble + content.slice(detailsOpenIdx, detailsEnd);
1154
+ }
1155
+ }
1156
+ return stripShippedMilestones(content);
1157
+ }
993
1158
 
994
- if (allMatches.length === 0) return stripShippedMilestones(content);
1159
+ const allMatches = headingMatches;
995
1160
 
996
1161
  // Select the first non-closed heading; fall back to first match if all are closed.
997
1162
  // A heading is "closed" only if it carries a closed marker AND no active marker.
998
1163
  const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
999
- const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧/i;
1164
+ const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
1000
1165
  const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
1001
1166
  const firstMatch = allMatches[0];
1002
1167
  const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
@@ -1102,8 +1267,9 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
1102
1267
  // #3537: route through canonical padding-tolerant fragment. The prior
1103
1268
  // hand-rolled `isNumeric` branch only stripped padding on integer-only
1104
1269
  // ids and missed decimal padding (`02.7` against `Phase 2.7:` headings).
1270
+ // Also tolerate optional [bracket-token] scope prefix on phase headings.
1105
1271
  const phasePattern = new RegExp(
1106
- `#{2,4}\\s*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
1272
+ `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
1107
1273
  'i'
1108
1274
  );
1109
1275
  const headerMatch = content.match(phasePattern);
@@ -1112,7 +1278,8 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
1112
1278
  const phaseName = headerMatch[1].trim();
1113
1279
  const headerIndex = headerMatch.index;
1114
1280
  const restOfContent = content.slice(headerIndex);
1115
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+Phase\s+[\w]/i);
1281
+ // Boundary: next phase heading — also matches bracket-prefixed form.
1282
+ const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
1116
1283
  const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
1117
1284
  const section = content.slice(headerIndex, sectionEnd).trim();
1118
1285
 
@@ -1222,35 +1389,91 @@ function _warnUnknownProfileOverrides(parsed, configLabel) {
1222
1389
  }
1223
1390
 
1224
1391
  const overrides = parsed.model_profile_overrides;
1225
- if (!overrides || typeof overrides !== 'object') return;
1226
- for (const [overrideRuntime, tierMap] of Object.entries(overrides)) {
1227
- if (!KNOWN_RUNTIMES.has(overrideRuntime)) {
1228
- const key = `${configLabel}::override-runtime::${overrideRuntime}`;
1229
- if (!_warnedConfigKeys.has(key)) {
1230
- _warnedConfigKeys.add(key);
1231
- try {
1232
- process.stderr.write(
1233
- `gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
1234
- `unknown runtime "${overrideRuntime}". Known runtimes: ` +
1235
- `${[...KNOWN_RUNTIMES].sort().join(', ')}. (#2517)\n`
1236
- );
1237
- } catch { /* ok */ }
1238
- }
1239
- }
1240
- if (!tierMap || typeof tierMap !== 'object') continue;
1241
- for (const tierName of Object.keys(tierMap)) {
1242
- if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
1243
- const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
1392
+ if (overrides && typeof overrides === 'object') {
1393
+ for (const [overrideRuntime, tierMap] of Object.entries(overrides)) {
1394
+ if (!KNOWN_RUNTIMES.has(overrideRuntime)) {
1395
+ const key = `${configLabel}::override-runtime::${overrideRuntime}`;
1244
1396
  if (!_warnedConfigKeys.has(key)) {
1245
1397
  _warnedConfigKeys.add(key);
1246
1398
  try {
1247
1399
  process.stderr.write(
1248
- `gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
1249
- `uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
1400
+ `gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
1401
+ `unknown runtime "${overrideRuntime}". Known runtimes: ` +
1402
+ `${[...KNOWN_RUNTIMES].sort().join(', ')}. (#2517)\n`
1250
1403
  );
1251
1404
  } catch { /* ok */ }
1252
1405
  }
1253
1406
  }
1407
+ if (!tierMap || typeof tierMap !== 'object') continue;
1408
+ for (const tierName of Object.keys(tierMap)) {
1409
+ if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
1410
+ const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
1411
+ if (!_warnedConfigKeys.has(key)) {
1412
+ _warnedConfigKeys.add(key);
1413
+ try {
1414
+ process.stderr.write(
1415
+ `gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
1416
+ `uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
1417
+ );
1418
+ } catch { /* ok */ }
1419
+ }
1420
+ }
1421
+ }
1422
+ }
1423
+ }
1424
+
1425
+ const policy = parsed.model_policy;
1426
+ if (policy && typeof policy === 'object') {
1427
+ const provider = policy.provider;
1428
+ // 'generic' and 'custom' are sentinel values for the manual model-ID path — not catalog entries.
1429
+ const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']);
1430
+ if (provider && typeof provider === 'string' &&
1431
+ !KNOWN_PROVIDERS.has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) {
1432
+ const pkey = `${configLabel}::model_policy::provider::${provider}`;
1433
+ if (!_warnedConfigKeys.has(pkey)) {
1434
+ _warnedConfigKeys.add(pkey);
1435
+ try {
1436
+ process.stderr.write(
1437
+ `gsd: warning — model_policy.provider has unknown value "${provider}". ` +
1438
+ `Known providers: ${[...KNOWN_PROVIDERS].sort().join(', ')}. ` +
1439
+ `For manual model IDs use provider="custom". (#49)\n`
1440
+ );
1441
+ } catch { /* ok */ }
1442
+ }
1443
+ }
1444
+
1445
+ const rtOverrides = policy.runtime_tiers;
1446
+ if (rtOverrides && typeof rtOverrides === 'object') {
1447
+ for (const [pruntime, tierMap] of Object.entries(rtOverrides)) {
1448
+ if (!KNOWN_RUNTIMES.has(pruntime)) {
1449
+ const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`;
1450
+ if (!_warnedConfigKeys.has(key)) {
1451
+ _warnedConfigKeys.add(key);
1452
+ try {
1453
+ process.stderr.write(
1454
+ `gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` +
1455
+ `unknown runtime "${pruntime}". Known runtimes: ` +
1456
+ `${[...KNOWN_RUNTIMES].sort().join(', ')}. (#49)\n`
1457
+ );
1458
+ } catch { /* ok */ }
1459
+ }
1460
+ }
1461
+ if (!tierMap || typeof tierMap !== 'object') continue;
1462
+ for (const tierName of Object.keys(tierMap)) {
1463
+ if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
1464
+ const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`;
1465
+ if (!_warnedConfigKeys.has(key)) {
1466
+ _warnedConfigKeys.add(key);
1467
+ try {
1468
+ process.stderr.write(
1469
+ `gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` +
1470
+ `uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n`
1471
+ );
1472
+ } catch { /* ok */ }
1473
+ }
1474
+ }
1475
+ }
1476
+ }
1254
1477
  }
1255
1478
  }
1256
1479
  }
@@ -1314,6 +1537,85 @@ function _resolveRuntimeTier(config, tier) {
1314
1537
  });
1315
1538
  }
1316
1539
 
1540
+ /**
1541
+ * #49 — Provider-neutral model policy preset resolution.
1542
+ *
1543
+ * Signature: resolveModelPolicy(policy, tier) → string | null
1544
+ *
1545
+ * Resolves a model ID string from a model_policy object. Two resolution sub-paths:
1546
+ *
1547
+ * A. runtime_tiers sub-block (explicit per-runtime entries):
1548
+ * policy.runtime_tiers[policy.runtime][tier] → model string
1549
+ * The active runtime is read from policy.runtime (the caller merges
1550
+ * config.runtime into the policy object before calling).
1551
+ *
1552
+ * B. provider + budget preset lookup (catalog-backed):
1553
+ * PROVIDER_PRESETS[provider][tier][budget] → model string
1554
+ * budget defaults to 'medium' if absent from policy.
1555
+ *
1556
+ * Returns a string model ID on a hit, null on miss.
1557
+ * Never throws — unknown provider/tier/budget degrades gracefully to null
1558
+ * so the caller falls through to model_profile_overrides.
1559
+ *
1560
+ * Precedence: runtime_tiers wins over provider presets when both are present
1561
+ * for the same runtime+tier combination.
1562
+ */
1563
+ function resolveModelPolicy(policy, tier) {
1564
+ if (!policy || typeof policy !== 'object') return null;
1565
+ if (!tier) return null;
1566
+
1567
+ // Sub-path A: explicit runtime_tiers override (highest precedence within policy).
1568
+ // The active runtime is read from policy.runtime — the caller is responsible for
1569
+ // merging config.runtime into the policy object (see step 2.5 in resolveModelInternal).
1570
+ const runtime = policy.runtime;
1571
+ const rtOverrides = policy.runtime_tiers;
1572
+ if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
1573
+ if (Object.hasOwn(rtOverrides, runtime)) {
1574
+ const runtimeEntry = rtOverrides[runtime];
1575
+ if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
1576
+ const raw = runtimeEntry[tier];
1577
+ if (raw != null) {
1578
+ const entry = typeof raw === 'string' ? { model: raw } : raw;
1579
+ if (entry && entry.model) return entry.model;
1580
+ }
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ // Sub-path B: catalog-backed provider preset.
1586
+ const provider = policy.provider;
1587
+ if (!provider || typeof provider !== 'string') return null;
1588
+
1589
+ // Sub-path B1: generic provider — model IDs supplied directly via model_policy.high/medium/low.
1590
+ // 'generic' and 'custom' are sentinel values meaning "user-supplied IDs, no catalog lookup".
1591
+ // The tier-to-key mapping: 'opus' → high, 'sonnet' → medium, 'haiku' → low.
1592
+ if (provider === 'generic' || provider === 'custom') {
1593
+ const TIER_TO_POLICY_KEY = { opus: 'high', sonnet: 'medium', haiku: 'low' };
1594
+ const policyKey = TIER_TO_POLICY_KEY[tier];
1595
+ if (!policyKey) return null;
1596
+ const v = policy[policyKey];
1597
+ return (v && typeof v === 'string') ? v : null;
1598
+ }
1599
+
1600
+ // Object.hasOwn guards prevent __proto__ / constructor key escalation from
1601
+ // user-controlled policy.provider / policy.budget reaching inherited slots.
1602
+ if (!Object.hasOwn(PROVIDER_PRESETS, provider)) return null;
1603
+ const presetForProvider = PROVIDER_PRESETS[provider];
1604
+ if (!presetForProvider || typeof presetForProvider !== 'object') return null;
1605
+
1606
+ if (!Object.hasOwn(presetForProvider, tier)) return null;
1607
+ const tierPresets = presetForProvider[tier];
1608
+ if (!tierPresets || typeof tierPresets !== 'object') return null;
1609
+
1610
+ // Budget defaults to 'medium' — mirrors the existing 'balanced' default bias.
1611
+ const budget = (policy.budget && typeof policy.budget === 'string') ? policy.budget : 'medium';
1612
+ if (!Object.hasOwn(tierPresets, budget)) return null;
1613
+ const budgetEntry = tierPresets[budget];
1614
+ if (!budgetEntry || !budgetEntry.model) return null; // Missing or null budget slot.
1615
+
1616
+ return budgetEntry.model;
1617
+ }
1618
+
1317
1619
  function resolveModelInternal(cwd, agentType) {
1318
1620
  const config = loadConfig(cwd);
1319
1621
 
@@ -1356,6 +1658,22 @@ function resolveModelInternal(cwd, agentType) {
1356
1658
  ? 'inherit'
1357
1659
  : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
1358
1660
 
1661
+ // 2.5. model_policy preset (#49) — higher precedence than model_profile_overrides.
1662
+ // Fires when config.model_policy is set AND runtime is a non-Claude runtime with
1663
+ // tier !== 'inherit'. The provider preset catalog (Sub-path B) and any
1664
+ // runtime_tiers overrides (Sub-path A) are checked here before falling through to
1665
+ // the existing model_profile_overrides chain in step 3.
1666
+ if (config.runtime && config.runtime !== 'claude' && tier && tier !== 'inherit') {
1667
+ // Merge config.runtime into the policy object so resolveModelPolicy can use
1668
+ // it for Sub-path A (runtime_tiers) lookup without needing a separate argument.
1669
+ const mergedPolicy = config.model_policy
1670
+ ? { ...config.model_policy, runtime: config.runtime }
1671
+ : null;
1672
+ const policyModel = resolveModelPolicy(mergedPolicy, tier);
1673
+ if (policyModel) return policyModel;
1674
+ // No policy hit → fall through to step 3 (model_profile_overrides).
1675
+ }
1676
+
1359
1677
  // 3. Runtime-aware resolution (#2517) — only when `runtime` is explicitly set
1360
1678
  // to a non-Claude runtime. `runtime: "claude"` is the implicit default and is
1361
1679
  // treated as a no-op here so it does not silently override `resolve_model_ids:
@@ -1437,6 +1755,14 @@ function resolveModelForTier(cwd, agentType, attempt) {
1437
1755
  const override = config.model_overrides?.[agentType];
1438
1756
  if (override) return override;
1439
1757
 
1758
+ // model_policy beats dynamic_routing (#49 Codex adversarial HIGH finding).
1759
+ // Delegate to resolveModelInternal which handles step 2.5 (policy) correctly
1760
+ // and falls through to runtimeTierDefaults on a miss. Gate on non-Claude
1761
+ // runtime to match the same condition in resolveModelInternal step 2.5.
1762
+ if (config.model_policy && config.runtime && config.runtime !== 'claude') {
1763
+ return resolveModelInternal(cwd, agentType);
1764
+ }
1765
+
1440
1766
  const dr = config.dynamic_routing;
1441
1767
  // Disabled / missing / non-object → fall back to the existing resolver.
1442
1768
  if (!dr || typeof dr !== 'object' || dr.enabled !== true) {
@@ -1544,6 +1870,12 @@ function resolveEffortInternal(cwd, agentType, opts) {
1544
1870
  const v = ao[agentType];
1545
1871
  if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
1546
1872
  }
1873
+ } else {
1874
+ const mao = CANONICAL_CONFIG_DEFAULTS.effort && CANONICAL_CONFIG_DEFAULTS.effort.agent_overrides;
1875
+ if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
1876
+ const v = mao[agentType];
1877
+ if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
1878
+ }
1547
1879
  }
1548
1880
 
1549
1881
  // Step 3: routing_tier_defaults by agent's default tier.
@@ -1575,6 +1907,9 @@ function resolveEffortInternal(cwd, agentType, opts) {
1575
1907
  if (effortCfg) {
1576
1908
  const d = effortCfg.default;
1577
1909
  if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
1910
+ } else {
1911
+ const d = CANONICAL_CONFIG_DEFAULTS.effort && CANONICAL_CONFIG_DEFAULTS.effort.default;
1912
+ if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
1578
1913
  }
1579
1914
 
1580
1915
  // Step 5: hardcoded default
@@ -1888,16 +2223,56 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
1888
2223
  if (roadmapContent === null) throw new Error('missing');
1889
2224
  let roadmap = extractCurrentMilestone(roadmapContent, cwd);
1890
2225
 
2226
+ // Emit a deprecation warning for "free-form" roadmaps: those that have
2227
+ // Phase headings but no versioned milestone sections (## vX.Y / ## Roadmap vX.Y).
2228
+ // This is non-fatal — the roadmap continues to work via legacy behaviour.
2229
+ const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
2230
+ const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
2231
+ if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
2232
+ console.warn(
2233
+ '[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
2234
+ 'Set phase_id_convention in config.json to suppress this warning.'
2235
+ );
2236
+ }
2237
+
1891
2238
  if (versionOverride) {
1892
2239
  const escapedVersion = escapeRegex(versionOverride);
1893
- const sectionPattern = new RegExp(`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi');
1894
- const sectionMatch = roadmapContent.match(sectionPattern);
2240
+ // Exclude phase headings (e.g. "### Phase 1: v1.3 migration") that mention
2241
+ // the version but are not milestone-level headings. Same guard as sectionPattern
2242
+ // in extractCurrentMilestone().
2243
+ const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
2244
+ let sectionMatch = roadmapContent.match(sectionPattern);
2245
+
2246
+ // If no heading match, check whether the version lives in a <summary> tag
2247
+ // (milestone wrapped in <details open>). If so, extract that block's heading
2248
+ // so the phase-filter can scope correctly.
1895
2249
  if (!sectionMatch) {
1896
- // Only treat this as an error case when the roadmap is milestone-versioned.
2250
+ const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
2251
+ const summaryHit = roadmapContent.match(summaryPat);
2252
+ if (summaryHit) {
2253
+ // Treat the <summary> content as a synthetic heading match so the code
2254
+ // below can extract the section boundaries from it.
2255
+ // We fabricate a match object pointing at the <details> block start.
2256
+ const beforeSummary = roadmapContent.slice(0, summaryHit.index);
2257
+ const detailsIdx = beforeSummary.lastIndexOf('<details');
2258
+ if (detailsIdx !== -1) {
2259
+ // Use extractCurrentMilestone (already called above) which now handles
2260
+ // <summary> correctly — roadmap is already scoped. Skip the override
2261
+ // re-scoping: roadmap is already the current milestone content.
2262
+ // Do nothing: keep roadmap as-is from extractCurrentMilestone.
2263
+ sectionMatch = null; // fall through without setting missingExplicitVersion
2264
+ }
2265
+ }
2266
+ }
2267
+
2268
+ if (!sectionMatch) {
2269
+ // Only treat this as an error case when the roadmap is milestone-versioned
2270
+ // AND the version is genuinely absent (not in a <summary> tag handled above).
1897
2271
  // Older/flat roadmap formats without vX.Y milestone headings should keep
1898
2272
  // legacy pass-through behavior for milestone.complete.
1899
- const hasVersionedMilestones = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
1900
- if (hasVersionedMilestones) {
2273
+ const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
2274
+ const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
2275
+ if (hasVersionedMilestones && !versionInSummary) {
1901
2276
  roadmap = '';
1902
2277
  missingExplicitVersion = true;
1903
2278
  }
@@ -1936,8 +2311,9 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
1936
2311
  }
1937
2312
  }
1938
2313
 
1939
- // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
1940
- const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
2314
+ // Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:).
2315
+ // Also tolerate optional [bracket-token] scope prefix (e.g., ### [GSD] Phase 2-01:).
2316
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
1941
2317
  let m;
1942
2318
  while ((m = phasePattern.exec(roadmap)) !== null) {
1943
2319
  milestonePhaseNums.add(m[1]);
@@ -1952,13 +2328,25 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
1952
2328
  }
1953
2329
 
1954
2330
  const normalized = new Set(
1955
- [...milestonePhaseNums].map(n => (n.replace(/^0+(?=\d)/, '') || '0').toLowerCase())
2331
+ [...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
1956
2332
  );
1957
2333
 
2334
+ function normalizePhaseIdSegments(id) {
2335
+ return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
2336
+ }
2337
+
2338
+ // Only capture hyphenated M-NN segments when the ROADMAP itself uses that convention.
2339
+ // Legacy ROADMAPs with phase IDs like '1' must use the simple first-segment regex or
2340
+ // a legacy dir like '01-02-setup' (phase 1, slug '02-setup') would match as '1-02'.
2341
+ const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
2342
+ const numericRe = roadmapUsesHyphenedIds
2343
+ ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
2344
+ : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
2345
+
1958
2346
  function isDirInMilestone(dirName) {
1959
2347
  // Try numeric match first
1960
- const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1961
- if (m && normalized.has(m[1].toLowerCase())) return true;
2348
+ const m = dirName.match(numericRe);
2349
+ if (m && normalized.has(normalizePhaseIdSegments(m[1]).toLowerCase())) return true;
1962
2350
  // Try custom ID match (e.g. PROJ-42-description → PROJ-42)
1963
2351
  const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1964
2352
  if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
@@ -1971,8 +2359,8 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
1971
2359
  // milestone is keyed on the bare numeric form.
1972
2360
  const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
1973
2361
  if (stripped !== dirName) {
1974
- const sm = stripped.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
1975
- if (sm && normalized.has(sm[1].toLowerCase())) return true;
2362
+ const sm = stripped.match(numericRe);
2363
+ if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
1976
2364
  }
1977
2365
  return false;
1978
2366
  }
@@ -2061,6 +2449,8 @@ module.exports = {
2061
2449
  isGitIgnored,
2062
2450
  escapeRegex,
2063
2451
  normalizePhaseName,
2452
+ getMilestoneFromPhaseId,
2453
+ getPhaseDirFromPhaseId,
2064
2454
  phaseMarkdownRegexSource,
2065
2455
  phaseMarkdownRegexSourceExact,
2066
2456
  comparePhaseNum,
@@ -2084,6 +2474,8 @@ module.exports = {
2084
2474
  KNOWN_RUNTIMES,
2085
2475
  RUNTIME_OVERRIDE_TIERS,
2086
2476
  resolveTierEntry,
2477
+ resolveModelPolicy,
2478
+ KNOWN_PROVIDERS,
2087
2479
  _resetRuntimeWarningCacheForTests,
2088
2480
  pathExistsInternal,
2089
2481
  gitWorktreeInfoInternal,