diff --git a/CHANGELOG.md b/CHANGELOG.md index 598a0f7..82c0790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format loosely follows Keep a Changelog. ## [Unreleased] +### Fixed + +- **Evidence-backed DP-5 escalation (#102)**: replace raw `state set dp_5_*` writes with a guarded debugging-attempt ledger. `ssf debug attempt record/show` keeps failed fixes distinct from Wave Review repair failures, rejects duplicate or stale evidence, and `ssf debug escalate --confirm` requires at least three current attempts. Audit reports unsupported legacy DP-5 records instead of treating them as valid approvals. + ## [1.0.0] - 2026-08-03 ### Added diff --git a/README.md b/README.md index d942007..2b5f88c 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ execution plan。它位于 `/.superpowers/sdd/execution-plan.json`,不 候选项和推荐展示给用户。`plan` 或 `revise` 只接受匹配当前 artifact、contract 和 wave 的 凭据。用户用 `--confirm` 明确确认选择;若选择与推荐不同,必须额外 传入 `--acknowledge-recommendation` 记录已知风险。Batch Inline 始终串行,绝不冒充并行。 -Quick、direct Hotfix 与 `tweak` 保持轻量例外:不要求 contract、execution plan、wave receipt 或 DP;在边界内验证后持久化 `test_result: pass`。 +Quick、direct Hotfix 与 `tweak` 保持轻量例外:正常完成时不要求 contract、execution plan、wave receipt 或 DP;在边界内验证后持久化 `test_result: pass`。但一旦进入 DP-5 调试升级,任何路径都必须先有当前有效的 execution plan,才可记录失败尝试或持久化升级。 ```bash ssf execution recommend changes/my-change \ @@ -334,6 +334,8 @@ spec-superflow 把这两类问题分开处理:先判断改动风险;小改 **如何选择:** Quick、direct Hotfix、Tweak 默认保持轻量,只记录范围和验证;Full 与 legacy Hotfix 才要求执行契约、执行计划和 review receipt。风险会说明原因并交给用户选择,不会擅自升级路径。 +**DP-5 调试门禁:** 每次失败修复使用 `ssf debug attempt record` 保存唯一且可验证的证据;无论工作流路径,记录前都必须有 current、有效的 execution plan。Wave Review failure 不会计入调试次数。只有当前 execution context 下至少 3 次不同失败尝试,并由用户执行 `ssf debug escalate ... --confirm` 后,才会记录 DP-5。通用 `state set` 不能写入 `dp_5_*`,且不能通过多行值注入这些字段。 + ### 快速路径(Quick / Hotfix / Tweak) - **Quick** — ≤3 文件/任务、单模块代码:低风险时同轮推荐/接受;触及 PRD、Spec/Design、API、数据/权限或跨模块时,展示风险后由用户选择 Quick 或 Full。选择 Quick 会记录 `tdd`、`new-test` 或 `bounded` 验证策略。 diff --git a/docs/README_en.md b/docs/README_en.md index 1aae983..af6b17d 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -266,6 +266,8 @@ You: "add authorization to the API" **Path selection:** Quick, direct Hotfix, and Tweak remain lightweight: record the boundary and verification only. Full and legacy Hotfix require an execution contract, execution plan, and review receipt. Risks are explained for the user to choose from; they do not silently upgrade a path. +**DP-5 debugging gate:** Record every failed fix with `ssf debug attempt record` and distinct, verifiable evidence. Every workflow path must have a current, valid execution plan before it records an attempt. Wave Review failures do not count as debugging attempts. DP-5 is persisted only after at least three failed attempts in that plan context and an explicit `ssf debug escalate ... --confirm`; generic `state set` cannot write or inject `dp_5_*`. + ### Guarded execution plans For Full/legacy Hotfix, DP-4 is a persisted, current execution plan at @@ -278,7 +280,7 @@ user records a choice with `--confirm`; `plan` and `revise` require a receipt ma artifacts, contract, and waves. A non-recommended choice also requires `--acknowledge-recommendation`. Batch Inline remains serial and never claims parallel work. -Quick, direct Hotfix, and Tweak are exempt from contract, execution-plan, and review-receipt gates; they persist `test_result: pass` after bounded verification. +Quick, direct Hotfix, and Tweak are exempt from contract, execution-plan, and review-receipt gates during their normal bounded path; they persist `test_result: pass` after verification. If they reach DP-5 debugging escalation, they must first establish a current execution plan before recording attempts or persisting DP-5. ```bash ssf execution recommend changes/my-change \ diff --git a/docs/decision-points.md b/docs/decision-points.md index ea6bfee..3de65b5 100644 --- a/docs/decision-points.md +++ b/docs/decision-points.md @@ -56,7 +56,8 @@ - **名称**:调试升级 - **触发条件**:bug-investigator 连续 3 次或更多修复尝试失败后,无法自动解决当前问题 - **所需输入**:失败日志、每次修复尝试的具体方案与结果、错误根因分析、剩余可行方案(如有) -- **预期输出**:用户决定继续调试(可附带方向指引)或放弃当前任务并标记为阻塞 +- **证据门禁**:每次失败修复必须通过 `ssf debug attempt record` 写入结构化 ledger,包含唯一 attempt id、摘要和 change 目录内的物理证据文件。所有路径都必须先有 current、有效的 execution plan;缺失、陈旧或不匹配的 plan 会被拒绝。Wave Review 的 repair failure 不计入 debugging attempt;重复证据也会被拒绝。 +- **预期输出**:至少 3 次不同且证据完整的失败尝试后,用户决定继续调试或放弃;仅 `ssf debug escalate ... --confirm` 可以持久化 DP-5,通用 `state set` 不可写入 `dp_5_*`。 - **关联 skill**:`spec-superflow:bug-investigator` ## DP-6: 验证失败 diff --git a/docs/state-machine.md b/docs/state-machine.md index 90e2dea..23a77af 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -90,7 +90,7 @@ accept only a receipt whose artifacts, contract, and waves still match. The user selected mode with `--confirm`; a non-recommended selection also requires `--acknowledge-recommendation`. Batch Inline remains serial and is never a substitute for parallel execution. -Quick, direct Hotfix, and Tweak are exempt from execution-plan and review-receipt requirements; each closes only with `test_result: pass`. +Quick, direct Hotfix, and Tweak are exempt from execution-plan and review-receipt requirements on their normal bounded path; each closes with `test_result: pass`. If any enters DP-5 debugging escalation, it must first establish a current execution plan before recording an attempt or persisting DP-5. For Full/legacy Hotfix, the plan names ordered execution waves, dependencies, and parallel/serial strategy. `ssf execution show --json` reports @@ -195,7 +195,9 @@ During `executing`, if a bug, test failure, or unexpected behavior blocks progre 1. Pause `executing` and enter `debugging` 2. `bug-investigator` performs 4-phase root cause analysis 3. If root cause found → fix (with TDD) → return to `executing` -4. If 3+ fix attempts fail → question architecture → escalate to user +4. Before recording any attempt, require a current valid execution plan, including for Quick/direct Hotfix/Tweak; then record one distinct evidence-backed attempt with `ssf debug attempt record`; Wave Review repair failures remain separate +5. If 3+ recorded fix attempts fail → question architecture → present the ledger to the user +6. Persist DP-5 only through `ssf debug escalate ... --confirm`; raw `state set dp_5_*` is blocked ## Anti-Pattern diff --git a/scripts/lib/cmd-audit.mjs b/scripts/lib/cmd-audit.mjs index 72b1dae..16ef34b 100644 --- a/scripts/lib/cmd-audit.mjs +++ b/scripts/lib/cmd-audit.mjs @@ -4,6 +4,7 @@ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { parseArgs } from 'node:util'; import { readState } from './state-loader.mjs'; +import { inspectDebugEscalation } from './debug-attempts.mjs'; const DP_NAMES = { 0: '用户确认门禁', @@ -39,14 +40,26 @@ function formatDpResult(state, dp) { } function generateReport(changeDir, state) { + const debugEscalation = inspectDebugEscalation(changeDir, state); const rows = []; for (let i = 0; i <= 7; i++) { - const result = formatDpResult(state, i); + const recordedResult = formatDpResult(state, i); + const unsupportedDebugRecord = i === 5 + && recordedResult !== 'not recorded' + && !debugEscalation.supported; + const result = unsupportedDebugRecord ? `unsupported: ${recordedResult}` : recordedResult; const timestamp = formatTimestamp(state[`dp_${i}_timestamp`]); - rows.push({ dp: i, name: DP_NAMES[i], result, timestamp }); + rows.push({ + dp: i, + name: DP_NAMES[i], + result, + timestamp, + recorded: recordedResult !== 'not recorded' && !unsupportedDebugRecord, + unsupportedReason: unsupportedDebugRecord ? debugEscalation.reason : null, + }); } - const recordedCount = rows.filter(r => r.result !== 'not recorded').length; + const recordedCount = rows.filter(r => r.recorded).length; const missingCount = rows.length - recordedCount; let md = `# Decision-Point Audit Report\n\n`; @@ -68,7 +81,9 @@ function generateReport(changeDir, state) { md += `### DP-${r.dp}: ${r.name}\n\n`; md += `- **结果**: ${r.result}\n`; md += `- **时间戳**: ${r.timestamp}\n`; - if (r.result === 'not recorded') { + if (r.unsupportedReason) { + md += `- **解读**: DP-5 unsupported — ${r.unsupportedReason}.\n`; + } else if (r.result === 'not recorded') { md += '- **解读**: 该决策点尚未记录结果。如果工作流已经经过该阶段,请检查是否漏记。\n'; } else { md += `- **解读**: 决策点 DP-${r.dp} 已记录为 "${r.result}"。\n`; diff --git a/scripts/lib/cmd-debug.mjs b/scripts/lib/cmd-debug.mjs new file mode 100644 index 0000000..7f428ed --- /dev/null +++ b/scripts/lib/cmd-debug.mjs @@ -0,0 +1,84 @@ +import { parseArgs } from 'node:util'; +import { recordDebugAttempt, recordDebugEscalation, showDebugAttempts } from './debug-attempts.mjs'; + +const OPTIONS = { + id: { type: 'string' }, + summary: { type: 'string' }, + evidence: { type: 'string' }, + decision: { type: 'string' }, + reason: { type: 'string' }, + confirm: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + help: { type: 'boolean', default: false }, +}; + +class UsageError extends Error {} + +export function run(args, io = { stdout: process.stdout, stderr: process.stderr }) { + let parsed; + try { + parsed = parseArgs({ args, options: OPTIONS, allowPositionals: true }); + } catch (error) { + return fail(error.message, 2, io); + } + + const { positionals, values } = parsed; + if (values.help || positionals.length === 0) { + printHelp(io); + return { exitCode: 0 }; + } + + try { + if (positionals[0] === 'attempt') { + if (!['record', 'show'].includes(positionals[1]) || positionals.length !== 3) { + throw new UsageError('Usage: ssf debug attempt [options]'); + } + const changeDir = positionals[2]; + if (positionals[1] === 'record') { + const result = recordDebugAttempt(changeDir, { + id: values.id, + summary: values.summary, + evidence: values.evidence, + }); + return print({ ok: true, ...result }, values.json, io); + } + return print({ ok: true, ...showDebugAttempts(changeDir) }, values.json, io); + } + + if (positionals[0] === 'escalate') { + if (positionals.length !== 2) { + throw new UsageError('Usage: ssf debug escalate --decision --reason --confirm'); + } + const result = recordDebugEscalation(positionals[1], { + decision: values.decision, + reason: values.reason, + confirm: values.confirm, + }); + return print({ ok: true, ...result }, values.json, io); + } + + throw new UsageError(`Unknown debug subcommand: ${positionals[0]}`); + } catch (error) { + return fail(error.message, error instanceof UsageError ? 2 : 1, io); + } +} + +function print(value, json, io) { + if (json) io.stdout.write(`${JSON.stringify(value)}\n`); + else if (value.attempt) io.stdout.write(`Debug attempt ${value.attempt.id} recorded (${value.attempt_count}/3).\n`); + else if (value.escalation) io.stdout.write(`DP-5 recorded: ${value.escalation.result}\n`); + else io.stdout.write(`Debug attempts recorded: ${value.attempt_count}\n`); + return { exitCode: 0 }; +} + +function fail(message, exitCode, io) { + io.stderr.write(`${message}\n`); + return { exitCode }; +} + +function printHelp(io) { + io.stdout.write(`Usage: + ssf debug attempt record --id --summary --evidence [--json] + ssf debug attempt show [--json] + ssf debug escalate --decision --reason --confirm [--json]\n`); +} diff --git a/scripts/lib/cmd-state.mjs b/scripts/lib/cmd-state.mjs index 689d4ae..0add4c5 100644 --- a/scripts/lib/cmd-state.mjs +++ b/scripts/lib/cmd-state.mjs @@ -20,7 +20,6 @@ const SETTABLE_FIELDS = [ 'dp_1_result', 'dp_1_timestamp', 'dp_1_decisions', 'dp_1_confirmed', 'dp_2_result', 'dp_2_timestamp', 'dp_2_decisions', 'dp_2_confirmed', 'dp_3_result', 'dp_3_timestamp', 'dp_3_decisions', 'dp_3_confirmed', - 'dp_5_result', 'dp_5_timestamp', 'dp_5_decisions', 'dp_5_confirmed', 'dp_6_result', 'dp_6_timestamp', 'dp_6_decisions', 'dp_6_confirmed', 'dp_7_result', 'dp_7_timestamp', 'dp_7_decisions', 'dp_7_confirmed', ]; @@ -224,6 +223,10 @@ export async function run(args) { console.error(`⛔ Field '${field}' is not settable (use 'transition' for state, or check SETTABLE_FIELDS)`); process.exit(1); } + if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(value)) { + console.error('State field values must not contain control characters or line separators'); + process.exit(1); + } updateField(changeDir, field, value); if (values.json) { console.log(JSON.stringify({ ok: true, field, value })); diff --git a/scripts/lib/debug-attempts.mjs b/scripts/lib/debug-attempts.mjs new file mode 100644 index 0000000..6fd6d57 --- /dev/null +++ b/scripts/lib/debug-attempts.mjs @@ -0,0 +1,292 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, +} from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { computeArtifactsHash, computeContractHash } from './hash.mjs'; +import { readPlan, validatePlan } from './execution-plan.mjs'; +import { getOverlayPaths, getPlanScopedPaths } from './sdd-overlay.mjs'; +import { readState, writeState } from './state-loader.mjs'; + +const LEDGER_VERSION = 1; +const MINIMUM_FAILED_ATTEMPTS = 3; +const DECISIONS = new Set(['continue', 'abandon']); + +export function recordDebugAttempt(changeDir, input) { + requireSafeText(input?.id, 'id'); + if (!/^[A-Za-z0-9._-]{1,80}$/.test(input.id)) { + throw new Error('Attempt id must contain only letters, numbers, dots, underscores, or hyphens'); + } + requireSafeText(input?.summary, 'summary'); + requireSafeText(input?.evidence, 'evidence'); + + const loaded = loadCurrentLedger(changeDir, { requireDebugging: true, requirePlan: true }); + if (loaded.ledger.escalation) { + throw new Error('DP-5 is already recorded for the current debugging context'); + } + const proof = readEvidence(changeDir, input.evidence); + if (loaded.ledger.attempts.some(attempt => attempt.id === input.id)) { + throw new Error(`Debug attempt '${input.id}' is already recorded`); + } + if (loaded.ledger.attempts.some(attempt => attempt.evidence_sha256 === proof.sha256)) { + throw new Error('Duplicate evidence cannot count as a distinct debugging attempt'); + } + + const attempt = { + id: input.id, + summary: input.summary, + evidence: proof.path, + evidence_sha256: proof.sha256, + recorded_at: new Date().toISOString(), + }; + loaded.ledger.attempts.push(attempt); + writeLedger(changeDir, loaded.path, loaded.ledger); + return { attempt, attempt_count: loaded.ledger.attempts.length, context: loaded.context }; +} + +export function showDebugAttempts(changeDir) { + const loaded = loadCurrentLedger(changeDir, { requireDebugging: false }); + return { + attempts: loaded.ledger.attempts, + attempt_count: loaded.ledger.attempts.length, + escalation: loaded.ledger.escalation ?? null, + context: loaded.context, + }; +} + +export function recordDebugEscalation(changeDir, input) { + if (input?.confirm !== true) { + throw new Error('DP-5 escalation requires --confirm after the user reviews all failed attempts'); + } + if (!DECISIONS.has(input?.decision)) { + throw new Error(`DP-5 decision must be one of: ${[...DECISIONS].join(', ')}`); + } + requireSafeText(input?.reason, 'reason'); + + const loaded = loadCurrentLedger(changeDir, { requireDebugging: true, requirePlan: true }); + if (loaded.ledger.attempts.length < MINIMUM_FAILED_ATTEMPTS) { + throw new Error(`DP-5 escalation requires at least three distinct evidence-backed failed attempts; recorded: ${loaded.ledger.attempts.length}`); + } + + const recordedAt = new Date().toISOString(); + const result = `${input.decision}: ${input.reason}`; + const escalation = { + id: randomUUID(), + decision: input.decision, + reason: input.reason, + result, + confirmed: true, + attempt_count: loaded.ledger.attempts.length, + recorded_at: recordedAt, + }; + loaded.ledger.escalation = escalation; + writeLedger(changeDir, loaded.path, loaded.ledger); + + const state = readState(changeDir); + state.dp_5_result = result; + state.dp_5_timestamp = recordedAt; + state.dp_5_decisions = input.reason; + state.dp_5_confirmed = 'true'; + writeState(changeDir, state); + return { escalation, attempt_count: loaded.ledger.attempts.length, context: loaded.context }; +} + +export function inspectDebugEscalation(changeDir, state = readState(changeDir)) { + if (isEmpty(state.dp_5_result)) return { status: 'not-recorded', supported: false, reason: null }; + if (!isConfirmed(state.dp_5_confirmed)) { + return unsupported('DP-5 is not supported by explicit confirmation and three evidence-backed failed attempts'); + } + try { + const loaded = loadCurrentLedger(changeDir, { requireDebugging: false, requirePlan: true, state }); + if (loaded.ledger.attempts.length < MINIMUM_FAILED_ATTEMPTS) { + return unsupported(`DP-5 requires at least three evidence-backed failed attempts; recorded: ${loaded.ledger.attempts.length}`); + } + const escalation = loaded.ledger.escalation; + if (!escalation?.confirmed || escalation.attempt_count !== loaded.ledger.attempts.length) { + return unsupported('DP-5 ledger does not contain a matching confirmed escalation'); + } + if (escalation.result !== state.dp_5_result || escalation.recorded_at !== state.dp_5_timestamp) { + return unsupported('DP-5 state summary does not match its evidence ledger'); + } + return { + status: 'supported', + supported: true, + reason: null, + attempt_count: loaded.ledger.attempts.length, + escalation, + }; + } catch (error) { + return unsupported(error.message); + } +} + +function loadCurrentLedger(changeDir, options) { + const state = options.state ?? readState(changeDir); + const { context, plan } = buildContext(changeDir, state, options.requireDebugging, options.requirePlan); + const path = ledgerPath(changeDir, plan); + const physicalPath = resolveLedgerPath(changeDir, path); + if (!existsSync(physicalPath)) { + return { state, context, path, ledger: { version: LEDGER_VERSION, context, attempts: [] } }; + } + let ledger; + try { + ledger = JSON.parse(readFileSync(physicalPath, 'utf8')); + } catch (error) { + throw new Error(`Unable to read debugging attempt ledger: ${error.message}`); + } + validateLedger(changeDir, ledger); + if (!sameContext(ledger.context, context)) { + throw new Error('Debugging attempt context is stale; record new attempts for the current execution context'); + } + return { state, context, path, ledger }; +} + +function buildContext(changeDir, state, requireDebugging, requirePlan) { + if (requireDebugging && state.state !== 'debugging') { + throw new Error('Debug attempts and DP-5 escalation require the debugging state'); + } + + const plan = readPlan(changeDir); + const hasPlanSummary = !isEmpty(state.execution_plan_hash) + || !isEmpty(state.execution_plan_revision); + if (requirePlan && !plan) { + throw new Error('Current execution plan is required for debugging attempts and DP-5 escalation'); + } + if (hasPlanSummary || plan) { + if (!plan) throw new Error('Current execution plan is missing for the recorded state summary'); + const validation = validatePlan(changeDir, plan); + if (!validation.valid) { + throw new Error(`Current execution plan is stale: ${validation.failures.join('; ')}`); + } + } + + return { + context: { + workflow: state.workflow ?? 'auto', + artifacts_hash: computeArtifactsHash(changeDir), + contract_hash: computeContractHash(changeDir), + execution_plan_hash: plan?.hash ?? null, + execution_plan_revision: plan?.revision ?? null, + }, + plan, + }; +} + +function ledgerPath(changeDir, plan) { + if (plan) return join(getPlanScopedPaths(changeDir, plan).planRoot, 'debug-attempts.json'); + return join(getOverlayPaths(changeDir).root, 'debug-attempts.json'); +} + +function readEvidence(changeDir, evidence) { + const changeRoot = realpathSync(resolve(changeDir)); + const unresolved = resolve(isAbsolute(evidence) ? evidence : join(changeRoot, evidence)); + if (!existsSync(unresolved)) throw new Error(`Debug evidence does not exist: ${evidence}`); + const metadata = lstatSync(unresolved); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error('Debug evidence must be a physical regular file'); + } + const physical = realpathSync(unresolved); + const relativePath = relative(changeRoot, physical); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error('Debug evidence must be stored inside the change directory'); + } + const content = readFileSync(physical); + return { + path: relativePath.split(sep).join('/'), + sha256: `sha256:${createHash('sha256').update(content).digest('hex')}`, + }; +} + +function validateLedger(changeDir, ledger) { + if (ledger?.version !== LEDGER_VERSION || !ledger.context || !Array.isArray(ledger.attempts)) { + throw new Error('Debugging attempt ledger has an unsupported structure'); + } + const ids = new Set(); + const evidence = new Set(); + for (const attempt of ledger.attempts) { + requireSafeText(attempt?.id, 'attempt id'); + requireSafeText(attempt?.summary, 'attempt summary'); + requireSafeText(attempt?.evidence, 'attempt evidence'); + if (!/^sha256:[a-f0-9]{64}$/i.test(attempt?.evidence_sha256 ?? '')) { + throw new Error('Debugging attempt ledger contains an invalid evidence digest'); + } + if (ids.has(attempt.id) || evidence.has(attempt.evidence_sha256)) { + throw new Error('Debugging attempt ledger contains duplicate attempts or evidence'); + } + const proof = readEvidence(changeDir, attempt.evidence); + if (proof.sha256 !== attempt.evidence_sha256) { + throw new Error(`Debug evidence for attempt '${attempt.id}' has changed since it was recorded`); + } + ids.add(attempt.id); + evidence.add(attempt.evidence_sha256); + } +} + +function writeLedger(changeDir, path, ledger) { + const physicalPath = resolveLedgerPath(changeDir, path, { createParents: true }); + const tempPath = `${physicalPath}.tmp-${process.pid}-${randomUUID()}`; + writeFileSync(tempPath, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8'); + renameSync(tempPath, physicalPath); +} + +function resolveLedgerPath(changeDir, path, options = {}) { + const lexicalRoot = resolve(changeDir); + const relativePath = relative(lexicalRoot, resolve(path)); + if (relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + throw new Error('Debugging attempt ledger must be stored inside the change directory'); + } + + const physicalRoot = realpathSync(lexicalRoot); + const segments = relativePath.split(sep); + const fileName = segments.pop(); + let current = physicalRoot; + for (const segment of segments) { + current = join(current, segment); + if (existsSync(current)) { + const metadata = lstatSync(current); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + throw new Error('Debugging attempt ledger parent must be a physical directory'); + } + } else if (options.createParents) { + mkdirSync(current); + } + } + + const physicalPath = join(current, fileName); + if (existsSync(physicalPath)) { + const metadata = lstatSync(physicalPath); + if (metadata.isSymbolicLink() || !metadata.isFile()) { + throw new Error('Debugging attempt ledger must be a physical regular file'); + } + } + return physicalPath; +} + +function sameContext(left, right) { + return left?.workflow === right.workflow + && left?.artifacts_hash === right.artifacts_hash + && left?.contract_hash === right.contract_hash + && left?.execution_plan_hash === right.execution_plan_hash + && left?.execution_plan_revision === right.execution_plan_revision; +} + +function requireSafeText(value, field) { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} is required`); + if (/[\p{Cc}\p{Zl}\p{Zp}]/u.test(value)) { + throw new Error(`${field} must not contain control characters or line separators`); + } +} + +function isEmpty(value) { + return value === null || value === undefined || value === ''; +} + +function isConfirmed(value) { + return value === true || value === 'true'; +} + +function unsupported(reason) { + return { status: 'unsupported', supported: false, reason }; +} + +export { MINIMUM_FAILED_ATTEMPTS }; diff --git a/scripts/lib/state-loader.mjs b/scripts/lib/state-loader.mjs index c77250a..8bc9436 100644 --- a/scripts/lib/state-loader.mjs +++ b/scripts/lib/state-loader.mjs @@ -35,6 +35,8 @@ const BUILTIN_DEFAULTS = { dp_4_timestamp: null, dp_5_result: null, dp_5_timestamp: null, + dp_5_decisions: null, + dp_5_confirmed: null, dp_6_result: null, dp_6_timestamp: null, dp_7_result: null, @@ -104,6 +106,8 @@ export function writeState(changeDir, state) { lines.push(`dp_4_timestamp: ${state.dp_4_timestamp ?? 'null'}`); lines.push(`dp_5_result: ${state.dp_5_result ?? 'null'}`); lines.push(`dp_5_timestamp: ${state.dp_5_timestamp ?? 'null'}`); + lines.push(`dp_5_decisions: ${state.dp_5_decisions ?? 'null'}`); + lines.push(`dp_5_confirmed: ${state.dp_5_confirmed ?? 'null'}`); lines.push(`dp_6_result: ${state.dp_6_result ?? 'null'}`); lines.push(`dp_6_timestamp: ${state.dp_6_timestamp ?? 'null'}`); lines.push(`dp_7_result: ${state.dp_7_result ?? 'null'}`); diff --git a/scripts/lint/rules/dp-trigger-points.mjs b/scripts/lint/rules/dp-trigger-points.mjs index 02fb495..4d25d5f 100644 --- a/scripts/lint/rules/dp-trigger-points.mjs +++ b/scripts/lint/rules/dp-trigger-points.mjs @@ -12,6 +12,11 @@ const DP_SKILL_MAP = { 'DP-7': { skills: ['release-archivist'], name: '归档确认' }, }; +const GUARDED_RECORD_COMMANDS = { + 'DP-4': /ssf execution (plan|revise)/, + 'DP-5': /ssf debug escalate/, +}; + export default { name: 'dp-trigger-points', @@ -24,9 +29,11 @@ export default { // Check 1: Is DP referenced by name/number? const dpReferenced = content.includes(dp); - // Check 2: Is there a record command (ssf state set ... dp_N_*)? + // Check 2: Is there a raw DP field command or the DP's guarded command? const dpNum = dp.slice(-1); - const hasRecordCommand = new RegExp(`dp_${dpNum}_(result|timestamp|confirmed)`).test(content); + const guardedCommand = GUARDED_RECORD_COMMANDS[dp]; + const hasRecordCommand = guardedCommand?.test(content) + || new RegExp(`dp_${dpNum}_(result|timestamp|confirmed)`).test(content); // Check 3: Is the trigger condition described? const hasTriggerDesc = new RegExp( @@ -49,7 +56,7 @@ export default { if (!hasRecordCommand) { issues.push({ severity: 'warning', - message: `${dp} has no ssf state set dp_${dpNum}_* record command`, + message: `${dp} has no recognized record command`, }); } } diff --git a/scripts/spec-superflow.mjs b/scripts/spec-superflow.mjs index 644db51..b1c5632 100755 --- a/scripts/spec-superflow.mjs +++ b/scripts/spec-superflow.mjs @@ -15,6 +15,7 @@ const COMMANDS = { sync: () => import('./lib/cmd-sync.mjs'), config: () => import('./lib/cmd-config.mjs'), state: () => import('./lib/cmd-state.mjs'), + debug: () => import('./lib/cmd-debug.mjs'), inject: () => import('./lib/cmd-inject.mjs'), audit: () => import('./lib/cmd-audit.mjs'), checkpoint: () => import('./lib/cmd-checkpoint.mjs'), @@ -55,6 +56,12 @@ Commands: config [options] Display or modify configuration config --resolve-model Resolve a configured model profile without switching models state Manage .spec-superflow.yaml state (init|check|transition|get|rebuild) + debug attempt record --id --summary --evidence + Record one evidence-backed failed fix attempt + debug attempt show [--json] + Show failed fix attempts for the current execution context + debug escalate --decision --reason --confirm + Record guarded DP-5 after at least three failed attempts inject Generate phase-guard artifacts; use --platforms when platform is ambiguous audit Generate decision-point-audit.md from .spec-superflow.yaml checkpoint save --task --next @@ -134,6 +141,9 @@ Examples: ssf workflow recommend changes/fix-typo --task-count 1 --file-count 1 --config-doc-only no --schema-api-change no --new-module no --behavioral-constraint-change no --cross-module-change no --uncertainty low --request-kind incident ssf workflow accept changes/fix-typo --source direct-request --verification bounded ssf state get changes/my-change/ batches_completed + ssf debug attempt record changes/my-change/ --id fix-1 --summary "First fix failed" --evidence changes/my-change/.superpowers/sdd/debug-evidence/fix-1.log + ssf debug attempt show changes/my-change/ --json + ssf debug escalate changes/my-change/ --decision continue --reason "Three fixes failed" --confirm 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" diff --git a/skills/bug-investigator/SKILL.md b/skills/bug-investigator/SKILL.md index e9d66b1..cef2ffd 100644 --- a/skills/bug-investigator/SKILL.md +++ b/skills/bug-investigator/SKILL.md @@ -49,7 +49,31 @@ Scientific method: form a single hypothesis ("I think X is the root cause becaus ### DP-5: Debug Escalation (3+ Failures) -3+ failed fixes = architectural problem. Each fix revealing new problems elsewhere = wrong architecture. Record: `ssf state set dp_5_result `. Discuss with user before attempting more fixes. +3+ failed fixes = architectural problem. Each fix revealing new problems elsewhere = wrong architecture. + +After every failed fix, preserve its failure output in a physical file inside the change directory, then record the distinct attempt: + +Before this command, every workflow path (including Quick/direct Hotfix/Tweak) must have a current, valid execution plan. If it does not, establish and confirm one with `ssf execution recommend` and `ssf execution plan` before recording the attempt; the debug command rejects a missing or stale plan. + +```bash +ssf debug attempt record \ + --id \ + --summary "" \ + --evidence +``` + +Use `ssf debug attempt show --json` to present the complete attempt ledger. Wave Review repair failures are separate evidence and never count as debugging attempts. + +After at least three distinct evidence-backed attempts, stop and discuss the architectural decision with the user. Only after the user explicitly chooses may DP-5 be recorded: + +```bash +ssf debug escalate \ + --decision \ + --reason "" \ + --confirm +``` + +Never write `dp_5_*` through raw `ssf state set`; those fields are guarded by the debug ledger. If the user chooses `abandon`, transition to `abandoned` only after the guarded DP-5 receipt is recorded. ## Red Flags — Return to Phase 1 diff --git a/skills/build-executor/SKILL.md b/skills/build-executor/SKILL.md index 2a3c0ef..de07c0f 100644 --- a/skills/build-executor/SKILL.md +++ b/skills/build-executor/SKILL.md @@ -223,7 +223,7 @@ Quick direct execution requires the valid receipt, a bounded diff, the receipt's ## DP Records DP-4 is written by `ssf execution plan`; do not write it with raw `state set`. -DP-5 (debug escalation): `ssf state set dp_5_result ""` + timestamp. +DP-5 (debug escalation): bug-investigator records each failed fix through `ssf debug attempt record`; after at least three distinct attempts and explicit user confirmation, use `ssf debug escalate --decision --reason "" --confirm`. Raw `state set dp_5_*` is blocked. ## Completion Standard diff --git a/tests/lib/cmd-audit.test.mjs b/tests/lib/cmd-audit.test.mjs index 95b83a1..b0e4b32 100644 --- a/tests/lib/cmd-audit.test.mjs +++ b/tests/lib/cmd-audit.test.mjs @@ -2,12 +2,26 @@ // Tests for scripts/lib/cmd-audit.mjs import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { recordDebugAttempt, recordDebugEscalation } from '../../scripts/lib/debug-attempts.mjs'; +import { computeArtifactsHash, computeContractHash } from '../../scripts/lib/hash.mjs'; +import { readState, rebuildState, writeState } from '../../scripts/lib/state-loader.mjs'; +const CLI_PATH = join(process.cwd(), 'scripts/spec-superflow.mjs'); let tempDir; +function ssf(args) { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { encoding: 'utf8' }); + return { + exitCode: result.status ?? 1, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + describe('cmd-audit: generateReport()', () => { let generateReport, DP_NAMES; @@ -132,7 +146,8 @@ describe('cmd-audit: generateReport()', () => { assert.ok(report.includes(name), `Report should include DP-${dpNum} name: ${name}`); } - assert.ok(report.includes('8/8 已记录')); + assert.ok(report.includes('7/8 已记录')); + assert.match(report, /DP-5.*unsupported/i); }); it('formats timestamps correctly', () => { @@ -199,4 +214,65 @@ describe('cmd-audit: generateReport()', () => { assert.ok(report.includes('| DP | 名称 | 结果 | 时间戳 |'), 'Should have table header'); assert.ok(report.includes('|----|------|------|--------|'), 'Should have table separator'); }); + + it('flags a legacy DP-5 record without three attempts as unsupported', () => { + const state = { + change_name: 'legacy-debug-escalation', + state: 'debugging', + dp_5_result: 'continue: recorded through raw state set', + dp_5_timestamp: '2026-08-06T00:00:00Z', + }; + + const report = generateReport(tempDir, state); + + assert.match(report, /DP-5.*unsupported/i); + assert.match(report, /three|3/i); + }); + + it('counts an evidence-backed confirmed DP-5 record as supported', () => { + const validDir = mkdtempSync(join(tmpdir(), 'ssf-audit-debug-valid-')); + try { + writeFileSync(join(validDir, 'proposal.md'), '## Why\nAudit a guarded DP-5 record with durable evidence.\n## What Changes\n- Guard escalation.\n'); + writeFileSync(join(validDir, 'design.md'), '# Design\n'); + writeFileSync(join(validDir, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Guard escalation\n'); + writeFileSync(join(validDir, 'execution-contract.md'), '# Execution Contract\n'); + rebuildState(validDir, { computeArtifactsHash, computeContractHash }); + const state = readState(validDir); + state.state = 'approved-for-build'; + state.workflow = 'quick'; + writeState(validDir, state); + const wave = 'audit-debug:serial:1.1'; + assert.equal(ssf(['execution', 'recommend', validDir, '--wave', wave]).exitCode, 0); + assert.equal(ssf([ + 'execution', 'plan', validDir, + '--mode', 'inline', + '--confirm', + '--reason', 'Bind audit evidence to the current plan', + '--wave', wave, + ]).exitCode, 0); + const plannedState = readState(validDir); + plannedState.state = 'debugging'; + writeState(validDir, plannedState); + + const evidenceDir = join(validDir, '.superpowers', 'sdd', 'debug-evidence'); + mkdirSync(evidenceDir, { recursive: true }); + for (const id of ['fix-1', 'fix-2', 'fix-3']) { + const evidencePath = join(evidenceDir, `${id}.log`); + writeFileSync(evidencePath, `${id} failed\n`); + recordDebugAttempt(validDir, { id, summary: `${id} failed`, evidence: evidencePath }); + } + recordDebugEscalation(validDir, { + decision: 'continue', + reason: 'Three fixes failed', + confirm: true, + }); + + const report = generateReport(validDir, readState(validDir)); + assert.match(report, /\| DP-5 \| 调试升级 \| continue: Three fixes failed \|/); + assert.ok(report.includes('2/8 已记录')); + assert.doesNotMatch(report, /DP-5 unsupported/i); + } finally { + rmSync(validDir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/lib/cmd-debug.test.mjs b/tests/lib/cmd-debug.test.mjs new file mode 100644 index 0000000..caad387 --- /dev/null +++ b/tests/lib/cmd-debug.test.mjs @@ -0,0 +1,257 @@ +import { afterEach, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, unlinkSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { computeArtifactsHash, computeContractHash } from '../../scripts/lib/hash.mjs'; +import { readState, rebuildState, writeState } from '../../scripts/lib/state-loader.mjs'; + +const CLI_PATH = join(process.cwd(), 'scripts/spec-superflow.mjs'); +let changeDir; +let outsideDir; + +function ssf(args) { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { encoding: 'utf8' }); + return { + exitCode: result.status ?? 1, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + +function writeChange(directory) { + writeFileSync(join(directory, 'proposal.md'), '## Why\nDebug escalation needs durable evidence and a hard gate.\n## What Changes\n- Guard DP-5.\n'); + writeFileSync(join(directory, 'design.md'), '# Design\n'); + writeFileSync(join(directory, 'tasks.md'), '# Tasks\n\n- [ ] 1.1 Guard DP-5\n'); + writeFileSync(join(directory, 'execution-contract.md'), '# Execution Contract\n'); + mkdirSync(join(directory, 'specs', 'debugging'), { recursive: true }); + writeFileSync(join(directory, 'specs', 'debugging', 'spec.md'), '## ADDED Requirements\n### Requirement: Guarded DP-5\nThe system SHALL require three failed fixes.\n#### Scenario: Escalate\n- **WHEN** three fixes fail\n- **THEN** escalation is allowed.\n'); + rebuildState(directory, { computeArtifactsHash, computeContractHash }); + const state = readState(directory); + state.state = 'debugging'; + state.workflow = 'quick'; + writeState(directory, state); +} + +function prepareCurrentPlan() { + const state = readState(changeDir); + state.state = 'approved-for-build'; + state.workflow = 'quick'; + writeState(changeDir, state); + const wave = 'debug-guard:serial:1.1'; + assert.equal(ssf(['execution', 'recommend', changeDir, '--wave', wave]).exitCode, 0); + const planned = ssf([ + 'execution', 'plan', changeDir, + '--mode', 'inline', + '--confirm', + '--reason', 'Bind debugging evidence to the current plan', + '--wave', wave, + ]); + assert.equal(planned.exitCode, 0, planned.stderr); + const plannedState = readState(changeDir); + plannedState.state = 'debugging'; + writeState(changeDir, plannedState); +} + +function evidence(name, content = name) { + const directory = join(changeDir, '.superpowers', 'sdd', 'debug-evidence'); + mkdirSync(directory, { recursive: true }); + const path = join(directory, `${name}.log`); + writeFileSync(path, `${content}\n`); + return path; +} + +function record(id, evidencePath = evidence(id)) { + return ssf([ + 'debug', 'attempt', 'record', changeDir, + '--id', id, + '--summary', `Fix ${id} failed`, + '--evidence', evidencePath, + '--json', + ]); +} + +function escalate(extra = []) { + return ssf([ + 'debug', 'escalate', changeDir, + '--decision', 'continue', + '--reason', 'Three evidence-backed fixes failed', + ...extra, + '--json', + ]); +} + +beforeEach(() => { + changeDir = mkdtempSync(join(tmpdir(), 'ssf-debug-cmd-')); + outsideDir = mkdtempSync(join(tmpdir(), 'ssf-debug-outside-')); + writeChange(changeDir); +}); + +afterEach(() => { + rmSync(changeDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); +}); + +describe('ssf debug', () => { + it('rejects a Quick debugging context without a current execution plan', () => { + const result = record('attempt-1'); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /execution plan/i); + const escalation = escalate(['--confirm']); + assert.equal(escalation.exitCode, 1); + assert.match(escalation.stderr, /execution plan/i); + assert.equal(existsSync(join(changeDir, '.superpowers', 'sdd', 'debug-attempts.json')), false); + assert.equal(readState(changeDir).dp_5_result, null); + }); + + it('rejects attempt recording outside debugging state', () => { + const state = readState(changeDir); + state.state = 'executing'; + writeState(changeDir, state); + + const result = record('attempt-1'); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /debugging state/i); + }); + + it('records and shows one evidence-backed failed attempt', () => { + prepareCurrentPlan(); + const recorded = record('attempt-1'); + assert.equal(recorded.exitCode, 0, recorded.stderr); + + const shown = ssf(['debug', 'attempt', 'show', changeDir, '--json']); + assert.equal(shown.exitCode, 0, shown.stderr); + const payload = JSON.parse(shown.stdout); + assert.equal(payload.attempt_count, 1); + assert.equal(payload.attempts[0].id, 'attempt-1'); + assert.match(payload.attempts[0].evidence_sha256, /^sha256:[a-f0-9]{64}$/); + }); + + it('rejects DP-5 escalation with fewer than three failed attempts', () => { + prepareCurrentPlan(); + assert.equal(record('attempt-1').exitCode, 0); + assert.equal(record('attempt-2').exitCode, 0); + + const result = escalate(['--confirm']); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /at least three/i); + assert.equal(readState(changeDir).dp_5_result, null); + }); + + it('rejects duplicate failure evidence', () => { + prepareCurrentPlan(); + const sharedEvidence = evidence('shared', 'same failure output'); + assert.equal(record('attempt-1', sharedEvidence).exitCode, 0); + + const duplicate = record('attempt-2', sharedEvidence); + + assert.equal(duplicate.exitCode, 1); + assert.match(duplicate.stderr, /duplicate evidence/i); + }); + + it('rejects a ledger path redirected outside the change directory by a symlink', () => { + prepareCurrentPlan(); + renameSync(join(changeDir, '.superpowers'), join(outsideDir, 'overlay')); + symlinkSync(join(outsideDir, 'overlay'), join(changeDir, '.superpowers'), 'dir'); + const evidencePath = join(changeDir, 'attempt-1.log'); + writeFileSync(evidencePath, 'failed test output\n'); + + const result = record('attempt-1', evidencePath); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /physical directory/i); + assert.equal(existsSync(join(outsideDir, 'overlay', 'sdd', 'debug-attempts.json')), false); + }); + + it('does not count Wave Review repair failures as debugging attempts', () => { + prepareCurrentPlan(); + const repairDirectory = join(changeDir, '.superpowers', 'sdd', 'repair-state'); + mkdirSync(repairDirectory, { recursive: true }); + writeFileSync(join(repairDirectory, 'wave-review.json'), JSON.stringify({ failure_count: 5 })); + + const result = escalate(['--confirm']); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /at least three/i); + }); + + it('rejects escalation when the recorded context is stale', () => { + prepareCurrentPlan(); + for (const id of ['attempt-1', 'attempt-2', 'attempt-3']) { + assert.equal(record(id).exitCode, 0); + } + writeFileSync(join(changeDir, 'proposal.md'), '## Why\nScope changed after debugging attempts were recorded.\n## What Changes\n- Different fix.\n'); + + const result = escalate(['--confirm']); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /stale|context/i); + }); + + it('rejects escalation when recorded evidence is missing', () => { + prepareCurrentPlan(); + const evidencePaths = []; + for (const id of ['attempt-1', 'attempt-2', 'attempt-3']) { + const evidencePath = evidence(id); + evidencePaths.push(evidencePath); + assert.equal(record(id, evidencePath).exitCode, 0); + } + unlinkSync(evidencePaths[1]); + + const result = escalate(['--confirm']); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /evidence does not exist/i); + }); + + it('rejects attempts and escalation when the current execution plan is stale', () => { + prepareCurrentPlan(); + assert.equal(record('attempt-1').exitCode, 0); + + writeFileSync(join(changeDir, 'proposal.md'), '## Why\nThe execution plan is now stale.\n## What Changes\n- Changed scope.\n'); + const result = record('attempt-2'); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /execution plan is stale/i); + }); + + it('requires explicit confirmation even after three failed attempts', () => { + prepareCurrentPlan(); + for (const id of ['attempt-1', 'attempt-2', 'attempt-3']) { + assert.equal(record(id).exitCode, 0); + } + + const result = escalate(); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /--confirm/i); + }); + + it('persists DP-5 only after three distinct attempts and confirmation', () => { + prepareCurrentPlan(); + for (const id of ['attempt-1', 'attempt-2', 'attempt-3']) { + assert.equal(record(id).exitCode, 0); + } + + const result = escalate(['--confirm']); + + assert.equal(result.exitCode, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, true); + assert.equal(payload.attempt_count, 3); + const state = readState(changeDir); + assert.match(state.dp_5_result, /^continue:/); + assert.equal(state.dp_5_confirmed, 'true'); + assert.match(state.dp_5_timestamp, /^\d{4}-\d{2}-\d{2}T/); + + const laterAttempt = record('attempt-4'); + assert.equal(laterAttempt.exitCode, 1); + assert.match(laterAttempt.stderr, /already recorded/i); + }); +}); diff --git a/tests/lib/cmd-state.test.mjs b/tests/lib/cmd-state.test.mjs index 12f2728..af692f1 100644 --- a/tests/lib/cmd-state.test.mjs +++ b/tests/lib/cmd-state.test.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, chmodSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { execSync } from 'node:child_process'; +import { execSync, spawnSync } from 'node:child_process'; const CLI_PATH = join(process.cwd(), 'scripts/spec-superflow.mjs'); let tempDir; @@ -26,6 +26,15 @@ function shellQuote(value) { return `'${value.replaceAll("'", "'\\''")}'`; } +function ssfArgs(args) { + const result = spawnSync(process.execPath, [CLI_PATH, ...args], { encoding: 'utf8' }); + return { + exitCode: result.status ?? 1, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + describe('cmd-state: init', () => { before(() => { tempDir = mkdtempSync(join(tmpdir(), 'ssf-state-cmd-')); @@ -378,6 +387,26 @@ describe('cmd-state: set', () => { assert.match(result.stderr || result.stdout, /not settable/); }); + it('rejects manual edits to guarded DP-5 fields', () => { + ssf(`state init ${tempDir}`); + for (const field of ['dp_5_result', 'dp_5_timestamp', 'dp_5_decisions', 'dp_5_confirmed']) { + const result = ssf(`state set ${tempDir} ${field} forged`); + assert.equal(result.exitCode, 1, `${field} should be guarded`); + assert.match(result.stderr || result.stdout, /not settable/); + } + }); + + it('rejects newline injection through another settable field', () => { + ssf(`state init ${tempDir}`); + const payload = 'valid DP-6 result\ndp_5_result: forged escalation\ndp_5_timestamp: 2026-08-07T00:00:00Z\ndp_5_confirmed: true'; + + const result = ssfArgs(['state', 'set', tempDir, 'dp_6_result', payload]); + + assert.equal(result.exitCode, 1); + assert.match(result.stderr || result.stdout, /control character|line separator/i); + assert.equal(ssf(`state get ${tempDir} dp_5_result`).stdout.trim(), 'null'); + }); + it('rejects unknown fields', () => { ssf(`state init ${tempDir}`); const result = ssf(`state set ${tempDir} nonexistent_field value`);