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
12 changes: 11 additions & 1 deletion src/agents/session-auditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ SAFETY rules: same scoping logic. bash_deny or git_protected_branch for a specif

==== OUTPUT LANGUAGE ====

All output fields (title, description, keywords, body, reasoning, handoff fields) MUST be in English. Even if the transcript is in another language (Russian, etc.), write the extraction in English. Non-English user quotes may be embedded inline as evidence with quotation marks, but the surrounding explanation must be English. This is a hard requirement.
All output fields (title, description, keywords, slug, body, reasoning, handoff fields) MUST be in English. If the transcript is in another language, TRANSLATE the concept into natural English and build the extraction from the translation — do NOT romanize or transliterate the foreign words. Non-English user quotes may be embedded inline in the body field as short evidence inside quotation marks, but the surrounding explanation, the slug, and the keywords must be English. This is a hard requirement.

If you cannot find a good English rendering for a concept, make the slug more generic (e.g. "user-preference-on-X") rather than keeping foreign roots. Transliteration is never acceptable.

==== OUTPUT FORMAT ====

Expand Down Expand Up @@ -581,6 +583,14 @@ async function runSingleAuditCall(opts: {
"Write", "Edit", "NotebookEdit", "Agent", "Skill", "TodoWrite",
"WebFetch", "WebSearch", "Bash", "ToolSearch",
],
// Pass AXME_SKIP_HOOKS=1 to the subclaude auditor's environment so that
// any axme-code PreToolUse/PostToolUse/SessionEnd hooks fired inside the
// sub-agent return immediately instead of creating "ghost" AXME sessions
// (Bug F from PR#6 E2E). settingSources=[] already prevents the SDK from
// auto-loading the project's .claude/settings.json, but users or CI may
// register hooks via environment or other means, so the belt-and-braces
// env check in every hook handler is what actually stops the recursion.
env: { ...process.env, AXME_SKIP_HOOKS: "1" },
};

const isMultiChunk = opts.totalChunks > 1;
Expand Down
84 changes: 84 additions & 0 deletions src/audit-spawner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Detached audit worker spawner.
*
* Spawns a standalone `axme-code audit-session --workspace X --session Y`
* child process that is fully decoupled from its parent. The worker lives in
* its own process group (setsid via `detached: true`), so SIGTERM/SIGKILL
* delivered to the parent's process group does NOT propagate to it. The
* parent returns in milliseconds; the worker runs to completion on its own.
*
* This is the one architectural fix that unblocks the entire audit pipeline:
*
* Before: runSessionCleanup runs synchronously inside the MCP server
* (on transport close) or inside the session-end hook subprocess. Both
* are children of Claude Code, which VS Code can kill at any moment —
* side panel mode gives them a few seconds, center editor tab mode kills
* them immediately. Large audits take 30-120s. Audits die mid-run.
*
* After: runSessionCleanup runs inside a detached child that VS Code does
* not know about. Parent exits in <10ms (well within any timeout). The
* child reads fresh code from dist/ on every invocation, so iteration on
* the auditor takes effect immediately without window reload.
*
* The worker redirects stdout/stderr to a per-session log file under
* .axme-code/audit-worker-logs/<sessionId>.log so operators can inspect
* what happened post-mortem.
*/

import { spawn } from "node:child_process";
import { openSync, closeSync } from "node:fs";
import { join } from "node:path";
import { ensureDir } from "./storage/engine.js";
import { AXME_CODE_DIR } from "./types.js";

const AUDIT_WORKER_LOGS_DIR = "audit-worker-logs";

