mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-15 13:23:13 +08:00
Compare commits
12 Commits
99177e81ea
...
841beea45c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841beea45c | ||
|
|
61992f7f5e | ||
|
|
2715315438 | ||
|
|
7627926216 | ||
|
|
20154ddb22 | ||
|
|
bb40978e31 | ||
|
|
7c5452f4fa | ||
|
|
cfe770a735 | ||
|
|
4c8499d509 | ||
|
|
85dfb5e5fc | ||
|
|
7b03a60503 | ||
|
|
fbd441b448 |
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user