@opengsd/gsd-core 1.4.2 → 1.5.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-executor.md +1 -1
  4. package/agents/gsd-plan-checker.md +11 -2
  5. package/agents/gsd-planner.md +11 -7
  6. package/agents/gsd-verifier.md +3 -3
  7. package/bin/install.js +947 -149
  8. package/commands/gsd/autonomous.md +5 -1
  9. package/commands/gsd/ns-manage.md +8 -1
  10. package/commands/gsd/ns-project.md +5 -0
  11. package/commands/gsd/ns-review.md +4 -1
  12. package/commands/gsd/ns-workflow.md +7 -1
  13. package/commands/gsd/plan-review-convergence.md +6 -5
  14. package/commands/gsd/surface.md +12 -5
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +198 -101
  17. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +417 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  20. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  21. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  22. package/gsd-core/bin/lib/configuration.cjs +4 -34
  23. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  24. package/gsd-core/bin/lib/core.cjs +57 -1817
  25. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  26. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  27. package/gsd-core/bin/lib/init.cjs +58 -12
  28. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  29. package/gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +123 -0
  30. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  31. package/gsd-core/bin/lib/io.cjs +167 -0
  32. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  34. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  35. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  36. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  37. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  39. package/gsd-core/bin/lib/roadmap.cjs +4 -1
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +96 -9
  41. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +2 -1
  42. package/gsd-core/bin/lib/runtime-homes.cjs +40 -0
  43. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -0
  44. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  45. package/gsd-core/bin/lib/state.cjs +114 -2
  46. package/gsd-core/bin/lib/surface.cjs +66 -14
  47. package/gsd-core/bin/lib/verify.cjs +1 -1
  48. package/gsd-core/bin/shared/model-catalog.json +5 -0
  49. package/gsd-core/bin/shared/runtime-aliases.manifest.json +3 -0
  50. package/gsd-core/bin/verify-reapply-patches.cjs +57 -7
  51. package/gsd-core/references/planner-reviews.md +5 -2
  52. package/gsd-core/templates/phase-prompt.md +7 -7
  53. package/gsd-core/templates/project.md +19 -2
  54. package/gsd-core/templates/summary-complex.md +1 -0
  55. package/gsd-core/templates/summary-minimal.md +1 -0
  56. package/gsd-core/templates/summary-standard.md +1 -0
  57. package/gsd-core/templates/summary.md +1 -0
  58. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  59. package/gsd-core/workflows/add-backlog.md +1 -1
  60. package/gsd-core/workflows/add-phase.md +1 -1
  61. package/gsd-core/workflows/add-tests.md +1 -1
  62. package/gsd-core/workflows/add-todo.md +1 -1
  63. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  64. package/gsd-core/workflows/audit-fix.md +1 -1
  65. package/gsd-core/workflows/audit-milestone.md +1 -1
  66. package/gsd-core/workflows/audit-uat.md +1 -1
  67. package/gsd-core/workflows/autonomous.md +86 -15
  68. package/gsd-core/workflows/check-todos.md +1 -1
  69. package/gsd-core/workflows/cleanup.md +1 -1
  70. package/gsd-core/workflows/code-review-fix.md +6 -4
  71. package/gsd-core/workflows/code-review.md +3 -2
  72. package/gsd-core/workflows/complete-milestone.md +11 -5
  73. package/gsd-core/workflows/debug.md +1 -1
  74. package/gsd-core/workflows/diagnose-issues.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  76. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  77. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  78. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  79. package/gsd-core/workflows/discuss-phase.md +8 -1
  80. package/gsd-core/workflows/do.md +1 -1
  81. package/gsd-core/workflows/docs-update.md +1 -1
  82. package/gsd-core/workflows/edit-phase.md +1 -1
  83. package/gsd-core/workflows/eval-review.md +4 -1
  84. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  85. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  86. package/gsd-core/workflows/execute-phase.md +8 -1
  87. package/gsd-core/workflows/execute-plan.md +1 -1
  88. package/gsd-core/workflows/explore.md +1 -1
  89. package/gsd-core/workflows/extract-learnings.md +1 -1
  90. package/gsd-core/workflows/forensics.md +1 -1
  91. package/gsd-core/workflows/graduation.md +1 -1
  92. package/gsd-core/workflows/health.md +1 -1
  93. package/gsd-core/workflows/help/modes/full.md +1 -1
  94. package/gsd-core/workflows/import.md +1 -1
  95. package/gsd-core/workflows/ingest-docs.md +1 -1
  96. package/gsd-core/workflows/insert-phase.md +1 -1
  97. package/gsd-core/workflows/list-workspaces.md +1 -1
  98. package/gsd-core/workflows/manager.md +1 -1
  99. package/gsd-core/workflows/map-codebase.md +1 -1
  100. package/gsd-core/workflows/milestone-summary.md +1 -1
  101. package/gsd-core/workflows/mvp-phase.md +1 -1
  102. package/gsd-core/workflows/new-milestone.md +1 -1
  103. package/gsd-core/workflows/new-project.md +1 -1
  104. package/gsd-core/workflows/new-workspace.md +1 -1
  105. package/gsd-core/workflows/next.md +1 -1
  106. package/gsd-core/workflows/pause-work.md +1 -1
  107. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  108. package/gsd-core/workflows/plan-phase.md +27 -2
  109. package/gsd-core/workflows/plan-review-convergence.md +76 -63
  110. package/gsd-core/workflows/plant-seed.md +1 -1
  111. package/gsd-core/workflows/profile-user.md +1 -1
  112. package/gsd-core/workflows/progress.md +1 -1
  113. package/gsd-core/workflows/quick.md +2 -2
  114. package/gsd-core/workflows/reapply-patches.md +18 -1
  115. package/gsd-core/workflows/remove-phase.md +1 -1
  116. package/gsd-core/workflows/remove-workspace.md +1 -1
  117. package/gsd-core/workflows/resume-project.md +1 -1
  118. package/gsd-core/workflows/review.md +1 -1
  119. package/gsd-core/workflows/scan.md +1 -1
  120. package/gsd-core/workflows/secure-phase.md +1 -1
  121. package/gsd-core/workflows/settings-advanced.md +1 -1
  122. package/gsd-core/workflows/settings-integrations.md +1 -1
  123. package/gsd-core/workflows/settings.md +1 -1
  124. package/gsd-core/workflows/ship.md +8 -1
  125. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  126. package/gsd-core/workflows/sketch.md +1 -1
  127. package/gsd-core/workflows/spec-phase.md +1 -1
  128. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  129. package/gsd-core/workflows/spike.md +1 -1
  130. package/gsd-core/workflows/stats.md +1 -1
  131. package/gsd-core/workflows/thread.md +1 -1
  132. package/gsd-core/workflows/transition.md +1 -1
  133. package/gsd-core/workflows/ui-phase.md +1 -1
  134. package/gsd-core/workflows/ui-review.md +1 -1
  135. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  136. package/gsd-core/workflows/update.md +20 -15
  137. package/gsd-core/workflows/validate-phase.md +1 -1
  138. package/gsd-core/workflows/verify-phase.md +1 -1
  139. package/gsd-core/workflows/verify-work.md +8 -1
  140. package/package.json +7 -2
  141. package/scripts/base64-scan.sh +1 -1
  142. package/scripts/changeset/cli.cjs +9 -2
  143. package/scripts/changeset/lint.cjs +38 -2
  144. package/scripts/ci-test-scope.cjs +21 -10
  145. package/scripts/gen-capability-registry.cjs +1808 -0
  146. package/scripts/gen-loop-host-contract.cjs +471 -0
  147. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  148. package/scripts/lint-regression-test-names.cjs +117 -0
  149. package/scripts/lint-test-file-count.allowlist.json +12 -4
  150. package/scripts/prompt-injection-scan.sh +4 -4
  151. package/scripts/run-tests.cjs +79 -16
  152. package/scripts/secret-scan.sh +3 -3
  153. package/scripts/run-cross-platform-tests.cjs +0 -67
@@ -10,12 +10,20 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
10
10
  return (mod && mod.__esModule) ? mod : { "default": mod };
11
11
  };
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
- const node_os_1 = __importDefault(require("node:os"));
14
13
  const node_path_1 = __importDefault(require("node:path"));
15
14
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
15
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ const ioModule = require("./io.cjs");
17
+ const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule;
18
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
19
+ const phaseIdModule = require("./phase-id.cjs");
20
+ const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule;
21
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
22
+ const roadmapParserModule = require("./roadmap-parser.cjs");
23
+ const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule;
24
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
17
25
  const modelProfiles = require("./model-profiles.cjs");
18
- const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
26
+ const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles;
19
27
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
20
28
  // eslint-disable-next-line @typescript-eslint/no-require-imports
21
29
  const worktreeSafety = require("./worktree-safety.cjs");
