refactor(shell-projection): migrate all fs call sites to platform* seam (Phase 3, #3467) (#3481)

* 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:
Tom Boucher
2026-05-13 19:34:23 -04:00
committed by GitHub
parent 409295b450
commit 439d9ceacd
19 changed files with 341 additions and 348 deletions

View 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.

View File

@@ -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

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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 [];
}

View File

@@ -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');
}

View File

@@ -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));
}
}

View File

@@ -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';

View File

@@ -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);

View File

@@ -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;
}
});

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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);

View File

@@ -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++;

View File

@@ -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 {

View File

@@ -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`
);
}
});