@opengsd/gsd-core 1.7.0 → 1.8.0
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +14 -0
- package/README.md +2 -0
- package/agents/gsd-debug-session-manager.md +42 -4
- package/agents/gsd-debugger.md +87 -29
- package/agents/gsd-executor.md +29 -2
- package/agents/gsd-planner.md +29 -36
- package/agents/gsd-verifier.md +2 -2
- package/bin/install.js +1152 -80
- package/commands/gsd/ai-integration-phase.md +1 -1
- package/commands/gsd/mempalace-capture.md +9 -5
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +5 -3
- package/commands/gsd/plan-review-convergence.md +3 -2
- package/gsd-core/bin/gsd-tools.cjs +1878 -2507
- package/gsd-core/bin/lib/adapter-imperative.cjs +8 -1
- package/gsd-core/bin/lib/agent-command-router.cjs +20 -5
- package/gsd-core/bin/lib/api-coverage.cjs +338 -45
- package/gsd-core/bin/lib/broken-windows.cjs +716 -0
- package/gsd-core/bin/lib/capability-command-router.cjs +733 -0
- package/gsd-core/bin/lib/capability-registry.cjs +155 -86
- package/gsd-core/bin/lib/capability-writer.cjs +6 -1
- package/gsd-core/bin/lib/check-command-router.cjs +128 -25
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +115 -27
- package/gsd-core/bin/lib/claude-orchestration.cjs +84 -9
- package/gsd-core/bin/lib/command-aliases.cjs +14 -0
- package/gsd-core/bin/lib/commands.cjs +81 -4
- package/gsd-core/bin/lib/config-loader.cjs +14 -2
- package/gsd-core/bin/lib/config.cjs +69 -18
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/decisions.cjs +32 -8
- package/gsd-core/bin/lib/docs.cjs +6 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +14 -2
- package/gsd-core/bin/lib/gap-checker.cjs +17 -2
- package/gsd-core/bin/lib/init.cjs +111 -47
- package/gsd-core/bin/lib/install-engine.cjs +298 -23
- package/gsd-core/bin/lib/install-profiles.cjs +239 -1
- package/gsd-core/bin/lib/installer-migrations/005-opencode-baseline-commands-dir.cjs +146 -0
- package/gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs +91 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +44 -5
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +107 -0
- package/gsd-core/bin/lib/milestone.cjs +246 -12
- package/gsd-core/bin/lib/model-catalog.cjs +19 -4
- package/gsd-core/bin/lib/model-resolver.cjs +189 -7
- package/gsd-core/bin/lib/onboard-projection.cjs +11 -8
- package/gsd-core/bin/lib/phase-id.cjs +26 -4
- package/gsd-core/bin/lib/phase.cjs +201 -12
- package/gsd-core/bin/lib/plan-scan.cjs +70 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +7 -4
- package/gsd-core/bin/lib/roadmap.cjs +13 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +7 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +22 -8
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +16 -0
- package/gsd-core/bin/lib/smart-entry.cjs +69 -4
- package/gsd-core/bin/lib/state-document.cjs +7 -4
- package/gsd-core/bin/lib/state-transition.cjs +22 -1
- package/gsd-core/bin/lib/state.cjs +65 -11
- package/gsd-core/bin/lib/surface.cjs +51 -9
- package/gsd-core/bin/lib/uat.cjs +420 -5
- package/gsd-core/bin/lib/validate.cjs +12 -8
- package/gsd-core/bin/lib/verification.cjs +112 -17
- package/gsd-core/bin/lib/verify.cjs +220 -22
- package/gsd-core/bin/shared/config-schema.manifest.json +3 -2
- package/gsd-core/references/api-coverage.md +37 -7
- package/gsd-core/references/checkpoints.md +1 -1
- package/gsd-core/references/common-bug-patterns.md +13 -0
- package/gsd-core/references/debugger-bug-taxonomy.md +111 -0
- package/gsd-core/references/debugger-fix-acceptance.md +157 -0
- package/gsd-core/references/debugger-philosophy.md +1 -0
- package/gsd-core/references/debugger-prevention.md +98 -0
- package/gsd-core/references/debugger-rca-branching.md +98 -0
- package/gsd-core/references/debugger-repro-hardening.md +130 -0
- package/gsd-core/references/debugger-sbfl.md +110 -0
- package/gsd-core/references/debugger-semantic-recall.md +81 -0
- package/gsd-core/references/execute-phase-quota-recovery.md +55 -0
- package/gsd-core/references/execute-phase-requirement-revert.md +8 -0
- package/gsd-core/references/execute-phase-response-language.md +7 -0
- package/gsd-core/references/planner-antipatterns.md +6 -0
- package/gsd-core/references/planner-mvp-mode.md +12 -13
- package/gsd-core/references/planner-preconditions.md +156 -0
- package/gsd-core/references/planner-reversibility.md +132 -0
- package/gsd-core/references/reviewer-instances.md +9 -7
- package/gsd-core/references/skeleton-template.md +1 -1
- package/gsd-core/references/thinking-models-planning.md +3 -1
- package/gsd-core/templates/DEBUG.md +5 -3
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +3 -1
- package/gsd-core/workflows/add-todo.md +32 -1
- package/gsd-core/workflows/ai-integration-phase.md +4 -2
- package/gsd-core/workflows/audit-fix.md +2 -2
- package/gsd-core/workflows/check-todos.md +3 -1
- package/gsd-core/workflows/cleanup.md +7 -1
- package/gsd-core/workflows/code-review.md +17 -5
- package/gsd-core/workflows/complete-milestone.md +3 -0
- package/gsd-core/workflows/debug.md +25 -5
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discovery-phase.md +7 -0
- package/gsd-core/workflows/discuss-phase/templates/context.md +16 -2
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -0
- package/gsd-core/workflows/do.md +7 -1
- package/gsd-core/workflows/docs-update.md +1 -0
- package/gsd-core/workflows/eval-review.md +3 -0
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +4 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -2
- package/gsd-core/workflows/execute-phase.md +25 -34
- package/gsd-core/workflows/execute-plan.md +15 -4
- package/gsd-core/workflows/graduation.md +3 -0
- package/gsd-core/workflows/health.md +7 -1
- package/gsd-core/workflows/help/modes/full.md +6 -2
- package/gsd-core/workflows/import.md +8 -2
- package/gsd-core/workflows/inbox.md +7 -0
- package/gsd-core/workflows/ingest-docs.md +15 -10
- package/gsd-core/workflows/manager.md +3 -1
- package/gsd-core/workflows/map-codebase.md +4 -4
- package/gsd-core/workflows/mvp-phase.md +3 -0
- package/gsd-core/workflows/new-milestone.md +69 -21
- package/gsd-core/workflows/new-project.md +17 -15
- package/gsd-core/workflows/new-workspace.md +3 -1
- package/gsd-core/workflows/onboard.md +3 -0
- package/gsd-core/workflows/plan-phase.md +14 -5
- package/gsd-core/workflows/plan-review-convergence.md +48 -3
- package/gsd-core/workflows/plant-seed.md +3 -0
- package/gsd-core/workflows/profile-user.md +7 -1
- package/gsd-core/workflows/progress.md +31 -3
- package/gsd-core/workflows/quick.md +19 -7
- package/gsd-core/workflows/remove-workspace.md +3 -0
- package/gsd-core/workflows/review.md +89 -73
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -0
- package/gsd-core/workflows/settings-integrations.md +3 -0
- package/gsd-core/workflows/settings.md +3 -0
- package/gsd-core/workflows/ship.md +50 -3
- package/gsd-core/workflows/sketch.md +3 -0
- package/gsd-core/workflows/smart-entry.md +3 -0
- package/gsd-core/workflows/spike.md +7 -1
- package/gsd-core/workflows/ui-phase.md +3 -1
- package/gsd-core/workflows/ui-review.md +3 -0
- package/gsd-core/workflows/undo.md +7 -0
- package/gsd-core/workflows/update.md +2 -0
- package/gsd-core/workflows/validate-phase.md +3 -0
- package/gsd-core/workflows/verify-phase.md +2 -2
- package/gsd-core/workflows/verify-work.md +7 -3
- package/hooks/dist/gsd-context-monitor.js +27 -9
- package/hooks/dist/gsd-statusline.js +88 -3
- package/hooks/gsd-context-monitor.js +27 -9
- package/hooks/gsd-statusline.js +88 -3
- package/package.json +6 -4
- package/pi/gsd.cjs +8 -2
- package/scripts/changeset/lint.cjs +1 -0
- package/scripts/changeset/parse.cjs +26 -0
- package/scripts/check-glossary-refs.cjs +220 -0
- package/scripts/ci-rebase-check.cjs +48 -4
- package/scripts/gen-adr-index.cjs +526 -0
- package/scripts/gen-test-timings.cjs +201 -0
- package/scripts/lint-portable-timeout.cjs +140 -0
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/release-tarball-smoke.cjs +18 -11
- package/scripts/run-tests.cjs +420 -58
- package/skills/gsd-ai-integration-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +9 -5
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +5 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +3 -2
- package/vscode/package.json +1 -1
|
@@ -11,12 +11,17 @@
|
|
|
11
11
|
* epic #1267; callers import resolvers from model-resolver.cjs directly.
|
|
12
12
|
*
|
|
13
13
|
* Dependencies (leaf modules only):
|
|
14
|
-
* - node:fs / node:path (
|
|
14
|
+
* - node:fs / node:path (read the per-install .gsd-runtime marker + project config for the #2297 omit gate)
|
|
15
|
+
* - ./runtime-name-policy.cjs (resolveRuntimeNameFromCandidates — canonicalize the active runtime)
|
|
16
|
+
* - ./planning-workspace.cjs (planningDir — workstream/project-aware project-config path)
|
|
15
17
|
* - ./config-loader.cjs (loadConfig)
|
|
16
18
|
* - ./configuration.cjs (CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS)
|
|
17
19
|
* - ./model-profiles.cjs (MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier)
|
|
18
|
-
* - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS)
|
|
20
|
+
* - ./model-catalog.cjs (MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, PROVIDER_PRESETS, VALID_TIERS)
|
|
19
21
|
*/
|
|
22
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
23
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
24
|
+
};
|
|
20
25
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
26
|
const configLoaderModule = require("./config-loader.cjs");
|
|
22
27
|
const { loadConfig } = configLoaderModule;
|
|
@@ -26,6 +31,92 @@ const configuration_cjs_1 = require("./configuration.cjs");
|
|
|
26
31
|
const modelProfiles = require("./model-profiles.cjs");
|
|
27
32
|
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
|
28
33
|
const model_catalog_cjs_1 = require("./model-catalog.cjs");
|
|
34
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
35
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
36
|
+
const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const planningWorkspaceMod = require("./planning-workspace.cjs");
|
|
39
|
+
const { planningDir } = planningWorkspaceMod;
|
|
40
|
+
// ─── #2297: per-install runtime identity for the resolve_model_ids:"omit" gate ─
|
|
41
|
+
//
|
|
42
|
+
// The installer writes `resolve_model_ids:"omit"` into the SHARED
|
|
43
|
+
// ~/.gsd/defaults.json for every runtime that lacks native model aliases (#1156).
|
|
44
|
+
// Because that file is machine-wide, a non-Claude install would otherwise poison
|
|
45
|
+
// a Claude no-project resolution into returning '' — silently defeating Claude's
|
|
46
|
+
// adaptive tier aliases. The "omit" must therefore apply only when a runtime that
|
|
47
|
+
// genuinely lacks native aliases is the one resolving.
|
|
48
|
+
//
|
|
49
|
+
// In a no-project session there is no `.planning/config.json` (so config.runtime
|
|
50
|
+
// is null) and GSD_RUNTIME is not exported by gsd-core, so the only reliable
|
|
51
|
+
// current-runtime signal is the per-install marker the installer co-locates next
|
|
52
|
+
// to VERSION at <install>/gsd-core/.gsd-runtime (this file's dir is
|
|
53
|
+
// <install>/gsd-core/bin/lib). Precedence for the gate: project config.runtime →
|
|
54
|
+
// GSD_RUNTIME env (manual/CI override + test seam) → install marker → 'claude'.
|
|
55
|
+
//
|
|
56
|
+
// `claude` is currently the ONLY runtime with nativeModelAliases:true; a
|
|
57
|
+
// registry-parity test guards this set so a future alias-capable runtime fails
|
|
58
|
+
// loudly here instead of silently omitting.
|
|
59
|
+
const RUNTIMES_WITH_NATIVE_ALIASES = new Set(['claude']);
|
|
60
|
+
let _installMarkerCache;
|
|
61
|
+
function readInstallRuntimeMarker() {
|
|
62
|
+
if (_installMarkerCache !== undefined)
|
|
63
|
+
return _installMarkerCache;
|
|
64
|
+
try {
|
|
65
|
+
const markerPath = node_path_1.default.join(__dirname, '..', '..', '.gsd-runtime');
|
|
66
|
+
const raw = node_fs_1.default.readFileSync(markerPath, 'utf8').trim();
|
|
67
|
+
_installMarkerCache = raw || null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// No marker: dev/source tree, or an install predating #2297. Fall through to
|
|
71
|
+
// the 'claude' default (keeps tier aliases — never worse than the bug).
|
|
72
|
+
_installMarkerCache = null;
|
|
73
|
+
}
|
|
74
|
+
return _installMarkerCache;
|
|
75
|
+
}
|
|
76
|
+
// Test seams for the install-marker rung (the dev/source tree has no marker, so
|
|
77
|
+
// the file read always bottoms out at 'claude' — these let tests exercise the
|
|
78
|
+
// third precedence rung and reset the module-level cache between cases).
|
|
79
|
+
function _setInstallRuntimeMarkerForTests(value) {
|
|
80
|
+
_installMarkerCache = value;
|
|
81
|
+
}
|
|
82
|
+
function _resetInstallRuntimeMarkerCacheForTests() {
|
|
83
|
+
_installMarkerCache = undefined;
|
|
84
|
+
}
|
|
85
|
+
// The runtime whose install is actually resolving, canonicalized so an alias or
|
|
86
|
+
// case variant (e.g. "claude-code"/"Claude") cannot defeat the native-alias
|
|
87
|
+
// check below (#2297 review). Precedence mirrors resolveRuntime()
|
|
88
|
+
// (runtime-slash.cts): GSD_RUNTIME env → project config.runtime → per-install
|
|
89
|
+
// .gsd-runtime marker → 'claude'.
|
|
90
|
+
function resolveActiveRuntime(config) {
|
|
91
|
+
return (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime'], readInstallRuntimeMarker()) || 'claude';
|
|
92
|
+
}
|
|
93
|
+
// Did the PROJECT's own config (root `.planning/config.json` or the active
|
|
94
|
+
// workstream/project override) explicitly set resolve_model_ids to "omit"?
|
|
95
|
+
// Project config takes precedence over the shared ~/.gsd/defaults.json (#2297
|
|
96
|
+
// out-of-scope guard + #2517 finding #4): an explicit project "omit" is honored
|
|
97
|
+
// regardless of runtime, whereas an "omit" that came only from the global
|
|
98
|
+
// defaults is ignored by native-alias runtimes. Workstream/project-scope aware
|
|
99
|
+
// via planningDir (mirrors loadConfig's precedence: workstream value wins over
|
|
100
|
+
// root); a plain read avoids loadConfig's normalization side effects.
|
|
101
|
+
function projectExplicitlySetsOmit(cwd) {
|
|
102
|
+
const wsDir = planningDir(cwd);
|
|
103
|
+
const rootDir = node_path_1.default.join(cwd, '.planning');
|
|
104
|
+
const layers = wsDir === rootDir ? [rootDir] : [wsDir, rootDir]; // workstream > root
|
|
105
|
+
for (const dir of layers) {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'config.json'), 'utf8'));
|
|
108
|
+
const value = parsed?.['resolve_model_ids'];
|
|
109
|
+
// First layer that sets the key wins (matches loadConfig's deep-merge
|
|
110
|
+
// precedence). A layer that omits the key falls through to the next.
|
|
111
|
+
if (value !== undefined)
|
|
112
|
+
return value === 'omit';
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Absent/unreadable layer — try the next.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
29
120
|
/**
|
|
30
121
|
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
|
31
122
|
*/
|
|
@@ -209,8 +300,7 @@ function resolveModelInternal(cwd, agentType) {
|
|
|
209
300
|
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
|
|
210
301
|
? configModels[phaseType]
|
|
211
302
|
: undefined;
|
|
212
|
-
const
|
|
213
|
-
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
|
303
|
+
const tier = (phaseTypeTier && model_catalog_cjs_1.VALID_TIERS.has(phaseTypeTier))
|
|
214
304
|
? phaseTypeTier
|
|
215
305
|
: (profile === 'inherit'
|
|
216
306
|
? 'inherit'
|
|
@@ -248,8 +338,18 @@ function resolveModelInternal(cwd, agentType) {
|
|
|
248
338
|
if (entry?.model)
|
|
249
339
|
return entry.model;
|
|
250
340
|
}
|
|
251
|
-
// 4. resolve_model_ids: "omit"
|
|
252
|
-
|
|
341
|
+
// 4. resolve_model_ids: "omit" — runtime-aware (#2297). Honor "omit" when the
|
|
342
|
+
// PROJECT explicitly set it (user intent — project config wins, #2517 finding
|
|
343
|
+
// #4) OR when the active runtime genuinely lacks native model aliases. Only a
|
|
344
|
+
// native-alias runtime (Claude) ignores an "omit" that came solely from the
|
|
345
|
+
// SHARED ~/.gsd/defaults.json — the #2297 poisoning fix — and falls through to
|
|
346
|
+
// its tier aliases below. Active runtime: GSD_RUNTIME → config.runtime → the
|
|
347
|
+
// per-install .gsd-runtime marker → 'claude' (canonicalized).
|
|
348
|
+
// NOTE: a non-Claude runtime that HAS a populated runtime-tier map already
|
|
349
|
+
// returned its own model id at step 3 above, before this gate — for those the
|
|
350
|
+
// explicit-project-omit honoring here is moot (step 3 wins, by #2517 design).
|
|
351
|
+
if (config['resolve_model_ids'] === 'omit'
|
|
352
|
+
&& (projectExplicitlySetsOmit(cwd) || !RUNTIMES_WITH_NATIVE_ALIASES.has(resolveActiveRuntime(config)))) {
|
|
253
353
|
return '';
|
|
254
354
|
}
|
|
255
355
|
// 5. Profile lookup (Claude-native default).
|
|
@@ -262,7 +362,11 @@ function resolveModelInternal(cwd, agentType) {
|
|
|
262
362
|
if (tier === 'inherit')
|
|
263
363
|
return 'inherit';
|
|
264
364
|
const alias = tier;
|
|
265
|
-
|
|
365
|
+
// Only the explicit `true` opt-in materializes full model IDs (#1569). Guard
|
|
366
|
+
// against the loose-truthy check catching a "omit" that a native-alias runtime
|
|
367
|
+
// ignored above (#2297): "omit" must fall through to the tier ALIAS here, not
|
|
368
|
+
// be materialized into a full ID Claude's Agent tool cannot spawn.
|
|
369
|
+
if (config['resolve_model_ids'] === true) {
|
|
266
370
|
return model_catalog_cjs_1.MODEL_ALIAS_MAP[alias] || alias;
|
|
267
371
|
}
|
|
268
372
|
return alias;
|
|
@@ -353,6 +457,80 @@ function resolveModelForTier(cwd, agentType, attempt) {
|
|
|
353
457
|
}
|
|
354
458
|
return alias;
|
|
355
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Keep only usable model ids: non-empty strings. A malformed config can put
|
|
462
|
+
* anything in here (nulls, numbers, blank strings), and a blank model id would
|
|
463
|
+
* resolve to an unusable agent invocation rather than failing visibly. Invalid
|
|
464
|
+
* entries are dropped and the surviving order is preserved, so the ladder stays
|
|
465
|
+
* predictable (ADR 227 — validate shape, not just type).
|
|
466
|
+
*/
|
|
467
|
+
function sanitizeProviderEscalation(raw) {
|
|
468
|
+
if (!Array.isArray(raw))
|
|
469
|
+
return [];
|
|
470
|
+
return raw.filter((entry) => typeof entry === 'string' && entry.trim().length > 0);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* #2296 — Resolve the model for one attempt of the PROVIDER escalation ladder.
|
|
474
|
+
*
|
|
475
|
+
* The tier ladder (`resolveModelForTier`) escalates within one provider's
|
|
476
|
+
* `tier_models`, which does not help when that provider is the thing that is
|
|
477
|
+
* throttled. This walks `dynamic_routing.provider_escalation` instead: an
|
|
478
|
+
* ordered list of alternative model ids, capped by
|
|
479
|
+
* `min(max_escalations, list length)`.
|
|
480
|
+
*
|
|
481
|
+
* `applicable` is the caller's policy decision (only a quota-exceeded
|
|
482
|
+
* classification should consult this ladder). It is a parameter rather than a
|
|
483
|
+
* class check here so this module keeps depending only on leaf modules, per
|
|
484
|
+
* CONTEXT.md's Model Resolution module contract.
|
|
485
|
+
*
|
|
486
|
+
* Attempt 0 — and every non-applicable call — stays on the source model.
|
|
487
|
+
* `exhausted` reports that the ladder is spent so the caller can fail loudly
|
|
488
|
+
* naming every model it tried.
|
|
489
|
+
*/
|
|
490
|
+
function resolveProviderEscalation(cwd, agentType, attempt, applicable) {
|
|
491
|
+
// The model that would be used with no provider escalation at all.
|
|
492
|
+
const from = resolveModelForTier(cwd, agentType, 0);
|
|
493
|
+
const stay = (exhausted = false) => ({
|
|
494
|
+
from,
|
|
495
|
+
to: from,
|
|
496
|
+
escalated: false,
|
|
497
|
+
exhausted,
|
|
498
|
+
attempted: [from],
|
|
499
|
+
index: 0,
|
|
500
|
+
});
|
|
501
|
+
if (!applicable)
|
|
502
|
+
return stay();
|
|
503
|
+
const config = loadConfig(cwd);
|
|
504
|
+
const dr = config['dynamic_routing'];
|
|
505
|
+
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true)
|
|
506
|
+
return stay();
|
|
507
|
+
if (dr['escalate_on_failure'] === false)
|
|
508
|
+
return stay();
|
|
509
|
+
const list = sanitizeProviderEscalation(dr['provider_escalation']);
|
|
510
|
+
if (list.length === 0)
|
|
511
|
+
return stay();
|
|
512
|
+
// Same default and same validity rule as the tier ladder above — a negative or
|
|
513
|
+
// non-integer max_escalations is invalid config, not a request for zero.
|
|
514
|
+
const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
|
|
515
|
+
? dr['max_escalations']
|
|
516
|
+
: 1;
|
|
517
|
+
const cap = Math.min(maxEscalations, list.length);
|
|
518
|
+
// An explicit cap of 0 means the ladder exists but is spent before it starts.
|
|
519
|
+
if (cap === 0)
|
|
520
|
+
return stay(true);
|
|
521
|
+
const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
|
|
522
|
+
if (attemptN === 0)
|
|
523
|
+
return stay();
|
|
524
|
+
const index = Math.min(attemptN, cap);
|
|
525
|
+
return {
|
|
526
|
+
from,
|
|
527
|
+
to: list[index - 1],
|
|
528
|
+
escalated: true,
|
|
529
|
+
exhausted: attemptN > cap,
|
|
530
|
+
attempted: [from, ...list.slice(0, index)],
|
|
531
|
+
index,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
356
534
|
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
|
357
535
|
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
358
536
|
const EFFORT_SET = new Set(VALID_EFFORTS);
|
|
@@ -515,14 +693,18 @@ function resolveEffortForTier(cwd, agentType, attempt) {
|
|
|
515
693
|
}
|
|
516
694
|
module.exports = {
|
|
517
695
|
resolveTierEntry,
|
|
696
|
+
CLAUDE_AGENT_ALIASES,
|
|
518
697
|
resolveModelPolicy,
|
|
519
698
|
resolveModelInternal,
|
|
520
699
|
_resetModelPolicyWarningCacheForTests,
|
|
521
700
|
_resetModelOverrideWarningCacheForTests,
|
|
701
|
+
_setInstallRuntimeMarkerForTests,
|
|
702
|
+
_resetInstallRuntimeMarkerCacheForTests,
|
|
522
703
|
VALID_GRANULARITIES,
|
|
523
704
|
resolveGranularityInternal,
|
|
524
705
|
assertValidGranularityOverride,
|
|
525
706
|
resolveModelForTier,
|
|
707
|
+
resolveProviderEscalation,
|
|
526
708
|
VALID_EFFORTS,
|
|
527
709
|
EFFORT_SET,
|
|
528
710
|
nextEffort,
|
|
@@ -269,7 +269,9 @@ function buildOnboardProjection(cwd, options) {
|
|
|
269
269
|
projectExists,
|
|
270
270
|
mapReadiness: mapReadinessValue,
|
|
271
271
|
onboardingSummaryExists,
|
|
272
|
-
|
|
272
|
+
// #2376: absolute (anchored on cwd/project_root), not orchestrator-cwd-relative —
|
|
273
|
+
// a spawned subagent's own cwd may differ from the orchestrator's.
|
|
274
|
+
onboardingSummaryPath: toPosixPath(onboardingSummaryPath),
|
|
273
275
|
hasPlanningArtifacts,
|
|
274
276
|
missingPlanningFiles,
|
|
275
277
|
handoffCommands,
|
|
@@ -289,13 +291,14 @@ function buildOnboardProjection(cwd, options) {
|
|
|
289
291
|
doc_candidate_count: docCandidates.length,
|
|
290
292
|
doc_candidates: docCandidates,
|
|
291
293
|
onboarding_summary_exists: onboardingSummaryExists,
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
294
|
+
// #2376: absolute — see comment on onboardingSummaryPath above.
|
|
295
|
+
onboarding_summary_path: toPosixPath(onboardingSummaryPath),
|
|
296
|
+
project_path: toPosixPath(node_fs_1.default.existsSync(projectRootPath) ? projectRootPath : projectScopedPath),
|
|
297
|
+
requirements_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
|
298
|
+
roadmap_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md')),
|
|
299
|
+
state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
|
|
300
|
+
codebase_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'codebase')),
|
|
301
|
+
onboarding_dir: toPosixPath(node_path_1.default.join(planningRoot(cwd), 'onboarding')),
|
|
299
302
|
};
|
|
300
303
|
}
|
|
301
304
|
module.exports = {
|
|
@@ -48,6 +48,24 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
|
|
48
48
|
// (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
|
|
49
49
|
// introduced outside this module without a `// phase-id-owner:` justification.
|
|
50
50
|
const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
|
|
51
|
+
// #2232: the canonical CONTINUATION-segment grammar — a dash-separated segment
|
|
52
|
+
// that extends a phase token (a zero-padded sub-phase or plan number, e.g. the
|
|
53
|
+
// "01" in "02-01-setup"). getPhaseDirFromPhaseId writes these zero-padded to
|
|
54
|
+
// exactly 2 digits, so the digit RUN of a genuine continuation is exactly 2:
|
|
55
|
+
// #2043's `\d{2,}` (2-or-more) over-collected a slug word that merely leads
|
|
56
|
+
// with ≥2 digits (a year: "14-2026-photos-…" yielded token "14-2026", so every
|
|
57
|
+
// phase-locating verb reported the phase as missing). The `(?!\d)` guard caps
|
|
58
|
+
// the run at 2 without anchoring what may follow, so call sites keep their own
|
|
59
|
+
// trailing grammar (letter suffixes, dotted sub-phases, segment boundaries).
|
|
60
|
+
// POLICY (locked by boundary tests): sub-phase/plan numbers ≥100 are out of the
|
|
61
|
+
// dir-token grammar — the LEADING phase number stays unbounded (`\d+`), only
|
|
62
|
+
// continuation segments are width-capped. Shared from here so the five #2043
|
|
63
|
+
// call sites cannot drift independently (see scripts/lint-phase-id-drift.cjs).
|
|
64
|
+
const PHASE_CONTINUATION_SEGMENT_SOURCE = '\\d{2}(?!\\d)';
|
|
65
|
+
const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_SEGMENT_SOURCE}`);
|
|
66
|
+
function isPhaseContinuationSegment(seg) {
|
|
67
|
+
return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
|
|
68
|
+
}
|
|
51
69
|
function stripProjectCodePrefix(value, caseInsensitive = true) {
|
|
52
70
|
const input = String(value);
|
|
53
71
|
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
|
@@ -211,9 +229,11 @@ function extractPhaseToken(dirName) {
|
|
|
211
229
|
}
|
|
212
230
|
const segments = rest.split('-');
|
|
213
231
|
const tokenSegments = [];
|
|
214
|
-
// #2043: distinguish a real (zero-padded
|
|
215
|
-
//
|
|
216
|
-
// continues with
|
|
232
|
+
// #2043: distinguish a real (zero-padded) phase/sub-phase segment from a
|
|
233
|
+
// single-digit slug word. A pure-numeric leading segment ("46") only
|
|
234
|
+
// continues with exactly-2-digit segments (#2232: a ≥3-digit run is a slug
|
|
235
|
+
// word such as a year — "14-2026-photos-…" yields "14", not "14-2026"), so
|
|
236
|
+
// "46-6-rs-…" yields "46" (the "6" is the
|
|
217
237
|
// slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
|
|
218
238
|
// with "M1-" already stripped as a project-code prefix (see
|
|
219
239
|
// PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
|
|
@@ -236,7 +256,7 @@ function extractPhaseToken(dirName) {
|
|
|
236
256
|
break;
|
|
237
257
|
}
|
|
238
258
|
}
|
|
239
|
-
else if (
|
|
259
|
+
else if (isPhaseContinuationSegment(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
|
|
240
260
|
tokenSegments.push(seg);
|
|
241
261
|
}
|
|
242
262
|
else {
|
|
@@ -358,6 +378,8 @@ module.exports = {
|
|
|
358
378
|
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
|
359
379
|
OPTIONAL_PHASE_TAG_SOURCE,
|
|
360
380
|
PHASE_NUMBER_TOKEN_SOURCE,
|
|
381
|
+
PHASE_CONTINUATION_SEGMENT_SOURCE,
|
|
382
|
+
isPhaseContinuationSegment,
|
|
361
383
|
stripProjectCodePrefix,
|
|
362
384
|
normalizePhaseName,
|
|
363
385
|
getMilestoneFromPhaseId,
|
|
@@ -106,6 +106,19 @@ function updateTraceabilityCell(text, match, column, newValue) {
|
|
|
106
106
|
return result;
|
|
107
107
|
return { ok: true, value: before + result.value + after };
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* Extract the MAJOR version segment from a version-ish string: "v1", "v1.3",
|
|
111
|
+
* "V1.0", and "1.0" all yield "1"; "v2" yields "2". Used (#2334 BLOCKER fix)
|
|
112
|
+
* to compare a `## v<N> ...` REQUIREMENTS.md heading against the current
|
|
113
|
+
* milestone's version at MAJOR-version granularity only — "v1" heading vs
|
|
114
|
+
* milestone "v1.3" is the SAME major version and must not be treated as a
|
|
115
|
+
* version mismatch. Returns null when `raw` has no leading digit run (not a
|
|
116
|
+
* version-shaped string), which the caller treats as "cannot resolve".
|
|
117
|
+
*/
|
|
118
|
+
function extractMajorVersion(raw) {
|
|
119
|
+
const m = raw.trim().match(/^v?(\d+)/i);
|
|
120
|
+
return m ? m[1] : null;
|
|
121
|
+
}
|
|
109
122
|
function describeNonCanonicalPlans(dirFiles, matchedFiles) {
|
|
110
123
|
const matched = new Set(matchedFiles);
|
|
111
124
|
const offenders = dirFiles.filter((f) => looksLikePlanFile(f) && !matched.has(f));
|
|
@@ -127,8 +140,13 @@ function extractCanonicalPlanId(filename) {
|
|
|
127
140
|
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
|
128
141
|
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
|
129
142
|
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
|
143
|
+
// #2232: the PAIRED plan component is a zero-padded continuation segment
|
|
144
|
+
// (exactly 2 digits), so a ≥3-digit slug word (a year) is not paired into a
|
|
145
|
+
// bogus "14-2026" id. The leading phase component keeps tokenRe's unbounded
|
|
146
|
+
// \d{2,} — phase numbers ≥100 are legitimate; only continuations are capped.
|
|
147
|
+
const planTokenRe = new RegExp(`^(?:${phaseIdMod.PHASE_CONTINUATION_SEGMENT_SOURCE}[A-Z]?|\\d[A-Z])(?:\\.\\d+)*$`, 'i');
|
|
130
148
|
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
|
131
|
-
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length &&
|
|
149
|
+
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && planTokenRe.test(parts[phaseIdx + 1])) {
|
|
132
150
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
133
151
|
}
|
|
134
152
|
return base;
|
|
@@ -603,6 +621,29 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
603
621
|
result['warnings'] = warnings;
|
|
604
622
|
output(result, raw);
|
|
605
623
|
}
|
|
624
|
+
// #2390 — phase.add title-shape heuristic. A description at or under this many
|
|
625
|
+
// characters, and with no sentence-ending punctuation followed by more text,
|
|
626
|
+
// reads as a short Title. Anything longer or multi-sentence reads as a Goal,
|
|
627
|
+
// not a Title. phase.add still writes the phase verbatim (it never mangles
|
|
628
|
+
// ROADMAP.md), but when the description looks goal-shaped the JSON result
|
|
629
|
+
// gains a `warning` key naming the gap, so the caller — or the orchestrating
|
|
630
|
+
// add-phase workflow — can split title vs. goal instead of the whole paragraph
|
|
631
|
+
// landing silently in the `### Phase N:` header.
|
|
632
|
+
const PHASE_ADD_TITLE_MAX_LEN = 80;
|
|
633
|
+
const PHASE_ADD_MULTI_SENTENCE_RE = /[.!?]['")\]]?\s+\S/;
|
|
634
|
+
function describeGoalShapedTitle(description) {
|
|
635
|
+
const trimmed = description.trim();
|
|
636
|
+
const tooLong = trimmed.length > PHASE_ADD_TITLE_MAX_LEN;
|
|
637
|
+
const multiSentence = PHASE_ADD_MULTI_SENTENCE_RE.test(trimmed);
|
|
638
|
+
if (!tooLong && !multiSentence)
|
|
639
|
+
return null;
|
|
640
|
+
const reasons = [
|
|
641
|
+
tooLong ? `${trimmed.length} chars (over the ${PHASE_ADD_TITLE_MAX_LEN}-char title threshold)` : null,
|
|
642
|
+
multiSentence ? 'multiple sentences' : null,
|
|
643
|
+
].filter(Boolean).join(', ');
|
|
644
|
+
return (`description looks goal-shaped, not title-shaped (${reasons}). It was written verbatim ` +
|
|
645
|
+
`as the phase title; consider a short title with the detail moved to **Goal:**.`);
|
|
646
|
+
}
|
|
606
647
|
function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
607
648
|
if (!description) {
|
|
608
649
|
error('description required for phase add');
|
|
@@ -690,6 +731,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
690
731
|
(0, shell_command_projection_cjs_1.platformWriteSync)(roadmapPath, updatedContent);
|
|
691
732
|
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
|
692
733
|
});
|
|
734
|
+
const titleWarning = describeGoalShapedTitle(description);
|
|
693
735
|
const result = {
|
|
694
736
|
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
|
695
737
|
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
|
|
@@ -698,7 +740,9 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
|
|
698
740
|
directory: toPosixPath(node_path_1.default.join(node_path_1.default.relative(cwd, planningDir(cwd)), 'phases', dirName)),
|
|
699
741
|
naming_mode: config.phase_naming,
|
|
700
742
|
};
|
|
701
|
-
|
|
743
|
+
if (titleWarning)
|
|
744
|
+
result['warning'] = titleWarning;
|
|
745
|
+
output(result, raw, result['padded']);
|
|
702
746
|
}
|
|
703
747
|
function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
|
704
748
|
if (!Array.isArray(descriptions) || descriptions.length === 0) {
|
|
@@ -1553,13 +1597,43 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1553
1597
|
const reqMatch = sectionText.match(/\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]+)/i);
|
|
1554
1598
|
const originalReqContent = node_fs_1.default.readFileSync(reqPath, 'utf-8');
|
|
1555
1599
|
let reqContent = originalReqContent;
|
|
1600
|
+
// #2316: `citedReqIds` — the REQ-IDs ROADMAP's own **Requirements:**
|
|
1601
|
+
// line for this phase actually cites — is hoisted out of the
|
|
1602
|
+
// `if (reqMatch)` block (previously scoped only inside it) so the
|
|
1603
|
+
// ghost-ID cross-check below (~#2316-1) can consult it. `TBD` is the
|
|
1604
|
+
// literal placeholder `phase.add`/`-batch`/`-insert` seed
|
|
1605
|
+
// (`**Requirements**: TBD`, src/phase.cts:833,920,1078) — never a
|
|
1606
|
+
// real REQ-ID, so it is filtered out wherever a cited-ID list feeds
|
|
1607
|
+
// a warning (#2316-7 boundary).
|
|
1608
|
+
const isPlaceholderReqId = (id) => id.toUpperCase() === 'TBD';
|
|
1609
|
+
let citedReqIds = [];
|
|
1610
|
+
// #2316-1: Traceability-row writes that matched NO row (ghost or
|
|
1611
|
+
// otherwise) — the `if (reqUpdate.ok)` below previously had no
|
|
1612
|
+
// `else`, discarding this fact silently instead of surfacing it.
|
|
1613
|
+
const traceabilityWriteMisses = [];
|
|
1556
1614
|
if (reqMatch) {
|
|
1557
|
-
|
|
1615
|
+
// #2334 HIGH 3: filter the tokenized capture to the REQ-ID SHAPE —
|
|
1616
|
+
// the SAME shape bodyReqIds (`\*\*([A-Z][A-Z0-9]*-\d+)\*\*`, below)
|
|
1617
|
+
// and tableReqIds (`([A-Z][A-Z0-9]*-\d+)`, below) already require —
|
|
1618
|
+
// so the ghost-ID / unregistered comparisons stay shape-symmetric.
|
|
1619
|
+
// Without this, `[^\n]+` split on `[,\s]+` turned EVERY word after
|
|
1620
|
+
// the ID list into a "cited REQ-ID": the shipped
|
|
1621
|
+
// `templates/roadmap.md:32` line
|
|
1622
|
+
// `**Requirements**: [REQ-01, REQ-02] <!-- brackets optional, ... -->`
|
|
1623
|
+
// warned to register `<!--`, `brackets`, `optional`, `-->`, etc., and
|
|
1624
|
+
// `**Requirements:** None` warned to register the literal word
|
|
1625
|
+
// `None`. This subsumes the `TBD` placeholder special-case (`TBD`
|
|
1626
|
+
// does not match the REQ-ID shape either); `isPlaceholderReqId` is
|
|
1627
|
+
// kept below as a defensive no-op for any caller that still hands
|
|
1628
|
+
// it a raw token.
|
|
1629
|
+
const REQ_ID_SHAPE_RE = /^[A-Z][A-Z0-9]*-\d+$/i;
|
|
1630
|
+
citedReqIds = reqMatch[1]
|
|
1558
1631
|
.replace(/[\[\]]/g, '')
|
|
1559
1632
|
.split(/[,\s]+/)
|
|
1560
1633
|
.map((r) => r.trim())
|
|
1561
|
-
.filter(Boolean)
|
|
1562
|
-
|
|
1634
|
+
.filter(Boolean)
|
|
1635
|
+
.filter((r) => REQ_ID_SHAPE_RE.test(r));
|
|
1636
|
+
for (const reqId of citedReqIds) {
|
|
1563
1637
|
const reqEscaped = escapeRegex(reqId);
|
|
1564
1638
|
reqContent = reqContent.replace(new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'), '$1x$2');
|
|
1565
1639
|
// Traceability row: | <REQ-ID> | Phase N | Pending|In Progress | ->
|
|
@@ -1578,14 +1652,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1578
1652
|
// Complete" gate is folded into the newValue callback so one
|
|
1579
1653
|
// updateTableCell call both probes and writes.
|
|
1580
1654
|
const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
|
|
1581
|
-
if (reqUpdate.ok)
|
|
1655
|
+
if (reqUpdate.ok) {
|
|
1582
1656
|
reqContent = reqUpdate.value;
|
|
1657
|
+
}
|
|
1658
|
+
else if (!isPlaceholderReqId(reqId)) {
|
|
1659
|
+
traceabilityWriteMisses.push(reqId);
|
|
1660
|
+
}
|
|
1583
1661
|
}
|
|
1584
1662
|
}
|
|
1585
1663
|
// #1159 (Defect B): collect requirement IDs only from ACTIVE sections.
|
|
1586
1664
|
// Requirements under headings whose text contains "deferred", "backlog",
|
|
1587
|
-
// "future", or
|
|
1588
|
-
// and must not be flagged as missing from the
|
|
1665
|
+
// "future", or an OFF-milestone `v<N>` (case-insensitive) are explicitly
|
|
1666
|
+
// out of current scope and must not be flagged as missing from the
|
|
1667
|
+
// Traceability table.
|
|
1589
1668
|
//
|
|
1590
1669
|
// Strategy: walk lines, track heading depth, and toggle a "deferred" flag
|
|
1591
1670
|
// when a heading matching the pattern is encountered. A sub-heading (higher
|
|
@@ -1593,7 +1672,47 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1593
1672
|
// opens a same-or-shallower heading that does NOT match the pattern.
|
|
1594
1673
|
// Lines inside fenced code blocks (``` or ~~~) are treated as content, not
|
|
1595
1674
|
// headings, to avoid false deferred-section detection from code examples.
|
|
1596
|
-
|
|
1675
|
+
//
|
|
1676
|
+
// #2334 BLOCKER fix (regresses closed bug #1159 against GSD's OWN
|
|
1677
|
+
// shipped template): #2316-4a dropped the bare `v\d+` alternative
|
|
1678
|
+
// entirely to stop it over-matching an ACTIVE heading like "## v1
|
|
1679
|
+
// Requirements" — but the shipped `templates/requirements.md:35`
|
|
1680
|
+
// scaffold ships `## v2 Requirements` / "Deferred to future release"
|
|
1681
|
+
// as its ONLY deferred marker, and `v\d+` was the ONLY alternative
|
|
1682
|
+
// that ever matched a bare version heading (the deferred-ness lives
|
|
1683
|
+
// in body prose, not the heading text). Dropping it regressed #1159
|
|
1684
|
+
// for every project scaffolded from the shipped template.
|
|
1685
|
+
//
|
|
1686
|
+
// Fix: make the `v<N>` alternative MILESTONE-AWARE instead of
|
|
1687
|
+
// deleting it. A `## v<N> ...` heading is deferred ONLY when `<N>`
|
|
1688
|
+
// (MAJOR version only — "v1" vs milestone "v1.3" is the SAME major
|
|
1689
|
+
// version) does not match the CURRENT milestone's major version,
|
|
1690
|
+
// resolved via `stateExtractField` against STATE.md's `milestone:`
|
|
1691
|
+
// frontmatter field (the same seam `getMilestoneInfo`/state.cts's
|
|
1692
|
+
// frontmatter builder already use — no bespoke frontmatter parsing).
|
|
1693
|
+
// "## v1 Requirements" while the milestone is v1.x is the ACTIVE
|
|
1694
|
+
// milestone's own section (#2316's original ask) and must NOT be
|
|
1695
|
+
// swallowed; "## v2 Requirements" while the milestone is v1.x is a
|
|
1696
|
+
// genuinely future milestone (#1159's ask, and the literal shipped-
|
|
1697
|
+
// template shape) and MUST stay suppressed. `deferred`/`backlog`/
|
|
1698
|
+
// `future` are unaffected by milestone resolution — a genuinely
|
|
1699
|
+
// deferred heading always spells one of those words too (see
|
|
1700
|
+
// #2316-5 regression guard: "## Deferred v2 Requirements", "##
|
|
1701
|
+
// Future Backlog", "## Deferred", "## Backlog", "## Future").
|
|
1702
|
+
//
|
|
1703
|
+
// Fail-safe: when the milestone version cannot be resolved at all
|
|
1704
|
+
// (no STATE.md, or no `milestone:` field), fall back to the OLD
|
|
1705
|
+
// pre-#2316-4a behavior and treat every `v\d+` heading as deferred.
|
|
1706
|
+
// A false "deferred" here only ever SUPPRESSES a warning — strictly
|
|
1707
|
+
// safer than spamming a warning on every v\d+-headed scaffold when
|
|
1708
|
+
// we cannot tell whether it names the active milestone.
|
|
1709
|
+
const DEFERRED_KEYWORD_RE = /\b(?:deferred|backlog|future)\b/i;
|
|
1710
|
+
const HEADING_VERSION_RE = /\bv(\d+)(?:\.\d+)*\b/i;
|
|
1711
|
+
const stateRawForMilestone = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
|
|
1712
|
+
const currentMilestoneRaw = stateRawForMilestone
|
|
1713
|
+
? stateExtractField(stateRawForMilestone, 'milestone')
|
|
1714
|
+
: null;
|
|
1715
|
+
const currentMilestoneMajor = currentMilestoneRaw ? extractMajorVersion(currentMilestoneRaw) : null;
|
|
1597
1716
|
const bodyReqIds = [];
|
|
1598
1717
|
// deferredDepth: the heading level that opened the current deferred block,
|
|
1599
1718
|
// or 0 when we are in an active section.
|
|
@@ -1617,11 +1736,21 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1617
1736
|
}
|
|
1618
1737
|
// Heading at same level or shallower than current deferred opener,
|
|
1619
1738
|
// or no active deferred block yet.
|
|
1620
|
-
if (
|
|
1739
|
+
if (DEFERRED_KEYWORD_RE.test(text)) {
|
|
1621
1740
|
deferredDepth = depth; // enter a deferred block
|
|
1622
1741
|
}
|
|
1623
1742
|
else {
|
|
1624
|
-
|
|
1743
|
+
const versionMatch = text.match(HEADING_VERSION_RE);
|
|
1744
|
+
if (versionMatch) {
|
|
1745
|
+
const headingMajor = versionMatch[1];
|
|
1746
|
+
deferredDepth =
|
|
1747
|
+
currentMilestoneMajor === null || headingMajor !== currentMilestoneMajor
|
|
1748
|
+
? depth // unresolved milestone (fail-safe) or off-milestone version -> deferred
|
|
1749
|
+
: 0; // same major version as the current milestone -> active
|
|
1750
|
+
}
|
|
1751
|
+
else {
|
|
1752
|
+
deferredDepth = 0; // back in an active section
|
|
1753
|
+
}
|
|
1625
1754
|
}
|
|
1626
1755
|
continue;
|
|
1627
1756
|
}
|
|
@@ -1654,8 +1783,68 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
|
|
1654
1783
|
if (unregistered.length > 0) {
|
|
1655
1784
|
warnings.push(`REQUIREMENTS.md: ${unregistered.length} REQ-ID(s) found in body but missing from Traceability table: ${unregistered.join(', ')} — add them manually to keep traceability in sync`);
|
|
1656
1785
|
}
|
|
1786
|
+
// #2316-1: ghost REQ-IDs — cited by ROADMAP's own **Requirements:**
|
|
1787
|
+
// line for this phase, but registered NOWHERE in REQUIREMENTS.md
|
|
1788
|
+
// (neither its body nor its Traceability table). The `unregistered`
|
|
1789
|
+
// check above only ever compares REQUIREMENTS.md's own body against
|
|
1790
|
+
// its own Traceability table; it never consults `citedReqIds`, so an
|
|
1791
|
+
// ID that ROADMAP cites but REQUIREMENTS.md never defines at all was
|
|
1792
|
+
// previously invisible to every guard. `TBD` (the phase.add/-batch/
|
|
1793
|
+
// -insert placeholder) is excluded — see #2316-7 boundary.
|
|
1794
|
+
//
|
|
1795
|
+
// #2334 HIGH 2: classify "ghost" by PROBING THE ACTUAL WRITE
|
|
1796
|
+
// SURFACES this same function just wrote to (:1947 checkbox,
|
|
1797
|
+
// :1967 Traceability row) — case-insensitively — mirroring
|
|
1798
|
+
// milestone.cts's `notFound`/`hasRow`/`doneCheckbox` classification
|
|
1799
|
+
// (src/milestone.cts:117-141,209-215), instead of set-differencing
|
|
1800
|
+
// `bodyReqIds` (deferred-filtered, case-sensitive, bold-only) and
|
|
1801
|
+
// `tableReqIds` (case-sensitive) against `citedReqIds`. Those two
|
|
1802
|
+
// indexes can disagree with the writes: an ID under a `##
|
|
1803
|
+
// Deferred` heading gets its checkbox ticked by the write loop
|
|
1804
|
+
// above but is deliberately EXCLUDED from `bodyReqIds` by the
|
|
1805
|
+
// deferred-heading filter (#1159), so the old set-diff reported it
|
|
1806
|
+
// as an unregistered ghost in the SAME response that just ticked
|
|
1807
|
+
// its checkbox; a case-mismatched citation (`known-01` vs
|
|
1808
|
+
// `**KNOWN-01**`) lands its write via the writes' case-insensitive
|
|
1809
|
+
// regexes but failed the old set-diff's case-SENSITIVE
|
|
1810
|
+
// `Array.includes`/`Set.has`. An ID whose checkbox OR Traceability
|
|
1811
|
+
// row actually matched is registered — not a ghost — regardless of
|
|
1812
|
+
// which section (deferred or not) it lives under.
|
|
1813
|
+
const reqIsRegisteredAnywhere = (id) => {
|
|
1814
|
+
const reqEscaped = escapeRegex(id);
|
|
1815
|
+
// Surface 1 — checkbox, EITHER state (`[ ]` or `[x]`), case-
|
|
1816
|
+
// insensitive: existence check, not the write's space-only match.
|
|
1817
|
+
if (new RegExp(`-\\s*\\[[ xX]\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i').test(reqContent)) {
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
// Surface 2 — Traceability row exists at all (any Status value),
|
|
1821
|
+
// via the SAME no-op-probe-through-updateTraceabilityCell
|
|
1822
|
+
// technique milestone.cts's `hasRow` uses (:210-214): a case-
|
|
1823
|
+
// insensitive first-cell match, regardless of current Status.
|
|
1824
|
+
const rowProbeMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === id.toLowerCase();
|
|
1825
|
+
return updateTraceabilityCell(reqContent, rowProbeMatch, 'Status', (current) => current).ok;
|
|
1826
|
+
};
|
|
1827
|
+
const ghostReqIds = citedReqIds.filter((id) => !isPlaceholderReqId(id) && !reqIsRegisteredAnywhere(id));
|
|
1828
|
+
if (ghostReqIds.length > 0) {
|
|
1829
|
+
warnings.push(`ROADMAP Phase ${phaseNum} cites REQ-ID(s) not registered anywhere in REQUIREMENTS.md (neither body nor Traceability table): ${ghostReqIds.join(', ')} — add them to REQUIREMENTS.md or correct the ROADMAP citation`);
|
|
1830
|
+
}
|
|
1831
|
+
// #2316-1 cont.: a cited ID whose Traceability-row write matched no
|
|
1832
|
+
// row for a reason OTHER than being a ghost (e.g. a malformed table)
|
|
1833
|
+
// still deserves a warning instead of a silent discard — but skip
|
|
1834
|
+
// IDs already reported above as ghosts to avoid a duplicate message
|
|
1835
|
+
// for the same root cause.
|
|
1836
|
+
const traceabilityWriteFailures = traceabilityWriteMisses.filter((id) => !ghostReqIds.includes(id));
|
|
1837
|
+
if (traceabilityWriteFailures.length > 0) {
|
|
1838
|
+
warnings.push(`REQUIREMENTS.md: Traceability row write skipped for REQ-ID(s) cited by ROADMAP (no matching row found): ${traceabilityWriteFailures.join(', ')}`);
|
|
1839
|
+
}
|
|
1657
1840
|
writes.push({ filePath: reqPath, before: originalReqContent, after: reqContent });
|
|
1658
|
-
|
|
1841
|
+
// #2316-3: `requirements_updated` must reflect whether REQUIREMENTS.md
|
|
1842
|
+
// content actually CHANGED, not merely that the file existed in the
|
|
1843
|
+
// transaction — mirrors the `writes.push({filePath,before,after})`
|
|
1844
|
+
// diff-tracking pattern used for the ROADMAP write above. A phase
|
|
1845
|
+
// whose citations match nothing (ghost REQ-IDs only) must report
|
|
1846
|
+
// `false`, not a bare "the file was present" `true`.
|
|
1847
|
+
requirementsUpdated = reqContent !== originalReqContent;
|
|
1659
1848
|
}
|
|
1660
1849
|
}
|
|
1661
1850
|
try {
|