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.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
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 --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
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
@@ -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
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:

View File

@@ -99,6 +99,9 @@ export ECC_HOOK_PROFILE=standard
# Disable specific hook IDs (comma-separated)
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)
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_SESSION_KEYS = 50;
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;
// --- 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) {
const raw = String(value || '').trim();
if (!raw) {
@@ -352,15 +367,26 @@ function routineBashMsg() {
].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 ---
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 {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason
permissionDecisionReason: includeRecoveryHint ? withRecoveryHint(reason, hookIds) : reason
}
}),
exitCode: 0
@@ -383,6 +409,11 @@ function run(rawInput) {
} catch (_) {
return rawInput; // allow on parse error
}
if (isGateGuardDisabled()) {
return rawInput;
}
activeStateFile = null;
getStateFile(data);
@@ -435,7 +466,7 @@ function run(rawInput) {
if (!markChecked(key)) {
return allowWithStateWarning();
}
return denyResult(destructiveBashMsg());
return denyResult(destructiveBashMsg(), { includeRecoveryHint: false });
}
return rawInput; // allow retry after facts presented
}
@@ -444,7 +475,7 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg());
return denyResult(routineBashMsg(), { hookIds: [BASH_HOOK_ID] });
}
return rawInput; // allow

View File

@@ -4,6 +4,7 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const crypto = require('crypto');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10;
@@ -24,9 +25,11 @@ function usage() {
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
' --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-count <n> Stop after n watch refreshes',
' --watch-interval-seconds <n> Seconds between watch refreshes (default: 5)',
' --write-dir <dir> Write index.json and per-session status snapshots',
'',
'Examples:',
' node scripts/loop-status.js --json',
@@ -62,6 +65,7 @@ function parseArgs(argv) {
const args = argv.slice(2);
const options = {
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: false,
home: null,
json: false,
limit: DEFAULT_LIMIT,
@@ -72,6 +76,7 @@ function parseArgs(argv) {
watchCount: null,
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: null,
};
for (let index = 0; index < args.length; index += 1) {
@@ -99,6 +104,8 @@ function parseArgs(argv) {
} else if (arg === '--now') {
options.now = readValue(args, index, arg);
index += 1;
} else if (arg === '--exit-code') {
options.exitCode = true;
} else if (arg === '--watch') {
options.watch = true;
} else if (arg === '--watch-count') {
@@ -107,11 +114,18 @@ function parseArgs(argv) {
} else if (arg === '--watch-interval-seconds') {
options.watchIntervalSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--write-dir') {
options.writeDir = readValue(args, index, arg);
index += 1;
} else {
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;
}
@@ -119,12 +133,14 @@ function normalizeOptions(options = {}) {
return {
...options,
bashTimeoutSeconds: options.bashTimeoutSeconds ?? DEFAULT_BASH_TIMEOUT_SECONDS,
exitCode: Boolean(options.exitCode),
limit: options.limit ?? DEFAULT_LIMIT,
transcriptPaths: options.transcriptPaths || [],
watch: Boolean(options.watch),
watchCount: options.watchCount ?? null,
wakeGraceMultiplier: options.wakeGraceMultiplier ?? DEFAULT_WAKE_GRACE_MULTIPLIER,
watchIntervalSeconds: options.watchIntervalSeconds ?? DEFAULT_WATCH_INTERVAL_SECONDS,
writeDir: options.writeDir || null,
};
}
@@ -594,6 +610,126 @@ function formatText(payload) {
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) {
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) {
const normalizedOptions = normalizeOptions(options);
let iteration = 0;
let exitCode = 0;
while (normalizedOptions.watchCount === null || iteration < normalizedOptions.watchCount) {
if (iteration > 0 && !normalizedOptions.json) {
console.log('');
}
writeStatus(buildStatus(normalizedOptions), normalizedOptions);
const payload = buildStatus(normalizedOptions);
tryWriteStatusSnapshots(payload, normalizedOptions);
writeStatus(payload, normalizedOptions);
exitCode = Math.max(exitCode, getStatusExitCode(payload));
iteration += 1;
if (normalizedOptions.watchCount !== null && iteration >= normalizedOptions.watchCount) {
@@ -623,6 +773,8 @@ async function runWatch(options) {
await sleep(normalizedOptions.watchIntervalSeconds * 1000);
}
return exitCode;
}
async function main() {
@@ -633,11 +785,19 @@ async function main() {
}
if (options.watch) {
await runWatch(options);
const exitCode = await runWatch(options);
if (options.exitCode) {
process.exitCode = exitCode;
}
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) {
@@ -652,6 +812,9 @@ module.exports = {
buildStatus,
extractToolResultIds,
extractToolUses,
getStatusExitCode,
parseArgs,
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.
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
```bash

View File

@@ -408,7 +408,104 @@ function runTests() {
}
})) 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();
if (test('denies first MultiEdit with unchecked file', () => {
const input = {

View File

@@ -6,10 +6,16 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const { spawnSync } = require('child_process');
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';
function run(args = [], options = {}) {
@@ -25,8 +31,7 @@ function run(args = [], options = {}) {
envOverrides.HOME = envOverrides.USERPROFILE;
}
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
const result = spawnSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
@@ -36,15 +41,13 @@ function run(args = [], options = {}) {
...envOverrides,
},
});
return { code: 0, stdout, stderr: '' };
} catch (error) {
return {
code: error.status || 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
code: result.status || (result.signal ? 1 : 0),
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
}
function createTempHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-home-'));
@@ -400,6 +403,7 @@ function runTests() {
const options = parseArgs([
'node',
'scripts/loop-status.js',
'--exit-code',
'--watch',
'--watch-count',
'2',
@@ -407,11 +411,74 @@ function runTests() {
'0.01',
]);
assert.strictEqual(options.exitCode, true);
assert.strictEqual(options.watch, true);
assert.strictEqual(options.watchCount, 2);
assert.strictEqual(options.watchIntervalSeconds, 0.01);
})) 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', () => {
const homeDir = createTempHome();
@@ -448,6 +515,233 @@ function runTests() {
}
})) 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}`);
process.exit(failed > 0 ? 1 : 0);
}