@@ -23,557 +31,32 @@ const { resolveWorktreeContext, parseWorktreePorcelain: parseWorktreePorcelainPo
23
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
32
  const planningWorkspace = require("./planning-workspace.cjs");
25
33
  // Compatibility shim: new imports should use planning-workspace.cjs directly.
26
- const { planningDir, planningRoot, planningPaths, withPlanningLock, getActiveWorkstream, setActiveWorkstream, findContextMdIn, } = planningWorkspace;
34
+ const { planningDir, planningRoot, planningPaths, withPlanningLock, getActiveWorkstream, setActiveWorkstream, } = planningWorkspace;
35
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
36
+ const coreUtilsModule = require("./core-utils.cjs");
37
+ const { toPosixPath, detectSubRepos, extractOneLinerFromBody, pathExistsInternal, generateSlugInternal, filterPlanFiles, filterSummaryFiles, getPhaseFileStats, readSubdirectories, timeAgo, } = coreUtilsModule;
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
39
+ const phaseLocatorModule = require("./phase-locator.cjs");
40
+ const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
27
41
  const project_root_cjs_1 = require("./project-root.cjs");
28
42
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
29
- // ─── Configuration Module (generated CJS mirror) ────────────────────────────
30
- const configuration_cjs_1 = require("./configuration.cjs");
43
+ // ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ─────
31
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
- const configSchema = require("./config-schema.cjs");
33
- const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema;
45
+ const configLoaderModule = require("./config-loader.cjs");
46
+ const { loadConfig, isGitIgnored, CONFIG_DEFAULTS, _warnUnknownProfileOverrides, _resetRuntimeWarningCacheForTests, RUNTIME_OVERRIDE_TIERS, } = configLoaderModule;
47
+ // ─── Model Resolver Module (extracted from core, ADR-857 phase 2f / #888) ────
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const modelResolverModule = require("./model-resolver.cjs");
50
+ const { resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, VALID_EFFORTS, EFFORT_SET, nextEffort, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, } = modelResolverModule;
34
51
  // ─── Path helpers ────────────────────────────────────────────────────────────
35
- /** Normalize a relative path to always use forward slashes (cross-platform). */
36
- function toPosixPath(p) {
37
- return p.split(node_path_1.default.sep).join('/');
38
- }
39
- /**
40
- * Scan immediate child directories for separate git repos.
41
- * Returns a sorted array of directory names that have their own `.git`.
42
- * Excludes hidden directories and node_modules.
43
- */
44
- function detectSubRepos(cwd) {
45
- const results = [];
46
- try {
47
- const entries = node_fs_1.default.readdirSync(cwd, { withFileTypes: true });
48
- for (const entry of entries) {
49
- if (!entry.isDirectory())
50
- continue;
51
- if (entry.name.startsWith('.') || entry.name === 'node_modules')
52
- continue;
53
- const gitPath = node_path_1.default.join(cwd, entry.name, '.git');
54
- try {
55
- if (node_fs_1.default.existsSync(gitPath)) {
56
- results.push(entry.name);
57
- }
58
- }
59
- catch { /* ignore */ }
60
- }
61
- }
62
- catch { /* ignore */ }
63
- return results.sort();
64
- }
52
+ // toPosixPath and detectSubRepos moved to core-utils.cjs (ADR-857 phase 2c / #877).
53
+ // The destructured bindings above (from coreUtilsModule) make them available to
54
+ // core-internal callers; core.cjs re-exports toPosixPath and detectSubRepos for back-compat.
65
55
  // findProjectRoot is now re-exported from the generated CJS module above.
66
- // ─── Output helpers ───────────────────────────────────────────────────────────
67
- /**
68
- * Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
69
- * Created on first use. Keeps GSD temp files isolated from the system
70
- * temp directory so reap scans only GSD files (#1975).
71
- */
72
- const GSD_TEMP_DIR = node_path_1.default.join(node_os_1.default.tmpdir(), 'gsd');
73
- function ensureGsdTempDir() {
74
- (0, shell_command_projection_cjs_1.platformEnsureDir)(GSD_TEMP_DIR);
75
- }
76
- /**
77
- * Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
78
- * Runs opportunistically before each new temp file write to prevent unbounded accumulation.
79
- * @param prefix - filename prefix to match (e.g., 'gsd-')
80
- * @param opts
81
- * @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
82
- * @param opts.dirsOnly - if true, only remove directories (default: false)
83
- */
84
- function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
85
- try {
86
- ensureGsdTempDir();
87
- const now = Date.now();
88
- const entries = node_fs_1.default.readdirSync(GSD_TEMP_DIR);
89
- for (const entry of entries) {
90
- if (!entry.startsWith(prefix))
91
- continue;
92
- const fullPath = node_path_1.default.join(GSD_TEMP_DIR, entry);
93
- try {
94
- const stat = node_fs_1.default.statSync(fullPath);
95
- if (now - stat.mtimeMs > maxAgeMs) {
96
- if (stat.isDirectory()) {
97
- node_fs_1.default.rmSync(fullPath, { recursive: true, force: true });
98
- }
99
- else if (!dirsOnly) {
100
- node_fs_1.default.unlinkSync(fullPath);
101
- }
102
- }
103
- }
104
- catch {
105
- // File may have been removed between readdir and stat — ignore
106
- }
107
- }
108
- }
109
- catch {
110
- // Non-critical — don't let cleanup failures break output
111
- }
112
- }
113
- function output(result, raw, rawValue) {
114
- let data;
115
- if (raw && rawValue !== undefined) {
116
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
117
- data = String(rawValue);
118
- }
119
- else {
120
- const json = JSON.stringify(result, null, 2);
121
- // Large payloads exceed Claude Code's Bash tool buffer (~50KB).
122
- // Write to tmpfile and output the path prefixed with @file: so callers can detect it.
123
- if (json.length > 50000) {
124
- reapStaleTempFiles();
125
- ensureGsdTempDir();
126
- const tmpPath = node_path_1.default.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
127
- (0, shell_command_projection_cjs_1.platformWriteSync)(tmpPath, json);
128
- data = '@file:' + tmpPath;
129
- }
130
- else {
131
- data = json;
132
- }
133
- }
134
- // process.stdout.write() is async when stdout is a pipe — process.exit()
135
- // can tear down the process before the reader consumes the buffer.
136
- // fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
137
- // skipping process.exit() lets the event loop drain naturally.
138
- node_fs_1.default.writeSync(1, data);
139
- }
140
- /**
141
- * Frozen enum of typed reason codes used by error() for structured errors.
142
- * Each subcommand contributes its own codes; the enum exists so tests can
143
- * assert against typed values instead of grepping stderr (#2974).
144
- *
145
- * Adding a new code:
146
- * - Pick a snake_case lowercase value (the JSON wire form)
147
- * - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
148
- * - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
149
- */
150
- const ERROR_REASON = Object.freeze({
151
- // config-get / config-set
152
- CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
153
- CONFIG_NO_FILE: 'config_no_file',
154
- CONFIG_PARSE_FAILED: 'config_parse_failed',
155
- CONFIG_INVALID_KEY: 'config_invalid_key',
156
- // SDK / gsd-tools dispatch
157
- SDK_FAIL_FAST: 'sdk_fail_fast',
158
- SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
159
- SDK_MISSING_ARG: 'sdk_missing_arg',
160
- // workflow / phase
161
- PHASE_NOT_FOUND: 'phase_not_found',
162
- SUMMARY_NO_PLANNING: 'summary_no_planning',
163
- // graphify
164
- GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
165
- GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
166
- // hooks
167
- HOOKS_OPT_OUT: 'hooks_opt_out',
168
- // security-scan
169
- SECURITY_SCAN_FAILED: 'security_scan_failed',
170
- // generic
171
- USAGE: 'usage',
172
- UNKNOWN: 'unknown',
173
- });
174
- /**
175
- * Process-level flag: when true, error() emits structured JSON to stderr
176
- * instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
177
- * CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
178
- * assertions by passing that flag and parsing the JSON.
179
- *
180
- * Default off so existing callers and human operators keep their plain-text
181
- * diagnostics. The structured form is opt-in for tooling and tests (#2974).
182
- */
183
- let _jsonErrorMode = false;
184
- function setJsonErrorMode(v) { _jsonErrorMode = !!v; }
185
- function getJsonErrorMode() { return _jsonErrorMode; }
186
- /**
187
- * Emit an error and exit. When the second argument is provided it must be
188
- * a value from ERROR_REASON; tests can assert on `result.reason`. When the
189
- * process is in JSON-error mode, stderr receives `{ ok: false, reason,
190
- * message }` so callers can parse it; otherwise stderr keeps the plain
191
- * text form for human operators.
192
- */
193
- function error(message, reason = ERROR_REASON.UNKNOWN) {
194
- if (_jsonErrorMode) {
195
- const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
196
- node_fs_1.default.writeSync(2, payload);
197
- }
198
- else {
199
- node_fs_1.default.writeSync(2, 'Error: ' + message + '\n');
200
- }
201
- process.exit(1);
202
- }
203
- // ─── File & Config utilities ──────────────────────────────────────────────────
204
- /**
205
- * Canonical config defaults — flat-key projection for CJS consumers.
206
- *
207
- * Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested
208
- * manifest loaded by configuration.generated.cjs). The flat shape is
209
- * preserved here so legacy consumers (config.cjs, verify.cjs, tests that
210
- * regex-parse this source) continue to work without changes. The key names
211
- * and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept.
212
- *
213
- * Mapping notes:
214
- * - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this)
215
- * - git.* → flat git keys (branching_strategy, templates)
216
- * - workflow.* → flat names (research, verifier, …)
217
- * - planning.sub_repos → sub_repos
218
- * - planning.commit_docs / search_gitignored → top-level flat keys
219
- */
220
- // CANONICAL_CONFIG_DEFAULTS is typed as Record<string, unknown> from configuration.cjs;
221
- // we use a typed accessor to avoid repeated casts.
222
- function _getConfigDefault(key) {
223
- return (configuration_cjs_1.CONFIG_DEFAULTS)[key];
224
- }
225
- function _getNestedConfigDefault(section, field) {
226
- const sec = (configuration_cjs_1.CONFIG_DEFAULTS)[section];
227
- if (sec && typeof sec === 'object' && !Array.isArray(sec)) {
228
- return sec[field];
229
- }
230
- return undefined;
231
- }
232
- const CONFIG_DEFAULTS = {
233
- model_profile: _getConfigDefault('model_profile'),
234
- commit_docs: _getConfigDefault('commit_docs'),
235
- search_gitignored: _getConfigDefault('search_gitignored'),
236
- branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'),
237
- phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'),
238
- milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'),
239
- quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'),
240
- research: _getNestedConfigDefault('workflow', 'research'),
241
- plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check
242
- verifier: _getNestedConfigDefault('workflow', 'verifier'),
243
- nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
244
- ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
245
- parallelization: _getConfigDefault('parallelization'),
246
- brave_search: _getConfigDefault('brave_search'),
247
- firecrawl: _getConfigDefault('firecrawl'),
248
- exa_search: _getConfigDefault('exa_search'),
249
- text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
250
- sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
251
- resolve_model_ids: _getConfigDefault('resolve_model_ids'),
252
- context_window: _getConfigDefault('context_window'),
253
- phase_naming: _getConfigDefault('phase_naming'),
254
- project_code: _getConfigDefault('project_code'),
255
- subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'),
256
- security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'),
257
- security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'),
258
- security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
259
- post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
260
- };
261
- /**
262
- * Deep-merge two plain config objects. `overlay` wins on key conflict.
263
- * Explicit `null` in overlay overrides base (null means "unset this key").
264
- * Arrays are replaced, not merged. Non-object primitives use overlay value.
265
- *
266
- * Note: `undefined` in overlay is treated as "no value provided" and falls
267
- * back to base (preserves inheritance). Explicit `null` overrides base.
268
- */
269
- function _deepMergeConfig(base, overlay) {
270
- if (overlay === null || overlay === undefined)
271
- return overlay;
272
- if (typeof base !== 'object' || typeof overlay !== 'object')
273
- return overlay;
274
- const result = { ...base };
275
- for (const key of Object.keys(overlay)) {
276
- if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
277
- result[key] = _deepMergeConfig((base[key] ?? {}), overlay[key]);
278
- }
279
- else {
280
- result[key] = overlay[key];
281
- }
282
- }
283
- return result;
284
- }
285
- // Module-level deduplication for unknown-key warnings (#3523).
286
- // A single `init phase-op N` call invokes loadConfig more than once; this Set
287
- // prevents the same warning from being echoed on each invocation.
288
- const _warnedUnknownConfigKeys = new Set();
289
- function loadConfig(cwd, options = {}) {
290
- const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
291
- ? options['workstream']
292
- : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
293
- ? options['workstreamContext']['ws']
294
- : (process.env['GSD_WORKSTREAM'] || null);
295
- // When GSD_WORKSTREAM is set, load root config first so workstream config
296
- // can inherit from it. This prevents users from duplicating model_overrides,
297
- // workflow.*, etc. across every workstream config (#2714).
298
- const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
299
- // #315 — per-call lazy memo: all three detection sites inside this loadConfig
300
- // call operate on the same cwd and the subrepo set cannot change mid-call, so
301
- // a single scan is sufficient. The memo is scoped to THIS call (not module-level)
302
- // so separate loadConfig invocations each get a fresh scan.
303
- let cachedSubRepos;
304
- const getDetectedSubRepos = () => {
305
- if (cachedSubRepos === undefined)
306
- cachedSubRepos = detectSubRepos(cwd);
307
- // Return a copy: original detectSubRepos returned a fresh array per call,
308
- // so each site must keep an independent array (avoid cross-site aliasing).
309
- return cachedSubRepos.slice();
310
- };
311
- let rootParsed = null;
312
- if (ws) {
313
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
314
- try {
315
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
316
- if (raw === null)
317
- throw new Error('missing');
318
- rootParsed = JSON.parse(raw);
319
- // Cycle 4: delegate all legacy-key normalization to the Configuration Module.
320
- const { parsed: rootNormalized, normalizations: rootNorms } = (0, configuration_cjs_1.normalizeLegacyKeys)(rootParsed);
321
- if (rootNorms.length > 0) {
322
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos)
323
- for (const norm of rootNorms) {
324
- if (norm.requiresFilesystem && !rootNormalized.planning?.['sub_repos']) {
325
- const detected = getDetectedSubRepos();
326
- if (detected.length > 0) {
327
- if (!rootNormalized.planning)
328
- rootNormalized.planning = {};
329
- rootNormalized.planning['sub_repos'] = detected;
330
- rootNormalized.planning['commit_docs'] = false;
331
- }
332
- }
333
- }
334
- rootParsed = rootNormalized;
335
- try {
336
- (0, shell_command_projection_cjs_1.platformWriteSync)(rootConfigPath, JSON.stringify(rootParsed, null, 2));
337
- }
338
- catch { /* ignore */ }
339
- }
340
- else {
341
- rootParsed = rootNormalized;
342
- }
343
- }
344
- catch {
345
- // Root config missing or unparseable — workstream config stands alone
346
- }
347
- }
348
- const configPath = node_path_1.default.join(planningDir(cwd, ws), 'config.json');
349
- const defaults = CONFIG_DEFAULTS;
350
- try {
351
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
352
- if (raw === null)
353
- throw new Error('missing');
354
- // `fileData` is the parsed content of the config.json file on disk — used
355
- // for migrations and writes so we never persist merged values back to disk.
356
- const fileData = JSON.parse(raw);
357
- // Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration
358
- // blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos,
359
- // branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk
360
- // writeback is handled below with the existing platformWriteSync pattern.
361
- let configDirty = false;
362
- {
363
- const { parsed: normalized, normalizations } = (0, configuration_cjs_1.normalizeLegacyKeys)(fileData);
364
- if (normalizations.length > 0) {
365
- // Merge normalized values back into fileData (mutation-in-place for legacy code below)
366
- Object.keys(fileData).forEach(k => delete fileData[k]);
367
- Object.assign(fileData, normalized);
368
- configDirty = true;
369
- // Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos).
370
- for (const norm of normalizations) {
371
- if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
372
- const detected = getDetectedSubRepos();
373
- if (detected.length > 0) {
374
- if (!fileData.planning)
375
- fileData.planning = {};
376
- fileData.planning['sub_repos'] = detected;
377
- fileData.planning['commit_docs'] = false;
378
- }
379
- }
380
- }
381
- }
382
- }
383
- // Keep planning.sub_repos in sync with actual filesystem
384
- const currentSubRepos = fileData.planning?.['sub_repos'] || [];
385
- if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
386
- const detected = getDetectedSubRepos();
387
- if (detected.length > 0) {
388
- const sorted = [...currentSubRepos].sort();
389
- if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
390
- if (!fileData.planning)
391
- fileData.planning = {};
392
- fileData.planning['sub_repos'] = detected;
393
- configDirty = true;
394
- }
395
- }
396
- }
397
- // Persist sub_repos changes (migration or sync) — write only the on-disk
398
- // file contents, never the merged result, to avoid polluting workstream configs.
399
- if (configDirty) {
400
- try {
401
- (0, shell_command_projection_cjs_1.platformWriteSync)(configPath, JSON.stringify(fileData, null, 2));
402
- }
403
- catch { /* ignore */ }
404
- }
405
- // Now apply root→workstream inheritance. `parsed` is the effective config
406
- // used for value extraction below; fileData is kept for disk writes only.
407
- const parsed = rootParsed
408
- ? (_deepMergeConfig(rootParsed, fileData) ?? fileData)
409
- : fileData;
410
- // Warn about unrecognized top-level keys so users don't silently lose config.
411
- const KNOWN_TOP_LEVEL = new Set([
412
- // Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
413
- ...[...VALID_CONFIG_KEYS].map((k) => k.split('.')[0]),
414
- // Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
415
- ...DYNAMIC_KEY_PATTERNS.map(p => p.topLevel),
416
- // Internal keys loadConfig reads but config-set doesn't expose
417
- 'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
418
- // Deprecated keys (still accepted for migration, not in config-set)
419
- 'depth', 'multiRepo', 'branching_strategy',
420
- ]);
421
- const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
422
- if (unknownKeys.length > 0) {
423
- const warnKey = unknownKeys.join(',');
424
- if (!_warnedUnknownConfigKeys.has(warnKey)) {
425
- _warnedUnknownConfigKeys.add(warnKey);
426
- process.stderr.write(`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`);
427
- }
428
- }
429
- // #2517 — Validate runtime/tier values
430
- _warnUnknownProfileOverrides(parsed, '.planning/config.json');
431
- const get = (key, nested) => {
432
- if (parsed[key] !== undefined)
433
- return parsed[key];
434
- if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) {
435
- const sec = parsed[nested.section];
436
- if (sec[nested.field] !== undefined) {
437
- return sec[nested.field];
438
- }
439
- }
440
- return undefined;
441
- };
442
- const parallelization = (() => {
443
- const val = get('parallelization');
444
- if (typeof val === 'boolean')
445
- return val;
446
- if (typeof val === 'object' && val !== null && 'enabled' in (val))
447
- return val['enabled'];
448
- return defaults.parallelization;
449
- })();
450
- return {
451
- model_profile: get('model_profile') ?? defaults.model_profile,
452
- commit_docs: (() => {
453
- const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
454
- // If explicitly set in config, respect the user's choice
455
- if (explicit !== undefined)
456
- return explicit;
457
- // Auto-detection: when no explicit value and .planning/ is gitignored,
458
- // default to false instead of true
459
- if (isGitIgnored(cwd, '.planning/'))
460
- return false;
461
- return defaults.commit_docs;
462
- })(),
463
- search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
464
- branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
465
- phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
466
- milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
467
- quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
468
- research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
469
- plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
470
- verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
471
- nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
472
- post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps,
473
- parallelization,
474
- brave_search: get('brave_search') ?? defaults.brave_search,
475
- firecrawl: get('firecrawl') ?? defaults.firecrawl,
476
- exa_search: get('exa_search') ?? defaults.exa_search,
477
- tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
478
- mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
479
- text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
480
- auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
481
- _auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
482
- mode: get('mode') ?? 'interactive',
483
- sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
484
- resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
485
- context_window: get('context_window') ?? defaults.context_window,
486
- phase_naming: get('phase_naming') ?? defaults.phase_naming,
487
- project_code: get('project_code') ?? defaults.project_code,
488
- subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
489
- model_overrides: (parsed['model_overrides']) || null,
490
- // #3023 — per-phase-type model map.
491
- models: (parsed['models']) || null,
492
- // #68 — top-level granularity
493
- granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
494
- // #68 — per-phase-type granularity map.
495
- granularities: (parsed['granularities']) || null,
496
- // #68 — planning sub-object
497
- planning: (parsed['planning']) || null,
498
- // #3024 — dynamic routing block.
499
- dynamic_routing: (parsed['dynamic_routing']) || null,
500
- // #2517 — runtime-aware profiles.
501
- runtime: (parsed['runtime']) || null,
502
- model_profile_overrides: (parsed['model_profile_overrides']) || null,
503
- // #49 — provider-neutral model policy presets.
504
- model_policy: (parsed['model_policy']) || null,
505
- // #443 — effort/fast_mode
506
- effort: (parsed['effort']) || null,
507
- fast_mode: (parsed['fast_mode']) || null,
508
- agent_skills: (parsed['agent_skills']) || {},
509
- agent_skills_security: (parsed['agent_skills_security']) || null,
510
- manager: (parsed['manager']) || {},
511
- response_language: get('response_language') || null,
512
- claude_md_path: get('claude_md_path') || null,
513
- claude_md_assembly: (parsed['claude_md_assembly']) || null,
514
- };
515
- }
516
- catch {
517
- // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
518
- if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
519
- if (rootParsed) {
520
- // Workstream has no config.json: re-parse using root config as the sole source.
521
- return loadConfig(cwd, { workstream: null });
522
- }
523
- return defaults;
524
- }
525
- try {
526
- const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
527
- const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
528
- const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
529
- if (raw === null)
530
- throw new Error('missing');
531
- const globalDefaults = JSON.parse(raw);
532
- return {
533
- ...defaults,
534
- model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
535
- commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
536
- research: (globalDefaults['research']) ?? defaults.research,
537
- plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker,
538
- verifier: (globalDefaults['verifier']) ?? defaults.verifier,
539
- nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation,
540
- post_planning_gaps: (globalDefaults['post_planning_gaps'])
541
- ?? globalDefaults['workflow']?.['post_planning_gaps']
542
- ?? defaults.post_planning_gaps,
543
- parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization,
544
- text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode,
545
- resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids,
546
- context_window: (globalDefaults['context_window']) ?? defaults.context_window,
547
- subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout,
548
- model_overrides: (globalDefaults['model_overrides']) || null,
549
- models: (globalDefaults['models']) || null,
550
- granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null,
551
- granularities: (globalDefaults['granularities']) || null,
552
- planning: (globalDefaults['planning']) || null,
553
- dynamic_routing: (globalDefaults['dynamic_routing']) || null,
554
- effort: (globalDefaults['effort']) || null,
555
- fast_mode: (globalDefaults['fast_mode']) || null,
556
- agent_skills: (globalDefaults['agent_skills']) || {},
557
- response_language: (globalDefaults['response_language']) || null,
558
- };
559
- }
560
- catch {
561
- return defaults;
562
- }
563
- }
564
- }
565
- // ─── Git utilities ────────────────────────────────────────────────────────────
566
- const _gitIgnoredCache = new Map();
567
- function isGitIgnored(cwd, targetPath) {
568
- const key = cwd + '::' + targetPath;
569
- if (_gitIgnoredCache.has(key))
570
- return _gitIgnoredCache.get(key);
571
- // --no-index checks .gitignore rules regardless of whether the file is tracked.
572
- const result = (0, shell_command_projection_cjs_1.execGit)(['check-ignore', '-q', '--no-index', '--', targetPath], { cwd });
573
- const ignored = result.exitCode === 0;
574
- _gitIgnoredCache.set(key, ignored);
575
- return ignored;
576
- }
56
+ // loadConfig, isGitIgnored, CONFIG_DEFAULTS, and related helpers moved to
57
+ // config-loader.cjs (ADR-857 phase 2e / #885). The destructured bindings above
58
+ // (from configLoaderModule) make them available to core-internal callers;
59
+ // core.cjs re-exports loadConfig and isGitIgnored for back-compat.
577
60
  // ─── Common path helpers ──────────────────────────────────────────────────────
