Skip to content
Merged
110 changes: 109 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,116 @@ await build({
});

// Create bin wrapper
import { writeFileSync, chmodSync } from "fs";
import { writeFileSync, chmodSync, mkdirSync } from "fs";
writeFileSync("dist/axme-code.js", '#!/usr/bin/env node\nimport("./cli.mjs");\n');
chmodSync("dist/axme-code.js", 0o755);

// --- Plugin bundled builds (self-contained, zero external deps) ---

await build({
entryPoints: ["src/server.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
packages: "bundle",
outfile: "dist/plugin/server.mjs",
sourcemap: true,
define,
});

await build({
entryPoints: ["src/cli.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
packages: "bundle",
external: ["@anthropic-ai/claude-agent-sdk"],
outfile: "dist/plugin/cli.mjs",
sourcemap: true,
banner: { js: "" },
define,
});

// Plugin bin wrapper — sets NODE_PATH so SDK can be found from CLAUDE_PLUGIN_DATA
mkdirSync("dist/plugin/bin", { recursive: true });
writeFileSync("dist/plugin/bin/axme-code", `#!/bin/bash
PLUGIN_DIR="\$(cd "\$(dirname "\$0")/.." && pwd)"
DATA_DIR="\${CLAUDE_PLUGIN_DATA:-\$HOME/.claude/plugins/data/axme-code}"
export NODE_PATH="\$DATA_DIR/node_modules:\$NODE_PATH"
exec node "\$PLUGIN_DIR/cli.mjs" "\$@"
`);
chmodSync("dist/plugin/bin/axme-code", 0o755);

// Plugin package.json — only SDK for npm install in CLAUDE_PLUGIN_DATA
writeFileSync("dist/plugin/package.json", JSON.stringify({
name: "@axme/code-plugin",
private: true,
dependencies: {
"@anthropic-ai/claude-agent-sdk": pkg.dependencies["@anthropic-ai/claude-agent-sdk"],
},
}, null, 2) + "\n");

// --- Assemble plugin directory ---
import { cpSync, existsSync } from "fs";

mkdirSync("dist/plugin/.claude-plugin", { recursive: true });
mkdirSync("dist/plugin/hooks", { recursive: true });

// Plugin manifest
cpSync(".claude-plugin/plugin.json", "dist/plugin/.claude-plugin/plugin.json");

// Plugin .mcp.json — uses ${CLAUDE_PLUGIN_ROOT} for self-contained execution
writeFileSync("dist/plugin/.mcp.json", JSON.stringify({
mcpServers: {
axme: {
command: "node",
args: ["${CLAUDE_PLUGIN_ROOT}/server.mjs"],
env: {
NODE_PATH: "${CLAUDE_PLUGIN_DATA}/node_modules",
},
},
},
}, null, 2) + "\n");

// Plugin hooks — safety enforcement via bundled CLI
writeFileSync("dist/plugin/hooks/hooks.json", JSON.stringify({
description: "AXME Code safety enforcement and session tracking",
hooks: {
SessionStart: [{
hooks: [{
type: "command",
command: "diff -q ${CLAUDE_PLUGIN_ROOT}/package.json ${CLAUDE_PLUGIN_DATA}/package.json >/dev/null 2>&1 || (mkdir -p ${CLAUDE_PLUGIN_DATA} && cp ${CLAUDE_PLUGIN_ROOT}/package.json ${CLAUDE_PLUGIN_DATA}/ && cd ${CLAUDE_PLUGIN_DATA} && npm install --omit=dev --ignore-scripts 2>/dev/null) ; NODE_PATH=${CLAUDE_PLUGIN_DATA}/node_modules node ${CLAUDE_PLUGIN_ROOT}/cli.mjs check-init",
timeout: 30,
}],
}],
PreToolUse: [{
hooks: [{
type: "command",
command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook pre-tool-use",
timeout: 5,
}],
}],
PostToolUse: [{
matcher: "Edit|Write|NotebookEdit",
hooks: [{
type: "command",
command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook post-tool-use",
timeout: 10,
}],
}],
SessionEnd: [{
hooks: [{
type: "command",
command: "node ${CLAUDE_PLUGIN_ROOT}/cli.mjs hook session-end",
timeout: 120,
}],
}],
},
}, null, 2) + "\n");

// Copy LICENSE and README
if (existsSync("LICENSE")) cpSync("LICENSE", "dist/plugin/LICENSE");

console.log("Build complete.");
4 changes: 2 additions & 2 deletions src/agents/scanners/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ Read the project's documentation and code to find decisions that were made about
- Check subdirectories for additional CLAUDE.md files
- Claude auto-memory (accumulated operational knowledge):
- Compute encoded path: replace non-alphanumeric chars in absolute project path with "-"
- Read ~/.claude/projects/<encoded-path>/memory/MEMORY.md
- Read ALL .md files in ~/.claude/projects/<encoded-path>/memory/
- First check if directory exists: ls ~/.claude/projects/<encoded-path>/memory/ — skip if not found
- If exists: read MEMORY.md and ALL .md files in that directory
- These contain decisions made during real work - extract them
- Architecture Decision Records (docs/adr/, docs/decisions/, docs/architecture/decisions/, adr/)
- Architecture docs (docs/, ARCHITECTURE.md, DESIGN.md)
Expand Down
3 changes: 1 addition & 2 deletions src/agents/scanners/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ Read these files if they exist:
8. package.json (scripts section), pyproject.toml - build/test/deploy commands
9. **Pre-deploy checklist files** - look for files with CHECKLIST, PRE_PROD, pre-deploy in name
10. **CLAUDE.md** - read for deploy rules, staging/prod procedures, deploy prohibitions
11. **Claude auto-memory** - check ~/.claude/projects/<encoded-path>/memory/ for deploy-related feedback
(encoded-path = absolute project path with non-alphanumeric chars replaced by "-")
11. **Claude auto-memory** - compute encoded-path (replace non-alphanumeric chars in absolute project path with "-"), check if ~/.claude/projects/<encoded-path>/memory/ exists (ls first), if yes read .md files for deploy-related feedback

## What to extract

Expand Down
4 changes: 2 additions & 2 deletions src/agents/scanners/oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ Thoroughly scan the project using the tools available to you. Read files, explor
- Check subdirectories for additional CLAUDE.md files
4. **Claude auto-memory (accumulated project knowledge):**
- Compute the encoded project path: replace every non-alphanumeric char in the absolute project path with "-"
- Check ~/.claude/projects/<encoded-path>/memory/MEMORY.md
- Read ALL .md files in ~/.claude/projects/<encoded-path>/memory/
- First check if the directory exists: ls ~/.claude/projects/<encoded-path>/memory/ — if it doesn't exist, skip this step entirely
- If it exists: read MEMORY.md and ALL .md files in that directory
- These contain hard-won operational lessons - treat as HIGH PRIORITY
5. **Config files:** tsconfig.json, .eslintrc*, eslint.config.*, .prettierrc*, .editorconfig, Makefile, Taskfile.yml, Justfile
6. **Source directory structure** (list all significant directories and their contents)
Expand Down
3 changes: 1 addition & 2 deletions src/agents/scanners/safety.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@ Read these files if they exist:
- Also check .claude/CLAUDE.md, .claude/rules/*.md, .claudecode/rules.md
- If CLAUDE.md references other files with rules - follow and read them
11. **AGENTS.md** - may contain safety constraints
12. **Claude auto-memory** - check ~/.claude/projects/<encoded-path>/memory/ for safety-related feedback
(encoded-path = absolute project path with non-alphanumeric chars replaced by "-")
12. **Claude auto-memory** - compute encoded-path (replace non-alphanumeric chars in absolute project path with "-"), check if ~/.claude/projects/<encoded-path>/memory/ exists (ls first), if yes read .md files for safety-related feedback

## What to extract

Expand Down
81 changes: 62 additions & 19 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,8 @@ async function main() {
switch (command) {
case "setup": {
const forceSetup = args.includes("--force");
const setupArgs = args.filter(a => a !== "--force");
const pluginMode = args.includes("--plugin") || !!process.env.CLAUDE_PLUGIN_ROOT;
const setupArgs = args.filter(a => a !== "--force" && a !== "--plugin");
const projectPath = resolve(setupArgs[1] || ".");
const hasGitDir = existsSync(join(projectPath, ".git"));
const ws = detectWorkspace(projectPath);
Expand Down Expand Up @@ -315,31 +316,43 @@ async function main() {
}
}

// Create or update .mcp.json (workspace root + each child repo)
const mcpEntry = { command: "axme-code", args: ["serve"] };
const mcpPaths = [projectPath];
if (isWorkspace) {
for (const p of ws.projects) {
mcpPaths.push(join(projectPath, p.path));
// Detect plugin context — skip .mcp.json and hooks if running from plugin
// (plugin provides its own .mcp.json and hooks/hooks.json)
const isPlugin = pluginMode;

if (!isPlugin) {
// Create or update .mcp.json (workspace root + each child repo)
const mcpEntry = { command: "axme-code", args: ["serve"] };
const mcpPaths = [projectPath];
if (isWorkspace) {
for (const p of ws.projects) {
mcpPaths.push(join(projectPath, p.path));
}
}
}
for (const dir of mcpPaths) {
const mcpPath = join(dir, ".mcp.json");
let mcpConfig: Record<string, any> = {};
if (existsSync(mcpPath)) {
try { mcpConfig = JSON.parse(readFileSync(mcpPath, "utf-8")); } catch { mcpConfig = {}; }
for (const dir of mcpPaths) {
const mcpPath = join(dir, ".mcp.json");
let mcpConfig: Record<string, any> = {};
if (existsSync(mcpPath)) {
try { mcpConfig = JSON.parse(readFileSync(mcpPath, "utf-8")); } catch { mcpConfig = {}; }
}
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
mcpConfig.mcpServers.axme = mcpEntry;
writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
}
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
mcpConfig.mcpServers.axme = mcpEntry;
writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n", "utf-8");
console.log(` .mcp.json: updated (${mcpPaths.length} locations)`);
} else {
console.log(` .mcp.json: skipped (plugin provides MCP server)`);
}
console.log(` .mcp.json: updated (${mcpPaths.length} locations)`);

// Generate CLAUDE.md
generateClaudeMd(projectPath, isWorkspace);

// Configure Claude Code hooks in .claude/settings.json
configureHooks(projectPath);
if (!isPlugin) {
// Configure Claude Code hooks in .claude/settings.json
configureHooks(projectPath);
} else {
console.log(` Hooks: skipped (plugin provides hooks)`);
}

// Add .axme-code/ to .gitignore
const gitignorePath = join(projectPath, ".gitignore");
Expand Down Expand Up @@ -394,6 +407,36 @@ async function main() {
break;
}

case "check-init": {
// Plugin SessionStart hook — ensures CLAUDE.md exists and outputs instruction
const checkPath = resolve(args[1] || ".");
const claudeMdPath = join(checkPath, "CLAUDE.md");
const axmeSection = `## AXME Code

### Session Start (MANDATORY)
Call axme_context at the start of every session.
If it returns "not initialized", run \`axme-code setup --plugin\` via Bash tool immediately.
Do NOT skip — without context you will miss critical project rules.
`;
// Ensure CLAUDE.md has AXME section
if (existsSync(claudeMdPath)) {
const content = readFileSync(claudeMdPath, "utf-8");
if (!content.includes("## AXME Code")) {
writeFileSync(claudeMdPath, content.trimEnd() + "\n\n" + axmeSection, "utf-8");
}
} else {
writeFileSync(claudeMdPath, axmeSection, "utf-8");
}

const { configExists } = await import("./storage/config.js");
if (configExists(checkPath)) {
console.log(`[AXME Code] Knowledge base ready. Call axme_context now.`);
} else {
console.log(`[AXME Code] Project not initialized. Run: axme-code setup --plugin`);
}
break;
}

case "audit-session": {
// Standalone entry point for the detached audit worker. Takes the
// workspace path and an AXME session id, runs runSessionCleanup on
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/post-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ function handlePostToolUse(workspacePath: string, event: HookInput): void {
* @param workspacePath - from --workspace CLI flag
*/
export async function runPostToolUseHook(workspacePath?: string): Promise<void> {
if (!workspacePath) return; // No workspace = nothing to do
if (!workspacePath) workspacePath = process.cwd();
if (!workspacePath) return;

// Skip entirely when we are running inside a subclaude audit worker
// (see session-auditor env: { ...process.env, AXME_SKIP_HOOKS: "1" }).
Expand Down
1 change: 1 addition & 0 deletions src/hooks/pre-tool-use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ function handlePreToolUse(sessionOrigin: string, event: HookInput): void {
* @param workspacePath - from --workspace CLI flag
*/
export async function runPreToolUseHook(workspacePath?: string): Promise<void> {
if (!workspacePath) workspacePath = process.cwd();
if (!workspacePath) return;

// Subclaude audit workers run inside session-auditor with
Expand Down
1 change: 1 addition & 0 deletions src/hooks/session-end.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ function handleSessionEnd(workspacePath: string, input: SessionEndInput): void {
* @param workspacePath - from --workspace CLI flag
*/
export async function runSessionEndHook(workspacePath?: string): Promise<void> {
if (!workspacePath) workspacePath = process.cwd();
if (!workspacePath) return;

// Skip entirely when running inside a subclaude audit worker (see
Expand Down
9 changes: 5 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,18 +738,18 @@ server.tool(
})).optional().describe("Safety rules to add/remove"),
// --- Handoff ---
stopped_at: z.string().describe("What the session stopped at (single line)"),
summary: z.string().describe("2-5 bullet points of what was accomplished"),
in_progress: z.string().describe("Current state: branches, PRs, uncommitted work"),
summary: z.string().describe("2-5 bullet points of what was accomplished. Use real newlines, NOT literal backslash-n. Each bullet on its own line starting with '- '."),
in_progress: z.string().describe("Current state: branches, PRs, uncommitted work. Use real newlines, NOT literal backslash-n."),
prs: z.array(z.object({
url: z.string(),
title: z.string(),
status: z.string(),
})).optional().describe("PRs created/merged in this session"),
test_results: z.string().optional().describe("Test run summary"),
blockers: z.string().optional().describe("Blockers for next session"),
next_steps: z.string().describe("Concrete next steps for next session"),
next_steps: z.string().describe("Concrete next steps for next session. Use real newlines, NOT literal backslash-n."),
dirty_branches: z.string().optional().describe("Branch names with state"),
worklog_entry: z.string().describe("Narrative session summary (5-15 lines markdown)"),
worklog_entry: z.string().describe("Narrative session summary (5-15 lines markdown). Use real newlines, NOT literal backslash-n."),
startup_text: z.string().describe("Ready-to-paste startup text for the next session"),
},
async (args) => {
Expand Down Expand Up @@ -879,6 +879,7 @@ server.tool(
const session = loadSession(targetPath, sid);
if (session) {
session.agentClosed = true;
session.closedAt = new Date().toISOString();
writeSession(targetPath, session);
}

Expand Down
13 changes: 10 additions & 3 deletions src/tools/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { oracleContext, showOracle, oracleExists, loadOracleFiles } from "../storage/oracle.js";
import { decisionsContext, showDecisions, enforceableDecisionsContext, listDecisions } from "../storage/decisions.js";
import { pathExists, readSafe } from "../storage/engine.js";
import { configExists } from "../storage/config.js";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { AXME_CODE_DIR } from "../types.js";
Expand Down Expand Up @@ -67,10 +68,16 @@ export function getFullContextSections(projectPath: string, workspacePath?: stri
// Storage root header
parts.push(buildStorageRootHeader(projectPath, workspacePath));

// Not initialized check
// Not initialized check — both directory and config must exist
const storageDirExists = pathExists(join(projectPath, AXME_CODE_DIR));
if (!storageDirExists) {
return [parts[0] + "\n\nProject not initialized. Ask the user to run 'axme-code setup' in terminal."];
const hasConfig = configExists(projectPath);
if (!storageDirExists || !hasConfig) {
// Check if setup is already running
const setupLock = join(projectPath, AXME_CODE_DIR, "setup.lock");
if (pathExists(setupLock)) {
return [parts[0] + "\n\nSetup is already running. Wait for it to finish, then call axme_context again."];
}
return [parts[0] + "\n\nProject not initialized. Run `axme-code setup --plugin` now via Bash tool to create the knowledge base. Do NOT ask the user — just run it."];
}

// Safety rules (small, always inline)
Expand Down
19 changes: 18 additions & 1 deletion src/tools/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { initPlanStore } from "../storage/plans.js";
import { bundlesToDecisions, bundlesToMemories, bundlesToDeployChecklists, applyPresetSafetyRules } from "../presets.js";
import { AXME_CODE_DIR, DEFAULT_PROJECT_CONFIG } from "../types.js";
import { addCost, zeroCost, type CostInfo } from "../utils/cost-extractor.js";
import { atomicWrite } from "../storage/engine.js";
import { atomicWrite, removeFile } from "../storage/engine.js";
import yaml from "js-yaml";

export interface InitResult {
Expand Down Expand Up @@ -67,6 +67,20 @@ export async function initProjectWithLLM(projectPath: string, opts?: {

ensureDir(axmeDir);

// Setup lock — prevent concurrent setup runs (plugin may trigger multiple)
const lockPath = join(axmeDir, "setup.lock");
if (pathExists(lockPath)) {
return {
projectPath, created: false,
oracle: { files: 0, llm: false },
decisions: { count: 0, fromScan: 0, fromPresets: 0 },
memories: { count: 0, fromPresets: 0 },
safety: { created: false, llm: false, summary: "setup already running" },
config: false, cost: zeroCost(), durationMs: 0, errors: ["Setup already in progress"],
};
}
atomicWrite(lockPath, new Date().toISOString());

const presets = opts?.presets ?? DEFAULT_PROJECT_CONFIG.presets;
let totalCost = zeroCost();
const errors: string[] = [];
Expand Down Expand Up @@ -228,6 +242,9 @@ export async function initProjectWithLLM(projectPath: string, opts?: {
oracleFiles = 4;
}

// Remove setup lock
try { removeFile(lockPath); } catch {}

return {
projectPath,
created: !alreadyExists,
Expand Down