/**
* Spawn a detached audit worker for the given session. Returns immediately
* after the child is spawned; does not wait for it to finish.
*
* The child inherits:
* - The current `process.execPath` (same Node binary)
* - The current CLI script path (`process.argv[1]`, i.e. dist/axme-code.js)
* - A copy of `process.env` plus `AXME_SKIP_HOOKS=1` so any subclaude the
* audit spawns does not create ghost AXME sessions via its hooks
*
* The child does NOT inherit:
* - stdin (set to 'ignore' — the worker reads nothing from stdin)
* - stdout/stderr from parent (redirected to the log file instead — the
* parent may be exiting imminently and holding a pipe fd open would
* kill the writer once the reader closes)
*/
export function spawnDetachedAuditWorker(workspacePath: string, sessionId: string): void {
const logsDir = join(workspacePath, AXME_CODE_DIR, AUDIT_WORKER_LOGS_DIR);
ensureDir(logsDir);
const logPath = join(logsDir, `${sessionId}.log`);

// Open the log file ourselves and pass the raw fd to the child. 'a' mode
// appends across restarts, so repeated audits of the same session leave
// a cumulative history in one file.
const fd = openSync(logPath, "a");

try {
const cliPath = process.argv[1]; // the axme-code bin that started us
const child = spawn(
process.execPath,
[cliPath, "audit-session", "--workspace", workspacePath, "--session", sessionId],
{
detached: true,
stdio: ["ignore", fd, fd],
env: { ...process.env, AXME_SKIP_HOOKS: "1" },
},
);
child.unref();
process.stderr.write(
`AXME: spawned detached audit worker pid=${child.pid} session=${sessionId} log=${logPath}\n`,
);
} finally {
// The child now holds its own dup of the fd; we can close our copy.
// This is important: if we leave the fd open in the parent and the
// parent exits via process.exit(), the fd is closed anyway, but
// explicit close keeps fd accounting clean for tests.
try { closeSync(fd); } catch {}
}
}
59 changes: 57 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,38 @@ This prevents you from missing freshly-extracted rules from the previous
session that might contradict what you are about to do.
`;

const STORAGE_PATH_GUIDANCE = `
### Storage paths (critical)
For any direct inspection of .axme-code/ files via Bash (ls, cat, grep, find),
ALWAYS use the absolute path from axme_context output's "# AXME Storage Root"
header. Do NOT use relative paths from your cwd. In a multi-repo workspace the
workspace root and each child repo both have their own separate .axme-code/
storage, and reading the wrong one silently gives you stale or missing data.

Every session's meta.json contains an "origin" field with the absolute path of
the directory where the MCP server was running when the session was created.
Whenever you pick up a session file directly (not via axme_context) — for
example to audit a previous run, verify an audit log, or cross-reference past
work — read meta.origin FIRST to confirm which .axme-code/ storage that session
belongs to. This is the authoritative per-session source of truth.

### Reloading axme-code after code changes
Running 'npm run build' in axme-code does NOT reload the MCP server attached to
the current VS Code window — Node caches modules in memory for the server's
lifetime. After any code change to axme-code, close and reopen the VS Code
window (Developer: Reload Window) before testing new behavior. The detached
audit worker reads fresh code from disk on each invocation, so audit-logic
iterations take effect immediately; only changes to the MCP server itself
(tool definitions, cleanupAndExit, startup) require a window reload.
`;

const SINGLE_REPO_CLAUDE_MD = `## AXME Code

### Session Start (MANDATORY)
Call axme_context tool with this project's path at the start of every session.
This loads: oracle, decisions, safety rules, memories, test plan, active plans.
Do NOT skip - without context you will miss critical project rules.
${PENDING_AUDITS_GUIDANCE}
${PENDING_AUDITS_GUIDANCE}${STORAGE_PATH_GUIDANCE}
### During Work
- Error pattern or successful approach discovered -> call axme_save_memory immediately
- Architectural decision made or discovered -> call axme_save_decision immediately
Expand All @@ -76,7 +101,7 @@ Every repo has its own .axme-code/ storage (oracle, decisions, memory, safety) c
BEFORE reading code, making changes, or running tests in any repo:
call axme_context with that repo's path to load repo-specific context.
Each repo has unique decisions and safety rules. Workspace context alone is NOT enough.
${PENDING_AUDITS_GUIDANCE}
${PENDING_AUDITS_GUIDANCE}${STORAGE_PATH_GUIDANCE}
### During Work
- Save memories/decisions/safety rules immediately when discovered
- For cross-project findings: include scope parameter (e.g. scope: ["all"])
Expand Down Expand Up @@ -349,6 +374,36 @@ async function main() {
break;
}