578
61
  /**
579
62
  * Resolve the main worktree root when running inside a git worktree.
@@ -620,500 +103,23 @@ function pruneOrphanedWorktrees(repoRoot) {
620
103
  return [];
621
104
  }
622
105
  // ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
623
- // ─── Phase utilities ──────────────────────────────────────────────────────────
624
- function escapeRegex(value) {
625
- return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
626
- }
627
- function normalizePhaseName(phase) {
628
- const str = String(phase);
629
- // Strip optional project_code prefix (e.g., 'CK-01' → '01')
630
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
631
- // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
632
- const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
633
- if (milestoneMatch) {
634
- const major = milestoneMatch[1].padStart(2, '0');
635
- const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
636
- const suffix = milestoneMatch[3] || '';
637
- return `${major}-${subSegments.join('-')}${suffix}`;
638
- }
639
- // Standard numeric phases: 1, 01, 12A, 12.1
640
- const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
641
- if (match) {
642
- const padded = match[1].padStart(2, '0');
643
- // Preserve original case of letter suffix (#1962).
644
- const letter = match[2] || '';
645
- const decimal = match[3] || '';
646
- return padded + letter + decimal;
647
- }
648
- // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
649
- return str;
650
- }
651
- function getMilestoneFromPhaseId(phaseId) {
652
- const str = String(phaseId);
653
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
654
- const m = stripped.match(/^0*(\d+)-\d/);
655
- if (!m)
656
- return null;
657
- const major = parseInt(m[1], 10);
658
- if (major === 0 || major === 999)
659
- return null;
660
- return `v${major}.0`;
661
- }
662
- function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
663
- const str = String(phaseId);
664
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
665
- const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
666
- if (!m)
667
- return null;
668
- const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
669
- const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
670
- const sub = subParts.join('-');
671
- const slug = phaseName
672
- ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
673
- : '';
674
- const parts = [milestone, sub, slug].filter(Boolean);
675
- const base = parts.join('-');
676
- return projectCode ? `${projectCode}-${base}` : base;
677
- }
678
- /**
679
- * Render a regex source fragment matching a phase number against ROADMAP/STATE
680
- * prose regardless of zero-padding on either side.
681
- */
682
- function phaseMarkdownRegexSource(phaseNum) {
683
- const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
684
- // Milestone-prefixed IDs: M-NN or M-N-N (deep).
685
- const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
686
- if (milestoneSegments && milestoneSegments[2]) {
687
- const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
688
- const subParts = milestoneSegments[2].slice(1).split('-');
689
- const subFragments = subParts.map(s => {
690
- const unpadded = s.replace(/^0+/, '') || '0';
691
- return `0*${escapeRegex(unpadded)}`;
692
- });
693
- const suffix = milestoneSegments[3] || '';
694
- const suffixFragment = suffix ? escapeRegex(suffix) : '';
695
- return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
696
- }
697
- // Plain numeric phase: 1, 01, 12A, 12.1
698
- const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
699
- if (!match)
700
- return escapeRegex(phaseNum);
701
- const integer = match[1].replace(/^0+/, '') || '0';
702
- const letter = match[2] ? escapeRegex(match[2]) : '';
703
- const decimal = match[3] ? escapeRegex(match[3]) : '';
704
- return `0*${escapeRegex(integer)}${letter}${decimal}`;
705
- }
706
- /**
707
- * #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
708
- * return the exact-escaped form.
709
- */
710
- function phaseMarkdownRegexSourceExact(phaseNum) {
711
- const raw = String(phaseNum);
712
- if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw))
713
- return null;
714
- return escapeRegex(raw);
715
- }
716
- function comparePhaseNum(a, b) {
717
- // Strip optional project_code prefix before comparing
718
- const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
719
- const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
720
- const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
721
- const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
722
- if (milestoneA && milestoneB) {
723
- const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
724
- const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
725
- const maxSegs = Math.max(segsA.length, segsB.length);
726
- for (let i = 0; i < maxSegs; i++) {
727
- const av = segsA[i] !== undefined ? segsA[i] : 0;
728
- const bv = segsB[i] !== undefined ? segsB[i] : 0;
729
- if (av !== bv)
730
- return av - bv;
731
- }
732
- const sufA = milestoneA[3] || '';
733
- const sufB = milestoneB[3] || '';
734
- if (sufA !== sufB)
735
- return sufA < sufB ? -1 : 1;
736
- return 0;
737
- }
738
- if (milestoneA || milestoneB)
739
- return String(a).localeCompare(String(b));
740
- const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
741
- const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
742
- if (!pa || !pb)
743
- return String(a).localeCompare(String(b));
744
- const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
745
- if (intDiff !== 0)
746
- return intDiff;
747
- const la = (pa[2] || '').toUpperCase();
748
- const lb = (pb[2] || '').toUpperCase();
749
- if (la !== lb) {
750
- if (!la)
751
- return -1;
752
- if (!lb)
753
- return 1;
754
- return la < lb ? -1 : 1;
755
- }
756
- const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
757
- const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
758
- const maxLen = Math.max(aDecParts.length, bDecParts.length);
759
- if (aDecParts.length === 0 && bDecParts.length > 0)
760
- return -1;
761
- if (bDecParts.length === 0 && aDecParts.length > 0)
762
- return 1;
763
- for (let i = 0; i < maxLen; i++) {
764
- const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
765
- const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
766
- if (av !== bv)
767
- return av - bv;
768
- }
769
- return 0;
770
- }
771
- /**
772
- * Extract the phase token from a directory name.
773
- */
774
- function extractPhaseToken(dirName) {
775
- const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
776
- let prefix = '';
777
- let rest = dirName;
778
- if (codePrefixMatch) {
779
- prefix = codePrefixMatch[1] + '-';
780
- rest = codePrefixMatch[2];
781
- }
782
- const segments = rest.split('-');
783
- const tokenSegments = [];
784
- for (let i = 0; i < segments.length; i++) {
785
- const seg = segments[i];
786
- if (/^\d/.test(seg)) {
787
- tokenSegments.push(seg);
788
- }
789
- else {
790
- break;
791
- }
792
- }
793
- if (tokenSegments.length === 0) {
794
- return dirName;
795
- }
796
- return prefix + tokenSegments.join('-');
797
- }
798
- /**
799
- * Check if a directory name's phase token matches the normalized phase exactly.
800
- */
801
- function phaseTokenMatches(dirName, normalized) {
802
- const token = extractPhaseToken(dirName);
803
- if (token.toUpperCase() === normalized.toUpperCase())
804
- return true;
805
- const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
806
- if (stripped !== dirName) {
807
- const strippedToken = extractPhaseToken(stripped);
808
- if (strippedToken.toUpperCase() === normalized.toUpperCase())
809
- return true;
810
- }
811
- return false;
812
- }
813
- function extractCanonicalPlanId(filename) {
814
- const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
815
- const parts = base.split('-').filter(Boolean);
816
- const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
817
- const phaseIdx = parts.findIndex(p => tokenRe.test(p));
818
- if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
819
- return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
820
- }
821
- return base;
822
- }
823
- function searchPhaseInDir(baseDir, relBase, normalized) {
824
- try {
825
- const dirs = readSubdirectories(baseDir, true);
826
- const match = dirs.find(d => phaseTokenMatches(d, normalized));
827
- if (!match)
828
- return null;
829
- const phaseToken = extractPhaseToken(match);
830
- const phaseNumber = phaseToken || normalized;
831
- const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
832
- const phaseName = afterToken || null;
833
- const phaseDir = node_path_1.default.join(baseDir, match);
834
- const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
835
- const plans = unsortedPlans.sort();
836
- const summaries = unsortedSummaries.sort();
837
- const completedPlanIds = new Set(summaries.flatMap(s => {
838
- const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
839
- const canonical = extractCanonicalPlanId(s);
840
- return canonical === exact ? [exact] : [exact, canonical];
841
- }));
842
- const incompletePlans = plans.filter(p => {
843
- const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
844
- const canonical = extractCanonicalPlanId(p);
845
- return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
846
- });
847
- return {
848
- found: true,
849
- directory: toPosixPath(node_path_1.default.join(relBase, match)),
850
- phase_number: phaseNumber,
851
- phase_name: phaseName,
852
- phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
853
- plans,
854
- summaries,
855
- incomplete_plans: incompletePlans,
856
- has_research: hasResearch,
857
- has_context: hasContext,
858
- has_verification: hasVerification,
859
- has_reviews: hasReviews,
860
- };
861
- }
862
- catch {
863
- return null;
864
- }
865
- }
866
- function findPhaseInternal(cwd, phase) {
867
- if (!phase)
868
- return null;
869
- const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
870
- const normalized = normalizePhaseName(phase);
871
- const relPhasesDir = toPosixPath(node_path_1.default.relative(cwd, phasesDir));
872
- const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
873
- if (current)
874
- return current;
875
- const milestonesDir = node_path_1.default.join(cwd, '.planning', 'milestones');
876
- if (!node_fs_1.default.existsSync(milestonesDir))
877
- return null;
878
- try {
879
- const milestoneEntries = node_fs_1.default.readdirSync(milestonesDir, { withFileTypes: true });
880
- const archiveDirs = milestoneEntries
881
- .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
882
- .map(e => e.name)
883
- .sort()
884
- .reverse();
885
- for (const archiveName of archiveDirs) {
886
- const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
887
- const version = versionMatch[1];
888
- const archivePath = node_path_1.default.join(milestonesDir, archiveName);
889
- const relBase = '.planning/milestones/' + archiveName;
890
- const result = searchPhaseInDir(archivePath, relBase, normalized);
891
- if (result) {
892
- result.archived = version;
893
- return result;
894
- }
895
- }
896
- }
897
- catch { /* intentionally empty */ }
898
- return null;
899
- }
900
- function getArchivedPhaseDirs(cwd) {
901
- const milestonesDir = node_path_1.default.join(cwd, '.planning', 'milestones');
902
- const results = [];
903
- if (!node_fs_1.default.existsSync(milestonesDir))
904
- return results;
905
- try {
906
- const milestoneEntries = node_fs_1.default.readdirSync(milestonesDir, { withFileTypes: true });
907
- const phaseDirs = milestoneEntries
908
- .filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
909
- .map(e => e.name)
910
- .sort()
911
- .reverse();
912
- for (const archiveName of phaseDirs) {
913
- const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
914
- const version = versionMatch[1];
915
- const archivePath = node_path_1.default.join(milestonesDir, archiveName);
916
- const dirs = readSubdirectories(archivePath, true);
917
- for (const dir of dirs) {
918
- results.push({
919
- name: dir,
920
- milestone: version,
921
- basePath: node_path_1.default.join('.planning', 'milestones', archiveName),
922
- fullPath: node_path_1.default.join(archivePath, dir),
923
- });
924
- }
925
- }
926
- }
927
- catch { /* intentionally empty */ }
928
- return results;
929
- }
930
- // ─── Roadmap milestone scoping ───────────────────────────────────────────────
931
- /**
932
- * Strip shipped milestone content wrapped in <details> blocks.
933
- */
934
- function stripShippedMilestones(content) {
935
- return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
936
- }
937
- /**
938
- * Extract the current milestone section from ROADMAP.md by positive lookup.
939
- */
940
- function extractCurrentMilestone(content, cwd) {
941
- if (!cwd)
942
- return stripShippedMilestones(content);
943
- let version = null;
944
- try {
945
- const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
946
- const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
947
- if (stateRaw !== null) {
948
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
949
- if (milestoneMatch) {
950
- version = milestoneMatch[1].trim();
951
- }
952
- }
953
- }
954
- catch { /* ignore */ }
955
- if (!version) {
956
- const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
957
- if (inProgressMatch) {
958
- version = 'v' + inProgressMatch[1];
959
- }
960
- }
961
- if (!version)
962
- return stripShippedMilestones(content);
963
- const escapedVersion = escapeRegex(version);
964
- const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`, 'gmi');
965
- const summaryPattern = new RegExp(`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`, 'i');
966
- const headingMatches = [...content.matchAll(sectionPattern)];
967
- if (headingMatches.length === 0) {
968
- const summaryMatch = content.match(summaryPattern);
969
- if (summaryMatch) {
970
- const summaryIdx = content.indexOf(summaryMatch[0]);
971
- const beforeSummary = content.slice(0, summaryIdx);
972
- const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
973
- if (detailsOpenIdx !== -1) {
974
- const afterDetails = content.slice(detailsOpenIdx);
975
- const closingMatch = afterDetails.match(/<\/details>/i);
976
- const detailsEnd = closingMatch
977
- ? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
978
- : content.length;
979
- const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
980
- const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
981
- const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
982
- const preamble = content.slice(0, preambleCutoff)
983
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
984
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
985
- .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
986
- return preamble + content.slice(detailsOpenIdx, detailsEnd);
987
- }
988
- }
989
- return stripShippedMilestones(content);
990
- }
991
- const allMatches = headingMatches;
992
- const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
993
- const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
994
- const isClosed = (h) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
995
- const firstMatch = allMatches[0];
996
- const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
997
- const sectionStart = selected.index;
998
- const computeSectionEnd = (headingText, headingStart) => {
999
- const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
1000
- const rest = content.slice(headingStart + headingText.length);
1001
- const stopPattern = new RegExp(`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
1002
- let end = content.length;
1003
- let fc = null;
1004
- let fl = 0;
1005
- let off = 0;
1006
- for (const line of rest.split('\n')) {
1007
- const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
1008
- if (fm) {
1009
- const ch = fm[1][0];
1010
- const ln = fm[1].length;
1011
- const trailing = fm[2] || '';
1012
- if (!fc) {
1013
- fc = ch;
1014
- fl = ln;
1015
- }
1016
- else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
1017
- fc = null;
1018
- fl = 0;
1019
- }
1020
- }
1021
- else if (!fc && stopPattern.test(line)) {
1022
- end = headingStart + headingText.length + off;
1023
- break;
1024
- }
1025
- off += line.length + 1;
1026
- }
1027
- return end;
1028
- };
1029
- const sectionEnd = computeSectionEnd(selected[0], sectionStart);
1030
- const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
1031
- const firstMilestoneMatch = content.match(anyMilestonePattern);
1032
- const preambleCutoff = firstMilestoneMatch
1033
- ? firstMilestoneMatch.index
1034
- : firstMatch.index;
1035
- const beforeMilestones = content.slice(0, preambleCutoff);
1036
- const currentSection = content.slice(sectionStart, sectionEnd);
1037
- // Multi-milestone roadmaps split each added milestone across two version-bearing
1038
- // headings: a `## Phases` checklist subsection (early) and a dedicated
1039
- // `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
1040
- // detail headers. The scope window above stops at the next version-bearing
1041
- // heading — the current milestone's OWN Phase Details heading — leaving those
1042
- // detail headers outside `currentSection`. Append that section so phase
1043
- // resolution and counting see the current milestone's phases. Anchor the lookup
1044
- // to the SELECTED heading's specific version token (boundary-aware, so a
1045
- // `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
1046
- // that share a version prefix do not cross-pollinate. (#730)
1047
- const selectedVersionToken = selected[1].match(/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i)?.[0];
1048
- const detailsVersionBoundary = selectedVersionToken
1049
- ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
1050
- : null;
1051
- let detailsSection = '';
1052
- const detailsMatch = allMatches.find((m) => /\(Phase\s+Details\)/i.test(m[1]) &&
1053
- !isClosed(m[1]) &&
1054
- (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
1055
- (m.index ?? 0) >= sectionEnd);
1056
- if (detailsMatch) {
1057
- const detailsStart = detailsMatch.index ?? 0;
1058
- detailsSection = content.slice(detailsStart, computeSectionEnd(detailsMatch[0], detailsStart));
1059
- }
1060
- const preamble = beforeMilestones
1061
- .replace(/<details>[\s\S]*?<\/details>/gi, '')
1062
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
1063
- .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
1064
- return detailsSection
1065
- ? preamble + currentSection + '\n' + detailsSection
1066
- : preamble + currentSection;
1067
- }
1068
- /**
1069
- * Replace a pattern only in the current milestone section of ROADMAP.md.
1070
- */
1071
- function replaceInCurrentMilestone(content, pattern, replacement) {
1072
- const lastDetailsClose = content.lastIndexOf('</details>');
1073
- if (lastDetailsClose === -1) {
1074
- return content.replace(pattern, replacement);
1075
- }
1076
- const offset = lastDetailsClose + '</details>'.length;
1077
- const before = content.slice(0, offset);
1078
- const after = content.slice(offset);
1079
- return before + after.replace(pattern, replacement);
1080
- }
1081
- function getRoadmapPhaseInternal(cwd, phaseNum) {
1082
- if (!phaseNum)
1083
- return null;
1084
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1085
- if (!node_fs_1.default.existsSync(roadmapPath))
1086
- return null;
1087
- try {
1088
- const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1089
- if (roadmapRaw === null)
1090
- throw new Error('missing');
1091
- const content = extractCurrentMilestone(roadmapRaw, cwd);
1092
- const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
1093
- const headerMatch = content.match(phasePattern);
1094
- if (!headerMatch)
1095
- return null;
1096
- const phaseName = headerMatch[1].trim();
1097
- const headerIndex = headerMatch.index;
1098
- const restOfContent = content.slice(headerIndex);
1099
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
1100
- const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
1101
- const section = content.slice(headerIndex, sectionEnd).trim();
1102
- const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
1103
- const goal = goalMatch ? goalMatch[1].trim() : null;
1104
- return {
1105
- found: true,
1106
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
1107
- phase_number: String(phaseNum),
1108
- phase_name: phaseName,
1109
- goal,
1110
- section,
1111
- };
1112
- }
1113
- catch {
1114
- return null;
1115
- }
1116
- }
106
+ // ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ─────────────
107
+ // escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId,
108
+ // phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum,
109
+ // extractPhaseToken, phaseTokenMatches
110
+ // — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
111
+ // extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
112
+ // It is consumed exclusively by phase-locator.cjs, which imports it from
113
+ // core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
114
+ // in core.cjs's public export = block (it was never public).
115
+ // searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
116
+ // (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
117
+ // make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
118
+ // getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
119
+ // ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
120
+ // stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,
121
+ // getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter
122
+ // — all imported via `roadmapParserModule` above; internal callers use the destructured bindings.
1117
123
  // ─── Agent installation validation (#1371) ───────────────────────────────────
