* test: red — bounded git subprocess + structured worktree warnings (#3281) Regression tests for #3281: worktree-related git subprocess calls have no timeout bound, and timeout/error outcomes are not surfaced as structured signals. Failing assertions: - planWorktreePrune / listLinkedWorktreePaths / snapshotWorktreeInventory must return reason=git_timed_out (not generic git_list_failed) when execGit returns timedOut:true — enables callers to distinguish timeout from auth failure - executeWorktreePrunePlan must include timedOut:true in result when the git prune call itself times out Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): bounded git subprocess + structured warning surfacing (#3281) Root cause (PRED.k014): execGit / execGitDefault called spawnSync with no timeout, so `git worktree list --porcelain` against a hung/locked repo blocked the parent process indefinitely. Downstream callers in core.cjs and verify.cjs then swallowed any resulting failure silently via catch { /* intentionally empty */ } (PRED.k302). Fix: - worktree-safety.cjs: execGitDefault now passes timeout:10000 to spawnSync. Detects SIGTERM+ETIMEDOUT and returns { timedOut:true } in the result shape. readWorktreeList maps timedOut:true -> reason:'git_timed_out' (distinct from generic git_list_failed) so callers can emit a structured warning. executeWorktreePrunePlan propagates timedOut:true as a first-class result field. - core.cjs: execGit receives the same timeout+timedOut treatment (PRED.k014 uniform-fix discipline). pruneOrphanedWorktrees now emits a [gsd-tools] WARNING to stderr when the git prune call times out instead of silent-catch. - verify.cjs: Check 11 branches on worktreeHealth.ok to surface W018 warning when the worktree list times out, instead of silent-catch on ok:false. Backward-compatible: exitCode/stdout/stderr continue to work for all existing callers; timedOut and error are additive new fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changeset: pr=3283 for #3281 * fix(verify): rename W020 for worktree-timeout warning to avoid W018 collision W018 is already used for milestone archive drift (Check 12). The new worktree-health-degraded timeout warning was assigned W018, causing warning-code ambiguity in triage. Rename to W020 (next available code). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2037 lines
83 KiB
JavaScript
2037 lines
83 KiB
JavaScript
/**
|
|
* Core — Shared utilities, constants, and internal helpers
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { execSync, execFileSync, spawnSync } = require('child_process');
|
|
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = require('./model-profiles.cjs');
|
|
const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT } = require('./model-catalog.cjs');
|
|
const {
|
|
resolveWorktreeContext,
|
|
parseWorktreePorcelain: parseWorktreePorcelainPolicy,
|
|
planWorktreePrune,
|
|
executeWorktreePrunePlan,
|
|
inspectWorktreeHealth,
|
|
} = require('./worktree-safety.cjs');
|
|
// Compatibility shim: new imports should use planning-workspace.cjs directly.
|
|
const {
|
|
planningDir,
|
|
planningRoot,
|
|
planningPaths,
|
|
withPlanningLock,
|
|
getActiveWorkstream,
|
|
setActiveWorkstream,
|
|
} = require('./planning-workspace.cjs');
|
|
|
|
// ─── Path helpers ────────────────────────────────────────────────────────────
|
|
|
|
/** Normalize a relative path to always use forward slashes (cross-platform). */
|
|
function toPosixPath(p) {
|
|
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) {
|
|
const results = [];
|
|
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 {}
|
|
}
|
|
} catch {}
|
|
return results.sort();
|
|
}
|
|
|
|
/**
|
|
* Walk up from `startDir` to find the project root that owns `.planning/`.
|
|
*
|
|
* In multi-repo workspaces, Claude may open inside a sub-repo (e.g. `backend/`)
|
|
* instead of the project root. This function prevents `.planning/` from being
|
|
* created inside the sub-repo by locating the nearest ancestor that already has
|
|
* a `.planning/` directory.
|
|
*
|
|
* Detection strategy (checked in order for each ancestor):
|
|
* 1. Parent has `.planning/config.json` with `sub_repos` listing this directory
|
|
* 2. Parent has `.planning/config.json` with `multiRepo: true` (legacy format)
|
|
* 3. Parent has `.planning/` and current dir has its own `.git` (heuristic)
|
|
*
|
|
* Returns `startDir` unchanged when no ancestor `.planning/` is found (first-run
|
|
* or single-repo projects).
|
|
*/
|
|
function findProjectRoot(startDir) {
|
|
const resolved = path.resolve(startDir);
|
|
const root = path.parse(resolved).root;
|
|
const homedir = require('os').homedir();
|
|
|
|
// If startDir already contains .planning/, it IS the project root.
|
|
// Do not walk up to a parent workspace that also has .planning/ (#1362).
|
|
const ownPlanning = path.join(resolved, '.planning');
|
|
if (fs.existsSync(ownPlanning) && fs.statSync(ownPlanning).isDirectory()) {
|
|
return startDir;
|
|
}
|
|
|
|
// Check if startDir or any of its ancestors (up to AND including the
|
|
// candidate project root) contains a .git directory. This handles both
|
|
// `backend/` (direct sub-repo) and `backend/src/modules/` (nested inside),
|
|
// as well as the common case where .git lives at the same level as .planning/.
|
|
function isInsideGitRepo(candidateParent) {
|
|
let d = resolved;
|
|
while (d !== root) {
|
|
if (fs.existsSync(path.join(d, '.git'))) return true;
|
|
if (d === candidateParent) break;
|
|
d = path.dirname(d);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let dir = resolved;
|
|
while (dir !== root) {
|
|
const parent = path.dirname(dir);
|
|
if (parent === dir) break; // filesystem root
|
|
if (parent === homedir) break; // never go above home
|
|
|
|
const parentPlanning = path.join(parent, '.planning');
|
|
if (fs.existsSync(parentPlanning) && fs.statSync(parentPlanning).isDirectory()) {
|
|
const configPath = path.join(parentPlanning, 'config.json');
|
|
try {
|
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
const subRepos = config.sub_repos || config.planning?.sub_repos || [];
|
|
|
|
// Check explicit sub_repos list
|
|
if (Array.isArray(subRepos) && subRepos.length > 0) {
|
|
const relPath = path.relative(parent, resolved);
|
|
const topSegment = relPath.split(path.sep)[0];
|
|
if (subRepos.includes(topSegment)) {
|
|
return parent;
|
|
}
|
|
}
|
|
|
|
// Check legacy multiRepo flag
|
|
if (config.multiRepo === true && isInsideGitRepo(parent)) {
|
|
return parent;
|
|
}
|
|
} catch {
|
|
// config.json missing or malformed — fall back to .git heuristic
|
|
}
|
|
|
|
// Heuristic: parent has .planning/ and we're inside a git repo
|
|
if (isInsideGitRepo(parent)) {
|
|
return parent;
|
|
}
|
|
}
|
|
dir = parent;
|
|
}
|
|
return startDir;
|
|
}
|
|
|
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
|
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
|
* @param {string} prefix - filename prefix to match (e.g., 'gsd-')
|
|
* @param {object} opts
|
|
* @param {number} opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
|
* @param {boolean} opts.dirsOnly - if true, only remove directories (default: false)
|
|
*/
|
|
/**
|
|
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
|
|
* Created on first use. Keeps GSD temp files isolated from the system
|
|
* temp directory so reap scans only GSD files (#1975).
|
|
*/
|
|
const GSD_TEMP_DIR = path.join(require('os').tmpdir(), 'gsd');
|
|
|
|
function ensureGsdTempDir() {
|
|
fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
|
|
}
|
|
|
|
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
|
|
try {
|
|
ensureGsdTempDir();
|
|
const now = Date.now();
|
|
const entries = fs.readdirSync(GSD_TEMP_DIR);
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith(prefix)) continue;
|
|
const fullPath = path.join(GSD_TEMP_DIR, entry);
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
if (now - stat.mtimeMs > maxAgeMs) {
|
|
if (stat.isDirectory()) {
|
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
} else if (!dirsOnly) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// File may have been removed between readdir and stat — ignore
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical — don't let cleanup failures break output
|
|
}
|
|
}
|
|
|
|
function output(result, raw, rawValue) {
|
|
let data;
|
|
if (raw && rawValue !== undefined) {
|
|
data = String(rawValue);
|
|
} else {
|
|
const json = JSON.stringify(result, null, 2);
|
|
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
|
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
|
if (json.length > 50000) {
|
|
reapStaleTempFiles();
|
|
ensureGsdTempDir();
|
|
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
|
fs.writeFileSync(tmpPath, json, 'utf-8');
|
|
data = '@file:' + tmpPath;
|
|
} else {
|
|
data = json;
|
|
}
|
|
}
|
|
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
|
// can tear down the process before the reader consumes the buffer.
|
|
// fs.writeSync(1, ...) blocks until the kernel accepts the bytes, and
|
|
// skipping process.exit() lets the event loop drain naturally.
|
|
fs.writeSync(1, data);
|
|
}
|
|
|
|
/**
|
|
* Frozen enum of typed reason codes used by error() for structured errors.
|
|
* Each subcommand contributes its own codes; the enum exists so tests can
|
|
* assert against typed values instead of grepping stderr (#2974).
|
|
*
|
|
* Adding a new code:
|
|
* - Pick a snake_case lowercase value (the JSON wire form)
|
|
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
|
|
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
|
|
*/
|
|
const ERROR_REASON = Object.freeze({
|
|
// config-get / config-set
|
|
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
|
|
CONFIG_NO_FILE: 'config_no_file',
|
|
CONFIG_PARSE_FAILED: 'config_parse_failed',
|
|
CONFIG_INVALID_KEY: 'config_invalid_key',
|
|
// SDK / gsd-tools dispatch
|
|
SDK_FAIL_FAST: 'sdk_fail_fast',
|
|
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
|
|
SDK_MISSING_ARG: 'sdk_missing_arg',
|
|
// workflow / phase
|
|
PHASE_NOT_FOUND: 'phase_not_found',
|
|
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
|
// graphify
|
|
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
|
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
|
|
// hooks
|
|
HOOKS_OPT_OUT: 'hooks_opt_out',
|
|
// security-scan
|
|
SECURITY_SCAN_FAILED: 'security_scan_failed',
|
|
// generic
|
|
USAGE: 'usage',
|
|
UNKNOWN: 'unknown',
|
|
});
|
|
|
|
/**
|
|
* Process-level flag: when true, error() emits structured JSON to stderr
|
|
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
|
|
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
|
|
* assertions by passing that flag and parsing the JSON.
|
|
*
|
|
* Default off so existing callers and human operators keep their plain-text
|
|
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
|
|
*/
|
|
let _jsonErrorMode = false;
|
|
function setJsonErrorMode(v) { _jsonErrorMode = !!v; }
|
|
function getJsonErrorMode() { return _jsonErrorMode; }
|
|
|
|
/**
|
|
* Emit an error and exit. When the second argument is provided it must be
|
|
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
|
|
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
|
|
* message }` so callers can parse it; otherwise stderr keeps the plain
|
|
* text form for human operators.
|
|
*/
|
|
function error(message, reason = ERROR_REASON.UNKNOWN) {
|
|
if (_jsonErrorMode) {
|
|
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
|
fs.writeSync(2, payload);
|
|
} else {
|
|
fs.writeSync(2, 'Error: ' + message + '\n');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
// ─── File & Config utilities ──────────────────────────────────────────────────
|
|
|
|
function safeReadFile(filePath) {
|
|
try {
|
|
return fs.readFileSync(filePath, 'utf-8');
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Canonical config defaults. Single source of truth — imported by config.cjs and verify.cjs.
|
|
*/
|
|
const CONFIG_DEFAULTS = {
|
|
model_profile: 'balanced',
|
|
commit_docs: true,
|
|
search_gitignored: false,
|
|
branching_strategy: 'none',
|
|
phase_branch_template: 'gsd/phase-{phase}-{slug}',
|
|
milestone_branch_template: 'gsd/{milestone}-{slug}',
|
|
quick_branch_template: null,
|
|
research: true,
|
|
plan_checker: true,
|
|
verifier: true,
|
|
nyquist_validation: true,
|
|
ai_integration_phase: true,
|
|
parallelization: true,
|
|
brave_search: false,
|
|
firecrawl: false,
|
|
exa_search: false,
|
|
text_mode: false, // when true, use plain-text numbered lists instead of AskUserQuestion menus
|
|
sub_repos: [],
|
|
resolve_model_ids: false, // false: return alias as-is | true: map to full Claude model ID | "omit": return '' (runtime uses its default)
|
|
context_window: 200000, // default 200k; set to 1000000 for Opus/Sonnet 4.6 1M models
|
|
phase_naming: 'sequential', // 'sequential' (default, auto-increment) or 'custom' (arbitrary string IDs)
|
|
project_code: null, // optional short prefix for phase dirs (e.g., 'CK' → 'CK-01-foundation')
|
|
subagent_timeout: 300000, // 5 min default; increase for large codebases or slower models (ms)
|
|
security_enforcement: true, // workflow.security_enforcement — threat-model-anchored security verification via /gsd-secure-phase
|
|
security_asvs_level: 1, // workflow.security_asvs_level — OWASP ASVS verification level (1=opportunistic, 2=standard, 3=comprehensive)
|
|
security_block_on: 'high', // workflow.security_block_on — minimum severity that blocks phase advancement ('high' | 'medium' | 'low')
|
|
post_planning_gaps: true, // workflow.post_planning_gaps — unified post-planning gap report (#2493): scan REQUIREMENTS.md + CONTEXT.md decisions vs all PLAN.md files
|
|
};
|
|
|
|
/**
|
|
* 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, overlay) {
|
|
if (overlay === null || overlay === undefined) return overlay;
|
|
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
|
|
const result = { ...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] ?? {}, overlay[key]);
|
|
} else {
|
|
result[key] = overlay[key];
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function loadConfig(cwd, options = {}) {
|
|
const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
|
|
? options.workstream
|
|
: (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 = activeWorkstream;
|
|
let rootParsed = null;
|
|
if (ws) {
|
|
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
|
try {
|
|
const raw = fs.readFileSync(rootConfigPath, 'utf-8');
|
|
rootParsed = JSON.parse(raw);
|
|
} 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 = fs.readFileSync(configPath, 'utf-8');
|
|
// `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 = JSON.parse(raw);
|
|
|
|
// Migrate deprecated "depth" key to "granularity" with value mapping
|
|
if ('depth' in fileData && !('granularity' in fileData)) {
|
|
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
|
fileData.granularity = depthToGranularity[fileData.depth] || fileData.depth;
|
|
delete fileData.depth;
|
|
try { fs.writeFileSync(configPath, JSON.stringify(fileData, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
|
|
}
|
|
|
|
// Auto-detect and sync sub_repos: scan for child directories with .git
|
|
let configDirty = false;
|
|
|
|
// Migrate legacy "multiRepo: true" boolean → planning.sub_repos array.
|
|
// Canonical location is planning.sub_repos (#2561); writing to top-level
|
|
// would be flagged as unknown by the validator below (#2638).
|
|
if (fileData.multiRepo === true && !fileData.sub_repos && !fileData.planning?.sub_repos) {
|
|
const detected = detectSubRepos(cwd);
|
|
if (detected.length > 0) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
fileData.planning.sub_repos = detected;
|
|
fileData.planning.commit_docs = false;
|
|
delete fileData.multiRepo;
|
|
configDirty = true;
|
|
}
|
|
}
|
|
|
|
// Self-heal legacy/buggy installs: strip any stale top-level sub_repos,
|
|
// preserving its value as the planning.sub_repos seed if that slot is empty.
|
|
if (Object.prototype.hasOwnProperty.call(fileData, 'sub_repos')) {
|
|
if (!fileData.planning) fileData.planning = {};
|
|
if (!fileData.planning.sub_repos) {
|
|
fileData.planning.sub_repos = fileData.sub_repos;
|
|
}
|
|
delete fileData.sub_repos;
|
|
configDirty = true;
|
|
}
|
|
|
|
// Keep planning.sub_repos in sync with actual filesystem
|
|
const currentSubRepos = fileData.planning?.sub_repos || [];
|
|
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
|
const detected = detectSubRepos(cwd);
|
|
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 { fs.writeFileSync(configPath, JSON.stringify(fileData, null, 2), 'utf-8'); } catch {}
|
|
}
|
|
|
|
// Now apply root→workstream inheritance. `parsed` is the effective config
|
|
// used for value extraction below; fileData is kept for disk writes only.
|
|
const parsed = rootParsed ? _deepMergeConfig(rootParsed, fileData) : fileData;
|
|
|
|
// Warn about unrecognized top-level keys so users don't silently lose config.
|
|
// Derived from config-set's VALID_CONFIG_KEYS (canonical source) plus internal-only
|
|
// keys that loadConfig handles but config-set doesn't expose. This avoids maintaining
|
|
// a hardcoded duplicate that drifts when new config keys are added.
|
|
// DYNAMIC_KEY_PATTERNS supplies topLevel for each pattern so adding a new
|
|
// dynamic-pattern namespace to config-schema.cjs automatically updates this set
|
|
// — no more drift between the read side and the write side (#2687).
|
|
const { VALID_CONFIG_KEYS, DYNAMIC_KEY_PATTERNS } = require('./config-schema.cjs');
|
|
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 => k.split('.')[0]),
|
|
// Dynamic-pattern top-level containers (e.g. review, model_profile_overrides)
|
|
...DYNAMIC_KEY_PATTERNS.map(p => p.topLevel),
|
|
// Internal keys loadConfig reads but config-set doesn't expose
|
|
'model_overrides', 'context_window', 'resolve_model_ids', 'claude_md_path',
|
|
// Deprecated keys (still accepted for migration, not in config-set)
|
|
'depth', 'multiRepo',
|
|
]);
|
|
const unknownKeys = Object.keys(parsed).filter(k => !KNOWN_TOP_LEVEL.has(k));
|
|
if (unknownKeys.length > 0) {
|
|
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 for keys that loadConfig handles but
|
|
// can be edited directly into config.json (bypassing config-set's enum check).
|
|
// This catches typos like `runtime: "codx"` and `model_profile_overrides.codex.banana`
|
|
// at read time without rejecting back-compat values from new runtimes
|
|
// (review findings #10, #13).
|
|
_warnUnknownProfileOverrides(parsed, '.planning/config.json');
|
|
|
|
const get = (key, nested) => {
|
|
if (parsed[key] !== undefined) return parsed[key];
|
|
if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
|
|
return parsed[nested.section][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.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,
|
|
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. Six named slots
|
|
// (planning/discuss/research/execution/verification/completion).
|
|
// Resolves between per-agent override and profile-derived tier in
|
|
// resolveModelInternal. Defaults to null so configs without it
|
|
// behave exactly as today.
|
|
models: parsed.models || null,
|
|
// #3024 — dynamic routing block. When `enabled: true`, the
|
|
// resolveModelForTier() resolver picks tier_models[default_tier]
|
|
// for the agent and escalates one tier per attempt up to
|
|
// max_escalations. Disabled by default for backward compat.
|
|
dynamic_routing: parsed.dynamic_routing || null,
|
|
// #2517 — runtime-aware profiles. `runtime` defaults to null (back-compat).
|
|
// When null, resolveModelInternal preserves today's Claude-native behavior.
|
|
// NOTE: `runtime` and `model_profile_overrides` are intentionally read
|
|
// flat-only (not via `get()` with a workflow.X fallback) — they are
|
|
// top-level keys per docs/CONFIGURATION.md. The lighter-touch decision
|
|
// here was to document the constraint rather than introduce nested
|
|
// resolution edge cases for two new keys (review finding #9). The
|
|
// schema validation in `_warnUnknownProfileOverrides` runs against the
|
|
// raw `parsed` blob, so direct `.planning/config.json` edits surface
|
|
// unknown runtime/tier names at load time, not silently (review finding #10).
|
|
runtime: parsed.runtime || null,
|
|
model_profile_overrides: parsed.model_profile_overrides || null,
|
|
agent_skills: parsed.agent_skills || {},
|
|
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 .planning/ exists, the project is initialized — just missing config.json.
|
|
// When GSD_WORKSTREAM is set and root config was loaded, the workstream config
|
|
// doesn't exist — treat root config as the effective config for this workstream.
|
|
if (fs.existsSync(planningDir(cwd, ws))) {
|
|
if (rootParsed) {
|
|
// Workstream has no config.json: re-parse using root config as the sole source.
|
|
// Keep env immutable by explicitly reloading with workstream context cleared.
|
|
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 = fs.readFileSync(globalDefaultsPath, 'utf-8');
|
|
const globalDefaults = JSON.parse(raw);
|
|
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?.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,
|
|
dynamic_routing: globalDefaults.dynamic_routing || null,
|
|
agent_skills: globalDefaults.agent_skills || {},
|
|
response_language: globalDefaults.response_language || null,
|
|
};
|
|
} catch {
|
|
return defaults;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Git utilities ────────────────────────────────────────────────────────────
|
|
|
|
const _gitIgnoredCache = new Map();
|
|
|
|
function isGitIgnored(cwd, targetPath) {
|
|
const key = cwd + '::' + targetPath;
|
|
if (_gitIgnoredCache.has(key)) return _gitIgnoredCache.get(key);
|
|
try {
|
|
// --no-index checks .gitignore rules regardless of whether the file is tracked.
|
|
// Without it, git check-ignore returns "not ignored" for tracked files even when
|
|
// .gitignore explicitly lists them — a common source of confusion when .planning/
|
|
// was committed before being added to .gitignore.
|
|
// Use execFileSync (array args) to prevent shell interpretation of special characters
|
|
// in file paths — avoids command injection via crafted path names.
|
|
execFileSync('git', ['check-ignore', '-q', '--no-index', '--', targetPath], {
|
|
cwd,
|
|
stdio: 'pipe',
|
|
});
|
|
_gitIgnoredCache.set(key, true);
|
|
return true;
|
|
} catch {
|
|
_gitIgnoredCache.set(key, false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ─── Markdown normalization ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalize markdown to fix common markdownlint violations.
|
|
* Applied at write points so GSD-generated .planning/ files are IDE-friendly.
|
|
*
|
|
* Rules enforced:
|
|
* MD022 — Blank lines around headings
|
|
* MD031 — Blank lines around fenced code blocks
|
|
* MD032 — Blank lines around lists
|
|
* MD012 — No multiple consecutive blank lines (collapsed to 2 max)
|
|
* MD047 — Files end with a single newline
|
|
*/
|
|
function normalizeMd(content) {
|
|
if (!content || typeof content !== 'string') return content;
|
|
|
|
// Normalize line endings to LF for consistent processing
|
|
let text = content.replace(/\r\n/g, '\n');
|
|
|
|
const lines = text.split('\n');
|
|
const result = [];
|
|
|
|
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
|
|
const fenceRegex = /^```/;
|
|
const insideFence = new Array(lines.length);
|
|
let fenceOpen = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (fenceRegex.test(lines[i].trimEnd())) {
|
|
if (fenceOpen) {
|
|
// This is a closing fence — mark as NOT inside (it's the boundary)
|
|
insideFence[i] = false;
|
|
fenceOpen = false;
|
|
} else {
|
|
// This is an opening fence
|
|
insideFence[i] = false;
|
|
fenceOpen = true;
|
|
}
|
|
} else {
|
|
insideFence[i] = fenceOpen;
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const prev = i > 0 ? lines[i - 1] : '';
|
|
const prevTrimmed = prev.trimEnd();
|
|
const trimmed = line.trimEnd();
|
|
const isFenceLine = fenceRegex.test(trimmed);
|
|
|
|
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
|
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
|
result.push('');
|
|
}
|
|
|
|
// MD031: Blank line before fenced code blocks (opening fences only)
|
|
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
|
// Only add blank before opening fences (not closing ones)
|
|
if (i === 0 || !insideFence[i - 1]) {
|
|
result.push('');
|
|
}
|
|
}
|
|
|
|
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 &&
|
|
prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) &&
|
|
prevTrimmed !== '---') {
|
|
result.push('');
|
|
}
|
|
|
|
result.push(line);
|
|
|
|
// MD022: Blank line after headings
|
|
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1) {
|
|
const next = lines[i + 1];
|
|
if (next !== undefined && next.trimEnd() !== '') {
|
|
result.push('');
|
|
}
|
|
}
|
|
|
|
// MD031: Blank line after closing fenced code blocks
|
|
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
|
|
const next = lines[i + 1];
|
|
if (next !== undefined && next.trimEnd() !== '') {
|
|
result.push('');
|
|
}
|
|
}
|
|
|
|
// MD032: Blank line after last list item in a block
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
|
const next = lines[i + 1];
|
|
if (next !== undefined && next.trimEnd() !== '' &&
|
|
!/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) &&
|
|
!/^\s/.test(next)) {
|
|
// Only add blank line if next line is not a continuation/indented line
|
|
result.push('');
|
|
}
|
|
}
|
|
}
|
|
|
|
text = result.join('\n');
|
|
|
|
// MD012: Collapse 3+ consecutive blank lines to 2
|
|
text = text.replace(/\n{3,}/g, '\n\n');
|
|
|
|
// MD047: Ensure file ends with exactly one newline
|
|
text = text.replace(/\n*$/, '\n');
|
|
|
|
return text;
|
|
}
|
|
|
|
// Default timeout for worktree-related git subprocess calls (matches worktree-safety.cjs).
|
|
// Prevents `git worktree list --porcelain` and similar calls from blocking the parent
|
|
// process indefinitely when git is stalled (locked index, hung remote, NFS mount freeze).
|
|
// Callers can override via an options bag if needed.
|
|
const DEFAULT_GIT_TIMEOUT_MS = 10000;
|
|
|
|
/**
|
|
* Execute a git command with a bounded timeout.
|
|
*
|
|
* Return shape: { exitCode, stdout, stderr, timedOut, error }
|
|
* - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT — callers must
|
|
* branch on this to surface a structured warning (PRED.k302).
|
|
* - error: spawnSync error object or null
|
|
*
|
|
* Backward-compatible: existing callers that only read exitCode/stdout/stderr
|
|
* continue to work unchanged.
|
|
*/
|
|
function execGit(cwd, args, options = {}) {
|
|
const timeout = options.timeout ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
const result = spawnSync('git', args, {
|
|
cwd,
|
|
stdio: 'pipe',
|
|
encoding: 'utf-8',
|
|
timeout,
|
|
});
|
|
const timedOut = result.signal === 'SIGTERM' && result.error?.code === 'ETIMEDOUT';
|
|
return {
|
|
exitCode: result.status ?? 1,
|
|
stdout: (result.stdout ?? '').toString().trim(),
|
|
stderr: (result.stderr ?? '').toString().trim(),
|
|
timedOut,
|
|
error: result.error ?? null,
|
|
};
|
|
}
|
|
|
|
// ─── 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) {
|
|
const context = resolveWorktreeContext(cwd, {
|
|
execGit,
|
|
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 {string} porcelain - raw output from git worktree list --porcelain
|
|
* @returns {{ path: string, branch: string }[]}
|
|
*/
|
|
function parseWorktreePorcelain(porcelain) {
|
|
return parseWorktreePorcelainPolicy(porcelain);
|
|
}
|
|
|
|
/**
|
|
* Clear stale worktree metadata references via `git worktree prune`.
|
|
*
|
|
* Destructive linked-worktree removal is disabled by default for safety.
|
|
*
|
|
* @param {string} repoRoot - absolute path to the main (or any) worktree of
|
|
* the repository; used as `cwd` for git commands.
|
|
* @returns {string[]} list of worktree paths that were removed (always empty)
|
|
*/
|
|
function pruneOrphanedWorktrees(repoRoot) {
|
|
try {
|
|
const plan = planWorktreePrune(
|
|
repoRoot,
|
|
{ allowDestructive: false },
|
|
{ execGit, parseWorktreePorcelain }
|
|
);
|
|
const pruneResult = executeWorktreePrunePlan(plan, { execGit });
|
|
if (pruneResult && pruneResult.timedOut) {
|
|
// AC2: surface structured warning instead of silently swallowing the timeout.
|
|
// Uses process.stderr.write to match the [gsd-tools] WARNING prefix style.
|
|
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) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function normalizePhaseName(phase) {
|
|
const str = String(phase);
|
|
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
|
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
|
// 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).
|
|
// Uppercasing causes directory/roadmap mismatches on case-sensitive filesystems
|
|
// (e.g., "16c" in ROADMAP.md → directory "16C-name" → progress can't match).
|
|
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 comparePhaseNum(a, b) {
|
|
// Strip optional project_code prefix before comparing (e.g., 'CK-01-name' → '01-name')
|
|
const sa = String(a).replace(/^[A-Z]{1,6}-/, '');
|
|
const sb = String(b).replace(/^[A-Z]{1,6}-/, '');
|
|
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
|
|
// If either is non-numeric (custom ID), fall back to string comparison
|
|
if (!pa || !pb) return String(a).localeCompare(String(b));
|
|
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
|
|
if (intDiff !== 0) return intDiff;
|
|
// No letter sorts before letter: 12 < 12A < 12B
|
|
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;
|
|
}
|
|
// Segment-by-segment decimal comparison: 12A < 12A.1 < 12A.1.2 < 12A.2
|
|
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.
|
|
* Supports: '01-name', '1009A-name', '999.6-name', 'CK-01-name', 'PROJ-42-name'.
|
|
* Returns the token portion (e.g. '01', '1009A', '999.6', 'PROJ-42') or the full name if no separator.
|
|
*/
|
|
function extractPhaseToken(dirName) {
|
|
// Try project-code-prefixed numeric: CK-01-name → CK-01, CK-01A.2-name → CK-01A.2
|
|
const codePrefixed = dirName.match(/^([A-Z]{1,6}-\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
|
if (codePrefixed) return codePrefixed[1];
|
|
// Try plain numeric: 01-name, 1009A-name, 999.6-name
|
|
const numeric = dirName.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
|
if (numeric) return numeric[1];
|
|
// Custom IDs: PROJ-42-name → everything before the last segment that looks like a name
|
|
const custom = dirName.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)(?:-[a-z]|$)/i);
|
|
if (custom) return custom[1];
|
|
return dirName;
|
|
}
|
|
|
|
/**
|
|
* Check if a directory name's phase token matches the normalized phase exactly.
|
|
* Case-insensitive comparison for the token portion.
|
|
*/
|
|
function phaseTokenMatches(dirName, normalized) {
|
|
const token = extractPhaseToken(dirName);
|
|
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
|
// Strip optional project_code prefix from dir and retry
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
function searchPhaseInDir(baseDir, relBase, normalized) {
|
|
try {
|
|
const dirs = readSubdirectories(baseDir, true);
|
|
// Match: exact phase token comparison (not prefix matching)
|
|
const match = dirs.find(d => phaseTokenMatches(d, normalized));
|
|
if (!match) return null;
|
|
|
|
// Extract phase number and name — supports numeric (01-name), project-code-prefixed (CK-01-name), and custom (PROJ-42-name)
|
|
const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
|| match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i)
|
|
|| match.match(/^([A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*)-(.+)/i)
|
|
|| [null, match, null];
|
|
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
|
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : 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, phase) {
|
|
if (!phase) return null;
|
|
|
|
const phasesDir = path.join(planningDir(cwd), 'phases');
|
|
const normalized = normalizePhaseName(phase);
|
|
|
|
// Search current phases first
|
|
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
|
|
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
|
|
if (current) return current;
|
|
|
|
// Search archived milestone phases (newest first)
|
|
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 version = archiveName.match(/^(v[\d.]+)-phases$/)[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;
|
|
}
|
|
|
|
function getArchivedPhaseDirs(cwd) {
|
|
const milestonesDir = path.join(cwd, '.planning', 'milestones');
|
|
const results = [];
|
|
|
|
if (!fs.existsSync(milestonesDir)) return results;
|
|
|
|
try {
|
|
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
|
|
// Find v*-phases directories, sort newest first
|
|
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 version = archiveName.match(/^(v[\d.]+)-phases$/)[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.
|
|
* Used to isolate current milestone phases when searching ROADMAP.md
|
|
* for phase headings or checkboxes — prevents matching archived milestone
|
|
* phases that share the same numbers as current milestone phases.
|
|
*/
|
|
function stripShippedMilestones(content) {
|
|
return content.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
|
}
|
|
|
|
/**
|
|
* Extract the current milestone section from ROADMAP.md by positive lookup.
|
|
*
|
|
* Instead of stripping <details> blocks (negative heuristic that breaks if
|
|
* agents wrap the current milestone in <details>), this finds the section
|
|
* matching the current milestone version and returns only that content.
|
|
*
|
|
* Falls back to stripShippedMilestones() if:
|
|
* - cwd is not provided
|
|
* - STATE.md doesn't exist or has no milestone field
|
|
* - Version can't be found in ROADMAP.md
|
|
*
|
|
* @param {string} content - Full ROADMAP.md content
|
|
* @param {string} [cwd] - Working directory for reading STATE.md
|
|
* @returns {string} Content scoped to current milestone
|
|
*/
|
|
function extractCurrentMilestone(content, cwd) {
|
|
if (!cwd) return stripShippedMilestones(content);
|
|
|
|
// 1. Get current milestone version from STATE.md frontmatter
|
|
let version = null;
|
|
try {
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
if (fs.existsSync(statePath)) {
|
|
const stateRaw = fs.readFileSync(statePath, 'utf-8');
|
|
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (milestoneMatch) {
|
|
version = milestoneMatch[1].trim();
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// 2. Fallback: derive version from getMilestoneInfo pattern in ROADMAP.md itself
|
|
if (!version) {
|
|
// Check for 🚧 in-progress marker
|
|
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+\.\d+)\s/);
|
|
if (inProgressMatch) {
|
|
version = 'v' + inProgressMatch[1];
|
|
}
|
|
}
|
|
|
|
if (!version) return stripShippedMilestones(content);
|
|
|
|
// 3. Find the section matching this version
|
|
// Match headings like: ## Roadmap v3.0: Name, ## v3.0 Name, etc.
|
|
const escapedVersion = escapeRegex(version);
|
|
const sectionPattern = new RegExp(
|
|
`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`,
|
|
'mi'
|
|
);
|
|
const sectionMatch = content.match(sectionPattern);
|
|
|
|
if (!sectionMatch) return stripShippedMilestones(content);
|
|
|
|
const sectionStart = sectionMatch.index;
|
|
|
|
// Find the end: next milestone heading at same or higher level, or EOF.
|
|
// Milestone headings look like: ## v2.0, ## Roadmap v2.0, ## ✅ v1.0, etc.
|
|
// Scan line-by-line so that heading-like lines inside fenced code blocks
|
|
// (``` or ~~~) are not mistaken for milestone boundaries. See #2787.
|
|
const headingLevel = sectionMatch[1].match(/^(#{1,3})\s/)[1].length;
|
|
const restContent = content.slice(sectionStart + sectionMatch[0].length);
|
|
// Exclude phase headings (e.g. "### Phase 12: v1.0 Tech-Debt Closure") from
|
|
// being treated as milestone boundaries just because they mention vX.Y in
|
|
// the title. Phase headings always start with the literal `Phase `. See #2619.
|
|
const nextMilestonePattern = new RegExp(
|
|
`^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`,
|
|
'i'
|
|
);
|
|
|
|
let sectionEnd = content.length;
|
|
let fenceChar = 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;
|
|
}
|
|
|
|
// Return everything before the current milestone section (non-milestone content
|
|
// like title, overview) plus the current milestone section
|
|
const beforeMilestones = content.slice(0, sectionStart);
|
|
const currentSection = content.slice(sectionStart, sectionEnd);
|
|
|
|
// Also include any content before the first milestone heading (title, overview, etc.)
|
|
// but strip any <details> blocks in it (these are definitely shipped)
|
|
const preamble = beforeMilestones.replace(/<details>[\s\S]*?<\/details>/gi, '');
|
|
|
|
return preamble + currentSection;
|
|
}
|
|
|
|
/**
|
|
* Replace a pattern only in the current milestone section of ROADMAP.md
|
|
* (everything after the last </details> close tag). Used for write operations
|
|
* that must not accidentally modify archived milestone checkboxes/tables.
|
|
*/
|
|
function replaceInCurrentMilestone(content, pattern, replacement) {
|
|
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 ────────────────────────────────────────────────
|
|
|
|
function getRoadmapPhaseInternal(cwd, phaseNum) {
|
|
if (!phaseNum) return null;
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
if (!fs.existsSync(roadmapPath)) return null;
|
|
|
|
try {
|
|
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
|
// Strip leading zeros from purely numeric phase numbers so "03" matches "Phase 3:"
|
|
// in canonical ROADMAP headings. Non-numeric IDs (e.g. "PROJ-42") are kept as-is.
|
|
const normalized = /^\d+$/.test(String(phaseNum))
|
|
? String(phaseNum).replace(/^0+(?=\d)/, '')
|
|
: String(phaseNum);
|
|
const escapedPhase = escapeRegex(normalized);
|
|
// Match both numeric and custom (Phase PROJ-42:) headers.
|
|
// For purely numeric phases allow optional leading zeros so both "Phase 1:" and
|
|
// "Phase 01:" are matched regardless of whether the ROADMAP uses padded numbers.
|
|
const isNumeric = /^\d+$/.test(String(phaseNum));
|
|
const phasePattern = isNumeric
|
|
? new RegExp(`#{2,4}\\s*Phase\\s+0*${escapedPhase}:\\s*([^\\n]+)`, 'i')
|
|
: new RegExp(`#{2,4}\\s*Phase\\s+${escapedPhase}:\\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+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,
|
|
phase_number: phaseNum.toString(),
|
|
phase_name: phaseName,
|
|
goal,
|
|
section,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Agent installation validation (#1371) ───────────────────────────────────
|
|
|
|
/**
|
|
* Resolve the agents directory from the GSD install location.
|
|
* gsd-tools.cjs lives at <configDir>/get-shit-done/bin/gsd-tools.cjs,
|
|
* so agents/ is at <configDir>/agents/.
|
|
*
|
|
* GSD_AGENTS_DIR env var overrides the default path. Used in tests and for
|
|
* installs where the agents directory is not co-located with gsd-tools.cjs.
|
|
*
|
|
* @returns {string} Absolute path to the agents directory
|
|
*/
|
|
function getAgentsDir() {
|
|
if (process.env.GSD_AGENTS_DIR) {
|
|
return process.env.GSD_AGENTS_DIR;
|
|
}
|
|
// __dirname is get-shit-done/bin/lib/ → go up 3 levels to configDir
|
|
return path.join(__dirname, '..', '..', '..', 'agents');
|
|
}
|
|
|
|
/**
|
|
* Check which GSD agents are installed on disk.
|
|
* Returns an object with installation status and details.
|
|
*
|
|
* Recognises both standard format (gsd-planner.md) and Copilot format
|
|
* (gsd-planner.agent.md). Copilot renames agent files during install (#1512).
|
|
*
|
|
* @returns {{ agents_installed: boolean, missing_agents: string[], installed_agents: string[], agents_dir: string }}
|
|
*/
|
|
function checkAgentsInstalled() {
|
|
const agentsDir = getAgentsDir();
|
|
const expectedAgents = Object.keys(MODEL_PROFILES);
|
|
const installed = [];
|
|
const missing = [];
|
|
|
|
if (!fs.existsSync(agentsDir)) {
|
|
return {
|
|
agents_installed: false,
|
|
missing_agents: expectedAgents,
|
|
installed_agents: [],
|
|
agents_dir: agentsDir,
|
|
};
|
|
}
|
|
|
|
for (const agent of expectedAgents) {
|
|
// Check both .md (standard) and .agent.md (Copilot) file formats.
|
|
const agentFile = path.join(agentsDir, `${agent}.md`);
|
|
const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`);
|
|
if (fs.existsSync(agentFile) || fs.existsSync(agentFileCopilot)) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ─── Model alias resolution ───────────────────────────────────────────────────
|
|
|
|
const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
|
const _warnedConfigKeys = new Set();
|
|
|
|
function _warnUnknownProfileOverrides(parsed, configLabel) {
|
|
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') return;
|
|
for (const [overrideRuntime, tierMap] of Object.entries(overrides)) {
|
|
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 */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Internal helper exposed for tests so per-process warning state can be reset
|
|
// between cases that intentionally exercise the warning path repeatedly.
|
|
function _resetRuntimeWarningCacheForTests() {
|
|
_warnedConfigKeys.clear();
|
|
}
|
|
|
|
/**
|
|
* #2517 — Resolve the runtime-aware tier entry for (runtime, tier).
|
|
*
|
|
* Single source of truth shared by core.cjs (resolveModelInternal /
|
|
* resolveReasoningEffortInternal) and bin/install.js (Codex/OpenCode TOML emit
|
|
* paths). Always merges built-in defaults with user overrides at the field
|
|
* level so partial overrides keep the unspecified fields:
|
|
*
|
|
* `{ codex: { opus: "gpt-5-pro" } }` keeps reasoning_effort: 'xhigh'
|
|
* `{ codex: { opus: { reasoning_effort: 'low' } } }` keeps model: 'gpt-5.4'
|
|
*
|
|
* Without this field-merge, the documented string-shorthand example silently
|
|
* dropped reasoning_effort and a partial-object override silently dropped the
|
|
* model — both reported as critical findings in the #2609 review.
|
|
*
|
|
* Inputs:
|
|
* - runtime: string (e.g. 'codex', 'claude', 'opencode')
|
|
* - tier: 'opus' | 'sonnet' | 'haiku'
|
|
* - overrides: optional `model_profile_overrides` blob (may be null/undefined)
|
|
*
|
|
* Returns `{ model: string, reasoning_effort?: string } | null`.
|
|
*/
|
|
function resolveTierEntry({ runtime, tier, overrides }) {
|
|
if (!runtime || !tier) return null;
|
|
|
|
const builtin = RUNTIME_PROFILE_MAP[runtime]?.[tier] || null;
|
|
const userRaw = overrides?.[runtime]?.[tier];
|
|
|
|
// String shorthand from CONFIGURATION.md examples — `{ codex: { opus: "gpt-5-pro" } }`.
|
|
// Treat as `{ model: "gpt-5-pro" }` so the field-merge below still preserves
|
|
// reasoning_effort from the built-in defaults.
|
|
let userEntry = null;
|
|
if (userRaw) {
|
|
userEntry = typeof userRaw === 'string' ? { model: userRaw } : userRaw;
|
|
}
|
|
|
|
if (!builtin && !userEntry) return null;
|
|
// Field-merge: user fields win, built-in fills the gaps.
|
|
return { ...(builtin || {}), ...(userEntry || {}) };
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper used by resolveModelInternal / resolveReasoningEffortInternal.
|
|
* Pulls runtime + overrides out of a loaded config and delegates to resolveTierEntry.
|
|
*/
|
|
function _resolveRuntimeTier(config, tier) {
|
|
return resolveTierEntry({
|
|
runtime: config.runtime,
|
|
tier,
|
|
overrides: config.model_profile_overrides,
|
|
});
|
|
}
|
|
|
|
function resolveModelInternal(cwd, agentType) {
|
|
const config = loadConfig(cwd);
|
|
|
|
// 1. Per-agent override — always respected; highest precedence.
|
|
// Users who set fully-qualified model IDs (e.g., "openai/gpt-5.4") get exactly that.
|
|
const override = config.model_overrides?.[agentType];
|
|
if (override) {
|
|
return override;
|
|
}
|
|
|
|
// 2. Compute the tier (opus/sonnet/haiku/inherit) for this agent.
|
|
//
|
|
// #3023: phase-type slot can override the profile-derived tier.
|
|
// Precedence: per-agent override (above) > phase-type slot > profile.
|
|
// Phase-type values are tier aliases (opus/sonnet/haiku/inherit) — same
|
|
// shape as model_profile output — so the runtime-resolution chain
|
|
// (step 3), resolve_model_ids handling (step 4), and profile lookup
|
|
// (step 5) all stay correct without further branching.
|
|
const profile = String(config.model_profile || 'balanced').toLowerCase();
|
|
const agentModels = MODEL_PROFILES[agentType];
|
|
const phaseType = AGENT_TO_PHASE_TYPE[agentType];
|
|
const phaseTypeTier = (phaseType && config.models && typeof config.models === 'object')
|
|
? config.models[phaseType]
|
|
: undefined;
|
|
// Only honor phase-type tier if it's one of the recognized aliases.
|
|
// Anything else falls through to profile lookup so a typo doesn't
|
|
// silently break tier resolution.
|
|
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku', 'inherit']);
|
|
// Resolve tier: phase-type wins when valid; else profile-derived; else
|
|
// (when profile === 'inherit') propagate inherit so the later short-
|
|
// circuit fires. CR Major (#3030): a config like
|
|
// { model_profile: 'inherit', models: { execution: 'opus' } }
|
|
// must honor the phase-type opus, not return 'inherit'. Synthesizing
|
|
// tier='inherit' only when there's no phase-type override keeps the
|
|
// original inherit semantics intact while letting a valid phase-type
|
|
// tier win.
|
|
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
|
? phaseTypeTier
|
|
: (profile === 'inherit'
|
|
? 'inherit'
|
|
: (agentModels ? (agentModels[profile] || agentModels['balanced']) : null));
|
|
|
|
// 3. Runtime-aware resolution (#2517) — only when `runtime` is explicitly set
|
|
// to a non-Claude runtime. `runtime: "claude"` is the implicit default and is
|
|
// treated as a no-op here so it does not silently override `resolve_model_ids:
|
|
// "omit"` (review finding #4). Deliberate ordering for non-Claude runtimes:
|
|
// explicit opt-in beats `resolve_model_ids: "omit"` so users on Codex installs
|
|
// that auto-set "omit" can still flip on tiered behavior by setting runtime
|
|
// alone. Gate on tier !== 'inherit' (not profile !== 'inherit') so a
|
|
// valid phase-type tier flips runtime resolution on even when the
|
|
// profile is inherit.
|
|
if (config.runtime && config.runtime !== 'claude' && tier && tier !== 'inherit') {
|
|
const entry = _resolveRuntimeTier(config, tier);
|
|
if (entry?.model) return entry.model;
|
|
// Unknown runtime with no user-supplied overrides — fall through to Claude-safe
|
|
// default rather than emit an ID the runtime can't accept.
|
|
}
|
|
|
|
// 4. resolve_model_ids: "omit" — return empty string so the runtime uses its
|
|
// configured default model. For non-Claude runtimes (OpenCode, Codex, etc.) that
|
|
// don't recognize Claude aliases. Set automatically during install. See #1156.
|
|
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';
|
|
}
|
|
// Gate on tier (not profile) so a valid phase-type override beats
|
|
// profile=inherit (#3030 CR Major).
|
|
if (tier === 'inherit') return 'inherit';
|
|
// `tier` is guaranteed truthy here: agentModels exists, and MODEL_PROFILES
|
|
// entries always define `balanced`, so `agentModels[profile] || agentModels.balanced`
|
|
// resolves to a string. Keep the local for readability — no defensive fallback.
|
|
const alias = tier;
|
|
|
|
// resolve_model_ids: true — map alias to full Claude model ID.
|
|
// Prevents 404s when the Task tool passes aliases directly to the API.
|
|
if (config.resolve_model_ids) {
|
|
return MODEL_ALIAS_MAP[alias] || alias;
|
|
}
|
|
|
|
return alias;
|
|
}
|
|
|
|
/**
|
|
* #3024 — Resolve a model for a specific dynamic-routing attempt.
|
|
*
|
|
* The orchestrator (workflow agent) tracks the attempt counter. On
|
|
* the first spawn, it calls with attempt=0. If the orchestrator detects
|
|
* a soft failure (verification inconclusive, plan-check FLAG, etc.),
|
|
* it re-spawns with attempt=1, which escalates the agent's tier one
|
|
* step up. `max_escalations` caps how many escalations are allowed.
|
|
*
|
|
* Resolution precedence (highest → lowest):
|
|
* 1. config.model_overrides[agent] (full IDs accepted)
|
|
* 2. dynamic_routing.tier_models[escalated_tier] (when enabled)
|
|
* 3. models[phase_type] / model_profile (existing chain via
|
|
* resolveModelInternal)
|
|
*
|
|
* When dynamic_routing is null/disabled, this function is identical
|
|
* to resolveModelInternal — orchestrators can call it unconditionally
|
|
* without breaking back-compat.
|
|
*
|
|
* @param {string} cwd - Project directory.
|
|
* @param {string} agentType - Agent name (e.g. 'gsd-verifier').
|
|
* @param {number} [attempt=0] - 0 for first spawn; 1+ for escalation.
|
|
* Capped internally at max_escalations.
|
|
* @returns {string} Model alias (opus/sonnet/haiku) or full ID.
|
|
*/
|
|
function resolveModelForTier(cwd, agentType, attempt) {
|
|
const config = loadConfig(cwd);
|
|
const attemptN = Number.isInteger(attempt) && attempt > 0 ? attempt : 0;
|
|
|
|
// Per-agent override always wins — same as resolveModelInternal step 1.
|
|
// User-supplied full IDs bypass the entire tier mechanism.
|
|
const override = config.model_overrides?.[agentType];
|
|
if (override) return override;
|
|
|
|
const dr = config.dynamic_routing;
|
|
// Disabled / missing / non-object → fall back to the existing resolver.
|
|
if (!dr || typeof dr !== 'object' || dr.enabled !== true) {
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const tierModels = dr.tier_models;
|
|
if (!tierModels || typeof tierModels !== 'object') {
|
|
// tier_models missing — can't dynamic-route; fall back.
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
const defaultTier = AGENT_DEFAULT_TIERS[agentType];
|
|
if (!defaultTier || !VALID_AGENT_TIERS.has(defaultTier)) {
|
|
// Unmapped agent — no default tier; fall back so we don't silently
|
|
// pick the wrong model.
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
|
|
// Cap effective escalation at max_escalations (default 1). Beyond
|
|
// the cap, the resolver returns the model for the cap level so the
|
|
// orchestrator can log "max escalations reached" without burning
|
|
// further budget.
|
|
//
|
|
// CR Major (#3031): `escalate_on_failure: false` is the kill-switch
|
|
// for escalation — when false, every attempt resolves to the default
|
|
// tier regardless of the attempt counter. Without this guard, an
|
|
// orchestrator that blindly bumps the counter on retry would silently
|
|
// escalate even though the user opted out.
|
|
const maxEscalations = Number.isInteger(dr.max_escalations) && dr.max_escalations >= 0
|
|
? dr.max_escalations
|
|
: 1;
|
|
const escalationEnabled = dr.escalate_on_failure !== false;
|
|
const effectiveAttempt = escalationEnabled
|
|
? Math.min(attemptN, maxEscalations)
|
|
: 0;
|
|
|
|
// Walk the escalation chain N times from the default tier.
|
|
let tier = defaultTier;
|
|
for (let i = 0; i < effectiveAttempt; i += 1) {
|
|
const next = nextTier(tier);
|
|
if (!next || next === tier) break; // already at top
|
|
tier = next;
|
|
}
|
|
|
|
const alias = tierModels[tier];
|
|
if (typeof alias !== 'string' || alias.length === 0) {
|
|
// Misconfigured tier_models — missing slot. Fall back rather
|
|
// than emit an empty model id.
|
|
return resolveModelInternal(cwd, agentType);
|
|
}
|
|
return alias;
|
|
}
|
|
|
|
/**
|
|
* #2517 — Resolve runtime-specific reasoning_effort for an agent.
|
|
* Returns null unless:
|
|
* - `runtime` is explicitly set in config,
|
|
* - the runtime supports reasoning_effort (currently: codex),
|
|
* - profile is not 'inherit',
|
|
* - the resolved tier entry has a `reasoning_effort` value.
|
|
*
|
|
* Never returns a value for Claude — keeps reasoning_effort out of Claude spawn paths.
|
|
*/
|
|
function resolveReasoningEffortInternal(cwd, agentType) {
|
|
const config = loadConfig(cwd);
|
|
if (!config.runtime) return null;
|
|
// Strict allowlist: reasoning_effort only propagates for runtimes whose
|
|
// install path actually accepts it. Adding a new runtime here is the only
|
|
// way to enable effort propagation — overrides cannot bypass the gate.
|
|
// Without this, a typo in `runtime` (e.g. `"codx"`) plus a user override
|
|
// for that typo would leak `xhigh` into a Claude or unknown install
|
|
// (review finding #3).
|
|
if (!RUNTIMES_WITH_REASONING_EFFORT.has(config.runtime)) return null;
|
|
// Per-agent override means user supplied a fully-qualified ID; reasoning_effort
|
|
// for that case must be set via per-agent mechanism, not tier inference.
|
|
if (config.model_overrides?.[agentType]) return null;
|
|
|
|
const profile = String(config.model_profile || 'balanced').toLowerCase();
|
|
const agentModels = MODEL_PROFILES[agentType];
|
|
if (!agentModels) return null;
|
|
|
|
// #3023 (CR Major): mirror the phase-type tier lookup from
|
|
// resolveModelInternal. Without this, `model` and `reasoning_effort`
|
|
// derive from different tier sources on Codex when models.<phase_type>
|
|
// overrides the profile.
|
|
//
|
|
// #3030 CR follow-up: do NOT short-circuit on profile === 'inherit'
|
|
// before reading the phase-type tier. A config like
|
|
// { model_profile: 'inherit', models: { execution: 'opus' } }
|
|
// must produce the opus runtime effort, not null. Compute tier from
|
|
// phase-type first; only fall back to profile when there's no valid
|
|
// phase-type override; only return null when the resolved tier is
|
|
// 'inherit' or unknown.
|
|
const phaseType = AGENT_TO_PHASE_TYPE[agentType];
|
|
const phaseTypeTier = (phaseType && config.models && typeof config.models === 'object')
|
|
? config.models[phaseType]
|
|
: undefined;
|
|
// Explicit phase-type 'inherit' is the user opting out of tier-based
|
|
// effort for this phase — return null instead of falling through to
|
|
// profile (which would silently emit the profile's effort and
|
|
// contradict the user's choice).
|
|
if (phaseTypeTier === 'inherit') return null;
|
|
const VALID_TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
|
const tier = (phaseTypeTier && VALID_TIERS.has(phaseTypeTier))
|
|
? phaseTypeTier
|
|
: (profile === 'inherit'
|
|
? 'inherit'
|
|
: (agentModels[profile] || agentModels['balanced']));
|
|
// 'inherit' (from profile fallback) yields no runtime effort.
|
|
if (!tier || tier === 'inherit') return null;
|
|
|
|
const entry = _resolveRuntimeTier(config, tier);
|
|
return entry?.reasoning_effort || null;
|
|
}
|
|
|
|
// ─── Summary body helpers ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Extract a one-liner from the summary body when it's not in frontmatter.
|
|
* The summary template defines one-liner as a bold markdown line after the heading:
|
|
* # Phase X: Name Summary
|
|
* **[substantive one-liner text]**
|
|
*/
|
|
function extractOneLinerFromBody(content) {
|
|
if (!content) return null;
|
|
// Normalize EOLs so matching works for LF and CRLF files.
|
|
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
// Strip frontmatter first
|
|
const body = normalized.replace(/^---\n[\s\S]*?\n---\n*/, '');
|
|
// Find the first **...** span on a line after a # heading.
|
|
// Two supported template forms:
|
|
// 1) Labeled: **One-liner:** Real prose here. (bug #2660 — new template)
|
|
// 2) Bare: **Real prose here.** (legacy template)
|
|
// For (1), the first bold span ends in a colon and the prose that follows
|
|
// on the same line is the one-liner. For (2), the bold span itself is the
|
|
// one-liner.
|
|
const match = body.match(/^#[^\n]*\n+\*\*([^*\n]+)\*\*([^\n]*)/m);
|
|
if (!match) return null;
|
|
const boldInner = match[1].trim();
|
|
const afterBold = match[2];
|
|
// Labeled form: bold span is a "Label:" prefix — capture prose after it.
|
|
if (/:\s*$/.test(boldInner)) {
|
|
const prose = afterBold.trim();
|
|
return prose.length > 0 ? prose : null;
|
|
}
|
|
// Bare form: the bold content itself is the one-liner.
|
|
return boldInner.length > 0 ? boldInner : null;
|
|
}
|
|
|
|
// ─── Misc utilities ───────────────────────────────────────────────────────────
|
|
|
|
function pathExistsInternal(cwd, targetPath) {
|
|
const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
|
|
try {
|
|
fs.statSync(fullPath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function generateSlugInternal(text) {
|
|
if (!text) return null;
|
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
|
|
}
|
|
|
|
function getMilestoneInfo(cwd) {
|
|
try {
|
|
const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
|
|
|
|
// 0. Prefer STATE.md milestone: frontmatter as the authoritative source.
|
|
// This prevents falling through to a regex that may match an old heading
|
|
// when the active milestone's 🚧 marker is inside a <summary> tag without
|
|
// **bold** formatting (bug #2409).
|
|
let stateVersion = null;
|
|
if (cwd) {
|
|
try {
|
|
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
|
if (fs.existsSync(statePath)) {
|
|
const stateRaw = fs.readFileSync(statePath, 'utf-8');
|
|
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
|
if (m) stateVersion = m[1].trim();
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
}
|
|
|
|
if (stateVersion) {
|
|
// Look up the name for this version in ROADMAP.md
|
|
const escapedVer = escapeRegex(stateVersion);
|
|
// Match heading-format: ## Roadmap v2.9: Name or ## v2.9 Name
|
|
const headingMatch = roadmap.match(
|
|
new RegExp(`##[^\\n]*${escapedVer}[:\\s]+([^\\n(]+)`, 'i')
|
|
);
|
|
if (headingMatch) {
|
|
// If the heading line contains ✅ the milestone is already shipped.
|
|
// Fall through to normal detection so the NEW active milestone is returned
|
|
// instead of the stale shipped one still recorded in STATE.md.
|
|
if (!headingMatch[0].includes('✅')) {
|
|
return { version: stateVersion, name: headingMatch[1].trim() };
|
|
}
|
|
// Shipped milestone — do not early-return; fall through to normal detection below.
|
|
} else {
|
|
// Match list-format: 🚧 **v2.9 Name** or 🚧 v2.9 Name
|
|
const listMatch = roadmap.match(
|
|
new RegExp(`🚧\\s*\\*?\\*?${escapedVer}\\s+([^*\\n]+)`, 'i')
|
|
);
|
|
if (listMatch) {
|
|
return { version: stateVersion, name: listMatch[1].trim() };
|
|
}
|
|
// Version found in STATE.md but no name match in ROADMAP — return bare version
|
|
return { version: stateVersion, name: 'milestone' };
|
|
}
|
|
}
|
|
|
|
// First: check for list-format roadmaps using 🚧 (in-progress) marker
|
|
// e.g. "- 🚧 **v2.1 Belgium** — Phases 24-28 (in progress)"
|
|
// e.g. "- 🚧 **v1.2.1 Tech Debt** — Phases 1-8 (in progress)"
|
|
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
|
|
if (inProgressMatch) {
|
|
return {
|
|
version: 'v' + inProgressMatch[1],
|
|
name: inProgressMatch[2].trim(),
|
|
};
|
|
}
|
|
|
|
// Second: heading-format roadmaps — strip shipped milestones.
|
|
// <details> blocks are stripped by stripShippedMilestones; heading-format ✅ markers
|
|
// are excluded by the negative lookahead below so a stale STATE.md version (or any
|
|
// shipped ✅ heading) never wins over the first non-shipped milestone heading.
|
|
const cleaned = stripShippedMilestones(roadmap);
|
|
// Negative lookahead skips headings that contain ✅ (shipped milestone marker).
|
|
// Supports 2+ segment versions: v1.2, v1.2.1, v2.0.1, etc.
|
|
const headingMatch = cleaned.match(/## (?!.*✅).*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
|
|
if (headingMatch) {
|
|
return {
|
|
version: 'v' + headingMatch[1],
|
|
name: headingMatch[2].trim(),
|
|
};
|
|
}
|
|
// Fallback: try bare version match (greedy — capture longest version string)
|
|
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
|
|
return {
|
|
version: versionMatch ? versionMatch[0] : 'v1.0',
|
|
name: 'milestone',
|
|
};
|
|
} catch {
|
|
return { version: 'v1.0', name: 'milestone' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a filter function that checks whether a phase directory belongs
|
|
* to the current milestone based on ROADMAP.md phase headings.
|
|
* If no ROADMAP exists or no phases are listed, returns a pass-all filter.
|
|
*/
|
|
function getMilestonePhaseFilter(cwd, versionOverride) {
|
|
const milestonePhaseNums = new Set();
|
|
let missingExplicitVersion = false;
|
|
try {
|
|
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
|
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
|
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
|
|
|
if (versionOverride) {
|
|
const escapedVersion = escapeRegex(versionOverride);
|
|
const sectionPattern = new RegExp(`(^#{1,3}\\s+.*${escapedVersion}[^\\n]*)`, 'mi');
|
|
const sectionMatch = roadmapContent.match(sectionPattern);
|
|
if (!sectionMatch) {
|
|
// Only treat this as an error case when the roadmap is milestone-versioned.
|
|
// Older/flat roadmap formats without vX.Y milestone headings should keep
|
|
// legacy pass-through behavior for milestone.complete.
|
|
const hasVersionedMilestones = /^#{1,3}\s+.*v\d+\.\d+/mi.test(roadmapContent);
|
|
if (hasVersionedMilestones) {
|
|
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 = 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;
|
|
}
|
|
}
|
|
|
|
// Match both numeric phases (Phase 1:) and custom IDs (Phase PROJ-42:)
|
|
const phasePattern = /#{2,4}\s*Phase\s+([\w][\w.-]*)\s*:/gi;
|
|
let m;
|
|
while ((m = phasePattern.exec(roadmap)) !== null) {
|
|
milestonePhaseNums.add(m[1]);
|
|
}
|
|
} catch { /* intentionally empty */ }
|
|
|
|
if (milestonePhaseNums.size === 0) {
|
|
const passAll = () => true;
|
|
passAll.phaseCount = 0;
|
|
passAll.missingExplicitVersion = missingExplicitVersion;
|
|
return passAll;
|
|
}
|
|
|
|
const normalized = new Set(
|
|
[...milestonePhaseNums].map(n => (n.replace(/^0+(?=\d)/, '') || '0').toLowerCase())
|
|
);
|
|
|
|
function isDirInMilestone(dirName) {
|
|
// Try numeric match first
|
|
const m = dirName.match(/^0*(\d+[A-Za-z]?(?:\.\d+)*)/);
|
|
if (m && normalized.has(m[1].toLowerCase())) return true;
|
|
// Try custom ID match (e.g. PROJ-42-description → PROJ-42)
|
|
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
|
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
|
return false;
|
|
}
|
|
isDirInMilestone.phaseCount = milestonePhaseNums.size;
|
|
isDirInMilestone.missingExplicitVersion = missingExplicitVersion;
|
|
return isDirInMilestone;
|
|
}
|
|
|
|
// ─── Phase file helpers ──────────────────────────────────────────────────────
|
|
|
|
/** Filter a file list to just PLAN.md / *-PLAN.md entries. */
|
|
function filterPlanFiles(files) {
|
|
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) {
|
|
return files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
|
}
|
|
|
|
/**
|
|
* Read a phase directory and return counts/flags for common file types.
|
|
* Returns an object with plans[], summaries[], and boolean flags for
|
|
* research/context/verification files.
|
|
*/
|
|
function getPhaseFileStats(phaseDir) {
|
|
const files = fs.readdirSync(phaseDir);
|
|
return {
|
|
plans: filterPlanFiles(files),
|
|
summaries: filterSummaryFiles(files),
|
|
hasResearch: files.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md'),
|
|
hasContext: files.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md'),
|
|
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, sort = false) {
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
// ─── Atomic file writes ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Write a file atomically using write-to-temp-then-rename.
|
|
*
|
|
* On POSIX systems, `fs.renameSync` is atomic when the source and destination
|
|
* are on the same filesystem. This prevents a process killed mid-write from
|
|
* leaving a truncated file that is unparseable on next read.
|
|
*
|
|
* The temp file is placed alongside the target so it is guaranteed to be on
|
|
* the same filesystem (required for rename atomicity). The PID is embedded in
|
|
* the temp file name so concurrent writers use distinct paths.
|
|
*
|
|
* If `renameSync` fails (e.g. cross-device move), the function falls back to a
|
|
* direct `writeFileSync` so callers always get a best-effort write.
|
|
*
|
|
* @param {string} filePath Absolute path to write.
|
|
* @param {string|Buffer} content File content.
|
|
* @param {string} [encoding='utf-8'] Encoding passed to writeFileSync.
|
|
*/
|
|
function atomicWriteFileSync(filePath, content, encoding = 'utf-8') {
|
|
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
try {
|
|
fs.writeFileSync(tmpPath, content, encoding);
|
|
fs.renameSync(tmpPath, filePath);
|
|
} catch (renameErr) {
|
|
// Clean up the temp file if rename failed, then fall back to direct write.
|
|
try { fs.unlinkSync(tmpPath); } catch { /* already gone or never created */ }
|
|
fs.writeFileSync(filePath, content, encoding);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a Date as a fuzzy relative time string (e.g. "5 minutes ago").
|
|
* @param {Date} date
|
|
* @returns {string}
|
|
*/
|
|
function timeAgo(date) {
|
|
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`;
|
|
}
|
|
|
|
module.exports = {
|
|
output,
|
|
error,
|
|
ERROR_REASON,
|
|
setJsonErrorMode,
|
|
getJsonErrorMode,
|
|
safeReadFile,
|
|
loadConfig,
|
|
isGitIgnored,
|
|
execGit,
|
|
normalizeMd,
|
|
escapeRegex,
|
|
normalizePhaseName,
|
|
comparePhaseNum,
|
|
searchPhaseInDir,
|
|
extractPhaseToken,
|
|
phaseTokenMatches,
|
|
findPhaseInternal,
|
|
getArchivedPhaseDirs,
|
|
getRoadmapPhaseInternal,
|
|
resolveModelInternal,
|
|
resolveModelForTier,
|
|
resolveReasoningEffortInternal,
|
|
RUNTIME_PROFILE_MAP,
|
|
RUNTIMES_WITH_REASONING_EFFORT,
|
|
KNOWN_RUNTIMES,
|
|
RUNTIME_OVERRIDE_TIERS,
|
|
resolveTierEntry,
|
|
_resetRuntimeWarningCacheForTests,
|
|
pathExistsInternal,
|
|
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,
|
|
atomicWriteFileSync,
|
|
timeAgo,
|
|
pruneOrphanedWorktrees,
|
|
inspectWorktreeHealth,
|
|
};
|