Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
171 lines
5.8 KiB
JavaScript
171 lines
5.8 KiB
JavaScript
// Build a hook context from a hook's stdin payload: identity, session-scoped
|
|
// paths, a logger, and terminal accept/block/fail helpers. The `name` is the
|
|
// hook's label; log() and error() prefix every line with `[name]`, and
|
|
// accept/block/fail add the `ACCEPT|BLOCK|FAIL` verb on top — so call sites pass
|
|
// only the detail and never repeat the name.
|
|
|
|
import { appendFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
import { basename, dirname, isAbsolute, join } from "node:path";
|
|
import { decisionBlock } from "./io.mjs";
|
|
import { REPO, TMP_ROOT, sanitizePath } from "./paths.mjs";
|
|
import { exec } from "./process.mjs";
|
|
import { loadConfig, worktreeProvision } from "./config.mjs";
|
|
|
|
/**
|
|
* @param {string | Record<string, unknown>} input
|
|
* @param {string} [name]
|
|
* @returns {object}
|
|
*/
|
|
export function createHookContext(input, name = "hook") {
|
|
const i = typeof input === "string" ? JSON.parse(input) : input || {};
|
|
const clean = (s) => String(s ?? "").replace(/[\t\n]/g, " ");
|
|
|
|
const agentId = clean(i.agent_id);
|
|
// agentName: env var carries the suffixed runtime name (e.g. developer-TASK-001)
|
|
// in PostToolUse/PreToolUse contexts; for SubagentStart the env is the parent's,
|
|
// so fall back to i.agent_name which Claude Code populates with the child's name.
|
|
const agentName = process.env.CLAUDE_AGENT_NAME || clean(i.agent_name) || "";
|
|
const chatSessionId = process.env.CHAT_SESSION_DIR ? basename(process.env.CHAT_SESSION_DIR) : "";
|
|
|
|
const sessionId =
|
|
clean(i.session_id) || process.env.CLAUDE_CODE_SESSION_ID || chatSessionId || "default";
|
|
const agentType = clean(i.agent_type) || agentName;
|
|
|
|
const sessionShort = sessionId.split("-")[0];
|
|
const sessionDir = join(TMP_ROOT, sanitizePath(REPO), sessionId);
|
|
const logFile = join(sessionDir, "hooks.log");
|
|
|
|
/**
|
|
* @param {...unknown} parts
|
|
* @returns {void}
|
|
*/
|
|
const log = (...parts) => {
|
|
try {
|
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
appendFileSync(
|
|
logFile,
|
|
`[${new Date().toISOString()}] [${name}] ${parts.join(" ")}\n`,
|
|
);
|
|
} catch {
|
|
// logging must never break a hook
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Write to stderr (the agent-visible channel), prefixed with `[${name}]` the
|
|
* same way log() prefixes each log line. The prefix is added once, so
|
|
* multi-line payloads (compiler/test output) stay readable; a trailing
|
|
* newline is ensured.
|
|
* @param {...unknown} parts
|
|
* @returns {void}
|
|
*/
|
|
const error = (...parts) => {
|
|
const text = parts.join(" ");
|
|
process.stderr.write(`[${name}] ${text}${text.endsWith("\n") ? "" : "\n"}`);
|
|
};
|
|
|
|
const verdict = (verb, detail) => log(detail ? `${verb} ${detail}` : verb);
|
|
|
|
// The `npm-link` provisioning builtin: mirror $REPO/node_modules into the
|
|
// worktree. See provisionWorktree below for the config-driven dispatcher.
|
|
const linkNodeModules = (wt) => {
|
|
const target = join(wt, "node_modules");
|
|
if (existsSync(target)) return;
|
|
const source = join(REPO, "node_modules");
|
|
if (!existsSync(source)) return;
|
|
// Fast path: hardlink tree (cp -al) when the worktree base shares a
|
|
// filesystem with the repo. In dev containers /tmp and /workspaces are
|
|
// different mounts (cross-device), so cp -al fails; fall back to a full
|
|
// real copy. A symlinked node_modules is NOT an option: vitest browser
|
|
// mode (Chromium) hangs on it. See memory worktree-node-modules-provisioning.
|
|
if (exec("cp", ["-al", source, target]).status === 0) return;
|
|
rmSync(target, { recursive: true, force: true });
|
|
if (exec("cp", ["-a", source, target]).status !== 0) {
|
|
rmSync(target, { recursive: true, force: true });
|
|
throw new Error(`node_modules provisioning failed for ${wt} - cp -al and cp -a both failed`);
|
|
}
|
|
};
|
|
|
|
// Config-driven worktree provisioning (config.worktree.provision): the
|
|
// `npm-link` builtin (default, mirrors node_modules), `none` (no provisioning,
|
|
// for a stack that needs none), or a script path (run with the worktree as its
|
|
// one argument). Removes the npm assumption from worktree setup. Fail-closed on
|
|
// a provision-script error, like the npm-link builtin.
|
|
const provisionWorktree = (wt) => {
|
|
let mode = "npm-link";
|
|
try {
|
|
mode = worktreeProvision(loadConfig());
|
|
} catch {
|
|
mode = "npm-link";
|
|
}
|
|
if (mode === "none") {
|
|
log(`provision skipped (none) wt=${wt}`);
|
|
return;
|
|
}
|
|
if (mode === "npm-link") {
|
|
linkNodeModules(wt);
|
|
return;
|
|
}
|
|
const script = isAbsolute(mode) ? mode : join(REPO, mode);
|
|
const r = exec("bash", [script, wt], { cwd: REPO });
|
|
if (r.status !== 0) {
|
|
throw new Error(
|
|
`worktree provision script failed (${script}): ${(r.stderr || r.stdout || "").slice(0, 300)}`,
|
|
);
|
|
}
|
|
log(`provisioned via script ${script} wt=${wt}`);
|
|
};
|
|
|
|
return {
|
|
name,
|
|
repo: REPO,
|
|
sessionId,
|
|
agentType,
|
|
agentName,
|
|
agentId,
|
|
sessionShort,
|
|
sessionDir,
|
|
worktreeBase: sessionDir,
|
|
logFile,
|
|
ticketsDir: process.env.TICKETS_DIR || join(sessionDir, "tickets"),
|
|
|
|
log,
|
|
error,
|
|
|
|
/**
|
|
* @param {string} [detail]
|
|
* @returns {never}
|
|
*/
|
|
accept(detail) {
|
|
verdict("ACCEPT", detail);
|
|
process.exit(0);
|
|
},
|
|
|
|
/**
|
|
* @param {{ reason: string, log?: string }} options
|
|
* @returns {never}
|
|
*/
|
|
block({ reason, log: detail }) {
|
|
verdict("BLOCK", detail);
|
|
decisionBlock(reason);
|
|
process.exit(0);
|
|
},
|
|
|
|
/**
|
|
* @param {string} message
|
|
* @param {{ log?: string }} [options]
|
|
* @returns {never}
|
|
*/
|
|
fail(message, { log: detail } = {}) {
|
|
verdict("FAIL", detail);
|
|
error(message);
|
|
process.exit(2);
|
|
},
|
|
|
|
// Worktree provisioning: the config-driven dispatcher plus the npm-link
|
|
// builtin it wraps (both defined as closures above).
|
|
provisionWorktree,
|
|
linkNodeModules,
|
|
};
|
|
}
|