ADR-857 rollout phase 1. Move the CLI I/O primitives — output(), error(), ERROR_REASON, setJsonErrorMode/getJsonErrorMode, and the output() large-payload temp-file spillover helpers (GSD_TEMP_DIR, ensureGsdTempDir, reapStaleTempFiles) — out of the 2271-line core.cts god-module into a new, small src/io.cts. core.cts re-exports them so existing consumers are unaffected (behavior-preserving). Repoint src/profile-pipeline.cts to import output/error/reapStaleTempFiles from io directly; graphify/intel/audit were verified not to import these symbols. Net: the leaf feature modules no longer depend on core just for I/O — the enabling first cut toward Capability extraction. New-CLI-module checklist: .gitignore (bin/lib/io.cjs), eslint.config.mjs ignores, INVENTORY.md count 90→91 + io.cjs row, INVENTORY-MANIFEST.json, ARCHITECTURE.md core.cjs/io.cjs rows, CONTEXT.md "I/O Module" glossary entry. Adds tests/io.test.cjs (28 behavioral tests incl. shim-identity and the @file: spillover branch). Gates: lint, code-review, security-review, codex adversarial-review, and gsd-test-both (14742 pass on Mac + Linux Docker, 0 fail) all green. Closes #859 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2130 lines
84 KiB
TypeScript
2130 lines
84 KiB
TypeScript
/**
|
|
* Core — Shared utilities, constants, and internal helpers
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/core.cjs collapsed
|
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only strict types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execGit, platformWriteSync, platformReadSync } from './shell-command-projection.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import ioModule = require('./io.cjs');
|
|
const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import modelProfiles = require('./model-profiles.cjs');
|
|
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
|
|
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import worktreeSafety = require('./worktree-safety.cjs');
|
|
const {
|
|
resolveWorktreeContext,
|
|
parseWorktreePorcelain: parseWorktreePorcelainPolicy,
|
|
planWorktreePrune,
|
|
executeWorktreePrunePlan,
|
|
inspectWorktreeHealth,
|
|
} = worktreeSafety;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
// Compatibility shim: new imports should use planning-workspace.cjs directly.
|
|
const {
|
|
planningDir,
|
|
planningRoot,
|
|
planningPaths,
|
|
withPlanningLock,
|
|
getActiveWorkstream,
|
|
setActiveWorkstream,
|
|
findContextMdIn,
|
|
} = planningWorkspace;
|
|
import { findProjectRoot } from './project-root.cjs';
|
|
import { getGlobalConfigDir } from './runtime-homes.cjs';
|
|
|
|
// ─── Configuration Module (generated CJS mirror) ────────────────────────────
|
|
import { CONFIG_DEFAULTS as CANONICAL_CONFIG_DEFAULTS, normalizeLegacyKeys } from './configuration.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import configSchema = require('./config-schema.cjs');
|
|
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = configSchema;
|
|
|
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
|
|
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
|
function toPosixPath(p: string): string {
|
|
return p.split(path.sep).join('/');
|
|
}
|
|
|
|
/**
|
|
* Scan immediate child directories for separate git repos.
|
|
* Returns a sorted array of directory names that have their own `.git`.
|
|
* Excludes hidden directories and node_modules.
|
|
*/
|
|
function detectSubRepos(cwd: string): string[] {
|
|
const results: string[] = [];
|
|
try {
|
|
const entries = fs.readdirSync(cwd, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
const gitPath = path.join(cwd, entry.name, '.git');
|
|
try {
|
|
if (fs.existsSync(gitPath)) {
|
|
results.push(entry.name);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
} catch { /* ignore */ }
|
|
return results.sort();
|
|
}
|
|
|
|
// findProjectRoot is now re-exported from the generated CJS module above.
|
|
|
|
// ─── File & Config utilities ──────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Canonical config defaults — flat-key projection for CJS consumers.
|
|
*
|
|
* Cycle 4: Values are sourced from CANONICAL_CONFIG_DEFAULTS (the nested
|
|
* manifest loaded by configuration.generated.cjs). The flat shape is
|
|
* preserved here so legacy consumers (config.cjs, verify.cjs, tests that
|
|
* regex-parse this source) continue to work without changes. The key names
|
|
* and the `const CONFIG_DEFAULTS = {` pattern are intentionally kept.
|
|
*
|
|
* Mapping notes:
|
|
* - workflow.plan_check → plan_checker (CJS flat name; verify.cjs uses this)
|
|
* - git.* → flat git keys (branching_strategy, templates)
|
|
* - workflow.* → flat names (research, verifier, …)
|
|
* - planning.sub_repos → sub_repos
|
|
* - planning.commit_docs / search_gitignored → top-level flat keys
|
|
*/
|
|
|
|
// CANONICAL_CONFIG_DEFAULTS is typed as Record<string, unknown> from configuration.cjs;
|
|
// we use a typed accessor to avoid repeated casts.
|
|
function _getConfigDefault(key: string): unknown {
|
|
return (CANONICAL_CONFIG_DEFAULTS)[key];
|
|
}
|
|
function _getNestedConfigDefault(section: string, field: string): unknown {
|
|
const sec = (CANONICAL_CONFIG_DEFAULTS)[section];
|
|
if (sec && typeof sec === 'object' && !Array.isArray(sec)) {
|
|
return (sec as Record<string, unknown>)[field];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const CONFIG_DEFAULTS = {
|
|
model_profile: _getConfigDefault('model_profile'),
|
|
commit_docs: _getConfigDefault('commit_docs'),
|
|
search_gitignored: _getConfigDefault('search_gitignored'),
|
|
branching_strategy: _getNestedConfigDefault('git', 'branching_strategy'),
|
|
phase_branch_template: _getNestedConfigDefault('git', 'phase_branch_template'),
|
|
milestone_branch_template: _getNestedConfigDefault('git', 'milestone_branch_template'),
|
|
quick_branch_template: _getNestedConfigDefault('git', 'quick_branch_template'),
|
|
research: _getNestedConfigDefault('workflow', 'research'),
|
|
plan_checker: _getNestedConfigDefault('workflow', 'plan_check'), // flat CJS name maps to workflow.plan_check
|
|
verifier: _getNestedConfigDefault('workflow', 'verifier'),
|
|
nyquist_validation: _getNestedConfigDefault('workflow', 'nyquist_validation'),
|
|
ai_integration_phase: _getNestedConfigDefault('workflow', 'ai_integration_phase'),
|
|
parallelization: _getConfigDefault('parallelization'),
|
|
brave_search: _getConfigDefault('brave_search'),
|
|
firecrawl: _getConfigDefault('firecrawl'),
|
|
exa_search: _getConfigDefault('exa_search'),
|
|
text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
|
|
sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
|
|
resolve_model_ids: _getConfigDefault('resolve_model_ids'),
|
|
context_window: _getConfigDefault('context_window'),
|
|
phase_naming: _getConfigDefault('phase_naming'),
|
|
project_code: _getConfigDefault('project_code'),
|
|
subagent_timeout: _getNestedConfigDefault('workflow', 'subagent_timeout'),
|
|
security_enforcement: _getNestedConfigDefault('workflow', 'security_enforcement'),
|
|
security_asvs_level: _getNestedConfigDefault('workflow', 'security_asvs_level'),
|
|
security_block_on: _getNestedConfigDefault('workflow', 'security_block_on'),
|
|
post_planning_gaps: _getNestedConfigDefault('workflow', 'post_planning_gaps'),
|
|
};
|
|
|
|
/**
|
|
* Deep-merge two plain config objects. `overlay` wins on key conflict.
|
|
* Explicit `null` in overlay overrides base (null means "unset this key").
|
|
* Arrays are replaced, not merged. Non-object primitives use overlay value.
|
|
*
|
|
* Note: `undefined` in overlay is treated as "no value provided" and falls
|
|
* back to base (preserves inheritance). Explicit `null` overrides base.
|
|
*/
|
|
function _deepMergeConfig(base: Record<string, unknown>, overlay: Record<string, unknown> | null | undefined): Record<string, unknown> | null | undefined {
|
|
if (overlay === null || overlay === undefined) return overlay;
|
|
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
|
|
const result: Record<string, unknown> = { ...base };
|
|
for (const key of Object.keys(overlay)) {
|
|
if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
|
|
result[key] = _deepMergeConfig((base[key] ?? {}) as Record<string, unknown>, overlay[key] as Record<string, unknown>);
|
|
} else {
|
|
result[key] = overlay[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Module-level deduplication for unknown-key warnings (#3523).
|
|
// A single `init phase-op N` call invokes loadConfig more than once; this Set
|
|
// prevents the same warning from being echoed on each invocation.
|
|
const _warnedUnknownConfigKeys = new Set<string>();
|
|
|
|
// Normalization result shape from configuration.cjs
|
|
interface NormalizationEntry {
|
|
requiresFilesystem?: boolean;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
// Typed parsed config shape used internally
|
|
interface ParsedConfig {
|
|
[key: string]: unknown;
|
|
planning?: Record<string, unknown>;
|
|
}
|
|
|
|
function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
|
? options['workstream']
|
|
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
|
? (options['workstreamContext'] as Record<string, unknown>)['ws']
|
|
: (process.env['GSD_WORKSTREAM'] || null);
|
|
// When GSD_WORKSTREAM is set, load root config first so workstream config
|
|
// can inherit from it. This prevents users from duplicating model_overrides,
|
|
// workflow.*, etc. across every workstream config (#2714).
|
|
const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
|
|
// #315 — per-call lazy memo: all three detection sites inside this loadConfig
|
|
// call operate on the same cwd and the subrepo set cannot change mid-call, so
|
|
// a single scan is sufficient. The memo is scoped to THIS call (not module-level)
|
|
// so separate loadConfig invocations each get a fresh scan.
|
|
let cachedSubRepos: string[] | undefined;
|
|
const getDetectedSubRepos = (): string[] => {
|
|
if (cachedSubRepos === undefined) cachedSubRepos = detectSubRepos(cwd);
|
|
// Return a copy: original detectSubRepos returned a fresh array per call,
|
|
// so each site must keep an independent array (avoid cross-site aliasing).
|
|
return cachedSubRepos.slice();
|
|
};
|
|
let rootParsed: ParsedConfig | null = null;
|
|
if (ws) {
|
|
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
|
try {
|
|
const raw = platformReadSync(rootConfigPath);
|
|
if (raw === null) throw new Error('missing');
|
|
rootParsed = JSON.parse(raw) as ParsedConfig;
|
|
// Cycle 4: delegate all legacy-key normalization to the Configuration Module.
|
|
const { parsed: rootNormalized, normalizations: rootNorms } = normalizeLegacyKeys(rootParsed);
|
|
if (rootNorms.length > 0) {
|
|
// Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos)
|
|
for (const norm of rootNorms as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !(rootNormalized as ParsedConfig).planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
if (!(rootNormalized as ParsedConfig).planning) (rootNormalized as ParsedConfig).planning = {};
|
|
(rootNormalized as ParsedConfig).planning!['sub_repos'] = detected;
|
|
(rootNormalized as ParsedConfig).planning!['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
rootParsed = rootNormalized;
|
|
try { platformWriteSync(rootConfigPath, JSON.stringify(rootParsed, null, 2)); } catch { /* ignore */ }
|
|
} else {
|
|
rootParsed = rootNormalized;
|
|
}
|
|
} catch {
|
|
// Root config missing or unparseable — workstream config stands alone
|
|
}
|
|
}
|
|
|
|
const configPath = path.join(planningDir(cwd, ws), 'config.json');
|
|
const defaults = CONFIG_DEFAULTS;
|
|
|
|
try {
|
|
const raw = platformReadSync(configPath);
|
|
if (raw === null) throw new Error('missing');
|
|
// `fileData` is the parsed content of the config.json file on disk — used
|
|
// for migrations and writes so we never persist merged values back to disk.
|
|
const fileData: ParsedConfig = JSON.parse(raw) as ParsedConfig;
|
|
|
|
// Cycle 4: Single normalizeLegacyKeys call replaces all four inline migration
|
|
// blocks (depth→granularity, multiRepo→planning.sub_repos, sub_repos→planning.sub_repos,
|
|
// branching_strategy→git.branching_strategy). The Module is pure (no I/O); disk
|
|
// writeback is handled below with the existing platformWriteSync pattern.
|
|
let configDirty = false;
|
|
{
|
|
const { parsed: normalized, normalizations } = normalizeLegacyKeys(fileData);
|
|
if (normalizations.length > 0) {
|
|
// Merge normalized values back into fileData (mutation-in-place for legacy code below)
|
|
Object.keys(fileData).forEach(k => delete (fileData as Record<string, unknown>)[k]);
|
|
Object.assign(fileData, normalized);
|
|
configDirty = true;
|
|
// Resolve filesystem-dependent normalizations (multiRepo → planning.sub_repos).
|
|
for (const norm of normalizations as unknown as NormalizationEntry[]) {
|
|
if (norm.requiresFilesystem && !fileData.planning?.['sub_repos']) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
fileData.planning['commit_docs'] = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep planning.sub_repos in sync with actual filesystem
|
|
const currentSubRepos = (fileData.planning?.['sub_repos'] as string[] | undefined) || [];
|
|
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
|
const detected = getDetectedSubRepos();
|
|
if (detected.length > 0) {
|
|
const sorted = [...currentSubRepos].sort();
|
|
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
fileData.planning['sub_repos'] = detected;
|
|
configDirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Persist sub_repos changes (migration or sync) — write only the on-disk
|
|
// file contents, never the merged result, to avoid polluting workstream configs.
|
|
if (configDirty) {
|
|
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* ignore */ }
|
|
}
|
|
|
|
// Now apply root→workstream inheritance. `parsed` is the effective config
|
|
// used for value extraction below; fileData is kept for disk writes only.
|
|
const parsed: ParsedConfig = rootParsed
|
|
? (_deepMergeConfig(rootParsed, fileData) as ParsedConfig ?? fileData)
|
|
: fileData;
|
|
|
|
// Warn about unrecognized top-level keys so users don't silently lose config.
|
|
const KNOWN_TOP_LEVEL = new Set([
|
|
// Extract top-level key names from dot-notation paths (e.g., 'workflow.research' → 'workflow')
|
|
...[...VALID_CONFIG_KEYS].map((k: string) => k.split('.')[0]),
|
|
// Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
|
|
...(DYNAMIC_KEY_PATTERNS as unknown as Array<{ topLevel: string }>).map(p => p.topLevel),
|
|
// Internal keys loadConfig reads but config-set doesn't expose
|
|
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path', 'effort', 'fast_mode',
|
|
// Deprecated keys (still accepted for migration, not in config-set)
|
|
'depth', 'multiRepo', 'branching_strategy',
|
|
]);
|
|
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
const warnKey = unknownKeys.join(',');
|
|
if (!_warnedUnknownConfigKeys.has(warnKey)) {
|
|
_warnedUnknownConfigKeys.add(warnKey);
|
|
process.stderr.write(
|
|
`gsd-tools: warning: unknown config key(s) in .planning/config.json: ${unknownKeys.join(', ')} — these will be ignored\n`
|
|
);
|
|
}
|
|
}
|
|
|
|
// #2517 — Validate runtime/tier values
|
|
_warnUnknownProfileOverrides(parsed, '.planning/config.json');
|
|
|
|
const get = (key: string, nested?: { section: string; field: string }): unknown => {
|
|
if (parsed[key] !== undefined) return parsed[key];
|
|
if (nested && parsed[nested.section] && typeof parsed[nested.section] === 'object' && parsed[nested.section] !== null) {
|
|
const sec = parsed[nested.section] as Record<string, unknown>;
|
|
if (sec[nested.field] !== undefined) {
|
|
return sec[nested.field];
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const parallelization = (() => {
|
|
const val = get('parallelization');
|
|
if (typeof val === 'boolean') return val;
|
|
if (typeof val === 'object' && val !== null && 'enabled' in (val)) return (val as Record<string, unknown>)['enabled'];
|
|
return defaults.parallelization;
|
|
})();
|
|
|
|
return {
|
|
model_profile: get('model_profile') ?? defaults.model_profile,
|
|
commit_docs: (() => {
|
|
const explicit = get('commit_docs', { section: 'planning', field: 'commit_docs' });
|
|
// If explicitly set in config, respect the user's choice
|
|
if (explicit !== undefined) return explicit;
|
|
// Auto-detection: when no explicit value and .planning/ is gitignored,
|
|
// default to false instead of true
|
|
if (isGitIgnored(cwd, '.planning/')) return false;
|
|
return defaults.commit_docs;
|
|
})(),
|
|
search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
|
|
branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
|
|
phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
|
|
milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
|
|
quick_branch_template: get('quick_branch_template', { section: 'git', field: 'quick_branch_template' }) ?? defaults.quick_branch_template,
|
|
research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
|
|
plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
|
|
verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
|
|
nyquist_validation: get('nyquist_validation', { section: 'workflow', field: 'nyquist_validation' }) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: get('post_planning_gaps', { section: 'workflow', field: 'post_planning_gaps' }) ?? defaults.post_planning_gaps,
|
|
parallelization,
|
|
brave_search: get('brave_search') ?? defaults.brave_search,
|
|
firecrawl: get('firecrawl') ?? defaults.firecrawl,
|
|
exa_search: get('exa_search') ?? defaults.exa_search,
|
|
tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
|
|
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
|
|
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
|
|
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,
|
|
_auto_chain_active: get('_auto_chain_active', { section: 'workflow', field: '_auto_chain_active' }) ?? false,
|
|
mode: get('mode') ?? 'interactive',
|
|
sub_repos: get('sub_repos', { section: 'planning', field: 'sub_repos' }) ?? defaults.sub_repos,
|
|
resolve_model_ids: get('resolve_model_ids') ?? defaults.resolve_model_ids,
|
|
context_window: get('context_window') ?? defaults.context_window,
|
|
phase_naming: get('phase_naming') ?? defaults.phase_naming,
|
|
project_code: get('project_code') ?? defaults.project_code,
|
|
subagent_timeout: get('subagent_timeout', { section: 'workflow', field: 'subagent_timeout' }) ?? defaults.subagent_timeout,
|
|
model_overrides: (parsed['model_overrides']) || null,
|
|
// #3023 — per-phase-type model map.
|
|
models: (parsed['models']) || null,
|
|
// #68 — top-level granularity
|
|
granularity: parsed['granularity'] !== undefined ? parsed['granularity'] : null,
|
|
// #68 — per-phase-type granularity map.
|
|
granularities: (parsed['granularities']) || null,
|
|
// #68 — planning sub-object
|
|
planning: (parsed['planning']) || null,
|
|
// #3024 — dynamic routing block.
|
|
dynamic_routing: (parsed['dynamic_routing']) || null,
|
|
// #2517 — runtime-aware profiles.
|
|
runtime: (parsed['runtime']) || null,
|
|
model_profile_overrides: (parsed['model_profile_overrides']) || null,
|
|
// #49 — provider-neutral model policy presets.
|
|
model_policy: (parsed['model_policy']) || null,
|
|
// #443 — effort/fast_mode
|
|
effort: (parsed['effort']) || null,
|
|
fast_mode: (parsed['fast_mode']) || null,
|
|
agent_skills: (parsed['agent_skills']) || {},
|
|
agent_skills_security: (parsed['agent_skills_security']) || null,
|
|
manager: (parsed['manager']) || {},
|
|
response_language: get('response_language') || null,
|
|
claude_md_path: get('claude_md_path') || null,
|
|
claude_md_assembly: (parsed['claude_md_assembly']) || null,
|
|
};
|
|
} catch {
|
|
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
|
if (fs.existsSync(planningDir(cwd, ws))) {
|
|
if (rootParsed) {
|
|
// Workstream has no config.json: re-parse using root config as the sole source.
|
|
return loadConfig(cwd, { workstream: null });
|
|
}
|
|
return defaults;
|
|
}
|
|
try {
|
|
const home = process.env['GSD_HOME'] || os.homedir();
|
|
const globalDefaultsPath = path.join(home, '.gsd', 'defaults.json');
|
|
const raw = platformReadSync(globalDefaultsPath);
|
|
if (raw === null) throw new Error('missing');
|
|
const globalDefaults = JSON.parse(raw) as Record<string, unknown>;
|
|
return {
|
|
...defaults,
|
|
model_profile: (globalDefaults['model_profile']) ?? defaults.model_profile,
|
|
commit_docs: (globalDefaults['commit_docs']) ?? defaults.commit_docs,
|
|
research: (globalDefaults['research']) ?? defaults.research,
|
|
plan_checker: (globalDefaults['plan_checker']) ?? defaults.plan_checker,
|
|
verifier: (globalDefaults['verifier']) ?? defaults.verifier,
|
|
nyquist_validation: (globalDefaults['nyquist_validation']) ?? defaults.nyquist_validation,
|
|
post_planning_gaps: (globalDefaults['post_planning_gaps'])
|
|
?? (globalDefaults['workflow'] as Record<string, unknown> | undefined)?.['post_planning_gaps']
|
|
?? defaults.post_planning_gaps,
|
|
parallelization: (globalDefaults['parallelization']) ?? defaults.parallelization,
|
|
text_mode: (globalDefaults['text_mode']) ?? defaults.text_mode,
|
|
resolve_model_ids: (globalDefaults['resolve_model_ids']) ?? defaults.resolve_model_ids,
|
|
context_window: (globalDefaults['context_window']) ?? defaults.context_window,
|
|
subagent_timeout: (globalDefaults['subagent_timeout']) ?? defaults.subagent_timeout,
|
|
model_overrides: (globalDefaults['model_overrides']) || null,
|
|
models: (globalDefaults['models']) || null,
|
|
granularity: (globalDefaults['granularity']) !== undefined ? globalDefaults['granularity'] : null,
|
|
granularities: (globalDefaults['granularities']) || null,
|
|
planning: (globalDefaults['planning']) || null,
|
|
dynamic_routing: (globalDefaults['dynamic_routing']) || null,
|
|
effort: (globalDefaults['effort']) || null,
|
|
fast_mode: (globalDefaults['fast_mode']) || null,
|
|
agent_skills: (globalDefaults['agent_skills']) || {},
|
|
response_language: (globalDefaults['response_language']) || null,
|
|
};
|
|
} catch {
|
|
return defaults;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Git utilities ────────────────────────────────────────────────────────────
|
|
|
|
const _gitIgnoredCache = new Map<string, boolean>();
|
|
|
|
function isGitIgnored(cwd: string, targetPath: string): boolean {
|
|
const key = cwd + '::' + targetPath;
|
|
if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key)!;
|
|
// --no-index checks .gitignore rules regardless of whether the file is tracked.
|
|
const result = execGit(['check-ignore', '-q', '--no-index', '--', targetPath], { cwd });
|
|
const ignored = result.exitCode === 0;
|
|
_gitIgnoredCache.set(key, ignored);
|
|
return ignored;
|
|
}
|
|
|
|
// ─── Common path helpers ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the main worktree root when running inside a git worktree.
|
|
* In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
|
|
* Returns the main worktree path, or cwd if not in a worktree.
|
|
*/
|
|
function resolveWorktreeRoot(cwd: string): string {
|
|
const context = resolveWorktreeContext(cwd, {
|
|
existsSync: fs.existsSync,
|
|
});
|
|
return context.effectiveRoot;
|
|
}
|
|
|
|
/**
|
|
* Parse `git worktree list --porcelain` output into an array of
|
|
* { path, branch } objects. Entries with a detached HEAD (no branch line)
|
|
* are skipped because we cannot safely reason about their merge status.
|
|
*
|
|
* @param porcelain - raw output from git worktree list --porcelain
|
|
* @returns {{ path: string, branch: string }[]}
|
|
*/
|
|
function parseWorktreePorcelain(porcelain: string): Array<{ path: string; branch: string }> {
|
|
return parseWorktreePorcelainPolicy(porcelain);
|
|
}
|
|
|
|
/**
|
|
* Clear stale worktree metadata references via `git worktree prune`.
|
|
*
|
|
* Destructive linked-worktree removal is disabled by default for safety.
|
|
*
|
|
* @param repoRoot - absolute path to the main (or any) worktree of
|
|
* the repository; used as `cwd` for git commands.
|
|
* @returns list of worktree paths that were removed (always empty)
|
|
*/
|
|
function pruneOrphanedWorktrees(repoRoot: string): string[] {
|
|
try {
|
|
const plan = planWorktreePrune(
|
|
repoRoot,
|
|
{ allowDestructive: false },
|
|
{ parseWorktreePorcelain }
|
|
);
|
|
const pruneResult = executeWorktreePrunePlan(plan) as { timedOut?: boolean } | null;
|
|
if (pruneResult && pruneResult.timedOut) {
|
|
process.stderr.write(
|
|
'[gsd-tools] WARNING: worktree health check degraded' +
|
|
' — git worktree prune timed out after 10s.' +
|
|
' Orphaned worktree metadata may remain until the next successful run.\n'
|
|
);
|
|
}
|
|
} catch { /* never crash the caller */ }
|
|
return [];
|
|
}
|
|
|
|
// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
|
|
|
|
// ─── Phase utilities ──────────────────────────────────────────────────────────
|
|
|
|
function escapeRegex(value: unknown): string {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function normalizePhaseName(phase: unknown): string {
|
|
const str = String(phase);
|
|
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
|
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
|
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
if (milestoneMatch) {
|
|
const major = milestoneMatch[1].padStart(2, '0');
|
|
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
|
|
const suffix = milestoneMatch[3] || '';
|
|
return `${major}-${subSegments.join('-')}${suffix}`;
|
|
}
|
|
// Standard numeric phases: 1, 01, 12A, 12.1
|
|
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
if (match) {
|
|
const padded = match[1].padStart(2, '0');
|
|
// Preserve original case of letter suffix (#1962).
|
|
const letter = match[2] || '';
|
|
const decimal = match[3] || '';
|
|
return padded + letter + decimal;
|
|
}
|
|
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
|
|
return str;
|
|
}
|
|
|
|
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
|
|
const str = String(phaseId);
|
|
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
const m = stripped.match(/^0*(\d+)-\d/);
|
|
if (!m) return null;
|
|
const major = parseInt(m[1], 10);
|
|
if (major === 0 || major === 999) return null;
|
|
return `v${major}.0`;
|
|
}
|
|
|
|
function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null {
|
|
const str = String(phaseId);
|
|
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
|
if (!m) return null;
|
|
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
|
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
|
|
const sub = subParts.join('-');
|
|
const slug = phaseName
|
|
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
|
|
: '';
|
|
const parts = [milestone, sub, slug].filter(Boolean);
|
|
const base = parts.join('-');
|
|
return projectCode ? `${projectCode}-${base}` : base;
|
|
}
|
|
|
|
/**
|
|
* Render a regex source fragment matching a phase number against ROADMAP/STATE
|
|
* prose regardless of zero-padding on either side.
|
|
*/
|
|
function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
|
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
|
|
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
|
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
|
if (milestoneSegments && milestoneSegments[2]) {
|
|
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
|
|
const subParts = milestoneSegments[2].slice(1).split('-');
|
|
const subFragments = subParts.map(s => {
|
|
const unpadded = s.replace(/^0+/, '') || '0';
|
|
return `0*${escapeRegex(unpadded)}`;
|
|
});
|
|
const suffix = milestoneSegments[3] || '';
|
|
const suffixFragment = suffix ? escapeRegex(suffix) : '';
|
|
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
|
|
}
|
|
|
|
// Plain numeric phase: 1, 01, 12A, 12.1
|
|
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
|
|
if (!match) return escapeRegex(phaseNum);
|
|
|
|
const integer = match[1].replace(/^0+/, '') || '0';
|
|
const letter = match[2] ? escapeRegex(match[2]) : '';
|
|
const decimal = match[3] ? escapeRegex(match[3]) : '';
|
|
return `0*${escapeRegex(integer)}${letter}${decimal}`;
|
|
}
|
|
|
|
/**
|
|
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
|
|
* return the exact-escaped form.
|
|
*/
|
|
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
|
|
const raw = String(phaseNum);
|
|
if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
|
|
return escapeRegex(raw);
|
|
}
|
|
|
|
function comparePhaseNum(a: unknown, b: unknown): number {
|
|
// Strip optional project_code prefix before comparing
|
|
const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
|
|
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
|
|
|
if (milestoneA && milestoneB) {
|
|
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
|
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
|
|
const maxSegs = Math.max(segsA.length, segsB.length);
|
|
for (let i = 0; i < maxSegs; i++) {
|
|
const av = segsA[i] !== undefined ? segsA[i] : 0;
|
|
const bv = segsB[i] !== undefined ? segsB[i] : 0;
|
|
if (av !== bv) return av - bv;
|
|
}
|
|
const sufA = milestoneA[3] || '';
|
|
const sufB = milestoneB[3] || '';
|
|
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
|
|
return 0;
|
|
}
|
|
|
|
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
|
|
|
|
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
if (intDiff !== 0) return intDiff;
|
|
const la = (pa[2] || '').toUpperCase();
|
|
const lb = (pb[2] || '').toUpperCase();
|
|
if (la !== lb) {
|
|
if (!la) return -1;
|
|
if (!lb) return 1;
|
|
return la < lb ? -1 : 1;
|
|
}
|
|
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
|
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
|
|
const maxLen = Math.max(aDecParts.length, bDecParts.length);
|
|
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
|
|
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
|
|
for (let i = 0; i < maxLen; i++) {
|
|
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
|
|
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
|
|
if (av !== bv) return av - bv;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Extract the phase token from a directory name.
|
|
*/
|
|
function extractPhaseToken(dirName: string): string {
|
|
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
|
|
let prefix = '';
|
|
let rest = dirName;
|
|
if (codePrefixMatch) {
|
|
prefix = codePrefixMatch[1] + '-';
|
|
rest = codePrefixMatch[2];
|
|
}
|
|
|
|
const segments = rest.split('-');
|
|
const tokenSegments: string[] = [];
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const seg = segments[i];
|
|
if (/^\d/.test(seg)) {
|
|
tokenSegments.push(seg);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (tokenSegments.length === 0) {
|
|
return dirName;
|
|
}
|
|
|
|
return prefix + tokenSegments.join('-');
|
|
}
|
|
|
|
/**
|
|
* Check if a directory name's phase token matches the normalized phase exactly.
|
|
*/
|
|
function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
|
const token = extractPhaseToken(dirName);
|
|
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
|
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
if (stripped !== dirName) {
|
|
const strippedToken = extractPhaseToken(stripped);
|
|
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function extractCanonicalPlanId(filename: string): string {
|
|
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
|
|
const parts = base.split('-').filter(Boolean);
|
|
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
|
|
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
|
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
|
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
interface PhaseSearchResult {
|
|
found: boolean;
|
|
directory: string;
|
|
phase_number: string;
|
|
phase_name: string | null;
|
|
phase_slug: string | null;
|
|
plans: string[];
|
|
summaries: string[];
|
|
incomplete_plans: string[];
|
|
has_research: boolean;
|
|
has_context: boolean;
|
|
has_verification: boolean;
|
|
has_reviews: boolean;
|
|
archived?: string;
|
|
}
|
|
|
|
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
|
|
try {
|
|
const dirs = readSubdirectories(baseDir, true);
|
|
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
|
if (!match) return null;
|
|
|
|
const phaseToken = extractPhaseToken(match);
|
|
const phaseNumber = phaseToken || normalized;
|
|
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
|
const phaseName = afterToken || null;
|
|
const phaseDir = path.join(baseDir, match);
|
|
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
|
|
const plans = unsortedPlans.sort();
|
|
const summaries = unsortedSummaries.sort();
|
|
|
|
const completedPlanIds = new Set(
|
|
summaries.flatMap(s => {
|
|
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
|
const canonical = extractCanonicalPlanId(s);
|
|
return canonical === exact ? [exact] : [exact, canonical];
|
|
})
|
|
);
|
|
const incompletePlans = plans.filter(p => {
|
|
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
|
|
const canonical = extractCanonicalPlanId(p);
|
|
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
|
|
});
|
|
|
|
return {
|
|
found: true,
|
|
directory: toPosixPath(path.join(relBase, match)),
|
|
phase_number: phaseNumber,
|
|
phase_name: phaseName,
|
|
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
|
|
plans,
|
|
summaries,
|
|
incomplete_plans: incompletePlans,
|
|
has_research: hasResearch,
|
|
has_context: hasContext,
|
|
has_verification: hasVerification,
|
|
has_reviews: hasReviews,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
|
|
if (!phase) return null;
|
|
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
|
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
|
if (current) return current;
|
|
|
|
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
|
if (!fs.existsSync(milestonesDir)) return null;
|
|
|
|
try {
|
|
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
|
const archiveDirs = milestoneEntries
|
|
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
|
.map(e => e.name)
|
|
.sort()
|
|
.reverse();
|
|
|
|
for (const archiveName of archiveDirs) {
|
|
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
|
const version = versionMatch![1];
|
|
const archivePath = path.join(milestonesDir, archiveName);
|
|
const relBase = '.planning/milestones/' + archiveName;
|
|
const result = searchPhaseInDir(archivePath, relBase, normalized);
|
|
if (result) {
|
|
result.archived = version;
|
|
return result;
|
|
}
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
|
|
return null;
|
|
}
|
|
|
|
interface ArchivedPhaseDir {
|
|
name: string;
|
|
milestone: string;
|
|
basePath: string;
|
|
fullPath: string;
|
|
}
|
|
|
|
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
|
|
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
|
const results: ArchivedPhaseDir[] = [];
|
|
|
|
if (!fs.existsSync(milestonesDir)) return results;
|
|
|
|
try {
|
|
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
|
const phaseDirs = milestoneEntries
|
|
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
|
|
.map(e => e.name)
|
|
.sort()
|
|
.reverse();
|
|
|
|
for (const archiveName of phaseDirs) {
|
|
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
|
|
const version = versionMatch![1];
|
|
const archivePath = path.join(milestonesDir, archiveName);
|
|
const dirs = readSubdirectories(archivePath, true);
|
|
|
|
for (const dir of dirs) {
|
|
results.push({
|
|
name: dir,
|
|
milestone: version,
|
|
basePath: path.join('.planning', 'milestones', archiveName),
|
|
fullPath: path.join(archivePath, dir),
|
|
});
|
|
}
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
|
|
return results;
|
|
}
|
|
|
|
// ─── Roadmap milestone scoping ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Strip shipped milestone content wrapped in <details> blocks.
|
|
*/
|
|
function stripShippedMilestones(content: string): string {
|
|
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
|
}
|
|
|
|
/**
|
|
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
|
*/
|
|
function extractCurrentMilestone(content: string, cwd?: string): string {
|
|
if (!cwd) return stripShippedMilestones(content);
|
|
|
|
let version: string | null = null;
|
|
try {
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
const stateRaw = platformReadSync(statePath);
|
|
if (stateRaw !== null) {
|
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (milestoneMatch) {
|
|
version = milestoneMatch[1].trim();
|
|
}
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
if (!version) {
|
|
const inProgressMatch = content.match(/(?:🚧|🔄)\s*\*\*v(\d+\.\d+)\s/);
|
|
if (inProgressMatch) {
|
|
version = 'v' + inProgressMatch[1];
|
|
}
|
|
}
|
|
|
|
if (!version) return stripShippedMilestones(content);
|
|
|
|
const escapedVersion = escapeRegex(version);
|
|
const sectionPattern = new RegExp(
|
|
`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}\\b[^\\n]*)`,
|
|
'gmi'
|
|
);
|
|
const summaryPattern = new RegExp(
|
|
`<summary[^>]*>([^<]*${escapedVersion}[^<]*)<\\/summary>`,
|
|
'i'
|
|
);
|
|
const headingMatches = [...content.matchAll(sectionPattern)];
|
|
|
|
if (headingMatches.length === 0) {
|
|
const summaryMatch = content.match(summaryPattern);
|
|
if (summaryMatch) {
|
|
const summaryIdx = content.indexOf(summaryMatch[0]);
|
|
const beforeSummary = content.slice(0, summaryIdx);
|
|
const detailsOpenIdx = beforeSummary.lastIndexOf('<details');
|
|
if (detailsOpenIdx !== -1) {
|
|
const afterDetails = content.slice(detailsOpenIdx);
|
|
const closingMatch = afterDetails.match(/<\/details>/i);
|
|
const detailsEnd = closingMatch
|
|
? detailsOpenIdx + (closingMatch.index ?? 0) + '</details>'.length
|
|
: content.length;
|
|
const anyMilestoneOrDetails = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧|🔄)|<details/im;
|
|
const firstMilestoneMatch = content.match(anyMilestoneOrDetails);
|
|
const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index! : detailsOpenIdx;
|
|
const preamble = content.slice(0, preambleCutoff)
|
|
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
|
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
|
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
|
return preamble + content.slice(detailsOpenIdx, detailsEnd);
|
|
}
|
|
}
|
|
return stripShippedMilestones(content);
|
|
}
|
|
|
|
const allMatches = headingMatches;
|
|
|
|
const closedMarkerPattern = /\b(?:CLOSED|ARCHIVED|ABANDONED|SHIPPED|FAILED)\b|✅|🗄/i;
|
|
const activeMarkerPattern = /\b(?:STARTED|ACTIVE|WIP)\b|in\s+progress|🚧|🔄/i;
|
|
const isClosed = (h: string) => closedMarkerPattern.test(h) && !activeMarkerPattern.test(h);
|
|
const firstMatch = allMatches[0];
|
|
const selected = allMatches.find((m) => !isClosed(m[1])) || firstMatch;
|
|
|
|
const sectionStart = selected.index;
|
|
|
|
const computeSectionEnd = (headingText: string, headingStart: number): number => {
|
|
const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
const rest = content.slice(headingStart + headingText.length);
|
|
const stopPattern = new RegExp(
|
|
`^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
|
'i',
|
|
);
|
|
let end = content.length;
|
|
let fc: string | null = null;
|
|
let fl = 0;
|
|
let off = 0;
|
|
for (const line of rest.split('\n')) {
|
|
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
|
if (fm) {
|
|
const ch = fm[1][0];
|
|
const ln = fm[1].length;
|
|
const trailing = fm[2] || '';
|
|
if (!fc) {
|
|
fc = ch;
|
|
fl = ln;
|
|
} else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) {
|
|
fc = null;
|
|
fl = 0;
|
|
}
|
|
} else if (!fc && stopPattern.test(line)) {
|
|
end = headingStart + headingText.length + off;
|
|
break;
|
|
}
|
|
off += line.length + 1;
|
|
}
|
|
return end;
|
|
};
|
|
|
|
const sectionEnd = computeSectionEnd(selected[0], sectionStart);
|
|
|
|
const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im;
|
|
const firstMilestoneMatch = content.match(anyMilestonePattern);
|
|
const preambleCutoff = firstMilestoneMatch
|
|
? firstMilestoneMatch.index!
|
|
: firstMatch.index;
|
|
const beforeMilestones = content.slice(0, preambleCutoff);
|
|
const currentSection = content.slice(sectionStart, sectionEnd);
|
|
|
|
// Multi-milestone roadmaps split each added milestone across two version-bearing
|
|
// headings: a `## Phases` checklist subsection (early) and a dedicated
|
|
// `## Milestone … (Phase Details)` section (late) holding the `### Phase N:`
|
|
// detail headers. The scope window above stops at the next version-bearing
|
|
// heading — the current milestone's OWN Phase Details heading — leaving those
|
|
// detail headers outside `currentSection`. Append that section so phase
|
|
// resolution and counting see the current milestone's phases. Anchor the lookup
|
|
// to the SELECTED heading's specific version token (boundary-aware, so a
|
|
// `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones
|
|
// that share a version prefix do not cross-pollinate. (#730)
|
|
const selectedVersionToken = selected[1].match(
|
|
/v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i,
|
|
)?.[0];
|
|
const detailsVersionBoundary = selectedVersionToken
|
|
? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i')
|
|
: null;
|
|
let detailsSection = '';
|
|
const detailsMatch = allMatches.find(
|
|
(m) =>
|
|
/\(Phase\s+Details\)/i.test(m[1]) &&
|
|
!isClosed(m[1]) &&
|
|
(!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) &&
|
|
(m.index ?? 0) >= sectionEnd,
|
|
);
|
|
if (detailsMatch) {
|
|
const detailsStart = detailsMatch.index ?? 0;
|
|
detailsSection = content.slice(
|
|
detailsStart,
|
|
computeSectionEnd(detailsMatch[0], detailsStart),
|
|
);
|
|
}
|
|
|
|
const preamble = beforeMilestones
|
|
.replace(/<details>[\s\S]*?<\/details>/gi, '')
|
|
.replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
|
|
.replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
|
|
|
|
return detailsSection
|
|
? preamble + currentSection + '\n' + detailsSection
|
|
: preamble + currentSection;
|
|
}
|
|
|
|
/**
|
|
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
|
*/
|
|
function replaceInCurrentMilestone(content: string, pattern: RegExp, replacement: string): string {
|
|
const lastDetailsClose = content.lastIndexOf('</details>');
|
|
if (lastDetailsClose === -1) {
|
|
return content.replace(pattern, replacement);
|
|
}
|
|
const offset = lastDetailsClose + '</details>'.length;
|
|
const before = content.slice(0, offset);
|
|
const after = content.slice(offset);
|
|
return before + after.replace(pattern, replacement);
|
|
}
|
|
|
|
// ─── Roadmap & model utilities ────────────────────────────────────────────────
|
|
|
|
interface RoadmapPhaseResult {
|
|
found: boolean;
|
|
phase_number: string;
|
|
phase_name: string;
|
|
goal: string | null;
|
|
section: string;
|
|
}
|
|
|
|
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
|
if (!phaseNum) return null;
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
if (!fs.existsSync(roadmapPath)) return null;
|
|
|
|
try {
|
|
const roadmapRaw = platformReadSync(roadmapPath);
|
|
if (roadmapRaw === null) throw new Error('missing');
|
|
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
|
const phasePattern = new RegExp(
|
|
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
|
'i'
|
|
);
|
|
const headerMatch = content.match(phasePattern);
|
|
if (!headerMatch) return null;
|
|
|
|
const phaseName = headerMatch[1].trim();
|
|
const headerIndex = headerMatch.index!;
|
|
const restOfContent = content.slice(headerIndex);
|
|
const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
|
|
const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length;
|
|
const section = content.slice(headerIndex, sectionEnd).trim();
|
|
|
|
const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
|
|
const goal = goalMatch ? goalMatch[1].trim() : null;
|
|
|
|
return {
|
|
found: true,
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
phase_number: String(phaseNum),
|
|
phase_name: phaseName,
|
|
goal,
|
|
section,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Agent installation validation (#1371) ───────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the agents directory for the given runtime.
|
|
*
|
|
* Priority:
|
|
* 1. GSD_AGENTS_DIR env var (explicit override, any runtime)
|
|
* 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/)
|
|
* This is correct for both repo runs and real installs (the runtime config dir's
|
|
* agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases.
|
|
* 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents
|
|
*
|
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
*/
|
|
function getAgentsDir(runtime?: string): string {
|
|
if (process.env['GSD_AGENTS_DIR']) {
|
|
return process.env['GSD_AGENTS_DIR'];
|
|
}
|
|
const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
if (resolved === 'claude') {
|
|
return path.join(__dirname, '..', '..', '..', 'agents');
|
|
}
|
|
return path.join(getGlobalConfigDir(resolved), 'agents');
|
|
}
|
|
|
|
interface AgentsInstalledResult {
|
|
agents_installed: boolean;
|
|
missing_agents: string[];
|
|
installed_agents: string[];
|
|
agents_dir: string;
|
|
agent_runtime: string;
|
|
}
|
|
|
|
/**
|
|
* Check which GSD agents are installed on disk.
|
|
*
|
|
* @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude'
|
|
*/
|
|
function checkAgentsInstalled(runtime?: string): AgentsInstalledResult {
|
|
const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude');
|
|
const agentsDir = getAgentsDir(resolvedRuntime);
|
|
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
const installed: string[] = [];
|
|
const missing: string[] = [];
|
|
|
|
if (!fs.existsSync(agentsDir)) {
|
|
return {
|
|
agents_installed: false,
|
|
missing_agents: expectedAgents,
|
|
installed_agents: [],
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
};
|
|
}
|
|
|
|
for (const agent of expectedAgents) {
|
|
const agentFile = path.join(agentsDir, `${agent}.md`);
|
|
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
|
|
const agentFileCodex = path.join(agentsDir, `${agent}.toml`);
|
|
if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot) || fs.existsSync(agentFileCodex)) {
|
|
installed.push(agent);
|
|
} else {
|
|
missing.push(agent);
|
|
}
|
|
}
|
|
|
|
return {
|
|
agents_installed: installed.length > 0 && missing.length === 0,
|
|
missing_agents: missing,
|
|
installed_agents: installed,
|
|
agents_dir: agentsDir,
|
|
agent_runtime: resolvedRuntime,
|
|
};
|
|
}
|
|
|
|
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
|
|
const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
|
const _warnedConfigKeys = new Set<string>();
|
|
|
|
function _warnUnknownProfileOverrides(parsed: Record<string, unknown>, configLabel: string): void {
|
|
if (!parsed || typeof parsed !== 'object') return;
|
|
|
|
const runtime = parsed['runtime'];
|
|
if (runtime && typeof runtime === 'string' && !(KNOWN_RUNTIMES).has(runtime)) {
|
|
const key = `${configLabel}::runtime::${runtime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — config key "runtime" has unknown value "${runtime}". ` +
|
|
`Known runtimes: ${[...(KNOWN_RUNTIMES)].sort().join(', ')}. ` +
|
|
`Resolution will fall back to safe defaults. (#2517)\n`
|
|
);
|
|
} catch { /* stderr might be closed in some test harnesses */ }
|
|
}
|
|
}
|
|
|
|
const overrides = parsed['model_profile_overrides'];
|
|
if (overrides && typeof overrides === 'object' && !Array.isArray(overrides)) {
|
|
for (const [overrideRuntime, tierMap] of Object.entries(overrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(overrideRuntime)) {
|
|
const key = `${configLabel}::override-runtime::${overrideRuntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.* uses ` +
|
|
`unknown runtime "${overrideRuntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::override-tier::${overrideRuntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_profile_overrides.${overrideRuntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed tiers: opus, sonnet, haiku. (#2517)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const policy = parsed['model_policy'];
|
|
if (policy && typeof policy === 'object' && !Array.isArray(policy)) {
|
|
const policyObj = policy as Record<string, unknown>;
|
|
const provider = policyObj['provider'];
|
|
const _POLICY_SENTINEL_PROVIDERS = new Set(['generic', 'custom']);
|
|
if (provider && typeof provider === 'string' &&
|
|
!(KNOWN_PROVIDERS).has(provider) && !_POLICY_SENTINEL_PROVIDERS.has(provider)) {
|
|
const pkey = `${configLabel}::model_policy::provider::${provider}`;
|
|
if (!_warnedConfigKeys.has(pkey)) {
|
|
_warnedConfigKeys.add(pkey);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.provider has unknown value "${provider}". ` +
|
|
`Known providers: ${[...(KNOWN_PROVIDERS)].sort().join(', ')}. ` +
|
|
`For manual model IDs use provider="custom". (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
|
|
const rtOverrides = policyObj['runtime_tiers'];
|
|
if (rtOverrides && typeof rtOverrides === 'object' && !Array.isArray(rtOverrides)) {
|
|
for (const [pruntime, tierMap] of Object.entries(rtOverrides as Record<string, unknown>)) {
|
|
if (!(KNOWN_RUNTIMES).has(pruntime)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.* uses ` +
|
|
`unknown runtime "${pruntime}". Known runtimes: ` +
|
|
`${[...(KNOWN_RUNTIMES)].sort().join(', ')}. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
if (!tierMap || typeof tierMap !== 'object') continue;
|
|
for (const tierName of Object.keys(tierMap)) {
|
|
if (!RUNTIME_OVERRIDE_TIERS.has(tierName)) {
|
|
const key = `${configLabel}::model_policy.runtime_tiers::${pruntime}.${tierName}`;
|
|
if (!_warnedConfigKeys.has(key)) {
|
|
_warnedConfigKeys.add(key);
|
|
try {
|
|
process.stderr.write(
|
|
`gsd: warning — model_policy.runtime_tiers.${pruntime}.${tierName} ` +
|
|
`uses unknown tier "${tierName}". Allowed: opus, sonnet, haiku. (#49)\n`
|
|
);
|
|
} catch { /* ok */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internal helper exposed for tests so per-process warning state can be reset
|
|
// between cases that intentionally exercise the warning path repeatedly.
|
|
function _resetRuntimeWarningCacheForTests(): void {
|
|
_warnedConfigKeys.clear();
|
|
}
|
|
|
|
interface TierEntryResolved {
|
|
model: string;
|
|
reasoning_effort?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface ResolveTierEntryOpts {
|
|
runtime: string | null | undefined;
|
|
tier: string | null | undefined;
|
|
overrides: Record<string, unknown> | null | undefined;
|
|
}
|
|
|
|
/**
|
|
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
|
*/
|
|
function resolveTierEntry({ runtime, tier, overrides }: ResolveTierEntryOpts): TierEntryResolved | null {
|
|
if (!runtime || !tier) return null;
|
|
|
|
const runtimeMap = RUNTIME_PROFILE_MAP as unknown as Record<string, Record<string, Record<string, unknown>>>;
|
|
const builtin = runtimeMap[runtime]?.[tier] || null;
|
|
const overridesMap = overrides as Record<string, Record<string, unknown>> | null | undefined;
|
|
const userRaw = overridesMap?.[runtime]?.[tier];
|
|
|
|
let userEntry: Record<string, unknown> | null = null;
|
|
if (userRaw) {
|
|
userEntry = typeof userRaw === 'string' ? { model: userRaw } : (userRaw as Record<string, unknown>);
|
|
}
|
|
|
|
if (!builtin && !userEntry) return null;
|
|
return { ...(builtin || {}), ...(userEntry || {}) } as TierEntryResolved;
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper used by resolveModelInternal.
|
|
*/
|
|
function _resolveRuntimeTier(config: Record<string, unknown>, tier: string): TierEntryResolved | null {
|
|
return resolveTierEntry({
|
|
runtime: config['runtime'] as string | null | undefined,
|
|
tier,
|
|
overrides: config['model_profile_overrides'] as Record<string, unknown> | null | undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* #49 — Provider-neutral model policy preset resolution.
|
|
*/
|
|
function resolveModelPolicy(policy: Record<string, unknown> | null | undefined, tier: string | null | undefined): string | null {
|
|
if (!policy || typeof policy !== 'object') return null;
|
|
if (!tier) return null;
|
|
|
|
const runtime = policy['runtime'];
|
|
const rtOverrides = policy['runtime_tiers'];
|
|
if (runtime && typeof runtime === 'string' && rtOverrides && typeof rtOverrides === 'object') {
|
|
const rtOverridesMap = rtOverrides as Record<string, unknown>;
|
|
if (Object.hasOwn(rtOverridesMap, runtime)) {
|
|
const runtimeEntry = rtOverridesMap[runtime];
|
|
if (runtimeEntry && typeof runtimeEntry === 'object' && Object.hasOwn(runtimeEntry, tier)) {
|
|
const raw = (runtimeEntry as Record<string, unknown>)[tier];
|
|
if (raw != null) {
|
|
const entry = typeof raw === 'string' ? { model: raw } : (raw as Record<string, unknown>);
|
|
if (entry && entry['model']) return entry['model'] as string;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const provider = policy['provider'];
|
|
if (!provider || typeof provider !== 'string') return null;
|
|
|
|
if (provider === 'generic' || provider === 'custom') {
|
|
const TIER_TO_POLICY_KEY: Record<string, string> = { opus: 'high', sonnet: 'medium', haiku: 'low' };
|
|
const policyKey = TIER_TO_POLICY_KEY[tier];
|
|
if (!policyKey) return null;
|
|
const v = policy[policyKey];
|
|
return (v && typeof v === 'string') ? v : null;
|
|
}
|
|
|
|
const presetsMap = PROVIDER_PRESETS as Record<string, Record<string, Record<string, { model: string } | null>>>;
|
|
if (!Object.hasOwn(presetsMap, provider)) return null;
|
|
const presetForProvider = presetsMap[provider];
|
|
if (!presetForProvider || typeof presetForProvider !== 'object') return null;
|
|
|
|
if (!Object.hasOwn(presetForProvider, tier)) return null;
|
|
const tierPresets = presetForProvider[tier];
|
|
if (!tierPresets || typeof tierPresets !== 'object') return null;
|
|
|
|
const budget = (policy['budget'] && typeof policy['budget'] === 'string') ? policy['budget'] : 'medium';
|
|
if (!Object.hasOwn(tierPresets, budget)) return null;
|
|
const budgetEntry = tierPresets[budget];
|
|
if (!budgetEntry || !budgetEntry.model) return null;
|
|
|
|
return budgetEntry.model;
|
|
}
|
|
|
|
function resolveModelInternal(cwd: string, agentType: string): string {
|
|
const config = loadConfig(cwd);
|
|
|
|
// 1. Per-agent override
|
|
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
|
const override = modelOverrides?.[agentType];
|
|
if (override) {
|
|
return override;
|
|
}
|
|
|
|
// 2. Compute the tier
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
const profile = String(config['model_profile'] || 'balanced').toLowerCase();
|
|
const agentModels = (MODEL_PROFILES as unknown as Record<string, Record<string, string>>)[agentType];
|
|
const phaseType = (AGENT_TO_PHASE_TYPE)[agentType];
|
|
const configModels = config['models'] as Record<string, string> | null | undefined;
|
|
const phaseTypeTier = (phaseType && configModels && typeof configModels === 'object')
|
|
? configModels[phaseType]
|
|
: undefined;
|
|
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
|
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
|
? phaseTypeTier
|
|
: (profile === 'inherit'
|
|
? 'inherit'
|
|
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
|
|
|
// 2.5. model_policy preset (#49)
|
|
const configRuntime = config['runtime'] as string | null | undefined;
|
|
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
|
const mergedPolicy = config['model_policy']
|
|
? { ...(config['model_policy'] as Record<string, unknown>), runtime: configRuntime }
|
|
: null;
|
|
const policyModel = resolveModelPolicy(mergedPolicy, tier);
|
|
if (policyModel) return policyModel;
|
|
}
|
|
|
|
// 3. Runtime-aware resolution (#2517)
|
|
if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') {
|
|
const entry = _resolveRuntimeTier(config, tier);
|
|
if (entry?.model) return entry.model;
|
|
}
|
|
|
|
// 4. resolve_model_ids: "omit"
|
|
if (config['resolve_model_ids'] === 'omit') {
|
|
return '';
|
|
}
|
|
|
|
// 5. Profile lookup (Claude-native default).
|
|
if (!agentModels) {
|
|
return profile === 'quality' ? 'opus'
|
|
: profile === 'budget' ? 'haiku'
|
|
: profile === 'inherit' ? 'inherit'
|
|
: 'sonnet';
|
|
}
|
|
if (tier === 'inherit') return 'inherit';
|
|
const alias = tier;
|
|
|
|
if (config['resolve_model_ids']) {
|
|
return (MODEL_ALIAS_MAP as Record<string, string>)[alias!] || alias!;
|
|
}
|
|
|
|
return alias!;
|
|
}
|
|
|
|
const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']);
|
|
|
|
/**
|
|
* Resolve the planning granularity for a phase type (#68).
|
|
*/
|
|
function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string {
|
|
if (override !== undefined && override !== null && override !== '') {
|
|
if (VALID_GRANULARITIES.has(override)) {
|
|
return override;
|
|
}
|
|
}
|
|
const config = loadConfig(cwd);
|
|
const configGranularities = config['granularities'] as Record<string, string> | null | undefined;
|
|
const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object')
|
|
? configGranularities[phaseType]
|
|
: undefined;
|
|
if (perPhase && VALID_GRANULARITIES.has(perPhase)) {
|
|
return perPhase;
|
|
}
|
|
if (config['granularity'] !== undefined && config['granularity'] !== null && config['granularity'] !== '') {
|
|
return config['granularity'] as string;
|
|
}
|
|
const planning = config['planning'] as Record<string, unknown> | null | undefined;
|
|
const planningGran = planning && planning['granularity'];
|
|
if (planningGran !== undefined && planningGran !== null && planningGran !== '') {
|
|
return planningGran as string;
|
|
}
|
|
return 'standard';
|
|
}
|
|
|
|
/**
|
|
* Validate a CLI granularity override at the command boundary. Empty/null/undefined
|
|
* are treated as "no override" (no-op). An invalid non-empty value calls `fail`.
|
|
*/
|
|
function assertValidGranularityOverride(
|
|
override: string | null | undefined,
|
|
fail: (msg: string) => never,
|
|
): void {
|
|
if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) {
|
|
fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
|
*/
|
|
function resolveModelForTier(cwd: string, agentType: string, attempt?: number): string {
|
|
const config = loadConfig(cwd);
|
|
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
|
|
|
const modelOverrides = config['model_overrides'] as Record<string, string> | null | undefined;
|
|
const override = modelOverrides?.[agentType];
|
|
if (override) return override;
|
|
|
|
if (config['model_policy'] && config['runtime'] && config['runtime'] !== 'claude') {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
|
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const tierModels = dr['tier_models'] as Record<string, string> | null | undefined;
|
|
if (!tierModels || typeof tierModels !== 'object') {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const defaultTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
if (!defaultTier || !(VALID_AGENT_TIERS).has(defaultTier)) {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
|
? (dr['max_escalations'] as number)
|
|
: 1;
|
|
const escalationEnabled = dr['escalate_on_failure'] !== false;
|
|
const effectiveAttempt = escalationEnabled
|
|
? Math.min(attemptN, maxEscalations)
|
|
: 0;
|
|
|
|
let tier = defaultTier;
|
|
for (let i = 0; i < effectiveAttempt; i += 1) {
|
|
const next = (nextTier)(tier);
|
|
if (!next || next === tier) break;
|
|
tier = next;
|
|
}
|
|
|
|
const alias = tierModels[tier];
|
|
if (typeof alias !== 'string' || alias.length === 0) {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
return alias;
|
|
}
|
|
|
|
// ─── #443 — Unified effort + fast_mode resolvers ─────────────────────────────
|
|
|
|
const VALID_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
const EFFORT_SET = new Set(VALID_EFFORTS);
|
|
|
|
/**
|
|
* Walk one step up the effort ladder from `e`.
|
|
*/
|
|
function nextEffort(e: string): string | null {
|
|
const i = VALID_EFFORTS.indexOf(e);
|
|
if (i < 0) return null;
|
|
return VALID_EFFORTS[Math.min(i + 1, VALID_EFFORTS.length - 1)];
|
|
}
|
|
|
|
interface EffortOpts {
|
|
override?: string;
|
|
}
|
|
|
|
interface FastModeOpts {
|
|
override?: boolean;
|
|
}
|
|
|
|
/**
|
|
* #443 — Resolve a universal effort string for (cwd, agentType).
|
|
*/
|
|
function resolveEffortInternal(cwd: string, agentType: string, opts?: EffortOpts): string {
|
|
// Step 1: invocation override
|
|
if (opts && typeof opts.override === 'string' && EFFORT_SET.has(opts.override)) {
|
|
return opts.override;
|
|
}
|
|
|
|
const config = loadConfig(cwd);
|
|
const effortCfg = (config['effort'] && typeof config['effort'] === 'object' && !Array.isArray(config['effort']))
|
|
? (config['effort'] as Record<string, unknown>)
|
|
: null;
|
|
|
|
// Step 2: agent_overrides
|
|
if (effortCfg) {
|
|
const ao = effortCfg['agent_overrides'];
|
|
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
|
const v = (ao as Record<string, unknown>)[agentType];
|
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
}
|
|
} else {
|
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
const mao = canonicalEffort && typeof canonicalEffort === 'object'
|
|
? (canonicalEffort as Record<string, unknown>)['agent_overrides']
|
|
: undefined;
|
|
if (mao && typeof mao === 'object' && !Array.isArray(mao)) {
|
|
const v = (mao as Record<string, unknown>)[agentType];
|
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
}
|
|
}
|
|
|
|
// Step 3: routing_tier_defaults by agent's default tier.
|
|
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
if (agentTier) {
|
|
if (effortCfg && effortCfg['routing_tier_defaults'] &&
|
|
typeof effortCfg['routing_tier_defaults'] === 'object' &&
|
|
!Array.isArray(effortCfg['routing_tier_defaults'])) {
|
|
const v = (effortCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
} else if (!effortCfg) {
|
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
const manifestDefaults = canonicalEffort && typeof canonicalEffort === 'object'
|
|
? (canonicalEffort as Record<string, unknown>)['routing_tier_defaults']
|
|
: undefined;
|
|
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
|
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
|
if (typeof v === 'string' && EFFORT_SET.has(v)) return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: effort.default
|
|
if (effortCfg) {
|
|
const d = effortCfg['default'];
|
|
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
|
} else {
|
|
const canonicalEffort = (CANONICAL_CONFIG_DEFAULTS)['effort'];
|
|
const d = canonicalEffort && typeof canonicalEffort === 'object'
|
|
? (canonicalEffort as Record<string, unknown>)['default']
|
|
: undefined;
|
|
if (typeof d === 'string' && EFFORT_SET.has(d)) return d;
|
|
}
|
|
|
|
// Step 5: hardcoded default
|
|
return 'high';
|
|
}
|
|
|
|
/**
|
|
* #443 — Resolve fast_mode boolean for (cwd, agentType).
|
|
*/
|
|
function resolveFastModeInternal(cwd: string, agentType: string, opts?: FastModeOpts): boolean {
|
|
// Step 1: invocation override
|
|
if (opts && typeof opts.override === 'boolean') {
|
|
return opts.override;
|
|
}
|
|
|
|
const config = loadConfig(cwd);
|
|
const fmCfg = (config['fast_mode'] && typeof config['fast_mode'] === 'object' && !Array.isArray(config['fast_mode']))
|
|
? (config['fast_mode'] as Record<string, unknown>)
|
|
: null;
|
|
|
|
// Step 2: agent_overrides
|
|
if (fmCfg) {
|
|
const ao = fmCfg['agent_overrides'];
|
|
if (ao && typeof ao === 'object' && !Array.isArray(ao)) {
|
|
const v = (ao as Record<string, unknown>)[agentType];
|
|
if (typeof v === 'boolean') return v;
|
|
}
|
|
}
|
|
|
|
// Step 3: routing_tier_defaults by agent's default tier.
|
|
const agentTier = (AGENT_DEFAULT_TIERS)[agentType];
|
|
if (agentTier) {
|
|
if (fmCfg && fmCfg['routing_tier_defaults'] &&
|
|
typeof fmCfg['routing_tier_defaults'] === 'object' &&
|
|
!Array.isArray(fmCfg['routing_tier_defaults'])) {
|
|
const v = (fmCfg['routing_tier_defaults'] as Record<string, unknown>)[agentTier];
|
|
if (typeof v === 'boolean') return v;
|
|
} else if (!fmCfg) {
|
|
const canonicalFm = (CANONICAL_CONFIG_DEFAULTS)['fast_mode'];
|
|
const manifestDefaults = canonicalFm && typeof canonicalFm === 'object'
|
|
? (canonicalFm as Record<string, unknown>)['routing_tier_defaults']
|
|
: undefined;
|
|
if (manifestDefaults && typeof manifestDefaults === 'object') {
|
|
const v = (manifestDefaults as Record<string, unknown>)[agentTier];
|
|
if (typeof v === 'boolean') return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: fast_mode.enabled
|
|
if (fmCfg && typeof fmCfg['enabled'] === 'boolean') {
|
|
return fmCfg['enabled'];
|
|
}
|
|
|
|
// Step 5: hardcoded default
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* #443 — Resolve effort for a dynamic-routing attempt (with escalation).
|
|
*/
|
|
function resolveEffortForTier(cwd: string, agentType: string, attempt?: number): string {
|
|
const base = resolveEffortInternal(cwd, agentType);
|
|
|
|
const config = loadConfig(cwd);
|
|
const dr = config['dynamic_routing'] as Record<string, unknown> | null | undefined;
|
|
if (!dr || typeof dr !== 'object' || dr['enabled'] !== true) {
|
|
return base;
|
|
}
|
|
if (dr['escalate_on_failure'] === false) {
|
|
return base;
|
|
}
|
|
|
|
const maxEscalations = Number.isInteger(dr['max_escalations']) && (dr['max_escalations'] as number) >= 0
|
|
? (dr['max_escalations'] as number)
|
|
: 1;
|
|
|
|
const attemptN = Number.isInteger(attempt) && (attempt as number) > 0 ? (attempt as number) : 0;
|
|
const effectiveAttempt = Math.min(attemptN, maxEscalations);
|
|
|
|
let current = base;
|
|
for (let i = 0; i < effectiveAttempt; i++) {
|
|
const next = nextEffort(current);
|
|
if (!next || next === current) break;
|
|
current = next;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
// ─── Summary body helpers ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract a one-liner from the summary body when it's not in frontmatter.
|
|
*/
|
|
function extractOneLinerFromBody(content: string | null | undefined): string | null {
|
|
if (!content) return null;
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
|
|
if (!match) return null;
|
|
const boldInner = match[1].trim();
|
|
const afterBold = match[2];
|
|
if (/:\s*$/.test(boldInner)) {
|
|
const prose = afterBold.trim();
|
|
return prose.length > 0 ? prose : null;
|
|
}
|
|
return boldInner.length > 0 ? boldInner : null;
|
|
}
|
|
|
|
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
|
|
|
function pathExistsInternal(cwd: string, targetPath: string): boolean {
|
|
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
|
|
try {
|
|
fs.statSync(fullPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
interface GitWorktreeInfo {
|
|
inside: boolean;
|
|
worktreeRoot: string | null;
|
|
}
|
|
|
|
/**
|
|
* Detect whether `cwd` sits inside a git worktree, and if so, return the
|
|
* absolute path of the worktree root.
|
|
*/
|
|
function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo {
|
|
try {
|
|
const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
|
|
if (insideResult.exitCode !== 0) {
|
|
return { inside: false, worktreeRoot: null };
|
|
}
|
|
const insideStdout = String(insideResult.stdout || '').trim();
|
|
if (insideStdout !== 'true') {
|
|
return { inside: false, worktreeRoot: null };
|
|
}
|
|
const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
|
|
if (rootResult.exitCode !== 0) {
|
|
return { inside: true, worktreeRoot: null };
|
|
}
|
|
const root = String(rootResult.stdout || '').trim();
|
|
return { inside: true, worktreeRoot: root || null };
|
|
} catch {
|
|
return { inside: false, worktreeRoot: null };
|
|
}
|
|
}
|
|
|
|
function generateSlugInternal(text: string | null | undefined): string | null {
|
|
if (!text) return null;
|
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
|
|
}
|
|
|
|
interface MilestoneInfo {
|
|
version: string;
|
|
name: string;
|
|
}
|
|
|
|
function getMilestoneInfo(cwd: string): MilestoneInfo {
|
|
try {
|
|
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
|
if (roadmap === null) throw new Error('missing');
|
|
|
|
let stateVersion: string | null = null;
|
|
if (cwd) {
|
|
try {
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
const stateRaw = platformReadSync(statePath);
|
|
if (stateRaw !== null) {
|
|
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (m) stateVersion = m[1].trim();
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
}
|
|
|
|
if (stateVersion) {
|
|
const escapedVer = escapeRegex(stateVersion);
|
|
const headingMatch = roadmap.match(
|
|
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
|
);
|
|
if (headingMatch) {
|
|
if (!headingMatch[0].includes('✅')) {
|
|
return { version: stateVersion, name: headingMatch[1].trim() };
|
|
}
|
|
} else {
|
|
const listMatch = roadmap.match(
|
|
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
|
);
|
|
if (listMatch) {
|
|
return { version: stateVersion, name: listMatch[1].trim() };
|
|
}
|
|
return { version: stateVersion, name: 'milestone' };
|
|
}
|
|
}
|
|
|
|
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
|
if (inProgressMatch) {
|
|
return {
|
|
version: 'v' + inProgressMatch[1],
|
|
name: inProgressMatch[2].trim(),
|
|
};
|
|
}
|
|
|
|
const cleaned = stripShippedMilestones(roadmap);
|
|
const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
|
if (headingMatch) {
|
|
return {
|
|
version: 'v' + headingMatch[1],
|
|
name: headingMatch[2].trim(),
|
|
};
|
|
}
|
|
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
|
return {
|
|
version: versionMatch ? versionMatch[0] : 'v1.0',
|
|
name: 'milestone',
|
|
};
|
|
} catch {
|
|
return { version: 'v1.0', name: 'milestone' };
|
|
}
|
|
}
|
|
|
|
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
|
|
phaseCount: number;
|
|
missingExplicitVersion: boolean;
|
|
};
|
|
|
|
/**
|
|
* Returns a filter function that checks whether a phase directory belongs
|
|
* to the current milestone based on ROADMAP.md phase headings.
|
|
*/
|
|
function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null): MilestonePhaseFilter {
|
|
const milestonePhaseNums = new Set<string>();
|
|
let missingExplicitVersion = false;
|
|
try {
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
const roadmapContent = platformReadSync(roadmapPath);
|
|
if (roadmapContent === null) throw new Error('missing');
|
|
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
|
|
const hasVersionedMilestonesGlobal = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
|
const hasPhaseHeadings = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i.test(roadmapContent);
|
|
if (!hasVersionedMilestonesGlobal && hasPhaseHeadings) {
|
|
console.warn(
|
|
'[gsd] Deprecated: free-form ROADMAP.md detected (no versioned milestone headings). ' +
|
|
'Set phase_id_convention in config.json to suppress this warning.'
|
|
);
|
|
}
|
|
|
|
if (versionOverride) {
|
|
const escapedVersion = escapeRegex(versionOverride);
|
|
const sectionPattern = new RegExp(`(^#{1,3}\\s+(?!Phase\\s+\\S).*${escapedVersion}[^\\n]*)`, 'mi');
|
|
let sectionMatch = roadmapContent.match(sectionPattern);
|
|
|
|
if (!sectionMatch) {
|
|
const summaryPat = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i');
|
|
const summaryHit = roadmapContent.match(summaryPat);
|
|
if (summaryHit) {
|
|
const beforeSummary = roadmapContent.slice(0, summaryHit.index);
|
|
const detailsIdx = beforeSummary.lastIndexOf('<details');
|
|
if (detailsIdx !== -1) {
|
|
sectionMatch = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!sectionMatch) {
|
|
const hasVersionedMilestones = /^#{1,3}\s+(?!Phase\s+\S).*v\d+\.\d+/mi.test(roadmapContent);
|
|
const versionInSummary = new RegExp(`<summary[^>]*>[^<]*${escapedVersion}[^<]*<\\/summary>`, 'i').test(roadmapContent);
|
|
if (hasVersionedMilestones && !versionInSummary) {
|
|
roadmap = '';
|
|
missingExplicitVersion = true;
|
|
}
|
|
} else {
|
|
const sectionStart = sectionMatch.index!;
|
|
const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length;
|
|
const restContent = roadmapContent.slice(sectionStart + sectionMatch[0].length);
|
|
const nextMilestonePattern = new RegExp(`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, 'i');
|
|
|
|
let sectionEnd = roadmapContent.length;
|
|
let fenceChar: string | null = null;
|
|
let fenceLen = 0;
|
|
let charOffset = 0;
|
|
for (const line of restContent.split('\n')) {
|
|
const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
|
|
if (fenceMatch) {
|
|
const char = fenceMatch[1][0];
|
|
const len = fenceMatch[1].length;
|
|
const trailing = fenceMatch[2] || '';
|
|
if (!fenceChar) {
|
|
fenceChar = char;
|
|
fenceLen = len;
|
|
} else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) {
|
|
fenceChar = null;
|
|
fenceLen = 0;
|
|
}
|
|
} else if (!fenceChar && nextMilestonePattern.test(line)) {
|
|
sectionEnd = sectionStart + sectionMatch[0].length + charOffset;
|
|
break;
|
|
}
|
|
charOffset += line.length + 1;
|
|
}
|
|
|
|
const currentSection = roadmapContent.slice(sectionStart, sectionEnd);
|
|
roadmap = currentSection;
|
|
}
|
|
}
|
|
|
|
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = phasePattern.exec(roadmap)) !== null) {
|
|
milestonePhaseNums.add(m[1]);
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
|
|
if (milestonePhaseNums.size === 0) {
|
|
const passAll = (() => true) as unknown as MilestonePhaseFilter;
|
|
passAll.phaseCount = 0;
|
|
passAll.missingExplicitVersion = missingExplicitVersion;
|
|
return passAll;
|
|
}
|
|
|
|
const normalized = new Set(
|
|
[...milestonePhaseNums].map(n => n.split('-').map(seg => (seg.replace(/^0+(?=\d)/, '') || '0')).join('-').toLowerCase())
|
|
);
|
|
|
|
function normalizePhaseIdSegments(id: string): string {
|
|
return id.split('-').map(seg => seg.replace(/^0+(?=\d)/, '') || '0').join('-');
|
|
}
|
|
|
|
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
|
const numericRe = roadmapUsesHyphenedIds
|
|
? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
|
|
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
|
|
|
function isDirInMilestone(dirName: string): boolean {
|
|
const m2 = dirName.match(numericRe);
|
|
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
|
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
|
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
|
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
|
if (stripped !== dirName) {
|
|
const sm = stripped.match(numericRe);
|
|
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
|
|
}
|
|
return false;
|
|
}
|
|
(isDirInMilestone as MilestonePhaseFilter).phaseCount = milestonePhaseNums.size;
|
|
(isDirInMilestone as MilestonePhaseFilter).missingExplicitVersion = missingExplicitVersion;
|
|
return isDirInMilestone as MilestonePhaseFilter;
|
|
}
|
|
|
|
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
|
|
|
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
|
|
function filterPlanFiles(files: string[]): string[] {
|
|
return files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
|
|
}
|
|
|
|
/** Filter a file list to just SUMMARY.md / *-SUMMARY.md entries. */
|
|
function filterSummaryFiles(files: string[]): string[] {
|
|
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
}
|
|
|
|
interface PhaseFileStats {
|
|
plans: string[];
|
|
summaries: string[];
|
|
hasResearch: boolean;
|
|
hasContext: boolean;
|
|
hasVerification: boolean;
|
|
hasReviews: boolean;
|
|
}
|
|
|
|
/**
|
|
* Read a phase directory and return counts/flags for common file types.
|
|
*/
|
|
function getPhaseFileStats(phaseDir: string): PhaseFileStats {
|
|
const files = fs.readdirSync(phaseDir);
|
|
return {
|
|
plans: filterPlanFiles(files),
|
|
summaries: filterSummaryFiles(files),
|
|
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
|
hasContext: findContextMdIn(files) !== null,
|
|
hasVerification: files.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md'),
|
|
hasReviews: files.some(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Read immediate child directories from a path.
|
|
* Returns [] if the path doesn't exist or can't be read.
|
|
* Pass sort=true to apply comparePhaseNum ordering.
|
|
*/
|
|
function readSubdirectories(dirPath: string, sort = false): string[] {
|
|
try {
|
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
|
|
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
|
|
*/
|
|
function timeAgo(date: Date): string {
|
|
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
|
if (seconds < 5) return 'just now';
|
|
if (seconds < 60) return `${seconds} seconds ago`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes === 1) return '1 minute ago';
|
|
if (minutes < 60) return `${minutes} minutes ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours === 1) return '1 hour ago';
|
|
if (hours < 24) return `${hours} hours ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days === 1) return '1 day ago';
|
|
if (days < 30) return `${days} days ago`;
|
|
const months = Math.floor(days / 30);
|
|
if (months === 1) return '1 month ago';
|
|
if (months < 12) return `${months} months ago`;
|
|
const years = Math.floor(days / 365);
|
|
if (years === 1) return '1 year ago';
|
|
return `${years} years ago`;
|
|
}
|
|
|
|
export = {
|
|
output,
|
|
error,
|
|
ERROR_REASON,
|
|
setJsonErrorMode,
|
|
getJsonErrorMode,
|
|
loadConfig,
|
|
isGitIgnored,
|
|
escapeRegex,
|
|
normalizePhaseName,
|
|
getMilestoneFromPhaseId,
|
|
getPhaseDirFromPhaseId,
|
|
phaseMarkdownRegexSource,
|
|
phaseMarkdownRegexSourceExact,
|
|
comparePhaseNum,
|
|
searchPhaseInDir,
|
|
extractPhaseToken,
|
|
phaseTokenMatches,
|
|
findPhaseInternal,
|
|
getArchivedPhaseDirs,
|
|
getRoadmapPhaseInternal,
|
|
resolveModelInternal,
|
|
resolveModelForTier,
|
|
resolveGranularityInternal,
|
|
VALID_GRANULARITIES,
|
|
assertValidGranularityOverride,
|
|
resolveEffortInternal,
|
|
resolveFastModeInternal,
|
|
resolveEffortForTier,
|
|
VALID_EFFORTS,
|
|
EFFORT_SET,
|
|
nextEffort,
|
|
RUNTIME_PROFILE_MAP,
|
|
RUNTIMES_WITH_REASONING_EFFORT,
|
|
RUNTIMES_WITH_FAST_MODE,
|
|
KNOWN_RUNTIMES,
|
|
RUNTIME_OVERRIDE_TIERS,
|
|
resolveTierEntry,
|
|
resolveModelPolicy,
|
|
KNOWN_PROVIDERS,
|
|
_resetRuntimeWarningCacheForTests,
|
|
pathExistsInternal,
|
|
gitWorktreeInfoInternal,
|
|
generateSlugInternal,
|
|
getMilestoneInfo,
|
|
getMilestonePhaseFilter,
|
|
stripShippedMilestones,
|
|
extractCurrentMilestone,
|
|
replaceInCurrentMilestone,
|
|
toPosixPath,
|
|
extractOneLinerFromBody,
|
|
resolveWorktreeRoot,
|
|
// Deprecated re-exports — prefer direct import from planning-workspace.cjs
|
|
withPlanningLock,
|
|
findProjectRoot,
|
|
detectSubRepos,
|
|
reapStaleTempFiles,
|
|
GSD_TEMP_DIR,
|
|
MODEL_ALIAS_MAP,
|
|
CONFIG_DEFAULTS,
|
|
planningDir,
|
|
planningRoot,
|
|
planningPaths,
|
|
getActiveWorkstream,
|
|
setActiveWorkstream,
|
|
filterPlanFiles,
|
|
filterSummaryFiles,
|
|
getPhaseFileStats,
|
|
readSubdirectories,
|
|
getAgentsDir,
|
|
checkAgentsInstalled,
|
|
timeAgo,
|
|
pruneOrphanedWorktrees,
|
|
inspectWorktreeHealth,
|
|
};
|