From f66dd4f3ed4ed41e100a1fcf171c09704b6b3751 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 19:34:58 +0800 Subject: [PATCH 01/20] feat: add deterministic change recovery resolution --- scripts/lib/change-recovery.mjs | 84 ++++++++++++++++++++++++++++++ tests/lib/change-recovery.test.mjs | 47 +++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 scripts/lib/change-recovery.mjs create mode 100644 tests/lib/change-recovery.test.mjs diff --git a/scripts/lib/change-recovery.mjs b/scripts/lib/change-recovery.mjs new file mode 100644 index 0000000..a78f6ea --- /dev/null +++ b/scripts/lib/change-recovery.mjs @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { readState } from './state-loader.mjs'; + +const RECOGNIZABLE_ARTIFACTS = [ + '.spec-superflow.yaml', + 'proposal.md', + 'tasks.md', + 'execution-contract.md', +]; + +export class RecoveryError extends Error { + constructor(code, message, details = {}, exitCode = 1) { + super(message); + this.code = code; + this.details = details; + this.exitCode = exitCode; + } +} + +export function resolveChangeTarget(input, cwd = process.cwd()) { + if (hasText(input)) return inspectExplicitTarget(input, cwd); + + const candidates = listRecognizableChanges(join(cwd, 'changes')) + .filter(change => !['closing', 'abandoned'].includes(change.state)); + if (candidates.length === 1) return { ...candidates[0], selection: 'only-active' }; + if (candidates.length === 0) { + throw new RecoveryError('NO_ACTIVE_CHANGE', 'No active change found', { candidates: [] }); + } + throw new RecoveryError('AMBIGUOUS_CHANGE', 'Multiple active changes found', { + candidates: candidates.map(change => change.name).sort(), + }); +} + +function hasText(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function inspectExplicitTarget(input, cwd) { + const requested = input.trim(); + const directPath = resolve(cwd, requested); + const changesPath = resolve(cwd, 'changes', requested); + const targetPath = [directPath, changesPath].find(isRecognizableChange); + + if (!targetPath) { + throw new RecoveryError('TARGET_NOT_FOUND', 'Change target was not found', { + input: requested, + }); + } + + return describeChange(targetPath, 'explicit'); +} + +function listRecognizableChanges(changesDir) { + if (!isDirectory(changesDir)) return []; + + return fs.readdirSync(changesDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => join(changesDir, entry.name)) + .filter(isRecognizableChange) + .map(changeDir => describeChange(changeDir)); +} + +function isRecognizableChange(changeDir) { + return isDirectory(changeDir) && RECOGNIZABLE_ARTIFACTS + .some(artifact => fs.existsSync(join(changeDir, artifact))); +} + +function isDirectory(candidate) { + try { + return fs.statSync(candidate).isDirectory(); + } catch { + return false; + } +} + +function describeChange(changeDir, selection) { + return { + name: basename(changeDir), + path: changeDir, + state: readState(changeDir).state, + ...(selection ? { selection } : {}), + }; +} diff --git a/tests/lib/change-recovery.test.mjs b/tests/lib/change-recovery.test.mjs new file mode 100644 index 0000000..4d16f59 --- /dev/null +++ b/tests/lib/change-recovery.test.mjs @@ -0,0 +1,47 @@ +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveChangeTarget } from '../../scripts/lib/change-recovery.mjs'; + +describe('change-recovery: resolveChangeTarget()', () => { + let root; + + before(() => { + root = mkdtempSync(join(tmpdir(), 'ssf-change-recovery-test-')); + mkdirSync(join(root, 'changes')); + }); + + after(() => { + if (root) rmSync(root, { recursive: true, force: true }); + }); + + function makeChange(name, state) { + const changeDir = join(root, 'changes', name); + mkdirSync(changeDir); + writeFileSync(join(changeDir, '.spec-superflow.yaml'), `state: ${state}\n`); + return changeDir; + } + + it('selects the only active change and rejects ambiguous recovery', () => { + makeChange('alpha', 'executing'); + assert.equal(resolveChangeTarget(undefined, root).name, 'alpha'); + makeChange('beta', 'specifying'); + assert.throws( + () => resolveChangeTarget(undefined, root), + error => { + assert.equal(error.code, 'AMBIGUOUS_CHANGE'); + assert.deepEqual(error.details.candidates, ['alpha', 'beta']); + return true; + }, + ); + }); + + it('requires switch targets to resolve to recognizable changes', () => { + assert.throws( + () => resolveChangeTarget('missing', root), + error => error.code === 'TARGET_NOT_FOUND', + ); + }); +}); From 39463c7a1c972955b214e0461aadee87aa3fcc2f Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 19:41:21 +0800 Subject: [PATCH 02/20] feat: aggregate auditable change recovery context --- scripts/lib/change-recovery.mjs | 125 +++++++++++++++++++++++++++++ tests/lib/change-recovery.test.mjs | 56 ++++++++++++- 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/scripts/lib/change-recovery.mjs b/scripts/lib/change-recovery.mjs index a78f6ea..1128b46 100644 --- a/scripts/lib/change-recovery.mjs +++ b/scripts/lib/change-recovery.mjs @@ -1,5 +1,7 @@ import fs from 'node:fs'; import { basename, join, resolve } from 'node:path'; +import { describeWaves, readPlan, validatePlan } from './execution-plan.mjs'; +import { listCheckpoints, listHandoffs } from './sdd-overlay.mjs'; import { readState } from './state-loader.mjs'; const RECOGNIZABLE_ARTIFACTS = [ @@ -32,6 +34,129 @@ export function resolveChangeTarget(input, cwd = process.cwd()) { }); } +export function createRecoverySummary(changeDir) { + const state = readState(changeDir); + const terminal = ['closing', 'abandoned'].includes(state.state); + const checkpoints = listCheckpoints(changeDir) + .sort((a, b) => String(b.created_at).localeCompare(String(a.created_at))); + const handoffs = partitionHandoffs(listHandoffs(changeDir)); + const execution = inspectExecution(changeDir, state.state); + const blockers = terminal ? [] : buildBlockers(changeDir, handoffs, execution); + + return { + ok: blockers.length === 0, + change: { name: basename(changeDir), path: resolve(changeDir) }, + state: state.state, + workflow: state.workflow, + terminal, + checkpoint: checkpoints[0] + ? { status: checkpoints[0].stale ? 'stale' : 'current', record: checkpoints[0] } + : null, + handoffs, + execution, + blockers, + next_action: selectNextAction(changeDir, state, terminal, checkpoints[0], blockers), + }; +} + +function partitionHandoffs(records) { + const handoffs = { active: [], result_ready: [], resolved: [] }; + for (const record of records) { + if (record.status === 'active') handoffs.active.push(record); + if (record.status === 'result-ready') handoffs.result_ready.push(record); + if (record.status === 'resolved') handoffs.resolved.push(record); + } + for (const status of Object.keys(handoffs)) { + handoffs[status].sort((a, b) => String(a.id).localeCompare(String(b.id))); + } + return handoffs; +} + +function inspectExecution(changeDir, state) { + const required = ['approved-for-build', 'executing', 'debugging'].includes(state); + const plan = readPlan(changeDir); + if (!plan) { + return { + required, + present: false, + current: false, + revision: null, + next_eligible_wave: null, + failures: ['execution plan is missing'], + }; + } + + const validation = validatePlan(changeDir, plan); + const waves = validation.valid ? describeWaves(changeDir, plan) : []; + return { + required, + present: true, + current: validation.valid, + revision: plan.revision ?? null, + next_eligible_wave: waves.find(wave => wave.eligible)?.id ?? null, + failures: validation.failures, + }; +} + +function buildBlockers(changeDir, handoffs, execution) { + const handoffBlockers = handoffs.result_ready.map(handoff => ({ + code: 'HANDOFF_REVIEW_REQUIRED', + handoff: handoff.id, + message: `Handoff '${handoff.id}' is ready for review`, + command: `ssf handoff resolve ${changeDir} ${handoff.id} --decision `, + })); + if (!execution.required || execution.current) return handoffBlockers; + + return [ + ...handoffBlockers, + { + code: execution.present ? 'EXECUTION_PLAN_STALE' : 'EXECUTION_PLAN_REQUIRED', + message: execution.present + ? 'Execution plan is invalid or stale' + : 'A current execution plan is required', + failures: execution.failures, + }, + ]; +} + +function selectNextAction(changeDir, state, terminal, checkpoint, blockers) { + if (terminal) { + return { skill: 'none', command: null, reason: 'Change is terminal' }; + } + if (blockers[0]?.code === 'HANDOFF_REVIEW_REQUIRED') { + return { + skill: 'workflow-start', + command: blockers[0].command, + reason: blockers[0].message, + }; + } + if (blockers[0]?.code === 'EXECUTION_PLAN_REQUIRED' || blockers[0]?.code === 'EXECUTION_PLAN_STALE') { + return { + skill: 'build-executor', + command: null, + reason: 'Rebuild a current execution plan before implementation', + }; + } + + const routes = { + exploring: 'need-explorer', + specifying: 'spec-writer', + bridging: 'contract-builder', + 'approved-for-build': 'build-executor', + executing: 'build-executor', + debugging: 'bug-investigator', + }; + const route = routes[state.state] ?? 'workflow-start'; + const checkpointContext = checkpoint && !checkpoint.stale + ? ` Current checkpoint: ${checkpoint.next}` + : ''; + return { + skill: route, + command: null, + reason: `Route from ${state.state}.${checkpointContext}`, + }; +} + function hasText(value) { return typeof value === 'string' && value.trim().length > 0; } diff --git a/tests/lib/change-recovery.test.mjs b/tests/lib/change-recovery.test.mjs index 4d16f59..1145d4e 100644 --- a/tests/lib/change-recovery.test.mjs +++ b/tests/lib/change-recovery.test.mjs @@ -3,7 +3,8 @@ import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { resolveChangeTarget } from '../../scripts/lib/change-recovery.mjs'; +import { createRecoverySummary, resolveChangeTarget } from '../../scripts/lib/change-recovery.mjs'; +import { createHandoff, finishHandoff, saveCheckpoint } from '../../scripts/lib/sdd-overlay.mjs'; describe('change-recovery: resolveChangeTarget()', () => { let root; @@ -24,6 +25,35 @@ describe('change-recovery: resolveChangeTarget()', () => { return changeDir; } + function makeExecutableChange(name) { + const changeDir = makeChange(name, 'executing'); + writeFileSync(join(changeDir, 'tasks.md'), '- [ ] 1.1 Recovery summary\n'); + return changeDir; + } + + function makeStaleCheckpoint(changeDir, taskId) { + saveCheckpoint(changeDir, { taskId, next: 'Use this stale recovery note' }); + writeFileSync(join(changeDir, 'tasks.md'), '- [x] 1.1 Recovery summary\n'); + } + + function makeResultReadyHandoff(changeDir, id) { + const handoff = createHandoff(changeDir, { + id, + type: 'research', + title: 'Recovery research', + question: 'What needs review?', + }); + writeFileSync(join(handoff.directory, 'HANDOFF_RESULT.md'), [ + '## Conclusion\nReady', + '## Evidence\nRecorded', + '## Produced Artifacts\nNone', + '## Risks\nNone', + '## Suggested Changes\nNone', + '', + ].join('\n\n')); + finishHandoff(changeDir, id); + } + it('selects the only active change and rejects ambiguous recovery', () => { makeChange('alpha', 'executing'); assert.equal(resolveChangeTarget(undefined, root).name, 'alpha'); @@ -44,4 +74,28 @@ describe('change-recovery: resolveChangeTarget()', () => { error => error.code === 'TARGET_NOT_FOUND', ); }); + + it('prioritizes result-ready handoffs over execution-plan blockers', () => { + const change = makeExecutableChange('summary-alpha'); + makeStaleCheckpoint(change, '1.1'); + makeResultReadyHandoff(change, 'research-1'); + + const summary = createRecoverySummary(change); + + assert.equal(summary.checkpoint.status, 'stale'); + assert.equal(summary.blockers[0].code, 'HANDOFF_REVIEW_REQUIRED'); + assert.equal( + summary.next_action.command, + `ssf handoff resolve ${change} research-1 --decision `, + ); + }); + + it('returns no next skill for terminal changes', () => { + const change = makeChange('done', 'closing'); + const summary = createRecoverySummary(change); + + assert.equal(summary.terminal, true); + assert.equal(summary.next_action.skill, 'none'); + assert.deepEqual(summary.blockers, []); + }); }); From 9559705f6f74ae93703ab7c07173e66aaff800f7 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 19:46:18 +0800 Subject: [PATCH 03/20] fix: preserve recovery blockers for malformed plans --- scripts/lib/change-recovery.mjs | 44 +++++++++++++++--------- tests/lib/change-recovery.test.mjs | 55 +++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 17 deletions(-) diff --git a/scripts/lib/change-recovery.mjs b/scripts/lib/change-recovery.mjs index 1128b46..228857c 100644 --- a/scripts/lib/change-recovery.mjs +++ b/scripts/lib/change-recovery.mjs @@ -74,28 +74,40 @@ function partitionHandoffs(records) { function inspectExecution(changeDir, state) { const required = ['approved-for-build', 'executing', 'debugging'].includes(state); - const plan = readPlan(changeDir); - if (!plan) { + let plan = null; + try { + plan = readPlan(changeDir); + if (!plan) { + return { + required, + present: false, + current: false, + revision: null, + next_eligible_wave: null, + failures: ['execution plan is missing'], + }; + } + + const validation = validatePlan(changeDir, plan); + const waves = validation.valid ? describeWaves(changeDir, plan) : []; + return { + required, + present: true, + current: validation.valid, + revision: plan.revision ?? null, + next_eligible_wave: waves.find(wave => wave.eligible)?.id ?? null, + failures: validation.failures, + }; + } catch (error) { return { required, - present: false, + present: true, current: false, - revision: null, + revision: plan?.revision ?? null, next_eligible_wave: null, - failures: ['execution plan is missing'], + failures: [error instanceof Error ? error.message : String(error)], }; } - - const validation = validatePlan(changeDir, plan); - const waves = validation.valid ? describeWaves(changeDir, plan) : []; - return { - required, - present: true, - current: validation.valid, - revision: plan.revision ?? null, - next_eligible_wave: waves.find(wave => wave.eligible)?.id ?? null, - failures: validation.failures, - }; } function buildBlockers(changeDir, handoffs, execution) { diff --git a/tests/lib/change-recovery.test.mjs b/tests/lib/change-recovery.test.mjs index 1145d4e..49c3dc7 100644 --- a/tests/lib/change-recovery.test.mjs +++ b/tests/lib/change-recovery.test.mjs @@ -54,6 +54,12 @@ describe('change-recovery: resolveChangeTarget()', () => { finishHandoff(changeDir, id); } + function makeMalformedPlan(changeDir) { + const planDir = join(changeDir, '.superpowers', 'sdd'); + mkdirSync(planDir, { recursive: true }); + writeFileSync(join(planDir, 'execution-plan.json'), '{not valid JSON'); + } + it('selects the only active change and rejects ambiguous recovery', () => { makeChange('alpha', 'executing'); assert.equal(resolveChangeTarget(undefined, root).name, 'alpha'); @@ -83,13 +89,51 @@ describe('change-recovery: resolveChangeTarget()', () => { const summary = createRecoverySummary(change); assert.equal(summary.checkpoint.status, 'stale'); - assert.equal(summary.blockers[0].code, 'HANDOFF_REVIEW_REQUIRED'); + assert.deepEqual( + summary.blockers.map(blocker => blocker.code), + ['HANDOFF_REVIEW_REQUIRED', 'EXECUTION_PLAN_REQUIRED'], + ); assert.equal( summary.next_action.command, `ssf handoff resolve ${change} research-1 --decision `, ); }); + it('keeps malformed-plan failures after sorted result-ready handoff blockers', () => { + const change = makeExecutableChange('malformed-plan'); + makeResultReadyHandoff(change, 'research-z'); + makeResultReadyHandoff(change, 'research-a'); + makeMalformedPlan(change); + + const summary = createRecoverySummary(change); + + assert.equal(summary.execution.current, false); + assert.match(summary.execution.failures[0], /Unable to read execution plan/); + assert.deepEqual( + summary.blockers.map(blocker => blocker.code), + ['HANDOFF_REVIEW_REQUIRED', 'HANDOFF_REVIEW_REQUIRED', 'EXECUTION_PLAN_STALE'], + ); + assert.deepEqual( + summary.blockers.map(blocker => blocker.handoff), + ['research-a', 'research-z', undefined], + ); + assert.equal( + summary.next_action.command, + `ssf handoff resolve ${change} research-a --decision `, + ); + }); + + it('requires a current plan throughout execution states', () => { + for (const state of ['approved-for-build', 'executing', 'debugging']) { + const change = makeChange(`requires-plan-${state}`, state); + const summary = createRecoverySummary(change); + + assert.equal(summary.execution.required, true); + assert.deepEqual(summary.blockers.map(blocker => blocker.code), ['EXECUTION_PLAN_REQUIRED']); + assert.equal(summary.next_action.skill, 'build-executor'); + } + }); + it('returns no next skill for terminal changes', () => { const change = makeChange('done', 'closing'); const summary = createRecoverySummary(change); @@ -98,4 +142,13 @@ describe('change-recovery: resolveChangeTarget()', () => { assert.equal(summary.next_action.skill, 'none'); assert.deepEqual(summary.blockers, []); }); + + it('returns no next skill for abandoned changes', () => { + const change = makeChange('abandoned', 'abandoned'); + const summary = createRecoverySummary(change); + + assert.equal(summary.terminal, true); + assert.equal(summary.next_action.skill, 'none'); + assert.deepEqual(summary.blockers, []); + }); }); From 3dd0bcec4d71f54aa0b98aa6652a012d81c60686 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 19:53:02 +0800 Subject: [PATCH 04/20] feat: add resume and switch commands --- scripts/lib/cmd-resume.mjs | 5 ++ scripts/lib/cmd-switch.mjs | 5 ++ scripts/lib/recovery-command.mjs | 90 +++++++++++++++++++++++++++ scripts/spec-superflow.mjs | 8 +++ tests/lib/cmd-recovery.test.mjs | 102 +++++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+) create mode 100644 scripts/lib/cmd-resume.mjs create mode 100644 scripts/lib/cmd-switch.mjs create mode 100644 scripts/lib/recovery-command.mjs create mode 100644 tests/lib/cmd-recovery.test.mjs diff --git a/scripts/lib/cmd-resume.mjs b/scripts/lib/cmd-resume.mjs new file mode 100644 index 0000000..11bb114 --- /dev/null +++ b/scripts/lib/cmd-resume.mjs @@ -0,0 +1,5 @@ +import { runRecoveryCommand } from './recovery-command.mjs'; + +export async function run(args) { + await runRecoveryCommand('resume', args); +} diff --git a/scripts/lib/cmd-switch.mjs b/scripts/lib/cmd-switch.mjs new file mode 100644 index 0000000..b74b9c6 --- /dev/null +++ b/scripts/lib/cmd-switch.mjs @@ -0,0 +1,5 @@ +import { runRecoveryCommand } from './recovery-command.mjs'; + +export async function run(args) { + await runRecoveryCommand('switch', args, { requireTarget: true }); +} diff --git a/scripts/lib/recovery-command.mjs b/scripts/lib/recovery-command.mjs new file mode 100644 index 0000000..026b5d5 --- /dev/null +++ b/scripts/lib/recovery-command.mjs @@ -0,0 +1,90 @@ +import { parseArgs } from 'node:util'; +import { RecoveryError, createRecoverySummary, resolveChangeTarget } from './change-recovery.mjs'; + +export async function runRecoveryCommand(command, args, { requireTarget = false } = {}) { + let values = { json: args.includes('--json') }; + + try { + const parsed = parseArgs({ + args, + allowPositionals: true, + options: { json: { type: 'boolean', default: false } }, + }); + values = parsed.values; + + if (requireTarget && !parsed.positionals[0]) { + throw new RecoveryError( + 'TARGET_REQUIRED', + 'switch requires an explicit change target', + {}, + 2, + ); + } + + const selection = resolveChangeTarget(parsed.positionals[0], process.cwd()); + const summary = createRecoverySummary(selection.path); + printRecoverySummary(values.json, { + ok: summary.ok, + command, + change: { ...summary.change, ...selection }, + state: summary.state, + workflow: summary.workflow, + terminal: summary.terminal, + checkpoint: summary.checkpoint, + handoffs: summary.handoffs, + execution: summary.execution, + blockers: summary.blockers, + next_action: summary.next_action, + }); + } catch (error) { + printRecoveryError(command, error, values.json); + } +} + +function printRecoverySummary(json, summary) { + if (json) { + console.log(JSON.stringify(summary)); + return; + } + + const checkpoint = summary.checkpoint + ? `${summary.checkpoint.status} (${summary.checkpoint.record.task_id})` + : 'none'; + const execution = summary.execution.required + ? `current ${summary.execution.current ? 'yes' : 'no'}` + : 'current n/a'; + const handoffs = summary.handoffs; + const nextAction = summary.next_action.command + ?? `${summary.next_action.skill}: ${summary.next_action.reason}`; + + console.log([ + `Change: ${summary.change.name}`, + `State: ${summary.state}`, + `Checkpoint: ${checkpoint}`, + `Handoffs: active ${handoffs.active.length}, result-ready ${handoffs.result_ready.length}, resolved ${handoffs.resolved.length}`, + `Execution: ${execution}`, + summary.blockers.length === 0 + ? 'Blockers: none' + : `Blockers: ${summary.blockers.map(blocker => blocker.message).join('; ')}`, + `Next action: ${nextAction}`, + ].join('\n')); +} + +function printRecoveryError(command, error, json) { + const recoveryError = error instanceof RecoveryError + ? error + : new RecoveryError('INVALID_ARGUMENTS', error instanceof Error ? error.message : String(error), {}, 2); + const payload = { + ok: false, + command, + error: { + code: recoveryError.code, + message: recoveryError.message, + details: recoveryError.details, + }, + }; + + if (json) console.log(JSON.stringify(payload)); + else console.error(`${recoveryError.code}: ${recoveryError.message}`); + process.exitCode = recoveryError.exitCode; +} diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index b4cb546..f064a9c 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -18,6 +18,8 @@ const COMMANDS = { handoff: () => import('./lib/cmd-handoff.mjs'), isolate: () => import('./lib/cmd-isolate.mjs'), execution: () => import('./lib/cmd-execution.mjs'), + resume: () => import('./lib/cmd-resume.mjs'), + switch: () => import('./lib/cmd-switch.mjs'), runtime: () => import('./lib/cmd-runtime.mjs'), 'install-cursor': () => import('./lib/cmd-install-cursor.mjs'), 'install-workbuddy': () => import('./lib/cmd-install-workbuddy.mjs'), @@ -72,6 +74,10 @@ Commands: Upgrade inline/batch to SDD, or replan existing SDD waves, as a new revision execution review --wave --base --head --report --verdict pass|fail Record one review receipt for a planned wave + resume [change-dir] [--json] + Recover the only active change or an explicit change context + switch [--json] + Recover an explicit change context without changing the shell runtime check-update Run a portable update check for canonical skills runtime infer Infer workflow mode without a plugin-root path runtime guard ... Run a portable phase-transition guard @@ -110,6 +116,8 @@ Examples: ssf checkpoint save changes/my-change/ --task 1.1 --next "Run focused tests" ssf checkpoint list changes/my-change/ ssf handoff create changes/my-change/ --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" + ssf resume changes/my-change --json + ssf switch changes/another-change ssf install-cursor ssf install-workbuddy ssf install-cline --local /path/to/spec-superflow diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs new file mode 100644 index 0000000..5fbf8b8 --- /dev/null +++ b/tests/lib/cmd-recovery.test.mjs @@ -0,0 +1,102 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); +let root; + +function runSsf(args) { + try { + const stdout = execFileSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], + }); + return { status: 0, stdout, stderr: '' }; + } catch (error) { + return { + status: error.status || 1, + stdout: error.stdout?.toString() || '', + stderr: error.stderr?.toString() || error.message, + }; + } +} + +function makeChange(name, state) { + const changeDir = join(root, name); + mkdirSync(changeDir); + writeFileSync(join(changeDir, '.spec-superflow.yaml'), `state: ${state}\nworkflow: full\n`); + return changeDir; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'ssf-cmd-recovery-')); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('ssf resume and switch', () => { + it('returns one stable JSON object from resume', () => { + const change = makeChange('alpha', 'specifying'); + const result = runSsf(['resume', change, '--json']); + + assert.equal(result.status, 0, result.stderr); + const value = JSON.parse(result.stdout); + assert.deepEqual( + Object.keys(value).filter(key => + ['ok', 'command', 'change', 'state', 'terminal', 'blockers', 'next_action'].includes(key), + ), + ['ok', 'command', 'change', 'state', 'terminal', 'blockers', 'next_action'], + ); + assert.equal(value.command, 'resume'); + }); + + it('rejects switch without an explicit target as JSON', () => { + const result = runSsf(['switch', '--json']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'switch', + error: { + code: 'TARGET_REQUIRED', + message: 'switch requires an explicit change target', + details: {}, + }, + }); + }); + + it('returns a single JSON object for recovery domain errors', () => { + const result = runSsf(['resume', join(root, 'missing'), '--json']); + + assert.equal(result.status, 1); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'resume', + error: { + code: 'TARGET_NOT_FOUND', + message: 'Change target was not found', + details: { input: join(root, 'missing') }, + }, + }); + }); + + it('renders the complete recovery context as text without changing the target', () => { + const change = makeChange('alpha', 'specifying'); + const stateBefore = readFileSync(join(change, '.spec-superflow.yaml'), 'utf8'); + + const result = runSsf(['switch', change]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /State: specifying/); + assert.match(result.stdout, /Checkpoint: none/); + assert.match(result.stdout, /Handoffs: active 0, result-ready 0, resolved 0/); + assert.match(result.stdout, /Execution: current n\/a/); + assert.match(result.stdout, /Blockers: none/); + assert.match(result.stdout, /Next action:/); + assert.equal(readFileSync(join(change, '.spec-superflow.yaml'), 'utf8'), stateBefore); + }); +}); From cd84f0022f87035712c5fac308f96cd9ea3ebb19 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 19:58:31 +0800 Subject: [PATCH 05/20] fix: validate recovery command arguments --- scripts/lib/recovery-command.mjs | 27 ++++++++++++++++++++++--- tests/lib/cmd-recovery.test.mjs | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/scripts/lib/recovery-command.mjs b/scripts/lib/recovery-command.mjs index 026b5d5..15a0eb3 100644 --- a/scripts/lib/recovery-command.mjs +++ b/scripts/lib/recovery-command.mjs @@ -12,6 +12,15 @@ export async function runRecoveryCommand(command, args, { requireTarget = false }); values = parsed.values; + if (parsed.positionals.length > 1) { + throw new RecoveryError( + 'INVALID_ARGUMENTS', + `${command} accepts at most one change target`, + { positionals: parsed.positionals }, + 2, + ); + } + if (requireTarget && !parsed.positionals[0]) { throw new RecoveryError( 'TARGET_REQUIRED', @@ -71,9 +80,7 @@ function printRecoverySummary(json, summary) { } function printRecoveryError(command, error, json) { - const recoveryError = error instanceof RecoveryError - ? error - : new RecoveryError('INVALID_ARGUMENTS', error instanceof Error ? error.message : String(error), {}, 2); + const recoveryError = toRecoveryError(error); const payload = { ok: false, command, @@ -88,3 +95,17 @@ function printRecoveryError(command, error, json) { else console.error(`${recoveryError.code}: ${recoveryError.message}`); process.exitCode = recoveryError.exitCode; } + +function toRecoveryError(error) { + if (error instanceof RecoveryError) return error; + + const message = error instanceof Error ? error.message : String(error); + const details = error instanceof Error + ? { + message, + ...(typeof error.code === 'string' ? { code: error.code } : {}), + ...(typeof error.path === 'string' ? { path: error.path } : {}), + } + : { message }; + return new RecoveryError('RECOVERY_FAILED', message, details, 1); +} diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index 5fbf8b8..9811e7c 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -69,6 +69,23 @@ describe('ssf resume and switch', () => { }); }); + for (const command of ['resume', 'switch']) { + it(`rejects multiple change targets from ${command} as JSON`, () => { + const result = runSsf([command, 'alpha', 'beta', '--json']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command, + error: { + code: 'INVALID_ARGUMENTS', + message: `${command} accepts at most one change target`, + details: { positionals: ['alpha', 'beta'] }, + }, + }); + }); + } + it('returns a single JSON object for recovery domain errors', () => { const result = runSsf(['resume', join(root, 'missing'), '--json']); @@ -84,6 +101,23 @@ describe('ssf resume and switch', () => { }); }); + it('keeps unexpected recovery read failures as domain JSON errors', () => { + const change = join(root, 'unreadable'); + mkdirSync(change); + mkdirSync(join(change, '.spec-superflow.yaml')); + + const result = runSsf(['resume', change, '--json']); + const payload = JSON.parse(result.stdout); + + assert.equal(result.status, 1); + assert.equal(payload.ok, false); + assert.equal(payload.command, 'resume'); + assert.equal(payload.error.code, 'RECOVERY_FAILED'); + assert.match(payload.error.message, /EISDIR/); + assert.equal(payload.error.details.code, 'EISDIR'); + assert.match(payload.error.details.message, /EISDIR/); + }); + it('renders the complete recovery context as text without changing the target', () => { const change = makeChange('alpha', 'specifying'); const stateBefore = readFileSync(join(change, '.spec-superflow.yaml'), 'utf8'); From 85220246f4613c1aa33e82593d735ccf869497da Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:03:43 +0800 Subject: [PATCH 06/20] fix: reject blank switch targets --- scripts/lib/recovery-command.mjs | 6 +++++- tests/lib/cmd-recovery.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/lib/recovery-command.mjs b/scripts/lib/recovery-command.mjs index 15a0eb3..3e47f3d 100644 --- a/scripts/lib/recovery-command.mjs +++ b/scripts/lib/recovery-command.mjs @@ -21,7 +21,7 @@ export async function runRecoveryCommand(command, args, { requireTarget = false ); } - if (requireTarget && !parsed.positionals[0]) { + if (requireTarget && !hasExplicitTarget(parsed.positionals[0])) { throw new RecoveryError( 'TARGET_REQUIRED', 'switch requires an explicit change target', @@ -109,3 +109,7 @@ function toRecoveryError(error) { : { message }; return new RecoveryError('RECOVERY_FAILED', message, details, 1); } + +function hasExplicitTarget(target) { + return typeof target === 'string' && target.trim().length > 0; +} diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index 9811e7c..b7ae40e 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -69,6 +69,29 @@ describe('ssf resume and switch', () => { }); }); + it('rejects a whitespace-only switch target as text', () => { + const result = runSsf(['switch', ' ']); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /TARGET_REQUIRED: switch requires an explicit change target/); + }); + + it('rejects a whitespace-only switch target as JSON', () => { + const result = runSsf(['switch', ' ', '--json']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'switch', + error: { + code: 'TARGET_REQUIRED', + message: 'switch requires an explicit change target', + details: {}, + }, + }); + }); + for (const command of ['resume', 'switch']) { it(`rejects multiple change targets from ${command} as JSON`, () => { const result = runSsf([command, 'alpha', 'beta', '--json']); From 7a3e8836522665f5b686354ed4fc27eac51b9f7a Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:15:33 +0800 Subject: [PATCH 07/20] feat: add checkpoint-compatible save command --- scripts/lib/cmd-checkpoint.mjs | 55 +++++++++++++++++++---------- scripts/lib/cmd-save.mjs | 57 ++++++++++++++++++++++++++++++ scripts/spec-superflow.mjs | 4 +++ tests/lib/cmd-recovery.test.mjs | 61 ++++++++++++++++++++++++++++++++- 4 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 scripts/lib/cmd-save.mjs diff --git a/scripts/lib/cmd-checkpoint.mjs b/scripts/lib/cmd-checkpoint.mjs index c9dad2c..45b622c 100644 --- a/scripts/lib/cmd-checkpoint.mjs +++ b/scripts/lib/cmd-checkpoint.mjs @@ -1,19 +1,43 @@ // scripts/lib/cmd-checkpoint.mjs - ssf checkpoint recovery records import { parseArgs } from 'node:util'; -import { getCheckpoint, listCheckpoints, saveCheckpoint } from './sdd-overlay.mjs'; +import { computeTaskHash, getCheckpoint, listCheckpoints, saveCheckpoint } from './sdd-overlay.mjs'; const USAGE = 'Usage: ssf checkpoint [options]'; +export const CHECKPOINT_SAVE_OPTIONS = { + task: { type: 'string' }, next: { type: 'string' }, completed: { type: 'string' }, + verification: { type: 'string' }, review: { type: 'string' }, risk: { type: 'string' }, + 'commit-start': { type: 'string' }, 'commit-end': { type: 'string' }, + json: { type: 'boolean', default: false }, +}; + +export class CheckpointUsageError extends Error {} + +export function saveFromValues(changeDir, values) { + if (!hasText(values.task) || !hasText(values.next)) { + throw new CheckpointUsageError('save requires --task and --next '); + } + + // Validate before entering the storage writer so an unknown task cannot + // create an overlay directory as a side effect. + computeTaskHash(changeDir, values.task); + return saveCheckpoint(changeDir, { + taskId: values.task, + next: values.next, + completed: values.completed, + evidence: values.verification, + review: values.review, + risk: values.risk, + commitStart: values['commit-start'], + commitEnd: values['commit-end'], + }); +} + export async function run(args) { const { positionals, values } = parseArgs({ args, allowPositionals: true, - options: { - task: { type: 'string' }, next: { type: 'string' }, completed: { type: 'string' }, - verification: { type: 'string' }, review: { type: 'string' }, risk: { type: 'string' }, - 'commit-start': { type: 'string' }, 'commit-end': { type: 'string' }, - json: { type: 'boolean', default: false }, - }, + options: CHECKPOINT_SAVE_OPTIONS, }); const subcommand = positionals[0]; @@ -26,21 +50,14 @@ export async function run(args) { } if (subcommand === 'save') { - if (!hasText(values.task) || !hasText(values.next)) { + let checkpoint; + try { + checkpoint = saveFromValues(changeDir, values); + } catch (error) { + if (!(error instanceof CheckpointUsageError)) throw error; console.error('Usage: ssf checkpoint save --task --next '); process.exit(2); } - - const checkpoint = saveCheckpoint(changeDir, { - taskId: values.task, - next: values.next, - completed: values.completed, - evidence: values.verification, - review: values.review, - risk: values.risk, - commitStart: values['commit-start'], - commitEnd: values['commit-end'], - }); if (values.json) { console.log(JSON.stringify({ ok: true, checkpoint })); } else { diff --git a/scripts/lib/cmd-save.mjs b/scripts/lib/cmd-save.mjs new file mode 100644 index 0000000..8fcce82 --- /dev/null +++ b/scripts/lib/cmd-save.mjs @@ -0,0 +1,57 @@ +// Checkpoint-compatible shortcut for recording a task recovery note. +import { parseArgs } from 'node:util'; +import { basename, resolve } from 'node:path'; +import { + CHECKPOINT_SAVE_OPTIONS, CheckpointUsageError, saveFromValues, +} from './cmd-checkpoint.mjs'; + +const USAGE = 'Usage: ssf save --task --next '; + +export async function run(args) { + let values = { json: args.includes('--json') }; + try { + const parsed = parseArgs({ + args, + allowPositionals: true, + options: CHECKPOINT_SAVE_OPTIONS, + }); + values = parsed.values; + + if (parsed.positionals.length !== 1) { + throw new CheckpointUsageError(USAGE); + } + + const changeDir = parsed.positionals[0]; + const checkpoint = saveFromValues(changeDir, values); + output(values.json, { + ok: true, + command: 'save', + change: { name: basename(changeDir), path: resolve(changeDir) }, + checkpoint, + }, `Checkpoint saved: ${checkpoint.task_id}`); + } catch (error) { + printError(values.json, error); + } +} + +function output(json, payload, message) { + console.log(json ? JSON.stringify(payload) : message); +} + +function printError(json, error) { + const usage = error instanceof CheckpointUsageError; + const message = error instanceof Error ? error.message : String(error); + const payload = { + ok: false, + command: 'save', + error: { + code: usage ? 'INVALID_ARGUMENTS' : 'CHECKPOINT_SAVE_FAILED', + message, + details: {}, + }, + }; + + if (json) console.log(JSON.stringify(payload)); + else console.error(usage ? USAGE : `${payload.error.code}: ${message}`); + process.exitCode = usage ? 2 : 1; +} diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index f064a9c..670f6b9 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -15,6 +15,7 @@ const COMMANDS = { inject: () => import('./lib/cmd-inject.mjs'), audit: () => import('./lib/cmd-audit.mjs'), checkpoint: () => import('./lib/cmd-checkpoint.mjs'), + save: () => import('./lib/cmd-save.mjs'), handoff: () => import('./lib/cmd-handoff.mjs'), isolate: () => import('./lib/cmd-isolate.mjs'), execution: () => import('./lib/cmd-execution.mjs'), @@ -56,6 +57,8 @@ Commands: List checkpoints and stale status checkpoint show Show one recovery checkpoint + save --task --next + Save a checkpoint through the compatibility shortcut handoff create --type --objective --expected-output --acceptance Create an explicit prototype/research/experiment handoff handoff list @@ -115,6 +118,7 @@ Examples: ssf state get changes/my-change/ batches_completed ssf checkpoint save changes/my-change/ --task 1.1 --next "Run focused tests" ssf checkpoint list changes/my-change/ + ssf save changes/my-change/ --task 1.1 --next "Run focused tests" ssf handoff create changes/my-change/ --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" ssf resume changes/my-change --json ssf switch changes/another-change diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index b7ae40e..d8f5ce4 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -30,6 +30,13 @@ function makeChange(name, state) { return changeDir; } +function makeTasksChange(name, tasks) { + const changeDir = join(root, name); + mkdirSync(changeDir); + writeFileSync(join(changeDir, 'tasks.md'), `# Tasks\n\n${tasks}`); + return changeDir; +} + beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ssf-cmd-recovery-')); }); @@ -157,3 +164,55 @@ describe('ssf resume and switch', () => { assert.equal(readFileSync(join(change, '.spec-superflow.yaml'), 'utf8'), stateBefore); }); }); + +describe('ssf save', () => { + it('writes the existing checkpoint schema', () => { + const change = makeTasksChange('alpha', '- [ ] 1.1 Run recovery test\n'); + const result = runSsf(['save', change, '--task', '1.1', '--next', 'Run tests', '--json']); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.deepEqual(Object.keys(payload), ['ok', 'command', 'change', 'checkpoint']); + assert.equal(payload.command, 'save'); + assert.deepEqual(payload.change, { name: 'alpha', path: change }); + assert.equal(payload.checkpoint.task_id, '1.1'); + assert.equal(payload.checkpoint.next, 'Run tests'); + assert.match(readFileSync(join(change, '.superpowers/sdd/checkpoints/1.1.md'), 'utf8'), /task_hash: "sha256:/); + }); + + it('rejects missing save inputs as one JSON usage error without writes', () => { + const change = makeTasksChange('alpha', '- [ ] 1.1 Existing task\n'); + const result = runSsf(['save', change, '--task', '1.1', '--json']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'save', + error: { + code: 'INVALID_ARGUMENTS', + message: 'save requires --task and --next ', + details: {}, + }, + }); + assert.equal(existsSync(join(change, '.superpowers/sdd/checkpoints/1.1.md')), false); + assert.equal(existsSync(join(change, '.superpowers')), false); + }); + + it('rejects unknown tasks as one JSON domain error without creating a checkpoint', () => { + const change = makeTasksChange('alpha', '- [ ] 1.1 Existing task\n'); + const result = runSsf(['save', change, '--task', '9.9', '--next', 'Continue', '--json']); + + assert.equal(result.status, 1); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'save', + error: { + code: 'CHECKPOINT_SAVE_FAILED', + message: "Task '9.9' was not found in tasks.md", + details: {}, + }, + }); + assert.equal(existsSync(join(change, '.superpowers/sdd/checkpoints/9.9.md')), false); + assert.equal(existsSync(join(change, '.superpowers')), false); + }); +}); From 993035fc314b8271b3ff53c070ea54855fbabd3b Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:23:08 +0800 Subject: [PATCH 08/20] fix: classify save usage errors before writes --- scripts/lib/cmd-checkpoint.mjs | 5 +---- scripts/lib/cmd-save.mjs | 12 ++++++++++-- scripts/lib/sdd-overlay.mjs | 3 ++- tests/lib/cmd-checkpoint.test.mjs | 7 +++++++ tests/lib/cmd-recovery.test.mjs | 27 +++++++++++++++++++++++++++ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/scripts/lib/cmd-checkpoint.mjs b/scripts/lib/cmd-checkpoint.mjs index 45b622c..f719921 100644 --- a/scripts/lib/cmd-checkpoint.mjs +++ b/scripts/lib/cmd-checkpoint.mjs @@ -1,6 +1,6 @@ // scripts/lib/cmd-checkpoint.mjs - ssf checkpoint recovery records import { parseArgs } from 'node:util'; -import { computeTaskHash, getCheckpoint, listCheckpoints, saveCheckpoint } from './sdd-overlay.mjs'; +import { getCheckpoint, listCheckpoints, saveCheckpoint } from './sdd-overlay.mjs'; const USAGE = 'Usage: ssf checkpoint [options]'; @@ -18,9 +18,6 @@ export function saveFromValues(changeDir, values) { throw new CheckpointUsageError('save requires --task and --next '); } - // Validate before entering the storage writer so an unknown task cannot - // create an overlay directory as a side effect. - computeTaskHash(changeDir, values.task); return saveCheckpoint(changeDir, { taskId: values.task, next: values.next, diff --git a/scripts/lib/cmd-save.mjs b/scripts/lib/cmd-save.mjs index 8fcce82..56c9af7 100644 --- a/scripts/lib/cmd-save.mjs +++ b/scripts/lib/cmd-save.mjs @@ -9,13 +9,21 @@ const USAGE = 'Usage: ssf save --task --next '; export async function run(args) { let values = { json: args.includes('--json') }; + + let parsed; try { - const parsed = parseArgs({ + parsed = parseArgs({ args, allowPositionals: true, options: CHECKPOINT_SAVE_OPTIONS, }); - values = parsed.values; + } catch { + printError(values.json, new CheckpointUsageError(USAGE)); + return; + } + + values = parsed.values; + try { if (parsed.positionals.length !== 1) { throw new CheckpointUsageError(USAGE); diff --git a/scripts/lib/sdd-overlay.mjs b/scripts/lib/sdd-overlay.mjs index 5541ba1..5752ca1 100644 --- a/scripts/lib/sdd-overlay.mjs +++ b/scripts/lib/sdd-overlay.mjs @@ -36,11 +36,12 @@ export function computeTaskHash(changeDir, taskId) { export function saveCheckpoint(changeDir, input) { requireText(input?.taskId, 'taskId'); requireText(input?.next, 'next'); + const taskHash = computeTaskHash(changeDir, input.taskId); const paths = getOverlayPaths(changeDir); mkdirSync(paths.checkpoints, { recursive: true }); const record = { task_id: input.taskId, - task_hash: computeTaskHash(changeDir, input.taskId), + task_hash: taskHash, next: input.next, completed: input.completed ?? 'Not recorded', evidence: input.evidence ?? 'Not recorded', diff --git a/tests/lib/cmd-checkpoint.test.mjs b/tests/lib/cmd-checkpoint.test.mjs index bd056ca..d46085d 100644 --- a/tests/lib/cmd-checkpoint.test.mjs +++ b/tests/lib/cmd-checkpoint.test.mjs @@ -75,6 +75,13 @@ describe('ssf checkpoint', () => { const result = runSsf(['checkpoint', 'save', changeDir, '--task', '1.1']); assert.equal(result.exitCode, 2); assert.equal(existsSync(join(changeDir, '.superpowers', 'sdd', 'checkpoints', '1.1.md')), false); + assert.equal(existsSync(join(changeDir, '.superpowers')), false); + }); + + it('rejects an unknown save task before creating the checkpoint directory', () => { + const result = runSsf(['checkpoint', 'save', changeDir, '--task', '9.9', '--next', 'Continue']); + assert.equal(result.exitCode, 1); + assert.equal(existsSync(join(changeDir, '.superpowers')), false); }); it('lists a stale checkpoint as JSON', () => { diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index d8f5ce4..53837c2 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -198,6 +198,33 @@ describe('ssf save', () => { assert.equal(existsSync(join(change, '.superpowers')), false); }); + it('maps unknown save options to a text usage error without writes', () => { + const change = makeTasksChange('alpha', '- [ ] 1.1 Existing task\n'); + const result = runSsf(['save', change, '--task', '1.1', '--next', 'Continue', '--unexpected']); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, 'Usage: ssf save --task --next \n'); + assert.equal(existsSync(join(change, '.superpowers')), false); + }); + + it('maps missing save option values to one JSON usage error without writes', () => { + const change = makeTasksChange('alpha', '- [ ] 1.1 Existing task\n'); + const result = runSsf(['save', change, '--task', '--next', 'Continue', '--json']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command: 'save', + error: { + code: 'INVALID_ARGUMENTS', + message: 'Usage: ssf save --task --next ', + details: {}, + }, + }); + assert.equal(existsSync(join(change, '.superpowers')), false); + }); + it('rejects unknown tasks as one JSON domain error without creating a checkpoint', () => { const change = makeTasksChange('alpha', '- [ ] 1.1 Existing task\n'); const result = runSsf(['save', change, '--task', '9.9', '--next', 'Continue', '--json']); From 27e0f6a2183f538b74af07ed0eb4ebfdb6763405 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:29:12 +0800 Subject: [PATCH 09/20] fix: avoid rereading checkpoints after save --- scripts/lib/sdd-overlay.mjs | 2 +- tests/lib/sdd-overlay.test.mjs | 36 +++++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/scripts/lib/sdd-overlay.mjs b/scripts/lib/sdd-overlay.mjs index 5752ca1..8ab6369 100644 --- a/scripts/lib/sdd-overlay.mjs +++ b/scripts/lib/sdd-overlay.mjs @@ -53,7 +53,7 @@ export function saveCheckpoint(changeDir, input) { }; const targetPath = join(paths.checkpoints, `${safeName(input.taskId)}.md`); atomicWrite(targetPath, renderRecord(record, `# Checkpoint: ${input.taskId}`, checkpointBody(record))); - return readCheckpoint(targetPath); + return { ...record, stale: false }; } export function listCheckpoints(changeDir) { diff --git a/tests/lib/sdd-overlay.test.mjs b/tests/lib/sdd-overlay.test.mjs index 74bfda4..0bfba78 100644 --- a/tests/lib/sdd-overlay.test.mjs +++ b/tests/lib/sdd-overlay.test.mjs @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { - saveCheckpoint, listCheckpoints, createHandoff, listHandoffs, finishHandoff, resolveHandoff, + getCheckpoint, saveCheckpoint, listCheckpoints, createHandoff, listHandoffs, finishHandoff, resolveHandoff, } from '../../scripts/lib/sdd-overlay.mjs'; let changeDir; @@ -30,6 +30,40 @@ afterEach(() => { }); describe('checkpoint storage', () => { + it('returns the saved current schema while list and show reparse it', () => { + const saved = saveCheckpoint(changeDir, { taskId: '1.1', next: 'Run the focused test' }); + + assert.deepEqual(Object.keys(saved), [ + 'task_id', 'task_hash', 'next', 'completed', 'evidence', 'review', 'risk', + 'commit_start', 'commit_end', 'created_at', 'stale', + ]); + assert.equal(saved.task_id, '1.1'); + assert.match(saved.task_hash, /^sha256:/); + assert.equal(saved.next, 'Run the focused test'); + assert.equal(saved.completed, 'Not recorded'); + assert.equal(saved.evidence, 'Not recorded'); + assert.equal(saved.review, 'Not recorded'); + assert.equal(saved.risk, 'Not recorded'); + assert.equal(saved.commit_start, 'Not recorded'); + assert.equal(saved.commit_end, 'Not recorded'); + assert.equal(saved.stale, false); + assert.deepEqual(listCheckpoints(changeDir)[0], saved); + assert.deepEqual(getCheckpoint(changeDir, '1.1'), saved); + + writeFileSync(join(changeDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Changed task text\n'); + assert.equal(listCheckpoints(changeDir)[0].stale, true); + assert.equal(getCheckpoint(changeDir, '1.1').stale, true); + }); + + it('computes the save task hash once without rereading the saved checkpoint', () => { + const source = readFileSync(new URL('../../scripts/lib/sdd-overlay.mjs', import.meta.url), 'utf8'); + const saveBody = source.match(/export function saveCheckpoint[\s\S]*?\n}\n\nexport function listCheckpoints/)[0]; + + assert.equal((saveBody.match(/computeTaskHash/g) ?? []).length, 1); + assert.ok(saveBody.indexOf('computeTaskHash') < saveBody.indexOf('mkdirSync')); + assert.doesNotMatch(saveBody, /readCheckpoint/); + }); + it('persists commit boundaries and recovery fields across list and show reads', () => { saveCheckpoint(changeDir, { taskId: '1.1', From fb7a9f4663adf3308a71b0e0cc1208348fdcbac7 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:37:46 +0800 Subject: [PATCH 10/20] fix: classify recovery parse errors as usage --- scripts/lib/recovery-command.mjs | 25 +++++++++++++++++++++---- tests/lib/cmd-recovery.test.mjs | 32 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/scripts/lib/recovery-command.mjs b/scripts/lib/recovery-command.mjs index 3e47f3d..98ef856 100644 --- a/scripts/lib/recovery-command.mjs +++ b/scripts/lib/recovery-command.mjs @@ -1,16 +1,33 @@ import { parseArgs } from 'node:util'; import { RecoveryError, createRecoverySummary, resolveChangeTarget } from './change-recovery.mjs'; +const USAGE = { + resume: 'Usage: ssf resume [change-dir] [--json]', + switch: 'Usage: ssf switch [--json]', +}; + export async function runRecoveryCommand(command, args, { requireTarget = false } = {}) { let values = { json: args.includes('--json') }; + let parsed; try { - const parsed = parseArgs({ + parsed = parseArgs({ args, allowPositionals: true, options: { json: { type: 'boolean', default: false } }, }); - values = parsed.values; + } catch { + printRecoveryError( + command, + new RecoveryError('INVALID_ARGUMENTS', USAGE[command], {}, 2), + values.json, + { usage: true }, + ); + return; + } + + values = parsed.values; + try { if (parsed.positionals.length > 1) { throw new RecoveryError( @@ -79,7 +96,7 @@ function printRecoverySummary(json, summary) { ].join('\n')); } -function printRecoveryError(command, error, json) { +function printRecoveryError(command, error, json, { usage = false } = {}) { const recoveryError = toRecoveryError(error); const payload = { ok: false, @@ -92,7 +109,7 @@ function printRecoveryError(command, error, json) { }; if (json) console.log(JSON.stringify(payload)); - else console.error(`${recoveryError.code}: ${recoveryError.message}`); + else console.error(usage ? recoveryError.message : `${recoveryError.code}: ${recoveryError.message}`); process.exitCode = recoveryError.exitCode; } diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index 53837c2..c3535d3 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -18,7 +18,7 @@ function runSsf(args) { return { status: error.status || 1, stdout: error.stdout?.toString() || '', - stderr: error.stderr?.toString() || error.message, + stderr: error.stderr?.toString() ?? error.message, }; } } @@ -114,6 +114,36 @@ describe('ssf resume and switch', () => { }, }); }); + + it(`maps native ${command} parse errors to one JSON usage error`, () => { + const usage = command === 'resume' + ? 'Usage: ssf resume [change-dir] [--json]' + : 'Usage: ssf switch [--json]'; + const result = runSsf([command, '--json', '--bad-option']); + + assert.equal(result.status, 2); + assert.deepEqual(JSON.parse(result.stdout), { + ok: false, + command, + error: { + code: 'INVALID_ARGUMENTS', + message: usage, + details: {}, + }, + }); + assert.equal(result.stderr, ''); + }); + + it(`maps native ${command} parse errors to text usage`, () => { + const usage = command === 'resume' + ? 'Usage: ssf resume [change-dir] [--json]' + : 'Usage: ssf switch [--json]'; + const result = runSsf([command, '--bad-option']); + + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, `${usage}\n`); + }); } it('returns a single JSON object for recovery domain errors', () => { From 6ec8ea9d65080ff64902c3af12358622056d275d Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:45:01 +0800 Subject: [PATCH 11/20] feat: add ssf recovery slash commands --- commands/ssf/resume.md | 10 +++++ commands/ssf/save.md | 10 +++++ commands/ssf/switch.md | 10 +++++ tests/lib/recovery-command-assets.test.mjs | 45 ++++++++++++++++++++++ 4 files changed, 75 insertions(+) create mode 100644 commands/ssf/resume.md create mode 100644 commands/ssf/save.md create mode 100644 commands/ssf/switch.md create mode 100644 tests/lib/recovery-command-assets.test.mjs diff --git a/commands/ssf/resume.md b/commands/ssf/resume.md new file mode 100644 index 0000000..e98fc5b --- /dev/null +++ b/commands/ssf/resume.md @@ -0,0 +1,10 @@ +--- + +description: 恢复一个 spec-superflow change,并按现有状态机继续 +argument-hint: "[change-name-or-path]" +allowed-tools: Bash(npx:*) +--- + +运行 `npx --yes --package spec-superflow@0.10.0 ssf resume $ARGUMENTS --json`。 + +只使用返回的 `change`、`blockers` 和 `next_action`:存在 blocker 时停止并展示修复命令;否则通过 `workflow-start` 进入 `next_action` 指定的下一 skill。不要直接修改状态文件。 diff --git a/commands/ssf/save.md b/commands/ssf/save.md new file mode 100644 index 0000000..ca80103 --- /dev/null +++ b/commands/ssf/save.md @@ -0,0 +1,10 @@ +--- + +description: 为一个 spec-superflow change 保存兼容 checkpoint +argument-hint: " --task --next " +allowed-tools: Bash(npx:*) +--- + +`$ARGUMENTS` 必须明确提供 change、`--task` 和 `--next`。信息不足时先询问一次;不要编造 verification 或 review 证据。 + +确认参数后运行 `npx --yes --package spec-superflow@0.10.0 ssf save $ARGUMENTS --json`。只报告 CLI 返回的 checkpoint 结果,并保留现有状态机和存储边界。 diff --git a/commands/ssf/switch.md b/commands/ssf/switch.md new file mode 100644 index 0000000..30784c0 --- /dev/null +++ b/commands/ssf/switch.md @@ -0,0 +1,10 @@ +--- + +description: 切换当前对话关注的 spec-superflow change +argument-hint: "" +allowed-tools: Bash(npx:*) +--- + +`$ARGUMENTS` 必须提供一个非空的 change 名称或路径;若未提供,先询问用户选择哪个 change。 + +运行 `npx --yes --package spec-superflow@0.10.0 ssf switch $ARGUMENTS --json`,只使用返回的恢复上下文切换当前对话的关注对象。存在 blocker 时停止并展示修复命令;不得改变文件、工作目录或任何隐藏指针。 diff --git a/tests/lib/recovery-command-assets.test.mjs b/tests/lib/recovery-command-assets.test.mjs new file mode 100644 index 0000000..eed30bc --- /dev/null +++ b/tests/lib/recovery-command-assets.test.mjs @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +function read(path) { + return readFileSync(join(process.cwd(), path), 'utf8'); +} + +describe('SSF recovery command assets', () => { + for (const name of ['resume', 'switch', 'save']) { + it(`${name} uses the portable ssf command without hidden state writes`, () => { + const content = read(`commands/ssf/${name}.md`); + + assert.match(content, /^---\n[\s\S]+description:/); + assert.match(content, /argument-hint:/); + assert.match(content, new RegExp(`spec-superflow@0\\.10\\.0 ssf ${name}`)); + assert.match(content, /\$ARGUMENTS/); + assert.doesNotMatch(content, /state set|state transition|active-change|\bcd\s/); + }); + } + + it('routes resume blockers through workflow-start without changing state directly', () => { + const content = read('commands/ssf/resume.md'); + + assert.match(content, /change.*blockers.*next_action/s); + assert.match(content, /存在 blocker 时停止/); + assert.match(content, /workflow-start/); + }); + + it('requires an explicit switch target and limits switching to conversation focus', () => { + const content = read('commands/ssf/switch.md'); + + assert.match(content, /\$ARGUMENTS.*非空/s); + assert.match(content, /当前对话.*关注对象/); + }); + + it('asks once for incomplete save input and never invents checkpoint evidence', () => { + const content = read('commands/ssf/save.md'); + + assert.match(content, /change、`--task` 和 `--next`/); + assert.match(content, /信息不足时先询问一次/); + assert.match(content, /不要编造 verification 或 review 证据/); + }); +}); From 0d63d87515f04588b6e900e9306cb68a276893a1 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:51:52 +0800 Subject: [PATCH 12/20] fix: quote recovery slash command arguments --- commands/ssf/resume.md | 2 +- commands/ssf/save.md | 4 ++-- commands/ssf/switch.md | 2 +- tests/lib/recovery-command-assets.test.mjs | 25 ++++++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/commands/ssf/resume.md b/commands/ssf/resume.md index e98fc5b..3a13433 100644 --- a/commands/ssf/resume.md +++ b/commands/ssf/resume.md @@ -5,6 +5,6 @@ argument-hint: "[change-name-or-path]" allowed-tools: Bash(npx:*) --- -运行 `npx --yes --package spec-superflow@0.10.0 ssf resume $ARGUMENTS --json`。 +先检查 `$ARGUMENTS` 是否为非空目标:为空时运行 `npx --yes --package spec-superflow@0.10.0 ssf resume --json`,让 CLI 按唯一活跃 change 的既有确定性规则选择;非空时运行 `npx --yes --package spec-superflow@0.10.0 ssf resume --json "$ARGUMENTS"`,将整个目标作为单一字面参数。 只使用返回的 `change`、`blockers` 和 `next_action`:存在 blocker 时停止并展示修复命令;否则通过 `workflow-start` 进入 `next_action` 指定的下一 skill。不要直接修改状态文件。 diff --git a/commands/ssf/save.md b/commands/ssf/save.md index ca80103..eb40360 100644 --- a/commands/ssf/save.md +++ b/commands/ssf/save.md @@ -5,6 +5,6 @@ argument-hint: " --task --next " allowed-tools: Bash(npx:*) --- -`$ARGUMENTS` 必须明确提供 change、`--task` 和 `--next`。信息不足时先询问一次;不要编造 verification 或 review 证据。 +只把 `$ARGUMENTS` 作为对话输入,从中提取明确的 change、task 和 next,以及用户明确提供的可选 evidence 字段。信息不足时先询问一次;不要编造 verification 或 review 证据。 -确认参数后运行 `npx --yes --package spec-superflow@0.10.0 ssf save $ARGUMENTS --json`。只报告 CLI 返回的 checkpoint 结果,并保留现有状态机和存储边界。 +确认参数后运行 `npx --yes --package spec-superflow@0.10.0 ssf save "" --task "" --next "" [--completed ""] [--verification ""] [--review ""] [--risk ""] [--commit-start ""] [--commit-end ""] --json`。每个值必须来自已提取或已确认的信息,并作为单独引用的字面参数传入。只报告 CLI 返回的 checkpoint 结果,并保留现有状态机和存储边界。 diff --git a/commands/ssf/switch.md b/commands/ssf/switch.md index 30784c0..7f81e2a 100644 --- a/commands/ssf/switch.md +++ b/commands/ssf/switch.md @@ -7,4 +7,4 @@ allowed-tools: Bash(npx:*) `$ARGUMENTS` 必须提供一个非空的 change 名称或路径;若未提供,先询问用户选择哪个 change。 -运行 `npx --yes --package spec-superflow@0.10.0 ssf switch $ARGUMENTS --json`,只使用返回的恢复上下文切换当前对话的关注对象。存在 blocker 时停止并展示修复命令;不得改变文件、工作目录或任何隐藏指针。 +确认目标非空后运行 `npx --yes --package spec-superflow@0.10.0 ssf switch --json "$ARGUMENTS"`,将整个目标作为单一字面参数。只使用返回的恢复上下文切换当前对话的关注对象。存在 blocker 时停止并展示修复命令;不得改变文件、工作目录或任何隐藏指针。 diff --git a/tests/lib/recovery-command-assets.test.mjs b/tests/lib/recovery-command-assets.test.mjs index eed30bc..1016ad8 100644 --- a/tests/lib/recovery-command-assets.test.mjs +++ b/tests/lib/recovery-command-assets.test.mjs @@ -42,4 +42,29 @@ describe('SSF recovery command assets', () => { assert.match(content, /信息不足时先询问一次/); assert.match(content, /不要编造 verification 或 review 证据/); }); + + it('never expands raw arguments as shell command input', () => { + for (const name of ['resume', 'switch', 'save']) { + const content = read(`commands/ssf/${name}.md`); + + assert.doesNotMatch(content, new RegExp(`ssf ${name} \\$ARGUMENTS(?:\\s|\\x60)`)); + } + }); + + it('passes resume and switch targets as one quoted literal after validation', () => { + for (const name of ['resume', 'switch']) { + const content = read(`commands/ssf/${name}.md`); + + assert.match(content, new RegExp(`ssf ${name} --json "\\$ARGUMENTS"`)); + assert.match(content, /非空/); + } + }); + + it('extracts save fields and quotes each explicit CLI argument', () => { + const content = read('commands/ssf/save.md'); + + assert.match(content, /提取.*change.*task.*next/s); + assert.match(content, /ssf save "" --task "" --next "".*--json/); + assert.doesNotMatch(content, /ssf save \$ARGUMENTS/); + }); }); From 95db157b0a649d1aa9569f6bd6230dbcdd29d191 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 20:56:47 +0800 Subject: [PATCH 13/20] test: harden recovery command argument checks --- tests/lib/recovery-command-assets.test.mjs | 42 +++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/tests/lib/recovery-command-assets.test.mjs b/tests/lib/recovery-command-assets.test.mjs index 1016ad8..bfe14c0 100644 --- a/tests/lib/recovery-command-assets.test.mjs +++ b/tests/lib/recovery-command-assets.test.mjs @@ -7,6 +7,27 @@ function read(path) { return readFileSync(join(process.cwd(), path), 'utf8'); } +function executableSsfCommands(content) { + return [...content.matchAll(/`([^`\n]*\bssf\s+(?:resume|switch|save)\b[^`]*)`/g)] + .map(match => match[1]); +} + +function assertNoUnquotedArguments(content) { + for (const command of executableSsfCommands(content)) { + let quote = null; + for (let index = 0; index < command.length; index += 1) { + const character = command[index]; + if (character === '"' || character === "'") { + quote = quote === character ? null : quote ?? character; + continue; + } + if (quote === null && command.startsWith('$ARGUMENTS', index)) { + assert.fail(`unquoted $ARGUMENTS in executable command: ${command}`); + } + } + } +} + describe('SSF recovery command assets', () => { for (const name of ['resume', 'switch', 'save']) { it(`${name} uses the portable ssf command without hidden state writes`, () => { @@ -38,19 +59,32 @@ describe('SSF recovery command assets', () => { it('asks once for incomplete save input and never invents checkpoint evidence', () => { const content = read('commands/ssf/save.md'); - assert.match(content, /change、`--task` 和 `--next`/); + assert.match(content, /提取明确的 change、task 和 next/); assert.match(content, /信息不足时先询问一次/); assert.match(content, /不要编造 verification 或 review 证据/); }); it('never expands raw arguments as shell command input', () => { for (const name of ['resume', 'switch', 'save']) { - const content = read(`commands/ssf/${name}.md`); - - assert.doesNotMatch(content, new RegExp(`ssf ${name} \\$ARGUMENTS(?:\\s|\\x60)`)); + assertNoUnquotedArguments(read(`commands/ssf/${name}.md`)); } }); + it('rejects unquoted raw arguments after executable command flags', () => { + const unsafeResume = 'Run `npx --yes --package spec-superflow@0.10.0 ssf resume --json $ARGUMENTS`.'; + const unsafeSwitch = 'Run `npx --yes --package spec-superflow@0.10.0 ssf switch --flag $ARGUMENTS`.'; + + assert.throws(() => assertNoUnquotedArguments(unsafeResume), /\$ARGUMENTS/); + assert.throws(() => assertNoUnquotedArguments(unsafeSwitch), /\$ARGUMENTS/); + }); + + it('accepts quoted argument input and prose-only argument mentions', () => { + const safeResume = 'Run `npx --yes --package spec-superflow@0.10.0 ssf resume --json "$ARGUMENTS"`. $ARGUMENTS is conversational input.'; + + assert.doesNotThrow(() => assertNoUnquotedArguments(safeResume)); + assert.doesNotThrow(() => assertNoUnquotedArguments(read('commands/ssf/save.md'))); + }); + it('passes resume and switch targets as one quoted literal after validation', () => { for (const name of ['resume', 'switch']) { const content = read(`commands/ssf/${name}.md`); From 26eff0d7cdbebc41850e39befd271c553ab52be6 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 21:09:36 +0800 Subject: [PATCH 14/20] feat: distribute recovery commands to WorkBuddy --- scripts/lib/cmd-install-workbuddy.mjs | 58 +++++++++++++-- tests/lib/cmd-install-workbuddy.test.mjs | 92 +++++++++++++++++++++++- 2 files changed, 142 insertions(+), 8 deletions(-) diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index a3a0b74..e2c4f86 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -19,7 +19,7 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { cp, writeFile, mkdtemp } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve, sep } from 'node:path'; import { parseArgs } from 'node:util'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; @@ -33,6 +33,7 @@ const PLUGIN_NAME = 'spec-superflow'; const GITHUB_REPO = 'MageByte-Zero/spec-superflow'; const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; const RUNTIME_DIRS = ['scripts', 'docs', 'templates', 'dist', 'hooks']; +const CANONICAL_COMMAND_NAMES = ['ssf:resume', 'ssf:save', 'ssf:switch']; // ─── helpers ────────────────────────────────────────────── @@ -57,6 +58,36 @@ function listSkillNames(skillsDir) { .sort(); } +function walkMarkdown(dir) { + return readdirSync(dir, { withFileTypes: true }) + .flatMap(entry => { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) return walkMarkdown(entryPath); + return entry.isFile() && entry.name.endsWith('.md') ? [entryPath] : []; + }) + .sort(); +} + +function listCommandNames(commandsDir) { + if (!existsSync(commandsDir)) { + throw new Error(`commands/ directory not found at ${commandsDir}`); + } + return walkMarkdown(commandsDir) + .map(filePath => relative(commandsDir, filePath).replace(/\.md$/, '').split(sep).join(':')) + .sort(); +} + +function assertCanonicalCommands(commandNames) { + if ( + commandNames.length !== CANONICAL_COMMAND_NAMES.length + || commandNames.some((name, index) => name !== CANONICAL_COMMAND_NAMES[index]) + ) { + throw new Error( + `canonical recovery command set is required: ${CANONICAL_COMMAND_NAMES.join(', ')}; found: ${commandNames.join(', ') || '(none)'}`, + ); + } +} + /** Recursively copy a directory. */ async function copyDir(src, dst) { if (!existsSync(src)) return 0; @@ -199,10 +230,14 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark const root = resolve(pluginRoot); const skillsDir = join(root, 'skills'); const skillNames = listSkillNames(skillsDir); + const commandsDir = join(root, 'commands'); + const commandNames = listCommandNames(commandsDir); + assertCanonicalCommands(commandNames); const workbuddyRoot = join(homeDir, '.workbuddy'); const pluginsDir = join(workbuddyRoot, 'plugins', 'marketplaces', marketplaceName, 'plugins'); const targetPluginDir = join(pluginsDir, PLUGIN_NAME); const targetSkills = join(targetPluginDir, 'skills'); + const targetCommands = join(targetPluginDir, 'commands'); const targetRules = join(targetPluginDir, 'rules'); const manifestDir = join(targetPluginDir, '.codebuddy-plugin'); const settingsPath = join(workbuddyRoot, 'settings.json'); @@ -214,10 +249,13 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark pluginRoot: root, skillsDir, skillNames, + commandsDir, + commandNames, workbuddyRoot, pluginsDir, targetPluginDir, targetSkills, + targetCommands, targetRules, manifestDir, settingsPath, @@ -232,7 +270,7 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { const plan = planInstall({ pluginRoot, homeDir, marketplaceName }); - const { skillNames, targetPluginDir, targetSkills, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = plan; + const { skillNames, commandNames, targetPluginDir, targetSkills, targetCommands, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = plan; // 0. Clean old plugin dir. if (existsSync(targetPluginDir)) { @@ -253,21 +291,25 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { } } - // 2. Copy skills with ${CLAUDE_PLUGIN_ROOT} rewriting. + // 2. Copy canonical recovery commands as complete Markdown assets. + const commandCount = await copyDir(plan.commandsDir, targetCommands); + console.log(` commands/ → ${targetCommands} (${commandCount} entries, ${commandNames.length} commands)`); + + // 3. Copy skills with ${CLAUDE_PLUGIN_ROOT} rewriting. const count = await copySkillsWithRoot(plan.skillsDir, targetSkills, pluginRootAbs); console.log(` skills/ → ${targetSkills} (${count} skills, paths rewritten)`); - // 3. Write phase-guard rule. + // 4. Write phase-guard rule. ensureDir(targetRules); await writeFile(join(targetRules, 'phase-guard.md'), phaseGuardContent(), 'utf-8'); console.log(` phase-guard → ${join(targetRules, 'phase-guard.md')}`); - // 4. Write plugin manifest. + // 5. Write plugin manifest. ensureDir(manifestDir); await writeFile(join(manifestDir, 'plugin.json'), JSON.stringify(pluginManifest(skillNames, version), null, 2) + '\n', 'utf-8'); console.log(` manifest → ${join(manifestDir, 'plugin.json')}`); - // 5. Enable plugin in settings.json. + // 6. Enable plugin in settings.json. ensureDir(plan.workbuddyRoot); const settings = readJsonIfExists(settingsPath); settings.enabledPlugins = settings.enabledPlugins && typeof settings.enabledPlugins === 'object' @@ -310,9 +352,11 @@ export async function run(args) { console.log('WorkBuddy install plan:'); console.log(` Plugin: ${PLUGIN_NAME} v${plan.version}`); console.log(` Skills: ${plan.skillNames.length} (${plan.skillNames.join(', ')})`); + console.log(` Commands: ${plan.commandNames.length} (${plan.commandNames.join(', ')})`); console.log(` Marketplace: ${plan.marketplaceName}`); console.log(` Target: ${plan.targetPluginDir}`); console.log(` Rules: ${plan.targetRules}/phase-guard.md`); + console.log(` Command dir: ${plan.targetCommands}`); console.log(` Settings: ${plan.enabledPluginKey}`); return; } @@ -356,4 +400,4 @@ export async function run(args) { } } -export { planInstall, installWorkBuddy, PLUGIN_NAME }; +export { listCommandNames, planInstall, installWorkBuddy, PLUGIN_NAME }; diff --git a/tests/lib/cmd-install-workbuddy.test.mjs b/tests/lib/cmd-install-workbuddy.test.mjs index 33c55f8..b089b60 100644 --- a/tests/lib/cmd-install-workbuddy.test.mjs +++ b/tests/lib/cmd-install-workbuddy.test.mjs @@ -3,6 +3,7 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -21,13 +22,18 @@ describe('cmd-install-workbuddy', () => { if (tempDir) rmSync(tempDir, { recursive: true, force: true }); }); - function makePluginRoot() { + function makePluginRoot({ commands = ['resume', 'save', 'switch'] } = {}) { const pluginRoot = join(tempDir, 'spec-superflow'); const skillsDir = join(pluginRoot, 'skills'); mkdirSync(join(skillsDir, 'workflow-start'), { recursive: true }); mkdirSync(join(skillsDir, 'need-explorer'), { recursive: true }); writeFileSync(join(skillsDir, 'workflow-start', 'SKILL.md'), '---\nname: workflow-start\n---\n'); writeFileSync(join(skillsDir, 'need-explorer', 'SKILL.md'), '---\nname: need-explorer\n---\n'); + for (const command of commands) { + const commandFile = join(pluginRoot, 'commands', 'ssf', `${command}.md`); + mkdirSync(join(commandFile, '..'), { recursive: true }); + writeFileSync(commandFile, `# ${command}\n`); + } writeFileSync(join(pluginRoot, 'package.json'), JSON.stringify({ version: '0.8.14' })); return pluginRoot; } @@ -51,6 +57,9 @@ describe('cmd-install-workbuddy', () => { assert.equal(plan.targetPluginDir, join(plan.pluginsDir, 'spec-superflow')); assert.equal(plan.targetSkills, join(plan.targetPluginDir, 'skills')); assert.equal(plan.targetRules, join(plan.targetPluginDir, 'rules')); + assert.deepEqual(plan.commandNames, ['ssf:resume', 'ssf:save', 'ssf:switch']); + assert.equal(plan.commandsDir, join(pluginRoot, 'commands')); + assert.equal(plan.targetCommands, join(plan.targetPluginDir, 'commands')); }); it('deploys skills, runtime dirs, rules, manifest, and preserves existing settings', async () => { @@ -69,6 +78,7 @@ describe('cmd-install-workbuddy', () => { const result = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'cb_teams_marketplace' }); assert.equal(result.skillNames.length, 2); + assert.deepEqual(result.commandNames, ['ssf:resume', 'ssf:save', 'ssf:switch']); // Settings: existing key preserved, single spec-superflow key added. const settings = JSON.parse(readFileSync(join(settingsDir, 'settings.json'), 'utf-8')); @@ -95,6 +105,86 @@ describe('cmd-install-workbuddy', () => { const manifest = JSON.parse(readFileSync(join(pluginDir, '.codebuddy-plugin', 'plugin.json'), 'utf-8')); assert.equal(manifest.name, 'spec-superflow'); assert.equal(manifest.skills.length, 2); + + // Canonical recovery commands deploy as complete Markdown assets. + for (const name of ['resume', 'save', 'switch']) { + assert.equal(readFileSync(join(pluginDir, 'commands', 'ssf', `${name}.md`), 'utf-8'), `# ${name}\n`); + } + }); + + it('rejects missing or incomplete canonical recovery commands before installing', () => { + const homeDir = join(tempDir, 'home'); + assert.throws( + () => planInstall({ pluginRoot: makePluginRoot({ commands: [] }), homeDir }), + /commands\/ directory not found/, + ); + assert.throws( + () => planInstall({ pluginRoot: makePluginRoot({ commands: ['resume', 'save'] }), homeDir }), + /canonical recovery command set/, + ); + assert.equal(existsSync(homeDir), false); + }); + + it('dry-run reports commands and does not write the requested home directory', () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'dry-run-home'); + const cli = join(process.cwd(), 'scripts', 'spec-superflow.mjs'); + const stdout = execFileSync(process.execPath, [ + cli, + 'install-workbuddy', + '--local', pluginRoot, + '--home', homeDir, + '--dry-run', + ], { encoding: 'utf-8' }); + + assert.match(stdout, /Commands:\s+3 \(ssf:resume, ssf:save, ssf:switch\)/); + assert.match(stdout, /Command dir:/); + assert.equal(existsSync(homeDir), false); + }); + + it('rebuilds the plugin directory so stale commands are removed', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'home'); + const first = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }); + const staleCommand = join(first.targetCommands, 'ssf', 'legacy.md'); + writeFileSync(staleCommand, '# legacy\n'); + + const second = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }); + assert.ok(existsSync(join(second.targetCommands, 'ssf', 'resume.md'))); + assert.equal(existsSync(staleCommand), false); + }); + + it('installs all canonical package assets into a real temporary WorkBuddy home', async () => { + const pluginRoot = process.cwd(); + const homeDir = join(tempDir, 'real-home'); + const settingsDir = join(homeDir, '.workbuddy'); + mkdirSync(settingsDir, { recursive: true }); + writeFileSync(join(settingsDir, 'settings.json'), JSON.stringify({ + enabledPlugins: { + 'workflow-start@cb_teams_marketplace': true, + 'unrelated@marketplace': true, + }, + })); + + const result = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'cb_teams_marketplace' }); + assert.equal(result.skillNames.length, 9); + assert.deepEqual(result.commandNames, ['ssf:resume', 'ssf:save', 'ssf:switch']); + for (const runtimeDir of ['scripts', 'docs', 'templates', 'dist', 'hooks']) { + assert.ok(existsSync(join(result.targetPluginDir, runtimeDir)), `${runtimeDir}/ should be installed`); + } + assert.ok(existsSync(join(result.targetRules, 'phase-guard.md'))); + assert.ok(existsSync(join(result.manifestDir, 'plugin.json'))); + for (const name of ['resume', 'save', 'switch']) { + assert.equal( + readFileSync(join(result.targetCommands, 'ssf', `${name}.md`), 'utf-8'), + readFileSync(join(pluginRoot, 'commands', 'ssf', `${name}.md`), 'utf-8'), + ); + } + + const settings = JSON.parse(readFileSync(join(settingsDir, 'settings.json'), 'utf-8')); + assert.equal(settings.enabledPlugins['spec-superflow@cb_teams_marketplace'], true); + assert.equal(settings.enabledPlugins['workflow-start@cb_teams_marketplace'], undefined); + assert.equal(settings.enabledPlugins['unrelated@marketplace'], true); }); it('uses the package root by default instead of the caller cwd', () => { From a9d7fef8935e106110c18a4522f3d857d1a1b2e7 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 21:22:03 +0800 Subject: [PATCH 15/20] fix: reject linked WorkBuddy commands --- scripts/lib/cmd-install-workbuddy.mjs | 37 ++++++++++++++++++------ tests/lib/cmd-install-workbuddy.test.mjs | 33 ++++++++++++++++++++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index e2c4f86..436c8ba 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -16,7 +16,7 @@ // ${CLAUDE_PLUGIN_ROOT} rewrite, phase-guard rule) but targets the marketplace // plugin structure instead of a project-local `.platform/` directory. -import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { cp, writeFile, mkdtemp } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, relative, resolve, sep } from 'node:path'; @@ -62,14 +62,27 @@ function walkMarkdown(dir) { return readdirSync(dir, { withFileTypes: true }) .flatMap(entry => { const entryPath = join(dir, entry.name); - if (entry.isDirectory()) return walkMarkdown(entryPath); - return entry.isFile() && entry.name.endsWith('.md') ? [entryPath] : []; + const entryStat = lstatSync(entryPath); + if (entryStat.isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${entryPath}`); + } + if (entryStat.isDirectory()) return walkMarkdown(entryPath); + return entryStat.isFile() && entry.name.endsWith('.md') ? [entryPath] : []; }) .sort(); } function listCommandNames(commandsDir) { - if (!existsSync(commandsDir)) { + let commandsStat; + try { + commandsStat = lstatSync(commandsDir); + } catch { + throw new Error(`commands/ directory not found at ${commandsDir}`); + } + if (commandsStat.isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${commandsDir}`); + } + if (!commandsStat.isDirectory()) { throw new Error(`commands/ directory not found at ${commandsDir}`); } return walkMarkdown(commandsDir) @@ -89,21 +102,27 @@ function assertCanonicalCommands(commandNames) { } /** Recursively copy a directory. */ -async function copyDir(src, dst) { +async function copyDir(src, dst, { rejectSymlinks = false } = {}) { if (!existsSync(src)) return 0; ensureDir(dst); const entries = readdirSync(src); + let fileCount = 0; for (const name of entries) { const srcPath = join(src, name); const dstPath = join(dst, name); - const st = statSync(srcPath); + const sourceStat = lstatSync(srcPath); + if (sourceStat.isSymbolicLink() && rejectSymlinks) { + throw new Error(`symbolic links are not allowed in command source: ${srcPath}`); + } + const st = sourceStat.isSymbolicLink() ? statSync(srcPath) : sourceStat; if (st.isDirectory()) { - await copyDir(srcPath, dstPath); + fileCount += await copyDir(srcPath, dstPath, { rejectSymlinks }); } else { await cp(srcPath, dstPath, { force: true }); + fileCount += 1; } } - return entries.length; + return fileCount; } /** @@ -292,7 +311,7 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { } // 2. Copy canonical recovery commands as complete Markdown assets. - const commandCount = await copyDir(plan.commandsDir, targetCommands); + const commandCount = await copyDir(plan.commandsDir, targetCommands, { rejectSymlinks: true }); console.log(` commands/ → ${targetCommands} (${commandCount} entries, ${commandNames.length} commands)`); // 3. Copy skills with ${CLAUDE_PLUGIN_ROOT} rewriting. diff --git a/tests/lib/cmd-install-workbuddy.test.mjs b/tests/lib/cmd-install-workbuddy.test.mjs index b089b60..d2412cf 100644 --- a/tests/lib/cmd-install-workbuddy.test.mjs +++ b/tests/lib/cmd-install-workbuddy.test.mjs @@ -2,7 +2,7 @@ // Tests for scripts/lib/cmd-install-workbuddy.mjs import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync, existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; @@ -125,6 +125,23 @@ describe('cmd-install-workbuddy', () => { assert.equal(existsSync(homeDir), false); }); + it('rejects symbolic links in the canonical command tree before writing WorkBuddy home', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'symlink-home'); + const source = join(pluginRoot, 'commands', 'ssf', 'resume.md'); + symlinkSync(source, join(pluginRoot, 'commands', 'ssf', 'linked-command.md')); + + assert.throws( + () => planInstall({ pluginRoot, homeDir }), + /symbolic links are not allowed in command source/, + ); + await assert.rejects( + installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }), + /symbolic links are not allowed in command source/, + ); + assert.equal(existsSync(homeDir), false); + }); + it('dry-run reports commands and does not write the requested home directory', () => { const pluginRoot = makePluginRoot(); const homeDir = join(tempDir, 'dry-run-home'); @@ -142,6 +159,20 @@ describe('cmd-install-workbuddy', () => { assert.equal(existsSync(homeDir), false); }); + it('reports the recursive command file count during installation', () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'count-home'); + const cli = join(process.cwd(), 'scripts', 'spec-superflow.mjs'); + const stdout = execFileSync(process.execPath, [ + cli, + 'install-workbuddy', + '--local', pluginRoot, + '--home', homeDir, + ], { encoding: 'utf-8' }); + + assert.match(stdout, /commands\/.*\(3 entries, 3 commands\)/); + }); + it('rebuilds the plugin directory so stale commands are removed', async () => { const pluginRoot = makePluginRoot(); const homeDir = join(tempDir, 'home'); From 21fc41b8fc8e9c417ee1994efe1f1e33f0091e67 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 21:29:42 +0800 Subject: [PATCH 16/20] fix: validate complete WorkBuddy command tree --- scripts/lib/cmd-install-workbuddy.mjs | 71 ++++++++++++++++++------ tests/lib/cmd-install-workbuddy.test.mjs | 48 +++++++++++++++- 2 files changed, 99 insertions(+), 20 deletions(-) diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index 436c8ba..1b7c66e 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -19,7 +19,7 @@ import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { cp, writeFile, mkdtemp } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, relative, resolve, sep } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { parseArgs } from 'node:util'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; @@ -34,6 +34,7 @@ const GITHUB_REPO = 'MageByte-Zero/spec-superflow'; const GITHUB_API_URL = `https://api.github.com/repos/${GITHUB_REPO}/releases/latest`; const RUNTIME_DIRS = ['scripts', 'docs', 'templates', 'dist', 'hooks']; const CANONICAL_COMMAND_NAMES = ['ssf:resume', 'ssf:save', 'ssf:switch']; +const CANONICAL_COMMAND_FILES = ['resume.md', 'save.md', 'switch.md']; // ─── helpers ────────────────────────────────────────────── @@ -58,18 +59,27 @@ function listSkillNames(skillsDir) { .sort(); } -function walkMarkdown(dir) { - return readdirSync(dir, { withFileTypes: true }) - .flatMap(entry => { - const entryPath = join(dir, entry.name); - const entryStat = lstatSync(entryPath); - if (entryStat.isSymbolicLink()) { - throw new Error(`symbolic links are not allowed in command source: ${entryPath}`); - } - if (entryStat.isDirectory()) return walkMarkdown(entryPath); - return entryStat.isFile() && entry.name.endsWith('.md') ? [entryPath] : []; - }) - .sort(); +function canonicalCommandTreeError(path) { + return new Error( + `canonical command tree must be exactly commands/ssf/{${CANONICAL_COMMAND_FILES.join(', ')}}: ${path}`, + ); +} + +function listExactEntries(dir, expectedEntries) { + const entries = readdirSync(dir).sort(); + for (const entry of entries) { + const entryPath = join(dir, entry); + if (lstatSync(entryPath).isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${entryPath}`); + } + } + if ( + entries.length !== expectedEntries.length + || entries.some((entry, index) => entry !== expectedEntries[index]) + ) { + throw canonicalCommandTreeError(dir); + } + return entries; } function listCommandNames(commandsDir) { @@ -85,9 +95,28 @@ function listCommandNames(commandsDir) { if (!commandsStat.isDirectory()) { throw new Error(`commands/ directory not found at ${commandsDir}`); } - return walkMarkdown(commandsDir) - .map(filePath => relative(commandsDir, filePath).replace(/\.md$/, '').split(sep).join(':')) - .sort(); + listExactEntries(commandsDir, ['ssf']); + + const ssfCommandsDir = join(commandsDir, 'ssf'); + const ssfCommandsStat = lstatSync(ssfCommandsDir); + if (ssfCommandsStat.isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${ssfCommandsDir}`); + } + if (!ssfCommandsStat.isDirectory()) { + throw canonicalCommandTreeError(ssfCommandsDir); + } + listExactEntries(ssfCommandsDir, CANONICAL_COMMAND_FILES); + for (const file of CANONICAL_COMMAND_FILES) { + const filePath = join(ssfCommandsDir, file); + const fileStat = lstatSync(filePath); + if (fileStat.isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${filePath}`); + } + if (!fileStat.isFile()) { + throw canonicalCommandTreeError(filePath); + } + } + return [...CANONICAL_COMMAND_NAMES]; } function assertCanonicalCommands(commandNames) { @@ -291,6 +320,10 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { const plan = planInstall({ pluginRoot, homeDir, marketplaceName }); const { skillNames, commandNames, targetPluginDir, targetSkills, targetCommands, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = plan; + // Revalidate before clearing the prior installation, then copy only the + // validated canonical command subtree. + listCommandNames(plan.commandsDir); + // 0. Clean old plugin dir. if (existsSync(targetPluginDir)) { rmSync(targetPluginDir, { recursive: true, force: true }); @@ -311,7 +344,11 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { } // 2. Copy canonical recovery commands as complete Markdown assets. - const commandCount = await copyDir(plan.commandsDir, targetCommands, { rejectSymlinks: true }); + const commandCount = await copyDir( + join(plan.commandsDir, 'ssf'), + join(targetCommands, 'ssf'), + { rejectSymlinks: true }, + ); console.log(` commands/ → ${targetCommands} (${commandCount} entries, ${commandNames.length} commands)`); // 3. Copy skills with ${CLAUDE_PLUGIN_ROOT} rewriting. diff --git a/tests/lib/cmd-install-workbuddy.test.mjs b/tests/lib/cmd-install-workbuddy.test.mjs index d2412cf..c8e8a76 100644 --- a/tests/lib/cmd-install-workbuddy.test.mjs +++ b/tests/lib/cmd-install-workbuddy.test.mjs @@ -22,8 +22,8 @@ describe('cmd-install-workbuddy', () => { if (tempDir) rmSync(tempDir, { recursive: true, force: true }); }); - function makePluginRoot({ commands = ['resume', 'save', 'switch'] } = {}) { - const pluginRoot = join(tempDir, 'spec-superflow'); + function makePluginRoot({ commands = ['resume', 'save', 'switch'], name = 'spec-superflow' } = {}) { + const pluginRoot = join(tempDir, name); const skillsDir = join(pluginRoot, 'skills'); mkdirSync(join(skillsDir, 'workflow-start'), { recursive: true }); mkdirSync(join(skillsDir, 'need-explorer'), { recursive: true }); @@ -120,7 +120,7 @@ describe('cmd-install-workbuddy', () => { ); assert.throws( () => planInstall({ pluginRoot: makePluginRoot({ commands: ['resume', 'save'] }), homeDir }), - /canonical recovery command set/, + /canonical command tree/, ); assert.equal(existsSync(homeDir), false); }); @@ -142,6 +142,48 @@ describe('cmd-install-workbuddy', () => { assert.equal(existsSync(homeDir), false); }); + it('rejects extra regular files inside the canonical command directory before writing WorkBuddy home', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'extra-command-file-home'); + writeFileSync(join(pluginRoot, 'commands', 'ssf', 'notes.txt'), 'not a command\n'); + + assert.throws( + () => planInstall({ pluginRoot, homeDir }), + /canonical command tree/, + ); + await assert.rejects( + installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }), + /canonical command tree/, + ); + assert.equal(existsSync(homeDir), false); + }); + + it('rejects unexpected files and directories at the commands root before writing WorkBuddy home', async () => { + const cases = [ + { + name: 'root-file', + mutate: pluginRoot => writeFileSync(join(pluginRoot, 'commands', 'notes.txt'), 'not a command\n'), + }, + { + name: 'root-directory', + mutate: pluginRoot => mkdirSync(join(pluginRoot, 'commands', 'other'), { recursive: true }), + }, + ]; + + for (const { name, mutate } of cases) { + const pluginRoot = makePluginRoot({ name: `spec-superflow-${name}` }); + const homeDir = join(tempDir, `${name}-home`); + mutate(pluginRoot); + + assert.throws(() => planInstall({ pluginRoot, homeDir }), /canonical command tree/); + await assert.rejects( + installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test' }), + /canonical command tree/, + ); + assert.equal(existsSync(homeDir), false); + } + }); + it('dry-run reports commands and does not write the requested home directory', () => { const pluginRoot = makePluginRoot(); const homeDir = join(tempDir, 'dry-run-home'); From 6ea899436b813f8f1d16d302c1d03d10f71ca989 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 21:36:05 +0800 Subject: [PATCH 17/20] fix: snapshot WorkBuddy command assets --- scripts/lib/cmd-install-workbuddy.mjs | 56 +++++++++++++++++------- tests/lib/cmd-install-workbuddy.test.mjs | 19 ++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/scripts/lib/cmd-install-workbuddy.mjs b/scripts/lib/cmd-install-workbuddy.mjs index 1b7c66e..de86fa3 100644 --- a/scripts/lib/cmd-install-workbuddy.mjs +++ b/scripts/lib/cmd-install-workbuddy.mjs @@ -119,6 +119,25 @@ function listCommandNames(commandsDir) { return [...CANONICAL_COMMAND_NAMES]; } +function snapshotCommandAssets(commandsDir) { + listCommandNames(commandsDir); + const ssfCommandsDir = join(commandsDir, 'ssf'); + return Object.freeze(CANONICAL_COMMAND_FILES.map(file => { + const filePath = join(ssfCommandsDir, file); + const fileStat = lstatSync(filePath); + if (fileStat.isSymbolicLink()) { + throw new Error(`symbolic links are not allowed in command source: ${filePath}`); + } + if (!fileStat.isFile()) { + throw canonicalCommandTreeError(filePath); + } + return Object.freeze({ + relativePath: `ssf/${file}`, + content: readFileSync(filePath, 'utf-8'), + }); + })); +} + function assertCanonicalCommands(commandNames) { if ( commandNames.length !== CANONICAL_COMMAND_NAMES.length @@ -130,6 +149,15 @@ function assertCanonicalCommands(commandNames) { } } +async function copyValidatedCommands(commandAssets, targetCommands) { + for (const asset of commandAssets) { + const targetPath = join(targetCommands, ...asset.relativePath.split('/')); + ensureDir(dirname(targetPath)); + await writeFile(targetPath, asset.content, 'utf-8'); + } + return commandAssets.length; +} + /** Recursively copy a directory. */ async function copyDir(src, dst, { rejectSymlinks = false } = {}) { if (!existsSync(src)) return 0; @@ -279,7 +307,8 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark const skillsDir = join(root, 'skills'); const skillNames = listSkillNames(skillsDir); const commandsDir = join(root, 'commands'); - const commandNames = listCommandNames(commandsDir); + const commandAssets = snapshotCommandAssets(commandsDir); + const commandNames = commandAssets.map(asset => asset.relativePath.replace(/\.md$/, '').replace('/', ':')); assertCanonicalCommands(commandNames); const workbuddyRoot = join(homeDir, '.workbuddy'); const pluginsDir = join(workbuddyRoot, 'plugins', 'marketplaces', marketplaceName, 'plugins'); @@ -299,6 +328,7 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark skillNames, commandsDir, commandNames, + commandAssets, workbuddyRoot, pluginsDir, targetPluginDir, @@ -316,13 +346,9 @@ function planInstall({ pluginRoot = defaultPluginRoot, homeDir = homedir(), mark // ─── install ────────────────────────────────────────────── -async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { - const plan = planInstall({ pluginRoot, homeDir, marketplaceName }); - const { skillNames, commandNames, targetPluginDir, targetSkills, targetCommands, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = plan; - - // Revalidate before clearing the prior installation, then copy only the - // validated canonical command subtree. - listCommandNames(plan.commandsDir); +async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName, plan } = {}) { + const installPlan = plan || planInstall({ pluginRoot, homeDir, marketplaceName }); + const { skillNames, commandNames, commandAssets, targetPluginDir, targetSkills, targetCommands, targetRules, manifestDir, settingsPath, enabledPluginKey, version, pluginRootAbs } = installPlan; // 0. Clean old plugin dir. if (existsSync(targetPluginDir)) { @@ -333,7 +359,7 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { // 1. Copy runtime dependencies (scripts/docs/templates/dist/hooks). console.log('📋 Copying runtime dependencies...'); for (const dir of RUNTIME_DIRS) { - const src = join(plan.pluginRoot, dir); + const src = join(installPlan.pluginRoot, dir); const dst = join(targetPluginDir, dir); if (existsSync(src)) { const count = await copyDir(src, dst); @@ -344,15 +370,11 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { } // 2. Copy canonical recovery commands as complete Markdown assets. - const commandCount = await copyDir( - join(plan.commandsDir, 'ssf'), - join(targetCommands, 'ssf'), - { rejectSymlinks: true }, - ); + const commandCount = await copyValidatedCommands(commandAssets, targetCommands); console.log(` commands/ → ${targetCommands} (${commandCount} entries, ${commandNames.length} commands)`); // 3. Copy skills with ${CLAUDE_PLUGIN_ROOT} rewriting. - const count = await copySkillsWithRoot(plan.skillsDir, targetSkills, pluginRootAbs); + const count = await copySkillsWithRoot(installPlan.skillsDir, targetSkills, pluginRootAbs); console.log(` skills/ → ${targetSkills} (${count} skills, paths rewritten)`); // 4. Write phase-guard rule. @@ -366,7 +388,7 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { console.log(` manifest → ${join(manifestDir, 'plugin.json')}`); // 6. Enable plugin in settings.json. - ensureDir(plan.workbuddyRoot); + ensureDir(installPlan.workbuddyRoot); const settings = readJsonIfExists(settingsPath); settings.enabledPlugins = settings.enabledPlugins && typeof settings.enabledPlugins === 'object' ? settings.enabledPlugins @@ -379,7 +401,7 @@ async function installWorkBuddy({ pluginRoot, homeDir, marketplaceName }) { writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); console.log(` settings → ${enabledPluginKey} enabled`); - return plan; + return installPlan; } // ─── CLI entry ──────────────────────────────────────────── diff --git a/tests/lib/cmd-install-workbuddy.test.mjs b/tests/lib/cmd-install-workbuddy.test.mjs index c8e8a76..540a8eb 100644 --- a/tests/lib/cmd-install-workbuddy.test.mjs +++ b/tests/lib/cmd-install-workbuddy.test.mjs @@ -215,6 +215,25 @@ describe('cmd-install-workbuddy', () => { assert.match(stdout, /commands\/.*\(3 entries, 3 commands\)/); }); + it('installs only the immutable command snapshot when source changes after planning', async () => { + const pluginRoot = makePluginRoot(); + const homeDir = join(tempDir, 'snapshot-home'); + const plan = planInstall({ pluginRoot, homeDir, marketplaceName: 'test' }); + assert.equal(plan.commandAssets.length, 3); + assert.deepEqual( + plan.commandAssets.map(asset => asset.relativePath), + ['ssf/resume.md', 'ssf/save.md', 'ssf/switch.md'], + ); + + writeFileSync(join(pluginRoot, 'commands', 'ssf', 'resume.md'), '# changed after planning\n'); + writeFileSync(join(pluginRoot, 'commands', 'ssf', 'late-addition.md'), '# should not install\n'); + + const result = await installWorkBuddy({ pluginRoot, homeDir, marketplaceName: 'test', plan }); + assert.equal(result, plan); + assert.equal(readFileSync(join(plan.targetCommands, 'ssf', 'resume.md'), 'utf-8'), '# resume\n'); + assert.equal(existsSync(join(plan.targetCommands, 'ssf', 'late-addition.md')), false); + }); + it('rebuilds the plugin directory so stale commands are removed', async () => { const pluginRoot = makePluginRoot(); const homeDir = join(tempDir, 'home'); From e7a7659749467defabac845995b034034d4aa8ce Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 21:53:42 +0800 Subject: [PATCH 18/20] docs: publish issue 47 recovery workflow --- CHANGELOG.md | 4 +++ INSTALL.md | 12 ++++++-- README.md | 13 +++++++-- docs/README_en.md | 14 +++++++-- docs/artifact-contract.md | 18 ++++++++++-- docs/release-checklist.md | 5 +++- docs/state-machine.md | 14 +++++++-- tests/lib/execution-control-plane.test.mjs | 33 ++++++++++++++++++---- 8 files changed, 96 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7565072..dbf652d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format loosely follows Keep a Changelog. ## [Unreleased] +### Added + +- **Closes #47 — Recovery workflow commands**: publish `ssf resume`, `ssf switch`, and `ssf save` as a control-plane overlay. Resume/switch are read-only; save writes only the compatible checkpoint and never automatically commits, pushes, or syncs. WorkBuddy distributes the canonical `/ssf:resume`, `/ssf:switch`, and `/ssf:save` Markdown command adapters. + ## [0.10.0] - 2026-07-16 ### Added diff --git a/INSTALL.md b/INSTALL.md index 7af1361..21a9803 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -339,6 +339,8 @@ rm -rf your-project/.agents/skills WorkBuddy 把 Skill 作为 marketplace 插件管理。安装器把 spec-superflow 部署为单个插件,包含 9 个 skill、运行时依赖(scripts/docs/templates/dist/hooks)、phase-guard 规则和 `.codebuddy-plugin/plugin.json` 清单,写入 `~/.workbuddy/plugins/marketplaces//plugins/spec-superflow/`。 +安装器还会分发三份 canonical Markdown command adapter:`/ssf:resume`、`/ssf:switch`、`/ssf:save`。它们仅在 CodeBuddy/WorkBuddy 的 command 机制中提供这些 slash 名称,并调用同一组 CLI guard;不表示所有平台都有完全相同的 slash 命令。 + ### 安装(推荐:一键脚本) ```bash @@ -362,6 +364,7 @@ ssf install-workbuddy --dry-run ```text ~/.workbuddy/plugins/marketplaces/cb_teams_marketplace/plugins/spec-superflow/ ├── .codebuddy-plugin/plugin.json ← 插件清单(name, version, skills[]) +├── commands/ssf/ ← resume、switch、save Markdown command adapter ├── skills/ ← 9 个 skill(${CLAUDE_PLUGIN_ROOT} 已重写) ├── rules/phase-guard.md ← phase-guard 规则(WorkBuddy 自动加载) ├── scripts/ docs/ templates/ ← 运行时依赖 @@ -759,8 +762,7 @@ ssf execution review changes/my-change --wave foundation --base --head /spec.md`。扁平的 `specs/.md` 和根级 `specs/spec.md` 都不会被当作合法规范静默通过。 @@ -776,6 +778,10 @@ ssf inject changes/my-change --platforms all ### 会话恢复与可选 prototype ```bash +ssf resume # 恰好一个活跃 change 时才自动选择 +ssf resume changes/my-change # 只读恢复指定 change 摘要 +ssf switch changes/another-change # 只读返回明确 change 的恢复上下文 +ssf save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint list changes/my-change ssf checkpoint show changes/my-change 1.1 @@ -785,6 +791,8 @@ ssf handoff finish changes/my-change ssf handoff resolve changes/my-change --decision accept ``` +`resume` 与 `switch` 是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文切换当前对话关注对象。`save` 只手动写入兼容 checkpoint,不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy Markdown command adapter,会分发至相同的 CLI guard。 + Checkpoint 是任务级恢复上下文。`result-ready` handoff 在继续受影响的工作前必须显式审阅并 resolve。Prototype 只在用户明确确认后创建;后端、CLI、配置和内部重构不会自动进入 prototype 流程。handoff 结果不会自动修改 `design.md` 或 `tasks.md`。 ## 验证 diff --git a/README.md b/README.md index 777b0e3..c7e064c 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,9 @@ npx spec-superflow list # 或通过 npx 使用 | `ssf checkpoint save --task --next ` | 保存任务级会话恢复点 | | `ssf checkpoint list ` | 列出 checkpoint 及 stale 状态 | | `ssf checkpoint show ` | 查看单个恢复点 | +| `ssf resume [change]` | 只读恢复摘要;唯一活跃 change 可自动选择 | +| `ssf switch ` | 只读返回明确 change 的恢复上下文;adapter 可据此切换当前 AI 对话关注对象 | +| `ssf save --task --next ` | 手动写入兼容 checkpoint;不自动 commit、push 或 sync | | `ssf handoff create --type ...` | 创建 prototype/research/experiment handoff | | `ssf handoff list ` | 列出 handoff 生命周期状态 | | `ssf handoff finish ` | 校验 handoff 结果 | @@ -182,11 +185,17 @@ ssf inject changes/my-change --platforms all 会话恢复与可选 prototype: ```bash +ssf resume # 只在唯一活跃 change 时自动选择 +ssf resume changes/my-change # 只读恢复指定 change 的摘要 +ssf switch changes/another-change # 只读返回明确 change 的恢复上下文 +ssf save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint list changes/my-change ssf handoff create changes/my-change --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" ``` +`resume` 与 `switch` 都是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择目标。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文切换当前对话关注对象。`save` 仅手动写入既有 checkpoint 协议,绝不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy 使用的 Markdown command adapter:它们分发到同一 CLI guard,不为其他平台承诺完全相同的 slash 名称。 + Prototype 只在用户明确确认后创建;后端、CLI、配置和内部重构不会自动进入 prototype 流程。handoff 结果不会自动修改 `design.md` 或 `tasks.md`。 Delta spec 的规范路径是 `specs//spec.md`;扁平的 `specs/.md` 和根级 `specs/spec.md` 不会被视为合法规范。 @@ -231,8 +240,8 @@ ssf execution review changes/my-change --wave foundation --base --head --task --next ` | Save a task-level recovery checkpoint | | `ssf checkpoint list ` | List checkpoints and stale status | | `ssf checkpoint show ` | Show one recovery checkpoint | +| `ssf resume [change]` | Read-only recovery summary; auto-selects the only active change | +| `ssf switch ` | Read-only recovery context for an explicit change; an adapter may use it to focus the current AI conversation | +| `ssf save --task --next ` | Manually writes a compatible checkpoint; never commits, pushes, or syncs automatically | | `ssf handoff create --type ...` | Create a prototype/research/experiment handoff | | `ssf handoff list ` | List handoff lifecycle status | | `ssf handoff finish ` | Validate a handoff result | @@ -144,11 +147,17 @@ If `--platforms` is omitted, injection only proceeds when exactly one project ma Session recovery and optional prototypes: ```bash +ssf resume # auto-select only when exactly one change is active +ssf resume changes/my-change # read-only summary for one explicit change +ssf switch changes/another-change # read-only recovery context for one explicit change +ssf save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint save changes/my-change --task 1.1 --next "Run focused tests" ssf checkpoint list changes/my-change ssf handoff create changes/my-change --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" ``` +`resume` and `switch` are read-only recovery operations; `resume` auto-selects a target only when exactly one active change exists. `switch` only returns the explicit target's recovery context and never changes cwd, a TUI session, or a hidden pointer; a CodeBuddy/WorkBuddy adapter or host agent may use that context to focus the conversation. `save` only manually writes the established checkpoint protocol; it never commits, pushes, or syncs automatically. `/ssf:resume`, `/ssf:switch`, and `/ssf:save` are Markdown command adapters for CodeBuddy/WorkBuddy that dispatch to the same CLI guards; other platforms are not promised identical slash names. + Prototype work starts only after explicit user confirmation. Backend, CLI, configuration, and internal-refactor work does not enter the prototype route automatically. Handoff results never edit `design.md` or `tasks.md` automatically. @@ -274,8 +283,9 @@ non-symlink file. Every planned wave needs a current `pass` review receipt before dependent waves or closing may proceed; revising a plan invalidates earlier receipts. -The recovery, switching, and manual-save slash commands proposed in #47 are -not implemented, so this documentation does not claim `/ssf:*` commands. +Recovery, switching, and manual save form a control-plane overlay, not a ninth +workflow state; their CLI and CodeBuddy/WorkBuddy Markdown adapters keep the +same guards. ### Fast Paths (hotfix / tweak) diff --git a/docs/artifact-contract.md b/docs/artifact-contract.md index 69ece26..1fb04d3 100644 --- a/docs/artifact-contract.md +++ b/docs/artifact-contract.md @@ -71,9 +71,21 @@ required for every wave before dependent work or closing proceeds. `tweak` is exempt from execution-plan and review-receipt gates. `ssf execution revise` retains or upgrades an existing plan as `sdd`, requires fresh confirmation, creates a new revision, and -clears prior review receipts; it never permits a downgrade. #47 slash commands for recovery, -switching, and manual save are not implemented, so `/ssf:*` commands must not -be claimed. +clears prior review receipts; it never permits a downgrade. + +### Recovery control-plane overlay + +Recovery commands operate beside the eight-state workflow, without creating a +ninth state or a new transition. `ssf resume [change-dir]` and `ssf switch +` are read-only: resume returns a recovery summary and chooses a +target automatically only when there is one active change; switch returns the +explicit target's recovery context and never changes cwd, a TUI session, or a +hidden pointer. Its CodeBuddy/WorkBuddy adapter may use that context to focus a +conversation. `ssf save --task --next ` manually writes a +compatible checkpoint through the existing checkpoint save protocol. It never +commits, pushes, or syncs automatically. `/ssf:resume`, `/ssf:switch`, and +`/ssf:save` are CodeBuddy/WorkBuddy Markdown command adapters that dispatch to +the same CLI guards; other platforms are not promised identical slash names. ## Mapping diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 66edfae..1c71a53 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -27,6 +27,7 @@ Use this checklist before publishing a new version of `spec-superflow`. - templates reflect the current workflow expectations - `docs/artifact-contract.md` matches the templates and skills - `docs/state-machine.md` matches the actual workflow routing model +- Recovery remains a control-plane overlay: resume/switch are read-only, save writes only the compatible checkpoint, and no ninth state is documented. - examples still demonstrate the documented workflow ## Example Quality @@ -46,7 +47,9 @@ For each example in `docs/examples/`: - `node scripts/spec-superflow.mjs version --dry-run` — reports all files in sync - `node scripts/check-version-consistency.mjs` — exits 0 - `node scripts/spec-superflow.mjs --help` — all subcommands listed -- `node scripts/spec-superflow.mjs install-workbuddy --dry-run` — finds all 9 skills and target paths +- Verify `commands/ssf/resume.md`, `commands/ssf/switch.md`, and `commands/ssf/save.md` are complete canonical Markdown command assets. +- `node scripts/spec-superflow.mjs install-workbuddy --dry-run` — finds all 9 skills, all 3 recovery commands, and target paths. +- Run `install-workbuddy` against a temporary home and verify it installs `ssf:resume`, `ssf:switch`, and `ssf:save` as complete command assets. - `npm run test:raw-mode` — packs the current source and runs a canonical runtime in an empty directory with no plugin-root variables or global `ssf`. - Run a representative local-installer smoke test. - `spec-superflow.config.json` absence still works (backward compatible defaults) diff --git a/docs/state-machine.md b/docs/state-machine.md index 1dc4cc7..00e66e0 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -60,8 +60,9 @@ are eligible. Each completed wave must have a current wave or `closing` can proceed. `ssf execution revise` retains or upgrades an existing plan as `sdd`; that new revision requires a fresh confirmation (and acknowledgement when it differs from the new recommendation), invalidates old -review receipts, and does not permit a downgrade. #47 recovery/switch/save -slash commands are not implemented; do not assume `/ssf:*` commands exist. +review receipts, and does not permit a downgrade. Recovery, switching, and +manual save are a control-plane overlay; they do not create a ninth workflow +state. ### `debugging` @@ -96,6 +97,12 @@ Checkpoints, handoffs, and prototypes are durable overlays, not workflow states. They do not add transitions to the state machine or change the meaning of the eight core states. +- `ssf resume [change-dir]` is read-only and returns a recovery summary. With no + target, it auto-selects only the unique active change. +- `ssf switch ` is read-only and returns the explicit target's + recovery context; it never changes cwd, a TUI session, or a hidden pointer. +- `ssf save --task --next ` manually writes a compatible + checkpoint. It never commits, pushes, or syncs automatically. - `ssf checkpoint save --task --next ` records task-level recovery context under `.superpowers/sdd/checkpoints/`. - `ssf handoff create --type ...` creates explicit side-work @@ -105,6 +112,9 @@ eight core states. checkpoints remain historical evidence only. - Prototype work is optional and requires explicit user confirmation. Results are reviewed manually and never mutate `design.md` or `tasks.md`. +- `/ssf:resume`, `/ssf:switch`, and `/ssf:save` are CodeBuddy/WorkBuddy Markdown + command adapters for these CLI guards. The switch adapter may use its returned + context to focus a conversation; these names are not promised on every platform. ## Transitions diff --git a/tests/lib/execution-control-plane.test.mjs b/tests/lib/execution-control-plane.test.mjs index d7dea99..c0cba1b 100644 --- a/tests/lib/execution-control-plane.test.mjs +++ b/tests/lib/execution-control-plane.test.mjs @@ -7,7 +7,7 @@ const root = process.cwd(); const read = path => readFileSync(join(root, path), 'utf8'); describe('execution control plane instructions', () => { - it('documents #45 guarded execution without claiming #47 slash commands', () => { + it('documents #45 guarded execution', () => { const documents = [ 'README.md', 'docs/README_en.md', @@ -29,16 +29,39 @@ describe('execution control plane instructions', () => { assert.match(read('templates/execution-contract.md'), /Execution Waves/); assert.match(read('CHANGELOG.md'), /#45/); - assert.doesNotMatch(read('README.md'), /\/ssf:resume/); - assert.doesNotMatch(read('docs/README_en.md'), /\/ssf:resume/); - assert.doesNotMatch(read('INSTALL.md'), /\/ssf:resume/); - for (const path of documents) { assert.doesNotMatch(read(path), /automatic(?:ally)?\s+(?:defaults?\s+to\s+)?Batch Inline/i, `${path} does not advertise automatic Batch Inline`); } }); + it('documents implemented #47 recovery commands without adding states', () => { + for (const path of ['README.md', 'docs/README_en.md', 'INSTALL.md']) { + const content = read(path); + for (const command of ['/ssf:resume', '/ssf:switch', '/ssf:save']) { + assert.match(content, new RegExp(command.replace('/', '\\/'))); + } + assert.match(content, /ssf resume/); + assert.match(content, /ssf switch/); + assert.match(content, /ssf save/); + assert.match(content, /只读|read-only/i, `${path} keeps recovery commands read-only`); + assert.match(content, /switch.*(?:只读|read-only).*?(?:恢复上下文|recovery context)/is, + `${path} documents switch as a read-only recovery-context operation`); + assert.match(content, /save.*(?:checkpoint|检查点).*?(?:不自动 commit、push 或 sync|never commits, pushes, or syncs automatically)/is, + `${path} limits save to checkpoint writes without automatic Git or sync effects`); + assert.match(content, /CodeBuddy\/WorkBuddy/, + `${path} limits slash adapters to CodeBuddy/WorkBuddy`); + assert.match(content, /不为其他平台承诺完全相同的 slash 名称|not promised identical slash names|不表示所有平台都有完全相同的 slash 命令/i, + `${path} does not promise identical slash names on every platform`); + } + assert.match(read('docs/state-machine.md'), /control[- ]plane overlay|控制层叠加/i); + assert.match(read('docs/state-machine.md'), /ninth\s+workflow\s+state|第九个状态/i); + assert.match(read('docs/artifact-contract.md'), /checkpoint.*save|保存.*checkpoint/is); + assert.match(read('docs/release-checklist.md'), /commands\/ssf\/resume\.md/); + assert.match(read('docs/release-checklist.md'), /complete command assets/i); + assert.match(read('CHANGELOG.md'), /#47/); + }); + it('documents only the persisted execution-plan contract that #45 implements', () => { const documents = [ 'README.md', From a2658267128c7f783055a4286917171a11201a66 Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 22:10:34 +0800 Subject: [PATCH 19/20] docs: harden issue 47 recovery contract --- INSTALL.md | 2 +- README.md | 2 +- docs/README_en.md | 4 +- docs/release-checklist.md | 20 ++++ tests/lib/execution-control-plane.test.mjs | 104 +++++++++++++++++---- 5 files changed, 109 insertions(+), 23 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 21a9803..ff1fa82 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -791,7 +791,7 @@ ssf handoff finish changes/my-change ssf handoff resolve changes/my-change --decision accept ``` -`resume` 与 `switch` 是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文切换当前对话关注对象。`save` 只手动写入兼容 checkpoint,不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy Markdown command adapter,会分发至相同的 CLI guard。 +`resume` 与 `switch` 是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CLI 本身不切换当前对话关注对象,CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文完成该动作。`save` 只手动复用既有 checkpoint 协议,不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy Markdown command adapter,会分发至相同的 CLI guard,不为其他平台承诺完全相同的 slash 名称。 Checkpoint 是任务级恢复上下文。`result-ready` handoff 在继续受影响的工作前必须显式审阅并 resolve。Prototype 只在用户明确确认后创建;后端、CLI、配置和内部重构不会自动进入 prototype 流程。handoff 结果不会自动修改 `design.md` 或 `tasks.md`。 diff --git a/README.md b/README.md index c7e064c..9eb4206 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,7 @@ ssf checkpoint list changes/my-change ssf handoff create changes/my-change --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" ``` -`resume` 与 `switch` 都是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择目标。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文切换当前对话关注对象。`save` 仅手动写入既有 checkpoint 协议,绝不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy 使用的 Markdown command adapter:它们分发到同一 CLI guard,不为其他平台承诺完全相同的 slash 名称。 +`resume` 与 `switch` 都是只读恢复操作;`resume` 只会在恰好一个活跃 change 时自动选择目标。`switch` 只返回明确目标的恢复上下文,不修改 cwd、TUI 会话或任何隐藏指针;CLI 本身不切换当前对话关注对象,CodeBuddy/WorkBuddy adapter 或宿主 Agent 可用该上下文完成该动作。`save` 仅手动写入既有 checkpoint 协议,绝不自动 commit、push 或 sync。`/ssf:resume`、`/ssf:switch`、`/ssf:save` 是 CodeBuddy/WorkBuddy 使用的 Markdown command adapter:它们分发到同一 CLI guard,不为其他平台承诺完全相同的 slash 名称。 Prototype 只在用户明确确认后创建;后端、CLI、配置和内部重构不会自动进入 prototype 流程。handoff 结果不会自动修改 `design.md` 或 `tasks.md`。 diff --git a/docs/README_en.md b/docs/README_en.md index 36c13d8..3245b74 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -119,7 +119,7 @@ npm install -g spec-superflow | `ssf checkpoint show ` | Show one recovery checkpoint | | `ssf resume [change]` | Read-only recovery summary; auto-selects the only active change | | `ssf switch ` | Read-only recovery context for an explicit change; an adapter may use it to focus the current AI conversation | -| `ssf save --task --next ` | Manually writes a compatible checkpoint; never commits, pushes, or syncs automatically | +| `ssf save --task --next ` | Manually reuses the existing checkpoint protocol; never commits, pushes, or syncs automatically | | `ssf handoff create --type ...` | Create a prototype/research/experiment handoff | | `ssf handoff list ` | List handoff lifecycle status | | `ssf handoff finish ` | Validate a handoff result | @@ -156,7 +156,7 @@ ssf checkpoint list changes/my-change ssf handoff create changes/my-change --type research --objective "Compare approaches" --expected-output "Recommendation" --acceptance "Evidence recorded" ``` -`resume` and `switch` are read-only recovery operations; `resume` auto-selects a target only when exactly one active change exists. `switch` only returns the explicit target's recovery context and never changes cwd, a TUI session, or a hidden pointer; a CodeBuddy/WorkBuddy adapter or host agent may use that context to focus the conversation. `save` only manually writes the established checkpoint protocol; it never commits, pushes, or syncs automatically. `/ssf:resume`, `/ssf:switch`, and `/ssf:save` are Markdown command adapters for CodeBuddy/WorkBuddy that dispatch to the same CLI guards; other platforms are not promised identical slash names. +`resume` and `switch` are read-only recovery operations; `resume` auto-selects a target only when exactly one active change exists. `switch` only returns the explicit target's recovery context and never changes cwd, a TUI session, or a hidden pointer; the CLI itself does not mutate conversation focus, while a CodeBuddy/WorkBuddy adapter or host agent may use that context to focus the conversation. `save` only manually reuses the existing checkpoint protocol; it never commits, pushes, or syncs automatically. `/ssf:resume`, `/ssf:switch`, and `/ssf:save` are Markdown command adapters for CodeBuddy/WorkBuddy that dispatch to the same CLI guards; other platforms are not promised identical slash names. Prototype work starts only after explicit user confirmation. Backend, CLI, configuration, and internal-refactor work does not enter the prototype route diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 1c71a53..3ad9a29 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -50,6 +50,26 @@ For each example in `docs/examples/`: - Verify `commands/ssf/resume.md`, `commands/ssf/switch.md`, and `commands/ssf/save.md` are complete canonical Markdown command assets. - `node scripts/spec-superflow.mjs install-workbuddy --dry-run` — finds all 9 skills, all 3 recovery commands, and target paths. - Run `install-workbuddy` against a temporary home and verify it installs `ssf:resume`, `ssf:switch`, and `ssf:save` as complete command assets. + + ```bash + # Local release-candidate smoke: never writes ~/.workbuddy or downloads latest. + SSF_WORKBUDDY_SMOKE_HOME="$(mktemp -d)" + node scripts/spec-superflow.mjs install-workbuddy --local "$PWD" --home "$SSF_WORKBUDDY_SMOKE_HOME" + SSF_WORKBUDDY_PLUGIN="$SSF_WORKBUDDY_SMOKE_HOME/.workbuddy/plugins/marketplaces/cb_teams_marketplace/plugins/spec-superflow" + test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/resume.md" + test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/switch.md" + test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/save.md" + test "$(find "$SSF_WORKBUDDY_PLUGIN/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 9 + test -d "$SSF_WORKBUDDY_PLUGIN/scripts" + test -d "$SSF_WORKBUDDY_PLUGIN/docs" + test -d "$SSF_WORKBUDDY_PLUGIN/templates" + test -d "$SSF_WORKBUDDY_PLUGIN/dist" + test -d "$SSF_WORKBUDDY_PLUGIN/hooks" + test -f "$SSF_WORKBUDDY_PLUGIN/rules/phase-guard.md" + test -f "$SSF_WORKBUDDY_PLUGIN/.codebuddy-plugin/plugin.json" + node --input-type=module -e 'import { readFileSync } from "node:fs"; const settings = JSON.parse(readFileSync(process.argv[1], "utf8")); if (settings.enabledPlugins?.["spec-superflow@cb_teams_marketplace"] !== true) throw new Error("WorkBuddy plugin is not enabled");' "$SSF_WORKBUDDY_SMOKE_HOME/.workbuddy/settings.json" + node --test tests/lib/cmd-install-workbuddy.test.mjs + ``` - `npm run test:raw-mode` — packs the current source and runs a canonical runtime in an empty directory with no plugin-root variables or global `ssf`. - Run a representative local-installer smoke test. - `spec-superflow.config.json` absence still works (backward compatible defaults) diff --git a/tests/lib/execution-control-plane.test.mjs b/tests/lib/execution-control-plane.test.mjs index c0cba1b..964a60c 100644 --- a/tests/lib/execution-control-plane.test.mjs +++ b/tests/lib/execution-control-plane.test.mjs @@ -36,29 +36,95 @@ describe('execution control plane instructions', () => { }); it('documents implemented #47 recovery commands without adding states', () => { - for (const path of ['README.md', 'docs/README_en.md', 'INSTALL.md']) { + const chineseDocuments = ['README.md', 'INSTALL.md']; + for (const path of chineseDocuments) { const content = read(path); for (const command of ['/ssf:resume', '/ssf:switch', '/ssf:save']) { - assert.match(content, new RegExp(command.replace('/', '\\/'))); + assert.match(content, new RegExp(command.replace('/', '\\/')), + `${path} publishes ${command}`); } - assert.match(content, /ssf resume/); - assert.match(content, /ssf switch/); - assert.match(content, /ssf save/); - assert.match(content, /只读|read-only/i, `${path} keeps recovery commands read-only`); - assert.match(content, /switch.*(?:只读|read-only).*?(?:恢复上下文|recovery context)/is, - `${path} documents switch as a read-only recovery-context operation`); - assert.match(content, /save.*(?:checkpoint|检查点).*?(?:不自动 commit、push 或 sync|never commits, pushes, or syncs automatically)/is, - `${path} limits save to checkpoint writes without automatic Git or sync effects`); - assert.match(content, /CodeBuddy\/WorkBuddy/, - `${path} limits slash adapters to CodeBuddy/WorkBuddy`); - assert.match(content, /不为其他平台承诺完全相同的 slash 名称|not promised identical slash names|不表示所有平台都有完全相同的 slash 命令/i, - `${path} does not promise identical slash names on every platform`); + assert.match(content, /ssf resume.*(?:唯一活跃|恰好一个活跃).*自动选择/is, + `${path} limits resume auto-selection to the sole active change`); + assert.match(content, /ssf switch.*只读.*恢复上下文/is, + `${path} documents switch as read-only recovery context`); + assert.match(content, /switch.*不修改 cwd、TUI 会话或任何隐藏指针.*CLI 本身不.*当前对话关注对象/is, + `${path} keeps switch from mutating environment or conversation focus`); + assert.match(content, /save.*(?:既有|已有).*checkpoint.*不自动 commit、push 或 sync/is, + `${path} limits save to the existing checkpoint without automatic Git or sync effects`); + assert.match(content, /CodeBuddy\/WorkBuddy.*不为其他平台承诺完全相同的 slash 名称/is, + `${path} scopes slash adapters to CodeBuddy/WorkBuddy`); + } + + const english = read('docs/README_en.md'); + for (const command of ['/ssf:resume', '/ssf:switch', '/ssf:save']) { + assert.match(english, new RegExp(command.replace('/', '\\/')), + `docs/README_en.md publishes ${command}`); + } + assert.match(english, /ssf resume.*exactly one active change/is, + 'English docs limit resume auto-selection to the sole active change'); + assert.match(english, /ssf switch.*read-only recovery context/is, + 'English docs describe switch as read-only recovery context'); + assert.match(english, /switch.*never changes cwd, a TUI session, or a hidden pointer.*CLI itself does not.*conversation focus/is, + 'English docs keep switch from mutating environment or conversation focus'); + assert.match(english, /save.*existing checkpoint.*never commits, pushes, or syncs automatically/is, + 'English docs limit save to the existing checkpoint without automatic Git or sync effects'); + assert.match(english, /CodeBuddy\/WorkBuddy.*not promised identical slash names/is, + 'English docs scope slash adapters to CodeBuddy/WorkBuddy'); + + for (const path of [ + 'README.md', + 'docs/README_en.md', + 'INSTALL.md', + 'docs/state-machine.md', + 'docs/artifact-contract.md', + ]) { + assert.doesNotMatch(read(path), + /(?:#47|recovery|恢复|slash).{0,200}(?:not implemented|未实现)|(?:not implemented|未实现).{0,200}(?:#47|recovery|恢复|slash)/is, + `${path} does not claim #47 recovery or slash commands are unimplemented`); + } + + const stateMachine = read('docs/state-machine.md'); + assert.match(stateMachine, /control[- ]plane overlay/is, + 'state machine calls recovery a control-plane overlay'); + assert.match(stateMachine, /do not create a ninth\s+workflow\s+state/is, + 'state machine rejects a ninth workflow state'); + + const artifactContract = read('docs/artifact-contract.md'); + assert.match(artifactContract, /resume.*switch.*read-only/is, + 'artifact contract keeps resume and switch read-only'); + assert.match(artifactContract, /save.*existing checkpoint save protocol/is, + 'artifact contract writes save through the existing checkpoint protocol'); + + const releaseChecklist = read('docs/release-checklist.md'); + assert.match(releaseChecklist, /SSF_WORKBUDDY_SMOKE_HOME="\$\(mktemp -d\)"/, + 'release checklist creates a task-specific temporary WorkBuddy home'); + assert.match(releaseChecklist, + /node scripts\/spec-superflow\.mjs install-workbuddy --local "\$PWD" --home "\$SSF_WORKBUDDY_SMOKE_HOME"/, + 'release checklist installs the local candidate into that temporary home'); + assert.match(releaseChecklist, /cmd-install-workbuddy\.test\.mjs/, + 'release checklist validates the installer with its focused test'); + for (const [asset, assertion] of [ + ['commands/ssf/resume.md', /test -f "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf\/resume\.md"/], + ['commands/ssf/switch.md', /test -f "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf\/switch\.md"/], + ['commands/ssf/save.md', /test -f "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf\/save\.md"/], + ]) { + assert.match(releaseChecklist, assertion, `release checklist asserts ${asset}`); + } + assert.match(releaseChecklist, + /find "\$SSF_WORKBUDDY_PLUGIN\/skills".*-type d.*= 9/is, + 'release checklist asserts all nine skills'); + for (const runtimeDir of ['scripts', 'docs', 'templates', 'dist', 'hooks']) { + assert.match(releaseChecklist, + new RegExp(`test -d "\\$SSF_WORKBUDDY_PLUGIN/${runtimeDir}"`), + `release checklist validates ${runtimeDir}`); } - assert.match(read('docs/state-machine.md'), /control[- ]plane overlay|控制层叠加/i); - assert.match(read('docs/state-machine.md'), /ninth\s+workflow\s+state|第九个状态/i); - assert.match(read('docs/artifact-contract.md'), /checkpoint.*save|保存.*checkpoint/is); - assert.match(read('docs/release-checklist.md'), /commands\/ssf\/resume\.md/); - assert.match(read('docs/release-checklist.md'), /complete command assets/i); + assert.match(releaseChecklist, /test -f "\$SSF_WORKBUDDY_PLUGIN\/rules\/phase-guard\.md"/, + 'release checklist validates the WorkBuddy rule'); + assert.match(releaseChecklist, /test -f "\$SSF_WORKBUDDY_PLUGIN\/\.codebuddy-plugin\/plugin\.json"/, + 'release checklist validates the plugin manifest'); + assert.match(releaseChecklist, + /enabledPlugins.*spec-superflow@cb_teams_marketplace.*SSF_WORKBUDDY_SMOKE_HOME\/\.workbuddy\/settings\.json/is, + 'release checklist validates the enabled-plugin setting'); assert.match(read('CHANGELOG.md'), /#47/); }); From 06672df7a4bf44a8f4b886cd1f6fa54bf22d3e6d Mon Sep 17 00:00:00 2001 From: MageByte Date: Sun, 19 Jul 2026 23:06:02 +0800 Subject: [PATCH 20/20] fix: complete issue 47 recovery release guards --- docs/release-checklist.md | 9 +++ scripts/lib/recovery-command.mjs | 17 +++-- tests/lib/cmd-recovery.test.mjs | 72 ++++++++++++++++++++-- tests/lib/execution-control-plane.test.mjs | 9 +++ tests/lib/recovery-command-assets.test.mjs | 45 ++++++++++++++ 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 3ad9a29..3c4562e 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -48,17 +48,26 @@ For each example in `docs/examples/`: - `node scripts/check-version-consistency.mjs` — exits 0 - `node scripts/spec-superflow.mjs --help` — all subcommands listed - Verify `commands/ssf/resume.md`, `commands/ssf/switch.md`, and `commands/ssf/save.md` are complete canonical Markdown command assets. +- `node --test tests/lib/recovery-command-assets.test.mjs` — scans every command asset for checkout-specific absolute paths; any failure is a release blocker. - `node scripts/spec-superflow.mjs install-workbuddy --dry-run` — finds all 9 skills, all 3 recovery commands, and target paths. - Run `install-workbuddy` against a temporary home and verify it installs `ssf:resume`, `ssf:switch`, and `ssf:save` as complete command assets. ```bash # Local release-candidate smoke: never writes ~/.workbuddy or downloads latest. SSF_WORKBUDDY_SMOKE_HOME="$(mktemp -d)" + if grep -R -F "$PWD/" commands/ssf; then + echo "Canonical command assets contain the local checkout path" >&2 + exit 1 + fi node scripts/spec-superflow.mjs install-workbuddy --local "$PWD" --home "$SSF_WORKBUDDY_SMOKE_HOME" SSF_WORKBUDDY_PLUGIN="$SSF_WORKBUDDY_SMOKE_HOME/.workbuddy/plugins/marketplaces/cb_teams_marketplace/plugins/spec-superflow" test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/resume.md" test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/switch.md" test -f "$SSF_WORKBUDDY_PLUGIN/commands/ssf/save.md" + if grep -R -F "$PWD/" "$SSF_WORKBUDDY_PLUGIN/commands/ssf"; then + echo "Installed command assets contain the local checkout path" >&2 + exit 1 + fi test "$(find "$SSF_WORKBUDDY_PLUGIN/skills" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 9 test -d "$SSF_WORKBUDDY_PLUGIN/scripts" test -d "$SSF_WORKBUDDY_PLUGIN/docs" diff --git a/scripts/lib/recovery-command.mjs b/scripts/lib/recovery-command.mjs index 98ef856..8e8d8f7 100644 --- a/scripts/lib/recovery-command.mjs +++ b/scripts/lib/recovery-command.mjs @@ -76,19 +76,28 @@ function printRecoverySummary(json, summary) { const checkpoint = summary.checkpoint ? `${summary.checkpoint.status} (${summary.checkpoint.record.task_id})` : 'none'; - const execution = summary.execution.required - ? `current ${summary.execution.current ? 'yes' : 'no'}` - : 'current n/a'; + const executionCurrent = summary.execution.required + ? (summary.execution.current ? 'yes' : 'no') + : 'not required'; + const executionFailures = summary.execution.required + ? (summary.execution.failures.length > 0 ? summary.execution.failures.join('; ') : 'none') + : 'not required'; const handoffs = summary.handoffs; const nextAction = summary.next_action.command ?? `${summary.next_action.skill}: ${summary.next_action.reason}`; console.log([ `Change: ${summary.change.name}`, + `Path: ${summary.change.path}`, + `Selection: ${summary.change.selection}`, `State: ${summary.state}`, + `Workflow: ${summary.workflow ?? 'none'}`, `Checkpoint: ${checkpoint}`, `Handoffs: active ${handoffs.active.length}, result-ready ${handoffs.result_ready.length}, resolved ${handoffs.resolved.length}`, - `Execution: ${execution}`, + `Execution current: ${executionCurrent}`, + `Execution revision: ${summary.execution.revision ?? 'none'}`, + `Next eligible wave: ${summary.execution.next_eligible_wave ?? 'none'}`, + `Execution failures: ${executionFailures}`, summary.blockers.length === 0 ? 'Blockers: none' : `Blockers: ${summary.blockers.map(blocker => blocker.message).join('; ')}`, diff --git a/tests/lib/cmd-recovery.test.mjs b/tests/lib/cmd-recovery.test.mjs index c3535d3..8d214a9 100644 --- a/tests/lib/cmd-recovery.test.mjs +++ b/tests/lib/cmd-recovery.test.mjs @@ -1,17 +1,19 @@ import { afterEach, beforeEach, describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { createPlan, writePlan } from '../../scripts/lib/execution-plan.mjs'; +import { createRecommendationReceipt } from '../../scripts/lib/execution-recommendation.mjs'; const CLI = join(process.cwd(), 'scripts/spec-superflow.mjs'); let root; -function runSsf(args) { +function runSsf(args, { cwd = process.cwd() } = {}) { try { const stdout = execFileSync(process.execPath, [CLI, ...args], { - encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], + cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], }); return { status: 0, stdout, stderr: '' }; } catch (error) { @@ -37,6 +39,29 @@ function makeTasksChange(name, tasks) { return changeDir; } +function makeCurrentPlan(changeDir) { + writeFileSync(join(changeDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Resume execution\n'); + writeFileSync(join(changeDir, 'execution-contract.md'), '# Execution Contract\n\nResume safely.\n'); + const waves = [{ id: 'recovery-cli', strategy: 'serial', tasks: ['1.1'], depends_on: [] }]; + const recommendationReceipt = createRecommendationReceipt(changeDir, waves); + const recommendation = recommendationReceipt.recommendation; + const plan = createPlan(changeDir, { + mode: recommendation.recommendation.mode, + source: 'user-confirmed', + rationale: 'Resume the current recovery CLI wave', + waves, + recommendation, + recommendationReceipt, + selection: { + confirmed: true, + followed_recommendation: true, + acknowledged_non_recommendation: false, + }, + }); + writePlan(changeDir, plan); + return plan; +} + beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'ssf-cmd-recovery-')); }); @@ -185,14 +210,53 @@ describe('ssf resume and switch', () => { const result = runSsf(['switch', change]); assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Change: alpha/); + assert.match(result.stdout, new RegExp(`Path: ${change.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(result.stdout, /Selection: explicit/); assert.match(result.stdout, /State: specifying/); + assert.match(result.stdout, /Workflow: full/); assert.match(result.stdout, /Checkpoint: none/); assert.match(result.stdout, /Handoffs: active 0, result-ready 0, resolved 0/); - assert.match(result.stdout, /Execution: current n\/a/); + assert.match(result.stdout, /Execution current: not required/); + assert.match(result.stdout, /Execution revision: none/); + assert.match(result.stdout, /Next eligible wave: none/); + assert.match(result.stdout, /Execution failures: not required/); assert.match(result.stdout, /Blockers: none/); assert.match(result.stdout, /Next action:/); + assert.doesNotMatch(result.stdout, /undefined/); assert.equal(readFileSync(join(change, '.spec-superflow.yaml'), 'utf8'), stateBefore); }); + + it('reports when resume automatically selects the only active change', () => { + const changesDir = join(root, 'changes'); + mkdirSync(changesDir); + const change = join(changesDir, 'alpha'); + mkdirSync(change); + writeFileSync(join(change, '.spec-superflow.yaml'), 'state: specifying\nworkflow: full\n'); + + const result = runSsf(['resume'], { cwd: root }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Change: alpha/); + assert.match(result.stdout, new RegExp(`Path: ${realpathSync(change).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); + assert.match(result.stdout, /Selection: only-active/); + assert.doesNotMatch(result.stdout, /undefined/); + }); + + it('renders current execution plan fields and the next eligible wave', () => { + const change = makeChange('executing-alpha', 'executing'); + const plan = makeCurrentPlan(change); + + const result = runSsf(['resume', change]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Workflow: full/); + assert.match(result.stdout, /Execution current: yes/); + assert.match(result.stdout, new RegExp(`Execution revision: ${plan.revision}`)); + assert.match(result.stdout, /Next eligible wave: recovery-cli/); + assert.match(result.stdout, /Execution failures: none/); + assert.doesNotMatch(result.stdout, /undefined/); + }); }); describe('ssf save', () => { diff --git a/tests/lib/execution-control-plane.test.mjs b/tests/lib/execution-control-plane.test.mjs index 964a60c..deaab3f 100644 --- a/tests/lib/execution-control-plane.test.mjs +++ b/tests/lib/execution-control-plane.test.mjs @@ -103,6 +103,15 @@ describe('execution control plane instructions', () => { 'release checklist installs the local candidate into that temporary home'); assert.match(releaseChecklist, /cmd-install-workbuddy\.test\.mjs/, 'release checklist validates the installer with its focused test'); + assert.match(releaseChecklist, + /node --test tests\/lib\/recovery-command-assets\.test\.mjs.*(?:release blocker|阻断发布)/is, + 'release checklist runs the recovery command asset guard as a release blocker'); + assert.match(releaseChecklist, + /grep -R -F "\$PWD\/" commands\/ssf/is, + 'release checklist rejects the local checkout root in canonical command files'); + assert.match(releaseChecklist, + /grep -R -F "\$PWD\/" "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf"/is, + 'release checklist rejects the local checkout root in installed command files'); for (const [asset, assertion] of [ ['commands/ssf/resume.md', /test -f "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf\/resume\.md"/], ['commands/ssf/switch.md', /test -f "\$SSF_WORKBUDDY_PLUGIN\/commands\/ssf\/switch\.md"/], diff --git a/tests/lib/recovery-command-assets.test.mjs b/tests/lib/recovery-command-assets.test.mjs index bfe14c0..0a85a83 100644 --- a/tests/lib/recovery-command-assets.test.mjs +++ b/tests/lib/recovery-command-assets.test.mjs @@ -3,6 +3,15 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +const CHECKOUT_ABSOLUTE_PATHS = [ + /(?:^|[\s"'`(])\/Users\/[^\s"'`)]*/gm, + /(?:^|[\s"'`(])\/home\/[^\s"'`)]*/gm, + /(?:^|[\s"'`(])\/(?:workspace|workspaces)\/[^\s"'`)]*/gm, + /(?:^|[\s"'`(])\/(?:private\/)?tmp\/[^\s"'`)]*/gm, + /(?:^|[\s"'`(])\/(?:[^\s"'`)]*\/)*scripts\/spec-superflow\.mjs\b/gm, + /(?:^|[\s"'`(])[A-Za-z]:\\[^\s"'`)]*\\scripts\\spec-superflow\.mjs\b/gm, +]; + function read(path) { return readFileSync(join(process.cwd(), path), 'utf8'); } @@ -12,6 +21,18 @@ function executableSsfCommands(content) { .map(match => match[1]); } +function assertNoCheckoutAbsolutePaths(content) { + for (const pattern of CHECKOUT_ABSOLUTE_PATHS) { + pattern.lastIndex = 0; + const match = pattern.exec(content); + assert.equal( + match, + null, + `checkout-specific absolute path in command asset: ${match?.[0].trim()}`, + ); + } +} + function assertNoUnquotedArguments(content) { for (const command of executableSsfCommands(content)) { let quote = null; @@ -38,6 +59,7 @@ describe('SSF recovery command assets', () => { assert.match(content, new RegExp(`spec-superflow@0\\.10\\.0 ssf ${name}`)); assert.match(content, /\$ARGUMENTS/); assert.doesNotMatch(content, /state set|state transition|active-change|\bcd\s/); + assertNoCheckoutAbsolutePaths(content); }); } @@ -85,6 +107,29 @@ describe('SSF recovery command assets', () => { assert.doesNotThrow(() => assertNoUnquotedArguments(read('commands/ssf/save.md'))); }); + it('rejects checkout-specific absolute paths anywhere in a command asset', () => { + const unsafeAssets = [ + 'Run `node /Users/alice/src/spec-superflow/scripts/spec-superflow.mjs resume`.', + 'Run `node /home/alice/src/spec-superflow/scripts/spec-superflow.mjs switch`.', + 'Run `/workspace/spec-superflow/scripts/spec-superflow.mjs save`.', + 'Run `node /tmp/ssf-checkout/scripts/spec-superflow.mjs resume`.', + 'Run `node /opt/build/spec-superflow/scripts/spec-superflow.mjs resume`.', + ]; + + for (const content of unsafeAssets) { + assert.throws( + () => assertNoCheckoutAbsolutePaths(content), + /checkout-specific absolute path/, + ); + } + }); + + it('does not mistake the pinned portable npx entrypoint for a checkout path', () => { + const portable = 'Run `npx --yes --package spec-superflow@0.10.0 ssf resume --json "$ARGUMENTS"`.'; + + assert.doesNotThrow(() => assertNoCheckoutAbsolutePaths(portable)); + }); + it('passes resume and switch targets as one quoted literal after validation', () => { for (const name of ['resume', 'switch']) { const content = read(`commands/ssf/${name}.md`);