feat: add loop status transcript inspector

This commit is contained in:
Affaan Mustafa
2026-04-30 05:27:53 -04:00
committed by Affaan Mustafa
parent 2fd8dfc7e1
commit b8452dc108
7 changed files with 853 additions and 0 deletions

View File

@@ -6,6 +6,18 @@ description: Inspect active loop state, progress, failure signals, and recommend
Inspect active loop state, progress, and failure signals. Inspect active loop state, progress, and failure signals.
This slash command can only run after the current session dequeues it. If you
need to inspect a wedged or sibling session, run the packaged CLI from another
terminal:
```bash
npx --package ecc-universal ecc loop-status --json
```
The CLI scans local Claude transcript JSONL files under
`~/.claude/projects/**` and reports stale `ScheduleWakeup` calls or `Bash`
tool calls that have no matching `tool_result`.
## Usage ## Usage
`/loop-status [--watch]` `/loop-status [--watch]`
@@ -18,6 +30,15 @@ Inspect active loop state, progress, and failure signals.
- estimated time/cost drift - estimated time/cost drift
- recommended intervention (continue/pause/stop) - recommended intervention (continue/pause/stop)
## Cross-Session CLI
- `ecc loop-status --json` emits machine-readable status for recent local
Claude transcripts.
- `ecc loop-status --transcript <session.jsonl>` inspects one transcript
directly.
- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash
threshold.
## Watch Mode ## Watch Mode
When `--watch` is present, refresh status periodically and surface state changes. When `--watch` is present, refresh status periodically and surface state changes.

View File

@@ -75,6 +75,7 @@
"scripts/install-plan.js", "scripts/install-plan.js",
"scripts/lib/", "scripts/lib/",
"scripts/list-installed.js", "scripts/list-installed.js",
"scripts/loop-status.js",
"scripts/orchestration-status.js", "scripts/orchestration-status.js",
"scripts/orchestrate-codex-worker.sh", "scripts/orchestrate-codex-worker.sh",
"scripts/orchestrate-worktrees.js", "scripts/orchestrate-worktrees.js",

View File

@@ -49,6 +49,10 @@ const COMMANDS = {
script: 'session-inspect.js', script: 'session-inspect.js',
description: 'Emit canonical ECC session snapshots from dmux or Claude history targets', description: 'Emit canonical ECC session snapshots from dmux or Claude history targets',
}, },
'loop-status': {
script: 'loop-status.js',
description: 'Inspect Claude transcripts for stale loop wakeups and pending tool results',
},
uninstall: { uninstall: {
script: 'uninstall.js', script: 'uninstall.js',
description: 'Remove ECC-managed files recorded in install-state', description: 'Remove ECC-managed files recorded in install-state',
@@ -66,6 +70,7 @@ const PRIMARY_COMMANDS = [
'status', 'status',
'sessions', 'sessions',
'session-inspect', 'session-inspect',
'loop-status',
'uninstall', 'uninstall',
]; ];
@@ -100,6 +105,7 @@ Examples:
ecc sessions ecc sessions
ecc sessions session-active --json ecc sessions session-active --json
ecc session-inspect claude:latest ecc session-inspect claude:latest
ecc loop-status --json
ecc uninstall --target antigravity --dry-run ecc uninstall --target antigravity --dry-run
`); `);

506
scripts/loop-status.js Normal file
View File