1118
124
  /**
1119
125
  * Resolve the agents directory for the given runtime.
@@ -1161,7 +167,15 @@ function checkAgentsInstalled(runtime) {
1161
167
  const agentFile = node_path_1.default.join(agentsDir, `${agent}.md`);
1162
168
  const agentFileCopilot = node_path_1.default.join(agentsDir, `${agent}.agent.md`);
1163
169
  const agentFileCodex = node_path_1.default.join(agentsDir, `${agent}.toml`);
1164
- if (node_fs_1.default.existsSync(agentFile) || node_fs_1.default.existsSync(agentFileCopilot) || node_fs_1.default.existsSync(agentFileCodex)) {
170
+ const agentFileKimiYaml = node_path_1.default.join(agentsDir, 'subagents', `${agent}.yaml`);
171
+ const agentFileKimiPrompt = node_path_1.default.join(agentsDir, 'subagents', `${agent}.md`);
172
+ const kimiAgentInstalled = resolvedRuntime === 'kimi' &&
173
+ node_fs_1.default.existsSync(agentFileKimiYaml) &&
174
+ node_fs_1.default.existsSync(agentFileKimiPrompt);
175
+ if (node_fs_1.default.existsSync(agentFile) ||
176
+ node_fs_1.default.existsSync(agentFileCopilot) ||
177
+ node_fs_1.default.existsSync(agentFileCodex) ||
178
+ kimiAgentInstalled) {
1165
179
  installed.push(agent);
1166
180
  }
1167
181
  else {
@@ -1176,527 +190,6 @@ function checkAgentsInstalled(runtime) {
1176
190
  agent_runtime: resolvedRuntime,
1177
191
  };
1178
192
  }
1179
- // ─── Model alias resolution ───────────────────────────────────────────────────
1180
- const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
1181
- const _warnedConfigKeys = new Set();
1182
- function _warnUnknownProfileOverrides(parsed, configLabel) {
1183
- if (!parsed || typeof parsed !== 'object')
1184
- return;
1185
- const runtime = parsed['runtime'];
1186
- if (runtime && typeof runtime === 'string' && !(model_catalog_cjs_1.KNOWN_RUNTIMES).has(runtime)) {
1187
- const key = `${configLabel}::runtime::${runtime}`;
1188
- if (!_warnedConfigKeys.has(key)) {
1189
- _warnedConfigKeys.add(key);
1190
- try {
1191
- process.stderr.write(`gsd: warning — config key "runtime" has unknown value "${runtime}". ` +
1192
- `Known runtimes: ${[...(model_catalog_cjs_1.KNOWN_RUNTIMES)].sort().join(', ')}. ` +
1193
- `Resolution will fall back to safe defaults. (#2517)\n`);
1194
- }
1195
- catch { /* stderr might be closed in some test harnesses */ }
1196
- }
1197
- }
1198
- const overrides = parsed['model_profile_overrides'];
1199
- if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) {
1200
- for (const [overrideRuntime, tierMap] of Object.entries(overrides)) {
1201
- if (!(model_catalog_cjs_1.KNOWN_RUNTIMES).has(overrideRuntime)) {
1202
- const key = `${configLabel}::override-runtime::${overrideRuntime}`;
1203
- if (!_warnedConfigKeys.has(key)) {
1204
- _warnedConfigKeys.add(key);
1205
- try {
1206
- process.stderr.write(`gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
1207
- `unknown runtime "${overrideRuntime}". Known runtimes: ` +
1208
- `${[...(model_catalog_cjs_1.KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n`);
1209
- }
1210
- catch { /* ok */ }
1211
- }
1212
- }
1213
- if (!tierMap || typeof tierMap !== 'object')
1214
- continue;
1215
- for (const tierName of Object.keys(tierMap)) {
1216
- if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
1217
- const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
1218
- if (!_warnedConfigKeys.has(key)) {
1219
- _warnedConfigKeys.add(key);
1220
- try {
1221
- process.stderr.write(`gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
1222
- `uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`);
1223
- }
1224
- catch { /* ok */ }
1225
- }
1226
- }
1227
- }
1228
- }
1229
- }
1230
- const policy = parsed['model_policy'];
1231
- if (policy && typeof policy === 'object' && !Array.isArray(policy)) {
1232
- const policyObj = policy;
1233
- const provider = policyObj['provider'];
1234
- const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']);
1235
- if (provider && typeof provider === 'string' &&
1236
- !(model_catalog_cjs_1.KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) {
1237
- const pkey = `${configLabel}::model_policy::provider::${provider}`;
1238
- if (!_warnedConfigKeys.has(pkey)) {
1239
- _warnedConfigKeys.add(pkey);
1240
- try {
1241
- process.stderr.write(`gsd: warning — model_policy.provider has unknown value "${provider}". ` +
1242
- `Known providers: ${[...(model_catalog_cjs_1.KNOWN_PROVIDERS)].sort().join(', ')}. ` +
1243
- `For manual model IDs use provider="custom". (#49)\n`);
1244
- }
1245
- catch { /* ok */ }
1246
- }
1247
- }
1248
- const rtOverrides = policyObj['runtime_tiers'];
1249
- if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) {
1250
- for (const [pruntime, tierMap] of Object.entries(rtOverrides)) {
1251
- if (!(model_catalog_cjs_1.KNOWN_RUNTIMES).has(pruntime)) {
1252
- const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`;
1253
- if (!_warnedConfigKeys.has(key)) {
1254
- _warnedConfigKeys.add(key);
1255
- try {
1256
- process.stderr.write(`gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` +
1257
- `unknown runtime "${pruntime}". Known runtimes: ` +
1258
- `${[...(model_catalog_cjs_1.KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n`);
1259
- }
1260
- catch { /* ok */ }
1261
- }
1262
- }
1263
- if (!tierMap || typeof tierMap !== 'object')
1264
- continue;
1265
- for (const tierName of Object.keys(tierMap)) {
1266
- if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
1267
- const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`;
1268
- if (!_warnedConfigKeys.has(key)) {
1269
- _warnedConfigKeys.add(key);
1270
- try {
1271
- process.stderr.write(`gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` +
1272
- `uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n`);
1273
- }
1274
- catch { /* ok */ }
1275
- }
1276
- }
1277
- }
1278
- }
1279
- }
1280
- }
1281
- }
1282
- // Internal helper exposed for tests so per-process warning state can be reset
1283
- // between cases that intentionally exercise the warning path repeatedly.
1284
- function _resetRuntimeWarningCacheForTests() {
1285
- _warnedConfigKeys.clear();
1286
- }
1287
- /**
1288
- * #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
1289
- */
1290
- function resolveTierEntry({ runtime, tier, overrides }) {
1291
- if (!runtime || !tier)
1292
- return null;
1293
- const runtimeMap = model_catalog_cjs_1.RUNTIME_PROFILE_MAP;
1294
- const builtin = runtimeMap[runtime]?.[tier] || null;
1295
- const overridesMap = overrides;
1296
- const userRaw = overridesMap?.[runtime]?.[tier];
1297
- let userEntry = null;
1298
- if (userRaw) {
1299
- userEntry = typeof userRaw === 'string' ? { model: userRaw } : userRaw;
1300
- }
1301
- if (!builtin && !userEntry)
1302
- return null;
1303
- return { ...(builtin || {}), ...(userEntry || {}) };
1304
- }
1305
- /**
1306
- * Convenience wrapper used by resolveModelInternal.
1307
- */
1308
- function _resolveRuntimeTier(config, tier) {
1309
- return resolveTierEntry({
1310
- runtime: config['runtime'],
1311
- tier,
1312
- overrides: config['model_profile_overrides'],
1313
- });
1314
- }
1315
- /**
1316
- * #49 — Provider-neutral model policy preset resolution.
1317
- */
1318
- function resolveModelPolicy(policy, tier) {
1319
- if (!policy || typeof policy !== 'object')
1320
- return null;
1321
- if (!tier)
1322
- return null;
1323
- const runtime = policy['runtime'];
1324
- const rtOverrides = policy['runtime_tiers'];
1325
- if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
1326
- const rtOverridesMap = rtOverrides;
1327
- if (Object.hasOwn(rtOverridesMap, runtime)) {
1328
- const runtimeEntry = rtOverridesMap[runtime];
1329
- if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
1330
- const raw = runtimeEntry[tier];
1331
- if (raw != null) {
1332
- const entry = typeof raw === 'string' ? { model: raw } : raw;
1333
- if (entry && entry['model'])
1334
- return entry['model'];
1335
- }
1336
- }
1337
- }
1338
- }
1339
- const provider = policy['provider'];
1340
- if (!provider || typeof provider !== 'string')
1341
- return null;
1342
- if (provider === 'generic' || provider === 'custom') {
1343
- const TIER_TO_POLICY_KEY = { opus: 'high', sonnet: 'medium', haiku: 'low' };
1344
- const policyKey = TIER_TO_POLICY_KEY[tier];
1345
- if (!policyKey)
1346
- return null;
1347
- const v = policy[policyKey];
1348
- return (v && typeof v === 'string') ? v : null;
1349
- }
1350
- const presetsMap = model_catalog_cjs_1.PROVIDER_PRESETS;
1351
- if (!Object.hasOwn(presetsMap, provider))
1352
- return null;
1353
- const presetForProvider = presetsMap[provider];
1354
- if (!presetForProvider || typeof presetForProvider !== 'object')
1355
- return null;
1356
- if (!Object.hasOwn(presetForProvider, tier))
1357
- return null;
1358
- const tierPresets = presetForProvider[tier];
1359
- if (!tierPresets || typeof tierPresets !== 'object')
1360
- return null;
1361
- const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
1362
- if (!Object.hasOwn(tierPresets, budget))
1363
- return null;
1364
- const budgetEntry = tierPresets[budget];
1365
- if (!budgetEntry || !budgetEntry.model)
1366
- return null;
1367
- return budgetEntry.model;
1368
- }
1369
- function resolveModelInternal(cwd, agentType) {
1370
- const config = loadConfig(cwd);
1371
- // 1. Per-agent override
1372
- const modelOverrides = config['model_overrides'];
1373
- const override = modelOverrides?.[agentType];
1374
- if (override) {
1375
- return override;
1376
- }
1377
- // 2. Compute the tier
1378
- // eslint-disable-next-line @typescript-eslint/no-base-to-string
1379
- const profile = String(config['model_profile'] || 'balanced').toLowerCase();
1380
- const agentModels = MODEL_PROFILES[agentType];
1381
- const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
1382
- const configModels = config['models'];
1383
- const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
1384
- ? configModels[phaseType]
1385
- : undefined;
1386
- const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
1387
- const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
1388
- ? phaseTypeTier
1389
- : (profile === 'inherit'
1390
- ? 'inherit'
1391
- : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
1392
- // 2.5. model_policy preset (#49)
1393
- const configRuntime = config['runtime'];
1394
- if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
1395
- const mergedPolicy = config['model_policy']
1396
- ? { ...config['model_policy'], runtime: configRuntime }
1397
- : null;
1398
- const policyModel = resolveModelPolicy(mergedPolicy, tier);
1399
- if (policyModel)
1400
- return policyModel;
1401
- }
1402
- // 3. Runtime-aware resolution (#2517)
1403
- if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
1404
- const entry = _resolveRuntimeTier(config, tier);
1405
- if (entry?.model)
1406
- return entry.model;
1407
- }
1408
- // 4. resolve_model_ids: "omit"
1409
- if (config['resolve_model_ids'] === 'omit') {
1410
- return '';
1411
- }
1412
- // 5. Profile lookup (Claude-native default).
1413
- if (!agentModels) {
1414
- return profile === 'quality' ? 'opus'
1415
- : profile === 'budget' ? 'haiku'
1416
- : profile === 'inherit' ? 'inherit'
1417
- : 'sonnet';
1418
- }
1419
- if (tier === 'inherit')
1420
- return 'inherit';
1421
- const alias = tier;
1422
- if (config['resolve_model_ids']) {
1423
- return model_catalog_cjs_1.MODEL_ALIAS_MAP[alias] || alias;
1424
- }
1425
- return alias;
1426
- }
1427
- const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
1428
- /**
1429
- * Resolve the planning granularity for a phase type (#68).
1430
- */
1431
- function resolveGranularityInternal(cwd, phaseType, override) {
1432
- if (override !== undefined && override !== null && override !== '') {
1433
- if (VALID_GRANULARITIES.has(override)) {
1434
- return override;
1435
- }
1436
- }
1437
- const config = loadConfig(cwd);
1438
- const configGranularities = config['granularities'];
1439
- const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
1440
- ? configGranularities[phaseType]
1441
- : undefined;
1442
- if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
1443
- return perPhase;
1444
- }
1445
- if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
1446
- return config['granularity'];
1447
- }
1448
- const planning = config['planning'];
1449
- const planningGran = planning && planning['granularity'];
1450
- if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
1451
- return planningGran;
1452
- }
1453
- return 'standard';
1454
- }
1455
- /**
1456
- * Validate a CLI granularity override at the command boundary. Empty/null/undefined
1457
- * are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
1458
- */
1459
- function assertValidGranularityOverride(override, fail) {
1460
- if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
1461
- fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
1462
- }
1463
- }
1464
- /**
1465
- * #3024 — Resolve a model for a specific dynamic-routing attempt.
1466
- */
1467
- function resolveModelForTier(cwd, agentType, attempt) {
1468
- const config = loadConfig(cwd);
1469
- const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
1470
- const modelOverrides = config['model_overrides'];
1471
- const override = modelOverrides?.[agentType];
1472
- if (override)
1473
- return override;
1474
- if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
1475
- return resolveModelInternal(cwd, agentType);
1476
- }
1477
- const dr = config['dynamic_routing'];
1478
- if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
1479
- return resolveModelInternal(cwd, agentType);
1480
- }
1481
- const tierModels = dr['tier_models'];
1482
- if (!tierModels || typeof tierModels !== 'object') {
1483
- return resolveModelInternal(cwd, agentType);
1484
- }
1485
- const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
1486
- if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
1487
- return resolveModelInternal(cwd, agentType);
1488
- }
1489
- const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
1490
- ? dr['max_escalations']
1491
- : 1;
1492
- const escalationEnabled = dr['escalate_on_failure'] !== false;
1493
- const effectiveAttempt = escalationEnabled
1494
- ? Math.min(attemptN, maxEscalations)
1495
- : 0;
1496
- let tier = defaultTier;
1497
- for (let i = 0; i < effectiveAttempt; i += 1) {
1498
- const next = (nextTier)(tier);
1499
- if (!next || next === tier)
1500
- break;
1501
- tier = next;
1502
- }
1503
- const alias = tierModels[tier];
1504
- if (typeof alias !== 'string' || alias.length === 0) {
1505
- return resolveModelInternal(cwd, agentType);
1506
- }
1507
- return alias;
1508
- }
1509
- // ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
1510
- const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
1511
- const EFFORT_SET = new Set(VALID_EFFORTS);
1512
- /**
1513
- * Walk one step up the effort ladder from `e`.
1514
- */
1515
- function nextEffort(e) {
1516
- const i = VALID_EFFORTS.indexOf(e);
1517
- if (i < 0)
1518
- return null;
1519
- return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
1520
- }
1521
- /**
1522
- * #443 — Resolve a universal effort string for (cwd, agentType).
1523
- */
1524
- function resolveEffortInternal(cwd, agentType, opts) {
1525
- // Step 1: invocation override
1526
- if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
1527
- return opts.override;
1528
- }
1529
- const config = loadConfig(cwd);
1530
- const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
1531
- ? config['effort']
1532
- : null;
1533
- // Step 2: agent_overrides
1534
- if (effortCfg) {
1535
- const ao = effortCfg['agent_overrides'];
1536
- if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
1537
- const v = ao[agentType];
1538
- if (typeof v === 'string' && EFFORT_SET.has(v))
1539
- return v;
1540
- }
1541
- }
1542
- else {
1543
- const canonicalEffort = (configuration_cjs_1.CONFIG_DEFAULTS)['effort'];
1544
- const mao = canonicalEffort && typeof canonicalEffort === 'object'
1545
- ? canonicalEffort['agent_overrides']
1546
- : undefined;
1547
- if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
1548
- const v = mao[agentType];
1549
- if (typeof v === 'string' && EFFORT_SET.has(v))
1550
- return v;
1551
- }
1552
- }
1553
- // Step 3: routing_tier_defaults by agent's default tier.
1554
- const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
1555
- if (agentTier) {
1556
- if (effortCfg && effortCfg['routing_tier_defaults'] &&
1557
- typeof effortCfg['routing_tier_defaults'] === 'object' &&
1558
- !Array.isArray(effortCfg['routing_tier_defaults'])) {
1559
- const v = effortCfg['routing_tier_defaults'][agentTier];
1560
- if (typeof v === 'string' && EFFORT_SET.has(v))
1561
- return v;
1562
- }
1563
- else if (!effortCfg) {
1564
- const canonicalEffort = (configuration_cjs_1.CONFIG_DEFAULTS)['effort'];
1565
- const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
1566
- ? canonicalEffort['routing_tier_defaults']
1567
- : undefined;
1568
- if (manifestDefaults && typeof manifestDefaults === 'object') {
1569
- const v = manifestDefaults[agentTier];
1570
- if (typeof v === 'string' && EFFORT_SET.has(v))
1571
- return v;
1572
- }
1573
- }
1574
- }
1575
- // Step 4: effort.default
1576
- if (effortCfg) {
1577
- const d = effortCfg['default'];
1578
- if (typeof d === 'string' && EFFORT_SET.has(d))
1579
- return d;
1580
- }
1581
- else {
1582
- const canonicalEffort = (configuration_cjs_1.CONFIG_DEFAULTS)['effort'];
1583
- const d = canonicalEffort && typeof canonicalEffort === 'object'
1584
- ? canonicalEffort['default']
1585
- : undefined;
1586
- if (typeof d === 'string' && EFFORT_SET.has(d))
1587
- return d;
1588
- }
1589
- // Step 5: hardcoded default
1590
- return 'high';
1591
- }
1592
- /**
1593
- * #443 — Resolve fast_mode boolean for (cwd, agentType).
1594
- */
1595
- function resolveFastModeInternal(cwd, agentType, opts) {
1596
- // Step 1: invocation override
1597
- if (opts && typeof opts.override === 'boolean') {
1598
- return opts.override;
1599
- }
1600
- const config = loadConfig(cwd);
1601
- const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode']))
1602
- ? config['fast_mode']
1603
- : null;
1604
- // Step 2: agent_overrides
1605
- if (fmCfg) {
1606
- const ao = fmCfg['agent_overrides'];
1607
- if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
1608
- const v = ao[agentType];
1609
- if (typeof v === 'boolean')
1610
- return v;
1611
- }
1612
- }
1613
- // Step 3: routing_tier_defaults by agent's default tier.
1614
- const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
1615
- if (agentTier) {
1616
- if (fmCfg && fmCfg['routing_tier_defaults'] &&
1617
- typeof fmCfg['routing_tier_defaults'] === 'object' &&
1618
- !Array.isArray(fmCfg['routing_tier_defaults'])) {
1619
- const v = fmCfg['routing_tier_defaults'][agentTier];
1620
- if (typeof v === 'boolean')
1621
- return v;
1622
- }
1623
- else if (!fmCfg) {
1624
- const canonicalFm = (configuration_cjs_1.CONFIG_DEFAULTS)['fast_mode'];
1625
- const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
1626
- ? canonicalFm['routing_tier_defaults']
1627
- : undefined;
1628
- if (manifestDefaults && typeof manifestDefaults === 'object') {
1629
- const v = manifestDefaults[agentTier];
1630
- if (typeof v === 'boolean')
1631
- return v;
1632
- }
1633
- }
1634
- }
1635
- // Step 4: fast_mode.enabled
1636
- if (fmCfg && typeof fmCfg['enabled'] === 'boolean') {
1637
- return fmCfg['enabled'];
1638
- }
1639
- // Step 5: hardcoded default
1640
- return false;
1641
- }
1642
- /**
1643
- * #443 — Resolve effort for a dynamic-routing attempt (with escalation).
1644
- */
1645
- function resolveEffortForTier(cwd, agentType, attempt) {
1646
- const base = resolveEffortInternal(cwd, agentType);
1647
- const config = loadConfig(cwd);
1648
- const dr = config['dynamic_routing'];
1649
- if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
1650
- return base;
1651
- }
1652
- if (dr['escalate_on_failure'] === false) {
1653
- return base;
1654
- }
1655
- const maxEscalations = Number.isInteger(dr['max_escalations']) && dr['max_escalations'] >= 0
1656
- ? dr['max_escalations']
1657
- : 1;
1658
- const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
1659
- const effectiveAttempt = Math.min(attemptN, maxEscalations);
1660
- let current = base;
1661
- for (let i = 0; i < effectiveAttempt; i++) {
1662
- const next = nextEffort(current);
1663
- if (!next || next === current)
1664
- break;
1665
- current = next;
1666
- }
1667
- return current;
1668
- }
1669
- // ─── Summary body helpers ─────────────────────────────────────────────────
1670
- /**
1671
- * Extract a one-liner from the summary body when it's not in frontmatter.
1672
- */
1673
- function extractOneLinerFromBody(content) {
1674
- if (!content)
1675
- return null;
1676
- const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
1677
- const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
1678
- const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
1679
- if (!match)
1680
- return null;
1681
- const boldInner = match[1].trim();
1682
- const afterBold = match[2];
1683
- if (/:\s*$/.test(boldInner)) {
1684
- const prose = afterBold.trim();
1685
- return prose.length > 0 ? prose : null;
1686
- }
1687
- return boldInner.length > 0 ? boldInner : null;
1688
- }
1689
- // ─── Misc utilities ───────────────────────────────────────────────────────────
1690
- function pathExistsInternal(cwd, targetPath) {
1691
- const fullPath = node_path_1.default.isAbsolute(targetPath) ? targetPath : node_path_1.default.join(cwd, targetPath);
1692
- try {
1693
- node_fs_1.default.statSync(fullPath);
1694
- return true;
1695
- }
1696
- catch {
1697
- return false;
1698
- }
1699
- }
1700
193
  /**
1701
194
  * Detect whether `cwd` sits inside a git worktree, and if so, return the
1702
195
  * absolute path of the worktree root.
@@ -1722,258 +215,6 @@ function gitWorktreeInfoInternal(cwd) {
1722
215
  return { inside: false, worktreeRoot: null };
1723
216
  }
1724
217
  }
1725
- function generateSlugInternal(text) {
1726
- if (!text)
1727
- return null;
1728
- return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
1729
- }
1730
- function getMilestoneInfo(cwd) {
1731
- try {
1732
- const roadmap = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(planningDir(cwd), 'ROADMAP.md'));
1733
- if (roadmap === null)
1734
- throw new Error('missing');
1735
- let stateVersion = null;
1736
- if (cwd) {
1737
- try {
1738
- const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1739
- const stateRaw = (0, shell_command_projection_cjs_1.platformReadSync)(statePath);
1740
- if (stateRaw !== null) {
1741
- const m = stateRaw.match(/^milestone:\s*(.+)/m);
1742
- if (m)
1743
- stateVersion = m[1].trim();
1744
- }
1745
- }
1746
- catch { /* intentionally empty */ }
1747
- }
1748
- if (stateVersion) {
1749
- const escapedVer = escapeRegex(stateVersion);
1750
- const headingMatch = roadmap.match(new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i'));
1751
- if (headingMatch) {
1752
- if (!headingMatch[0].includes('✅')) {
1753
- return { version: stateVersion, name: headingMatch[1].trim() };
1754
- }
1755
- }
1756
- else {
1757
- const listMatch = roadmap.match(new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i'));
1758
- if (listMatch) {
1759
- return { version: stateVersion, name: listMatch[1].trim() };
1760
- }
1761
- return { version: stateVersion, name: 'milestone' };
1762
- }
1763
- }
1764
- const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
1765
- if (inProgressMatch) {
1766
- return {
1767
- version: 'v' + inProgressMatch[1],
1768
- name: inProgressMatch[2].trim(),
1769
- };
1770
- }
1771
- const cleaned = stripShippedMilestones(roadmap);
1772
- const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
1773
- if (headingMatch) {
1774
- return {
1775
- version: 'v' + headingMatch[1],
1776
- name: headingMatch[2].trim(),
1777
- };
1778
- }
1779
- const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
1780
- return {
1781
- version: versionMatch ? versionMatch[0] : 'v1.0',
1782
- name: 'milestone',
1783
- };
1784
- }
1785
- catch {
1786
- return { version: 'v1.0', name: 'milestone' };
1787
- }
1788
- }
1789
- /**
1790
- * Returns a filter function that checks whether a phase directory belongs
1791
- * to the current milestone based on ROADMAP.md phase headings.
1792
- */
1793
- function getMilestonePhaseFilter(cwd, versionOverride) {
1794
- const milestonePhaseNums = new Set();
1795
- let missingExplicitVersion = false;
1796
- try {
1797
- const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1798
- const roadmapContent = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
1799
- if (roadmapContent === null)
1800
- throw new Error('missing');
1801
- let roadmap = extractCurrentMilestone(roadmapContent, cwd);
1802
- const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
1803
- const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
1804
- if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
1805
- console.warn('[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
1806
- 'Set phase_id_convention in config.json to suppress this warning.');
1807
- }
1808
- if (versionOverride) {
1809
- const escapedVersion = escapeRegex(versionOverride);
1810
- const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
1811
- let sectionMatch = roadmapContent.match(sectionPattern);
1812
- if (!sectionMatch) {
1813
- const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
1814
- const summaryHit = roadmapContent.match(summaryPat);
1815
- if (summaryHit) {
1816
- const beforeSummary = roadmapContent.slice(0, summaryHit.index);
1817
- const detailsIdx = beforeSummary.lastIndexOf('<details');
1818
- if (detailsIdx !== -1) {
1819
- sectionMatch = null;
1820
- }
1821
- }
1822
- }
1823
- if (!sectionMatch) {
1824
- const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
1825
- const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
1826
- if (hasVersionedMilestones && !versionInSummary) {
1827
- roadmap = '';
1828
- missingExplicitVersion = true;
1829
- }
1830
- }
1831
- else {
1832
- const sectionStart = sectionMatch.index;
1833
- const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
1834
- const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
1835
- const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
1836
- let sectionEnd = roadmapContent.length;
1837
- let fenceChar = null;
1838
- let fenceLen = 0;
1839
- let charOffset = 0;
1840
- for (const line of restContent.split('\n')) {
1841
- const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
1842
- if (fenceMatch) {
1843
- const char = fenceMatch[1][0];
1844
- const len = fenceMatch[1].length;
1845
- const trailing = fenceMatch[2] || '';
1846
- if (!fenceChar) {
1847
- fenceChar = char;
1848
- fenceLen = len;
1849
- }
1850
- else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
1851
- fenceChar = null;
1852
- fenceLen = 0;
1853
- }
1854
- }
1855
- else if (!fenceChar && nextMilestonePattern.test(line)) {
1856
- sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
1857
- break;
1858
- }
1859
- charOffset += line.length + 1;
1860
- }
1861
- const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
1862
- roadmap = currentSection;
1863
- }
1864
- }
1865
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
1866
- let m;
1867
- while ((m = phasePattern.exec(roadmap)) !== null) {
1868
- milestonePhaseNums.add(m[1]);
1869
- }
1870
- }
1871
- catch { /* intentionally empty */ }
1872
- if (milestonePhaseNums.size === 0) {
1873
- const passAll = (() => true);
1874
- passAll.phaseCount = 0;
1875
- passAll.missingExplicitVersion = missingExplicitVersion;
1876
- return passAll;
1877
- }
1878
- const normalized = new Set([...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase()));
1879
- function normalizePhaseIdSegments(id) {
1880
- return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
1881
- }
1882
- const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
1883
- const numericRe = roadmapUsesHyphenedIds
1884
- ? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
1885
- : /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
1886
- function isDirInMilestone(dirName) {
1887
- const m2 = dirName.match(numericRe);
1888
- if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase()))
1889
- return true;
1890
- const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
1891
- if (customMatch && normalized.has(customMatch[1].toLowerCase()))
1892
- return true;
1893
- const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
1894
- if (stripped !== dirName) {
1895
- const sm = stripped.match(numericRe);
1896
- if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase()))
1897
- return true;
1898
- }
1899
- return false;
1900
- }
1901
- isDirInMilestone.phaseCount = milestonePhaseNums.size;
1902
- isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
1903
- return isDirInMilestone;
1904
- }
1905
- // ─── Phase file helpers ──────────────────────────────────────────────────────
1906
- /** Filter a file list to just PLAN.md / *-PLAN.md entries. */
1907
- function filterPlanFiles(files) {
1908
- return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
1909
- }
1910
- /** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
1911
- function filterSummaryFiles(files) {
1912
- return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1913
- }
1914
- /**
1915
- * Read a phase directory and return counts/flags for common file types.
1916
- */
1917
- function getPhaseFileStats(phaseDir) {
1918
- const files = node_fs_1.default.readdirSync(phaseDir);
1919
- return {
1920
- plans: filterPlanFiles(files),
1921
- summaries: filterSummaryFiles(files),
1922
- hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
1923
- hasContext: findContextMdIn(files) !== null,
1924
- hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
1925
- hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
1926
- };
1927
- }
1928
- /**
1929
- * Read immediate child directories from a path.
1930
- * Returns [] if the path doesn't exist or can't be read.
1931
- * Pass sort=true to apply comparePhaseNum ordering.
1932
- */
1933
- function readSubdirectories(dirPath, sort = false) {
1934
- try {
1935
- const entries = node_fs_1.default.readdirSync(dirPath, { withFileTypes: true });
1936
- const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
1937
- return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
1938
- }
1939
- catch {
1940
- return [];
1941
- }
1942
- }
1943
- /**
1944
- * Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
1945
- */
1946
- function timeAgo(date) {
1947
- const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
1948
- if (seconds < 5)
1949
- return 'just now';
1950
- if (seconds < 60)
1951
- return `${seconds} seconds ago`;
1952
- const minutes = Math.floor(seconds / 60);
1953
- if (minutes === 1)
1954
- return '1 minute ago';
1955
- if (minutes < 60)
1956
- return `${minutes} minutes ago`;
1957
- const hours = Math.floor(minutes / 60);
1958
- if (hours === 1)
1959
- return '1 hour ago';
1960
- if (hours < 24)
1961
- return `${hours} hours ago`;
1962
- const days = Math.floor(hours / 24);
1963
- if (days === 1)
1964
- return '1 day ago';
1965
- if (days < 30)
1966
- return `${days} days ago`;
1967
- const months = Math.floor(days / 30);
1968
- if (months === 1)
1969
- return '1 month ago';
1970
- if (months < 12)
1971
- return `${months} months ago`;
1972
- const years = Math.floor(days / 365);
1973
- if (years === 1)
1974
- return '1 year ago';
1975
- return `${years} years ago`;
1976
- }
1977
218
  module.exports = {
1978
219
  output,
1979
220
  error,
@@ -2031,7 +272,6 @@ module.exports = {
2031
272
  findProjectRoot: project_root_cjs_1.findProjectRoot,
2032
273
  detectSubRepos,
2033
274
  reapStaleTempFiles,
2034
- ensureGsdTempDir,
2035
275
  GSD_TEMP_DIR,
2036
276
  MODEL_ALIAS_MAP: model_catalog_cjs_1.MODEL_ALIAS_MAP,
2037
277
  CONFIG_DEFAULTS,