@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.
- package/README.ja-JP.md +6 -5
- package/README.ko-KR.md +6 -5
- package/README.md +10 -45
- package/README.pt-BR.md +5 -4
- package/README.zh-CN.md +6 -5
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-doc-writer.md +7 -6
- package/agents/gsd-domain-researcher.md +1 -1
- package/agents/gsd-eval-planner.md +1 -1
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +1 -1
- package/assets/gsd-logo-2000-transparent.png +0 -0
- package/assets/gsd-logo-2000-transparent.svg +17 -0
- package/assets/gsd-logo-2000.png +0 -0
- package/assets/gsd-logo-2000.svg +21 -0
- package/assets/terminal.svg +68 -0
- package/bin/install.js +86 -44
- package/commands/gsd/review.md +2 -1
- package/get-shit-done/bin/gsd-tools.cjs +37 -1
- package/get-shit-done/bin/lib/command-aliases.cjs +16 -0
- package/get-shit-done/bin/lib/commands.cjs +109 -5
- package/get-shit-done/bin/lib/config-types.cjs +19 -0
- package/get-shit-done/bin/lib/core.cjs +455 -63
- package/get-shit-done/bin/lib/gap-checker.cjs +56 -7
- package/get-shit-done/bin/lib/installer-migration-report.cjs +1 -0
- package/get-shit-done/bin/lib/milestone.cjs +59 -1
- package/get-shit-done/bin/lib/model-catalog.cjs +17 -0
- package/get-shit-done/bin/lib/phase-lifecycle.cjs +0 -1
- package/get-shit-done/bin/lib/review-reviewer-selection.cjs +1 -0
- package/get-shit-done/bin/lib/roadmap-command-router.cjs +134 -0
- package/get-shit-done/bin/lib/roadmap-upgrade.cjs +569 -0
- package/get-shit-done/bin/lib/roadmap.cjs +6 -5
- package/get-shit-done/bin/lib/semver-compare.cjs +34 -27
- package/get-shit-done/bin/lib/shell-command-projection.cjs +35 -0
- package/get-shit-done/bin/lib/state-document.cjs +0 -4
- package/get-shit-done/bin/lib/state.cjs +41 -6
- package/get-shit-done/bin/lib/ui-safety-gate.cjs +111 -0
- package/get-shit-done/bin/lib/validate.cjs +43 -17
- package/get-shit-done/bin/lib/verify.cjs +107 -4
- package/get-shit-done/bin/shared/config-defaults.manifest.json +1 -0
- package/get-shit-done/bin/shared/config-schema.manifest.json +12 -1
- package/get-shit-done/bin/shared/model-catalog.json +27 -0
- package/get-shit-done/references/checkpoints.md +1 -1
- package/get-shit-done/references/planner-human-verify-mode.md +1 -1
- package/get-shit-done/references/ui-brand.md +4 -2
- package/get-shit-done/workflows/audit-fix.md +1 -1
- package/get-shit-done/workflows/audit-milestone.md +2 -0
- package/get-shit-done/workflows/autonomous.md +7 -5
- package/get-shit-done/workflows/cleanup.md +43 -3
- package/get-shit-done/workflows/code-review-fix.md +3 -3
- package/get-shit-done/workflows/code-review.md +2 -0
- package/get-shit-done/workflows/debug.md +6 -1
- package/get-shit-done/workflows/diagnose-issues.md +2 -0
- package/get-shit-done/workflows/discuss-phase/modes/advisor.md +1 -1
- package/get-shit-done/workflows/discuss-phase-assumptions.md +2 -2
- package/get-shit-done/workflows/docs-update.md +18 -4
- package/get-shit-done/workflows/eval-review.md +1 -1
- package/get-shit-done/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/get-shit-done/workflows/execute-phase.md +21 -11
- package/get-shit-done/workflows/execute-plan.md +1 -1
- package/get-shit-done/workflows/explore.md +2 -0
- package/get-shit-done/workflows/help/modes/default.md +3 -3
- package/get-shit-done/workflows/help/modes/full.md +5 -5
- package/get-shit-done/workflows/import.md +2 -0
- package/get-shit-done/workflows/ingest-docs.md +2 -2
- package/get-shit-done/workflows/manager.md +2 -2
- package/get-shit-done/workflows/map-codebase.md +2 -0
- package/get-shit-done/workflows/new-milestone.md +2 -2
- package/get-shit-done/workflows/new-project.md +2 -2
- package/get-shit-done/workflows/plan-phase.md +16 -15
- package/get-shit-done/workflows/plan-review-convergence.md +3 -3
- package/get-shit-done/workflows/quick.md +5 -3
- package/get-shit-done/workflows/review.md +109 -1
- package/get-shit-done/workflows/scan.md +2 -0
- package/get-shit-done/workflows/secure-phase.md +2 -0
- package/get-shit-done/workflows/settings-advanced.md +195 -5
- package/get-shit-done/workflows/settings.md +15 -1
- package/get-shit-done/workflows/ui-phase.md +2 -2
- package/get-shit-done/workflows/ui-review.md +1 -1
- package/get-shit-done/workflows/validate-phase.md +2 -0
- package/get-shit-done/workflows/verify-work.md +2 -2
- package/hooks/dist/gsd-worktree-path-guard.js +169 -0
- package/hooks/gsd-worktree-path-guard.js +169 -0
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +9 -6
- package/scripts/build-hooks.js +1 -0
- package/scripts/ci-test-scope.cjs +10 -0
- package/scripts/run-tests.cjs +25 -1
- package/scripts/lint-shared-module-handsync.cjs +0 -388
- 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
|
-
*
|
|
765
|
-
*
|
|
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
|
-
//
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
const phaseName =
|
|
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(
|
|
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
|
|
1119
|
+
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
|
990
1120
|
'gmi'
|
|
991
1121
|
);
|
|
992
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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}
|
|
1249
|
-
`
|
|
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
|
-
|
|
1894
|
-
|
|
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
|
-
|
|
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
|
|
1900
|
-
|
|
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
|
-
|
|
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 =>
|
|
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(
|
|
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(
|
|
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,
|