12 Commits

Author SHA1 Message Date
Affaan Mustafa
841beea45c fix: handle dotted reserved snapshot names 2026-04-30 12:25:14 -04:00
Affaan Mustafa
61992f7f5e fix: harden loop-status snapshot writes 2026-04-30 12:25:14 -04:00
Affaan Mustafa
2715315438 fix: avoid loop-status index snapshot collision 2026-04-30 12:25:14 -04:00
Affaan Mustafa
7627926216 fix: preserve loop-status output on snapshot errors 2026-04-30 12:25:14 -04:00
Affaan Mustafa
20154ddb22 feat: write loop-status snapshots 2026-04-30 12:25:14 -04:00
Affaan Mustafa
bb40978e31 fix: show correct gateguard hook recovery id 2026-04-30 11:26:15 -04:00
Affaan Mustafa
7c5452f4fa fix: keep gateguard destructive gate strict 2026-04-30 11:26:15 -04:00
Affaan Mustafa
cfe770a735 fix: add gateguard recovery escape hatch 2026-04-30 11:26:15 -04:00
Affaan Mustafa
4c8499d509 docs: clarify loop-status exit-code watch constraint 2026-04-30 10:33:17 -04:00
Affaan Mustafa
85dfb5e5fc test: isolate loop-status missing transcript fixture 2026-04-30 10:33:17 -04:00
Affaan Mustafa
7b03a60503 fix: require bounded loop-status exit-code watch 2026-04-30 10:33:17 -04:00
Affaan Mustafa
fbd441b448 feat: add loop-status exit-code mode 2026-04-30 10:33:17 -04:00
7 changed files with 642 additions and 29 deletions

View File

@@ -40,9 +40,18 @@ tool calls that have no matching `tool_result`.
directly. directly.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash - `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
threshold. threshold.
- `ecc loop-status --exit-code` exits `2` when stale loop or tool signals are
found, or `1` when transcripts cannot be scanned.
- `--exit-code` with `--watch` requires `--watch-count` so watchdog scripts do
not wait forever for a process exit.
- `ecc loop-status --watch` refreshes status until interrupted. - `ecc loop-status --watch` refreshes status until interrupted.
- `ecc loop-status --watch --watch-count 3 --exit-code` refreshes a bounded
number of times, then exits with the highest status seen.
- `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for - `ecc loop-status --watch --watch-count 3` emits a bounded watch stream for
scripts and handoffs. scripts and handoffs.
- `ecc loop-status --watch --write-dir ~/.claude/loops` maintains
`index.json` and per-session JSON snapshots for sibling terminals or
watchdog scripts.
## Watch Mode ## Watch Mode
@@ -50,6 +59,18 @@ When `--watch` is present, refresh status periodically. With `--json`, each
refresh is emitted as one JSON object per line so another terminal or script can refresh is emitted as one JSON object per line so another terminal or script can
consume the stream. consume the stream.
## Snapshot Files
Use `--write-dir <dir>` when a separate process needs to inspect loop state
without waiting for the current Claude session to dequeue `/loop-status`. The
CLI writes:
- `index.json` with one row per inspected session.
- `<session-id>.json` with the full status payload for that session.
These files are snapshots of local transcript analysis. They do not control or
timeout Claude Code runtime tool calls.
## Arguments ## Arguments
$ARGUMENTS: $ARGUMENTS:

View File

@@ -99,6 +99,9 @@ export ECC_HOOK_PROFILE=standard
# Disable specific hook IDs (comma-separated) # Disable specific hook IDs (comma-separated)
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck" export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"
# Disable only GateGuard during setup or recovery
export ECC_GATEGUARD=off
# Cap SessionStart additional context (default: 8000 chars) # Cap SessionStart additional context (default: 8000 chars)
export ECC_SESSION_START_MAX_CHARS=4000 export ECC_SESSION_START_MAX_CHARS=4000

View File

