From b8452dc108502141f581383bf389e5fc60df6ea3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 30 Apr 2026 05:27:53 -0400 Subject: [PATCH] feat: add loop status transcript inspector --- commands/loop-status.md | 21 + package.json | 1 + scripts/ecc.js | 6 + scripts/loop-status.js | 506 ++++++++++++++++++++++ tests/scripts/ecc.test.js | 31 ++ tests/scripts/loop-status.test.js | 287 ++++++++++++ tests/scripts/npm-publish-surface.test.js | 1 + 7 files changed, 853 insertions(+) create mode 100644 scripts/loop-status.js create mode 100644 tests/scripts/loop-status.test.js diff --git a/commands/loop-status.md b/commands/loop-status.md index 8ee3f376..f75ab70c 100644 --- a/commands/loop-status.md +++ b/commands/loop-status.md @@ -6,6 +6,18 @@ description: Inspect active loop state, progress, failure signals, and recommend 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 `/loop-status [--watch]` @@ -18,6 +30,15 @@ Inspect active loop state, progress, and failure signals. - estimated time/cost drift - 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 ` inspects one transcript + directly. +- `ecc loop-status --bash-timeout-seconds 1800` adjusts the stale Bash + threshold. + ## Watch Mode When `--watch` is present, refresh status periodically and surface state changes. diff --git a/package.json b/package.json index 1c031e0b..6dd8e336 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "scripts/install-plan.js", "scripts/lib/", "scripts/list-installed.js", + "scripts/loop-status.js", "scripts/orchestration-status.js", "scripts/orchestrate-codex-worker.sh", "scripts/orchestrate-worktrees.js", diff --git a/scripts/ecc.js b/scripts/ecc.js index 44ba6547..a7c745f4 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -49,6 +49,10 @@ const COMMANDS = { script: 'session-inspect.js', 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: { script: 'uninstall.js', description: 'Remove ECC-managed files recorded in install-state', @@ -66,6 +70,7 @@ const PRIMARY_COMMANDS = [ 'status', 'sessions', 'session-inspect', + 'loop-status', 'uninstall', ]; @@ -100,6 +105,7 @@ Examples: ecc sessions ecc sessions session-active --json ecc session-inspect claude:latest + ecc loop-status --json ecc uninstall --target antigravity --dry-run `); diff --git a/scripts/loop-status.js b/scripts/loop-status.js new file mode 100644 index 00000000..014794ee --- /dev/null +++ b/scripts/loop-status.js @@ -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 ] [--limit ]', + ' node scripts/loop-status.js --transcript [--json]', + '', + 'Options:', + ' --json Emit machine-readable status JSON', + ' --home Override the home directory to scan', + ' --transcript Inspect one transcript directly', + ' --limit Maximum recent transcripts to inspect (default: 10)', + ' --bash-timeout-seconds Age before a pending Bash call is stale (default: 1800)', + ' --wake-grace-multiplier 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, +}; diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 27bd987c..f64d1299 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -69,6 +69,7 @@ function main() { assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /doctor/); assert.match(result.stdout, /auto-update/); + assert.match(result.stdout, /loop-status/); }], ['delegates explicit install command', () => { const result = runCli(['install', '--dry-run', '--json', 'typescript']); @@ -142,6 +143,36 @@ function main() { assert.strictEqual(payload.adapterId, 'claude-history'); 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', () => { const result = runCli(['help', 'repair']); assert.strictEqual(result.status, 0, result.stderr); diff --git a/tests/scripts/loop-status.test.js b/tests/scripts/loop-status.test.js new file mode 100644 index 00000000..0c09ed86 --- /dev/null +++ b/tests/scripts/loop-status.test.js @@ -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(); diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index c3c320c6..530f6cf9 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -50,6 +50,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/install-apply.js", "scripts/install-plan.js", "scripts/list-installed.js", + "scripts/loop-status.js", "scripts/skill-create-output.js", "scripts/repair.js", "scripts/harness-audit.js",