ffd70d6fa5
Sibling Claude session 2026-05-30 found that lastTurnEntries treats
harness-injected skill bodies as spurious turn boundaries, breaking both
enforce-memory-coverage (can't find user's coverage line) AND
enforce-normative-content-rules::detectLegitSkillActive (can't find the
Skill tool_use that lives in the assistant message BEFORE the body).
Refinement applied here: this session inspected 29 isMeta:true entries
across the live transcript (8f4ba767-...jsonl) via a debug helper and
found isMeta:true is ALSO used for "Continue from where you left off"
auto-resume, Stop hook feedback strings, and <local-command-caveat>
wrappers — those are real user-equivalent boundaries that must remain
visible. Sibling's blanket "skip isMeta" proposal would have broken them.
Discriminator: skip ONLY when isMeta === true AND typeof sourceToolUseID
=== 'string' (tool-spawned content). Skill bodies have the linking field;
the other isMeta sources do not. The sourceToolUseID field is harness-
controlled and not writable by controller from inside a tool call —
cannot be spoofed.
Behaviour after fix:
* Skill body injection → skipped → walk continues back to find user's
real prompt (with coverage line).
* The assistant message containing the Skill tool_use is now inside the
turn → detectLegitSkillActive finds it → normative writes pass when
invoked under an active claude-md-management skill.
* "Continue from where you left off." → still treated as turn boundary.
* Stop hook feedback strings → still treated as turn boundary.
TDD:
* 3 new tests in tools/enforce-hook-helpers.test.mjs under the
"lastTurnEntries / lastUserPromptText / lastAssistantText / turnToolUses"
describe block:
- lastTurnEntries skips skill body injections (isMeta + sourceToolUseID)
- lastTurnEntries does NOT skip "Continue from where you left off"
(isMeta but no sourceToolUseID)
- turnToolUses includes Skill tool_use spawned in same turn as the
injected skill body
* 2/3 RED→GREEN (the "Continue" negative test passed on baseline already
since its string content satisfies the existing string-content branch).
Scope:
* Fixes 2 of the 5 structural quirks documented in the Stream H
completion log (enforce-memory-coverage gap, enforce-normative-
content-rules detectLegitSkillActive gap).
* Does NOT fix: enforce-read-path-deny LEGIT_SKILLS exemption gap
(separate hook, no lastTurnEntries dependency); TDD-gate cross-actor
blindness (different mechanism — actor session boundaries);
detectFullTestRun regex narrowness (command-pattern matching).
Regression: vitest tools 1788/1788 GREEN (was 1785; +3 new tests).
Plan: docs/superpowers/plans/2026-05-30-lastturnentries-skill-body-skip.md
453 lines
15 KiB
JavaScript
453 lines
15 KiB
JavaScript
/**
|
|
* Shared helpers for the 10-rule enforcement hook layer.
|
|
*
|
|
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
|
|
* Plan: docs/superpowers/plans/2026-05-25-enforce-hard-rules.md
|
|
*
|
|
* Design contract: ALL hooks MUST fail-quiet on internal error (exit 0 with empty {}).
|
|
* Only deliberate enforcement violations exit 2.
|
|
*
|
|
* Security note: this file uses child_process.execFileSync with FIXED arguments
|
|
* (no user input concatenation) — pattern is safe by construction. No injection
|
|
* surface. See readGitBranch().
|
|
*
|
|
* Security Guidance #40: pure parsing — no exec/execSync except readGitBranch which
|
|
* is the documented use case (fixed args, no user input).
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { homedir } from 'os';
|
|
import { execFileSync } from 'child_process';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
/** Read full stdin as utf-8 string. Returns '' on empty/error. */
|
|
export async function readStdin(stdinStream = process.stdin) {
|
|
return new Promise((resolve) => {
|
|
let data = '';
|
|
let timedOut = false;
|
|
const timer = setTimeout(() => { timedOut = true; resolve(data); }, 4500);
|
|
stdinStream.setEncoding('utf-8');
|
|
stdinStream.on('data', (chunk) => { data += chunk; });
|
|
stdinStream.on('end', () => {
|
|
if (timedOut) return;
|
|
clearTimeout(timer);
|
|
resolve(data);
|
|
});
|
|
stdinStream.on('error', () => {
|
|
clearTimeout(timer);
|
|
resolve('');
|
|
});
|
|
});
|
|
}
|
|
|
|
export function parseEventJson(raw) {
|
|
try { return JSON.parse(raw || '{}'); } catch { return {}; }
|
|
}
|
|
|
|
/** Runtime directory: ~/.claude/runtime/ */
|
|
export function runtimeDir() {
|
|
const dir = join(homedir(), '.claude', 'runtime');
|
|
try { mkdirSync(dir, { recursive: true }); } catch { /* ignore */ }
|
|
return dir;
|
|
}
|
|
|
|
export function sentinelPath(name, sessionId) {
|
|
return join(runtimeDir(), `${name}-${sessionId || 'unknown'}.json`);
|
|
}
|
|
|
|
export function writeSentinel(name, sessionId, data) {
|
|
try {
|
|
const p = sentinelPath(name, sessionId);
|
|
writeFileSync(p, JSON.stringify({ ...data, written_at: new Date().toISOString() }, null, 2));
|
|
return p;
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function readSentinel(name, sessionId) {
|
|
try {
|
|
const p = sentinelPath(name, sessionId);
|
|
if (!existsSync(p)) return null;
|
|
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function sentinelAgeSec(name, sessionId) {
|
|
const s = readSentinel(name, sessionId);
|
|
if (!s || !s.written_at) return null;
|
|
const ms = Date.now() - new Date(s.written_at).getTime();
|
|
if (!Number.isFinite(ms)) return null;
|
|
return Math.floor(ms / 1000);
|
|
}
|
|
|
|
export function readTranscript(transcriptPath) {
|
|
if (!transcriptPath || typeof transcriptPath !== 'string') return [];
|
|
if (!existsSync(transcriptPath)) return [];
|
|
try {
|
|
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
const lines = raw.split('\n').filter(Boolean);
|
|
const out = [];
|
|
for (const l of lines) {
|
|
try { out.push(JSON.parse(l)); } catch { /* skip */ }
|
|
}
|
|
return out;
|
|
} catch { return []; }
|
|
}
|
|
|
|
export function lastTurnEntries(entries) {
|
|
if (!Array.isArray(entries) || entries.length === 0) return [];
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
const e = entries[i];
|
|
// Sibling-session find 2026-05-30: harness-injected skill bodies arrive as
|
|
// role:'user' messages with isMeta:true AND a top-level sourceToolUseID
|
|
// linking them back to the originating Skill tool_use. Treating them as
|
|
// turn boundaries hides both the user's real prompt (breaks coverage
|
|
// detection) and the Skill tool_use (breaks detectLegitSkillActive in
|
|
// enforce-normative-content-rules). Skip ONLY this exact shape — other
|
|
// isMeta:true messages (auto-resume "Continue from where you left off.",
|
|
// Stop hook feedback, local-command-caveat wrappers) remain valid
|
|
// boundaries. Discriminator field sourceToolUseID is harness-controlled
|
|
// and not writable by controller from inside a tool call.
|
|
if (e && e.isMeta === true && typeof e.sourceToolUseID === 'string') continue;
|
|
if (e && e.message && e.message.role === 'user') {
|
|
const c = e.message.content;
|
|
if (typeof c === 'string' && c.trim().length > 0) return entries.slice(i);
|
|
if (Array.isArray(c)) {
|
|
const hasToolResult = c.some((b) => b && b.type === 'tool_result');
|
|
const hasText = c.some((b) => b && b.type === 'text');
|
|
if (hasText && !hasToolResult) return entries.slice(i);
|
|
}
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
export function lastUserPromptText(entries) {
|
|
const turn = lastTurnEntries(entries);
|
|
if (!turn || turn.length === 0) return '';
|
|
const e = turn[0];
|
|
if (!e || !e.message) return '';
|
|
const c = e.message.content;
|
|
if (typeof c === 'string') return c;
|
|
if (Array.isArray(c)) {
|
|
return c.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('\n');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function lastAssistantText(entries) {
|
|
const turn = lastTurnEntries(entries);
|
|
let out = '';
|
|
for (const e of turn) {
|
|
if (e && e.message && e.message.role === 'assistant') {
|
|
const c = e.message.content;
|
|
if (Array.isArray(c)) {
|
|
for (const b of c) {
|
|
if (b && b.type === 'text' && typeof b.text === 'string') out += b.text + '\n';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function parseCoverageLine(text) {
|
|
if (typeof text !== 'string') return null;
|
|
const m = text.match(/coverage:\s*(skill|node|chain|hook|agent|direct)\s*:\s*([^\s\n<>]+)/i);
|
|
if (!m) return null;
|
|
return { channel: m[1].toLowerCase(), id: m[2] };
|
|
}
|
|
|
|
export function sessionToolUses(entries) {
|
|
if (!Array.isArray(entries)) return [];
|
|
const uses = [];
|
|
for (const e of entries) {
|
|
const c = e && e.message && e.message.content;
|
|
if (!Array.isArray(c)) continue;
|
|
for (const b of c) {
|
|
if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
|
|
}
|
|
}
|
|
return uses;
|
|
}
|
|
|
|
export function turnToolUses(entries) {
|
|
const turn = lastTurnEntries(entries);
|
|
const uses = [];
|
|
for (const e of turn) {
|
|
const c = e && e.message && e.message.content;
|
|
if (!Array.isArray(c)) continue;
|
|
for (const b of c) {
|
|
if (b && b.type === 'tool_use') uses.push({ name: b.name, input: b.input || {} });
|
|
}
|
|
}
|
|
return uses;
|
|
}
|
|
|
|
export function turnToolResults(entries) {
|
|
const turn = lastTurnEntries(entries);
|
|
const results = [];
|
|
for (const e of turn) {
|
|
const c = e && e.message && e.message.content;
|
|
if (!Array.isArray(c)) continue;
|
|
for (const b of c) {
|
|
if (b && b.type === 'tool_result') {
|
|
const txt = typeof b.content === 'string' ? b.content
|
|
: Array.isArray(b.content) ? b.content.map((p) => (p && p.text) || '').join('\n') : '';
|
|
results.push({ tool_use_id: b.tool_use_id, is_error: b.is_error === true, content: txt });
|
|
}
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
// v4 stubs — universal vocab override surface removed per spec §4.2.
|
|
// Keep symbols exported so callers in other hooks compile; runtime returns null/empty.
|
|
export function loadOverrideVocab(_path) {
|
|
return { phrases: [] };
|
|
}
|
|
|
|
export function _resetVocabCache() { /* no-op, vocab disabled */ }
|
|
|
|
export function findOverride(_userPrompt, _ruleKey, _vocab) {
|
|
return null;
|
|
}
|
|
|
|
export function findOverrideAttempt(_userPrompt, _ruleKey, _vocab) {
|
|
return null;
|
|
}
|
|
export function logHookOutcome(ruleKey, outcome, sessionId) {
|
|
try {
|
|
const f = join(runtimeDir(), 'hook-outcomes.jsonl');
|
|
appendFileSync(f, JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
session_id: sessionId || null,
|
|
rule: ruleKey,
|
|
outcome,
|
|
}) + '\n');
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
export function logOverride(ruleKey, phraseObj, sessionId) {
|
|
try {
|
|
const f = join(runtimeDir(), 'override-usage.jsonl');
|
|
appendFileSync(f, JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
session_id: sessionId || null,
|
|
rule: ruleKey,
|
|
phrase: phraseObj && phraseObj.phrase,
|
|
}) + '\n');
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
/**
|
|
* Read current git branch via execFileSync with fixed args (no shell, no user
|
|
* input concatenation — safe by construction). Returns empty string on error.
|
|
*/
|
|
export function readGitBranch(cwd) {
|
|
try {
|
|
return execFileSync('git', ['branch', '--show-current'], {
|
|
cwd: cwd || process.cwd(),
|
|
encoding: 'utf-8',
|
|
timeout: 1000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
}).trim();
|
|
} catch { return ''; }
|
|
}
|
|
|
|
export function expectedBranchPath(sessionId) {
|
|
return join(runtimeDir(), `expected-branch-${sessionId || 'unknown'}`);
|
|
}
|
|
|
|
export function getExpectedBranch(sessionId) {
|
|
try {
|
|
const p = expectedBranchPath(sessionId);
|
|
if (!existsSync(p)) return '';
|
|
return readFileSync(p, 'utf-8').trim();
|
|
} catch { return ''; }
|
|
}
|
|
|
|
export function setExpectedBranch(sessionId, branch) {
|
|
try {
|
|
writeFileSync(expectedBranchPath(sessionId), String(branch || '').trim());
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
export function appendRationalizationFlag(sessionId, kind, evidence) {
|
|
try {
|
|
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
|
appendFileSync(f, JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
kind,
|
|
evidence: typeof evidence === 'string' ? evidence.slice(0, 240) : evidence,
|
|
}) + '\n');
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
export function readRationalizationFlags(sessionId) {
|
|
try {
|
|
const f = join(runtimeDir(), `rationalization-flags-${sessionId || 'unknown'}.jsonl`);
|
|
if (!existsSync(f)) return [];
|
|
return readFileSync(f, 'utf-8').split('\n').filter(Boolean).map((l) => {
|
|
try { return JSON.parse(l); } catch { return null; }
|
|
}).filter(Boolean);
|
|
} catch { return []; }
|
|
}
|
|
|
|
export function readRouterState(sessionId) {
|
|
try {
|
|
const p = join(runtimeDir(), `router-state-${sessionId || 'unknown'}.json`);
|
|
if (!existsSync(p)) return null;
|
|
return JSON.parse(readFileSync(p, 'utf-8'));
|
|
} catch { return null; }
|
|
}
|
|
|
|
export function exitDecision({ block, message } = {}) {
|
|
if (block) {
|
|
if (message) process.stderr.write(message + '\n');
|
|
process.exit(2);
|
|
return;
|
|
}
|
|
try { process.stdout.write('{}'); } catch { /* ignore */ }
|
|
process.exit(0);
|
|
}
|
|
|
|
export function isProductionCodePath(p) {
|
|
if (typeof p !== 'string') return false;
|
|
const n = p.replace(/\\/g, '/');
|
|
if (/\.(test|spec)\.[a-z0-9]+$/i.test(n)) return false;
|
|
if (/(?:^|\/)tests?\//.test(n) || /(?:^|\/)spec\//.test(n)) return false;
|
|
if (/(?:^|\/)tools\/[^/]+\.mjs$/.test(n)) return true;
|
|
if (/(?:^|\/)app\/app\/.+\.php$/.test(n)) return true;
|
|
if (/(?:^|\/)resources\/js\/.+\.(vue|ts|tsx|js)$/.test(n)) return true;
|
|
return false;
|
|
}
|
|
|
|
export function isMemoryPath(p) {
|
|
if (typeof p !== 'string') return false;
|
|
const n = p.replace(/\\/g, '/');
|
|
if (/\/memory\/[^/]+\.md$/i.test(n)) return true;
|
|
if (/\/MEMORY\.md$/i.test(n)) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns true if path is docs-only (ends with `.md`, case-insensitive).
|
|
*
|
|
* Used by verify-before-push to short-circuit regression-gating for docs /
|
|
* memory / spec / plan / SKILL.md pushes — no executable code → no test value.
|
|
*
|
|
* Anything else (.json, .php, .ts, .yaml, .mjs, no-extension files) returns false
|
|
* and remains under the normal regression gate.
|
|
*/
|
|
export function isDocsOnlyPath(p) {
|
|
if (typeof p !== 'string' || p.length === 0) return false;
|
|
return /\.md$/i.test(p);
|
|
}
|
|
|
|
/**
|
|
* Returns true iff `paths` is a non-empty array where EVERY entry is docs-only.
|
|
* Empty/non-array → false (unknown = conservative, fall through to normal gate).
|
|
*/
|
|
export function isDocsOnlyChange(paths) {
|
|
if (!Array.isArray(paths) || paths.length === 0) return false;
|
|
return paths.every(isDocsOnlyPath);
|
|
}
|
|
|
|
/**
|
|
* List changed file paths for an in-progress git commit / push.
|
|
* - commit: staged files (`git diff --cached --name-only`)
|
|
* - push: commits ahead of upstream (`git diff --name-only @{u}..HEAD`)
|
|
*
|
|
* Returns [] on any git error (no upstream, detached HEAD, git missing, etc.)
|
|
* or unrecognized `kind`. Callers MUST treat empty as "unknown" and NOT short-
|
|
* circuit on it.
|
|
*
|
|
* Security: execFileSync with fixed args, no user-input concatenation.
|
|
*/
|
|
export function listChangedFiles(kind, cwd) {
|
|
try {
|
|
let args;
|
|
if (kind === 'commit') {
|
|
args = ['diff', '--cached', '--name-only'];
|
|
} else if (kind === 'push') {
|
|
args = ['diff', '--name-only', '@{u}..HEAD'];
|
|
} else {
|
|
return [];
|
|
}
|
|
const out = execFileSync('git', args, {
|
|
cwd: cwd || process.cwd(),
|
|
encoding: 'utf-8',
|
|
timeout: 2000,
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function detectGitCommandKind(cmd) {
|
|
if (typeof cmd !== 'string') return null;
|
|
const c = cmd.trim();
|
|
if (/(^|\s|;|&&|\|\|)git\s+push\b/i.test(c)) return 'push';
|
|
if (/(^|\s|;|&&|\|\|)git\s+commit\b/i.test(c)) return 'commit';
|
|
if (/(^|\s|;|&&|\|\|)git\s+cherry-pick\b/i.test(c)) return 'cherry-pick';
|
|
if (/(^|\s|;|&&|\|\|)git\s+reset\s+--hard\b/i.test(c)) return 'reset-hard';
|
|
if (/(^|\s|;|&&|\|\|)git\s+rebase\b/i.test(c)) return 'rebase';
|
|
if (/(^|\s|;|&&|\|\|)git\s+branch\s+-[df]\b/i.test(c)) return 'branch-force';
|
|
return null;
|
|
}
|
|
|
|
export function detectFullTestRun(cmd) {
|
|
if (typeof cmd !== 'string') return null;
|
|
const c = cmd.toLowerCase();
|
|
// FIRST-REAL-COMMAND approach: split on shell separators, find first segment
|
|
// after skipping cd / env-prefix. Only that command counts. Embedded args
|
|
// (commit messages, echo strings) don't matter — they live inside the args
|
|
// of the first command, not as independent shell segments.
|
|
//
|
|
// Caveat: naive `&&` split can match inside quoted strings. We accept this
|
|
// because we use the FIRST segment only; later segments are ignored. As
|
|
// long as user's first real command is git/echo/etc, the whole command is
|
|
// classified as that.
|
|
const segments = c.split(/\s*(?:&&|\|\||;|\|)\s*/);
|
|
let firstReal = null;
|
|
for (let seg of segments) {
|
|
seg = seg.trim();
|
|
// Strip env-var prefixes (KEY=value) and skip `cd <path>` segments.
|
|
seg = seg.replace(/^(?:[a-z_][a-z0-9_]*=\S+\s+)+/i, '').trim();
|
|
if (/^cd\b/i.test(seg)) continue;
|
|
firstReal = seg;
|
|
break;
|
|
}
|
|
if (!firstReal) return null;
|
|
|
|
// Hard guard: first real command starts with a non-test shell-utility →
|
|
// whole compound is not a test run, regardless of quoted args.
|
|
if (/^(?:git|scp|ssh|curl|wget|cat|echo|grep|awk|sed|tar|gzip|bzip2|cp|mv|rm|mkdir|touch|chmod|chown|ls|cd|pwd|head|tail|find)\b/.test(firstReal)) {
|
|
return null;
|
|
}
|
|
|
|
if (/^npx\s+vitest\s+run\b/.test(firstReal) || /^vitest\s+run\b/.test(firstReal)) {
|
|
// narrow vitest (specific .test file) is NOT full
|
|
if (/\btools\/[^\s]+\.test\.mjs\b/.test(firstReal)) return null;
|
|
return 'vitest-full';
|
|
}
|
|
if (/^npm\s+run\s+test\b/.test(firstReal)) return 'npm-test';
|
|
if (/^php\s+artisan\s+test\b/.test(firstReal) || /^composer\s+test\b/.test(firstReal)) return 'pest';
|
|
if (/^(?:\.\/)?(?:vendor\/bin\/)?pest\b/.test(firstReal)) return 'pest';
|
|
return null;
|
|
}
|
|
|
|
export function isVerificationFresh(sessionId, maxAgeSec = 1800) {
|
|
const s = readSentinel('verify-pass', sessionId);
|
|
if (!s || s.result !== 'pass') return false;
|
|
const age = sentinelAgeSec('verify-pass', sessionId);
|
|
return age !== null && age <= maxAgeSec;
|
|
}
|