* refactor(shell-projection): migrate roadmap.cjs writes to platformWriteSync (#3467) 2 atomicWriteFileSync calls → platformWriteSync. The seam owns markdown normalization, so the explicit utf-8 encoding arg is no longer needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate config.cjs writes to platformWriteSync (#3467) - 3 atomicWriteFileSync calls → platformWriteSync - 1 raw fs.writeFileSync (depth→granularity migration) → platformWriteSync - 2 fs.mkdirSync(planningBase, { recursive: true }) → platformEnsureDir Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate docs.cjs reads to platformReadSync (#3467) 6 try { fs.readFileSync } catch {} patterns → platformReadSync(path) with explicit null guards. detectProjectType now reads package.json once and shares it across has_cli_bin/is_monorepo/has_tests checks. JSON.parse is still wrapped in a try (parsing is a separate failure mode from missing file). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate audit.cjs reads to platformReadSync (#3467) 8 try { fs.readFileSync(safeFilePath, 'utf-8') } catch { continue } patterns → const content = platformReadSync(safeFilePath); if (content === null) continue; The single safeSum case (where catch set status='unreadable' rather than continue) maps to an if/else that preserves the same semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate planning-workspace.cjs to platform* seam (#3467) - 2 try { fs.readFileSync } catch {} → platformReadSync (null on missing) - 2 fs.writeFileSync (workstream pointer writes) → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir The .lock file write at withPlanningLock is intentionally NOT migrated. That call uses { flag: 'wx' } for atomic exclusive-create, which is the correct lock-acquisition primitive. platformWriteSync's atomic-rename pattern would silently overwrite an existing lock file and break the locking guarantee. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate milestone.cjs writes to platform* seam (#3467) - 5 atomicWriteFileSync calls → platformWriteSync (4 dropped normalizeMd wrapper; seam handles .md normalization automatically) - 2 raw fs.writeFileSync (archive ROADMAP.md / REQUIREMENTS.md) → platformWriteSync - 2 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd import (only used as write pre-call here) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate intel.cjs to platform* seam (#3467) - 7 fs.readFileSync (existsSync+readFileSync patterns and try/catch) → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 1 fs.mkdirSync(intelPath, { recursive: true }) → platformEnsureDir - Consolidated dual-check (existsSync + readFileSync) into single platformReadSync call returning null on missing file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate workstream.cjs to platform* seam (#3467) - 5 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - 1 fs.writeFileSync (STATE.md initial scaffold) → platformWriteSync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate init.cjs reads/writes to platform* seam (#3467) - 11 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (skill-manifest.json) → platformWriteSync Three bare fs.readFileSync calls remain (ROADMAP/STATE reads in code paths where the file is required to exist) — these are not "Done when" violations (no try/catch wrapping, no inline existsSync guard). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate commands.cjs reads/writes to platform* seam (#3467) - 6 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 2 fs.writeFileSync → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Removed unused safeReadFile import (zero call sites in this file) Three bare fs.readFileSync calls remain (sourcePath at line 752, fullPath at 443, roadmapPath in cmdAuditOpen) — preceded by existsSync guards or in code paths where file presence is required; not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate profile-output.cjs to platform* seam (#3467) - 6 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 3 try/JSON.parse(readFileSync) patterns → platformReadSync + try/JSON.parse - 1 existsSync+readFileSync pattern (claude.md update) → platformReadSync - 5 fs.writeFileSync → platformWriteSync - 4 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Two bare fs.readFileSync calls remain (template reads where file must exist or fail loudly) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate state.cjs to platform* seam (#3467) - 4 atomicWriteFileSync calls → platformWriteSync (3 dropped normalizeMd wrapper; seam handles .md normalization) - 4 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 1 fs.writeFileSync (WAITING.json) → platformWriteSync - 1 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir - Dropped normalizeMd and atomicWriteFileSync imports (only used as write pre-calls here) Bare fs.readFileSync calls remain in code paths where STATE.md is required to exist (statePath reads in cmd handlers, dry-run prune) — not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate core.cjs to platform* seam (#3467) - 7 try/readFileSync and existsSync+readFileSync patterns → platformReadSync - 3 fs.writeFileSync (config writes + large-payload temp file) → platformWriteSync - 1 fs.mkdirSync (GSD_TEMP_DIR) → platformEnsureDir Three fs calls remain — they are the internal implementations of the safeReadFile and atomicWriteFileSync wrappers that core.cjs exports for backward compatibility. The wrappers are scheduled for removal in Phase 4 (#3468) and will not be migrated here. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate phase.cjs writes to platform* seam (#3467) - 6 atomicWriteFileSync calls → platformWriteSync - 3 fs.writeFileSync(path.join(dirPath, '.gitkeep'), '') → platformWriteSync - 3 fs.mkdirSync(..., { recursive: true }) → platformEnsureDir Bare fs.readFileSync calls remain for roadmapPath/planPath reads where the file is required to exist; these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate verify.cjs to platform* seam (#3467) - 8 safeReadFile (from core.cjs) calls preserved by aliasing platformReadSync as safeReadFile in the import — same semantics, zero call-site changes - 1 existsSync+readFileSync inline ternary → safeReadFile (returns null) - 5 fs.writeFileSync (config writes + milestones writes) → platformWriteSync Bare fs.readFileSync calls remain for code paths where the file is required to exist (roadmap/state/config full reads); these are not "Done when" violations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate frontmatter.cjs + update atomic-write test (#3467) - frontmatter.cjs: 2 atomicWriteFileSync calls → platformWriteSync. The legacy normalizeMd wrapper is dropped because the seam handles markdown normalization. safeReadFile preserved by aliasing platformReadSync. - atomic-write-coverage.test.cjs: update the #1972 structural invariant to assert on platformWriteSync. platformWriteSync uses the same tmp-file + atomic-rename primitive that atomicWriteFileSync did — the no-partial-write guarantee is preserved across the migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add entry for shell-projection Phase 3 migration (#3467) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(coderabbit): disable ESLint tool (repo uses custom lint scripts) CodeRabbit's review surface emits a "skipped: no ESLint configuration" warning because the repo doesn't ship ESLint config. The repo intentionally does not use ESLint — it ships its own targeted lint scripts (scripts/lint-no-source-grep.cjs, npm run lint:tests) that enforce repo-specific test-quality invariants. Adding ESLint config purely to satisfy CR would add an external dependency (CONTRIBUTING.md: "No external dependencies in core") and overlap with the existing custom lint surface. Disable the ESLint tool in CR's tools config so the skip warning stops appearing on every PR. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/shell-projection-fs-migration.md
Normal file
5
.changeset/shell-projection-fs-migration.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3467
|
||||
---
|
||||
Migrate all file I/O call sites to the shell-command-projection seam (`platformWriteSync`, `platformReadSync`, `platformEnsureDir`). Consolidates write atomicity, line-ending normalization, directory creation, and read null-safety. The seam owns `.md` normalization automatically — `normalizeMd` is dropped at every write site that used it as a pre-call. See #3467.
|
||||
@@ -24,3 +24,13 @@ reviews:
|
||||
# at their defaults.
|
||||
docstrings:
|
||||
mode: off
|
||||
|
||||
tools:
|
||||
# Disable ESLint. The repo intentionally does not use ESLint — it ships
|
||||
# its own targeted lint scripts (scripts/lint-no-source-grep.cjs, plus
|
||||
# the `lint:tests` npm script) that enforce repo-specific test-quality
|
||||
# invariants. Adding ESLint config purely to satisfy CR would add an
|
||||
# external dependency (CONTRIBUTING.md: "No external dependencies in
|
||||
# core") and overlap with the existing custom lint surface.
|
||||
eslint:
|
||||
enabled: false
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { toPosixPath } = require('./core.cjs');
|
||||
const { platformReadSync } = require('./shell-command-projection.cjs');
|
||||
const { planningDir } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { requireSafePath, sanitizeForDisplay } = require('./security.cjs');
|
||||
@@ -46,12 +47,8 @@ function scanDebugSessions(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
@@ -133,12 +130,12 @@ function scanQuickTasks(planDir) {
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(safeSum, 'utf-8');
|
||||
const content = platformReadSync(safeSum);
|
||||
if (content === null) {
|
||||
status = 'unreadable';
|
||||
} else {
|
||||
const fm = extractFrontmatter(content);
|
||||
status = (fm.status || 'unknown').toLowerCase();
|
||||
} catch {
|
||||
status = 'unreadable';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +192,8 @@ function scanThreads(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
let status = (fm.status || '').toLowerCase().trim();
|
||||
@@ -266,12 +259,8 @@ function scanTodos(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
@@ -326,12 +315,8 @@ function scanSeeds(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'dormant').toLowerCase();
|
||||
@@ -406,12 +391,8 @@ function scanUatGaps(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
@@ -478,12 +459,8 @@ function scanVerificationGaps(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const status = (fm.status || 'unknown').toLowerCase();
|
||||
@@ -542,12 +519,8 @@ function scanContextQuestions(planDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(safeFilePath);
|
||||
if (content === null) continue;
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execGit } = require('./shell-command-projection.cjs');
|
||||
const { safeReadFile, loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
|
||||
const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { loadConfig, isGitIgnored, normalizePhaseName, comparePhaseNum, getArchivedPhaseDirs, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, resolveModelInternal, stripShippedMilestones, extractCurrentMilestone, toPosixPath, output, error, findPhaseInternal, extractOneLinerFromBody, getRoadmapPhaseInternal } = require('./core.cjs');
|
||||
const { planningDir, planningPaths } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { MODEL_PROFILES } = require('./model-profiles.cjs');
|
||||
@@ -23,7 +23,7 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
|
||||
const files = fs.readdirSync(phaseDir);
|
||||
const verificationFile = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
|
||||
if (verificationFile) {
|
||||
const content = fs.readFileSync(path.join(phaseDir, verificationFile), 'utf-8');
|
||||
const content = platformReadSync(path.join(phaseDir, verificationFile)) || '';
|
||||
if (/status:\s*passed/i.test(content)) return 'Complete';
|
||||
if (/status:\s*human_needed/i.test(content)) return 'Needs Review';
|
||||
if (/status:\s*gaps_found/i.test(content)) return 'Executed';
|
||||
@@ -81,26 +81,25 @@ function cmdListTodos(cwd, area, raw) {
|
||||
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
|
||||
const createdMatch = content.match(/^created:\s*(.+)$/m);
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m);
|
||||
const areaMatch = content.match(/^area:\s*(.+)$/m);
|
||||
const content = platformReadSync(path.join(pendingDir, file));
|
||||
if (content === null) continue;
|
||||
const createdMatch = content.match(/^created:\s*(.+)$/m);
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m);
|
||||
const areaMatch = content.match(/^area:\s*(.+)$/m);
|
||||
|
||||
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
|
||||
const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
|
||||
|
||||
// Apply area filter if specified
|
||||
if (area && todoArea !== area) continue;
|
||||
// Apply area filter if specified
|
||||
if (area && todoArea !== area) continue;
|
||||
|
||||
count++;
|
||||
todos.push({
|
||||
file,
|
||||
created: createdMatch ? createdMatch[1].trim() : 'unknown',
|
||||
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
|
||||
area: todoArea,
|
||||
path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))),
|
||||
});
|
||||
} catch { /* intentionally empty */ }
|
||||
count++;
|
||||
todos.push({
|
||||
file,
|
||||
created: createdMatch ? createdMatch[1].trim() : 'unknown',
|
||||
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
|
||||
area: todoArea,
|
||||
path: toPosixPath(path.relative(cwd, path.join(pendingDir, file))),
|
||||
});
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
@@ -168,8 +167,9 @@ function cmdHistoryDigest(cwd, raw) {
|
||||
const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
|
||||
|
||||
for (const summary of summaries) {
|
||||
const content = platformReadSync(path.join(dirPath, summary));
|
||||
if (content === null) continue;
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8');
|
||||
const fm = extractFrontmatter(content);
|
||||
|
||||
const phaseNum = fm.phase || dir.split('-')[0];
|
||||
@@ -625,21 +625,20 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
||||
try {
|
||||
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m);
|
||||
const areaMatch = content.match(/^area:\s*(.+)$/m);
|
||||
const filesMatch = content.match(/^files:\s*(.+)$/m);
|
||||
const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
|
||||
const content = platformReadSync(path.join(pendingDir, file));
|
||||
if (content === null) continue;
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m);
|
||||
const areaMatch = content.match(/^area:\s*(.+)$/m);
|
||||
const filesMatch = content.match(/^files:\s*(.+)$/m);
|
||||
const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
|
||||
|
||||
todos.push({
|
||||
file,
|
||||
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
|
||||
area: areaMatch ? areaMatch[1].trim() : 'general',
|
||||
files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
|
||||
body: body.slice(0, 200), // first 200 chars for context
|
||||
});
|
||||
} catch {}
|
||||
todos.push({
|
||||
file,
|
||||
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
|
||||
area: areaMatch ? areaMatch[1].trim() : 'general',
|
||||
files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
|
||||
body: body.slice(0, 200), // first 200 chars for context
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
@@ -671,13 +670,12 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
||||
const phaseDir = path.join(cwd, phaseInfoDisk.directory);
|
||||
const planFiles = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
|
||||
for (const pf of planFiles) {
|
||||
try {
|
||||
const planContent = fs.readFileSync(path.join(phaseDir, pf), 'utf-8');
|
||||
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
|
||||
if (fmFiles) {
|
||||
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
|
||||
}
|
||||
} catch {}
|
||||
const planContent = platformReadSync(path.join(phaseDir, pf));
|
||||
if (planContent === null) continue;
|
||||
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
|
||||
if (fmFiles) {
|
||||
phasePlans.push(...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -748,14 +746,14 @@ function cmdTodoComplete(cwd, filename, raw) {
|
||||
}
|
||||
|
||||
// Ensure completed directory exists
|
||||
fs.mkdirSync(completedDir, { recursive: true });
|
||||
platformEnsureDir(completedDir);
|
||||
|
||||
// Read, add completion timestamp, move
|
||||
let content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
content = `completed: ${today}\n` + content;
|
||||
|
||||
fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
|
||||
platformWriteSync(path.join(completedDir, filename), content);
|
||||
fs.unlinkSync(sourcePath);
|
||||
|
||||
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
||||
@@ -803,9 +801,9 @@ function cmdScaffold(cwd, type, options, raw) {
|
||||
const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : '';
|
||||
const dirName = `${scaffoldPrefix}${padded}-${slug}`;
|
||||
const phasesParent = planningPaths(cwd).phases;
|
||||
fs.mkdirSync(phasesParent, { recursive: true });
|
||||
platformEnsureDir(phasesParent);
|
||||
const dirPath = path.join(phasesParent, dirName);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
platformEnsureDir(dirPath);
|
||||
output({ created: true, directory: toPosixPath(path.relative(cwd, dirPath)), path: dirPath }, raw, dirPath);
|
||||
return;
|
||||
}
|
||||
@@ -818,7 +816,7 @@ function cmdScaffold(cwd, type, options, raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
platformWriteSync(filePath, content);
|
||||
const relPath = toPosixPath(path.relative(cwd, filePath));
|
||||
output({ created: true, path: relPath }, raw, relPath);
|
||||
}
|
||||
@@ -837,7 +835,9 @@ function cmdStats(cwd, format, raw) {
|
||||
let totalSummaries = 0;
|
||||
|
||||
try {
|
||||
const roadmapContent = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('roadmap missing');
|
||||
const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
|
||||
let match;
|
||||
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
||||
@@ -893,28 +893,24 @@ function cmdStats(cwd, format, raw) {
|
||||
// Requirements stats
|
||||
let requirementsTotal = 0;
|
||||
let requirementsComplete = 0;
|
||||
try {
|
||||
if (fs.existsSync(reqPath)) {
|
||||
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
const checked = reqContent.match(/^- \[x\] \*\*/gm);
|
||||
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
|
||||
requirementsComplete = checked ? checked.length : 0;
|
||||
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
const reqContent = platformReadSync(reqPath);
|
||||
if (reqContent !== null) {
|
||||
const checked = reqContent.match(/^- \[x\] \*\*/gm);
|
||||
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
|
||||
requirementsComplete = checked ? checked.length : 0;
|
||||
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
|
||||
}
|
||||
|
||||
// Last activity from STATE.md
|
||||
let lastActivity = null;
|
||||
try {
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|
||||
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/^Last activity:\s*(.+)$/im);
|
||||
if (activityMatch) lastActivity = activityMatch[1].trim();
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
const stateContent = platformReadSync(statePath);
|
||||
if (stateContent !== null) {
|
||||
const activityMatch = stateContent.match(/^last_activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|
||||
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|
||||
|| stateContent.match(/^Last activity:\s*(.+)$/im);
|
||||
if (activityMatch) lastActivity = activityMatch[1].trim();
|
||||
}
|
||||
|
||||
// Git stats
|
||||
let gitCommits = 0;
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, error, ERROR_REASON, CONFIG_DEFAULTS, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { output, error, ERROR_REASON, CONFIG_DEFAULTS } = require('./core.cjs');
|
||||
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
|
||||
const {
|
||||
VALID_PROFILES,
|
||||
@@ -144,7 +145,7 @@ function buildNewProjectConfig(userChoices) {
|
||||
userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth;
|
||||
delete userDefaults.depth;
|
||||
try {
|
||||
fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8');
|
||||
platformWriteSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2));
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
}
|
||||
@@ -275,9 +276,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
|
||||
// Ensure .planning directory exists
|
||||
try {
|
||||
if (!fs.existsSync(planningBase)) {
|
||||
fs.mkdirSync(planningBase, { recursive: true });
|
||||
}
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + err.message);
|
||||
}
|
||||
@@ -285,7 +284,7 @@ function cmdConfigNewProject(cwd, choicesJson, raw) {
|
||||
const config = buildNewProjectConfig(userChoices);
|
||||
|
||||
try {
|
||||
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
output({ created: true, path: '.planning/config.json' }, raw, 'created');
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + err.message);
|
||||
@@ -304,9 +303,7 @@ function ensureConfigFile(cwd) {
|
||||
|
||||
// Ensure .planning directory exists
|
||||
try {
|
||||
if (!fs.existsSync(planningBase)) {
|
||||
fs.mkdirSync(planningBase, { recursive: true });
|
||||
}
|
||||
platformEnsureDir(planningBase);
|
||||
} catch (err) {
|
||||
error('Failed to create .planning directory: ' + err.message);
|
||||
}
|
||||
@@ -319,7 +316,7 @@ function ensureConfigFile(cwd) {
|
||||
const config = buildNewProjectConfig({});
|
||||
|
||||
try {
|
||||
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { created: true, path: '.planning/config.json' };
|
||||
} catch (err) {
|
||||
error('Failed to create config.json: ' + err.message);
|
||||
@@ -377,7 +374,7 @@ function setConfigValue(cwd, keyPath, parsedValue) {
|
||||
|
||||
// Write back
|
||||
try {
|
||||
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { updated: true, key: keyPath, value: parsedValue, previousValue };
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + err.message);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execGit } = require('./shell-command-projection.cjs');
|
||||
const { execGit, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
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 {
|
||||
@@ -107,7 +107,9 @@ function findProjectRoot(startDir) {
|
||||
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 raw = platformReadSync(configPath);
|
||||
if (raw === null) throw new Error('missing');
|
||||
const config = JSON.parse(raw);
|
||||
const subRepos = config.sub_repos || config.planning?.sub_repos || [];
|
||||
|
||||
// Check explicit sub_repos list
|
||||
@@ -155,7 +157,7 @@ function findProjectRoot(startDir) {
|
||||
const GSD_TEMP_DIR = path.join(require('os').tmpdir(), 'gsd');
|
||||
|
||||
function ensureGsdTempDir() {
|
||||
fs.mkdirSync(GSD_TEMP_DIR, { recursive: true });
|
||||
platformEnsureDir(GSD_TEMP_DIR);
|
||||
}
|
||||
|
||||
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false } = {}) {
|
||||
@@ -196,7 +198,7 @@ function output(result, raw, rawValue) {
|
||||
reapStaleTempFiles();
|
||||
ensureGsdTempDir();
|
||||
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
||||
fs.writeFileSync(tmpPath, json, 'utf-8');
|
||||
platformWriteSync(tmpPath, json);
|
||||
data = '@file:' + tmpPath;
|
||||
} else {
|
||||
data = json;
|
||||
@@ -351,7 +353,8 @@ function loadConfig(cwd, options = {}) {
|
||||
if (ws) {
|
||||
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
|
||||
try {
|
||||
const raw = fs.readFileSync(rootConfigPath, 'utf-8');
|
||||
const raw = platformReadSync(rootConfigPath);
|
||||
if (raw === null) throw new Error('missing');
|
||||
rootParsed = JSON.parse(raw);
|
||||
} catch {
|
||||
// Root config missing or unparseable — workstream config stands alone
|
||||
@@ -362,7 +365,8 @@ function loadConfig(cwd, options = {}) {
|
||||
const defaults = CONFIG_DEFAULTS;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
||||
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 = JSON.parse(raw);
|
||||
@@ -372,7 +376,7 @@ function loadConfig(cwd, options = {}) {
|
||||
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 */ }
|
||||
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
// Auto-detect and sync sub_repos: scan for child directories with .git
|
||||
@@ -420,7 +424,7 @@ function loadConfig(cwd, options = {}) {
|
||||
// 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 {}
|
||||
try { platformWriteSync(configPath, JSON.stringify(fileData, null, 2)); } catch {}
|
||||
}
|
||||
|
||||
// Now apply root→workstream inheritance. `parsed` is the effective config
|
||||
@@ -556,7 +560,8 @@ function loadConfig(cwd, options = {}) {
|
||||
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 raw = platformReadSync(globalDefaultsPath);
|
||||
if (raw === null) throw new Error('missing');
|
||||
const globalDefaults = JSON.parse(raw);
|
||||
return {
|
||||
...defaults,
|
||||
@@ -1038,8 +1043,8 @@ function extractCurrentMilestone(content, cwd) {
|
||||
let version = null;
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateRaw = fs.readFileSync(statePath, 'utf-8');
|
||||
const stateRaw = platformReadSync(statePath);
|
||||
if (stateRaw !== null) {
|
||||
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (milestoneMatch) {
|
||||
version = milestoneMatch[1].trim();
|
||||
@@ -1145,7 +1150,9 @@ function getRoadmapPhaseInternal(cwd, phaseNum) {
|
||||
if (!fs.existsSync(roadmapPath)) return null;
|
||||
|
||||
try {
|
||||
const content = extractCurrentMilestone(fs.readFileSync(roadmapPath, 'utf-8'), cwd);
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('missing');
|
||||
const content = extractCurrentMilestone(roadmapRaw, 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))
|
||||
@@ -1655,7 +1662,8 @@ function generateSlugInternal(text) {
|
||||
|
||||
function getMilestoneInfo(cwd) {
|
||||
try {
|
||||
const roadmap = fs.readFileSync(path.join(planningDir(cwd), 'ROADMAP.md'), 'utf-8');
|
||||
const roadmap = platformReadSync(path.join(planningDir(cwd), 'ROADMAP.md'));
|
||||
if (roadmap === null) throw new Error('missing');
|
||||
|
||||
// 0. Prefer STATE.md milestone: frontmatter as the authoritative source.
|
||||
// This prevents falling through to a regex that may match an old heading
|
||||
@@ -1665,8 +1673,8 @@ function getMilestoneInfo(cwd) {
|
||||
if (cwd) {
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateRaw = fs.readFileSync(statePath, 'utf-8');
|
||||
const stateRaw = platformReadSync(statePath);
|
||||
if (stateRaw !== null) {
|
||||
const m = stateRaw.match(/^milestone:\s*(.+)/m);
|
||||
if (m) stateVersion = m[1].trim();
|
||||
}
|
||||
@@ -1747,7 +1755,8 @@ function getMilestonePhaseFilter(cwd, versionOverride) {
|
||||
let missingExplicitVersion = false;
|
||||
try {
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const roadmapContent = platformReadSync(roadmapPath);
|
||||
if (roadmapContent === null) throw new Error('missing');
|
||||
let roadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
|
||||
if (versionOverride) {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, loadConfig, resolveModelInternal, pathExistsInternal, toPosixPath, checkAgentsInstalled } = require('./core.cjs');
|
||||
const { platformReadSync } = require('./shell-command-projection.cjs');
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -122,30 +123,27 @@ function detectProjectType(cwd) {
|
||||
try { return pathExistsInternal(cwd, rel); } catch { return false; }
|
||||
};
|
||||
|
||||
// Read package.json once — used by has_cli_bin, is_monorepo, has_tests checks.
|
||||
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
|
||||
let pkg = null;
|
||||
if (pkgRaw) {
|
||||
try { pkg = JSON.parse(pkgRaw); } catch { /* invalid JSON */ }
|
||||
}
|
||||
|
||||
// has_cli_bin: package.json has a `bin` field
|
||||
let has_cli_bin = false;
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
|
||||
has_cli_bin = !!(pkg.bin && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0));
|
||||
} catch { /* no package.json or invalid JSON */ }
|
||||
const has_cli_bin = !!(pkg && pkg.bin && (typeof pkg.bin === 'string' || Object.keys(pkg.bin).length > 0));
|
||||
|
||||
// is_monorepo: pnpm-workspace.yaml, lerna.json, or package.json workspaces
|
||||
let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json');
|
||||
if (!is_monorepo) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
|
||||
is_monorepo = Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0;
|
||||
} catch { /* ignore */ }
|
||||
if (!is_monorepo && pkg) {
|
||||
is_monorepo = Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0;
|
||||
}
|
||||
|
||||
// has_tests: common test directories or test frameworks in devDependencies
|
||||
let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec');
|
||||
if (!has_tests) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
|
||||
const devDeps = Object.keys(pkg.devDependencies || {});
|
||||
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
|
||||
} catch { /* ignore */ }
|
||||
if (!has_tests && pkg) {
|
||||
const devDeps = Object.keys(pkg.devDependencies || {});
|
||||
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
|
||||
}
|
||||
|
||||
// has_deploy_config: various deployment config files
|
||||
@@ -202,32 +200,37 @@ function detectDocTooling(cwd) {
|
||||
*/
|
||||
function detectMonorepoWorkspaces(cwd) {
|
||||
// pnpm-workspace.yaml
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(cwd, 'pnpm-workspace.yaml'), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const pnpmRaw = platformReadSync(path.join(cwd, 'pnpm-workspace.yaml'));
|
||||
if (pnpmRaw) {
|
||||
const workspaces = [];
|
||||
for (const line of lines) {
|
||||
for (const line of pnpmRaw.split('\n')) {
|
||||
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
|
||||
if (m) workspaces.push(m[1].trim());
|
||||
}
|
||||
if (workspaces.length > 0) return workspaces;
|
||||
} catch { /* not present */ }
|
||||
}
|
||||
|
||||
// package.json workspaces
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8'));
|
||||
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
|
||||
return pkg.workspaces;
|
||||
}
|
||||
} catch { /* not present or invalid */ }
|
||||
const pkgRaw = platformReadSync(path.join(cwd, 'package.json'));
|
||||
if (pkgRaw) {
|
||||
try {
|
||||
const pkg = JSON.parse(pkgRaw);
|
||||
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
|
||||
return pkg.workspaces;
|
||||
}
|
||||
} catch { /* invalid JSON */ }
|
||||
}
|
||||
|
||||
// lerna.json
|
||||
try {
|
||||
const lerna = JSON.parse(fs.readFileSync(path.join(cwd, 'lerna.json'), 'utf-8'));
|
||||
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) {
|
||||
return lerna.packages;
|
||||
}
|
||||
} catch { /* not present or invalid */ }
|
||||
const lernaRaw = platformReadSync(path.join(cwd, 'lerna.json'));
|
||||
if (lernaRaw) {
|
||||
try {
|
||||
const lerna = JSON.parse(lernaRaw);
|
||||
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) {
|
||||
return lerna.packages;
|
||||
}
|
||||
} catch { /* invalid JSON */ }
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { safeReadFile, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { output, error } = require('./core.cjs');
|
||||
const { platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
|
||||
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -344,7 +345,7 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
||||
try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
|
||||
fm[field] = parsedValue;
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
atomicWriteFileSync(fullPath, normalizeMd(newContent));
|
||||
platformWriteSync(fullPath, newContent);
|
||||
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
||||
}
|
||||
|
||||
@@ -358,7 +359,7 @@ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
||||
try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
|
||||
Object.assign(fm, mergeData);
|
||||
const newContent = spliceFrontmatter(content, fm);
|
||||
atomicWriteFileSync(fullPath, normalizeMd(newContent));
|
||||
platformWriteSync(fullPath, newContent);
|
||||
output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execGit } = require('./shell-command-projection.cjs');
|
||||
const { execGit, platformWriteSync, platformReadSync } = require('./shell-command-projection.cjs');
|
||||
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs');
|
||||
const { planningPaths, planningDir, planningRoot } = require('./planning-workspace.cjs');
|
||||
const { maskIfSecret } = require('./secrets.cjs');
|
||||
@@ -26,19 +26,15 @@ function listPhasePlanFiles(phaseDir) {
|
||||
|
||||
function getLatestCompletedMilestone(cwd) {
|
||||
const milestonesPath = path.join(planningRoot(cwd), 'MILESTONES.md');
|
||||
if (!fs.existsSync(milestonesPath)) return null;
|
||||
const content = platformReadSync(milestonesPath);
|
||||
if (content === null) return null;
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(milestonesPath, 'utf-8');
|
||||
const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m);
|
||||
if (!match) return null;
|
||||
return {
|
||||
version: match[1],
|
||||
name: match[2].trim(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const match = content.match(/^##\s+(v[\d.]+)\s+(.+?)\s+\(Shipped:/m);
|
||||
if (!match) return null;
|
||||
return {
|
||||
version: match[1],
|
||||
name: match[2].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,15 +64,13 @@ function withProjectRoot(cwd, result) {
|
||||
}
|
||||
// Extract project title from PROJECT.md first H1 heading.
|
||||
const projectMdPath = path.join(planningDir(cwd), 'PROJECT.md');
|
||||
try {
|
||||
if (fs.existsSync(projectMdPath)) {
|
||||
const content = fs.readFileSync(projectMdPath, 'utf8');
|
||||
const h1Match = content.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) {
|
||||
result.project_title = h1Match[1].trim();
|
||||
}
|
||||
const content = platformReadSync(projectMdPath);
|
||||
if (content) {
|
||||
const h1Match = content.match(/^#\s+(.+)$/m);
|
||||
if (h1Match) {
|
||||
result.project_title = h1Match[1].trim();
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -187,8 +181,8 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
|
||||
if (options.validate) {
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const stateContent = platformReadSync(statePath);
|
||||
if (stateContent !== null) {
|
||||
const status = stateExtractField(stateContent, 'Status') || '';
|
||||
result.state_validation_ran = true;
|
||||
// Simple inline validation — check for obvious drift
|
||||
@@ -357,8 +351,8 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
|
||||
if (options.validate) {
|
||||
try {
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
if (fs.existsSync(statePath)) {
|
||||
const stateContent = fs.readFileSync(statePath, 'utf-8');
|
||||
const stateContent = platformReadSync(statePath);
|
||||
if (stateContent !== null) {
|
||||
const warnings = [];
|
||||
result.state_validation_ran = true;
|
||||
const totalPlansRaw = stateExtractField(stateContent, 'Total Plans in Phase');
|
||||
@@ -611,9 +605,8 @@ function cmdInitResume(cwd, raw) {
|
||||
|
||||
// Check for interrupted agent
|
||||
let interruptedAgentId = null;
|
||||
try {
|
||||
interruptedAgentId = fs.readFileSync(path.join(planningRoot(cwd), 'current-agent-id.txt'), 'utf-8').trim();
|
||||
} catch { /* intentionally empty */ }
|
||||
const agentIdRaw = platformReadSync(path.join(planningRoot(cwd), 'current-agent-id.txt'));
|
||||
if (agentIdRaw !== null) interruptedAgentId = agentIdRaw.trim();
|
||||
|
||||
const result = {
|
||||
// File existence
|
||||
@@ -843,8 +836,9 @@ function cmdInitTodos(cwd, area, raw) {
|
||||
try {
|
||||
const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
|
||||
for (const file of files) {
|
||||
const content = platformReadSync(path.join(pendingDir, file));
|
||||
if (content === null) continue;
|
||||
try {
|
||||
const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
|
||||
const createdMatch = content.match(/^created:\s*(.+)$/m);
|
||||
const titleMatch = content.match(/^title:\s*(.+)$/m);
|
||||
const areaMatch = content.match(/^area:\s*(.+)$/m);
|
||||
@@ -1212,8 +1206,9 @@ function cmdInitManager(cwd, raw) {
|
||||
let waitingSignal = null;
|
||||
try {
|
||||
const waitingPath = path.join(cwd, '.planning', 'WAITING.json');
|
||||
if (fs.existsSync(waitingPath)) {
|
||||
waitingSignal = JSON.parse(fs.readFileSync(waitingPath, 'utf-8'));
|
||||
const waitingRaw = platformReadSync(waitingPath);
|
||||
if (waitingRaw !== null) {
|
||||
waitingSignal = JSON.parse(waitingRaw);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
@@ -1453,11 +1448,11 @@ function cmdInitProgress(cwd, raw) {
|
||||
|
||||
// Check for paused work
|
||||
let pausedAt = null;
|
||||
try {
|
||||
const state = fs.readFileSync(path.join(planningDir(cwd), 'STATE.md'), 'utf-8');
|
||||
const state = platformReadSync(path.join(planningDir(cwd), 'STATE.md'));
|
||||
if (state !== null) {
|
||||
const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/);
|
||||
if (pauseMatch) pausedAt = pauseMatch[1].trim();
|
||||
} catch { /* intentionally empty */ }
|
||||
}
|
||||
|
||||
const result = {
|
||||
// Models
|
||||
@@ -1559,14 +1554,14 @@ function cmdInitListWorkspaces(cwd, raw) {
|
||||
let repoCount = 0;
|
||||
let hasProject = false;
|
||||
let strategy = 'unknown';
|
||||
try {
|
||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = platformReadSync(manifestPath);
|
||||
if (manifest !== null) {
|
||||
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
|
||||
if (strategyMatch) strategy = strategyMatch[1].trim();
|
||||
// Count table rows (lines starting with |, excluding header and separator)
|
||||
const tableRows = manifest.split('\n').filter(l => l.match(/^\|\s*\w/) && !l.includes('Repo') && !l.includes('---'));
|
||||
repoCount = tableRows.length;
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
hasProject = fs.existsSync(path.join(wsPath, '.planning', 'PROJECT.md'));
|
||||
|
||||
workspaces.push({
|
||||
@@ -1606,9 +1601,10 @@ function cmdInitRemoveWorkspace(cwd, name, raw) {
|
||||
// Parse manifest for repo info
|
||||
const repos = [];
|
||||
let strategy = 'unknown';
|
||||
if (fs.existsSync(manifestPath)) {
|
||||
const manifestContent = platformReadSync(manifestPath);
|
||||
if (manifestContent !== null) {
|
||||
try {
|
||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||
const manifest = manifestContent;
|
||||
const strategyMatch = manifest.match(/^Strategy:\s*(.+)$/m);
|
||||
if (strategyMatch) strategy = strategyMatch[1].trim();
|
||||
|
||||
@@ -1894,14 +1890,8 @@ function buildSkillManifest(cwd, skillsDir = null) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const skillMdPath = path.join(rootPath, entry.name, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(skillMdPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const content = platformReadSync(skillMdPath);
|
||||
if (content === null) continue;
|
||||
|
||||
const frontmatter = extractFrontmatter(content);
|
||||
const name = frontmatter.name || entry.name;
|
||||
@@ -1980,7 +1970,7 @@ function cmdSkillManifest(cwd, args, raw) {
|
||||
const planningDir = path.join(cwd, '.planning');
|
||||
if (fs.existsSync(planningDir)) {
|
||||
const manifestPath = path.join(planningDir, 'skill-manifest.json');
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
|
||||
platformWriteSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,9 +37,7 @@ const INTEL_FILES = {
|
||||
*/
|
||||
function ensureIntelDir(planningDir) {
|
||||
const intelPath = path.join(planningDir, 'intel');
|
||||
if (!fs.existsSync(intelPath)) {
|
||||
fs.mkdirSync(intelPath, { recursive: true });
|
||||
}
|
||||
platformEnsureDir(intelPath);
|
||||
return intelPath;
|
||||
}
|
||||
|
||||
@@ -53,8 +52,9 @@ function ensureIntelDir(planningDir) {
|
||||
function isIntelEnabled(planningDir) {
|
||||
try {
|
||||
const configPath = path.join(planningDir, 'config.json');
|
||||
if (!fs.existsSync(configPath)) return false;
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
const raw = platformReadSync(configPath);
|
||||
if (raw === null) return false;
|
||||
const config = JSON.parse(raw);
|
||||
if (config && config.intel && config.intel.enabled === true) return true;
|
||||
return false;
|
||||
} catch (_e) {
|
||||
@@ -89,8 +89,9 @@ function intelFilePath(planningDir, filename) {
|
||||
*/
|
||||
function safeReadJson(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
const raw = platformReadSync(filePath);
|
||||
if (raw === null) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
@@ -105,8 +106,8 @@ function safeReadJson(filePath) {
|
||||
*/
|
||||
function hashFile(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return null;
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
} catch (_e) {
|
||||
return null;
|
||||
@@ -178,8 +179,8 @@ function matchesInValue(value, lowerTerm) {
|
||||
*/
|
||||
function searchArchMd(filePath, term) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) return [];
|
||||
const lowerTerm = term.toLowerCase();
|
||||
const lines = content.split(/\r?\n/);
|
||||
return lines.filter(line => line.toLowerCase().includes(lowerTerm));
|
||||
@@ -343,11 +344,11 @@ function saveRefreshSnapshot(planningDir) {
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const snapshotPath = path.join(intelPath, '.last-refresh.json');
|
||||
fs.writeFileSync(snapshotPath, JSON.stringify({
|
||||
platformWriteSync(snapshotPath, JSON.stringify({
|
||||
hashes,
|
||||
timestamp,
|
||||
version: 1
|
||||
}, null, 2), 'utf8');
|
||||
}, null, 2));
|
||||
|
||||
return { saved: true, timestamp, files: fileCount };
|
||||
}
|
||||
@@ -392,9 +393,14 @@ function intelValidate(planningDir) {
|
||||
// All intel files are JSON — validate _meta and entries structure
|
||||
|
||||
// Parse JSON
|
||||
const raw = platformReadSync(filePath);
|
||||
if (raw === null) {
|
||||
errors.push(`${filename}: file missing`);
|
||||
continue;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
data = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
errors.push(`${filename}: invalid JSON — ${e.message}`);
|
||||
continue;
|
||||
@@ -462,11 +468,10 @@ function intelValidate(planningDir) {
|
||||
*/
|
||||
function intelPatchMeta(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { patched: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(content);
|
||||
@@ -482,7 +487,7 @@ function intelPatchMeta(filePath) {
|
||||
data._meta.updated_at = timestamp;
|
||||
data._meta.version = (data._meta.version || 0) + 1;
|
||||
|
||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
||||
platformWriteSync(filePath, JSON.stringify(data, null, 2) + '\n');
|
||||
|
||||
return { patched: true, file: filePath, timestamp };
|
||||
} catch (e) {
|
||||
@@ -500,11 +505,10 @@ function intelPatchMeta(filePath) {
|
||||
* @returns {{ file: string, exports: string[], method: string }}
|
||||
*/
|
||||
function intelExtractExports(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
const content = platformReadSync(filePath);
|
||||
if (content === null) {
|
||||
return { file: filePath, exports: [], method: 'none' };
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
let exports = [];
|
||||
let method = 'none';
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { escapeRegex, getMilestonePhaseFilter, extractOneLinerFromBody, output, error } = require('./core.cjs');
|
||||
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningPaths } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, stateReplaceFieldWithFallback } = require('./state.cjs');
|
||||
@@ -75,7 +76,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
|
||||
}
|
||||
|
||||
if (updated.length > 0) {
|
||||
atomicWriteFileSync(reqPath, reqContent);
|
||||
platformWriteSync(reqPath, reqContent);
|
||||
}
|
||||
|
||||
output({
|
||||
@@ -102,7 +103,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
const milestoneName = options.name || version;
|
||||
|
||||
// Ensure archive directory exists
|
||||
fs.mkdirSync(archiveDir, { recursive: true });
|
||||
platformEnsureDir(archiveDir);
|
||||
|
||||
// Scope stats and accomplishments to only the phases belonging to the
|
||||
// current milestone's ROADMAP. Uses the shared filter from core.cjs
|
||||
@@ -158,14 +159,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
// Archive ROADMAP.md
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
|
||||
platformWriteSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent);
|
||||
}
|
||||
|
||||
// Archive REQUIREMENTS.md
|
||||
if (fs.existsSync(reqPath)) {
|
||||
const reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
|
||||
fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
|
||||
platformWriteSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent);
|
||||
}
|
||||
|
||||
// Archive audit file if exists
|
||||
@@ -182,21 +183,21 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
const existing = fs.readFileSync(milestonesPath, 'utf-8');
|
||||
if (!existing.trim()) {
|
||||
// Empty file — treat like new
|
||||
atomicWriteFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`));
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
} else {
|
||||
// Insert after the header line(s) for reverse chronological order (newest first)
|
||||
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[1];
|
||||
const rest = existing.slice(header.length);
|
||||
atomicWriteFileSync(milestonesPath, normalizeMd(header + milestoneEntry + rest));
|
||||
platformWriteSync(milestonesPath, header + milestoneEntry + rest);
|
||||
} else {
|
||||
// No recognizable header — prepend the entry
|
||||
atomicWriteFileSync(milestonesPath, normalizeMd(milestoneEntry + existing));
|
||||
platformWriteSync(milestonesPath, milestoneEntry + existing);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
atomicWriteFileSync(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`));
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`);
|
||||
}
|
||||
|
||||
// Update STATE.md — keep frontmatter/body semantically aligned after closure
|
||||
@@ -241,7 +242,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
|
||||
if (options.archivePhases) {
|
||||
try {
|
||||
const phaseArchiveDir = path.join(archiveDir, `${version}-phases`);
|
||||
fs.mkdirSync(phaseArchiveDir, { recursive: true });
|
||||
platformEnsureDir(phaseArchiveDir);
|
||||
|
||||
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const phaseDirNames = phaseEntries.filter(e => e.isDirectory()).map(e => e.name);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, output, error, readSubdirectories, phaseTokenMatches } = require('./core.cjs');
|
||||
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningDir, withPlanningLock } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, updatePerformanceMetricsSection } = require('./state.cjs');
|
||||
@@ -571,8 +572,8 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
platformEnsureDir(dirPath);
|
||||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Build phase entry
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
||||
@@ -587,7 +588,7 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||
updatedContent = rawContent + phaseEntry;
|
||||
}
|
||||
|
||||
atomicWriteFileSync(roadmapPath, updatedContent);
|
||||
platformWriteSync(roadmapPath, updatedContent);
|
||||
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
||||
});
|
||||
|
||||
@@ -650,8 +651,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
||||
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
|
||||
}
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
platformEnsureDir(dirPath);
|
||||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
@@ -667,7 +668,7 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
|
||||
naming_mode: config.phase_naming,
|
||||
});
|
||||
}
|
||||
atomicWriteFileSync(roadmapPath, rawContent);
|
||||
platformWriteSync(roadmapPath, rawContent);
|
||||
return added;
|
||||
});
|
||||
output({ phases: results, count: results.length }, raw);
|
||||
@@ -737,8 +738,8 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
platformEnsureDir(dirPath);
|
||||
platformWriteSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Build phase entry
|
||||
const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`;
|
||||
@@ -762,7 +763,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||
}
|
||||
|
||||
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||||
atomicWriteFileSync(roadmapPath, updatedContent);
|
||||
platformWriteSync(roadmapPath, updatedContent);
|
||||
return { decimalPhase: _decimalPhase, dirName: _dirName };
|
||||
});
|
||||
|
||||
@@ -911,7 +912,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
|
||||
);
|
||||
}
|
||||
|
||||
atomicWriteFileSync(roadmapPath, content);
|
||||
platformWriteSync(roadmapPath, content);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1087,7 +1088,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
atomicWriteFileSync(roadmapPath, roadmapContent);
|
||||
platformWriteSync(roadmapPath, roadmapContent);
|
||||
|
||||
// Update REQUIREMENTS.md traceability for this phase's requirements
|
||||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||||
@@ -1156,7 +1157,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
||||
);
|
||||
}
|
||||
|
||||
atomicWriteFileSync(reqPath, reqContent);
|
||||
platformWriteSync(reqPath, reqContent);
|
||||
requirementsUpdated = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { probeTty } = require('./shell-command-projection.cjs');
|
||||
const { probeTty, platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
|
||||
|
||||
const WORKSTREAM_SESSION_ENV_KEYS = [
|
||||
@@ -153,14 +153,11 @@ function createSharedPointerAdapter(cwd) {
|
||||
const filePath = path.join(planningRoot(cwd), 'active-workstream');
|
||||
return {
|
||||
read() {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const raw = platformReadSync(filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name) {
|
||||
fs.writeFileSync(filePath, name + '\n', 'utf-8');
|
||||
platformWriteSync(filePath, name + '\n');
|
||||
},
|
||||
clear() {
|
||||
try { fs.unlinkSync(filePath); } catch {}
|
||||
@@ -174,15 +171,12 @@ function createSessionScopedPointerAdapter(cwd, fixedSessionKey) {
|
||||
|
||||
return {
|
||||
read() {
|
||||
try {
|
||||
return fs.readFileSync(scoped.filePath, 'utf-8').trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const raw = platformReadSync(scoped.filePath);
|
||||
return raw ? raw.trim() || null : null;
|
||||
},
|
||||
write(name) {
|
||||
fs.mkdirSync(scoped.dirPath, { recursive: true });
|
||||
fs.writeFileSync(scoped.filePath, name + '\n', 'utf-8');
|
||||
platformEnsureDir(scoped.dirPath);
|
||||
platformWriteSync(scoped.filePath, name + '\n');
|
||||
},
|
||||
clear() {
|
||||
try { fs.unlinkSync(scoped.filePath); } catch {}
|
||||
@@ -240,7 +234,7 @@ function withPlanningLock(cwd, fn) {
|
||||
const start = Date.now();
|
||||
|
||||
// Ensure .planning/ exists
|
||||
try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ }
|
||||
try { platformEnsureDir(planningDir(cwd)); } catch { /* ok */ }
|
||||
|
||||
function runWithHeldLock() {
|
||||
// Atomic create — fails if file exists
|
||||
@@ -333,7 +327,7 @@ function createPlanningWorkspace(cwd, opts = {}) {
|
||||
}
|
||||
|
||||
const wsDir = path.join(planningRoot(cwd), 'workstreams', name);
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
platformEnsureDir(wsDir);
|
||||
adapter.write(name);
|
||||
},
|
||||
clear() {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { output, error, safeReadFile, loadConfig } = require('./core.cjs');
|
||||
const { output, error, loadConfig } = require('./core.cjs');
|
||||
const { platformReadSync: safeReadFile, platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { getGlobalSkillDir } = require('./runtime-homes.cjs');
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
@@ -485,8 +486,10 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
analysis = JSON.parse(fs.readFileSync(analysisPath, 'utf-8'));
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
}
|
||||
@@ -631,8 +634,8 @@ function cmdWriteProfile(cwd, options, raw) {
|
||||
outputPath = path.join(cwd, outputPath);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, template, 'utf-8');
|
||||
platformEnsureDir(path.dirname(outputPath));
|
||||
platformWriteSync(outputPath, template);
|
||||
|
||||
const result = {
|
||||
profile_path: outputPath,
|
||||
@@ -716,8 +719,10 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
analysis = JSON.parse(fs.readFileSync(analysisPath, 'utf-8'));
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
}
|
||||
@@ -803,8 +808,8 @@ function cmdGenerateDevPreferences(cwd, options, raw) {
|
||||
outputPath = path.join(cwd, outputPath);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, template, 'utf-8');
|
||||
platformEnsureDir(path.dirname(outputPath));
|
||||
platformWriteSync(outputPath, template);
|
||||
|
||||
const result = {
|
||||
command_path: outputPath,
|
||||
@@ -824,8 +829,10 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
if (!fs.existsSync(analysisPath)) error(`Analysis file not found: ${analysisPath}`);
|
||||
|
||||
let analysis;
|
||||
const analysisRaw = safeReadFile(analysisPath);
|
||||
try {
|
||||
analysis = JSON.parse(fs.readFileSync(analysisPath, 'utf-8'));
|
||||
if (analysisRaw === null) throw new Error(`analysis file not found: ${analysisPath}`);
|
||||
analysis = JSON.parse(analysisRaw);
|
||||
} catch (err) {
|
||||
error(`Failed to parse analysis JSON: ${err.message}`);
|
||||
}
|
||||
@@ -904,8 +911,8 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
|
||||
let action;
|
||||
|
||||
if (fs.existsSync(targetPath)) {
|
||||
let existingContent = fs.readFileSync(targetPath, 'utf-8');
|
||||
let existingContent = safeReadFile(targetPath);
|
||||
if (existingContent !== null) {
|
||||
const startMarker = '<!-- GSD:profile-start -->';
|
||||
const endMarker = '<!-- GSD:profile-end -->';
|
||||
const startIdx = existingContent.indexOf(startMarker);
|
||||
@@ -920,10 +927,10 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
existingContent = existingContent.trimEnd() + '\n\n' + sectionContent + '\n';
|
||||
action = 'appended';
|
||||
}
|
||||
fs.writeFileSync(targetPath, existingContent, 'utf-8');
|
||||
platformWriteSync(targetPath, existingContent);
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
fs.writeFileSync(targetPath, sectionContent + '\n', 'utf-8');
|
||||
platformEnsureDir(path.dirname(targetPath));
|
||||
platformWriteSync(targetPath, sectionContent + '\n');
|
||||
action = 'created';
|
||||
}
|
||||
|
||||
@@ -1021,8 +1028,8 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
sections.push(CLAUDE_MD_PROFILE_PLACEHOLDER);
|
||||
existingContent = sections.join('\n\n') + '\n';
|
||||
action = 'created';
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
fs.writeFileSync(outputPath, existingContent, 'utf-8');
|
||||
platformEnsureDir(path.dirname(outputPath));
|
||||
platformWriteSync(outputPath, existingContent);
|
||||
} else {
|
||||
action = 'updated';
|
||||
let fileContent = existingContent;
|
||||
@@ -1060,7 +1067,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
fileContent = fileContent.trimEnd() + '\n\n' + CLAUDE_MD_PROFILE_PLACEHOLDER + '\n';
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, fileContent, 'utf-8');
|
||||
platformWriteSync(outputPath, fileContent);
|
||||
}
|
||||
|
||||
const finalContent = safeReadFile(outputPath);
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { escapeRegex, normalizePhaseName, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, phaseTokenMatches } = require('./core.cjs');
|
||||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
const { planningPaths, withPlanningLock } = require('./planning-workspace.cjs');
|
||||
const scanPhasePlans = require('./plan-scan.cjs');
|
||||
|
||||
@@ -407,7 +408,7 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
atomicWriteFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
platformWriteSync(roadmapPath, roadmapContent);
|
||||
});
|
||||
output({
|
||||
updated: true,
|
||||
@@ -580,7 +581,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
|
||||
|
||||
const nextContent = content.slice(0, phaseStart) + newPhaseSection + content.slice(phaseEnd);
|
||||
if (nextContent === content) return;
|
||||
atomicWriteFileSync(roadmapPath, nextContent);
|
||||
platformWriteSync(roadmapPath, nextContent);
|
||||
updated = true;
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, normalizeMd, output, error, atomicWriteFileSync } = require('./core.cjs');
|
||||
const { escapeRegex, loadConfig, getMilestoneInfo, getMilestonePhaseFilter, output, error } = require('./core.cjs');
|
||||
const { platformWriteSync, platformReadSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningDir, planningPaths } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
||||
const scanPhasePlans = require('./plan-scan.cjs');
|
||||
@@ -41,10 +42,7 @@ function cmdStateLoad(cwd, raw) {
|
||||
const config = loadConfig(cwd);
|
||||
const planDir = planningPaths(cwd).planning;
|
||||
|
||||
let stateRaw = '';
|
||||
try {
|
||||
stateRaw = fs.readFileSync(path.join(planDir, 'STATE.md'), 'utf-8');
|
||||
} catch { /* intentionally empty */ }
|
||||
const stateRaw = platformReadSync(path.join(planDir, 'STATE.md')) || '';
|
||||
|
||||
const configExists = fs.existsSync(path.join(planDir, 'config.json'));
|
||||
const roadmapExists = fs.existsSync(path.join(planDir, 'ROADMAP.md'));
|
||||
@@ -84,8 +82,12 @@ function cmdStateLoad(cwd, raw) {
|
||||
|
||||
function cmdStateGet(cwd, section, raw) {
|
||||
const statePath = planningPaths(cwd).state;
|
||||
try {
|
||||
const content = fs.readFileSync(statePath, 'utf-8');
|
||||
const content = platformReadSync(statePath);
|
||||
if (content === null) {
|
||||
error('STATE.md not found');
|
||||
return;
|
||||
}
|
||||
{
|
||||
|
||||
if (!section) {
|
||||
output({ content }, raw, content);
|
||||
@@ -120,8 +122,6 @@ function cmdStateGet(cwd, section, raw) {
|
||||
}
|
||||
|
||||
output({ error: `Section or field "${section}" not found` }, raw, '');
|
||||
} catch {
|
||||
error('STATE.md not found');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -974,7 +974,7 @@ function writeStateMd(statePath, content, cwd) {
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
platformWriteSync(statePath, synced);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
@@ -1002,7 +1002,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options) {
|
||||
const resync = !options || options.resync !== false;
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const content = platformReadSync(statePath) || '';
|
||||
// Snapshot the existing progress block BEFORE the transform so we can
|
||||
// restore it when resync is false.
|
||||
const preFm = resync ? null : extractFrontmatter(content);
|
||||
@@ -1022,7 +1022,7 @@ function readModifyWriteStateMd(statePath, transformFn, cwd, options) {
|
||||
synced = `---\n${yamlStr}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
atomicWriteFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
platformWriteSync(statePath, synced);
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
@@ -1217,8 +1217,8 @@ function cmdSignalWaiting(cwd, type, question, options, phase, raw) {
|
||||
};
|
||||
|
||||
try {
|
||||
fs.mkdirSync(gsdDir, { recursive: true });
|
||||
fs.writeFileSync(waitingPath, JSON.stringify(signal, null, 2), 'utf-8');
|
||||
platformEnsureDir(gsdDir);
|
||||
platformWriteSync(waitingPath, JSON.stringify(signal, null, 2));
|
||||
output({ signaled: true, path: waitingPath }, raw, 'true');
|
||||
} catch (e) {
|
||||
output({ signaled: false, error: e.message }, raw, 'false');
|
||||
@@ -1347,7 +1347,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const content = platformReadSync(statePath) || '';
|
||||
const existingFm = extractFrontmatter(content);
|
||||
const body = stripFrontmatter(content);
|
||||
|
||||
@@ -1383,7 +1383,7 @@ function cmdStateMilestoneSwitch(cwd, version, name, raw) {
|
||||
|
||||
const yamlStr = reconstructFrontmatter(fm);
|
||||
const assembled = `---\n${yamlStr}\n---\n\n${newBody.replace(/^\n+/, '')}`;
|
||||
atomicWriteFileSync(statePath, normalizeMd(assembled), 'utf-8');
|
||||
platformWriteSync(statePath, assembled);
|
||||
output(
|
||||
{ switched: true, version, name: resolvedName, status: 'planning' },
|
||||
raw,
|
||||
@@ -1752,17 +1752,15 @@ function cmdStatePrune(cwd, options, raw) {
|
||||
// Write archived entries to STATE-ARCHIVE.md
|
||||
if (archived.length > 0) {
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
let archiveContent = '';
|
||||
if (fs.existsSync(archivePath)) {
|
||||
archiveContent = fs.readFileSync(archivePath, 'utf-8');
|
||||
} else {
|
||||
let archiveContent = platformReadSync(archivePath);
|
||||
if (archiveContent === null) {
|
||||
archiveContent = '# STATE Archive\n\nPruned entries from STATE.md. Recoverable but no longer loaded into agent context.\n\n';
|
||||
}
|
||||
archiveContent += `## Pruned ${timestamp} (phases 1-${cutoff}, kept recent ${keepRecent})\n\n`;
|
||||
for (const section of archived) {
|
||||
archiveContent += `### ${section.section}\n\n${section.lines.join('\n')}\n\n`;
|
||||
}
|
||||
atomicWriteFileSync(archivePath, archiveContent);
|
||||
platformWriteSync(archivePath, archiveContent);
|
||||
}
|
||||
|
||||
const totalPruned = archived.reduce((sum, s) => sum + s.count, 0);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { safeReadFile, loadConfig, normalizePhaseName, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
|
||||
const { execGit } = require('./shell-command-projection.cjs');
|
||||
const { loadConfig, normalizePhaseName, escapeRegex, findPhaseInternal, getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone, output, error, checkAgentsInstalled, CONFIG_DEFAULTS, inspectWorktreeHealth } = require('./core.cjs');
|
||||
const { execGit, platformReadSync: safeReadFile, platformWriteSync } = require('./shell-command-projection.cjs');
|
||||
const { planningDir } = require('./planning-workspace.cjs');
|
||||
const { extractFrontmatter, parseMustHavesBlock } = require('./frontmatter.cjs');
|
||||
const { writeStateMd } = require('./state.cjs');
|
||||
@@ -1020,7 +1020,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
parallelization: CONFIG_DEFAULTS.parallelization,
|
||||
brave_search: CONFIG_DEFAULTS.brave_search,
|
||||
};
|
||||
fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(defaults, null, 2));
|
||||
repairActions.push({ action: repair, success: true, path: 'config.json' });
|
||||
break;
|
||||
}
|
||||
@@ -1058,7 +1058,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
if (!configParsed.workflow) configParsed.workflow = {};
|
||||
if (configParsed.workflow.nyquist_validation === undefined) {
|
||||
configParsed.workflow.nyquist_validation = true;
|
||||
fs.writeFileSync(configPath, JSON.stringify(configParsed, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(configParsed, null, 2));
|
||||
}
|
||||
repairActions.push({ action: repair, success: true, path: 'config.json' });
|
||||
} catch (err) {
|
||||
@@ -1075,7 +1075,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
if (!configParsed.workflow) configParsed.workflow = {};
|
||||
if (configParsed.workflow.ai_integration_phase === undefined) {
|
||||
configParsed.workflow.ai_integration_phase = true;
|
||||
fs.writeFileSync(configPath, JSON.stringify(configParsed, null, 2), 'utf-8');
|
||||
platformWriteSync(configPath, JSON.stringify(configParsed, null, 2));
|
||||
}
|
||||
repairActions.push({ action: repair, success: true, path: 'config.json' });
|
||||
} catch (err) {
|
||||
@@ -1091,7 +1091,7 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
for (const ver of missingFromRegistry) {
|
||||
try {
|
||||
const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
|
||||
const snapshot = fs.existsSync(snapshotPath) ? fs.readFileSync(snapshotPath, 'utf-8') : null;
|
||||
const snapshot = safeReadFile(snapshotPath);
|
||||
// Build minimal entry from snapshot title or version
|
||||
const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
|
||||
const milestoneName = titleMatch ? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim() : ver;
|
||||
@@ -1100,15 +1100,15 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
? fs.readFileSync(milestonesPath, 'utf-8')
|
||||
: '';
|
||||
if (!milestonesContent.trim()) {
|
||||
fs.writeFileSync(milestonesPath, `# Milestones\n\n${entry}`, 'utf-8');
|
||||
platformWriteSync(milestonesPath, `# Milestones\n\n${entry}`);
|
||||
} else {
|
||||
const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[1];
|
||||
const rest = milestonesContent.slice(header.length);
|
||||
fs.writeFileSync(milestonesPath, header + entry + rest, 'utf-8');
|
||||
platformWriteSync(milestonesPath, header + entry + rest);
|
||||
} else {
|
||||
fs.writeFileSync(milestonesPath, entry + milestonesContent, 'utf-8');
|
||||
platformWriteSync(milestonesPath, entry + milestonesContent);
|
||||
}
|
||||
}
|
||||
backfilled++;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { output, error, toPosixPath, getMilestoneInfo, generateSlugInternal } = require('./core.cjs');
|
||||
const { platformWriteSync, platformEnsureDir } = require('./shell-command-projection.cjs');
|
||||
const { planningRoot, setActiveWorkstream, getActiveWorkstream } = require('./planning-workspace.cjs');
|
||||
const { toWorkstreamSlug, hasInvalidPathSegment, isValidActiveWorkstreamName } = require('./workstream-name-policy.cjs');
|
||||
const {
|
||||
@@ -46,7 +47,7 @@ function migrateToWorkstreams(cwd, workstreamName) {
|
||||
{ name: 'phases', type: 'dir' },
|
||||
];
|
||||
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
platformEnsureDir(wsDir);
|
||||
|
||||
const filesMoved = [];
|
||||
try {
|
||||
@@ -131,12 +132,12 @@ function cmdWorkstreamCreate(cwd, name, options, raw) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
fs.mkdirSync(wsRoot, { recursive: true });
|
||||
platformEnsureDir(wsRoot);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(wsDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
platformEnsureDir(wsDir);
|
||||
platformEnsureDir(path.join(wsDir, 'phases'));
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const stateContent = [
|
||||
@@ -165,7 +166,7 @@ function cmdWorkstreamCreate(cwd, name, options, raw) {
|
||||
|
||||
const statePath = path.join(wsDir, 'STATE.md');
|
||||
if (!fs.existsSync(statePath)) {
|
||||
fs.writeFileSync(statePath, stateContent, 'utf-8');
|
||||
platformWriteSync(statePath, stateContent);
|
||||
}
|
||||
|
||||
setActiveWorkstream(cwd, slug);
|
||||
@@ -253,7 +254,7 @@ function cmdWorkstreamComplete(cwd, name, options, raw) {
|
||||
archivePath = path.join(archiveDir, `ws-${name}-${today}-${suffix++}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(archivePath, { recursive: true });
|
||||
platformEnsureDir(archivePath);
|
||||
|
||||
const filesMoved = [];
|
||||
try {
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
*
|
||||
* Ensures that milestone.cjs, phase.cjs, and frontmatter.cjs do NOT
|
||||
* contain bare fs.writeFileSync calls targeting .planning/ files. All
|
||||
* such writes must go through atomicWriteFileSync to prevent partial
|
||||
* writes from corrupting planning artifacts on crash.
|
||||
* such writes must go through platformWriteSync (the shell-projection
|
||||
* seam's atomic writer) to prevent partial writes from corrupting planning
|
||||
* artifacts on crash. platformWriteSync uses the same tmp-file + rename
|
||||
* primitive as the legacy atomicWriteFileSync — migrated in #3467.
|
||||
*
|
||||
* Allowed exceptions:
|
||||
* - Writes to .gitkeep (empty files, no corruption risk)
|
||||
@@ -66,29 +68,29 @@ describe('atomic write coverage (#1972)', () => {
|
||||
const report = violations.map(v => ` line ${v.line}: ${v.text}`).join('\n');
|
||||
assert.fail(
|
||||
`${file} contains ${violations.length} bare fs.writeFileSync call(s) targeting planning files.\n` +
|
||||
`These should use atomicWriteFileSync instead:\n${report}`
|
||||
`These should use platformWriteSync instead:\n${report}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${file}: imports atomicWriteFileSync from core.cjs`, () => {
|
||||
test(`${file}: imports platformWriteSync from shell-command-projection.cjs`, () => {
|
||||
const filePath = path.join(libDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/atomicWriteFileSync.*require\(['"]\.\/core\.cjs['"]\)|atomicWriteFileSync[^)]*\}\s*=\s*require\(['"]\.\/core\.cjs['"]\)/s,
|
||||
`${file} must import atomicWriteFileSync from core.cjs`
|
||||
/platformWriteSync[^)]*\}\s*=\s*require\(['"]\.\/shell-command-projection\.cjs['"]\)/s,
|
||||
`${file} must import platformWriteSync from shell-command-projection.cjs`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('all three files use atomicWriteFileSync at least once', () => {
|
||||
test('all three files use platformWriteSync at least once', () => {
|
||||
for (const file of targetFiles) {
|
||||
const content = fs.readFileSync(path.join(libDir, file), 'utf-8');
|
||||
assert.match(
|
||||
content,
|
||||
/atomicWriteFileSync\s*\(/,
|
||||
`${file} must contain at least one atomicWriteFileSync call`
|
||||
/platformWriteSync\s*\(/,
|
||||
`${file} must contain at least one platformWriteSync call`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user