* refactor(shell-projection): remove deprecated wrappers + finalize ADRs (Phase 4, #3468) Final phase of the shell-command-projection expansion. Removes the legacy core.cjs wrappers (`atomicWriteFileSync`, `safeReadFile`, `normalizeMd`) now that every call site lives behind the seam, plus three Phase-3 stragglers (`graphify.cjs`, `template.cjs`, dead import in `profile-pipeline.cjs`). Documentation: - ADR-0009: addendum noting Phase 1–4 scope expansion (subprocess + file I/O ownership), supersession of "does not execute" constraint, and resolution of open Q4. - ADR-0010: status changed to Superseded by ADR-0009 with explanation. - CONTEXT.md "Shell Command Projection Module" entry already current from Phase 1 — no edit needed. Tests: - `tests/atomic-write.test.cjs` deleted — wrapper it tested is gone; `atomic-write-coverage.test.cjs` (Phase 3) covers platformWriteSync. - `tests/core.test.cjs::safeReadFile` + `::normalizeMd` describes deleted — wrappers are gone. - `tests/concurrency-safety.test.cjs` normalizeMd suite (behavioral / perf / snapshot) repointed via 2-line shim at the seam's `normalizeContent` — full regression coverage preserved. Test result: 9059/9041/18 — exact pre-Phase-4 baseline. All 18 failures are pre-existing path-with-spaces local-env issues. Closes #3468 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate remaining raw fs.writeFileSync sites (Phase 4, #3468) Sweeps the 7 raw fs.writeFileSync call sites that bypassed the seam through Phase 3, folding them into platformWriteSync. Net -14 lines: deletes the local writeFileAtomicSync helper in installer-migrations.cjs and collapses surface.cjs's manual tmp+rename into a single seam call. Sites migrated: - drift.cjs (1) — frontmatter write - learnings.cjs (1) — learning record JSON write - install-profiles.cjs (1) — profile marker write (collapsed redundant mkdir) - gsd2-import.cjs (1) — imported file write (collapsed redundant mkdir) - surface.cjs (1) — surface state write (replaced manual tmp+rename block) - installer-migrations.cjs (3) — journal init/finalize + rewrite-json action; deleted private writeFileAtomicSync helper and its three call sites Two sites intentionally retained outside the seam: - planning-workspace.cjs:241 — workspace lock (wx-flag atomic-create; previously excluded by Phase 3) - installer-migrations.cjs:220 — install migration lock (fd write into wx-opened handle) - writeInstallState (installer-migrations.cjs) — strict atomic contract for install state; the seam's fallback-to-direct-write on rename failure would silently violate the invariant that install state must never be left half-written. Inline tmp+rename with rethrow keeps the original guarantee. Tests: 9059 / 9041 / 18 — exactly the pre-Phase-4 baseline; 18 failures are the pre-existing path-with-spaces local-env issues, identical files as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(installer-migrations): use strict atomic write for rollback install-state restore The rollback path was restoring INSTALL_STATE via platformWriteSync, which falls back to a direct write on rename failure and would silently violate the half-written invariant that the install-state contract guarantees elsewhere. Extracts the strict tmp+rename logic from writeInstallState into a shared atomicWriteInstallState(configDir, content) helper and routes both writeInstallState and rollbackAppliedMigrationResult through it. Preserves the existing null-handling (rmSync when previousInstallStateBytes === null) and existing failure-collection (failures.push on caught errors). Byte-faithful restore: previousInstallStateBytes is written as-is (no JSON parse round-trip), preserving the exact prior file contents on restore. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
399 lines
13 KiB
JavaScript
399 lines
13 KiB
JavaScript
'use strict';
|
||
/**
|
||
* Runtime surface module — ADR-0011 Phase 2 (Option B).
|
||
*
|
||
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
|
||
* each runtime's skills dir) independently of the install-time profile marker
|
||
* (`.gsd-profile`). Runtime config locations are resolved by callers.
|
||
*
|
||
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
|
||
* then transitively closed via the manifest.
|
||
*
|
||
* Exports:
|
||
* readSurface(runtimeConfigDir)
|
||
* writeSurface(runtimeConfigDir, surfaceState)
|
||
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
|
||
* applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)
|
||
* listSurface(runtimeConfigDir, manifest, clusterMap)
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const { platformWriteSync } = require('./shell-command-projection.cjs');
|
||
|
||
const {
|
||
readActiveProfile,
|
||
resolveProfile,
|
||
stageSkillsForProfile,
|
||
stageAgentsForProfile,
|
||
loadSkillsManifest,
|
||
PROFILES,
|
||
} = require('./install-profiles.cjs');
|
||
const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs');
|
||
|
||
const SURFACE_FILE_NAME = '.gsd-surface.json';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// State IO
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* @typedef {Object} SurfaceState
|
||
* @property {string} baseProfile
|
||
* @property {string[]} disabledClusters
|
||
* @property {string[]} explicitAdds
|
||
* @property {string[]} explicitRemoves
|
||
*/
|
||
|
||
/**
|
||
* Read the surface state from a runtime config directory.
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @returns {SurfaceState|null} null if file missing or corrupt
|
||
*/
|
||
function readSurface(runtimeConfigDir) {
|
||
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
|
||
try {
|
||
const raw = fs.readFileSync(filePath, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
// Structural validation — must have these fields with expected types
|
||
if (typeof parsed !== 'object' || parsed === null) return null;
|
||
if (typeof parsed.baseProfile !== 'string') return null;
|
||
if (!Array.isArray(parsed.disabledClusters)) return null;
|
||
if (!Array.isArray(parsed.explicitAdds)) return null;
|
||
if (!Array.isArray(parsed.explicitRemoves)) return null;
|
||
return {
|
||
baseProfile: parsed.baseProfile,
|
||
disabledClusters: parsed.disabledClusters,
|
||
explicitAdds: parsed.explicitAdds,
|
||
explicitRemoves: parsed.explicitRemoves,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Write the surface state atomically via the platform seam (mkdir + tmp+rename).
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @param {SurfaceState} surfaceState
|
||
*/
|
||
function writeSurface(runtimeConfigDir, surfaceState) {
|
||
platformWriteSync(path.join(runtimeConfigDir, SURFACE_FILE_NAME), JSON.stringify(surfaceState, null, 2) + '\n');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Resolution
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Expand cluster names to skill stems using the provided clusterMap.
|
||
*
|
||
* @param {string[]} clusterNames
|
||
* @param {Object} clusterMap CLUSTERS or override
|
||
* @returns {Set<string>}
|
||
*/
|
||
function clustersToSkills(clusterNames, clusterMap) {
|
||
const result = new Set();
|
||
for (const name of clusterNames) {
|
||
const members = clusterMap[name];
|
||
if (members) {
|
||
for (const s of members) result.add(s);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Resolve the effective surface to a typed profile-like object.
|
||
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
|
||
*
|
||
* Resolution order:
|
||
* 1. Start with base profile resolved via resolveProfile()
|
||
* 2. Remove skills in disabled clusters
|
||
* 3. Add explicitAdds (and their transitive closure)
|
||
* 4. Remove explicitRemoves (only the stem itself, no cascade)
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @param {Map<string, string[]>} manifest
|
||
* @param {Object} [clusterMap] defaults to CLUSTERS
|
||
* @returns {{ name: string, skills: Set<string>, agents: Set<string> }}
|
||
*/
|
||
function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
|
||
const cm = clusterMap || CLUSTERS;
|
||
const surface = readSurface(runtimeConfigDir);
|
||
|
||
// Determine base profile name: from surface state or from .gsd-profile marker
|
||
const baseProfileName = (surface && surface.baseProfile)
|
||
? surface.baseProfile
|
||
: (readActiveProfile(runtimeConfigDir) || 'full');
|
||
|
||
// Resolve base profile
|
||
const baseResolved = resolveProfile({
|
||
modes: baseProfileName.split(',').map(s => s.trim()),
|
||
manifest,
|
||
});
|
||
|
||
// If full, we need to enumerate all skills from the manifest
|
||
let skills;
|
||
if (baseResolved.skills === '*') {
|
||
// Materialize all skill stems from manifest
|
||
skills = new Set();
|
||
for (const [key] of manifest) {
|
||
if (!key.startsWith('_calls_agents_')) skills.add(key);
|
||
}
|
||
} else {
|
||
skills = new Set(baseResolved.skills);
|
||
}
|
||
|
||
if (surface) {
|
||
// Step 2: remove disabled cluster members
|
||
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
|
||
for (const s of disabledSkills) skills.delete(s);
|
||
|
||
// Step 3: add explicitAdds with transitive closure
|
||
if (surface.explicitAdds.length > 0) {
|
||
const addSet = new Set(surface.explicitAdds);
|
||
// Compute closure of adds
|
||
const queue = [...addSet];
|
||
const visited = new Set(addSet);
|
||
while (queue.length > 0) {
|
||
const stem = queue.pop();
|
||
const deps = manifest.get(stem) || [];
|
||
for (const dep of deps) {
|
||
if (!visited.has(dep)) {
|
||
visited.add(dep);
|
||
queue.push(dep);
|
||
}
|
||
}
|
||
}
|
||
for (const s of visited) skills.add(s);
|
||
}
|
||
|
||
// Step 4: remove explicitRemoves (stem only, no cascade)
|
||
for (const s of surface.explicitRemoves) {
|
||
skills.delete(s);
|
||
}
|
||
}
|
||
|
||
// Derive agents from skills
|
||
const agents = new Set();
|
||
for (const skillStem of skills) {
|
||
const agentRefs = manifest.get(`_calls_agents_${skillStem}`) || [];
|
||
for (const agentStem of agentRefs) agents.add(agentStem);
|
||
}
|
||
|
||
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
|
||
return { name, skills, agents };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Apply
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* Re-stage the active surface to commandsDir and agentsDir in-place.
|
||
* Only touches files matching `gsd-` prefix or `*.md` in commandsDir.
|
||
* Never touches non-`gsd-*` files.
|
||
*
|
||
* Steps:
|
||
* 1. Resolve surface → active skill/agent sets
|
||
* 2. Stage to temp dirs via stageSkillsForProfile / stageAgentsForProfile
|
||
* 3. Find the install source (where skill files live)
|
||
* 4. Sync: copy missing, delete superseded (gsd-only)
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @param {string} commandsDir runtime commands/gsd dir (resolved per-runtime by callers)
|
||
* @param {string} agentsDir runtime agents dir (resolved per-runtime by callers)
|
||
* @param {Map<string, string[]>} manifest
|
||
* @param {Object} [clusterMap]
|
||
*/
|
||
function applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) {
|
||
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
|
||
|
||
// Find install source
|
||
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
|
||
|
||
// Stage skills
|
||
const stagedSkills = stageSkillsForProfile(srcCommandsDir, resolved);
|
||
|
||
// Sync commandsDir from stagedSkills
|
||
_syncGsdDir(stagedSkills, commandsDir, 'commands');
|
||
|
||
// Stage and sync agents
|
||
if (agentsDir && fs.existsSync(agentsDir)) {
|
||
const srcAgentsDir = _findAgentsSource(runtimeConfigDir);
|
||
if (srcAgentsDir) {
|
||
const stagedAgents = stageAgentsForProfile(srcAgentsDir, resolved);
|
||
_syncGsdDir(stagedAgents, agentsDir, 'agents');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Sync destination directory from staged source.
|
||
* Adds files present in staged but missing in dest.
|
||
* Removes gsd-prefixed .md files in dest not present in staged.
|
||
* Never touches non-gsd files.
|
||
*
|
||
* @param {string} stagedDir source (staged temp dir or original)
|
||
* @param {string} destDir runtime destination
|
||
* @param {'commands'|'agents'} context
|
||
*/
|
||
function _syncGsdDir(stagedDir, destDir, context) {
|
||
if (!fs.existsSync(stagedDir)) return;
|
||
fs.mkdirSync(destDir, { recursive: true });
|
||
|
||
const stagedFiles = new Set(
|
||
fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'))
|
||
);
|
||
|
||
// Copy missing files from staged to dest
|
||
for (const file of stagedFiles) {
|
||
const destFile = path.join(destDir, file);
|
||
if (!fs.existsSync(destFile)) {
|
||
fs.copyFileSync(path.join(stagedDir, file), destFile);
|
||
} else {
|
||
// Overwrite to ensure content is current
|
||
fs.copyFileSync(path.join(stagedDir, file), destFile);
|
||
}
|
||
}
|
||
|
||
// Remove gsd-only files from dest that aren't in staged set
|
||
// For commands dir: all .md files are gsd skills
|
||
// For agents dir: only gsd-* files
|
||
const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md'));
|
||
for (const file of destEntries) {
|
||
if (context === 'agents' && !file.startsWith('gsd-')) continue;
|
||
if (!stagedFiles.has(file)) {
|
||
try { fs.unlinkSync(path.join(destDir, file)); } catch {}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Find the install source commands/gsd directory.
|
||
* Checks the runtime's `.gsd-source` marker (sibling of the surface state file),
|
||
* then walks up from __dirname to find the installed package source.
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @returns {string} path to install source commands/gsd
|
||
*/
|
||
function _findInstallSource(runtimeConfigDir) {
|
||
// Check for .gsd-source marker
|
||
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
|
||
if (fs.existsSync(sourceMarker)) {
|
||
try {
|
||
const src = fs.readFileSync(sourceMarker, 'utf8').trim();
|
||
if (src && fs.existsSync(src)) return src;
|
||
} catch {}
|
||
}
|
||
|
||
// Walk up from this module's dir to find commands/gsd
|
||
let dir = __dirname;
|
||
for (let i = 0; i < 6; i++) {
|
||
const candidate = path.join(dir, 'commands', 'gsd');
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
|
||
// Fallback: the runtimeConfigDir itself
|
||
return path.join(runtimeConfigDir, '..', 'commands', 'gsd');
|
||
}
|
||
|
||
/**
|
||
* Find the install source agents directory.
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @returns {string|null}
|
||
*/
|
||
function _findAgentsSource(runtimeConfigDir) {
|
||
// Prefer .gsd-source sibling marker (commands/gsd) and derive agents from it.
|
||
const sourceMarker = path.join(runtimeConfigDir, '.gsd-source');
|
||
if (fs.existsSync(sourceMarker)) {
|
||
try {
|
||
const commandsSrc = fs.readFileSync(sourceMarker, 'utf8').trim();
|
||
if (commandsSrc && fs.existsSync(commandsSrc)) {
|
||
const commandsParent = path.dirname(commandsSrc); // .../commands
|
||
const candidate = path.resolve(commandsParent, '..', 'agents');
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
let dir = __dirname;
|
||
for (let i = 0; i < 6; i++) {
|
||
const candidate = path.join(dir, 'agents');
|
||
if (fs.existsSync(candidate)) return candidate;
|
||
const parent = path.dirname(dir);
|
||
if (parent === dir) break;
|
||
dir = parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// List
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/**
|
||
* List the currently enabled and disabled skills with token cost.
|
||
*
|
||
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
|
||
* Descriptions are read from the installed commandsDir skill files.
|
||
*
|
||
* @param {string} runtimeConfigDir
|
||
* @param {Map<string, string[]>} manifest
|
||
* @param {Object} [clusterMap]
|
||
* @returns {{ enabled: string[], disabled: string[], tokenCost: number }}
|
||
*/
|
||
function listSurface(runtimeConfigDir, manifest, clusterMap) {
|
||
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
|
||
|
||
// All known stems from manifest (exclude _calls_agents_ meta keys)
|
||
const allStems = [];
|
||
for (const [key] of manifest) {
|
||
if (!key.startsWith('_calls_agents_')) allStems.push(key);
|
||
}
|
||
|
||
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
|
||
|
||
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
|
||
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
|
||
|
||
// Compute token cost by reading descriptions from the install source
|
||
const srcCommandsDir = _findInstallSource(runtimeConfigDir);
|
||
let tokenCost = 0;
|
||
for (const stem of enabled) {
|
||
const filePath = path.join(srcCommandsDir, `${stem}.md`);
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
const descMatch = content.match(/^description:\s*(.+)$/m);
|
||
if (descMatch) {
|
||
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
return { enabled, disabled, tokenCost };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Exports
|
||
// ---------------------------------------------------------------------------
|
||
|
||
module.exports = {
|
||
readSurface,
|
||
writeSurface,
|
||
resolveSurface,
|
||
applySurface,
|
||
listSurface,
|
||
// Exported for testing
|
||
_findInstallSource,
|
||
_syncGsdDir,
|
||
};
|