@@ -38,11 +38,26 @@ const READ_HEARTBEAT_MS = 60 * 1000;
const MAX_CHECKED_ENTRIES = 500; const MAX_CHECKED_ENTRIES = 500;
const MAX_SESSION_KEYS = 50; const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__'; const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force(?!-with-lease)|git\s+commit\s+--amend|dd\s+if=)\b/i; const DESTRUCTIVE_BASH = /\b(rm\s+-rf|git\s+reset\s+--hard|git\s+checkout\s+--|git\s+clean\s+-f|drop\s+table|delete\s+from|truncate|git\s+push\s+--force(?!-with-lease)|git\s+commit\s+--amend|dd\s+if=)\b/i;
// --- State management (per-session, atomic writes, bounded) --- // --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) {
return String(value || '').trim().toLowerCase();
}
function isGateGuardDisabled() {
if (normalizeEnvValue(process.env.GATEGUARD_DISABLED) === '1') {
return true;
}
return ECC_DISABLE_VALUES.has(normalizeEnvValue(process.env.ECC_GATEGUARD));
}
function sanitizeSessionKey(value) { function sanitizeSessionKey(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) { if (!raw) {
@@ -352,15 +367,26 @@ function routineBashMsg() {
].join('\n'); ].join('\n');
} }
function withRecoveryHint(message, hookIds = [EDIT_WRITE_HOOK_ID]) {
const disableTargets = hookIds.map(hookId => `\`${hookId}\``).join(' or ');
return [
message,
'',
`Recovery: if GateGuard is blocking setup or repair work, run this session with \`ECC_GATEGUARD=off\` or add ${disableTargets} to \`ECC_DISABLED_HOOKS\`.`
].join('\n');
}
// --- Deny helper --- // --- Deny helper ---
function denyResult(reason) { function denyResult(reason, options = {}) {
const includeRecoveryHint = options.includeRecoveryHint !== false;
const hookIds = Array.isArray(options.hookIds) && options.hookIds.length > 0 ? options.hookIds : [EDIT_WRITE_HOOK_ID];
return { return {
stdout: JSON.stringify({ stdout: JSON.stringify({
hookSpecificOutput: { hookSpecificOutput: {
hookEventName: 'PreToolUse', hookEventName: 'PreToolUse',
permissionDecision: 'deny', permissionDecision: 'deny',
permissionDecisionReason: reason permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
} }
}), }),
exitCode: 0 exitCode: 0
@@ -383,6 +409,11 @@ function run(rawInput) {
} catch (_) { } catch (_) {
return rawInput; // allow on parse error return rawInput; // allow on parse error
} }
if (isGateGuardDisabled()) {
return rawInput;
}
activeStateFile = null; activeStateFile = null;
getStateFile(data); getStateFile(data);
@@ -435,7 +466,7 @@ function run(rawInput) {
if (!markChecked(key)) { if (!markChecked(key)) {
return allowWithStateWarning(); return allowWithStateWarning();
} }
return denyResult(destructiveBashMsg()); return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
} }
return rawInput; // allow retry after facts presented return rawInput; // allow retry after facts presented
} }
@@ -444,7 +475,7 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) { if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning(); return allowWithStateWarning();
} }
return denyResult(routineBashMsg()); return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
} }
return rawInput; // allow return rawInput; // allow

View File