case "audit-session": {
// Standalone entry point for the detached audit worker. Takes the
// workspace path and an AXME session id, runs runSessionCleanup on
// the pair, and exits. This is what src/audit-spawner.ts spawns in
// a detached child — it is also directly invokable from the shell
// for manual force re-audit of a specific session.
const wsIdx = args.indexOf("--workspace");
const sidIdx = args.indexOf("--session");
const workspacePath = wsIdx >= 0 && args[wsIdx + 1] ? resolve(args[wsIdx + 1]) : undefined;
const sessionId = sidIdx >= 0 && args[sidIdx + 1] ? args[sidIdx + 1] : undefined;
if (!workspacePath || !sessionId) {
console.error("audit-session requires --workspace <path> --session <axme-uuid>");
process.exit(2);
}
process.stderr.write(
`axme-code audit-session: workspace=${workspacePath} session=${sessionId} pid=${process.pid}\n`,
);
try {
const { runSessionCleanup } = await import("./session-cleanup.js");
const result = await runSessionCleanup(workspacePath, sessionId);
process.stderr.write(`axme-code audit-session: ${JSON.stringify(result)}\n`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const stack = err instanceof Error ? err.stack : undefined;
process.stderr.write(`axme-code audit-session FAILED: ${stack ?? msg}\n`);
process.exit(1);
}
process.exit(0);
}

case "help":
case "--help":
case "-h":
Expand Down
6 changes: 6 additions & 0 deletions src/hooks/post-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ function handlePostToolUse(workspacePath: string, event: HookInput): void {
export async function runPostToolUseHook(workspacePath?: string): Promise<void> {
if (!workspacePath) return; // No workspace = nothing to do

// Skip entirely when we are running inside a subclaude audit worker
// (see session-auditor env: { ...process.env, AXME_SKIP_HOOKS: "1" }).
// Without this early exit, every tool call the auditor makes would spawn
// a ghost AXME session via ensureAxmeSessionForClaude (Bug F from PR#6).
if (process.env.AXME_SKIP_HOOKS === "1") return;

try {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk);
Expand Down
9 changes: 9 additions & 0 deletions src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,15 @@ function handlePreToolUse(sessionOrigin: string, event: HookInput): void {
export async function runPreToolUseHook(workspacePath?: string): Promise<void> {
if (!workspacePath) return;

// Subclaude audit workers run inside session-auditor with
// AXME_SKIP_HOOKS=1 in their environment. Their tool calls trigger any
// PreToolUse hooks that may still be registered (via .claude/settings.json
// or other means) — each such fire would call ensureAxmeSessionForClaude
// and create a short-lived "ghost" AXME session for the subclaude. Early-
// exit here breaks that recursion and leaves the main session's AXME
// bookkeeping intact.
if (process.env.AXME_SKIP_HOOKS === "1") return;

try {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk);
Expand Down
20 changes: 16 additions & 4 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
readClaudeSessionMapping,
clearClaudeSessionMapping,
} from "../storage/sessions.js";
import { runSessionCleanup } from "../session-cleanup.js";
import { spawnDetachedAuditWorker } from "../audit-spawner.js";
import { pathExists } from "../storage/engine.js";
import { AXME_CODE_DIR } from "../types.js";

Expand All @@ -30,7 +30,7 @@ interface SessionEndInput {
source?: string;
}

async function handleSessionEnd(workspacePath: string, input: SessionEndInput): Promise<void> {
function handleSessionEnd(workspacePath: string, input: SessionEndInput): void {
if (!pathExists(join(workspacePath, AXME_CODE_DIR))) return;

// SessionEnd must know which Claude session is ending. If it does not,
Expand All @@ -52,7 +52,12 @@ async function handleSessionEnd(workspacePath: string, input: SessionEndInput):
}
if (!axmeSessionId) return;

await runSessionCleanup(workspacePath, axmeSessionId);
// Spawn a detached audit worker and return immediately. The worker lives
// in its own process group and survives SIGKILL to Claude Code / the hook
// subprocess. We do NOT await runSessionCleanup here — the hook's 120s
// timeout and Claude Code's shutdown clock together make synchronous
// auditing unreliable in practice.
spawnDetachedAuditWorker(workspacePath, axmeSessionId);
// Clear this Claude session's mapping file — the session is over.
clearClaudeSessionMapping(workspacePath, input.session_id);
}
Expand All @@ -64,6 +69,13 @@ async function handleSessionEnd(workspacePath: string, input: SessionEndInput):
export async function runSessionEndHook(workspacePath?: string): Promise<void> {
if (!workspacePath) return;

// Skip entirely when running inside a subclaude audit worker (see
// session-auditor env: { ...process.env, AXME_SKIP_HOOKS: "1" }). Without
// this, a subclaude that exits mid-audit could trigger SessionEnd against
// an ephemeral Claude session id and recursively invoke runSessionCleanup
// on a ghost AXME session (Bug F from PR#6 E2E).
if (process.env.AXME_SKIP_HOOKS === "1") return;

try {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) chunks.push(chunk);
Expand All @@ -73,7 +85,7 @@ export async function runSessionEndHook(workspacePath?: string): Promise<void> {
} catch {
// Empty/invalid stdin is fine — we'll proceed without transcript attachment
}
await handleSessionEnd(workspacePath, input);
handleSessionEnd(workspacePath, input);
} catch {
// Hook failures must be silent
}
Expand Down
Loading