From 439d9ceacd297a13a8c98fc0a6e0b634418efd5b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 13 May 2026 19:34:23 -0400 Subject: [PATCH] refactor(shell-projection): migrate all fs call sites to platform* seam (Phase 3, #3467) (#3481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * chore(changeset): add entry for shell-projection Phase 3 migration (#3467) Co-Authored-By: Claude Sonnet 4.6 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/shell-projection-fs-migration.md | 5 + .coderabbit.yaml | 10 ++ get-shit-done/bin/lib/audit.cjs | 65 +++------- get-shit-done/bin/lib/commands.cjs | 130 +++++++++---------- get-shit-done/bin/lib/config.cjs | 19 ++- get-shit-done/bin/lib/core.cjs | 41 +++--- get-shit-done/bin/lib/docs.cjs | 69 +++++----- get-shit-done/bin/lib/frontmatter.cjs | 7 +- get-shit-done/bin/lib/init.cjs | 86 ++++++------ get-shit-done/bin/lib/intel.cjs | 46 ++++--- get-shit-done/bin/lib/milestone.cjs | 21 +-- get-shit-done/bin/lib/phase.cjs | 27 ++-- get-shit-done/bin/lib/planning-workspace.cjs | 26 ++-- get-shit-done/bin/lib/profile-output.cjs | 39 +++--- get-shit-done/bin/lib/roadmap.cjs | 7 +- get-shit-done/bin/lib/state.cjs | 40 +++--- get-shit-done/bin/lib/verify.cjs | 18 +-- get-shit-done/bin/lib/workstream.cjs | 13 +- tests/atomic-write-coverage.test.cjs | 20 +-- 19 files changed, 341 insertions(+), 348 deletions(-) create mode 100644 .changeset/shell-projection-fs-migration.md diff --git a/.changeset/shell-projection-fs-migration.md b/.changeset/shell-projection-fs-migration.md new file mode 100644 index 000000000..7a5922907 --- /dev/null +++ b/.changeset/shell-projection-fs-migration.md @@ -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. diff --git a/.coderabbit.yaml b/.coderabbit.yaml index a09f00ece..12408d73a 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -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 diff --git a/get-shit-done/bin/lib/audit.cjs b/get-shit-done/bin/lib/audit.cjs index 51bced10e..47d3386e3 100644 --- a/get-shit-done/bin/lib/audit.cjs +++ b/get-shit-done/bin/lib/audit.cjs @@ -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); diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index 1fe4704a9..8142bec7d 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -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; diff --git a/get-shit-done/bin/lib/config.cjs b/get-shit-done/bin/lib/config.cjs index f1c9b0661..158b65487 100644 --- a/get-shit-done/bin/lib/config.cjs +++ b/get-shit-done/bin/lib/config.cjs @@ -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); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index f2c95e475..14cd62c5d 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -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) { diff --git a/get-shit-done/bin/lib/docs.cjs b/get-shit-done/bin/lib/docs.cjs index 378842a23..545c79f68 100644 --- a/get-shit-done/bin/lib/docs.cjs +++ b/get-shit-done/bin/lib/docs.cjs @@ -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 []; } diff --git a/get-shit-done/bin/lib/frontmatter.cjs b/get-shit-done/bin/lib/frontmatter.cjs index c3865cc31..2bac41501 100644 --- a/get-shit-done/bin/lib/frontmatter.cjs +++ b/get-shit-done/bin/lib/frontmatter.cjs @@ -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'); } diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index b64bb2171..b8615044e 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -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)); } } diff --git a/get-shit-done/bin/lib/intel.cjs b/get-shit-done/bin/lib/intel.cjs index f7a3763ce..b4e52e9e5 100644 --- a/get-shit-done/bin/lib/intel.cjs +++ b/get-shit-done/bin/lib/intel.cjs @@ -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'; diff --git a/get-shit-done/bin/lib/milestone.cjs b/get-shit-done/bin/lib/milestone.cjs index 3eca300b5..674a88ee3 100644 --- a/get-shit-done/bin/lib/milestone.cjs +++ b/get-shit-done/bin/lib/milestone.cjs @@ -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); diff --git a/get-shit-done/bin/lib/phase.cjs b/get-shit-done/bin/lib/phase.cjs index 28e442188..e14be8474 100644 --- a/get-shit-done/bin/lib/phase.cjs +++ b/get-shit-done/bin/lib/phase.cjs @@ -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; } }); diff --git a/get-shit-done/bin/lib/planning-workspace.cjs b/get-shit-done/bin/lib/planning-workspace.cjs index 8bb9e8a49..f0a8e2424 100644 --- a/get-shit-done/bin/lib/planning-workspace.cjs +++ b/get-shit-done/bin/lib/planning-workspace.cjs @@ -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() { diff --git a/get-shit-done/bin/lib/profile-output.cjs b/get-shit-done/bin/lib/profile-output.cjs index 7a1217edd..3ae39f9b5 100644 --- a/get-shit-done/bin/lib/profile-output.cjs +++ b/get-shit-done/bin/lib/profile-output.cjs @@ -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 = ''; const endMarker = ''; 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); diff --git a/get-shit-done/bin/lib/roadmap.cjs b/get-shit-done/bin/lib/roadmap.cjs index 799f2d8e9..edf7603ee 100644 --- a/get-shit-done/bin/lib/roadmap.cjs +++ b/get-shit-done/bin/lib/roadmap.cjs @@ -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; }); diff --git a/get-shit-done/bin/lib/state.cjs b/get-shit-done/bin/lib/state.cjs index 8f8d50007..618dd56e5 100644 --- a/get-shit-done/bin/lib/state.cjs +++ b/get-shit-done/bin/lib/state.cjs @@ -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); diff --git a/get-shit-done/bin/lib/verify.cjs b/get-shit-done/bin/lib/verify.cjs index bd3ce7293..aa7fed2c2 100644 --- a/get-shit-done/bin/lib/verify.cjs +++ b/get-shit-done/bin/lib/verify.cjs @@ -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++; diff --git a/get-shit-done/bin/lib/workstream.cjs b/get-shit-done/bin/lib/workstream.cjs index 51881769a..67614a742 100644 --- a/get-shit-done/bin/lib/workstream.cjs +++ b/get-shit-done/bin/lib/workstream.cjs @@ -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 { diff --git a/tests/atomic-write-coverage.test.cjs b/tests/atomic-write-coverage.test.cjs index 533b1299c..3c20a569f 100644 --- a/tests/atomic-write-coverage.test.cjs +++ b/tests/atomic-write-coverage.test.cjs @@ -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` ); } });