@@ -4,6 +4,7 @@
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60; const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10; const DEFAULT_LIMIT = 10;
@@ -24,9 +25,11 @@ function usage() {
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)', ' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)', ' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
' --now <time> Override current time (ISO, epoch ms, or "now")', ' --now <time> Override current time (ISO, epoch ms, or "now")',
' --exit-code Exit 2 on attention signals, 1 on scan errors',
' --watch Refresh status until interrupted', ' --watch Refresh status until interrupted',
' --watch-count <n> Stop after n watch refreshes', ' --watch-count <n> Stop after n watch refreshes',
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)', ' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
' --write-dir <dir> Write index.json and per-session status snapshots',
'', '',
'Examples:', 'Examples:',
' node scripts/loop-status.js --json', ' node scripts/loop-status.js --json',
@@ -62,6 +65,7 @@ function parseArgs(argv) {
const args = argv.slice(2); const args = argv.slice(2);
const options = { const options = {
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS, bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: false,
home: null, home: null,
json: false, json: false,
limit: DEFAULT_LIMIT, limit: DEFAULT_LIMIT,
@@ -72,6 +76,7 @@ function parseArgs(argv) {
watchCount: null, watchCount: null,
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER, wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS, watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: null,
}; };
for (let index = 0; index < args.length; index += 1) { for (let index = 0; index < args.length; index += 1) {
@@ -99,6 +104,8 @@ function parseArgs(argv) {
} else if (arg === '--now') { } else if (arg === '--now') {
options.now = readValue(args, index, arg); options.now = readValue(args, index, arg);
index += 1; index += 1;
} else if (arg === '--exit-code') {
options.exitCode = true;
} else if (arg === '--watch') { } else if (arg === '--watch') {
options.watch = true; options.watch = true;
} else if (arg === '--watch-count') { } else if (arg === '--watch-count') {
@@ -107,11 +114,18 @@ function parseArgs(argv) {
} else if (arg === '--watch-interval-seconds') { } else if (arg === '--watch-interval-seconds') {
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg); options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1; index += 1;
} else if (arg === '--write-dir') {
options.writeDir = readValue(args, index, arg);
index += 1;
} else { } else {
throw new Error(`Unknown option: ${arg}`); throw new Error(`Unknown option: ${arg}`);
} }
} }
if (options.exitCode && options.watch && options.watchCount === null) {
throw new Error('--exit-code with --watch requires --watch-count so the process can exit');
}
return options; return options;
} }
@@ -119,12 +133,14 @@ function normalizeOptions(options = {}) {
return { return {
...options, ...options,
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS, bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: Boolean(options.exitCode),
limit: options.limit ?? DEFAULT_LIMIT, limit: options.limit ?? DEFAULT_LIMIT,
transcriptPaths: options.transcriptPaths || [], transcriptPaths: options.transcriptPaths || [],
watch: Boolean(options.watch), watch: Boolean(options.watch),
watchCount: options.watchCount ?? null, watchCount: options.watchCount ?? null,
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER, wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS, watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: options.writeDir || null,
}; };
} }
@@ -594,6 +610,126 @@ function formatText(payload) {
return lines.join('\n'); return lines.join('\n');
} }
function hashString(value) {
return crypto.createHash('sha256').update(String(value)).digest('hex');
}
function isWindowsReservedBasename(value) {
const basename = String(value).split('.')[0];
return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(basename);
}
function sanitizeSnapshotName(value, fallback = 'session') {
const raw = String(value || '').trim() || fallback;
const sanitized = raw.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '');
if (sanitized && sanitized.length <= 96 && !isWindowsReservedBasename(sanitized)) {
return sanitized;
}
if (sanitized && isWindowsReservedBasename(sanitized)) {
const firstDotIndex = sanitized.indexOf('.');
const hashSuffix = hashString(raw).slice(0, 8);
if (firstDotIndex === -1) {
return `${sanitized}-${hashSuffix}`;
}
return `${sanitized.slice(0, firstDotIndex)}-${hashSuffix}${sanitized.slice(firstDotIndex)}`;
}
const prefix = sanitized ? sanitized.slice(0, 48).replace(/[._-]+$/g, '') : fallback;
return `${prefix || fallback}-${hashString(raw).slice(0, 12)}`;
}
function atomicWriteJson(filePath, payload) {
const data = JSON.stringify(payload, null, 2) + '\n';
const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
fs.writeFileSync(tempPath, data, 'utf8');
try {
fs.renameSync(tempPath, filePath);
} catch (error) {
try {
fs.unlinkSync(tempPath);
} catch (cleanupError) {
if (cleanupError.code !== 'ENOENT') {
console.error(`[loop-status] WARNING: could not remove temporary snapshot file ${tempPath}: ${cleanupError.message}`);
}
}
throw error;
}
}
function getSnapshotPath(outputDir, session, usedNames) {
const baseName = sanitizeSnapshotName(session.sessionId);
const hashSuffix = hashString(session.transcriptPath || session.sessionId).slice(0, 8);
let attempt = 0;
while (attempt < 1000) {
const suffix = attempt === 0 ? '' : `-${hashSuffix}${attempt === 1 ? '' : `-${attempt}`}`;
const fileName = `${baseName}${suffix}.json`;
if (!usedNames.has(fileName)) {
usedNames.add(fileName);
return path.join(outputDir, fileName);
}
attempt += 1;
}
throw new Error(`Could not allocate a snapshot filename for session ${session.sessionId}`);
}
function writeStatusSnapshots(payload, writeDir) {
if (!writeDir) {
return null;
}
const outputDir = path.resolve(writeDir);
fs.mkdirSync(outputDir, { recursive: true });
const usedNames = new Set(['index.json']);
const sessions = payload.sessions.map(session => {
const snapshotPath = getSnapshotPath(outputDir, session, usedNames);
atomicWriteJson(snapshotPath, {
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.session.v1',
session,
});
return {
lastEventAt: session.lastEventAt,
sessionId: session.sessionId,
signalTypes: session.signals.map(signal => signal.type),
snapshotPath,
state: session.state,
transcriptPath: session.transcriptPath,
};
});
const indexPath = path.join(outputDir, 'index.json');
atomicWriteJson(indexPath, {
errors: payload.errors,
generatedAt: payload.generatedAt,
schemaVersion: 'ecc.loop-status.index.v1',
sessionCount: payload.sessions.length,
sessions,
source: payload.source,
});
return {
indexPath,
sessionCount: payload.sessions.length,
};
}
function tryWriteStatusSnapshots(payload, options) {
if (!options.writeDir) {
return null;
}
try {
return writeStatusSnapshots(payload, options.writeDir);
} catch (error) {
console.error(`[loop-status] WARNING: could not write status snapshots: ${error.message}`);
return null;
}
}
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
@@ -606,15 +742,29 @@ function writeStatus(payload, options) {
} }
} }
function getStatusExitCode(payload) {
if (payload.sessions.some(session => session.state === 'attention')) {
return 2;
}
if (payload.errors.length > 0) {
return 1;
}
return 0;
}
async function runWatch(options) { async function runWatch(options) {
const normalizedOptions = normalizeOptions(options); const normalizedOptions = normalizeOptions(options);
let iteration = 0; let iteration = 0;
let exitCode = 0;
while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) { while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) {
if (iteration > 0 && !normalizedOptions.json) { if (iteration > 0 && !normalizedOptions.json) {
console.log(''); console.log('');
} }
writeStatus(buildStatus(normalizedOptions), normalizedOptions); const payload = buildStatus(normalizedOptions);
tryWriteStatusSnapshots(payload, normalizedOptions);
writeStatus(payload, normalizedOptions);
exitCode = Math.max(exitCode, getStatusExitCode(payload));
iteration += 1; iteration += 1;
if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) { if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) {
@@ -623,6 +773,8 @@ async function runWatch(options) {
await sleep(normalizedOptions.watchIntervalSeconds * 1000); await sleep(normalizedOptions.watchIntervalSeconds * 1000);
} }
return exitCode;
} }
async function main() { async function main() {
@@ -633,11 +785,19 @@ async function main() {
} }
if (options.watch) { if (options.watch) {
await runWatch(options); const exitCode = await runWatch(options);
if (options.exitCode) {
process.exitCode = exitCode;
}
return; return;
} }
writeStatus(buildStatus(options), options); const payload = buildStatus(options);
tryWriteStatusSnapshots(payload, options);
writeStatus(payload, options);
if (options.exitCode) {
process.exitCode = getStatusExitCode(payload);
}
} }
if (require.main === module) { if (require.main === module) {
@@ -652,6 +812,9 @@ module.exports = {
buildStatus, buildStatus,
extractToolResultIds, extractToolResultIds,
extractToolUses, extractToolUses,
getStatusExitCode,
parseArgs, parseArgs,
runWatch, runWatch,
tryWriteStatusSnapshots,
writeStatusSnapshots,
}; };

View File

@@ -94,6 +94,10 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json. The hook at `scripts/hooks/gateguard-fact-force.js` is included in this plugin. Enable it via hooks.json.
If GateGuard blocks setup or repair work, start the session with
`ECC_GATEGUARD=off`. For hook-level control, keep using
`ECC_DISABLED_HOOKS` with the GateGuard hook ID.
### Option B: Full package with config ### Option B: Full package with config
```bash ```bash

View File

@@ -408,7 +408,104 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
// --- Test 10: MultiEdit gates first unchecked file --- // --- Test 10: respects direct GateGuard env disable for recovery sessions ---
clearState();
if (test('respects ECC_GATEGUARD=off without writing gate state', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/env-disabled.js', content: 'export const ok = true;' }
};
const result = runHook(input, { ECC_GATEGUARD: 'off' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.tool_name, 'Write', 'disabled gate should pass through raw input');
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny the operation');
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
})) passed++; else failed++;
// --- Test 11: respects legacy GATEGUARD_DISABLED env disable ---
clearState();
if (test('respects GATEGUARD_DISABLED=1 for Bash recovery', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input, { GATEGUARD_DISABLED: '1' });
const output = parseOutput(result.stdout);
assert.ok(output, 'should produce valid JSON output');
assert.strictEqual(output.tool_name, 'Bash', 'disabled gate should pass Bash through raw input');
assert.ok(!output.hookSpecificOutput, 'disabled gate should not deny Bash');
assert.ok(!fs.existsSync(stateFile), 'disabled gate should not create or mutate gate state');
})) passed++; else failed++;
// --- Test 12: legacy GATEGUARD_DISABLED compatibility is scoped to =1 ---
clearState();
if (test('does not treat GATEGUARD_DISABLED=true as a disable flag', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input, { GATEGUARD_DISABLED: 'true' });
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('current user request'));
})) passed++; else failed++;
// --- Test 13: denial messages show an escape hatch ---
clearState();
if (test('denial messages include direct recovery escape hatch', () => {
const input = {
tool_name: 'Write',
tool_input: { file_path: '/src/recovery-hint.js', content: 'export const ok = true;' }
};
const result = runHook(input);
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
'denial reason should show the direct recovery env toggle');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('ECC_DISABLED_HOOKS'),
'denial reason should mention the existing hook-id disable control');
})) passed++; else failed++;
// --- Test 14: routine Bash denial messages show the Bash hook escape hatch ---
clearState();
if (test('routine Bash denials include Bash hook disable id', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'npm test' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
const reason = output.hookSpecificOutput.permissionDecisionReason;
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(reason.includes('pre:bash:gateguard-fact-force'),
'routine Bash denial should show the Bash hook ID');
assert.ok(!reason.includes('pre:edit-write:gateguard-fact-force'),
'routine Bash denial should not show the Edit/Write hook ID as the targeted disable');
})) passed++; else failed++;
// --- Test 15: destructive Bash denials do not advertise the recovery escape hatch ---
clearState();
if (test('destructive Bash denials omit recovery escape hatch', () => {
const input = {
tool_name: 'Bash',
tool_input: { command: 'rm -rf /tmp/demo' }
};
const result = runBashHook(input);
const output = parseOutput(result.stdout);
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('Destructive command detected'));
assert.ok(!output.hookSpecificOutput.permissionDecisionReason.includes('ECC_GATEGUARD=off'),
'destructive gate should not advertise disabling GateGuard');
})) passed++; else failed++;
// --- Test 16: MultiEdit gates first unchecked file ---
clearState(); clearState();
if (test('denies first MultiEdit with unchecked file', () => { if (test('denies first MultiEdit with unchecked file', () => {
const input = { const input = {

View File

@@ -6,10 +6,16 @@ const assert = require('assert');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { execFileSync } = require('child_process'); const { spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
const { analyzeTranscript, buildStatus, parseArgs } = require('../../scripts/loop-status'); const {
analyzeTranscript,
buildStatus,
getStatusExitCode,
parseArgs,
writeStatusSnapshots,
} = require('../../scripts/loop-status');
const NOW = '2026-04-30T10:00:00.000Z'; const NOW = '2026-04-30T10:00:00.000Z';
function run(args = [], options = {}) { function run(args = [], options = {}) {
@@ -25,25 +31,22 @@ function run(args = [], options = {}) {
envOverrides.HOME = envOverrides.USERPROFILE; envOverrides.HOME = envOverrides.USERPROFILE;
} }
try { const result = spawnSync('node', [SCRIPT, ...args], {
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8',
encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000,
timeout: 10000, cwd: options.cwd || process.cwd(),
cwd: options.cwd || process.cwd(), env: {
env: { ...process.env,
...process.env, ...envOverrides,
...envOverrides, },
}, });
});
return { code: 0, stdout, stderr: '' }; return {
} catch (error) { code: result.status || (result.signal ? 1 : 0),
return { stdout: result.stdout || '',
code: error.status || 1, stderr: result.stderr || '',
stdout: error.stdout || '', };
stderr: error.stderr || '',
};
}
} }
function createTempHome() { function createTempHome() {
@@ -400,6 +403,7 @@ function runTests() {
const options = parseArgs([ const options = parseArgs([
'node', 'node',
'scripts/loop-status.js', 'scripts/loop-status.js',
'--exit-code',
'--watch', '--watch',
'--watch-count', '--watch-count',
'2', '2',
@@ -407,11 +411,74 @@ function runTests() {
'0.01', '0.01',
]); ]);
assert.strictEqual(options.exitCode, true);
assert.strictEqual(options.watch, true); assert.strictEqual(options.watch, true);
assert.strictEqual(options.watchCount, 2); assert.strictEqual(options.watchCount, 2);
assert.strictEqual(options.watchIntervalSeconds, 0.01); assert.strictEqual(options.watchIntervalSeconds, 0.01);
})) passed++; else failed++; })) passed++; else failed++;
if (test('parses write-dir snapshot option', () => {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--write-dir',
'/tmp/ecc-loop-snapshots',
]);
assert.strictEqual(options.writeDir, '/tmp/ecc-loop-snapshots');
})) passed++; else failed++;
if (test('exit-code mode returns 2 when attention signals are present', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-exit-code', 'session-exit-code.jsonl', [
toolUse('2026-04-30T09:10:00.000Z', 'session-exit-code', 'toolu_exit_bash', 'Bash', {
command: 'pytest tests/integration/test_pipeline.py',
}),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json', '--exit-code']);
assert.strictEqual(result.code, 2, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions[0].state, 'attention');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('exit-code mode returns 1 for scan errors without attention signals', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-missing-'));
const missingTranscript = path.join(tempDir, 'missing.jsonl');
const result = run(['--transcript', missingTranscript, '--now', NOW, '--json', '--exit-code']);
try {
assert.strictEqual(result.code, 1, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions.length, 0);
assert.strictEqual(payload.errors.length, 1);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('exit-code mode rejects unbounded watch mode', () => {
const result = run(['--watch', '--exit-code']);
assert.strictEqual(result.code, 1);
assert.match(result.stderr, /--exit-code with --watch requires --watch-count/);
})) passed++; else failed++;
if (test('getStatusExitCode prioritizes attention signals over scan errors', () => {
const payload = {
errors: [{ message: 'unreadable' }],
sessions: [{ state: 'attention' }],
};
assert.strictEqual(getStatusExitCode(payload), 2);
})) passed++; else failed++;
if (test('watch mode emits repeated JSON status frames', () => { if (test('watch mode emits repeated JSON status frames', () => {
const homeDir = createTempHome(); const homeDir = createTempHome();
@@ -448,6 +515,233 @@ function runTests() {
} }
})) passed++; else failed++; })) passed++; else failed++;
if (test('watch mode honors exit-code after bounded refreshes', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-watch-exit', 'session-watch-exit.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-watch-exit', 'toolu_watch_exit', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--watch',
'--watch-count',
'1',
'--watch-interval-seconds',
'0.01',
'--exit-code',
]);
assert.strictEqual(result.code, 2, result.stderr);
const frame = JSON.parse(result.stdout.trim());
assert.strictEqual(frame.sessions[0].state, 'attention');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('writes per-session status snapshots and index when write-dir is set', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-snapshots-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-snapshot', 'session-snapshot.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-snapshot', 'toolu_snapshot', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const stdoutPayload = parsePayload(result.stdout);
assert.strictEqual(stdoutPayload.schemaVersion, 'ecc.loop-status.v1');
const indexPath = path.join(snapshotDir, 'index.json');
const snapshotPath = path.join(snapshotDir, 'session-snapshot.json');
assert.ok(fs.existsSync(indexPath), 'write-dir should include an index.json file');
assert.ok(fs.existsSync(snapshotPath), 'write-dir should include a per-session snapshot');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
assert.strictEqual(indexPayload.sessions.length, 1);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'session-snapshot');
assert.strictEqual(indexPayload.sessions[0].state, 'attention');
assert.strictEqual(indexPayload.sessions[0].snapshotPath, snapshotPath);
const snapshotPayload = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.generatedAt, NOW);
assert.strictEqual(snapshotPayload.session.sessionId, 'session-snapshot');
assert.ok(snapshotPayload.session.signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('keeps index.json reserved when session id sanitizes to index', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-index-collision-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-index-collision', 'index.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'index', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const indexPath = path.join(snapshotDir, 'index.json');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.schemaVersion, 'ecc.loop-status.index.v1');
assert.strictEqual(indexPayload.sessions.length, 1);
assert.strictEqual(indexPayload.sessions[0].sessionId, 'index');
assert.notStrictEqual(indexPayload.sessions[0].snapshotPath, indexPath);
const snapshotPayload = JSON.parse(fs.readFileSync(indexPayload.sessions[0].snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.session.sessionId, 'index');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('avoids Windows reserved basenames for session snapshots', () => {
const homeDir = createTempHome();
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-windows-name-'));
try {
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'con', 'Loop checkpoint.'),
]);
writeTranscript(homeDir, '-Users-affoon-project-windows-name', 'con-txt.jsonl', [
assistantMessage('2026-04-30T09:56:00.000Z', 'con.txt', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
snapshotDir,
]);
assert.strictEqual(result.code, 0, result.stderr);
const indexPath = path.join(snapshotDir, 'index.json');
const indexPayload = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
assert.strictEqual(indexPayload.sessions.length, 2);
for (const sessionIndex of indexPayload.sessions) {
const snapshotName = path.basename(sessionIndex.snapshotPath);
assert.notStrictEqual(snapshotName.toLowerCase(), `${sessionIndex.sessionId}.json`);
assert.ok(!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(snapshotName.split('.')[0]));
const snapshotPayload = JSON.parse(fs.readFileSync(sessionIndex.snapshotPath, 'utf8'));
assert.strictEqual(snapshotPayload.schemaVersion, 'ecc.loop-status.session.v1');
assert.strictEqual(snapshotPayload.session.sessionId, sessionIndex.sessionId);
}
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('cleans temporary snapshot files when atomic rename fails', () => {
const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-rename-failure-'));
const originalRenameSync = fs.renameSync;
try {
fs.renameSync = () => {
throw new Error('simulated rename failure');
};
assert.throws(() => writeStatusSnapshots({
errors: [],
generatedAt: NOW,
sessions: [
{
eventCount: 1,
lastEventAt: NOW,
pendingTools: [],
recommendedAction: 'No action needed.',
sessionId: 'rename-failure',
signals: [],
state: 'ok',
transcriptPath: path.join(snapshotDir, 'rename-failure.jsonl'),
},
],
source: {},
}, snapshotDir), /simulated rename failure/);
const tempFiles = fs.readdirSync(snapshotDir).filter(fileName => fileName.endsWith('.tmp'));
assert.deepStrictEqual(tempFiles, []);
} finally {
fs.renameSync = originalRenameSync;
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('write-dir failures do not suppress normal stdout', () => {
const homeDir = createTempHome();
try {
const blockedPath = path.join(homeDir, 'snapshot-target-is-a-file');
fs.writeFileSync(blockedPath, 'not a directory\n', 'utf8');
writeTranscript(homeDir, '-Users-affoon-project-write-error', 'session-write-error.jsonl', [
assistantMessage('2026-04-30T09:55:00.000Z', 'session-write-error', 'Loop checkpoint.'),
]);
const result = run([
'--home',
homeDir,
'--now',
NOW,
'--json',
'--write-dir',
blockedPath,
]);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(payload.sessions[0].sessionId, 'session-write-error');
assert.match(result.stderr, /\[loop-status\] WARNING: could not write status snapshots:/);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0); process.exit(failed > 0 ? 1 : 0);
} }