e56ddd6a1b
Backlog item G. The `coverage:` line under-reported a skill chosen in a PRIOR turn: enforce-coverage-verify credited channel=skill only if the Skill tool ran in the CURRENT turn, so an honest `skill:X` continuation line was BLOCKED -> the controller learned to under-report as direct/chain. Two-sided systemic fix, no weakening: - enforce-coverage-verify: decide() also accepts skill:X when X was invoked anywhere earlier in THIS session (new priorSkillNames param; main() collects them via sessionToolUses). Still unforgeable -- a real Skill tool_use must exist in the transcript. The only residual is possibly-stale attribution, far better than the forced dishonest direct-reporting it replaces. - enforce-prompt-injection: the §17 reminder now lists active skills carried over from earlier turns (read from the transcript) and tells the controller to report `coverage: skill:<name>` when work continues under one -- the proactive half, so the correct line is not merely allowed but prompted. TDD: RED -> GREEN per behavior. tools-vitest 2032 passed / 2 skipped. Plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md (item G). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
4.2 KiB
JavaScript
113 lines
4.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Rule #2 — Coverage tag verified against artifacts (Stop hook).
|
|
*
|
|
* Reads transcript at Stop event. Parses `coverage: <channel>:<id>` from last
|
|
* assistant text. Then:
|
|
* - channel=skill / id=X — require Skill tool_use with input.skill === X
|
|
* - channel=node — accept any tool_use that produced work (>= 1 mutating tool)
|
|
* - channel=direct — accept (Rule #8 handles direct-vs-classifier mismatch)
|
|
* - channel=chain / hook / agent — accept (lighter discipline)
|
|
* - missing coverage line — block
|
|
*
|
|
* Override: "без скилов" / "direct ok" suppress this rule.
|
|
*
|
|
* NB: only fires when the assistant ACTUALLY did some work (>=1 tool_use).
|
|
* Pure conversational turns (no tool calls) pass without coverage requirement.
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
|
*/
|
|
|
|
import {
|
|
readStdin,
|
|
parseEventJson,
|
|
readTranscript,
|
|
lastUserPromptText,
|
|
lastAssistantText,
|
|
parseCoverageLine,
|
|
turnToolUses,
|
|
sessionToolUses,
|
|
findOverride,
|
|
logOverride,
|
|
exitDecision,
|
|
} from './enforce-hook-helpers.mjs';
|
|
|
|
const RULE_KEY = 'coverage-skill-match';
|
|
|
|
const MUTATING_TOOLS = new Set([
|
|
'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash',
|
|
]);
|
|
|
|
export function decide({
|
|
toolUses, assistantText, override, priorSkillNames = [],
|
|
}) {
|
|
// Pure conversational turn — skip.
|
|
const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name));
|
|
if (!hasMutating) return { block: false };
|
|
if (override) return { block: false };
|
|
|
|
const cov = parseCoverageLine(assistantText);
|
|
if (!cov) {
|
|
return {
|
|
block: true,
|
|
message: [
|
|
`[enforce-coverage-verify] Turn performed mutating tool calls but assistant response has no \`coverage:\` line.`,
|
|
`Add as first line of next response:`,
|
|
` coverage: skill:<name> (e.g., skill:superpowers:test-driven-development)`,
|
|
` coverage: direct:<role> (e.g., direct:memory-sync, direct:git-recovery)`,
|
|
].join('\n'),
|
|
};
|
|
}
|
|
|
|
if (cov.channel === 'skill') {
|
|
// Accept if the skill was invoked in THIS turn OR anywhere earlier in this
|
|
// session (item G): a skill chosen in a prior turn stays active, so an honest
|
|
// skill:X line on a continuation turn must not be punished into under-reporting.
|
|
// Still unforgeable — a real Skill tool_use must exist in the transcript.
|
|
const norm = (s) => String(s || '').replace(/^superpowers:/, '');
|
|
const idNorm = norm(cov.id);
|
|
const foundThisTurn = toolUses.some((u) => u.name === 'Skill' && u.input && norm(u.input.skill) === idNorm);
|
|
const foundPrior = (priorSkillNames || []).some((n) => norm(n) === idNorm);
|
|
if (!foundThisTurn && !foundPrior) {
|
|
return {
|
|
block: true,
|
|
message: [
|
|
`[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn or any prior turn of this session.`,
|
|
`Either invoke the skill via Skill tool, or switch coverage to direct:<role> with justification.`,
|
|
].join('\n'),
|
|
};
|
|
}
|
|
return { block: false };
|
|
}
|
|
|
|
// direct / node / chain / hook / agent — accepted at this layer.
|
|
return { block: false };
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
const raw = await readStdin();
|
|
const event = parseEventJson(raw);
|
|
const transcript = readTranscript(event.transcript_path);
|
|
const userPrompt = lastUserPromptText(transcript);
|
|
const override = findOverride(userPrompt, RULE_KEY);
|
|
if (override) logOverride(RULE_KEY, override, event.session_id);
|
|
|
|
const toolUses = turnToolUses(transcript);
|
|
const assistantText = lastAssistantText(transcript);
|
|
// Session-wide Skill invocations (item G): a skill chosen in a prior turn is
|
|
// still active and may legitimately be named in this turn's coverage line.
|
|
const priorSkillNames = sessionToolUses(transcript)
|
|
.filter((u) => u.name === 'Skill' && u.input && u.input.skill)
|
|
.map((u) => u.input.skill);
|
|
|
|
const result = decide({ toolUses, assistantText, override, priorSkillNames });
|
|
exitDecision(result);
|
|
} catch {
|
|
exitDecision({ block: false });
|
|
}
|
|
}
|
|
|
|
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-coverage-verify.mjs');
|
|
if (isCli) main();
|