Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.13.0",
"version": "3.14.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
2 changes: 1 addition & 1 deletion .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "allium",
"version": "3.13.0",
"version": "3.14.0",
"description": "Velocity through clarity.",
"author": {
"name": "JUXT",
Expand Down
20 changes: 20 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Task|Agent",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/loop-trace.mjs\" pre"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
Expand All @@ -9,6 +20,15 @@
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/allium-check.mjs\""
}
]
},
{
"matcher": "Task|Agent",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/loop-trace.mjs\" post"
}
]
}
]
}
Expand Down
131 changes: 131 additions & 0 deletions hooks/loop-trace.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Records real per-subagent-call timing into the Allium loop's trace.
//
// Registered twice in hooks.json, matched to the subagent tool:
// PreToolUse → node loop-trace.mjs pre (stamp the call's start)
// PostToolUse → node loop-trace.mjs post (write duration on return)
//
// Timing has to be captured outside the model — a subagent call isn't a Bash
// call the model can wrap in `date`, and the model can't read its own latency.
// A hook fires on the tool events, so it can. It writes one line per call to
// .allium-loop/timings.jsonl; the loop folds those durations into its trace and
// report (driving-the-loop §13). The model's trajectory + routing telemetry is
// the cross-harness baseline; this hook is the deterministic timing layer where
// hooks run (Claude Code and other editors that honour them).
//
// It only records while a loop is active (a .allium-loop/ dir exists), never
// blocks a call (always exits 0), and swallows its own errors.

import {
existsSync,
readFileSync,
writeFileSync,
appendFileSync,
realpathSync,
} from "fs";
import path from "path";

process.on("uncaughtException", () => process.exit(0));

const event = process.argv[2]; // "pre" | "post"
if (event !== "pre" && event !== "post") process.exit(0);

let data = "";
for await (const chunk of process.stdin) data += chunk;

let input;
try {
input = JSON.parse(data);
} catch {
process.exit(0);
}

// The subagent's type is the useful label ("allium:weed"). Field placement
// varies by harness, so look in the likely spots; fall back to the tool name.
const toolInput = input.tool_input ?? input.tool_info ?? {};
const agent =
toolInput.subagent_type ??
toolInput.subagentType ??
toolInput.agent ??
input.tool_name ??
input.tool ??
"subagent";

// A correlation id pairs a pre with its post. If the harness doesn't provide
// one, fall back to FIFO, which is correct for the sequential phase calls the
// loop makes (a documented limitation under parallel calls).
const corrId =
input.tool_use_id ?? input.toolUseId ?? toolInput.id ?? null;

// Resolve the project root the same way the allium-check hook does.
const payloadRoots = Array.isArray(input.workspace_roots) ? input.workspace_roots : [];
const roots = [process.env.CLAUDE_PROJECT_ROOT, ...payloadRoots].filter(Boolean);
if (roots.length === 0) roots.push(process.cwd());

let projectRoot = null;
for (const r of roots) {
try {
projectRoot = realpathSync(r);
break;
} catch {
// try the next root
}
}
if (!projectRoot) process.exit(0);

// Only trace while a loop is running; otherwise this is an unrelated subagent.
const loopDir = path.join(projectRoot, ".allium-loop");
if (!existsSync(loopDir)) process.exit(0);

const pendingPath = path.join(loopDir, ".timing-pending.json");
const timingsPath = path.join(loopDir, "timings.jsonl");
const now = Date.now();

function readPending() {
try {
const p = JSON.parse(readFileSync(pendingPath, "utf-8"));
return { byId: p.byId ?? {}, fifo: Array.isArray(p.fifo) ? p.fifo : [] };
} catch {
return { byId: {}, fifo: [] };
}
}
function writePending(p) {
try {
writeFileSync(pendingPath, JSON.stringify(p));
} catch {
// best-effort
}
}

if (event === "pre") {
const p = readPending();
if (corrId) p.byId[corrId] = { start: now, agent };
else p.fifo.push({ start: now, agent });
writePending(p);
process.exit(0);
}

// event === "post"
const p = readPending();
let rec = null;
if (corrId && p.byId[corrId]) {
rec = p.byId[corrId];
delete p.byId[corrId];
} else if (p.fifo.length > 0) {
rec = p.fifo.shift();
}
writePending(p);

