mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-05-01 06:23:28 +08:00
feat: add loop status transcript inspector
This commit is contained in:
committed by
Affaan Mustafa
parent
2fd8dfc7e1
commit
b8452dc108
@@ -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 <session.jsonl>` 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
`);
|
||||
|
||||
|
||||
506
scripts/loop-status.js
Normal file
506
scripts/loop-status.js
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
287
tests/scripts/loop-status.test.js
Normal file
287
tests/scripts/loop-status.test.js
Normal 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();
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user