diff --git a/agents/README.md b/agents/README.md index ff01879a..a05ccb42 100644 --- a/agents/README.md +++ b/agents/README.md @@ -55,7 +55,7 @@ The catalog contains 10 canonical roles across lifecycle, audit, and utility res - **Single source of truth:** `agents/*.md` in this repo. Consumer copies are generated, not edited. - **Delegates reference the local copy:** `distilled/templates/delegates/*.md` point to `.work/templates/roles/.md`, not back to this repo; legacy installs localize those paths to `.planning/templates/roles/.md`. - **Idempotent:** `gsdd init` skips the copy if `.work/templates/roles/` already exists. -- **Updates:** `gsdd update --templates` re-copies from latest framework sources with hash-based modification detection. +- **Updates:** plain `gsdd update` reconciles the manifest-owned repo-local templates, helpers, skills, and adapters from latest framework sources with hash-based modification detection. Verifier note: - `verifier.md` is phase-scoped. diff --git a/bin/lib/global-install.mjs b/bin/lib/global-install.mjs index 974e1504..d43f71a9 100644 --- a/bin/lib/global-install.mjs +++ b/bin/lib/global-install.mjs @@ -1,7 +1,7 @@ import os from 'os'; import { spawnSync } from 'child_process'; import { existsSync, lstatSync } from 'fs'; -import { join } from 'path'; +import { isAbsolute, join, parse, relative, resolve, sep } from 'path'; import { promptMultiSelect } from './init-prompts.mjs'; import { buildPortableSkillEntries, @@ -57,6 +57,25 @@ function getConfigHome(homeDir, env = process.env) { return env.XDG_CONFIG_HOME || join(homeDir, '.config'); } +function pathIsInside(root, target) { + const rel = relative(resolve(root), resolve(target)); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function globalContainmentRoot(roots, rootDir) { + if (pathIsInside(roots.home, rootDir)) return roots.home; + if (pathIsInside(roots.configHome, rootDir) && pathIsInside(roots.home, roots.configHome)) return roots.home; + // Explicit config/runtime homes outside the normal Workspine home are an + // allowed destination, but a missing leaf must not hide a junction or file + // collision in one of its existing ancestors. Anchor at that path's volume + // root so the read-only containment walk sees every ancestor before mkdir. + return parse(resolve(rootDir)).root; +} + +function globalInstallSpec(roots, runtime, rootDir, entries) { + return { runtime, rootDir, containmentRoot: globalContainmentRoot(roots, rootDir), entries }; +} + export function resolveGlobalInstallRoots({ homeDir = getHomeDir(), env = process.env } = {}) { const isolatedHome = env.GSDD_TEST_HOME || null; const effectiveHome = isolatedHome || homeDir; @@ -231,63 +250,35 @@ function buildGlobalEntries(target, ctx, rootDir) { function buildGlobalInstallSpecs(target, roots, ctx) { if (target === 'codex') { return [ - { - runtime: 'agent-skills', - rootDir: roots.codexSkills, - entries: buildAgentCompatibleGlobalSkillEntries(ctx), - }, - { - runtime: 'codex', - rootDir: roots.codex, - entries: buildCodexGlobalAgentEntries(), - }, + globalInstallSpec(roots, 'agent-skills', roots.codexSkills, buildAgentCompatibleGlobalSkillEntries(ctx)), + globalInstallSpec(roots, 'codex', roots.codex, buildCodexGlobalAgentEntries()), ]; } if (target === 'opencode' && roots.opencode !== roots.opencodeSkills) { return [ - { - runtime: 'agent-skills', - rootDir: roots.opencodeSkills, - entries: buildAgentCompatibleGlobalSkillEntries(ctx), - }, - { - runtime: 'opencode', - rootDir: roots.opencode, - entries: [ + globalInstallSpec(roots, 'agent-skills', roots.opencodeSkills, buildAgentCompatibleGlobalSkillEntries(ctx)), + globalInstallSpec(roots, 'opencode', roots.opencode, [ ...buildOpenCodeGlobalCommandEntries(ctx, roots.opencodeSkills), ...buildOpenCodeGlobalAgentEntries(ctx), - ], - }, + ]), ]; } if (target === 'copilot' && roots.copilot !== roots.copilotSkills) { return [ - { - runtime: 'agent-skills', - rootDir: roots.copilotSkills, - entries: buildAgentCompatibleGlobalSkillEntries(ctx), - }, - { - runtime: 'copilot', - rootDir: roots.copilot, - entries: buildCopilotGlobalAgentEntries(), - }, + globalInstallSpec(roots, 'agent-skills', roots.copilotSkills, buildAgentCompatibleGlobalSkillEntries(ctx)), + globalInstallSpec(roots, 'copilot', roots.copilot, buildCopilotGlobalAgentEntries()), ]; } return [ - { - runtime: target, - rootDir: roots[target], - entries: buildGlobalEntries(target, ctx, roots[target]), - }, + globalInstallSpec(roots, target, roots[target], buildGlobalEntries(target, ctx, roots[target])), ]; } function preflightInstallSpec(spec, { strictOwnership = false } = {}) { - const manifestState = inspectGlobalManifest(spec.rootDir); + const manifestState = inspectGlobalManifest(spec.rootDir, spec.containmentRoot); const previousManifest = manifestState.manifest; const manifestOwnershipMismatch = manifestState.status === 'valid' && (manifestState.manifest.product !== 'Workspine' @@ -295,7 +286,7 @@ function preflightInstallSpec(spec, { strictOwnership = false } = {}) { || !manifestState.manifest.files || typeof manifestState.manifest.files !== 'object' || Array.isArray(manifestState.manifest.files)); - if (['linked', 'collision', 'unreadable', 'corrupt'].includes(manifestState.status) || manifestOwnershipMismatch) { + if (['linked', 'collision', 'unreadable', 'unsafe', 'corrupt'].includes(manifestState.status) || manifestOwnershipMismatch) { const status = manifestOwnershipMismatch ? 'skipped_collision' : `skipped_${manifestState.status}`; return { ...spec, @@ -322,12 +313,14 @@ function preflightInstallSpec(spec, { strictOwnership = false } = {}) { nextFiles, dryRun: true, strictOwnership, + containmentRoot: spec.containmentRoot, })); const pruneResults = pruneStaleManifestTrackedFiles({ rootDir: spec.rootDir, previousManifest, nextFiles, dryRun: true, + containmentRoot: spec.containmentRoot, }); const results = [...fileResults, ...pruneResults]; @@ -349,12 +342,14 @@ function writeInstallSpec(plan, ctx) { previousManifest: plan.previousManifest, nextFiles, dryRun: false, + containmentRoot: plan.containmentRoot, })); results.push(...pruneStaleManifestTrackedFiles({ rootDir: plan.rootDir, previousManifest: plan.previousManifest, nextFiles, dryRun: false, + containmentRoot: plan.containmentRoot, })); writeGlobalManifest(plan.rootDir, { @@ -365,7 +360,7 @@ function writeInstallSpec(plan, ctx) { runtime: plan.runtime, generatedAt: new Date().toISOString(), files: nextFiles, - }); + }, plan.containmentRoot); return results; } @@ -591,6 +586,32 @@ export function getManifestOwnedGlobalTargets({ roots = resolveGlobalInstallRoot }); } +/** + * Global health must also inspect a partially lost ownership set when native + * Workspine-looking files remain. Shared agent-skills ownership alone is not + * enough to infer OpenCode/Codex/Copilot ownership. + */ +export function getGlobalHealthTargets({ roots = resolveGlobalInstallRoots(), ctx } = {}) { + const pathPresentOrUnreadable = (filePath) => { + try { + lstatSync(filePath); + return true; + } catch (error) { + return error?.code !== 'ENOENT'; + } + }; + const fullyOwned = new Set(getManifestOwnedGlobalTargets({ roots })); + return GLOBAL_AGENT_IDS.filter((target) => { + if (fullyOwned.has(target)) return true; + if (!ctx) return false; + const nativeSpecs = buildGlobalInstallSpecs(target, roots, ctx) + .filter((spec) => spec.runtime !== 'agent-skills'); + return nativeSpecs.some((spec) => + pathPresentOrUnreadable(join(spec.rootDir, GLOBAL_MANIFEST_FILENAME)) + || spec.entries.some((entry) => pathPresentOrUnreadable(join(spec.rootDir, entry.relativePath)))); + }); +} + export function collectGlobalInstallSpecs({ target, roots, ctx }) { return buildGlobalInstallSpecs(target, roots, ctx); } @@ -644,7 +665,9 @@ async function reconcileGlobalTargets({ ctx, targets, dryRun = false, heading = } if (hasBlocked) { - console.error('\nGlobal reconciliation finished with skipped files. Review them before retrying.'); + console.error(heading === 'update' + ? '\nGlobal update blocked. Manual resolution is required before retrying; review the warnings and run `npx -y workspine health --global` after resolving them.' + : '\nGlobal reconciliation finished with skipped files. Review them before retrying.'); process.exitCode = 1; return { targets, reports, blocked: true }; } diff --git a/bin/lib/global-manifest.mjs b/bin/lib/global-manifest.mjs index 01267e77..42a805a3 100644 --- a/bin/lib/global-manifest.mjs +++ b/bin/lib/global-manifest.mjs @@ -1,6 +1,6 @@ import { createHash } from 'crypto'; -import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import { dirname, join, relative, resolve } from 'path'; +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'path'; export const GLOBAL_MANIFEST_FILENAME = 'workspine-file-manifest.json'; @@ -12,6 +12,71 @@ export function fileHash(filePath) { return sha256(readFileSync(filePath)); } +function pathIsInside(root, target) { + const rel = relative(root, target); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +/** + * Inspect the install root plus every existing parent of a manifest-tracked + * target without following links. Missing parents are safe for a later + * create; linked/colliding parents must fail closed before health advertises + * or update performs a write through them. + */ +export function inspectGlobalTrackedPath(rootDir, relativePath, containmentRoot = rootDir) { + const root = resolve(rootDir); + const anchor = resolve(containmentRoot); + const target = resolve(root, relativePath); + if (!pathIsInside(root, target) || target === root) { + return { status: 'unsafe', message: 'target resolves outside the install root' }; + } + if (!pathIsInside(anchor, root)) { + return { status: 'unsafe', message: 'install root resolves outside its containment root' }; + } + + let anchorStat; + try { + anchorStat = lstatSync(anchor); + } catch (error) { + if (error?.code === 'ENOENT') return { status: 'safe' }; + return { status: 'unreadable', message: 'containment root could not be inspected safely' }; + } + if (anchorStat.isSymbolicLink()) return { status: 'linked', message: 'containment root is linked' }; + if (!anchorStat.isDirectory()) return { status: 'collision', message: 'containment root is not a directory' }; + + let realAnchor; + try { + realAnchor = realpathSync(anchor); + } catch { + return { status: 'unreadable', message: 'containment root could not be resolved safely' }; + } + + let current = anchor; + const parentParts = relative(anchor, dirname(target)).split(sep).filter(Boolean); + for (const part of parentParts) { + current = join(current, part); + let stat; + try { + stat = lstatSync(current); + } catch (error) { + if (error?.code === 'ENOENT') return { status: 'safe' }; + return { status: 'unreadable', message: 'target parent could not be inspected safely' }; + } + if (stat.isSymbolicLink()) return { status: 'linked', message: 'target parent is linked' }; + if (!stat.isDirectory()) return { status: 'collision', message: 'target parent is not a directory' }; + let realCurrent; + try { + realCurrent = realpathSync(current); + } catch { + return { status: 'unreadable', message: 'target parent could not be resolved safely' }; + } + if (!pathIsInside(realAnchor, realCurrent)) { + return { status: 'unsafe', message: 'target parent resolves outside the containment root' }; + } + } + return { status: 'safe' }; +} + export function readGlobalManifest(rootDir) { const manifestPath = join(rootDir, GLOBAL_MANIFEST_FILENAME); if (!existsSync(manifestPath)) return null; @@ -27,25 +92,52 @@ export function readGlobalManifest(rootDir) { * Distinguish a missing manifest from a corrupt or unsafe manifest path. The * installer must make this distinction before it writes any target bytes. */ -export function inspectGlobalManifest(rootDir) { +export function inspectGlobalManifest(rootDir, containmentRoot = rootDir) { const manifestPath = join(rootDir, GLOBAL_MANIFEST_FILENAME); + const pathState = inspectGlobalTrackedPath(rootDir, GLOBAL_MANIFEST_FILENAME, containmentRoot); + if (pathState.status !== 'safe') { + return { path: manifestPath, status: pathState.status, manifest: null }; + } let stat; try { stat = lstatSync(manifestPath); - } catch { - return { path: manifestPath, status: 'missing', manifest: null }; + } catch (error) { + return { + path: manifestPath, + status: error?.code === 'ENOENT' ? 'missing' : 'unreadable', + manifest: null, + }; } if (stat.isSymbolicLink()) return { path: manifestPath, status: 'linked', manifest: null }; if (!stat.isFile()) return { path: manifestPath, status: 'collision', manifest: null }; - const manifest = readGlobalManifest(rootDir); + let raw; + try { + raw = readFileSync(manifestPath, 'utf-8'); + } catch { + return { path: manifestPath, status: 'unreadable', manifest: null }; + } + let manifest; + try { + manifest = JSON.parse(raw); + } catch { + return { path: manifestPath, status: 'corrupt', manifest: null }; + } return manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? { path: manifestPath, status: 'valid', manifest } : { path: manifestPath, status: 'corrupt', manifest: null }; } -export function writeGlobalManifest(rootDir, manifest) { +export function writeGlobalManifest(rootDir, manifest, containmentRoot = rootDir) { + const before = inspectGlobalTrackedPath(rootDir, GLOBAL_MANIFEST_FILENAME, containmentRoot); + if (before.status !== 'safe') { + throw new Error(`Refusing global manifest write: ${before.message || before.status}.`); + } mkdirSync(rootDir, { recursive: true }); + const after = inspectGlobalTrackedPath(rootDir, GLOBAL_MANIFEST_FILENAME, containmentRoot); + if (after.status !== 'safe') { + throw new Error(`Refusing global manifest write: ${after.message || after.status}.`); + } writeFileSync(join(rootDir, GLOBAL_MANIFEST_FILENAME), JSON.stringify(manifest, null, 2)); } @@ -61,12 +153,22 @@ export function writeManifestTrackedFile({ nextFiles, dryRun = false, strictOwnership = false, + containmentRoot = rootDir, }) { const absolutePath = join(rootDir, relativePath); const normalizedRelativePath = relativePath.replace(/\\/g, '/'); const expectedHash = sha256(content); const previousHash = previousManifest?.files?.[normalizedRelativePath] || null; + const pathState = inspectGlobalTrackedPath(rootDir, normalizedRelativePath, containmentRoot); + if (pathState.status !== 'safe') { + return { + relativePath: normalizedRelativePath, + status: `skipped_${pathState.status}`, + message: pathState.message || 'target path is unsafe', + }; + } + let stat; try { stat = lstatSync(absolutePath); @@ -123,9 +225,26 @@ export function writeManifestTrackedFile({ } } + if (!stat && strictOwnership && !previousHash) { + return { + relativePath: normalizedRelativePath, + status: 'skipped_unmanaged', + message: 'missing target is unowned (not tracked by Workspine manifest)', + }; + } + nextFiles[normalizedRelativePath] = expectedHash; if (!dryRun) { mkdirSync(dirname(absolutePath), { recursive: true }); + const writePathState = inspectGlobalTrackedPath(rootDir, normalizedRelativePath, containmentRoot); + if (writePathState.status !== 'safe') { + delete nextFiles[normalizedRelativePath]; + return { + relativePath: normalizedRelativePath, + status: `skipped_${writePathState.status}`, + message: writePathState.message || 'target path became unsafe before write', + }; + } writeFileSync(absolutePath, content); } return { relativePath: normalizedRelativePath, status: dryRun ? 'would_write' : 'written' }; @@ -136,6 +255,7 @@ export function pruneStaleManifestTrackedFiles({ previousManifest, nextFiles, dryRun = false, + containmentRoot = rootDir, }) { if (!previousManifest?.files) return []; @@ -155,6 +275,16 @@ export function pruneStaleManifestTrackedFiles({ continue; } + const pathState = inspectGlobalTrackedPath(rootDir, normalizedRelativePath, containmentRoot); + if (pathState.status !== 'safe') { + results.push({ + relativePath: normalizedRelativePath, + status: `skipped_${pathState.status}`, + message: pathState.message || 'previous manifest path is unsafe', + }); + continue; + } + let stat; try { stat = lstatSync(absolutePath); @@ -196,7 +326,18 @@ export function pruneStaleManifestTrackedFiles({ continue; } - if (!dryRun) rmSync(absolutePath, { force: true }); + if (!dryRun) { + const removePathState = inspectGlobalTrackedPath(rootDir, normalizedRelativePath, containmentRoot); + if (removePathState.status !== 'safe') { + results.push({ + relativePath: normalizedRelativePath, + status: `skipped_${removePathState.status}`, + message: removePathState.message || 'stale target path became unsafe before removal', + }); + continue; + } + rmSync(absolutePath, { force: true }); + } results.push({ relativePath: normalizedRelativePath, status: dryRun ? 'would_remove' : 'removed_stale' }); } diff --git a/bin/lib/health.mjs b/bin/lib/health.mjs index 61ad32cc..82e748b4 100644 --- a/bin/lib/health.mjs +++ b/bin/lib/health.mjs @@ -9,13 +9,21 @@ import { readManifest, detectModifications } from './manifest.mjs'; import { output } from './cli-utils.mjs'; import { runTruthChecks, TRUTH_CHECK_IDS } from './health-truth.mjs'; import { evaluateLifecycleState } from './lifecycle-state.mjs'; -import { evaluateRuntimeFreshness } from './runtime-freshness.mjs'; -import { evaluateGlobalRuntimeFreshness } from './runtime-freshness.mjs'; +import { + evaluateGlobalRuntimeFreshness, + evaluateRuntimeFreshness, + getGlobalRuntimeRepairGuidance, + getRuntimeFreshnessRepairGuidance, +} from './runtime-freshness.mjs'; import { collectGlobalInstallSpecs, - getManifestOwnedGlobalTargets, + getGlobalHealthTargets, resolveGlobalInstallRoots, } from './global-install.mjs'; +import { + preflightLocalInitRepair, + preflightLocalUpdateRepair, +} from './init-flow.mjs'; import { resolveWorkspaceContext } from './workspace-root.mjs'; import { stateAuthorityGate } from './state-dir.mjs'; import { WORKFLOW_ID_PREFIX } from './workflows.mjs'; @@ -35,7 +43,7 @@ export function buildGlobalHealthReport(ctx, healthArgs = []) { return { status: 'broken', errors: [{ id: 'G1', severity: 'ERROR', message, fix: 'Remove --workspace-root from global health.' }], warnings: [], info: [], humanMessage: message }; } const roots = resolveGlobalInstallRoots(ctx.globalInstallRootOptions); - const targets = getManifestOwnedGlobalTargets({ roots }); + const targets = getGlobalHealthTargets({ roots, ctx }); if (targets.length === 0) { const message = 'No manifest-owned global install targets found. Run `npx -y workspine install --global --tools ` first.'; return { status: 'broken', errors: [{ id: 'G1', severity: 'ERROR', message, fix: message }], warnings: [], info: [], humanMessage: message }; @@ -50,14 +58,14 @@ export function buildGlobalHealthReport(ctx, healthArgs = []) { return true; }); const freshness = evaluateGlobalRuntimeFreshness({ specs }); - const severeStatuses = new Set(['missing', 'linked', 'collision', 'unreadable', 'corrupt']); + const severeStatuses = new Set(['missing', 'linked', 'collision', 'unreadable', 'unsafe', 'corrupt', 'foreign', 'manifest-missing', 'ownership-missing']); const errors = freshness.issues .filter((issue) => severeStatuses.has(issue.status)) .map((issue, index) => ({ id: `G${index + 2}`, severity: 'ERROR', message: `${issue.runtime}: ${issue.relativePath} is ${issue.status}`, - fix: issue.repairCommand, + fix: getGlobalRuntimeRepairGuidance(issue, freshness), })); const warnings = freshness.issues .filter((issue) => !severeStatuses.has(issue.status)) @@ -65,7 +73,7 @@ export function buildGlobalHealthReport(ctx, healthArgs = []) { id: `GW${index + 1}`, severity: 'WARN', message: `${issue.runtime}: ${issue.relativePath} is ${issue.status}`, - fix: issue.repairCommand, + fix: getGlobalRuntimeRepairGuidance(issue, freshness), })); const info = targets.map((target) => ({ id: 'GI1', @@ -138,10 +146,20 @@ export function buildHealthReport(ctx, healthArgs = []) { const requiredFields = ['researchDepth', 'modelProfile', 'initVersion']; const missing = requiredFields.filter((f) => !(f in config)); if (missing.length > 0) { - errors.push({ id: 'E2', severity: 'ERROR', message: `config.json missing required fields: ${missing.join(', ')}`, fix: 'Run `npx -y workspine init` to regenerate' }); + errors.push({ + id: 'E2', + severity: 'ERROR', + message: `config.json missing required fields: ${missing.join(', ')}`, + fix: `Repair or restore ${statePath(stateDirName, 'config.json')} manually from a trusted config, then rerun health. Init preserves an existing config and will not regenerate these fields.`, + }); } } catch { - errors.push({ id: 'E1', severity: 'ERROR', message: `${statePath(stateDirName, 'config.json')} is unparseable`, fix: 'Run `npx -y workspine init`' }); + errors.push({ + id: 'E1', + severity: 'ERROR', + message: `${statePath(stateDirName, 'config.json')} is unparseable`, + fix: `Repair or restore ${statePath(stateDirName, 'config.json')} manually from a trusted copy, then rerun health. Init refuses an invalid existing config rather than replacing it.`, + }); } // E3: templates/ missing @@ -156,47 +174,47 @@ export function buildHealthReport(ctx, healthArgs = []) { const skipInstalledTemplateChecks = !hasTemplatesDir && frameworkSourceMode; if (!hasTemplatesDir && !skipInstalledTemplateChecks) { - errors.push({ id: 'E3', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E3', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/')} missing`, fix: 'Run `npx -y workspine update`' }); } else if (hasTemplatesDir) { // E4: roles/ missing or empty if (!hasRolesDir) { - errors.push({ id: 'E4', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/roles/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E4', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/roles/')} missing`, fix: 'Run `npx -y workspine update`' }); } else { const roleFiles = readdirSync(rolesDir).filter((f) => f.endsWith('.md')); if (roleFiles.length === 0) { - errors.push({ id: 'E4', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/roles/')} has 0 role files`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E4', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/roles/')} has 0 role files`, fix: 'Run `npx -y workspine update`' }); } } // E5: delegates/ missing or empty if (!hasDelegatesDir) { - errors.push({ id: 'E5', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/delegates/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E5', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/delegates/')} missing`, fix: 'Run `npx -y workspine update`' }); } else { const delegateFiles = readdirSync(delegatesDir).filter((f) => f.endsWith('.md')); if (delegateFiles.length === 0) { - errors.push({ id: 'E5', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/delegates/')} has 0 delegate files`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E5', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/delegates/')} has 0 delegate files`, fix: 'Run `npx -y workspine update`' }); } } // E6: research/ missing or empty const researchDir = join(templatesDir, 'research'); if (!existsSync(researchDir)) { - errors.push({ id: 'E6', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/research/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E6', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/research/')} missing`, fix: 'Run `npx -y workspine update`' }); } else { const researchFiles = readdirSync(researchDir).filter((f) => f.endsWith('.md')); if (researchFiles.length === 0) { - errors.push({ id: 'E6', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/research/')} has 0 template files`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E6', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/research/')} has 0 template files`, fix: 'Run `npx -y workspine update`' }); } } // E7: codebase/ missing or empty const codebaseDir = join(templatesDir, 'codebase'); if (!existsSync(codebaseDir)) { - errors.push({ id: 'E7', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/codebase/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E7', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/codebase/')} missing`, fix: 'Run `npx -y workspine update`' }); } else { const codebaseFiles = readdirSync(codebaseDir).filter((f) => f.endsWith('.md')); if (codebaseFiles.length === 0) { - errors.push({ id: 'E7', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/codebase/')} has 0 template files`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E7', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/codebase/')} has 0 template files`, fix: 'Run `npx -y workspine update`' }); } } @@ -204,16 +222,16 @@ export function buildHealthReport(ctx, healthArgs = []) { const requiredRootFiles = ['spec.md', 'roadmap.md', 'auth-matrix.md', 'ui-proof.md']; const missingRoot = requiredRootFiles.filter((f) => !existsSync(join(templatesDir, f))); if (missingRoot.length > 0) { - errors.push({ id: 'E8', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/')} missing critical root files: ${missingRoot.join(', ')}`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E8', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/')} missing critical root files: ${missingRoot.join(', ')}`, fix: 'Run `npx -y workspine update`' }); } const brownfieldChangeDir = join(templatesDir, 'brownfield-change'); if (!existsSync(brownfieldChangeDir)) { - errors.push({ id: 'E9', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/brownfield-change/')} missing`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E9', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/brownfield-change/')} missing`, fix: 'Run `npx -y workspine update`' }); } else { const missingBrownfield = ['CHANGE.md', 'HANDOFF.md', 'VERIFICATION.md'].filter((file) => !existsSync(join(brownfieldChangeDir, file))); if (missingBrownfield.length > 0) { - errors.push({ id: 'E9', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/brownfield-change/')} missing critical files: ${missingBrownfield.join(', ')}`, fix: 'Run `npx -y workspine update --templates`' }); + errors.push({ id: 'E9', severity: 'ERROR', message: `${statePath(stateDirName, 'templates/brownfield-change/')} missing critical files: ${missingBrownfield.join(', ')}`, fix: 'Run `npx -y workspine update`' }); } } } @@ -223,18 +241,23 @@ export function buildHealthReport(ctx, healthArgs = []) { // W1: generation-manifest.json missing const manifest = skipInstalledTemplateChecks ? null : readManifest(planningDir); if (!manifest && !skipInstalledTemplateChecks) { - warnings.push({ id: 'W1', severity: 'WARN', message: 'generation-manifest.json missing', fix: 'Run `npx -y workspine update` to create' }); + warnings.push({ + id: 'W1', + severity: 'WARN', + message: 'generation-manifest.json missing', + fix: `Restore ${statePath(stateDirName, 'generation-manifest.json')} from a trusted backup that matches the generated surfaces. If no valid ownership record exists, preserve the existing generated files and initialize a clean workspace; update cannot recreate ownership safely.`, + }); } // W2 + W3: template/role hash mismatches and missing files if (manifest && hasTemplatesDir) { const allCategories = [ - { name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null, fixCommand: 'npx -y workspine update --templates' }, - { name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research, fixCommand: 'npx -y workspine update --templates' }, - { name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase, fixCommand: 'npx -y workspine update --templates' }, - { name: 'brownfield-change', dir: join(templatesDir, 'brownfield-change'), hashes: manifest.templates?.brownfieldChange, fixCommand: 'npx -y workspine update --templates' }, - { name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root, fixCommand: 'npx -y workspine update --templates' }, - { name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null, fixCommand: 'npx -y workspine update --templates' }, + { name: 'delegates', dir: delegatesDir, hashes: hasDelegatesDir ? manifest.templates?.delegates : null, fixCommand: 'npx -y workspine update' }, + { name: 'research', dir: join(templatesDir, 'research'), hashes: manifest.templates?.research, fixCommand: 'npx -y workspine update' }, + { name: 'codebase', dir: join(templatesDir, 'codebase'), hashes: manifest.templates?.codebase, fixCommand: 'npx -y workspine update' }, + { name: 'brownfield-change', dir: join(templatesDir, 'brownfield-change'), hashes: manifest.templates?.brownfieldChange, fixCommand: 'npx -y workspine update' }, + { name: 'root templates', dir: templatesDir, hashes: manifest.templates?.root, fixCommand: 'npx -y workspine update' }, + { name: 'roles', dir: rolesDir, hashes: hasRolesDir ? manifest.roles : null, fixCommand: 'npx -y workspine update' }, { name: 'runtime helpers', dir: planningDir, hashes: hasRuntimeHelpersDir ? manifest.runtimeHelpers : null, fixCommand: 'npx -y workspine update' }, ]; @@ -280,14 +303,27 @@ export function buildHealthReport(ctx, healthArgs = []) { } } - // W6: No generated workflow adapter surfaces detected - if (!hasAnyGeneratedWorkflowSurface(cwd)) { - warnings.push({ id: 'W6', severity: 'WARN', message: 'No generated workflow adapter surfaces detected', fix: 'Run `npx -y workspine init --tools `' }); - } - - const runtimeFreshnessReport = configOk && Array.isArray(ctx.workflows) + let runtimeFreshnessReport = configOk && Array.isArray(ctx.workflows) ? evaluateRuntimeFreshness({ cwd, workflows: ctx.workflows }) : null; + const repairCtx = { ...ctx, cwd }; + if (runtimeFreshnessReport?.issueCount > 0) { + runtimeFreshnessReport = preflightLocalRuntimeRepairGuidance(repairCtx, runtimeFreshnessReport); + } + + // W6: No generated workflow adapter surfaces detected. When a manifest + // still proves selected generated surfaces, use the same preflighted W11 + // repair sequence rather than contradicting it with a generic init hint. + if (!hasAnyGeneratedWorkflowSurface(cwd)) { + warnings.push({ + id: 'W6', + severity: 'WARN', + message: 'No generated workflow adapter surfaces detected', + fix: runtimeFreshnessReport?.issueCount > 0 + ? getRuntimeFreshnessRepairGuidance(runtimeFreshnessReport) + : 'Run `npx -y workspine init --tools `', + }); + } warnings.push(...runTruthChecks(planningDir, cwd, healthCheckIds, { runtimeFreshnessReport, stateDirName }).map((warning) => { if (warning.id !== 'W10') return warning; @@ -305,7 +341,7 @@ export function buildHealthReport(ctx, healthArgs = []) { // I1: generation manifest was produced by a different framework version if (manifest && manifest.frameworkVersion && manifest.frameworkVersion !== ctx.frameworkVersion) { - info.push({ id: 'I1', severity: 'INFO', message: `Generation manifest frameworkVersion (${manifest.frameworkVersion}) differs from current framework version (${ctx.frameworkVersion})`, fix: 'Run `npx -y workspine update --templates`' }); + info.push({ id: 'I1', severity: 'INFO', message: `Generation manifest frameworkVersion (${manifest.frameworkVersion}) differs from current framework version (${ctx.frameworkVersion})`, fix: 'Run `npx -y workspine update`' }); } // I2: Phase completion count @@ -328,6 +364,12 @@ export function buildHealthReport(ctx, healthArgs = []) { info.push({ id: 'I3', severity: 'INFO', message: `Installed runtime/governance surfaces: ${installedSurfaces.join(', ')}` }); } + reconcileRepositoryUpdateGuidance({ + ctx: repairCtx, + runtimeFreshnessReport, + entries: [...errors, ...warnings, ...info], + }); + // --- Verdict --- const hasErrors = errors.length > 0; const hasWarnings = warnings.length > 0; @@ -335,6 +377,63 @@ export function buildHealthReport(ctx, healthArgs = []) { return { status, errors, warnings, info }; } + +function reconcileRepositoryUpdateGuidance({ ctx, runtimeFreshnessReport, entries }) { + const updateEntries = entries.filter((entry) => + typeof entry.fix === 'string' && entry.fix.includes('npx -y workspine update')); + if (updateEntries.length === 0) return; + + const preflight = preflightLocalUpdateRepair(ctx); + if (!preflight.ok) { + const manual = `Resolve repository generated-surface safety/ownership manually first. Automatic update preflight refused: ${preflight.reason}. Preserve existing bytes, rerun health, and retry update only after the blocker is cleared.`; + for (const entry of updateEntries) entry.fix = manual; + return; + } + + if (runtimeFreshnessReport?.issueCount > 0) { + const sequence = getRuntimeFreshnessRepairGuidance(runtimeFreshnessReport); + for (const entry of updateEntries) entry.fix = sequence; + } +} + +function preflightLocalRuntimeRepairGuidance(ctx, report) { + const commandResults = new Map(); + const commands = [...new Set(report.issues.map((issue) => issue.repairCommand).filter(Boolean))]; + for (const command of commands) { + const initMatch = command.match(/\bworkspine init --tools ([a-z0-9_-]+)\b/); + commandResults.set(command, initMatch + ? preflightLocalInitRepair(ctx, initMatch[1]) + : command === 'npx -y workspine update' + ? preflightLocalUpdateRepair(ctx) + : { ok: true, reason: null }); + } + + const blockedInit = [...commandResults.entries()] + .find(([command, result]) => /\bworkspine init --tools /.test(command) && !result.ok); + const annotatedIssues = report.issues.map((issue) => { + if (!issue.repairCommand) return issue; + let result = commandResults.get(issue.repairCommand) ?? { ok: true, reason: null }; + // Plain update is downstream of init in mixed W11 repair chains. If init + // itself cannot start, do not advertise a later update that cannot yet make + // progress either. + if (issue.repairCommand === 'npx -y workspine update' && blockedInit) { + result = { + ok: false, + reason: `prerequisite ${blockedInit[0]} refused: ${blockedInit[1].reason}`, + }; + } + return result.ok + ? issue + : { + ...issue, + repairCommand: null, + retryCommand: null, + manual: true, + repairBlockReason: result.reason, + }; + }); + return { ...report, issues: annotatedIssues }; +} /** * Factory function returning the health command. * ctx should provide: { frameworkVersion, workflows } diff --git a/bin/lib/init-flow.mjs b/bin/lib/init-flow.mjs index 3b04ba1a..e1264423 100644 --- a/bin/lib/init-flow.mjs +++ b/bin/lib/init-flow.mjs @@ -41,7 +41,7 @@ import { ensureWorkStructure } from './work-context.mjs'; import { workflowId } from './workflows.mjs'; import { collectGlobalInstallSpecs, - getManifestOwnedGlobalTargets, + getGlobalHealthTargets, resolveGlobalInstallRoots, } from './global-install.mjs'; import { evaluateGlobalRuntimeFreshness } from './runtime-freshness.mjs'; @@ -53,12 +53,14 @@ function printRepoUpdateBoundary(ctx, { failed = false } = {}) { try { const roots = resolveGlobalInstallRoots(ctx.globalInstallRootOptions); - const targets = getManifestOwnedGlobalTargets({ roots }); + const targets = getGlobalHealthTargets({ roots, ctx }); if (targets.length === 0) return; const specs = targets.flatMap((target) => collectGlobalInstallSpecs({ target, roots, ctx })); const freshness = evaluateGlobalRuntimeFreshness({ specs }); if (freshness.issueCount > 0) { - console.log('Global agent surfaces also need attention. Run `npx -y workspine update --global`.'); + console.log(freshness.selectedSetBlocked + ? 'Global agent surfaces also need manual attention. Run `npx -y workspine health --global`, resolve the reported blocker(s), then retry global repair.' + : 'Global agent surfaces also need attention. Run `npx -y workspine update --global`.'); } } catch { // Global inspection is advisory and read-only. It must never hide or @@ -119,6 +121,106 @@ function validateKindContract(adapter, cwd) { } } +function preflightInitState(ctx, { isAuto, preselectedConfig = null }) { + const { planningDir, stateDirName } = ctx; + assertSafeGitignoreTarget(ctx.cwd); + validateTemplateSources(ctx); + const hasGeneratedTemplateState = existsSync(join(planningDir, 'templates')) + || existsSync(join(planningDir, 'generation-manifest.json')); + const templatePlan = existsSync(planningDir) && hasGeneratedTemplateState + ? planTemplateRefresh(ctx) + : null; + const selectedConfig = readSelectedConfig({ planningDir, isAuto, preselectedConfig }); + preflightCommitDocsOwnership(ctx.cwd, stateDirName, selectedConfig); + return { templatePlan, selectedConfig }; +} + +/** + * Read-only parity check for the repair command health would advertise for a + * missing native runtime target. This deliberately follows the same init + * ownership/template/config preflight without applying recovery or writing + * generated bytes. + */ +export function preflightLocalInitRepair(ctx, runtime) { + try { + const state = resolveStateDir(ctx.cwd); + const gate = stateAuthorityGate(state); + if (!gate.allowed) throw new Error(gate.message); + const initCtx = contextAtWorkspaceRoot(ctx, ctx.cwd); + preflightInitState(initCtx, { isAuto: false }); + const targets = getLocalAdapterTargets(initCtx.adapters, initCtx.workflows, [runtime]); + planAdapterGeneration({ + cwd: initCtx.cwd, + planningDir: initCtx.planningDir, + targets, + manifest: readManifest(initCtx.planningDir), + stateDirName: initCtx.stateDirName, + }); + preflightPlanningCliHelpersReadOnly(initCtx); + return { ok: true, reason: null }; + } catch (error) { + return { ok: false, reason: String(error?.message || error) }; + } +} + +/** + * Read-only structural preflight for repository update repair guidance. Missing + * native targets are allowed here because health orders their init repair + * before plain update; every other ownership/provenance/template blocker is + * checked against the same update planner used by the mutator. + */ +export function preflightLocalUpdateRepair(ctx) { + try { + const state = resolveStateDir(ctx.cwd); + const gate = stateAuthorityGate(state); + if (!gate.allowed) throw new Error(gate.message); + const updateCtx = contextAtWorkspaceRoot(ctx, ctx.cwd); + const { planningDir, stateDirName } = updateCtx; + const existingManifest = readManifest(planningDir); + let effectiveManifest = existingManifest; + let replacementHashes = null; + const historicalBridge = bridgeHistoricalAdapterOwnership({ + cwd: updateCtx.cwd, + manifest: existingManifest, + stateDirName, + }); + if (historicalBridge) { + effectiveManifest = historicalBridge.manifest; + replacementHashes = historicalBridge.replacementHashes; + } + const manifestPlatforms = Array.isArray(effectiveManifest?.adapterSelection) + ? effectiveManifest.adapterSelection.filter((name) => typeof name === 'string') + : [...new Set(Object.values(effectiveManifest?.adapterFiles ?? {}) + .map((entry) => entry && typeof entry === 'object' ? entry.adapter : null) + .filter((name) => typeof name === 'string'))]; + const platforms = manifestPlatforms.length > 0 ? manifestPlatforms : detectPlatforms(updateCtx.adapters); + const adaptersToUpdate = resolveAdapters(updateCtx.adapters, platforms); + const writerPlatforms = [...new Set([...platforms, ...adaptersToUpdate.map((adapter) => adapter.name)])]; + const targets = getLocalAdapterTargets(updateCtx.adapters, updateCtx.workflows, writerPlatforms); + + if (existsSync(planningDir)) { + validateTemplateOwnership(planningDir); + planTemplateRefresh({ ...updateCtx, isDry: true }); + } + planAdapterGeneration({ + cwd: updateCtx.cwd, + planningDir, + targets, + manifest: effectiveManifest, + stateDirName, + requireManifest: existsSync(planningDir), + // A preceding init repair may be the step that restores these. Keep the + // structural/provenance checks exact without rejecting that valid chain. + requireExistingNativeTargets: false, + replacementHashes, + }); + preflightPlanningCliHelpersReadOnly(updateCtx); + return { ok: true, reason: null }; + } catch (error) { + return { ok: false, reason: String(error?.message || error) }; + } +} + export function createCmdInit(ctx) { return async function cmdInit(...initArgs) { // A15-44: fail before any write when a flag is unknown, duplicated, or missing its value. @@ -175,6 +277,7 @@ export function createCmdInit(ctx) { let state = resolveStateDir(initCtx.cwd); const promptApi = ctx.initPromptApi || createInitPromptApi(); + let interactiveSession; if (state.status === 'legacy_migratable') { let approved = wantsMigration; @@ -186,7 +289,32 @@ export function createCmdInit(ctx) { process.exitCode = 1; return; } + interactiveSession = await resolveInteractiveInitSession({ + ctx: initCtx, + promptApi, + parsedTools, + isAuto, + }); try { + // Validate the existing bytes before moving them. Keep stateDirName at + // .work so generated content and tracking policy use the destination. + // Init revalidates after the rename before applying the refresh plan. + preflightInitState( + { ...initCtx, planningDir: state.legacyDir }, + { isAuto, preselectedConfig: interactiveSession.config }, + ); + const migrationAdapterTargets = getLocalAdapterTargets( + initCtx.adapters, + initCtx.workflows, + interactiveSession.adapterTargets, + ); + planAdapterGeneration({ + cwd: initCtx.cwd, + planningDir: state.legacyDir, + targets: migrationAdapterTargets, + manifest: readManifest(state.legacyDir), + stateDirName: initCtx.stateDirName, + }); migrateLegacyState(initCtx.cwd); } catch (error) { console.error(`ERROR: Legacy state migration failed: ${error.message}`); @@ -210,7 +338,7 @@ export function createCmdInit(ctx) { } } - const interactiveSession = await resolveInteractiveInitSession({ + interactiveSession ??= await resolveInteractiveInitSession({ ctx: initCtx, promptApi, parsedTools, @@ -224,18 +352,10 @@ export function createCmdInit(ctx) { let templatePlan; let selectedConfig; try { - validateTemplateSources(initCtx); - const hasGeneratedTemplateState = existsSync(join(planningDir, 'templates')) - || existsSync(join(planningDir, 'generation-manifest.json')); - templatePlan = existed && hasGeneratedTemplateState - ? planTemplateRefresh({ ...initCtx, planningDir, stateDirName }) - : null; - selectedConfig = readSelectedConfig({ - planningDir, + ({ templatePlan, selectedConfig } = preflightInitState(initCtx, { isAuto, preselectedConfig: interactiveSession.config, - }); - preflightCommitDocsOwnership(initCtx.cwd, stateDirName, selectedConfig); + })); } catch (error) { console.error(`ERROR: ${error.message}`); process.exitCode = 1; @@ -255,8 +375,12 @@ export function createCmdInit(ctx) { manifest: readManifest(planningDir), stateDirName, }); + preflightPlanningCliHelpersReadOnly(initCtx); applyAdapterRecovery(adapterPlan); } catch (error) { + if (String(error?.message || error).startsWith('Refusing to write generated runtime helper')) { + throw error; + } console.error(`ERROR: ${error.message}`); process.exitCode = 1; return; @@ -417,9 +541,13 @@ export function createCmdUpdate(ctx) { const templatePlan = doTemplates && existsSync(planningDir) ? planTemplateRefresh({ ...ctx, isDry }) : null; + preflightPlanningCliHelpersReadOnly(ctx); if (!isDry) applyAdapterRecovery(adapterPlan); if (templatePlan) templateOwnership = applyTemplateRefresh(templatePlan, { isDry }); } catch (error) { + if (String(error?.message || error).startsWith('Refusing to write generated runtime helper')) { + throw error; + } console.error(`ERROR: ${error.message}`); if (!isDry) printRepoUpdateBoundary(ctx, { failed: true }); process.exitCode = 1; @@ -584,6 +712,52 @@ function managedRuntimeContext(planningDir) { }; } +function preflightPlanningCliHelpersReadOnly({ packageName, packageVersion, planningDir, stateDirName = '.work' }) { + if (!existsSync(planningDir)) return; + const entries = buildPlanningCliHelperEntries({ packageName, packageVersion, stateDirName }); + const runtimeContext = managedRuntimeContext(planningDir); + if (!runtimeContext.realRuntimeRoot) return; + + for (const entry of entries) { + const absolutePath = resolve(planningDir, entry.relativePath); + if (!pathIsStrictlyInside(runtimeContext.runtimeRoot, absolutePath)) { + refuseGeneratedRuntimeHelperWrite(entry.relativePath, 'target must remain inside bin/'); + } + const parentRelative = relative(runtimeContext.runtimeRoot, dirname(absolutePath)); + const parentParts = parentRelative === '' ? [] : parentRelative.split(sep); + let currentPath = runtimeContext.runtimeRoot; + let missingParent = false; + for (const part of parentParts) { + currentPath = join(currentPath, part); + let stat; + try { + stat = lstatSync(currentPath); + } catch (error) { + if (error?.code === 'ENOENT') { + missingParent = true; + break; + } + refuseGeneratedRuntimeHelperWrite(entry.relativePath, 'parent could not be inspected safely'); + } + if (stat.isSymbolicLink() || !stat.isDirectory()) { + refuseGeneratedRuntimeHelperWrite(entry.relativePath, 'parent must be a real directory inside bin/'); + } + let realParent; + try { + realParent = realpathSync(currentPath); + } catch { + refuseGeneratedRuntimeHelperWrite(entry.relativePath, 'parent could not be resolved safely'); + } + if (realParent !== runtimeContext.realRuntimeRoot + && !pathIsStrictlyInside(runtimeContext.realRuntimeRoot, realParent)) { + refuseGeneratedRuntimeHelperWrite(entry.relativePath, 'parent resolves outside bin/'); + } + } + if (missingParent) continue; + assertSafeGeneratedRuntimeHelperTarget(runtimeContext, absolutePath, entry.relativePath, false); + } +} + function directoryIdentity(stat) { return { dev: stat.dev, ino: stat.ino }; } @@ -961,7 +1135,36 @@ function preflightCommitDocsOwnership(cwd, stateDirName, config) { } } +function assertSafeGitignoreTarget(cwd) { + const workspaceRoot = resolve(cwd); + const gitignorePath = resolve(workspaceRoot, '.gitignore'); + let stat; + try { + stat = lstatSync(gitignorePath); + } catch (error) { + if (error?.code === 'ENOENT') return; + throw new Error('Refusing init: .gitignore could not be inspected safely.'); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error('Refusing init: .gitignore must be a regular file inside the workspace root.'); + } + let realRoot; + let realTarget; + try { + realRoot = realpathSync(workspaceRoot); + realTarget = realpathSync(gitignorePath); + } catch { + throw new Error('Refusing init: .gitignore could not be resolved safely.'); + } + if (!pathIsStrictlyInside(realRoot, realTarget)) { + throw new Error('Refusing init: .gitignore resolves outside the workspace root.'); + } +} + function ensureGitignoreEntry(cwd, entry, message) { + // Revalidate immediately before every write. The earlier init preflight is + // intentionally not trusted across the mutation boundary. + assertSafeGitignoreTarget(cwd); const gitignorePath = join(cwd, '.gitignore'); const hasGitignore = existsSync(gitignorePath); const current = hasGitignore ? readFileSync(gitignorePath, 'utf-8') : ''; diff --git a/bin/lib/manifest.mjs b/bin/lib/manifest.mjs index 9a42f8ff..039cfd5c 100644 --- a/bin/lib/manifest.mjs +++ b/bin/lib/manifest.mjs @@ -251,8 +251,16 @@ function assertSafeAdapterTarget(workspaceRoot, absolutePath, label) { const parentParts = relative(root, dirname(target)).split(sep).filter(Boolean); for (const part of parentParts) { current = join(current, part); - if (!existsSync(current)) continue; - const stat = lstatSync(current); + let stat; + try { + // lstat must be used directly here: existsSync() follows links and + // reports dangling parent links as absent, which would let init/update + // advertise a repair that the adapter writer cannot complete safely. + stat = lstatSync(current); + } catch (error) { + if (error?.code === 'ENOENT') continue; + throw new Error(`Refusing adapter update: ${label} parent could not be inspected safely.`); + } if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Refusing adapter update: ${label} parent must be a real directory.`); if (!pathIsInside(realpathSync(root), realpathSync(current))) throw new Error(`Refusing adapter update: ${label} parent resolves outside the workspace root.`); } diff --git a/bin/lib/runtime-freshness.mjs b/bin/lib/runtime-freshness.mjs index a2488cc9..0c8ec1ca 100644 --- a/bin/lib/runtime-freshness.mjs +++ b/bin/lib/runtime-freshness.mjs @@ -31,7 +31,13 @@ import { resolveRuntimeAgentModel, } from './config.mjs'; import { resolveStateDir } from './state-dir.mjs'; -import { fileHash, inspectGlobalManifest } from './global-manifest.mjs'; +import { bridgeHistoricalAdapterOwnership, readManifest } from './manifest.mjs'; +import { + fileHash, + inspectGlobalManifest, + inspectGlobalTrackedPath, + pruneStaleManifestTrackedFiles, +} from './global-manifest.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -41,14 +47,58 @@ function normalizeContent(content) { return String(content).replace(/\r\n/g, '\n'); } -function compareGeneratedFile({ cwd, runtime, relativePath, expectedContent, repairCommand }) { +function compareGeneratedFile({ + cwd, + runtime, + relativePath, + expectedContent, + owned, + repairCommand, + missingRepairCommand = repairCommand, +}) { const absolutePath = join(cwd, relativePath); - if (!existsSync(absolutePath)) { + let stat; + try { + stat = lstatSync(absolutePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return { + runtime, + relativePath, + status: owned ? 'missing' : 'unowned-missing', + repairCommand: missingRepairCommand, + retryCommand: missingRepairCommand, + owned, + }; + } return { runtime, relativePath, - status: 'missing', + status: 'unreadable', repairCommand, + retryCommand: repairCommand, + owned, + }; + } + if (stat.isSymbolicLink() || !stat.isFile()) { + return { + runtime, + relativePath, + status: 'collision', + repairCommand, + retryCommand: repairCommand, + owned, + }; + } + + if (!owned) { + return { + runtime, + relativePath, + status: 'unowned', + repairCommand: missingRepairCommand, + retryCommand: missingRepairCommand, + owned, }; } @@ -60,6 +110,7 @@ function compareGeneratedFile({ cwd, runtime, relativePath, expectedContent, rep relativePath, status: 'clean', repairCommand, + owned, }; } @@ -68,38 +119,143 @@ function compareGeneratedFile({ cwd, runtime, relativePath, expectedContent, rep relativePath, status: 'stale', repairCommand, + owned, }; } -function compareGlobalGeneratedFile({ rootDir, runtime, relativePath, expectedContent, manifest }) { +function normalizeRelativePath(relativePath) { + return String(relativePath).replace(/\\/g, '/'); +} + +function localTargetOwned(manifest, stateDirName, runtime, relativePath) { + if (!manifest) return false; + const normalized = normalizeRelativePath(relativePath); + if (runtime === 'workspace-helper') { + const prefix = `${stateDirName}/`; + const manifestRelative = normalized.startsWith(prefix) ? normalized.slice(prefix.length) : normalized; + const helpers = manifest.runtimeHelpers; + return Boolean(helpers && typeof helpers === 'object' && !Array.isArray(helpers) + && Object.hasOwn(helpers, manifestRelative)); + } + const adapterFiles = manifest.adapterFiles; + return Boolean(adapterFiles && typeof adapterFiles === 'object' && !Array.isArray(adapterFiles) + && Object.hasOwn(adapterFiles, normalized)); +} + +function compareGlobalGeneratedFile({ rootDir, containmentRoot, runtime, relativePath, expectedContent, manifest }) { const absolutePath = join(rootDir, relativePath); + const manifestHash = manifest?.files?.[relativePath]; + const pathState = inspectGlobalTrackedPath(rootDir, relativePath, containmentRoot); + if (pathState.status !== 'safe') { + return { + runtime, + relativePath, + status: pathState.status, + repairCommand: null, + blocker: true, + }; + } let stat; try { stat = lstatSync(absolutePath); } catch (error) { + if (error?.code === 'ENOENT' && !manifestHash) { + return { + runtime, + relativePath, + status: 'ownership-missing', + repairCommand: null, + blocker: true, + }; + } return { runtime, relativePath, status: error?.code === 'ENOENT' ? 'missing' : 'unreadable', - repairCommand: 'npx -y workspine update --global', + repairCommand: error?.code === 'ENOENT' ? 'npx -y workspine update --global' : null, + blocker: error?.code !== 'ENOENT', }; } if (stat.isSymbolicLink()) { - return { runtime, relativePath, status: 'linked', repairCommand: 'npx -y workspine update --global' }; + return { runtime, relativePath, status: 'linked', repairCommand: null, blocker: true }; } if (!stat.isFile()) { - return { runtime, relativePath, status: 'collision', repairCommand: 'npx -y workspine update --global' }; + return { runtime, relativePath, status: 'collision', repairCommand: null, blocker: true }; } - const manifestHash = manifest?.files?.[relativePath]; if (!manifestHash) { - return { runtime, relativePath, status: 'untracked', repairCommand: 'npx -y workspine update --global' }; + return { runtime, relativePath, status: 'untracked', repairCommand: null, blocker: true }; } - const actualHash = fileHash(absolutePath); - if (actualHash !== manifestHash || normalizeContent(readFileSync(absolutePath, 'utf-8')) !== normalizeContent(expectedContent)) { - return { runtime, relativePath, status: 'modified', repairCommand: 'npx -y workspine update --global' }; + let actualHash; + let actualContent; + try { + actualHash = fileHash(absolutePath); + actualContent = normalizeContent(readFileSync(absolutePath, 'utf-8')); + } catch { + return { runtime, relativePath, status: 'unreadable', repairCommand: null, blocker: true }; } - return { runtime, relativePath, status: 'clean', repairCommand: 'npx -y workspine update --global' }; + if (actualHash !== manifestHash) { + return { runtime, relativePath, status: 'modified', repairCommand: null, blocker: true }; + } + if (actualContent !== normalizeContent(expectedContent)) { + return { + runtime, + relativePath, + status: 'package-stale', + repairCommand: 'npx -y workspine update --global', + blocker: false, + }; + } + return { runtime, relativePath, status: 'clean', repairCommand: null, blocker: false }; +} + +function compareObsoleteGlobalManifestEntries({ rootDir, containmentRoot, runtime, entries, manifest }) { + const currentFiles = Object.fromEntries(entries.map((entry) => [ + normalizeRelativePath(entry.relativePath), + true, + ])); + return pruneStaleManifestTrackedFiles({ + rootDir, + previousManifest: manifest, + nextFiles: currentFiles, + dryRun: true, + containmentRoot, + }).map((result) => { + const base = { + runtime, + relativePath: result.relativePath, + obsolete: true, + }; + if (result.status === 'would_remove') { + return { + ...base, + status: 'obsolete', + repairCommand: 'npx -y workspine update --global', + blocker: false, + }; + } + if (result.status === 'removed_missing') { + return { + ...base, + status: 'obsolete-missing', + repairCommand: 'npx -y workspine update --global', + blocker: false, + }; + } + const blockedStatus = { + skipped_modified: 'modified', + skipped_linked: 'linked', + skipped_collision: 'collision', + skipped_unreadable: 'unreadable', + skipped_unsafe: 'unsafe', + }[result.status] || 'unsafe'; + return { + ...base, + status: blockedStatus, + repairCommand: null, + blocker: true, + }; + }); } /** @@ -108,8 +264,9 @@ function compareGlobalGeneratedFile({ rootDir, runtime, relativePath, expectedCo * repairs or rewrites a personal-agent home. */ export function evaluateGlobalRuntimeFreshness({ specs = [] } = {}) { - const groups = specs.map((spec) => { - const manifestState = inspectGlobalManifest(spec.rootDir); + const rawGroups = specs.map((spec) => { + const containmentRoot = spec.containmentRoot || spec.rootDir; + const manifestState = inspectGlobalManifest(spec.rootDir, containmentRoot); const manifestOwned = manifestState.status === 'valid' && manifestState.manifest.product === 'Workspine' && manifestState.manifest.runtime === spec.runtime @@ -117,18 +274,33 @@ export function evaluateGlobalRuntimeFreshness({ specs = [] } = {}) { && typeof manifestState.manifest.files === 'object' && !Array.isArray(manifestState.manifest.files); const comparisons = manifestOwned - ? spec.entries.map((entry) => compareGlobalGeneratedFile({ - rootDir: spec.rootDir, - runtime: spec.runtime, - relativePath: entry.relativePath, - expectedContent: entry.content, - manifest: manifestState.manifest, - })) + ? [ + ...spec.entries.map((entry) => compareGlobalGeneratedFile({ + rootDir: spec.rootDir, + containmentRoot, + runtime: spec.runtime, + relativePath: entry.relativePath, + expectedContent: entry.content, + manifest: manifestState.manifest, + })), + ...compareObsoleteGlobalManifestEntries({ + rootDir: spec.rootDir, + containmentRoot, + runtime: spec.runtime, + entries: spec.entries, + manifest: manifestState.manifest, + }), + ] : [{ runtime: spec.runtime, relativePath: 'workspine-file-manifest.json', - status: manifestState.status === 'valid' ? 'collision' : manifestState.status, - repairCommand: 'npx -y workspine update --global', + status: manifestState.status === 'valid' + ? 'foreign' + : manifestState.status === 'missing' + ? 'manifest-missing' + : manifestState.status, + repairCommand: null, + blocker: true, }]; return { runtime: spec.runtime, @@ -138,16 +310,53 @@ export function evaluateGlobalRuntimeFreshness({ specs = [] } = {}) { issueCount: comparisons.filter((entry) => entry.status !== 'clean').length, }; }); + const rawIssues = rawGroups.flatMap((group) => group.comparisons.filter((entry) => entry.status !== 'clean')); + const selectedSetBlocked = rawIssues.some((entry) => entry.blocker === true); + const groups = selectedSetBlocked + ? rawGroups.map((group) => ({ + ...group, + comparisons: group.comparisons.map((entry) => entry.status === 'clean' + ? entry + : { ...entry, repairCommand: null }), + })) + : rawGroups; const issues = groups.flatMap((group) => group.comparisons.filter((entry) => entry.status !== 'clean')); return { groups, issues, issueCount: issues.length, - staleCount: issues.filter((entry) => entry.status === 'modified').length, + staleCount: issues.filter((entry) => entry.status === 'package-stale').length, missingCount: issues.filter((entry) => entry.status === 'missing').length, + blockerCount: issues.filter((entry) => entry.blocker === true).length, + selectedSetBlocked, }; } +export function getGlobalRuntimeRepairGuidance(issue, report) { + const pathLabel = `${issue.runtime}: ${issue.relativePath}`; + if (issue.blocker === true) { + if (issue.status === 'modified' || issue.status === 'untracked') { + return `Manual resolution required for ${pathLabel}. Preserve the existing file; move the customization aside or restore trusted manifest-owned bytes, then rerun \`npx -y workspine health --global\`. Do not adopt or overwrite it automatically.`; + } + if (issue.status === 'linked' || issue.status === 'collision') { + return `Manual resolution required for ${pathLabel}. Preserve the existing path, then move or rename the linked/colliding entry and rerun \`npx -y workspine health --global\`.`; + } + if (issue.status === 'unreadable') { + return `Manual resolution required for ${pathLabel}. Fix filesystem access so Workspine can inspect it safely, then rerun \`npx -y workspine health --global\`.`; + } + if (['corrupt', 'foreign', 'manifest-missing', 'ownership-missing'].includes(issue.status)) { + return `Manual ownership repair required for ${pathLabel}. Restore a trusted Workspine ownership manifest for this runtime, or preserve the existing home and do not adopt it automatically; then rerun \`npx -y workspine health --global\`.`; + } + return `Manual resolution required for ${pathLabel}. Preserve the existing home and rerun \`npx -y workspine health --global\` after resolving the ownership blocker.`; + } + if (report?.selectedSetBlocked) { + return `Automatic global reconciliation is blocked by another unsafe global issue. Resolve the manual blocker(s), then rerun \`npx -y workspine health --global\`.`; + } + return issue.repairCommand + ? `Run \`${issue.repairCommand}\` to reconcile this manifest-owned global surface.` + : `Rerun \`npx -y workspine health --global\` after resolving this global surface.`; +} + function buildClaudeEntries({ cwd, workflows, stateDirName = '.work' }) { const checkerModelAlias = resolveRuntimeAgentModel({ cwd, @@ -265,38 +474,69 @@ export function collectExpectedRuntimeSurfaceGroups({ cwd = process.cwd(), workf runtime: 'claude', label: 'Claude Code native surfaces', root: '.claude', - repairCommand: 'npx -y workspine update --tools claude', + repairCommand: 'npx -y workspine update', + missingRepairCommand: 'npx -y workspine init --tools claude', entries: buildClaudeEntries({ cwd, workflows, stateDirName }), }, { runtime: 'opencode', label: 'OpenCode native surfaces', root: '.opencode', - repairCommand: 'npx -y workspine update --tools opencode', + repairCommand: 'npx -y workspine update', + missingRepairCommand: 'npx -y workspine init --tools opencode', entries: buildOpenCodeEntries({ cwd, workflows, stateDirName }), }, { runtime: 'codex', label: 'Codex CLI native agents', root: '.codex', - repairCommand: 'npx -y workspine update --tools codex', + repairCommand: 'npx -y workspine update', + missingRepairCommand: 'npx -y workspine init --tools codex', entries: buildCodexEntries({ cwd }), }, ]; } export function evaluateRuntimeFreshness({ cwd = process.cwd(), workflows = [] }) { + const state = resolveStateDir(cwd); + const manifest = readManifest(state.dir); + let ownershipManifest = manifest; + try { + ownershipManifest = bridgeHistoricalAdapterOwnership({ + cwd, + manifest, + stateDirName: state.name, + })?.manifest ?? manifest; + } catch { + // Unsafe historical ownership must stay manual in health rather than + // advertising an update/init path that the real preflight will refuse. + ownershipManifest = null; + } const groups = collectExpectedRuntimeSurfaceGroups({ cwd, workflows }).map((group) => { + const selectionManifest = ownershipManifest ?? manifest; + const manifestSelected = group.runtime !== 'workspace-helper' && ( + group.entries.some((entry) => localTargetOwned( + selectionManifest, + state.name, + group.runtime, + entry.relativePath, + )) + || (['claude', 'opencode', 'codex'].includes(group.runtime) + && Array.isArray(selectionManifest?.adapterSelection) + && selectionManifest.adapterSelection.includes(group.runtime)) + ); const installed = group.runtime === 'workspace-helper' ? existsSync(resolveStateDir(cwd).dir) - : existsSync(join(cwd, group.root)); + : existsSync(join(cwd, group.root)) || manifestSelected; const comparisons = installed ? group.entries.map((entry) => compareGeneratedFile({ cwd, runtime: group.runtime, relativePath: entry.relativePath, expectedContent: entry.expectedContent, + owned: localTargetOwned(ownershipManifest, state.name, group.runtime, entry.relativePath), repairCommand: group.repairCommand, + missingRepairCommand: group.missingRepairCommand, })) : []; @@ -338,9 +578,33 @@ export function summarizeRuntimeFreshnessIssues(report, limit = 4) { export function getRuntimeFreshnessRepairGuidance(report) { if (!report || report.issueCount === 0) return 'Run `npx -y workspine update` to regenerate installed runtime surfaces.'; - const commands = [...new Set(report.issues.map((entry) => entry.repairCommand))]; - if (commands.length === 1) { - return `Run \`${commands[0]}\` to regenerate the installed runtime surfaces.`; + const manualIssues = report.issues.filter((entry) => + entry.manual === true + || entry.owned === false + || ['collision', 'unreadable', 'unowned', 'unowned-missing'].includes(entry.status)); + const automaticIssues = report.issues.filter((entry) => !manualIssues.includes(entry)); + const commands = [...new Set(automaticIssues.map((entry) => entry.repairCommand).filter(Boolean))]; + const orderedCommands = [ + ...commands.filter((command) => / workspine init --tools /.test(command)), + ...commands.filter((command) => command === 'npx -y workspine update'), + ...commands.filter((command) => !/ workspine init --tools /.test(command) && command !== 'npx -y workspine update'), + ]; + const commandGuidance = orderedCommands.length === 1 + ? `Run \`${orderedCommands[0]}\`.` + : orderedCommands.length > 1 + ? `Run ${orderedCommands.map((command) => `\`${command}\``).join(', then ')}.` + : ''; + + if (manualIssues.length > 0) { + const targets = [...new Set(manualIssues.map((entry) => entry.relativePath))]; + const blockedReasons = [...new Set(manualIssues.map((entry) => entry.repairBlockReason).filter(Boolean))]; + const preflightContext = blockedReasons.length > 0 + ? ` Automatic repair preflight refused: ${blockedReasons.join(' | ')}.` + : ''; + return `Resolve generated target ownership manually first (${targets.join(', ')}).${preflightContext} Preserve existing bytes; move or rename consumer-owned collisions, and restore matching generation-manifest ownership from a trusted backup. If no valid ownership record exists, preserve this workspace and initialize a clean workspace.${commandGuidance ? ` Then ${commandGuidance}` : ''}`; + } + if (orderedCommands.length === 1) { + return `Run \`${orderedCommands[0]}\` to regenerate the installed runtime surfaces.`; } - return `Run \`npx -y workspine update\` to regenerate all installed runtime surfaces, or target the affected adapters individually: ${commands.map((command) => `\`${command}\``).join(', ')}.`; + return `${commandGuidance.slice(0, -1)} in that order so each repair can make progress.`; } diff --git a/bin/lib/state-dir.mjs b/bin/lib/state-dir.mjs index 81859465..0c010bd8 100644 --- a/bin/lib/state-dir.mjs +++ b/bin/lib/state-dir.mjs @@ -101,7 +101,15 @@ export function resolveStateDir(root) { if (workStat && legacyStat) { return { ...base, status: 'dual_conflict', action: 'refuse', reason: 'both_state_roots_exist' }; } - if (workStat) return { ...base, status: 'current', action: 'use_current' }; + if (workStat) { + if (workStat.isSymbolicLink()) { + return { ...base, status: 'current_unsafe', action: 'refuse', reason: 'linked_current_root' }; + } + if (!workStat.isDirectory()) { + return { ...base, status: 'current_unsafe', action: 'refuse', reason: 'invalid_current_root' }; + } + return { ...base, status: 'current', action: 'use_current' }; + } if (!legacyStat) return { ...base, status: 'fresh', action: 'use_current' }; const legacy = inspectLegacyState(legacyDir, legacyStat); @@ -135,6 +143,13 @@ export function stateAuthorityGate(state) { message: 'Both `.work/` and `.planning/` exist. Refusing split-root state. Resolve the two roots manually so only one remains; Workspine will not merge or delete either root.', }; } + if (state.status === 'current_unsafe') { + return { + allowed: false, + status: state.status, + message: 'Current `.work/` state root must be a real directory inside this workspace. Preserve any linked or colliding target bytes, replace `.work/` with a real local directory, then rerun health.', + }; + } return { allowed: false, status: state.status, diff --git a/bin/lib/templates.mjs b/bin/lib/templates.mjs index 722b0105..e1d9b784 100644 --- a/bin/lib/templates.mjs +++ b/bin/lib/templates.mjs @@ -299,7 +299,7 @@ export function applyTemplateRefresh(plan, { isDry = false } = {}) { export function refreshTemplates(options) { if (!existsSync(options.planningDir)) { - // `update --templates --dry` may inspect a fresh directory; it must not + // `update --dry` may inspect a fresh directory; it must not // bootstrap a state root merely to describe a prospective refresh. return { templates: { delegates: {}, research: {}, codebase: {}, brownfieldChange: {}, root: {} }, roles: {} }; } diff --git a/distilled/DESIGN.md b/distilled/DESIGN.md index 464d5fec..df832ce2 100644 --- a/distilled/DESIGN.md +++ b/distilled/DESIGN.md @@ -765,19 +765,19 @@ architecturally not viable without reverting to vendor-specific APIs. This close **GSD:** `install.js` uses SHA-256 manifest (`installedFileHashes`) plus `gsd-local-patches/` backup directory (lines 1227-1327). On update, GSD backs up user-modified files before overwriting, enabling rollback. -**GSDD:** Generation manifest in `.planning/generation-manifest.json`, opt-in `--templates` flag on -`npx -y workspine update`, warn-but-overwrite semantics (no backup directory), `--dry` preview mode. +**GSDD:** Generation manifest in `.work/generation-manifest.json`, selector-free whole-repo +`npx -y workspine update`, warn-but-overwrite semantics (no backup directory), `--dry-run` preview mode. **Key differences from GSD:** - **No backup directory.** Git handles recovery — users can `git checkout` to restore any overwritten template. Adding a `gsd-local-patches/` equivalent would introduce stale-state complexity that Git already solves. -- **Opt-in flag.** `npx -y workspine update` without `--templates` preserves current behavior (adapter/skill refresh - only). Template refresh is explicitly requested, so users are not surprised by file overwrites. -- **Project-scoped manifest.** `generation-manifest.json` lives in `.planning/` alongside other project +- **Whole-repo reconciliation.** `npx -y workspine update` refreshes manifest-owned templates, helpers, skills, + and adapters together through one preflight rather than exposing partial-update selectors. +- **Project-scoped manifest.** `generation-manifest.json` lives in `.work/` alongside other project artifacts, making it portable and inspectable. The manifest records SHA-256 hashes of all installed templates and role contracts at init/update time. -- **Modification detection.** When `--templates` runs, GSDD compares installed file hashes against the +- **Modification detection.** During update, GSDD compares installed file hashes against the manifest to detect user modifications. Modified files trigger a `WARN` before overwrite. Files matching the manifest (unchanged) are silently refreshed. Files matching source (already current) are skipped. @@ -842,7 +842,7 @@ Implementation lives under `bin/lib/`: - keep ROADMAP phase checkbox transitions in a status-aware helper; broader roadmap rewrites stay outside this helper boundary - keep config-schema ownership in `config.mjs`; do not duplicate or relocate `buildDefaultConfig` into the init flow just to satisfy an old task list -- let `init` use the same template-sync module that `update --templates` uses, instead of maintaining separate +- let `init` use the same template-sync module that plain `update` uses, instead of maintaining separate copy logic - enforce the boundary with code-structure guard tests, not by re-auditing the file manually each session @@ -947,7 +947,7 @@ Implementation lives under `bin/lib/`: **GSD:** `health.md` (157 lines) — calls `gsd-tools.cjs validate health [--repair]`, parses JSON with error codes E001-E005/W001-W007, supports `--repair` flag for createConfig/resetConfig/regenerateState repair actions. -**GSDD:** `npx -y workspine health` CLI command (`bin/lib/health.mjs` + `bin/lib/health-truth.mjs`; global `gsdd health` is equivalent). Factory function `createCmdHealth(ctx)` returning an async command. No `--repair` flag — fixes are documented as actionable instructions, not automated mutations. GSDD already has `npx -y workspine init` and `npx -y workspine update --templates` as the repair paths; a separate repair mode would duplicate those commands. +**GSDD:** `npx -y workspine health` CLI command (`bin/lib/health.mjs` + `bin/lib/health-truth.mjs`; global `gsdd health` is equivalent). Factory function `createCmdHealth(ctx)` returning an async command. No `--repair` flag — fixes are documented as actionable instructions, not automated mutations. Missing config can bootstrap through `npx -y workspine init`; manifest-owned generated drift uses plain `npx -y workspine update`; invalid existing config or missing ownership records require manual repair rather than circular init/update advice. **Check categories:** @@ -990,9 +990,9 @@ Implementation lives under `bin/lib/`: **Key design choices:** -1. **No `--repair` flag.** GSD's health workflow supported `--repair` with three actions (createConfig, resetConfig, regenerateState). GSDD does not need this because `npx -y workspine init` and `npx -y workspine update --templates` already serve as repair paths. Documenting the fix command in each diagnostic is sufficient — agents can read and execute the instruction directly. +1. **No `--repair` flag.** GSD's health workflow supported `--repair` with three actions (createConfig, resetConfig, regenerateState). GSDD uses existing commands only where they are truthful: init for missing config/native-target bootstrap and plain update for manifest-owned repo-local drift. Invalid existing config and missing ownership records stay manual. Documenting the fix in each diagnostic is sufficient. -2. **`brew doctor` pattern.** Diagnose, report, suggest — never auto-fix. This matches the D13 principle: error messages ARE the enforcement mechanism. When an agent reads `"E3: .planning/templates/ missing. Fix: Run npx -y workspine update --templates"`, it can act on the instruction. +2. **`brew doctor` pattern.** Diagnose, report, suggest — never auto-fix. This matches the D13 principle: error messages ARE the enforcement mechanism. When an agent reads `"E3: .work/templates/ missing. Fix: Run npx -y workspine update"`, it can act on a supported instruction. 3. **Pre-init guard.** If `.planning/config.json` doesn't exist, output a one-line message and exit 1. No partial checks — the workspace is simply not initialized. @@ -1100,7 +1100,7 @@ Benefits: 1. **Reusable:** The same delegate can be invoked from multiple orchestrator workflows (new-project, plan, milestone audit) 2. **Testable:** Delegate contracts are explicit and can be verified independently 3. **Portable:** Delegates are plain markdown; any agent can read them -4. **Versioned:** The generation manifest tracks delegate content; `npx -y workspine update --templates` refreshes them +4. **Versioned:** The generation manifest tracks delegate content; plain `npx -y workspine update` refreshes them **Tradeoffs and close condition:** @@ -2276,7 +2276,7 @@ Sub-gap (b) was closed by D28's `` mandate and guarded by G30. Sub- **Decision:** - Add one shared renderer-backed helper for runtime-surface freshness rather than per-test or per-runtime drift logic. - Compare only installed runtime surfaces; absent generated roots stay non-issues until the runtime surface actually exists locally. -- Route drift through deterministic repair (`npx -y workspine update` or targeted `npx -y workspine update --tools `) instead of treating the fix as a manual review exercise. +- Route stale manifest-owned drift through plain `npx -y workspine update`; a missing manifest-owned Claude/OpenCode/Codex native target uses `npx -y workspine init --tools ` before update. Unowned generated-looking targets stay manual rather than being adopted automatically. - Treat the portable runtime surface as more than skill markdown: keep workflow discovery under `.agents/skills/`, generate the repo-local helper runtime at `.work/bin/gsdd.mjs`, route workflow-internal deterministic helper calls through `node .work/bin/gsdd.mjs ...` instead of bare `gsdd ...`, and keep human install/update/health guidance on `npx -y workspine ...` unless a global install is explicitly present. - Keep the public/runtime-facing wording brief: the authored source stays canonical, generated files are trusted because they are rendered and checked, and parity language remains narrow where live validation still does not exist. **Why this fits the codebase:** @@ -2892,7 +2892,7 @@ Posture compatibility is part of that closeout contract: `repo_closeout` and `ru **Consequences:** - Future UI-related phases must not add new evidence kinds by treating artifact types as proof categories. - Future dogfood or runtime validation must not upgrade artifact counts or human waivers into proof. -- Generated runtime surfaces and local templates must stay freshness-checkable through `gsdd update --templates` and health diagnostics. +- Generated runtime surfaces and local templates must stay freshness-checkable through plain `gsdd update` and health diagnostics. - Future provider/tooling work must not make `agent-browser` a required validator field without a separate product decision; the current contract makes it the default workflow path, not a schema lock. ## D63 - Computed-First Control Map diff --git a/distilled/templates/agents.block.md b/distilled/templates/agents.block.md index dad01dd9..7c6863f1 100644 --- a/distilled/templates/agents.block.md +++ b/distilled/templates/agents.block.md @@ -6,7 +6,7 @@ Lifecycle: `new-project -> plan -> execute -> verify -> audit-milestone`. Core skills: `work-new-project`, `work-plan`, `work-execute`, `work-verify`, `work-progress`. Planning state: `.work/` (legacy `.planning/` workspaces are still read). Portable workflows: `.agents/skills/work-*/SKILL.md`. -Install/repair: `npx -y workspine init` creates repo-local skills and planning state; `npx -y workspine health` verifies repo-local generated surfaces; `npx -y workspine update` repairs repo-local drift. Global personal skills use `npx -y workspine install --global` and are repaired by rerunning that install for the selected targets. +Install/repair: `npx -y workspine init` creates repo-local skills and planning state; `npx -y workspine health` verifies repo-local generated surfaces; `npx -y workspine update` repairs repo-local drift. `npx -y workspine install --global` is for fresh personal-agent installation; inspect an existing global install with `npx -y workspine health --global` and follow its safe-update or manual-resolution guidance. Invoke: `/work-plan` (Claude, OpenCode; Cursor/Copilot/Gemini when skill discovery is available) · `$work-plan` (Codex CLI, plan-only until `$work-execute`) · open SKILL.md directly elsewhere. diff --git a/docs/RUNTIME-SUPPORT.md b/docs/RUNTIME-SUPPORT.md index 41b58318..d8263e2d 100644 --- a/docs/RUNTIME-SUPPORT.md +++ b/docs/RUNTIME-SUPPORT.md @@ -6,7 +6,7 @@ This matrix is the release-floor truth surface. The package runtime floor is Node >=22. Update awareness is limited to the supported public CLI/generated helper, and within it to commands that already write to `.work/`; read-only commands such as `next` and `verify` never check or cache. It uses sequential/best-effort anonymous metadata checks, with no lock or cross-process concurrency guarantee, a two-second timeout, 64 KiB/normalized-version limits, no credentials or repository data, and a contained `.work/.local` cache with nonblocking failures. Use `--no-update-notice` or `GSDD_UPDATE_AWARENESS=0` to opt out. `health` and `update` are network-free; run `npx -y workspine update` for explicit repair. No native/TUI startup hook, automatic context transfer, runtime parity, or protection against adversarial concurrent cache-path swaps is implied. -Human repo setup and repair commands in this document use `npx -y workspine ...` because that works without a global install. If you installed `workspine` globally, the equivalent bare `gsdd ...` command is fine. For fresh cross-repo setup, run `npx -y workspine install --global` interactively or pass `--tools `; use `--auto` to refresh detected existing agent homes. +Human repo setup and repair commands in this document use `npx -y workspine ...` because that works without a global install. If you installed `workspine` globally, the equivalent bare `gsdd ...` command is fine. For fresh cross-repo setup, run `npx -y workspine install --global` interactively or pass `--tools `. For an existing Workspine-owned global install, run `npx -y workspine health --global` first; it emits `update --global` only when the whole discovered set is safe to reconcile automatically. `--auto` detects existing agent homes for install/setup and is not the generic repair path. Normal first use starts with `npx -y workspine setup`. The lower-level `npx -y workspine init` command remains available for compatibility and scripted advanced setup. @@ -64,7 +64,7 @@ Two surfaces matter for users: ## Global install surfaces -For a fresh install, choose targets interactively or run `npx -y workspine install --global --tools `. Use `npx -y workspine install --global --auto` to refresh detected existing agent homes; when none are detected it writes nothing and prints exact explicit commands. Supported target IDs are `claude,opencode,codex,copilot`: +For a fresh install, choose targets interactively or run `npx -y workspine install --global --tools `. `npx -y workspine install --global --auto` remains a setup convenience that selects detected existing homes; it is not a blanket repair command. Existing Workspine-owned homes should be inspected with `npx -y workspine health --global`, which distinguishes safe missing/package-stale files from manual ownership or filesystem blockers. Supported target IDs are `claude,opencode,codex,copilot`: | Target | Global surfaces | | --- | --- | @@ -86,7 +86,7 @@ The authored source contract stays in `distilled/workflows/*`. Generated runtime - `npx -y workspine update` regenerates drifted generated surfaces from the authored workflow and delegate sources. - Bare `gsdd health` and `gsdd update` are equivalent only when `workspine` is globally installed. - Missing generated surfaces are not treated as drift unless the corresponding runtime surface is actually installed locally. -- Detected existing global installs are refreshed by rerunning `npx -y workspine install --global --auto`; fresh or explicitly scoped installs use `npx -y workspine install --global --tools `. Global runtime probes remain an internal pressure-harness concern, not a public install flag. +- Existing global installs are checked with `npx -y workspine health --global`. If every discovered issue is auto-safe, health routes to `npx -y workspine update --global`; if any unowned, user-modified, linked, colliding, unreadable, corrupt, foreign, or ownership-missing state is present, it requires manual resolution and suppresses automatic update guidance for the selected set. Fresh or explicitly scoped installs use `npx -y workspine install --global --tools `. Global runtime probes remain an internal pressure-harness concern, not a public install flag. ## Entry and helper surfaces diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 0ea34450..e7b61da2 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -19,7 +19,7 @@ Run `npx -y workspine setup` from the repo root, then use the representative loo Use `work-map-codebase` only when a repo is unfamiliar, risky, or its existing map is stale. It creates trusted brownfield context before you choose Quick or a broader project route; it is not a fourth mandatory goal. -Compatibility: `npx -y workspine init` remains available for repo-local setup. For reusable global surfaces, run `npx -y workspine install --global` to choose targets interactively or pass `--tools ` in a fresh/headless home. Use `--auto` to refresh detected existing homes; global install never creates `.work/` in the current repo. +Compatibility: `npx -y workspine init` remains available for repo-local setup. For reusable global surfaces, run `npx -y workspine install --global` to choose targets interactively or pass `--tools ` in a fresh/headless home. `--auto` selects detected existing homes for install/setup; for repair, run `npx -y workspine health --global` first and follow its safe update or manual-resolution guidance. Global install never creates `.work/` in the current repo. Setup defaults to recommended portable files in the current repo. Use `setup --global` for personal agent homes, `--agent ` for one native target, `--all` for every detected target, or `--migrate` to approve a detected legacy-state move explicitly. `-y`/`--yes` accepts the bounded write without prompts; `--dry-run` previews it. @@ -204,7 +204,7 @@ npx -y workspine install --global --auto npx -y workspine install --global --tools claude,opencode,codex,copilot ``` -For a fresh install, choose targets interactively or pass `--tools `. Use `--auto` for a non-interactive refresh of detected existing agent homes. If none are detected, it writes nothing and prints one exact command per supported target. +For a fresh install, choose targets interactively or pass `--tools `. `--auto` selects detected existing agent homes for non-interactive install/setup; it is not the repair path. If none are detected, it writes nothing and prints one exact command per supported target. For an existing Workspine-owned home, inspect `npx -y workspine health --global` before attempting repair. Global install writes Workspine-managed files under selected agent homes and records per-runtime manifests. It does not bootstrap project planning state. Each target writes to these directories: @@ -244,8 +244,7 @@ Details worth knowing before you script it: | Command | Purpose | |---------|---------| | `npx -y workspine init [--tools ]` | Set up `.work/`, generate skills/adapters | -| `npx -y workspine update [--tools ]` | Regenerate skills/adapters from latest sources | -| `npx -y workspine update --templates` | Refresh role contracts and delegates (warns about user modifications) | +| `npx -y workspine update [--dry-run]` | Reconcile all manifest-owned repo-local templates, helpers, skills, and adapters from latest sources | | `npx -y workspine find-phase [N]` | Show phase info as JSON (for agent consumption) | | `npx -y workspine verify ` | Run artifact checks for phase N | | `npx -y workspine scaffold phase [name]` | Create a new phase plan file | @@ -286,7 +285,7 @@ Normal user flow: 2. Enter workflows through your runtime surface: `/work-*` or `$work-*`. 3. Use `npx -y workspine health` to check repo-local generated surfaces. 4. Use `npx -y workspine update` when repo-local generated surfaces drift or you want the latest shipped output. -5. For personal global installs, rerun `npx -y workspine install --global --auto` to repair or refresh detected existing agent homes, or use `npx -y workspine install --global --tools ` for a fresh or explicitly scoped target set. +5. For personal global installs, run `npx -y workspine health --global`. It recommends `npx -y workspine update --global` only when every discovered issue is safe to reconcile; otherwise resolve the named ownership/filesystem blocker manually. Use `npx -y workspine install --global --tools ` for a fresh or explicitly scoped install. Surface split: @@ -511,18 +510,18 @@ Do not re-run `work-execute`. Use `work-quick` for targeted fixes, or `work-veri ### Template Refresh After Update ```bash -npx -y workspine update --templates # Refreshes role contracts and delegates +npx -y workspine update # Reconciles role contracts, delegates, helpers, skills, and adapters ``` If you've modified any templates, the generation manifest detects this and warns you before overwriting. The SHA-256 hash of each generated file is tracked in `.work/generation-manifest.json`. ### Generated Surfaces Drift Or A Runtime Command Goes Missing -In a repo-local `.work/` workspace, start with `npx -y workspine health`. If it reports drift or missing installed generated surfaces, run `npx -y workspine update` for the whole workspace or `npx -y workspine update --tools ` for a specific runtime. For global personal installs, rerun `npx -y workspine install --global --auto` or scope it explicitly with `npx -y workspine install --global --tools `. +In a repo-local `.work/` workspace, start with `npx -y workspine health`. Stale manifest-owned repo-local surfaces are repaired with plain `npx -y workspine update`. If health reports a missing manifest-owned Claude, OpenCode, or Codex native target, follow its emitted `npx -y workspine init --tools ` repair first, then rerun health and plain update if further stale surfaces remain. Generated-looking files without matching manifest ownership require manual preservation/ownership repair rather than automatic adoption. For global personal installs, use `npx -y workspine health --global`: safe missing/package-stale ownership can route to `update --global`, while any unowned, user-modified, linked, colliding, unreadable, corrupt, foreign, or ownership-missing state blocks automatic reconciliation until resolved manually. Fresh installs still use `npx -y workspine install --global --tools `. That repair path is deterministic for generated files. It does not imply that every runtime has equal native ergonomics or equal validation depth. -A global install repair restores managed files you deleted and rewrites stale ones you have not touched, and it never overwrites your edits. If a managed file was hand-edited, or an untracked file sits where a managed one belongs, preflight stops and names that file, and nothing is written for any selected target until you resolve it. Restore the file from the manifest hash or delete it, then rerun the install. +For an existing global install, start with `npx -y workspine health --global`. Manifest-owned missing files and package-stale bytes can be reconciled with `npx -y workspine update --global` only when the whole discovered target set is auto-safe. If health reports a user-modified, untracked, ownership-missing, linked, colliding, unreadable, corrupt, or foreign state, preserve the existing bytes and resolve that blocker manually before rerunning global health; do not use global install as an in-place repair shortcut. ### Model Costs Too High @@ -539,7 +538,7 @@ Switch to budget profile: `npx -y workspine models profile budget` (or `gsdd mod | Quick targeted fix | `work-quick` | | Something broke | Use the debugger role for systematic debugging | | Costs running high | `npx -y workspine models profile budget`, disable workflow toggles | -| Templates out of date | `npx -y workspine update --templates` or `gsdd update --templates` if globally installed | +| Templates out of date | `npx -y workspine update` or `gsdd update` if globally installed | | Adapters out of date | `npx -y workspine update` or `gsdd update` if globally installed | --- diff --git a/tests/gsdd.global-install-pressure.test.cjs b/tests/gsdd.global-install-pressure.test.cjs index ec0a90fe..b0b69cb6 100644 --- a/tests/gsdd.global-install-pressure.test.cjs +++ b/tests/gsdd.global-install-pressure.test.cjs @@ -845,11 +845,14 @@ describe('global install pressure loop', () => { assert.match(cleanUpdate, /codex:/); assert.strictEqual(process.exitCode, undefined); fs.writeFileSync(claudeSkill, 'user edit that should be recovered\n'); + const beforeBlockedHome = snapshotTree(homeDir); const output = await captureLogs(() => gsdd.cmdGlobalUpdate()); assert.match(output, /claude:/); assert.match(output, /codex:/); assert.strictEqual(process.exitCode, 1, 'modified owned global bytes must refuse before any target writes'); + assert.match(output, /Manual resolution is required before retrying/); assert.strictEqual(fs.readFileSync(claudeSkill, 'utf-8'), 'user edit that should be recovered\n'); + assert.deepStrictEqual(snapshotTree(homeDir), beforeBlockedHome, 'one blocker must keep the entire selected global set zero-write'); assert.deepStrictEqual(snapshotTree(repoDir), beforeRepo, 'global update must not touch the invoking repo'); }); } finally { @@ -888,6 +891,42 @@ describe('global install pressure loop', () => { } }); + test('global update refuses an absent expected file when manifest ownership is also missing', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const restoreStdin = setNonInteractiveStdin(); + const previousExitCode = process.exitCode; + const target = path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const gsdd = await loadGsdd(repoDir); + const installOutput = await captureLogs(() => gsdd.cmdInstall('--global', '--tools', 'claude')); + assert.match(installOutput, /Global install complete/); + assert.ok(fs.existsSync(target), 'fresh install must create the expected target'); + + fs.unlinkSync(target); + const manifest = readJson(manifestPath); + delete manifest.files['skills/work-plan/SKILL.md']; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const before = snapshotTree(homeDir); + + const output = await captureLogs(() => gsdd.cmdGlobalUpdate()); + + assert.strictEqual(process.exitCode, 1, output); + assert.match(output, /missing target is unowned|not tracked by Workspine manifest/i); + assert.match(output, /Manual resolution is required before retrying/); + assert.ok(!fs.existsSync(target), 'strict global update must not recreate an absent unowned target'); + assert.deepStrictEqual(snapshotTree(homeDir), before, 'absent plus untracked global target must refuse with zero writes'); + }); + } finally { + restoreStdin(); + process.exitCode = previousExitCode; + cleanup(homeDir); + cleanup(repoDir); + } + }); + test('global update dry-run preserves modified owned bytes and refuses', async () => { const homeDir = createTempProject(); const repoDir = createTempProject(); @@ -975,6 +1014,7 @@ describe('global install pressure loop', () => { assert.strictEqual(process.exitCode, 1, `${scenario.name} must refuse`); const expectedReason = scenario.name === 'corrupt' ? 'corrupt' : scenario.name === 'linked-manifest' ? 'linked' : scenario.name; assert.match(output, new RegExp(expectedReason)); + assert.match(output, /Manual resolution is required before retrying/); assert.deepStrictEqual(snapshotTree(homeDir), before, `${scenario.name} refusal must be zero-write`); }); } finally { @@ -986,6 +1026,156 @@ describe('global install pressure loop', () => { } }); + test('global health and update refuse an intermediate parent junction before external writes', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const external = createTempProject(); + const restoreStdin = setNonInteractiveStdin(); + const previousExitCode = process.exitCode; + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const gsdd = await loadGsdd(repoDir); + await captureLogs(() => gsdd.cmdInstall('--global', '--tools', 'claude')); + + const linkedParent = path.join(homeDir, '.claude', 'skills', 'work-plan'); + fs.rmSync(linkedParent, { recursive: true, force: true }); + fs.symlinkSync(external, linkedParent, process.platform === 'win32' ? 'junction' : 'dir'); + const externalBefore = snapshotTree(external); + + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + const linked = [...report.errors, ...report.warnings].find((entry) => + /skills\/work-plan\/SKILL\.md/.test(entry.message)); + assert.ok(linked, health.output); + assert.match(linked.message, /linked/); + assert.match(linked.fix, /Manual resolution required/); + assert.doesNotMatch(linked.fix, /workspine update --global/); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'global health must not write through an intermediate junction'); + + process.exitCode = undefined; + const update = await captureLogs(() => gsdd.cmdGlobalUpdate()); + assert.strictEqual(process.exitCode, 1, update); + assert.match(update, /linked|Manual resolution is required before retrying/); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'global update must refuse before writing through an intermediate junction'); + }); + } finally { + restoreStdin(); + process.exitCode = previousExitCode; + cleanup(external); + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('isolated global install refuses a linked parent of the runtime root before external writes', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const external = createTempProject(); + const restoreStdin = setNonInteractiveStdin(); + const previousExitCode = process.exitCode; + try { + fs.symlinkSync(external, path.join(homeDir, '.config'), process.platform === 'win32' ? 'junction' : 'dir'); + const externalBefore = snapshotTree(external); + const homeBefore = snapshotTree(homeDir); + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.ignored-config') }, async () => { + const gsdd = await loadGsdd(repoDir); + const output = await captureLogs(() => gsdd.cmdInstall('--global', '--tools', 'opencode')); + assert.strictEqual(process.exitCode, 1, output); + assert.match(output, /linked|Manual resolution is required before retrying/); + }); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'global install must not write through a linked parent of the runtime root'); + assert.deepStrictEqual(snapshotTree(homeDir), homeBefore, 'one unsafe split-root spec must keep the selected global target zero-write'); + } finally { + restoreStdin(); + process.exitCode = previousExitCode; + cleanup(external); + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('explicit external runtime root refuses a linked ancestor before shared or external writes', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const parentDir = createTempProject(); + const external = createTempProject(); + const restoreStdin = setNonInteractiveStdin(); + const previousExitCode = process.exitCode; + try { + const linkedParent = path.join(parentDir, 'linked-runtime-parent'); + fs.symlinkSync(external, linkedParent, process.platform === 'win32' ? 'junction' : 'dir'); + const opencodeConfigDir = path.join(linkedParent, 'opencode'); + const homeBefore = snapshotTree(homeDir); + const externalBefore = snapshotTree(external); + + const [{ createCliContext }, { createCmdInstall }] = await Promise.all([ + import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'gsdd.mjs')).href}?t=${Date.now()}-external-linked-ctx`), + import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'global-install.mjs')).href}?t=${Date.now()}-external-linked-install`), + ]); + const ctx = createCliContext(repoDir); + ctx.globalInstallRootOptions = { + homeDir, + env: { + XDG_CONFIG_HOME: path.join(homeDir, '.config'), + OPENCODE_CONFIG_DIR: opencodeConfigDir, + }, + }; + process.exitCode = undefined; + const output = await captureLogs(() => createCmdInstall(ctx)('--global', '--tools', 'opencode')); + assert.strictEqual(process.exitCode, 1, output); + assert.match(output, /linked|selected set blocked/i); + assert.deepStrictEqual(snapshotTree(homeDir), homeBefore, 'unsafe explicit runtime root must block shared HOME writes too'); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'explicit runtime root must not write through an ancestor junction'); + } finally { + restoreStdin(); + process.exitCode = previousExitCode; + cleanup(external); + cleanup(parentDir); + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('explicit external runtime root refuses a file ancestor before partial selected-target writes', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const parentDir = createTempProject(); + const restoreStdin = setNonInteractiveStdin(); + const previousExitCode = process.exitCode; + try { + const fileParent = path.join(parentDir, 'runtime-parent-file'); + fs.writeFileSync(fileParent, 'consumer bytes\n'); + const opencodeConfigDir = path.join(fileParent, 'opencode'); + const homeBefore = snapshotTree(homeDir); + const fileBefore = fs.readFileSync(fileParent); + + const [{ createCliContext }, { createCmdInstall }] = await Promise.all([ + import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'gsdd.mjs')).href}?t=${Date.now()}-external-file-ctx`), + import(`${pathToFileURL(path.join(__dirname, '..', 'bin', 'lib', 'global-install.mjs')).href}?t=${Date.now()}-external-file-install`), + ]); + const ctx = createCliContext(repoDir); + ctx.globalInstallRootOptions = { + homeDir, + env: { + XDG_CONFIG_HOME: path.join(homeDir, '.config'), + OPENCODE_CONFIG_DIR: opencodeConfigDir, + }, + }; + process.exitCode = undefined; + const output = await captureLogs(() => createCmdInstall(ctx)('--global', '--tools', 'opencode')); + assert.strictEqual(process.exitCode, 1, output); + assert.match(output, /collision|not a directory|selected set blocked/i); + assert.deepStrictEqual(snapshotTree(homeDir), homeBefore, 'file-ancestor refusal must happen before shared HOME writes'); + assert.deepStrictEqual(fs.readFileSync(fileParent), fileBefore, 'consumer ancestor file must remain byte-identical'); + } finally { + restoreStdin(); + process.exitCode = previousExitCode; + cleanup(parentDir); + cleanup(homeDir); + cleanup(repoDir); + } + }); + test('global update leaves unknown unowned files untouched while reconciling owned files', async () => { const homeDir = createTempProject(); const repoDir = createTempProject(); diff --git a/tests/gsdd.guards.test.cjs b/tests/gsdd.guards.test.cjs index 896faae4..05501269 100644 --- a/tests/gsdd.guards.test.cjs +++ b/tests/gsdd.guards.test.cjs @@ -1043,6 +1043,10 @@ describe('G19 - Consumer First-Run Accuracy', () => { 'Generated AGENTS block must tell agents how to verify installed skill surfaces. FIX: Add health guidance.'); assert.match(agentsBlock, /npx -y workspine update/i, 'Generated AGENTS block must tell agents how to repair generated-surface drift. FIX: Add update guidance.'); + assert.match(agentsBlock, /health --global[\s\S]{0,180}(?:safe-update|manual-resolution)/i, + 'Generated AGENTS block must route existing global installs through health before conditional repair.'); + assert.doesNotMatch(agentsBlock, /repaired by rerunning.*install --global/i, + 'Generated AGENTS block must not present global install as generic repair.'); assert.match(agentsBlock, /Codex CLI/i, 'Generated AGENTS block must distinguish Codex CLI from Codex VS Code/app. FIX: Use Codex CLI in the $work-plan invocation guidance.'); assert.doesNotMatch(newProject, /`gsdd init --auto --brief `/, @@ -4682,4 +4686,26 @@ describe('Phase 16-D - global update and health routes', () => { assert.match(freshness, /read-only freshness evaluation for global manifest specs/i); assert.doesNotMatch(health, /writeFileSync|mkdirSync|rmSync/); }); + + test('global manifest inspection and freshness preserve safe-vs-manual repair truth', () => { + const manifest = fs.readFileSync(path.join(ROOT, 'bin', 'lib', 'global-manifest.mjs'), 'utf-8'); + const freshness = fs.readFileSync(path.join(ROOT, 'bin', 'lib', 'runtime-freshness.mjs'), 'utf-8'); + assert.match(manifest, /error\?\.code === 'ENOENT' \? 'missing' : 'unreadable'/, + 'Non-ENOENT manifest inspection failures must be unreadable, never missing.'); + assert.match(freshness, /status: 'package-stale'/, + 'Global freshness must distinguish package-stale owned bytes from user modification.'); + assert.match(freshness, /selectedSetBlocked/, + 'Global freshness must carry selected-set blocker truth so unsafe issues suppress automatic repair.'); + }); + + test('current global repair docs route existing homes through health before conditional update', () => { + const docs = [ + fs.readFileSync(path.join(ROOT, 'docs', 'USER-GUIDE.md'), 'utf-8'), + fs.readFileSync(path.join(ROOT, 'docs', 'RUNTIME-SUPPORT.md'), 'utf-8'), + ].join('\n'); + assert.match(docs, /health --global[\s\S]*update --global/i); + assert.match(docs, /manual (?:resolution|ownership|blocker)/i); + assert.doesNotMatch(docs, /(?:use|rerun)[^.\n]*--auto[^.\n]*(?:repair|refresh)/i, + '--auto must not be documented as generic global repair for existing homes.'); + }); }); diff --git a/tests/gsdd.health.test.cjs b/tests/gsdd.health.test.cjs index 2bf31d09..8571c583 100644 --- a/tests/gsdd.health.test.cjs +++ b/tests/gsdd.health.test.cjs @@ -6,6 +6,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); +const { createHash } = require('node:crypto'); const { createTempProject, loadGsdd, runCliAsMain, cleanup, withEnv } = require('./gsdd.helpers.cjs'); @@ -174,6 +175,7 @@ describe('Health — pre-init guard', () => { assert.strictEqual(json.status, 'broken'); assert.ok(json.errors.length > 0); assert.strictEqual(json.errors[0].id, 'E1'); + assert.match(json.errors[0].fix, /npx -y workspine init/); }); test('supported legacy state is a blocking migration issue and remains byte-identical', async () => { @@ -230,7 +232,10 @@ describe('Health — ERROR: malformed config.json', () => { assert.strictEqual(result.exitCode, 1); const json = JSON.parse(result.output); assert.strictEqual(json.status, 'broken'); - assert.ok(json.errors.some((e) => e.id === 'E1')); + const error = json.errors.find((e) => e.id === 'E1'); + assert.ok(error); + assert.match(error.fix, /Repair or restore .*config\.json manually/); + assert.doesNotMatch(error.fix, /Run `npx -y workspine init`/); }); }); @@ -243,8 +248,11 @@ describe('Health — ERROR: missing required config fields', () => { fs.writeFileSync(configPath, JSON.stringify(config)); const result = await runCliAsMain(tmpDir, ['health', '--json']); const json = JSON.parse(result.output); - assert.ok(json.errors.some((e) => e.id === 'E2')); - assert.match(json.errors.find((e) => e.id === 'E2').message, /researchDepth/); + const error = json.errors.find((e) => e.id === 'E2'); + assert.ok(error); + assert.match(error.message, /researchDepth/); + assert.match(error.fix, /Repair or restore .*config\.json manually/); + assert.doesNotMatch(error.fix, /Run `npx -y workspine init`/); }); }); @@ -316,7 +324,12 @@ describe('Health — ERROR: missing research/codebase/root templates', () => { fs.rmSync(path.join(tmpDir, '.work', 'templates', 'spec.md'), { force: true }); const result = await runCliAsMain(tmpDir, ['health', '--json']); const json = JSON.parse(result.output); - assert.ok(json.errors.some((e) => e.id === 'E8' && e.message.includes('spec.md'))); + const error = json.errors.find((e) => e.id === 'E8' && e.message.includes('spec.md')); + assert.ok(error); + assert.strictEqual(error.fix, 'Run `npx -y workspine update`'); + const repaired = await runCliAsMain(tmpDir, ['update']); + assert.strictEqual(repaired.exitCode, 0, repaired.output); + assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'templates', 'spec.md'))); }); test('ui-proof root template removed → E8', async () => { @@ -335,7 +348,11 @@ describe('Health — WARN: missing manifest', () => { if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath); const result = await runCliAsMain(tmpDir, ['health', '--json']); const json = JSON.parse(result.output); - assert.ok(json.warnings.some((w) => w.id === 'W1')); + const warning = json.warnings.find((w) => w.id === 'W1'); + assert.ok(warning); + assert.match(warning.fix, /Restore .*generation-manifest\.json.*trusted backup/); + assert.match(warning.fix, /initialize a clean workspace/); + assert.doesNotMatch(warning.fix, /Run `npx -y workspine update`/); assert.strictEqual(json.status, 'degraded'); assert.strictEqual(result.exitCode, 0); }); @@ -781,6 +798,317 @@ describe('Health — WARN: adapter and truth drift detection', () => { assert.match(warning.fix, /npx -y workspine update/); }); + test('missing owned Claude runtime target → W11 emits supported init repair and restores the file', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const target = path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + fs.rmSync(target); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const json = JSON.parse(result.output); + const warning = json.warnings.find((w) => w.id === 'W11'); + assert.ok(warning, 'missing owned Claude target should emit W11'); + assert.match(warning.fix, /`npx -y workspine init --tools claude`/); + assert.doesNotMatch(warning.fix, /workspine update --tools/); + + const repaired = await runCliAsMain(tmpDir, ['init', '--tools', 'claude']); + assert.strictEqual(repaired.exitCode, 0, repaired.output); + assert.ok(fs.existsSync(target), 'emitted init repair must restore the missing Claude target'); + }); + + test('stale owned Claude runtime target → W11 keeps plain update repair', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const target = path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + fs.appendFileSync(target, '\n\n'); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const json = JSON.parse(result.output); + const warning = json.warnings.find((w) => w.id === 'W11'); + assert.ok(warning, 'stale owned Claude target should emit W11'); + assert.match(warning.fix, /`npx -y workspine update`/); + assert.doesNotMatch(warning.fix, /workspine update --tools|workspine init --tools/); + }); + + test('mixed missing native plus stale helper W11 orders init repair before plain update', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + fs.appendFileSync(path.join(tmpDir, '.work', 'bin', 'gsdd.mjs'), '\n// drift\n'); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning); + const initIndex = warning.fix.indexOf('npx -y workspine init --tools claude'); + const updateIndex = warning.fix.indexOf('npx -y workspine update'); + assert.ok(initIndex >= 0 && updateIndex > initIndex, warning.fix); + assert.doesNotMatch(warning.fix, /workspine update --tools/); + }); + + test('unowned generated-looking Claude target → W11 requires manual ownership repair first', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const relativeTarget = '.claude/skills/work-plan/SKILL.md'; + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + delete manifest.adapterFiles[relativeTarget]; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning); + assert.match(warning.fix, /Resolve generated target ownership manually first/); + assert.match(warning.fix, /\.claude\/skills\/work-plan\/SKILL\.md/); + assert.doesNotMatch(warning.fix, /workspine (?:update|init) --tools/); + assert.doesNotMatch(warning.fix, /`npx -y workspine update`/); + }); + + test('dangling owned Claude symlink → W11 stays manual and never emits init repair', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const target = path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + fs.unlinkSync(target); + fs.symlinkSync(path.join(tmpDir, 'missing-dangling-skill.md'), target, 'file'); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.message, /\.claude\/skills\/work-plan\/SKILL\.md \[collision\]/); + assert.match(warning.fix, /Resolve generated target ownership manually first/); + assert.doesNotMatch(warning.fix, /workspine init --tools|`npx -y workspine update`/); + }); + + test('dangling Claude parent link blocks init repair before any repository writes', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const skillsDir = path.join(tmpDir, '.claude', 'skills'); + fs.rmSync(skillsDir, { recursive: true, force: true }); + fs.symlinkSync( + path.join(tmpDir, 'missing-external-skills'), + skillsDir, + process.platform === 'win32' ? 'junction' : 'dir', + ); + + const health = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(health.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, health.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.match(warning.fix, /parent must be a real directory/); + assert.doesNotMatch(warning.fix, /`npx -y workspine init --tools claude`|`npx -y workspine update`/); + + const before = snapshotTree(tmpDir); + const init = await runCliAsMain(tmpDir, ['init', '--tools', 'claude']); + assert.notStrictEqual(init.exitCode, 0, init.output); + assert.match(init.output, /parent must be a real directory/); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'init must refuse a dangling parent link before any repository writes'); + }); + + test('missing Claude target with inconsistent sibling provenance → W11 does not advertise init that will refuse', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.adapterFiles['.claude/agents/work-plan-checker.md'].source = 'bin/adapters/codex.mjs'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const before = snapshotTree(tmpDir); + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.match(warning.fix, /inconsistent source provenance/); + assert.doesNotMatch(warning.fix, /`npx -y workspine init --tools claude`/); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'health preflight must remain byte-neutral'); + + const refused = await runCliAsMain(tmpDir, ['init', '--tools', 'claude']); + assert.notStrictEqual(refused.exitCode, 0, refused.output); + assert.match(refused.output, /inconsistent source provenance/); + }); + + test('stale Claude target with inconsistent sibling provenance → W11 does not advertise update that will refuse', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.appendFileSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md'), '\n\n'); + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.adapterFiles['.claude/agents/work-plan-checker.md'].source = 'bin/adapters/codex.mjs'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.doesNotMatch(warning.fix, /`npx -y workspine update`/); + + const refused = await runCliAsMain(tmpDir, ['update']); + assert.notStrictEqual(refused.exitCode, 0, refused.output); + assert.match(refused.output, /inconsistent source provenance/); + }); + + test('manifest-selected Claude runtime with its whole native root deleted → W11 emits executable init repair', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude'), { recursive: true, force: true }); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.message, /\.claude\/skills\/work-plan\/SKILL\.md \[missing\]/); + assert.match(warning.fix, /`npx -y workspine init --tools claude`/); + + const repaired = await runCliAsMain(tmpDir, ['init', '--tools', 'claude']); + assert.strictEqual(repaired.exitCode, 0, repaired.output); + assert.ok(fs.existsSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md'))); + const clean = await runCliAsMain(tmpDir, ['health', '--json']); + assert.ok(!JSON.parse(clean.output).warnings.some((w) => w.id === 'W11'), clean.output); + }); + + test('nested cwd health preflights W11 against the resolved workspace root', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.adapterFiles['.claude/agents/work-plan-checker.md'].source = 'bin/adapters/codex.mjs'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const nested = path.join(tmpDir, 'src', 'nested'); + fs.mkdirSync(nested, { recursive: true }); + + const result = await runCliAsMain(nested, ['health', '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.doesNotMatch(warning.fix, /`npx -y workspine init --tools claude`/); + }); + + test('explicit workspace-root health preflights W11 against the named workspace', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.adapterFiles['.claude/agents/work-plan-checker.md'].source = 'bin/adapters/codex.mjs'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const foreign = createTempProject(); + try { + const result = await runCliAsMain(foreign, ['health', '--workspace-root', tmpDir, '--json']); + const warning = JSON.parse(result.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, result.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.doesNotMatch(warning.fix, /`npx -y workspine init --tools claude`/); + } finally { + cleanup(foreign); + } + }); + + test('W6 does not contradict manual W11 ownership guidance', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.agents'), { recursive: true, force: true }); + fs.rmSync(path.join(tmpDir, '.claude', 'skills'), { recursive: true, force: true }); + fs.rmSync(path.join(tmpDir, '.claude', 'commands'), { recursive: true, force: true }); + const manifestPath = path.join(tmpDir, '.work', 'generation-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.adapterFiles['.claude/agents/work-plan-checker.md'].source = 'bin/adapters/codex.mjs'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const report = JSON.parse(result.output); + const w6 = report.warnings.find((w) => w.id === 'W6'); + const w11 = report.warnings.find((w) => w.id === 'W11'); + assert.ok(w6 && w11, result.output); + assert.match(w11.fix, /Automatic repair preflight refused/); + assert.strictEqual(w6.fix, w11.fix, 'W6 must reuse the preflighted repair truth instead of advertising generic init'); + assert.doesNotMatch(w6.fix, /`npx -y workspine init --tools `/); + }); + + test('unsafe runtime-helper root suppresses every repository update fix before mutation', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + const external = createTempProject(); + const runtimeDir = path.join(tmpDir, '.work', 'bin'); + const externalRuntime = path.join(external, 'bin-copy'); + try { + fs.cpSync(runtimeDir, externalRuntime, { recursive: true }); + fs.rmSync(runtimeDir, { recursive: true, force: true }); + fs.symlinkSync(externalRuntime, runtimeDir, process.platform === 'win32' ? 'junction' : 'dir'); + fs.appendFileSync(path.join(externalRuntime, 'gsdd.mjs'), '\n// drift\n'); + const externalBefore = snapshotTree(external); + + const result = await runCliAsMain(tmpDir, ['health', '--json']); + const report = JSON.parse(result.output); + const updateFixes = [...report.errors, ...report.warnings, ...report.info] + .filter((entry) => typeof entry.fix === 'string' && /update/.test(entry.fix)); + assert.ok(updateFixes.length > 0, result.output); + for (const entry of updateFixes) { + assert.match(entry.fix, /Automatic update preflight refused/); + assert.doesNotMatch(entry.fix, /`npx -y workspine update`/); + } + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'health must not write through the runtime junction'); + + const repoBefore = snapshotTree(tmpDir); + await assert.rejects( + () => runCliAsMain(tmpDir, ['update']), + /generated runtime helpers: bin\/ must be a real directory/ + ); + assert.deepStrictEqual(snapshotTree(tmpDir), repoBefore, 'update must refuse unsafe runtime root before repository writes'); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'update must not write through the runtime junction'); + } finally { + cleanup(external); + } + }); + + test('linked .gitignore blocks init repair guidance and actual init before external writes', async () => { + const initialized = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.strictEqual(initialized.exitCode, 0, initialized.output); + fs.rmSync(path.join(tmpDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const external = createTempProject(); + const externalGitignore = path.join(external, 'outside.gitignore'); + fs.writeFileSync(externalGitignore, '# external\n'); + fs.rmSync(path.join(tmpDir, '.gitignore'), { force: true }); + fs.symlinkSync(externalGitignore, path.join(tmpDir, '.gitignore'), 'file'); + try { + const externalBefore = fs.readFileSync(externalGitignore, 'utf-8'); + const health = await runCliAsMain(tmpDir, ['health', '--json']); + const warning = JSON.parse(health.output).warnings.find((w) => w.id === 'W11'); + assert.ok(warning, health.output); + assert.match(warning.fix, /Automatic repair preflight refused/); + assert.match(warning.fix, /\.gitignore must be a regular file/); + assert.doesNotMatch(warning.fix, /`npx -y workspine init --tools claude`/); + assert.strictEqual(fs.readFileSync(externalGitignore, 'utf-8'), externalBefore); + + const init = await runCliAsMain(tmpDir, ['init', '--tools', 'claude']); + assert.notStrictEqual(init.exitCode, 0, init.output); + assert.match(init.output, /\.gitignore must be a regular file/); + assert.strictEqual(fs.readFileSync(externalGitignore, 'utf-8'), externalBefore, 'init must not write through linked .gitignore'); + } finally { + cleanup(external); + } + }); + + test('linked current .work root blocks init guidance and init before external writes', async () => { + const external = createTempProject(); + fs.symlinkSync(external, path.join(tmpDir, '.work'), process.platform === 'win32' ? 'junction' : 'dir'); + try { + const externalBefore = snapshotTree(external); + const health = await runCliAsMain(tmpDir, ['health', '--json']); + const report = JSON.parse(health.output); + const e1 = report.errors.find((entry) => entry.id === 'E1'); + assert.ok(e1, health.output); + assert.match(e1.fix, /\.work\/.*real directory/i); + assert.doesNotMatch(e1.fix, /workspine init/); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'health must not write through linked .work'); + + const init = await runCliAsMain(tmpDir, ['init', '--auto', '--tools', 'claude']); + assert.notStrictEqual(init.exitCode, 0, init.output); + assert.match(init.output, /\.work\/.*real directory/i); + assert.deepStrictEqual(snapshotTree(external), externalBefore, 'init must refuse linked .work before external writes'); + } finally { + cleanup(external); + } + }); + test('aligned framework truth files → no W7-W10', async () => { await initWorkspace(); writeAlignedTruthFixtures(); @@ -960,7 +1288,13 @@ describe('Health — global agent homes', () => { const beforeHealthRepo = snapshotTree(repoDir); const degraded = await runCliAsMain(repoDir, ['health', '-g', '--json']); assert.strictEqual(degraded.exitCode, 0, degraded.output); - assert.strictEqual(JSON.parse(degraded.output).status, 'degraded'); + const degradedReport = JSON.parse(degraded.output); + assert.strictEqual(degradedReport.status, 'degraded'); + assert.ok(degradedReport.warnings.some((warning) => warning.message.includes('modified'))); + for (const issue of [...degradedReport.errors, ...degradedReport.warnings]) { + assert.doesNotMatch(issue.fix, /update --global/, 'unsafe global health must not emit automatic update repair'); + } + assert.match(degradedReport.warnings.find((warning) => warning.message.includes('modified')).fix, /Preserve the existing file/); assert.deepStrictEqual(snapshotTree(homeDir), beforeHealthHome, 'global health must not rewrite the modified owned home'); assert.deepStrictEqual(snapshotTree(repoDir), beforeHealthRepo, 'global health must not touch the invoking repo'); }); @@ -970,6 +1304,81 @@ describe('Health — global agent homes', () => { } }); + test('global health reports user-modified obsolete manifest entries and blocks update-global', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const obsoleteRelativePath = 'skills/work-obsolete/SKILL.md'; + const obsoletePath = path.join(homeDir, '.claude', ...obsoleteRelativePath.split('/')); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.mkdirSync(path.dirname(obsoletePath), { recursive: true }); + const originalBytes = 'obsolete package-owned bytes\n'; + fs.writeFileSync(obsoletePath, originalBytes); + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.files[obsoleteRelativePath] = createHash('sha256').update(originalBytes).digest('hex'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + fs.appendFileSync(obsoletePath, 'user edit\n'); + + const beforeHealth = snapshotTree(homeDir); + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + const issue = report.warnings.find((entry) => entry.message.includes(obsoleteRelativePath) && /modified/.test(entry.message)); + assert.ok(issue, health.output); + assert.match(issue.fix, /Preserve the existing file/); + assert.doesNotMatch(issue.fix, /update --global/); + assert.deepStrictEqual(snapshotTree(homeDir), beforeHealth, 'global health must stay read-only'); + + const beforeUpdate = snapshotTree(homeDir); + const update = await runCliAsMain(repoDir, ['update', '--global']); + assert.notStrictEqual(update.exitCode, 0, update.output); + assert.match(update.output, /stale Workspine-managed file was modified by the user/); + assert.deepStrictEqual(snapshotTree(homeDir), beforeUpdate, 'blocked update must preserve the entire selected set'); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('safe obsolete manifest-owned global file is advertised and removed by update-global', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const obsoleteRelativePath = 'skills/work-obsolete/SKILL.md'; + const obsoletePath = path.join(homeDir, '.claude', ...obsoleteRelativePath.split('/')); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.mkdirSync(path.dirname(obsoletePath), { recursive: true }); + const originalBytes = 'obsolete package-owned bytes\n'; + fs.writeFileSync(obsoletePath, originalBytes); + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.files[obsoleteRelativePath] = createHash('sha256').update(originalBytes).digest('hex'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + const issue = report.warnings.find((entry) => entry.message.includes(obsoleteRelativePath) && /obsolete/.test(entry.message)); + assert.ok(issue, health.output); + assert.match(issue.fix, /npx -y workspine update --global/); + + const update = await runCliAsMain(repoDir, ['update', '--global']); + assert.strictEqual(update.exitCode, 0, update.output); + assert.ok(!fs.existsSync(obsoletePath), 'safe obsolete manifest-owned file should be removed'); + const clean = await runCliAsMain(repoDir, ['health', '--global', '--json']); + assert.strictEqual(clean.exitCode, 0, clean.output); + assert.strictEqual(JSON.parse(clean.output).status, 'healthy'); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + test('global health reports linked, colliding, and corrupt ownership read-only', async () => { const cases = [ { @@ -1004,6 +1413,15 @@ describe('Health — global agent homes', () => { fs.writeFileSync(path.join(homeDir, '.claude', 'workspine-file-manifest.json'), '{not-json'); }, }, + { + name: 'foreign', + mutate: (homeDir) => { + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.product = 'OtherProduct'; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + }, + }, ]; for (const scenario of cases) { const homeDir = createTempProject(); @@ -1020,6 +1438,9 @@ describe('Health — global agent homes', () => { const parsed = JSON.parse(result.output); assert.strictEqual(parsed.status, 'broken'); assert.match(`${parsed.errors.map((error) => error.message).join('\n')}\n${parsed.warnings.map((warning) => warning.message).join('\n')}`, new RegExp(scenario.name)); + for (const issue of [...parsed.errors, ...parsed.warnings]) { + assert.doesNotMatch(issue.fix, /update --global/, `${scenario.name} must not advertise blocked global update`); + } assert.deepStrictEqual(snapshotTree(homeDir), beforeHome, `${scenario.name} health must be zero-write`); assert.deepStrictEqual(snapshotTree(repoDir), beforeRepo, `${scenario.name} health must not touch repo`); }); @@ -1030,6 +1451,160 @@ describe('Health — global agent homes', () => { } }); + test('global health marks an untracked expected file manual and never emits update-global', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + delete manifest.files['skills/work-plan/SKILL.md']; + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + const before = snapshotTree(homeDir); + + const result = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(result.output); + const issue = [...report.errors, ...report.warnings].find((entry) => /untracked/.test(entry.message)); + assert.ok(issue, result.output); + assert.match(issue.fix, /Preserve the existing file/); + assert.doesNotMatch(issue.fix, /update --global/); + assert.deepStrictEqual(snapshotTree(homeDir), before); + + fs.unlinkSync(path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const beforeMissingOwnership = snapshotTree(homeDir); + const missingOwnership = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const missingReport = JSON.parse(missingOwnership.output); + const missingIssue = missingReport.errors.find((entry) => /ownership-missing/.test(entry.message)); + assert.ok(missingIssue, missingOwnership.output); + assert.match(missingIssue.fix, /Manual ownership repair required/); + assert.doesNotMatch(missingIssue.fix, /update --global/); + assert.deepStrictEqual(snapshotTree(homeDir), beforeMissingOwnership); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('safe missing owned global file emits update-global, restores it, and clears health', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const target = path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.unlinkSync(target); + + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + const issue = report.errors.find((entry) => /skills\/work-plan\/SKILL\.md is missing/.test(entry.message)); + assert.ok(issue, health.output); + assert.match(issue.fix, /npx -y workspine update --global/); + + const repaired = await runCliAsMain(repoDir, ['update', '--global']); + assert.strictEqual(repaired.exitCode, 0, repaired.output); + assert.ok(fs.existsSync(target)); + const clean = await runCliAsMain(repoDir, ['health', '--global', '--json']); + assert.strictEqual(clean.exitCode, 0, clean.output); + assert.strictEqual(JSON.parse(clean.output).status, 'healthy'); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('package-stale owned global bytes are auto-safe and update-global converges', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const target = path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + const manifestPath = path.join(homeDir, '.claude', 'workspine-file-manifest.json'); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + const oldBytes = 'older package-owned work-plan bytes\n'; + fs.writeFileSync(target, oldBytes); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + manifest.files['skills/work-plan/SKILL.md'] = createHash('sha256').update(oldBytes).digest('hex'); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + const issue = report.warnings.find((entry) => /package-stale/.test(entry.message)); + assert.ok(issue, health.output); + assert.match(issue.fix, /npx -y workspine update --global/); + const repaired = await runCliAsMain(repoDir, ['update', '--global']); + assert.strictEqual(repaired.exitCode, 0, repaired.output); + assert.notStrictEqual(fs.readFileSync(target, 'utf-8'), oldBytes); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('mixed safe missing plus manual blocker suppresses update-global everywhere and stays zero-write', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + const missingTarget = path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md'); + const modifiedTarget = path.join(homeDir, '.claude', 'agents', 'work-plan-checker.md'); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.unlinkSync(missingTarget); + fs.appendFileSync(modifiedTarget, '\nuser edit\n'); + const beforeHealth = snapshotTree(homeDir); + + const health = await runCliAsMain(repoDir, ['health', '--global', '--json']); + const report = JSON.parse(health.output); + assert.ok([...report.errors, ...report.warnings].some((entry) => /missing/.test(entry.message))); + assert.ok([...report.errors, ...report.warnings].some((entry) => /modified/.test(entry.message))); + for (const issue of [...report.errors, ...report.warnings]) { + assert.doesNotMatch(issue.fix, /update --global/); + } + assert.deepStrictEqual(snapshotTree(homeDir), beforeHealth, 'mixed global health must be read-only'); + + const beforeUpdate = snapshotTree(homeDir); + const blocked = await runCliAsMain(repoDir, ['update', '--global']); + assert.notStrictEqual(blocked.exitCode, 0, blocked.output); + assert.match(blocked.output, /Manual resolution is required before retrying/); + assert.deepStrictEqual(snapshotTree(homeDir), beforeUpdate, 'blocked selected set must write nothing'); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + + test('partial lost split-root ownership is reported manually instead of omitted as healthy', async () => { + const homeDir = createTempProject(); + const repoDir = createTempProject(); + try { + await withEnv({ GSDD_TEST_HOME: homeDir, XDG_CONFIG_HOME: path.join(homeDir, '.config') }, async () => { + const install = await runCliAsMain(repoDir, ['install', '--global', '--tools', 'opencode']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.unlinkSync(path.join(homeDir, '.agents', 'workspine-file-manifest.json')); + const before = snapshotTree(homeDir); + const result = await runCliAsMain(repoDir, ['health', '--global', '--json']); + assert.strictEqual(result.exitCode, 1, result.output); + const report = JSON.parse(result.output); + const issue = report.errors.find((entry) => /manifest-missing/.test(entry.message)); + assert.ok(issue, result.output); + assert.match(issue.fix, /Manual ownership repair required/); + assert.doesNotMatch(issue.fix, /update --global/); + assert.deepStrictEqual(snapshotTree(homeDir), before); + }); + } finally { + cleanup(homeDir); + cleanup(repoDir); + } + }); + test('global health without an owned manifest fails read-only with guidance', async () => { const homeDir = createTempProject(); const repoDir = createTempProject(); diff --git a/tests/gsdd.init.test.cjs b/tests/gsdd.init.test.cjs index 7714ef9a..4c81f4af 100644 --- a/tests/gsdd.init.test.cjs +++ b/tests/gsdd.init.test.cjs @@ -2816,6 +2816,50 @@ describe('gsdd init and update', () => { } }); + test('interactive legacy migration preflights the wizard-selected AGENTS.md target before renaming', async () => { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + fs.writeFileSync(path.join(tmpDir, 'AGENTS.md'), 'consumer-owned\n'); + const before = snapshotTree(tmpDir); + const initMod = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'init.mjs')); + const gsddMod = await importModule(path.join(__dirname, '..', 'bin', 'gsdd.mjs')); + const configMod = await importModule(path.join(__dirname, '..', 'bin', 'lib', 'config.mjs')); + const restoreStdin = setInteractiveStdin(); + const previousExitCode = process.exitCode; + const originalConsoleError = console.error; + const errors = []; + let wizardCalls = 0; + try { + const ctx = gsddMod.createCliContext(tmpDir); + ctx.initPromptApi = { + confirmLegacyMigration: async () => true, + runInitWizard: async () => { + wizardCalls += 1; + return { + selectedRuntimes: [], + adapterTargets: ['agents'], + config: configMod.buildDefaultConfig(), + }; + }, + }; + console.error = (...args) => errors.push(args.join(' ')); + process.exitCode = undefined; + + await initMod.createCmdInit(ctx)(); + + assert.strictEqual(process.exitCode, 1); + assert.strictEqual(wizardCalls, 1); + assert.match(errors.join('\n'), /Refusing adapter update: AGENTS\.md exists without a generation manifest/); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning')), 'adapter refusal must precede the rename'); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work')), false); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'interactive adapter refusal must preserve every path and byte'); + } finally { + console.error = originalConsoleError; + process.exitCode = previousExitCode; + restoreStdin(); + } + }); + test('models and rigor commands refuse supported legacy state without writes', async () => { fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); @@ -2845,6 +2889,95 @@ describe('gsdd init and update', () => { assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'bin', 'gsdd.mjs'))); }); + for (const { relativePath, tools } of [ + { relativePath: 'AGENTS.md', tools: 'agents' }, + { relativePath: '.agents/skills/work-plan/SKILL.md', tools: 'claude' }, + { relativePath: '.claude/skills/work-plan/SKILL.md', tools: 'claude' }, + { relativePath: '.codex/agents/work-plan-checker.toml', tools: 'codex' }, + ]) { + test(`legacy migration preflights selected adapter collision at ${relativePath}`, async () => { + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + const collisionPath = path.join(tmpDir, ...relativePath.split('/')); + fs.mkdirSync(path.dirname(collisionPath), { recursive: true }); + fs.writeFileSync(collisionPath, 'consumer-owned\n'); + const before = snapshotTree(tmpDir); + + const result = await runCliAsMain(tmpDir, ['init', '--migrate', '--auto', '--tools', tools]); + + assert.strictEqual(result.exitCode, 1, result.output); + assert.ok( + result.output.includes(`Refusing adapter update: ${relativePath} exists without a generation manifest`), + result.output, + ); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning')), 'adapter refusal must precede the rename'); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work')), false); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'adapter refusal must preserve every path and byte'); + }); + } + + for (const ownership of ['missing', 'corrupt', 'incomplete', 'unowned']) { + test(`legacy migration preflight preserves bytes for ${ownership} template ownership`, async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(legacy, 'templates'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + fs.writeFileSync(path.join(legacy, 'templates', 'spec.md'), '# Keep the existing template\n'); + fs.writeFileSync(path.join(legacy, 'consumer.bin'), Buffer.from([0, 1, 13, 10, 255])); + if (ownership !== 'missing') { + const manifest = ownership === 'corrupt' ? '{broken' : JSON.stringify(ownership === 'incomplete' + ? { templates: { root: {} }, roles: {} } + : { templates: { delegates: {}, research: {}, codebase: {}, brownfieldChange: {}, root: {} }, roles: {} }); + fs.writeFileSync(path.join(legacy, 'generation-manifest.json'), manifest); + } + const before = snapshotTree(tmpDir); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const result = await runCliAsMain(tmpDir, ['init', '--migrate', '--auto', '--tools', 'agents']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /generation manifest ownership is missing or corrupt|not manifest-owned/); + assert.ok(fs.existsSync(legacy), 'failed preflight must leave .planning in place'); + assert.strictEqual(fs.existsSync(path.join(tmpDir, '.work')), false); + assert.deepStrictEqual(snapshotTree(tmpDir), before, 'refusal and retry must preserve every path and byte'); + } + }); + } + + test('legacy migration preflight checks the destination tracking policy before renaming', async () => { + fs.mkdirSync(path.join(tmpDir, '.planning')); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ initVersion: 'v1.1', commitDocs: true })); + fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.work/\n'); + const before = snapshotTree(tmpDir); + + const result = await runCliAsMain(tmpDir, ['init', '--migrate', '--auto', '--tools', 'agents']); + assert.strictEqual(result.exitCode, 1, result.output); + assert.match(result.output, /\.work\/ is already ignored but commitDocs is true/); + assert.ok(fs.existsSync(path.join(tmpDir, '.planning')), 'tracking refusal must precede the rename'); + assert.deepStrictEqual(snapshotTree(tmpDir), before); + }); + + test('legacy migration preflight permits owned templates and retains unrelated consumer bytes', async () => { + const legacy = path.join(tmpDir, '.planning'); + fs.mkdirSync(path.join(legacy, 'templates'), { recursive: true }); + fs.writeFileSync(path.join(legacy, 'config.json'), JSON.stringify({ initVersion: 'v1.1' })); + const template = '# Older generated spec template\n'; + fs.writeFileSync(path.join(legacy, 'templates', 'spec.md'), template); + const templateHash = require('node:crypto').createHash('sha256').update(template).digest('hex'); + fs.writeFileSync(path.join(legacy, 'generation-manifest.json'), JSON.stringify({ + templates: { delegates: {}, research: {}, codebase: {}, brownfieldChange: {}, root: { 'spec.md': templateHash } }, + roles: {}, + })); + const consumerBytes = Buffer.from([0, 1, 13, 10, 255]); + fs.writeFileSync(path.join(legacy, 'consumer.bin'), consumerBytes); + + const result = await runCliAsMain(tmpDir, ['init', '--migrate', '--auto', '--tools', 'agents']); + assert.strictEqual(result.exitCode, 0, result.output); + assert.strictEqual(fs.existsSync(legacy), false); + assert.deepStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'consumer.bin')), consumerBytes); + assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'migration-receipt.json'))); + assert.ok(fs.existsSync(path.join(tmpDir, '.work', 'bin', 'gsdd.mjs'))); + assert.notStrictEqual(fs.readFileSync(path.join(tmpDir, '.work', 'templates', 'spec.md'), 'utf8'), template); + }); + test('init --migrate refuses legacy decision content and receipt collisions before writes', async () => { for (const collision of ['decisions', 'migration-receipt.json']) { fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true }); diff --git a/tests/gsdd.invariants.test.cjs b/tests/gsdd.invariants.test.cjs index 48acfdfa..f276216e 100644 --- a/tests/gsdd.invariants.test.cjs +++ b/tests/gsdd.invariants.test.cjs @@ -1947,19 +1947,19 @@ describe('G12 — Documentation Accuracy Guards', () => { }); // G12.5: No "(planned)" for implemented features - test('agents/README.md does not say "(planned)" for gsdd update --templates', () => { + test('agents/README.md does not say "(planned)" for repo-local update', () => { assert.ok( !agentsReadme.includes('(planned)'), 'agents/README.md still says "(planned)" for an implemented feature. FIX: Remove "(planned)" and describe current behavior.' ); }); - // G12.6: Update command documentation mentions --templates - test('User Guide update command mentions --templates while README links to it', () => { - assert.ok( - userGuide.includes('--templates'), - 'User Guide update command documentation does not mention --templates. FIX: Add --templates to the detailed command reference.' - ); + // G12.6: Update command documentation stays aligned with selector-free repo repair. + test('User Guide documents selector-free update while README links to it', () => { + assert.match(userGuide, /npx -y workspine update/, + 'User Guide must document the supported whole-repo update command.'); + assert.doesNotMatch(userGuide, /workspine update --(?:templates|tools)/, + 'User Guide must not advertise retired repo-local update selectors.'); assert.match(rootReadme, /\[User Guide\]\(docs\/USER-GUIDE\.md\)/); }); diff --git a/tests/gsdd.manifest.test.cjs b/tests/gsdd.manifest.test.cjs index c114e819..f4bc6dc2 100644 --- a/tests/gsdd.manifest.test.cjs +++ b/tests/gsdd.manifest.test.cjs @@ -622,7 +622,7 @@ describe('generation manifest', () => { assert.doesNotMatch(result.output, /updated root AGENTS\.md/, 'unchanged governance bytes must not be reported as updated'); }); - test('repository update inspects stale global ownership read-only and prints the explicit next command', async () => { + test('repository update routes unsafe global modification to read-only global health and manual attention', async () => { await initProject(); const homeDir = createTempProject(); try { @@ -637,7 +637,9 @@ describe('generation manifest', () => { assert.strictEqual(result.exitCode, 0, result.output); assert.match(result.output, /Repository update complete\. Global agent surfaces were not changed\./); - assert.match(result.output, /npx -y workspine update --global/); + assert.match(result.output, /npx -y workspine health --global/); + assert.match(result.output, /manual attention/i); + assert.doesNotMatch(result.output, /npx -y workspine update --global/); assert.deepStrictEqual(snapshotTree(homeDir), beforeHome, 'repository update must not write personal agent homes'); }); } finally { @@ -645,6 +647,28 @@ describe('generation manifest', () => { } }); + test('repository update advises update-global only for an auto-safe missing owned global file', async () => { + await initProject(); + const homeDir = createTempProject(); + try { + await withEnv({ GSDD_TEST_HOME: homeDir }, async () => { + const install = await runCliAsMain(tmpDir, ['install', '--global', '--tools', 'claude']); + assert.strictEqual(install.exitCode, 0, install.output); + fs.unlinkSync(path.join(homeDir, '.claude', 'skills', 'work-plan', 'SKILL.md')); + const beforeHome = snapshotTree(homeDir); + + const result = await runCliAsMain(tmpDir, ['update']); + + assert.strictEqual(result.exitCode, 0, result.output); + assert.match(result.output, /npx -y workspine update --global/); + assert.doesNotMatch(result.output, /manual attention/i); + assert.deepStrictEqual(snapshotTree(homeDir), beforeHome, 'repository update advisory must stay read-only for safe global drift'); + }); + } finally { + cleanup(homeDir); + } + }); + test('plain update reconciles templates as owned outputs', async () => { await initProject(); diff --git a/tests/gsdd.state-dir.test.cjs b/tests/gsdd.state-dir.test.cjs index bafb5c7b..7f4bcea6 100644 --- a/tests/gsdd.state-dir.test.cjs +++ b/tests/gsdd.state-dir.test.cjs @@ -198,6 +198,22 @@ describe('canonical Workspine state classification', () => { } }); + test('linked current .work root is unsafe and authority refuses it', async () => { + const { resolveStateDir, stateAuthorityGate } = await loadModule(STATE_DIR_MODULE); + const outside = createTempProject(); + try { + fs.symlinkSync(outside, path.join(tmp, '.work'), process.platform === 'win32' ? 'junction' : 'dir'); + const state = resolveStateDir(tmp); + assert.strictEqual(state.status, 'current_unsafe'); + assert.strictEqual(state.reason, 'linked_current_root'); + const gate = stateAuthorityGate(state); + assert.strictEqual(gate.allowed, false); + assert.match(gate.message, /\.work\/.*real directory/i); + } finally { + cleanup(outside); + } + }); + test('shared authority gate emits the exact explicit migration command', async () => { const { resolveStateDir, stateAuthorityGate, MIGRATION_COMMAND } = await loadModule(STATE_DIR_MODULE); writeLegacyConfig(tmp);