if (rec) {
const line =
JSON.stringify({
ts: new Date(now).toISOString(),
agent: rec.agent,
duration_ms: now - rec.start,
}) + "\n";
try {
appendFileSync(timingsPath, line);
} catch {
// best-effort
}
}
process.exit(0);
98 changes: 98 additions & 0 deletions hooks/loop-trace.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, readFileSync, existsSync, rmSync } from "fs";
import path from "path";
import { tmpdir } from "os";

const hook = new URL("./loop-trace.mjs", import.meta.url).pathname;
let passed = 0;
let failed = 0;

function assert(name, cond) {
if (cond) { console.log(` pass: ${name}`); passed++; }
else { console.log(` FAIL: ${name}`); failed++; }
}

// Run the hook once (one event) with a synthetic payload and a project root.
function runHook(event, input, projectRoot) {
try {
execFileSync("node", [hook, event], {
input: JSON.stringify(input),
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, CLAUDE_PROJECT_ROOT: projectRoot },
});
} catch {
// the hook always exits 0; ignore
}
}

function newProject({ withLoopDir }) {
const dir = mkdtempSync(path.join(tmpdir(), "allium-timing-"));
if (withLoopDir) mkdirSync(path.join(dir, ".allium-loop"));
return dir;
}
function timings(dir) {
const p = path.join(dir, ".allium-loop", "timings.jsonl");
if (!existsSync(p)) return [];
return readFileSync(p, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
}

console.log("\n── loop-trace hook ──\n");

// 1. pre + post with a correlation id → one timing line, right agent, numeric duration.
{
const dir = newProject({ withLoopDir: true });
const payload = { tool_use_id: "abc", tool_name: "Task", tool_input: { subagent_type: "allium:weed" } };
runHook("pre", payload, dir);
runHook("post", payload, dir);
const t = timings(dir);
assert("records one timing for a paired pre/post", t.length === 1);
assert("labels the timing with the subagent type", t[0]?.agent === "allium:weed");
assert("duration is a non-negative number", typeof t[0]?.duration_ms === "number" && t[0].duration_ms >= 0);
rmSync(dir, { recursive: true, force: true });
}

// 2. No .allium-loop dir → no-op, nothing written.
{
const dir = newProject({ withLoopDir: false });
const payload = { tool_use_id: "x", tool_name: "Task", tool_input: { subagent_type: "allium:weed" } };
runHook("pre", payload, dir);
runHook("post", payload, dir);
assert("does nothing when no loop is active", !existsSync(path.join(dir, ".allium-loop", "timings.jsonl")));
rmSync(dir, { recursive: true, force: true });
}

// 3. FIFO fallback when there is no correlation id: two calls pair in order.
{
const dir = newProject({ withLoopDir: true });
runHook("pre", { tool_name: "Task", tool_input: { subagent_type: "allium:distill" } }, dir);
runHook("pre", { tool_name: "Task", tool_input: { subagent_type: "allium:propagate" } }, dir);
runHook("post", { tool_name: "Task", tool_input: {} }, dir);
runHook("post", { tool_name: "Task", tool_input: {} }, dir);
const t = timings(dir);
assert("pairs two unkeyed calls in FIFO order", t.length === 2 && t[0].agent === "allium:distill" && t[1].agent === "allium:propagate");
rmSync(dir, { recursive: true, force: true });
}

// 4. A post with no matching pre writes nothing and does not crash.
{
const dir = newProject({ withLoopDir: true });
runHook("post", { tool_use_id: "orphan", tool_name: "Task", tool_input: { subagent_type: "allium:weed" } }, dir);
assert("ignores an unpaired post", timings(dir).length === 0);
rmSync(dir, { recursive: true, force: true });
}

// 5. Malformed stdin is swallowed (no crash, no output).
{
const dir = newProject({ withLoopDir: true });
try {
execFileSync("node", [hook, "pre"], { input: "not json", encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, CLAUDE_PROJECT_ROOT: dir } });
assert("survives malformed input", true);
} catch {
assert("survives malformed input", false);
}
rmSync(dir, { recursive: true, force: true });
}

console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
Loading