@@ -0,0 +1,506 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const DEFAULT_BASH_TIMEOUT_SECONDS = 30 * 60;
const DEFAULT_LIMIT = 10;
const DEFAULT_WAKE_GRACE_MULTIPLIER = 2;
function usage() {
console.log([
'Usage:',
' node scripts/loop-status.js [--json] [--home <dir>] [--limit <n>]',
' node scripts/loop-status.js --transcript <session.jsonl> [--json]',
'',
'Options:',
' --json Emit machine-readable status JSON',
' --home <dir> Override the home directory to scan',
' --transcript <session.jsonl> Inspect one transcript directly',
' --limit <n> Maximum recent transcripts to inspect (default: 10)',
' --bash-timeout-seconds <n> Age before a pending Bash call is stale (default: 1800)',
' --wake-grace-multiplier <n> ScheduleWakeup grace multiplier (default: 2)',
'',
'Examples:',
' node scripts/loop-status.js --json',
' node scripts/loop-status.js --transcript ~/.claude/projects/-repo/session.jsonl'
].join('\n'));
}
function readValue(args, index, flagName) {
const value = args[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flagName} requires a value`);
}
return value;
}
function readPositiveNumber(value, flagName) {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
throw new Error(`${flagName} must be a positive number`);
}
return number;
}
function parseArgs(argv) {
const args = argv.slice(2);
const options = {
bashTimeoutSeconds: DEFAULT_BASH_TIMEOUT_SECONDS,
home: null,
json: false,
limit: DEFAULT_LIMIT,
now: null,
showHelp: false,
transcriptPaths: [],
wakeGraceMultiplier: DEFAULT_WAKE_GRACE_MULTIPLIER,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') {
options.showHelp = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--home') {
options.home = readValue(args, index, arg);
index += 1;
} else if (arg === '--transcript') {
options.transcriptPaths.push(readValue(args, index, arg));
index += 1;
} else if (arg === '--limit') {
options.limit = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--bash-timeout-seconds') {
options.bashTimeoutSeconds = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--wake-grace-multiplier') {
options.wakeGraceMultiplier = readPositiveNumber(readValue(args, index, arg), arg);
index += 1;
} else if (arg === '--now') {
options.now = readValue(args, index, arg);
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
return options;
}
function getHomeDir(options = {}) {
if (options.home) {
return path.resolve(options.home);
}
return process.env.HOME || process.env.USERPROFILE || os.homedir();
}
function getNow(options = {}) {
if (!options.now) {
return new Date();
}
const now = new Date(options.now);
if (Number.isNaN(now.getTime())) {
throw new Error('--now must be a valid timestamp');
}
return now;
}
function walkJsonlFiles(dir, files = []) {
if (!fs.existsSync(dir)) {
return files;
}
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkJsonlFiles(fullPath, files);
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
files.push(fullPath);
}
}
return files;
}
function findTranscriptPaths(options = {}) {
if (options.transcriptPaths && options.transcriptPaths.length > 0) {
return options.transcriptPaths.map(transcriptPath => path.resolve(transcriptPath));
}
const homeDir = getHomeDir(options);
const transcriptRoot = path.join(homeDir, '.claude', 'projects');
return walkJsonlFiles(transcriptRoot)
.map(transcriptPath => ({
transcriptPath,
mtimeMs: fs.statSync(transcriptPath).mtimeMs,
}))
.sort((left, right) => right.mtimeMs - left.mtimeMs)
.slice(0, options.limit)
.map(entry => entry.transcriptPath);
}
function parseTimestamp(value) {
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
return date;
}
function getEntryTimestamp(entry) {
return parseTimestamp(entry.timestamp)
|| parseTimestamp(entry.createdAt)
|| parseTimestamp(entry.created_at)
|| parseTimestamp(entry.message && entry.message.timestamp);
}
function getSessionId(entry, transcriptPath) {
return entry.sessionId
|| entry.session_id
|| (entry.session && entry.session.id)
|| (entry.message && entry.message.sessionId)
|| path.basename(transcriptPath, '.jsonl');
}
function getContentBlocks(entry) {
const blocks = [];
if (entry.message && Array.isArray(entry.message.content)) {
blocks.push(...entry.message.content);
}
if (Array.isArray(entry.content)) {
blocks.push(...entry.content);
}
return blocks;
}
function extractToolUses(entry) {
const uses = [];
for (const block of getContentBlocks(entry)) {
if (block && block.type === 'tool_use' && block.id) {
uses.push({
id: block.id,
input: block.input || {},
name: block.name || 'unknown',
});
}
}
const topLevelUse = entry.tool_use || entry.toolUse;
if (topLevelUse && topLevelUse.id) {
uses.push({
id: topLevelUse.id,
input: topLevelUse.input || {},
name: topLevelUse.name || 'unknown',
});
}
if (entry.type === 'tool_use' && entry.id) {
uses.push({
id: entry.id,
input: entry.input || {},
name: entry.name || 'unknown',
});
}
return uses;
}
function extractToolResultIds(entry) {
const resultIds = [];
for (const block of getContentBlocks(entry)) {
if (block && block.type === 'tool_result') {
const toolUseId = block.tool_use_id || block.toolUseId || block.id;
if (toolUseId) {
resultIds.push(toolUseId);
}
}
}
const topLevelResult = entry.tool_result || entry.toolResult || entry.toolUseResult;
if (topLevelResult) {
const toolUseId = topLevelResult.tool_use_id || topLevelResult.toolUseId || topLevelResult.id;
if (toolUseId) {
resultIds.push(toolUseId);
}
}
if (entry.type === 'tool_result') {
const toolUseId = entry.tool_use_id || entry.toolUseId || entry.id;
if (toolUseId) {
resultIds.push(toolUseId);
}
}
return resultIds;
}
function isAssistantProgressEntry(entry) {
return entry.type === 'assistant'
|| (entry.message && entry.message.role === 'assistant')
|| extractToolUses(entry).length > 0;
}
function readJsonlEntries(transcriptPath) {
const raw = fs.readFileSync(transcriptPath, 'utf8');
const entries = [];
let parseErrors = 0;
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
entries.push(JSON.parse(line));
} catch (_error) {
parseErrors += 1;
}
}
return { entries, parseErrors };
}
function readDelaySeconds(input) {
const delay = input && (
input.delaySeconds
|| input.delay_seconds
|| input.seconds
|| input.delay
);
const number = Number(delay);
if (!Number.isFinite(number) || number <= 0) {
return null;
}
return number;
}
function toIso(date) {
return date ? date.toISOString() : null;
}
function buildRecommendation(signals) {
if (signals.some(signal => signal.type === 'pending_bash_tool_result')) {
return 'Open the transcript or interrupt the parked session; the Bash result appears stale.';
}
if (signals.some(signal => signal.type === 'schedule_wakeup_overdue')) {
return 'Open the transcript or interrupt the parked session; the scheduled wake is overdue.';
}
return 'No stale ScheduleWakeup or Bash waits detected.';
}
function analyzeTranscript(transcriptPath, options = {}) {
const absoluteTranscriptPath = path.resolve(transcriptPath);
const now = options.nowDate || getNow(options);
const nowMs = now.getTime();
const { entries, parseErrors } = readJsonlEntries(absoluteTranscriptPath);
const pendingTools = new Map();
let latestAssistantProgressAt = null;
let lastEventAt = null;
let latestWake = null;
let sessionId = path.basename(absoluteTranscriptPath, '.jsonl');
for (const entry of entries) {
sessionId = getSessionId(entry, absoluteTranscriptPath) || sessionId;
const timestamp = getEntryTimestamp(entry);
if (timestamp && (!lastEventAt || timestamp.getTime() > lastEventAt.getTime())) {
lastEventAt = timestamp;
}
if (
timestamp
&& isAssistantProgressEntry(entry)
&& (!latestAssistantProgressAt || timestamp.getTime() > latestAssistantProgressAt.getTime())
) {
latestAssistantProgressAt = timestamp;
}
for (const toolUse of extractToolUses(entry)) {
const startedAt = timestamp || lastEventAt;
pendingTools.set(toolUse.id, {
command: toolUse.input && toolUse.input.command ? String(toolUse.input.command) : null,
input: toolUse.input || {},
name: toolUse.name,
startedAt: toIso(startedAt),
toolUseId: toolUse.id,
});
if (toolUse.name === 'ScheduleWakeup') {
const delaySeconds = readDelaySeconds(toolUse.input);
if (delaySeconds && startedAt) {
const dueAt = new Date(startedAt.getTime() + delaySeconds * 1000);
latestWake = {
delaySeconds,
dueAt: dueAt.toISOString(),
reason: toolUse.input && toolUse.input.reason ? String(toolUse.input.reason) : null,
scheduledAt: startedAt.toISOString(),
toolUseId: toolUse.id,
};
}
}
}
for (const toolUseId of extractToolResultIds(entry)) {
pendingTools.delete(toolUseId);
}
}
const pendingToolList = Array.from(pendingTools.values()).map(tool => {
const startedAt = parseTimestamp(tool.startedAt);
return {
...tool,
ageSeconds: startedAt ? Math.max(0, Math.floor((nowMs - startedAt.getTime()) / 1000)) : null,
};
});
const signals = [];
if (latestWake) {
const scheduledAt = parseTimestamp(latestWake.scheduledAt);
const dueAt = parseTimestamp(latestWake.dueAt);
const thresholdMs = scheduledAt
? scheduledAt.getTime() + latestWake.delaySeconds * options.wakeGraceMultiplier * 1000
: null;
const hasAssistantProgressAfterDue = Boolean(
dueAt
&& latestAssistantProgressAt
&& latestAssistantProgressAt.getTime() > dueAt.getTime()
);
if (thresholdMs && nowMs >= thresholdMs && !hasAssistantProgressAfterDue) {
signals.push({
delaySeconds: latestWake.delaySeconds,
dueAt: latestWake.dueAt,
overdueSeconds: dueAt ? Math.max(0, Math.floor((nowMs - dueAt.getTime()) / 1000)) : null,
scheduledAt: latestWake.scheduledAt,
toolUseId: latestWake.toolUseId,
type: 'schedule_wakeup_overdue',
});
}
}
for (const tool of pendingToolList) {
if (tool.name === 'Bash' && tool.ageSeconds !== null && tool.ageSeconds >= options.bashTimeoutSeconds) {
signals.push({
ageSeconds: tool.ageSeconds,
command: tool.command,
startedAt: tool.startedAt,
thresholdSeconds: options.bashTimeoutSeconds,
toolUseId: tool.toolUseId,
type: 'pending_bash_tool_result',
});
}
}
return {
eventCount: entries.length,
lastEventAt: toIso(lastEventAt),
latestWake,
parseErrors,
pendingTools: pendingToolList,
projectSlug: path.basename(path.dirname(absoluteTranscriptPath)),
recommendedAction: buildRecommendation(signals),
sessionId,
signals,
state: signals.length > 0 ? 'attention' : 'ok',
transcriptPath: absoluteTranscriptPath,
};
}
function buildStatus(options = {}) {
const nowDate = getNow(options);
const mergedOptions = {
...options,
nowDate,
};
const homeDir = getHomeDir(options);
const transcriptPaths = findTranscriptPaths(options);
const sessions = transcriptPaths.map(transcriptPath => analyzeTranscript(transcriptPath, mergedOptions));
sessions.sort((left, right) => {
if (left.state !== right.state) {
return left.state === 'attention' ? -1 : 1;
}
return String(right.lastEventAt || '').localeCompare(String(left.lastEventAt || ''));
});
return {
generatedAt: nowDate.toISOString(),
schemaVersion: 'ecc.loop-status.v1',
sessions,
source: {
bashTimeoutSeconds: options.bashTimeoutSeconds,
homeDir,
limit: options.limit,
transcriptCount: transcriptPaths.length,
transcriptRoot: path.join(homeDir, '.claude', 'projects'),
wakeGraceMultiplier: options.wakeGraceMultiplier,
},
};
}
function formatSignals(signals) {
if (signals.length === 0) {
return 'none';
}
return signals.map(signal => signal.type).join(', ');
}
function formatText(payload) {
if (payload.sessions.length === 0) {
return [
`ECC loop status (${payload.generatedAt})`,
`No Claude transcript JSONL files found under ${payload.source.transcriptRoot}.`,
].join('\n');
}
const lines = [`ECC loop status (${payload.generatedAt})`];
for (const session of payload.sessions) {
lines.push(`- ${session.sessionId} [${session.state}] ${session.transcriptPath}`);
lines.push(` last event: ${session.lastEventAt || 'unknown'}; events: ${session.eventCount}`);
lines.push(` signals: ${formatSignals(session.signals)}`);
lines.push(` action: ${session.recommendedAction}`);
}
return lines.join('\n');
}
function main() {
const options = parseArgs(process.argv);
if (options.showHelp) {
usage();
return;
}
const payload = buildStatus(options);
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(formatText(payload));
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(`[loop-status] ${error.message}`);
process.exit(1);
}
}
module.exports = {
analyzeTranscript,
buildStatus,
extractToolResultIds,
extractToolUses,
parseArgs,
};

View File

@@ -69,6 +69,7 @@ function main() {
assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /list-installed/);
assert.match(result.stdout, /doctor/); assert.match(result.stdout, /doctor/);
assert.match(result.stdout, /auto-update/); assert.match(result.stdout, /auto-update/);
assert.match(result.stdout, /loop-status/);
}], }],
['delegates explicit install command', () => { ['delegates explicit install command', () => {
const result = runCli(['install', '--dry-run', '--json', 'typescript']); const result = runCli(['install', '--dry-run', '--json', 'typescript']);
@@ -142,6 +143,36 @@ function main() {
assert.strictEqual(payload.adapterId, 'claude-history'); assert.strictEqual(payload.adapterId, 'claude-history');
assert.strictEqual(payload.workers[0].branch, 'feat/ecc-cli'); assert.strictEqual(payload.workers[0].branch, 'feat/ecc-cli');
}], }],
['delegates loop-status command', () => {
const homeDir = createTempDir('ecc-cli-home-');
const transcriptDir = path.join(homeDir, '.claude', 'projects', '-tmp-ecc');
fs.mkdirSync(transcriptDir, { recursive: true });
fs.writeFileSync(
path.join(transcriptDir, 'session-loop.jsonl'),
JSON.stringify({
timestamp: '2026-04-30T09:00:00.000Z',
sessionId: 'session-loop',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'toolu_loop',
name: 'ScheduleWakeup',
input: { delaySeconds: 300 },
},
],
},
}) + '\n'
);
const result = runCli(['loop-status', '--home', homeDir, '--now', '2026-04-30T10:00:00.000Z', '--json']);
assert.strictEqual(result.status, 0, result.stderr);
const payload = parseJson(result.stdout);
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(payload.sessions[0].sessionId, 'session-loop');
}],
['supports help for a subcommand', () => { ['supports help for a subcommand', () => {
const result = runCli(['help', 'repair']); const result = runCli(['help', 'repair']);
assert.strictEqual(result.status, 0, result.stderr); assert.strictEqual(result.status, 0, result.stderr);

View File

@@ -0,0 +1,287 @@
/**
* Tests for scripts/loop-status.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'loop-status.js');
const NOW = '2026-04-30T10:00:00.000Z';
function run(args = [], options = {}) {
const envOverrides = {
...(options.env || {}),
};
if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) {
envOverrides.USERPROFILE = envOverrides.HOME;
}
if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) {
envOverrides.HOME = envOverrides.USERPROFILE;
}
try {
const stdout = execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 10000,
cwd: options.cwd || process.cwd(),
env: {
...process.env,
...envOverrides,
},
});
return { code: 0, stdout, stderr: '' };
} catch (error) {
return {
code: error.status || 1,
stdout: error.stdout || '',
stderr: error.stderr || '',
};
}
}
function createTempHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-loop-status-home-'));
}
function writeTranscript(homeDir, projectSlug, fileName, entries) {
const transcriptDir = path.join(homeDir, '.claude', 'projects', projectSlug);
fs.mkdirSync(transcriptDir, { recursive: true });
const transcriptPath = path.join(transcriptDir, fileName);
fs.writeFileSync(
transcriptPath,
entries.map(entry => JSON.stringify(entry)).join('\n') + '\n',
'utf8'
);
return transcriptPath;
}
function toolUse(timestamp, sessionId, id, name, input = {}) {
return {
timestamp,
sessionId,
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id,
name,
input,
},
],
},
};
}
function toolResult(timestamp, sessionId, toolUseId, content = 'ok') {
return {
timestamp,
sessionId,
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: toolUseId,
content,
},
],
},
};
}
function assistantMessage(timestamp, sessionId, text) {
return {
timestamp,
sessionId,
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'text',
text,
},
],
},
};
}
function parsePayload(stdout) {
return JSON.parse(stdout.trim());
}
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.error(` ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing loop-status.js ===\n');
let passed = 0;
let failed = 0;
if (test('reports overdue ScheduleWakeup calls from Claude transcripts', () => {
const homeDir = createTempHome();
try {
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-a', 'session-a.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-a', 'toolu_wake', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Iter 15: continue autonomous loop',
}),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.schemaVersion, 'ecc.loop-status.v1');
assert.strictEqual(payload.sessions.length, 1);
assert.strictEqual(payload.sessions[0].sessionId, 'session-a');
assert.strictEqual(payload.sessions[0].transcriptPath, transcriptPath);
assert.strictEqual(payload.sessions[0].state, 'attention');
assert.ok(payload.sessions[0].signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
assert.strictEqual(payload.sessions[0].latestWake.dueAt, '2026-04-30T09:05:00.000Z');
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('reports stale Bash tool_use entries without matching tool_result', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-b', 'session-b.jsonl', [
toolUse('2026-04-30T09:10:00.000Z', 'session-b', 'toolu_bash', 'Bash', {
command: 'pytest tests/integration/test_pipeline.py',
}),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions[0].state, 'attention');
assert.ok(payload.sessions[0].signals.some(signal => (
signal.type === 'pending_bash_tool_result'
&& signal.toolUseId === 'toolu_bash'
&& signal.ageSeconds === 3000
)));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not flag Bash tool_use entries that have a matching tool_result', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-c', 'session-c.jsonl', [
toolUse('2026-04-30T09:40:00.000Z', 'session-c', 'toolu_bash_ok', 'Bash', {
command: 'npm test',
}),
toolResult('2026-04-30T09:41:00.000Z', 'session-c', 'toolu_bash_ok', 'passed'),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions[0].state, 'ok');
assert.deepStrictEqual(payload.sessions[0].signals, []);
assert.deepStrictEqual(payload.sessions[0].pendingTools, []);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('does not flag ScheduleWakeup when later assistant progress exists', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-d', 'session-d.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-d', 'toolu_wake_ok', 'ScheduleWakeup', {
delaySeconds: 300,
reason: 'Loop checkpoint',
}),
assistantMessage('2026-04-30T09:06:00.000Z', 'session-d', 'Wake fired; continuing.'),
]);
const result = run(['--home', homeDir, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions[0].state, 'ok');
assert.ok(!payload.sessions[0].signals.some(signal => signal.type === 'schedule_wakeup_overdue'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('supports inspecting one transcript path directly', () => {
const homeDir = createTempHome();
try {
const transcriptPath = writeTranscript(homeDir, '-Users-affoon-project-e', 'session-e.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-e', 'toolu_direct', 'Bash', {
command: 'sleep 999',
}),
]);
const result = run(['--transcript', transcriptPath, '--now', NOW, '--json']);
assert.strictEqual(result.code, 0, result.stderr);
const payload = parsePayload(result.stdout);
assert.strictEqual(payload.sessions.length, 1);
assert.strictEqual(payload.sessions[0].transcriptPath, transcriptPath);
assert.ok(payload.sessions[0].signals.some(signal => signal.type === 'pending_bash_tool_result'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('prints text output with state and recommended action', () => {
const homeDir = createTempHome();
try {
writeTranscript(homeDir, '-Users-affoon-project-f', 'session-f.jsonl', [
toolUse('2026-04-30T09:00:00.000Z', 'session-f', 'toolu_text', 'ScheduleWakeup', {
delaySeconds: 600,
reason: 'Loop checkpoint',
}),
]);
const result = run(['--home', homeDir, '--now', NOW]);
assert.strictEqual(result.code, 0, result.stderr);
assert.match(result.stdout, /session-f/);
assert.match(result.stdout, /attention/);
assert.match(result.stdout, /schedule_wakeup_overdue/);
assert.match(result.stdout, /Open the transcript or interrupt the parked session/);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View File

@@ -50,6 +50,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/install-apply.js", "scripts/install-apply.js",
"scripts/install-plan.js", "scripts/install-plan.js",
"scripts/list-installed.js", "scripts/list-installed.js",
"scripts/loop-status.js",
"scripts/skill-create-output.js", "scripts/skill-create-output.js",
"scripts/repair.js", "scripts/repair.js",
"scripts/harness-audit.js", "scripts/harness-audit.js",