snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
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
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
This commit is contained in:
35
.claude/hooks/lib/agent-meta.mjs
Normal file
35
.claude/hooks/lib/agent-meta.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Read the sibling <agent>.meta.json for a SubagentStop payload.
|
||||
//
|
||||
// At SubagentStop the big transcript JSONL is often not flushed yet (a real
|
||||
// race), but the <agent>.meta.json written at spawn already carries the
|
||||
// stopping agent's identity. Three hooks (record-review-verdict, validate-on-stop,
|
||||
// cleanup-worktree) need it to scope their work to the right agent/worktree, so
|
||||
// the path derivation + parse lives here once instead of being copy-pasted.
|
||||
//
|
||||
// Returns { agentType, description } (both strings, possibly empty), or null
|
||||
// when the meta is absent/unreadable — callers then fall back to transcript
|
||||
// recovery.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} payload Parsed SubagentStop payload.
|
||||
* @returns {{ agentType: string, description: string } | null}
|
||||
*/
|
||||
export function readAgentMeta(payload) {
|
||||
const tp = String(
|
||||
(payload && (payload.agent_transcript_path || payload.transcript_path)) ||
|
||||
"",
|
||||
);
|
||||
const metaPath = tp.replace(/\.jsonl$/, ".meta.json");
|
||||
if (!metaPath.endsWith(".meta.json") || !existsSync(metaPath)) return null;
|
||||
try {
|
||||
const meta = JSON.parse(readFileSync(metaPath, "utf8"));
|
||||
return {
|
||||
agentType: String(meta.agentType || ""),
|
||||
description: String(meta.description || ""),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
150
.claude/hooks/lib/config.mjs
Normal file
150
.claude/hooks/lib/config.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
// harness.config.json loader: the single project-facts manifest consumed by
|
||||
// hooks/scripts at runtime (and by the renderer at sync time in a later phase).
|
||||
// Stdlib only. Fail-closed on a malformed config (throws a clear Error); a
|
||||
// MISSING file degrades to the built-in defaults so a config-less repo still
|
||||
// runs a minimal harness.
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { REPO } from "./paths.mjs";
|
||||
|
||||
export const CONFIG_FILENAME = "harness.config.json";
|
||||
|
||||
// Minimal safe baseline. The committed harness.config.json overrides these.
|
||||
// Optional capabilities (deploy, app) are ABSENT here on purpose: a capability
|
||||
// exists iff its block is present in the config.
|
||||
const DEFAULTS = {
|
||||
name: "harness",
|
||||
layout: { src: "src", e2e: "e2e", adr: "adr" },
|
||||
validation: { steps: [], extraForbidden: [] },
|
||||
worktree: { provision: "npm-link" },
|
||||
skills: { developerMenu: [] },
|
||||
roles: {},
|
||||
launcher: {
|
||||
sessionDirEnv: "CHAT_SESSION_DIR",
|
||||
turnSentinelDir: null,
|
||||
postCheckoutScript: null,
|
||||
logsDir: null,
|
||||
},
|
||||
};
|
||||
|
||||
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
|
||||
// Deep-merge `override` onto `base`. Objects merge recursively; arrays and
|
||||
// scalars from `override` REPLACE (never concatenate): a config that lists
|
||||
// validation.steps fully replaces the default empty list.
|
||||
function deepMerge(base, override) {
|
||||
if (!isObject(base) || !isObject(override)) return override;
|
||||
const out = { ...base };
|
||||
for (const [k, v] of Object.entries(override)) {
|
||||
out[k] = isObject(v) && isObject(base[k]) ? deepMerge(base[k], v) : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
class ConfigError extends Error {}
|
||||
|
||||
// Throw a clear, prefixed error so a fail-closed consumer surfaces exactly what
|
||||
// is wrong with the config.
|
||||
function fail(msg) {
|
||||
throw new ConfigError(`${CONFIG_FILENAME}: ${msg}`);
|
||||
}
|
||||
|
||||
function validate(cfg) {
|
||||
if (!isObject(cfg)) fail("top level must be a JSON object");
|
||||
|
||||
if (!isObject(cfg.validation) || !Array.isArray(cfg.validation.steps)) {
|
||||
fail("`validation.steps` must be an array");
|
||||
}
|
||||
for (const [i, step] of cfg.validation.steps.entries()) {
|
||||
if (!isObject(step)) fail(`validation.steps[${i}] must be an object`);
|
||||
if (typeof step.id !== "string" || !step.id) {
|
||||
fail(`validation.steps[${i}] needs a non-empty string \`id\``);
|
||||
}
|
||||
if (typeof step.kind !== "string" || !step.kind) {
|
||||
fail(`validation.steps[${step.id}] needs a non-empty string \`kind\``);
|
||||
}
|
||||
const isVitest = step.runner === "vitest";
|
||||
if (!isVitest && (typeof step.command !== "string" || !step.command)) {
|
||||
fail(`validation.steps[${step.id}] needs a \`command\` (or runner:"vitest")`);
|
||||
}
|
||||
if (isVitest && (typeof step.config !== "string" || !Array.isArray(step.projects))) {
|
||||
fail(`validation.steps[${step.id}] (vitest) needs \`config\` and \`projects[]\``);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isObject(cfg.roles)) fail("`roles` must be an object");
|
||||
for (const [name, role] of Object.entries(cfg.roles)) {
|
||||
if (!isObject(role) || typeof role.model !== "string" || !role.model) {
|
||||
fail(`roles.${name} needs a non-empty string \`model\``);
|
||||
}
|
||||
}
|
||||
|
||||
if ("deploy" in cfg && cfg.deploy !== undefined) {
|
||||
if (!isObject(cfg.deploy) || !Array.isArray(cfg.deploy.relevantGlobs)) {
|
||||
fail("`deploy.relevantGlobs` must be an array when `deploy` is present");
|
||||
}
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
/**
|
||||
* Load and validate the harness config, merged over the defaults.
|
||||
* @param {string} [repo] repo root holding harness.config.json (defaults to REPO)
|
||||
* @returns {object} the validated, merged config
|
||||
* @throws {Error} on malformed JSON or an invalid shape (fail-closed)
|
||||
*/
|
||||
export function loadConfig(repo = REPO) {
|
||||
if (cache.has(repo)) return cache.get(repo);
|
||||
const path = join(repo, CONFIG_FILENAME);
|
||||
let merged;
|
||||
if (!existsSync(path)) {
|
||||
merged = validate(structuredClone(DEFAULTS));
|
||||
} else {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch (e) {
|
||||
fail(`invalid JSON (${e.message})`);
|
||||
}
|
||||
merged = validate(deepMerge(DEFAULTS, parsed));
|
||||
}
|
||||
cache.set(repo, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Test-only: drop the memoized config so a test can reload after writing a new
|
||||
// file to the same repo path.
|
||||
export function clearConfigCache() {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// ---- Typed readers used by the config consumers ----------------
|
||||
|
||||
export const validationSteps = (cfg) => cfg.validation?.steps ?? [];
|
||||
export const extraForbidden = (cfg) => cfg.validation?.extraForbidden ?? [];
|
||||
export const isDeployEnabled = (cfg) => isObject(cfg.deploy);
|
||||
export const deployGlobs = (cfg) => cfg.deploy?.relevantGlobs ?? [];
|
||||
export const isAppSmokeEnabled = (cfg) => isObject(cfg.app);
|
||||
export const worktreeProvision = (cfg) => cfg.worktree?.provision ?? "npm-link";
|
||||
export const roleNames = (cfg) => Object.keys(cfg.roles ?? {});
|
||||
export const roleModel = (cfg, role) => cfg.roles?.[role]?.model ?? "";
|
||||
export const pipelineRoles = (cfg) =>
|
||||
roleNames(cfg).filter((r) => cfg.roles[r]?.pipeline);
|
||||
export const debounceRoles = (cfg) =>
|
||||
roleNames(cfg).filter((r) => cfg.roles[r]?.debounce);
|
||||
// Managed-launcher extension points (empty object when no launcher overlay).
|
||||
export const launcher = (cfg) => cfg.launcher ?? {};
|
||||
// The format-kind validation step (null when none), used by format-on-write.
|
||||
export const formatStep = (cfg) =>
|
||||
validationSteps(cfg).find((s) => s.kind === "format") ?? null;
|
||||
// Steps a pre-PR / pre-push gate runs on the human path (fast checks only).
|
||||
// `changedScoped` steps are EXCLUDED: they need a per-worktree diff base (the
|
||||
// session branch) that the human push path lacks, and running their bare command
|
||||
// whole-repo would lint/test far more than the change at hand.
|
||||
export const prePrSteps = (cfg) =>
|
||||
validationSteps(cfg).filter(
|
||||
(s) => (s.kind === "typecheck" || s.kind === "lint") && !s.changedScoped,
|
||||
);
|
||||
170
.claude/hooks/lib/context.mjs
Normal file
170
.claude/hooks/lib/context.mjs
Normal file
@@ -0,0 +1,170 @@
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
42
.claude/hooks/lib/dispatch-parse.mjs
Normal file
42
.claude/hooks/lib/dispatch-parse.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
// Parse a PreToolUse(Agent) hook payload: the dispatch tool_input plus the
|
||||
// `KEY: value` contract carried in the dispatch prompt (orchestrator STATE B
|
||||
// templates). Every Agent-gating hook (setup-worktree, enforce-dev-dispatch,
|
||||
// block-merger-without-review, block-promote-unmerged) parses through here, so
|
||||
// the contract regexes live in exactly one place.
|
||||
//
|
||||
// TASK_ID is anchored at line start and only accepts TASK-<n> | SIMPLE | MIGRATION
|
||||
// | PROMOTE | ROLLBACK, so prose mentioning another ticket (e.g. "TASK-001 is
|
||||
// merged; now merge this one") can never mis-key a gate.
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} input Parsed PreToolUse(Agent) payload.
|
||||
* @returns {object} Dispatch fields used by the Agent-gating hooks.
|
||||
*/
|
||||
export function parseDispatch(input) {
|
||||
const ti = (input && input.tool_input) || {};
|
||||
const prompt = String(ti.prompt ?? "");
|
||||
const grab = (re) => {
|
||||
const m = prompt.match(re);
|
||||
return m ? m[1] : "";
|
||||
};
|
||||
return {
|
||||
// "" for the main orchestrator; the agent's own type for a subagent-issued
|
||||
// dispatch (none of the ticket agents can dispatch, so this is informational).
|
||||
callerAgentType: String(input?.agent_type ?? ""),
|
||||
subagentType: String(ti.subagent_type ?? ""),
|
||||
name: String(ti.name ?? ""),
|
||||
isolation: String(ti.isolation ?? ""),
|
||||
runInBackground: Boolean(ti.run_in_background),
|
||||
role: grab(/^ROLE:\s*(\S+)/m),
|
||||
taskId: grab(/^TASK_ID:\s*(TASK-\d+|SIMPLE|MIGRATION|PROMOTE|ROLLBACK)\b/m),
|
||||
worktreePath: grab(/^WORKTREE_PATH:\s*(\S+)/m),
|
||||
branchName: grab(/^BRANCH_NAME:\s*(\S+)/m),
|
||||
mode: grab(/^MODE:\s*(\S+)/m),
|
||||
// STAGE: a-only marks a merger dispatch as Stage A ONLY (no Stage B / promotion),
|
||||
// even in SIMPLE / MIGRATION mode. Read by record-merger-stage to authorize promotion.
|
||||
stage: grab(/^STAGE:\s*(\S+)/m),
|
||||
sessionShortId: grab(/^SESSION_SHORT_ID:\s*(\S+)/m),
|
||||
ticketFile: grab(/^TICKET_FILE:\s*(\S+)/m),
|
||||
ticketsDir: grab(/^TICKETS_DIR:\s*(\S+)/m),
|
||||
};
|
||||
}
|
||||
88
.claude/hooks/lib/git.mjs
Normal file
88
.claude/hooks/lib/git.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
// Git repo and worktree state queries.
|
||||
|
||||
import { REPO } from "./paths.mjs";
|
||||
import { exec } from "./process.mjs";
|
||||
|
||||
export function git(args = [], opts = {}) {
|
||||
return exec("git", ["-C", REPO, ...args], opts);
|
||||
}
|
||||
|
||||
export function getBaseBranch() {
|
||||
const r = git(["symbolic-ref", "--short", "HEAD"]);
|
||||
return (r.status === 0 ? r.stdout.trim() : "") || "main";
|
||||
}
|
||||
|
||||
const WORKTREE_PREFIX = "worktree ";
|
||||
const BRANCH_PREFIX = "branch refs/heads/";
|
||||
|
||||
// Value of the first line carrying `prefix`, stripped of it; "" when absent.
|
||||
const lineValue = (lines, prefix) => {
|
||||
const line = lines.find((l) => l.startsWith(prefix));
|
||||
return line ? line.slice(prefix.length) : "";
|
||||
};
|
||||
|
||||
// `--porcelain` emits one blank-line-separated block per worktree, each opening
|
||||
// with a `worktree <path>` line and (unless detached/bare) a `branch refs/...`
|
||||
// line. Parse each block independently; drop blocks with no path (trailing blank).
|
||||
export function getWorktreeEntries() {
|
||||
return git(["worktree", "list", "--porcelain"]).stdout
|
||||
.split("\n\n")
|
||||
.map((block) => block.split("\n"))
|
||||
.map((lines) => ({
|
||||
path: lineValue(lines, WORKTREE_PREFIX),
|
||||
branch: lineValue(lines, BRANCH_PREFIX),
|
||||
}))
|
||||
.filter((entry) => entry.path);
|
||||
}
|
||||
|
||||
export const getWorktreePaths = () => getWorktreeEntries().map((e) => e.path);
|
||||
|
||||
// Combined per-worktree change summary in a single `git status --porcelain`
|
||||
// (working tree) + one `git log` (commits ahead of base) + one `git diff
|
||||
// --name-only` (committed file names). Returns { dirty, changedFiles } so callers
|
||||
// (validate-on-stop) get both signals without spawning `git status` twice.
|
||||
export function getWorktreeChangeSummary(wt, base) {
|
||||
const nonEmpty = (lines) => lines.split("\n").filter((l) => l.trim());
|
||||
const statusPath = (line) => line.trim().split(/\s+/).pop();
|
||||
const working = exec("git", ["-C", wt, "status", "--porcelain"]).stdout;
|
||||
const ahead = exec("git", ["-C", wt, "log", "--oneline", `${base}..HEAD`]).stdout.trim();
|
||||
const committed = exec("git", ["-C", wt, "diff", "--name-only", `${base}..HEAD`]).stdout;
|
||||
const changedFiles = [
|
||||
...new Set([
|
||||
...nonEmpty(committed).map((l) => l.trim()),
|
||||
...nonEmpty(working).map(statusPath),
|
||||
]),
|
||||
];
|
||||
return { dirty: Boolean(working.trim() || ahead), changedFiles };
|
||||
}
|
||||
|
||||
// Task branches under refs/heads/<short>/ that still have commits NOT merged into
|
||||
// `sessionRef`. The single source of truth for the promotion-safety check
|
||||
// (block-promote-unmerged.mjs); the orchestrator's STATE B reconcile Bash
|
||||
// mirrors it and must stay aligned. Fails CLOSED: a failed/unparseable rev-list
|
||||
// count is reported as unmerged (count: null) rather than silently treated as
|
||||
// merged. `exclude` simple branches that legitimately never merge into the session
|
||||
// branch (e.g. <short>/simple, which promotes straight to main).
|
||||
export function getUnmergedTaskBranches(short, sessionRef, exclude = []) {
|
||||
const branches = git([
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)",
|
||||
`refs/heads/${short}`,
|
||||
])
|
||||
.stdout.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.filter((b) => !exclude.includes(b));
|
||||
|
||||
const unmerged = [];
|
||||
for (const br of branches) {
|
||||
const r = git(["rev-list", "--count", `${sessionRef}..${br}`]);
|
||||
const n = Number(r.stdout.trim());
|
||||
if (r.status !== 0 || !Number.isFinite(n)) {
|
||||
unmerged.push({ branch: br, count: null }); // fail closed
|
||||
} else if (n > 0) {
|
||||
unmerged.push({ branch: br, count: n });
|
||||
}
|
||||
}
|
||||
return unmerged;
|
||||
}
|
||||
5
.claude/hooks/lib/io.mjs
Normal file
5
.claude/hooks/lib/io.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
// Emit a PreToolUse "block" decision on stdout — the channel Claude Code reads to
|
||||
// deny a tool call while surfacing `reason` to the agent.
|
||||
export function decisionBlock(reason) {
|
||||
process.stdout.write(JSON.stringify({ decision: "block", reason }) + "\n");
|
||||
}
|
||||
24
.claude/hooks/lib/paths.mjs
Normal file
24
.claude/hooks/lib/paths.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
import { join } from "node:path";
|
||||
import { exec } from "./process.mjs";
|
||||
|
||||
// APP_DIR / CLAUDE_PROJECT_DIR override the detected root (used by hook tests).
|
||||
function getRepo() {
|
||||
if (process.env.APP_DIR) return process.env.APP_DIR;
|
||||
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
|
||||
const top = exec("git", ["rev-parse", "--show-toplevel"]);
|
||||
if (top.status === 0 && top.stdout.trim()) return top.stdout.trim();
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
export const REPO = getRepo();
|
||||
|
||||
export const CONFIG_DIR =
|
||||
process.env.CLAUDE_CONFIG_DIR || join(process.env.HOME || "/root", ".claude");
|
||||
// HARNESS_TMP_ROOT is the neutral name; CRM_TMP_ROOT is the deprecated fallback
|
||||
// kept for one release so existing launchers / tests keep working.
|
||||
export const TMP_ROOT =
|
||||
process.env.HARNESS_TMP_ROOT || process.env.CRM_TMP_ROOT || "/tmp";
|
||||
|
||||
export function sanitizePath(p) {
|
||||
return String(p ?? "").replace(/\//g, "_");
|
||||
}
|
||||
15
.claude/hooks/lib/process.mjs
Normal file
15
.claude/hooks/lib/process.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
export function exec(file, args = [], opts = {}) {
|
||||
const r = spawnSync(file, args, { encoding: "utf8", ...opts });
|
||||
return {
|
||||
status: r.status ?? (r.error ? 1 : 0),
|
||||
stdout: r.stdout ?? "",
|
||||
stderr: r.stderr ?? "",
|
||||
error: r.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function bash(cmd, opts = {}) {
|
||||
return exec("bash", ["-c", cmd], opts);
|
||||
}
|
||||
32
.claude/hooks/lib/progress-log.mjs
Normal file
32
.claude/hooks/lib/progress-log.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Append a timestamped line to the #technical-harness progress log, but ONLY when
|
||||
// that log already exists.
|
||||
//
|
||||
// Its existence is the technical-run gate: the orchestrator creates
|
||||
// <sessionDir>/harness-progress.log under PERSONA: technical, render-status reads it,
|
||||
// and a web-chat / normal run has none. Enrichers (validate-on-stop) call this to
|
||||
// fill the otherwise-silent stretches BETWEEN the orchestrator's milestones, e.g.
|
||||
// the multi-minute validation chain a developer runs on stop, so a `tail -f`, the
|
||||
// board's "Recent activity", or a Monitor shows what is happening instead of going
|
||||
// dark. Best-effort: logging must never break a run, and it never CREATES the log
|
||||
// (creating it would leak a board into a non-technical run).
|
||||
|
||||
import { appendFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* @param {string} sessionDir The harness session dir (ctx.sessionDir).
|
||||
* @param {string} line Event text; a `HH:MM:SS ` UTC stamp is prepended to match
|
||||
* the orchestrator's own lines.
|
||||
* @returns {boolean} true if a line was written, false if the log was absent/unwritable.
|
||||
*/
|
||||
export function appendProgress(sessionDir, line) {
|
||||
try {
|
||||
const file = join(sessionDir, "harness-progress.log");
|
||||
if (!existsSync(file)) return false; // not a technical run -> stay inert
|
||||
const stamp = new Date().toISOString().slice(11, 19); // HH:MM:SS UTC
|
||||
appendFileSync(file, `${stamp} ${line}\n`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
31
.claude/hooks/lib/reviews.mjs
Normal file
31
.claude/hooks/lib/reviews.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Per-ticket review verdict flags. The presence of a flag means that reviewer
|
||||
// returned APPROVED for the ticket. PRIMARY writer is the quality-reviewer agent
|
||||
// itself — it touches the flag via Bash as its last action before emitting the
|
||||
// contract line (see quality-reviewer.md), so the verdict is recorded
|
||||
// synchronously and never depends on a post-stop transcript read. FALLBACK writer
|
||||
// is record-review-verdict.mjs (SubagentStop), kept as belt-and-suspenders for the
|
||||
// case the reviewer forgot the touch. Read by block-merger-without-review.mjs
|
||||
// (PreToolUse/Agent), cleared on REJECTED and when a developer is (re)dispatched
|
||||
// (setup-worktree.mjs) so a changed diff invalidates stale approvals. Session-scoped
|
||||
// under <sessionDir>/reviews, mirroring the <sessionDir>/flags convention.
|
||||
//
|
||||
// Path source: the quality-reviewer agent (PRIMARY writer) derives the flag dir
|
||||
// from its ticket file — `$(dirname TICKET_FILE)/reviews` — i.e. under the
|
||||
// `<session_dir>` it was handed (TICKETS_DIR == <session_dir>, see orchestrator.md).
|
||||
// On a managed launcher (CRM Builder's chat-service) that `<session_dir>` is
|
||||
// `CHAT_SESSION_DIR`, which is NOT the `/tmp/<repo>/<id>` path ctx.sessionDir
|
||||
// recomputes — so the reader/clearer/fallback below must key off CHAT_SESSION_DIR
|
||||
// when present, or they look in the wrong dir and the synchronous verdict is lost
|
||||
// (block-merger then blocks on a phantom "no APPROVED"). With no managed launcher,
|
||||
// CHAT_SESSION_DIR is unset and ctx.sessionDir already equals <session_dir> (the
|
||||
// session-bootstrap hook injects exactly that), so this is a no-op there.
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
export const REVIEW_ROLES = ["quality-reviewer"];
|
||||
|
||||
export const reviewsDir = (ctx) =>
|
||||
join(process.env.CHAT_SESSION_DIR || ctx.sessionDir, "reviews");
|
||||
|
||||
export const reviewFlag = (ctx, taskId, role) =>
|
||||
join(reviewsDir(ctx), `${taskId}-${role}`);
|
||||
121
.claude/hooks/lib/session-state.mjs
Normal file
121
.claude/hooks/lib/session-state.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
// In-flight detection for a harness session, keyed STRICTLY off the current
|
||||
// session's own id (ctx.sessionShort / ctx.sessionDir). Because Claude Code
|
||||
// reuses the same session id when a conversation is resumed (verified: a single
|
||||
// transcript spans multi-hour close/reopen gaps under one id), the same session
|
||||
// recomputes the same namespace on resume — so "is THIS session mid-harness?"
|
||||
// is answerable from disk + git alone, with no global state. Two concurrent
|
||||
// sessions have distinct ids => distinct namespaces => this never crosses them,
|
||||
// which is what makes the resume machinery multi-session safe by construction.
|
||||
//
|
||||
// Consumers: session-bootstrap (SessionStart banner offering resume) and
|
||||
// cleanup-session (SessionEnd guard that preserves an in-flight session's state
|
||||
// instead of deleting it). STATE RECOVERY in the orchestrator does the precise
|
||||
// re-classification; this only answers "worth resuming, and roughly where".
|
||||
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { REPO } from "./paths.mjs";
|
||||
import { exec } from "./process.mjs";
|
||||
import { getBaseBranch, git } from "./git.mjs";
|
||||
import { sessionBranch } from "./topology.mjs";
|
||||
|
||||
const TICKET_RE = /^TASK-\d+\.json$/;
|
||||
|
||||
// Tickets live in the session dir itself or a `tickets/` subdir - try both, same
|
||||
// as render-status.mjs. Unreadable JSON counts as a ticket (status "unreadable"),
|
||||
// so a corrupt file never silently downgrades an in-flight session to "done".
|
||||
function readTickets(ctx) {
|
||||
for (const dir of [ctx.ticketsDir, ctx.sessionDir]) {
|
||||
if (!existsSync(dir)) continue;
|
||||
let files;
|
||||
try {
|
||||
files = readdirSync(dir).filter((f) => TICKET_RE.test(f));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!files.length) continue;
|
||||
return files.map((f) => {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(dir, f), "utf8"));
|
||||
} catch {
|
||||
return { status: "unreadable" };
|
||||
}
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const verifyRef = (ref) =>
|
||||
git(["rev-parse", "--verify", "--quiet", ref]).status === 0;
|
||||
|
||||
// The base branch this session forked from: setup-worktree records it in
|
||||
// .git/config (sessionbase.<short>.branch); fall back to the repo's base branch.
|
||||
function forkBaseBranch(ctx) {
|
||||
const r = git(["config", "--get", `sessionbase.${ctx.sessionShort}.branch`]);
|
||||
return (r.status === 0 && r.stdout.trim()) || getBaseBranch();
|
||||
}
|
||||
|
||||
// A deploy-time migration still pending after promotion. Best-effort: any
|
||||
// non-zero exit (including 3 = undetermined) is read as "nothing pending", so a
|
||||
// missing deploy adapter or an odd state never fabricates a migration gate.
|
||||
function pendingDeploy(ctx) {
|
||||
const script = join(REPO, ".claude", "scripts", "pending-deploys.mjs");
|
||||
if (!existsSync(script)) return false;
|
||||
// Bounded: a hung probe must never wedge the SessionStart/SessionEnd hooks.
|
||||
// On timeout spawnSync sets an error → non-zero status → read as "nothing
|
||||
// pending" (the pre-promotion preserve conditions already covered the risky
|
||||
// windows; a promoted branch is git-recoverable regardless).
|
||||
const r = exec(
|
||||
"node",
|
||||
[script, "--app", REPO, "--session", ctx.sessionShort],
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
return r.status === 0 && Boolean(r.stdout.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} ctx hook context (createHookContext)
|
||||
* @param {{ checkDeploy?: boolean }} [opts] run the deploy-gate probe (default true)
|
||||
* @returns {{ inflight: boolean|null, phase: string|null }}
|
||||
* inflight true = resume it; false = nothing/finished; null = undetermined
|
||||
* (an error mid-probe) so callers can fail safe. `phase` is a human hint.
|
||||
*/
|
||||
export function detectInflight(ctx, { checkDeploy = true } = {}) {
|
||||
try {
|
||||
const short = ctx.sessionShort;
|
||||
if (!short || short === "default") return { inflight: false, phase: null };
|
||||
|
||||
const tickets = readTickets(ctx);
|
||||
const hasTickets = tickets.length > 0;
|
||||
const allMerged =
|
||||
hasTickets && tickets.every((t) => t.status === "merged");
|
||||
const sessRef = sessionBranch(ctx); // session/<short>
|
||||
const branchExists = verifyRef(`refs/heads/${sessRef}`);
|
||||
|
||||
// Tickets created but not all merged: plan gate leaves ONLY the tickets on
|
||||
// disk (no session branch yet); once dev started it is a mid-wave stop.
|
||||
if (hasTickets && !allMerged) {
|
||||
return { inflight: true, phase: branchExists ? "mid-wave" : "plan gate" };
|
||||
}
|
||||
|
||||
// No session branch and nothing unmerged: nothing was forked yet.
|
||||
if (!branchExists) return { inflight: false, phase: null };
|
||||
|
||||
// Session branch exists. Promoted into its fork base yet?
|
||||
const base = forkBaseBranch(ctx);
|
||||
const promoted =
|
||||
verifyRef(`refs/heads/${base}`) &&
|
||||
git(["merge-base", "--is-ancestor", sessRef, base]).status === 0;
|
||||
if (!promoted) {
|
||||
return { inflight: true, phase: "waves done, awaiting promotion" };
|
||||
}
|
||||
|
||||
// Promoted: only the deploy-time migration can still be pending.
|
||||
if (checkDeploy && pendingDeploy(ctx)) {
|
||||
return { inflight: true, phase: "migration gate" };
|
||||
}
|
||||
return { inflight: false, phase: null };
|
||||
} catch {
|
||||
return { inflight: null, phase: null };
|
||||
}
|
||||
}
|
||||
53
.claude/hooks/lib/teams.mjs
Normal file
53
.claude/hooks/lib/teams.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Agent identity helpers — the single source for every hook that gates on who
|
||||
// an agent is.
|
||||
|
||||
import { loadConfig, pipelineRoles, debounceRoles, roleNames } from "./config.mjs";
|
||||
|
||||
export const getFirstTaskId = (text) => (String(text ?? "").match(/TASK-[0-9]+/) || [])[0] || "";
|
||||
|
||||
// Role SETS come from harness.config.json (config.roles: `pipeline` / `debounce`
|
||||
// flags), so force-foreground and block-duplicate-dispatch no longer hardcode
|
||||
// their own copies. Fail-open to the built-in defaults when the config is
|
||||
// missing or lists no roles (e.g. a config-less test repo), so a guard never
|
||||
// silently empties out.
|
||||
const DEFAULT_PIPELINE = [
|
||||
"developer",
|
||||
"quality-reviewer",
|
||||
"merger",
|
||||
"planner",
|
||||
"simple-developer",
|
||||
"test-writer",
|
||||
];
|
||||
const DEFAULT_DEBOUNCE = ["developer", "quality-reviewer", "merger"];
|
||||
|
||||
const setFromConfig = (reader, fallback) => {
|
||||
try {
|
||||
const roles = reader(loadConfig());
|
||||
return new Set(roles.length ? roles : fallback);
|
||||
} catch {
|
||||
return new Set(fallback);
|
||||
}
|
||||
};
|
||||
|
||||
/** Roles the orchestrator must dispatch foreground and whose result it consumes inline. */
|
||||
export const pipelineRoleSet = () => setFromConfig(pipelineRoles, DEFAULT_PIPELINE);
|
||||
/** Roles whose identical re-dispatch is debounced (async-ack double dispatch). */
|
||||
export const debounceRoleSet = () => setFromConfig(debounceRoles, DEFAULT_DEBOUNCE);
|
||||
/** Every declared role name (config.roles keys); [] on a config error. */
|
||||
export const configRoleNames = () => {
|
||||
try {
|
||||
return roleNames(loadConfig());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Agent-name role predicates. Match the bare role, a suffixed name
|
||||
// (merger-TASK-003), or an @-qualified address.
|
||||
export const isQualityReviewer = (name) => /^quality-reviewer([-@]|$)/.test(name || "");
|
||||
export const isMerger = (name) => /^merger([-@]|$)/.test(name || "");
|
||||
export const isDeveloper = (name) => /^developer([-@]|$)/.test(name || "");
|
||||
// The orchestrator, including the chat- web variant and any runtime suffix
|
||||
// (orchestrator-…). Single source for every gate that keys on "is this the
|
||||
// orchestrator" (bash-guard guard-state rule, block-nested-orchestrator).
|
||||
export const isOrchestrator = (name) => /^(chat-)?orchestrator([-@]|$)/.test(name || "");
|
||||
25
.claude/hooks/lib/topology.mjs
Normal file
25
.claude/hooks/lib/topology.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Session worktree/branch topology — the single source for the layout created
|
||||
// by setup-worktree and torn down by cleanup-worktree:
|
||||
// <worktreeBase>/TASK-XXX on branch <short>/TASK-XXX (one per ticket)
|
||||
// <worktreeBase>/simple on branch <short>/simple (single-shot rollback / migration)
|
||||
// <worktreeBase>/_session on branch session/<short> (merger integration)
|
||||
// session-base/<short> (fixed fork anchor)
|
||||
// Every hook that builds or matches these names must go through this module.
|
||||
|
||||
import { join } from "node:path";
|
||||
|
||||
export const taskWorktreePath = (ctx, taskId) => join(ctx.worktreeBase, taskId);
|
||||
export const simpleWorktreePath = (ctx) => join(ctx.worktreeBase, "simple");
|
||||
export const sessionWorktreePath = (ctx) => join(ctx.worktreeBase, "_session");
|
||||
|
||||
export const taskBranch = (ctx, taskId) => `${ctx.sessionShort}/${taskId}`;
|
||||
export const simpleBranch = (ctx) => `${ctx.sessionShort}/simple`;
|
||||
export const sessionBranch = (ctx) => `session/${ctx.sessionShort}`;
|
||||
export const sessionBaseBranch = (ctx) => `session-base/${ctx.sessionShort}`;
|
||||
|
||||
// Infrastructure worktrees are never removed by cleanup.
|
||||
export const isInfraWorktreePath = (p) => p.endsWith("/_session") || p.endsWith("/simple");
|
||||
// Session-lifetime branches are never deleted by cleanup.
|
||||
export const isProtectedBranch = (b) => /^session(-base)?\//.test(b) || /\/simple$/.test(b);
|
||||
// Directory names under <worktreeBase> that the leftover sweep may touch.
|
||||
export const isTaskWorktreeDirName = (name) => /^TASK-[0-9]+$/.test(name) || name === "simple";
|
||||
246
.claude/hooks/lib/validation.mjs
Normal file
246
.claude/hooks/lib/validation.mjs
Normal file
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { getBaseBranch, getWorktreeChangeSummary, getWorktreePaths } from "./git.mjs";
|
||||
import { bash, exec } from "./process.mjs";
|
||||
import { loadConfig, validationSteps } from "./config.mjs";
|
||||
import { appendProgress } from "./progress-log.mjs";
|
||||
|
||||
// `only` narrows to a single worktree when the caller already knows it
|
||||
// (validate-on-stop scoping to the stopping agent's own task worktree);
|
||||
// VALIDATE_WORKTREE is the test override. Either falls back to all session
|
||||
// worktrees when the path is gone.
|
||||
export function getActiveWorktrees(ctx, only = "") {
|
||||
const narrowed = only || process.env.VALIDATE_WORKTREE || "";
|
||||
if (narrowed && existsSync(narrowed)) return [narrowed];
|
||||
return getWorktreePaths().filter((p) => p.startsWith(ctx.worktreeBase + "/"));
|
||||
}
|
||||
|
||||
// Pure query — never exits. An empty list with a non-empty skipReason means
|
||||
// there is nothing to validate.
|
||||
function getWorktreesToValidate(ctx, { skipAdrOnly = false, only = "", base = "" } = {}) {
|
||||
if (!existsSync(ctx.repo)) {
|
||||
return { worktrees: [], skipReason: "cd_failed" };
|
||||
}
|
||||
const active = getActiveWorktrees(ctx, only);
|
||||
if (active.length === 0) {
|
||||
return { worktrees: [], skipReason: "no_active_worktree" };
|
||||
}
|
||||
// Compare against the branch the worktree forked from (the session branch,
|
||||
// passed by validate-on-stop) so the change set is the ticket's OWN work, not
|
||||
// everything since the repo's checked-out base branch — which would over- or
|
||||
// under-report a worktree's delta whenever the base branch has diverged from
|
||||
// the session branch. Falls back to the repo base branch when no override.
|
||||
const effectiveBase = base || getBaseBranch();
|
||||
const isAdrOnly = (files) => files.length > 0 && files.every((f) => f.startsWith("adr/"));
|
||||
const worktrees = active.filter((wt) => {
|
||||
const { dirty, changedFiles } = getWorktreeChangeSummary(wt, effectiveBase);
|
||||
if (!dirty) {
|
||||
ctx.log(`SKIP wt=${wt} (no changes)`);
|
||||
return false;
|
||||
}
|
||||
if (skipAdrOnly && isAdrOnly(changedFiles)) {
|
||||
ctx.log(`SKIP wt=${wt} (adr-only)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return { worktrees, skipReason: worktrees.length === 0 ? "no_dirty_worktree" : "" };
|
||||
}
|
||||
|
||||
const tailLines = (text, n) => text.split("\n").slice(-n).join("\n");
|
||||
|
||||
// Last `n` lines of a file, "" if it can't be read.
|
||||
const tailFile = (path, n) => {
|
||||
try {
|
||||
return tailLines(readFileSync(path, "utf8"), n);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Best-effort cleanup — a leftover temp file must never break validation.
|
||||
const tryUnlink = (path) => {
|
||||
try {
|
||||
unlinkSync(path);
|
||||
} catch {
|
||||
/* swallow error */
|
||||
}
|
||||
};
|
||||
|
||||
function runVitest(wt, configFile, projects = [], changedSince = "") {
|
||||
const projectTag = projects.length ? `-${projects.join("-")}` : "";
|
||||
const out = join(tmpdir(), `vitest-${basename(configFile)}${projectTag}-${process.pid}.out`);
|
||||
const projectFlags = projects.map((p) => `--project ${p}`).join(" ");
|
||||
// Scope to tests related to THIS worktree's diff since `changedSince` (the session
|
||||
// branch it forked from), so a ticket runs only its own affected tests, not the whole
|
||||
// suite. Pass the ref EXPLICITLY: the ticket's work is committed by stop time,
|
||||
// so a bare `--changed` (uncommitted-only) would find nothing and run zero tests (false
|
||||
// green). Empty ref -> full run (safe fallback). The end-of-feature smoke re-runs the
|
||||
// full suite on the integrated session branch, catching anything the module graph missed.
|
||||
const changedFlag = changedSince ? `--changed ${changedSince}` : "";
|
||||
const r = bash(
|
||||
`CI=true timeout 180 npx vitest run --config ${configFile} ${projectFlags} ${changedFlag} > "${out}" 2>&1`,
|
||||
{ cwd: wt },
|
||||
);
|
||||
const output = tailFile(out, 40);
|
||||
tryUnlink(out);
|
||||
return { status: r.status, output, timedOut: r.status === 124 };
|
||||
}
|
||||
|
||||
// Files this worktree changed that lint should check: the config `extensions`,
|
||||
// restricted to paths that still exist on disk. A deleted / renamed-away file
|
||||
// must not be handed to eslint (it would error "No files matching"). Pure —
|
||||
// unit-tested independently of the shell-out.
|
||||
export function scopedLintFiles(changedFiles, cwd, extensions) {
|
||||
return changedFiles.filter(
|
||||
(f) => extensions.some((e) => f.endsWith(e)) && existsSync(join(cwd, f)),
|
||||
);
|
||||
}
|
||||
|
||||
// Run eslint on ONLY this worktree's diff since `base` (the session branch it
|
||||
// forked from), mirroring the vitest `--changed` scoping: a ticket is judged on
|
||||
// its own changed files, never the whole repo — which also sidesteps pre-existing
|
||||
// lint noise in unrelated / generated files. Zero matching files -> nothing to
|
||||
// lint (ok). `base` empty -> repo base branch, matching getWorktreesToValidate.
|
||||
// `command` is the eslint invocation prefix (e.g. `npx eslint`); files are
|
||||
// appended here, so the config command must NOT carry its own file glob.
|
||||
function runEslintScoped(cwd, base, command, extensions, tail) {
|
||||
const { changedFiles } = getWorktreeChangeSummary(cwd, base || getBaseBranch());
|
||||
const files = scopedLintFiles(changedFiles, cwd, extensions);
|
||||
if (files.length === 0) return { ok: true };
|
||||
const out = join(tmpdir(), `eslint-${process.pid}.out`);
|
||||
const args = files.map((f) => `'${f.replace(/'/g, `'\\''`)}'`).join(" ");
|
||||
const r = bash(`${command} ${args} > "${out}" 2>&1`, { cwd });
|
||||
const output = tailFile(out, tail);
|
||||
tryUnlink(out);
|
||||
return r.status === 0 ? { ok: true } : { ok: false, output };
|
||||
}
|
||||
|
||||
// Per-kind tail length for failure output (mechanical, not a project fact).
|
||||
const TAIL_LINES = { format: 15, typecheck: 20, lint: 30, unit: 40, e2e: 50 };
|
||||
|
||||
// A step is skipped when its `condition` is not met. `pathExists` gates on a
|
||||
// path under the repo (e.g. the supabase/functions unit-fn gate); `modeNot`
|
||||
// skips when MODE equals the given value (e.g. e2e in demo mode).
|
||||
function stepSkipped(ctx, step) {
|
||||
const c = step.condition;
|
||||
if (!c) return false;
|
||||
if (c.pathExists && !existsSync(join(ctx.repo, c.pathExists))) {
|
||||
ctx.log(`SKIP ${step.id} (no ${c.pathExists})`);
|
||||
return true;
|
||||
}
|
||||
if (c.modeNot && (process.env.MODE || "demo") === c.modeNot) {
|
||||
ctx.log(`${step.id} skipped (${c.modeNot} mode)`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Run ONE config-declared step in `cwd`. Returns { ok } or { ok:false, output }.
|
||||
// The step id is the caller's fail-report label, so config ids (prettier,
|
||||
// typecheck, unit-app, unit-fn, e2e) ARE the reported step names.
|
||||
function runStep(ctx, step, { cwd, base }) {
|
||||
const tail = TAIL_LINES[step.kind] ?? 40;
|
||||
|
||||
if (step.runner === "vitest") {
|
||||
const r = runVitest(cwd, step.config, step.projects, step.changedScoped ? base : "");
|
||||
if (r.timedOut) return { ok: false, output: "TIMEOUT (>180s) -- vitest did not exit. Tests may be hanging." };
|
||||
return r.status === 0 ? { ok: true } : { ok: false, output: r.output };
|
||||
}
|
||||
|
||||
if (step.kind === "lint" && step.changedScoped) {
|
||||
const exts = step.extensions ?? [".ts", ".tsx", ".mjs"];
|
||||
return runEslintScoped(cwd, base, step.command, exts, tail);
|
||||
}
|
||||
|
||||
const r = bash(`${step.command} 2>&1`, { cwd });
|
||||
if (r.status === 0) {
|
||||
if (step.kind === "format" && step.autoCommit) {
|
||||
if (exec("git", ["-C", cwd, "diff", "--quiet"]).status !== 0) {
|
||||
exec("git", ["-C", cwd, "add", "-A"]);
|
||||
exec("git", ["-C", cwd, "commit", "-m", `style(${basename(cwd)}): auto-apply prettier`]);
|
||||
ctx.log(`auto-prettier committed wt=${cwd}`);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
const detail =
|
||||
step.kind === "format"
|
||||
? "Prettier could not format one or more files (likely a syntax error). Fix the issue and commit.\n" +
|
||||
tailLines(r.stdout, tail)
|
||||
: tailLines(r.stdout, tail);
|
||||
return { ok: false, output: detail };
|
||||
}
|
||||
|
||||
// The full validation chain, run by validate-on-stop.mjs on SubagentStop (after
|
||||
// every developer stop). Steps are declared in harness.config.json's
|
||||
// `validation.steps` (single source of truth, shared with bash-guard) and run in
|
||||
// config order: steps without `cwd:"repo"` run per dirty worktree (fail-fast);
|
||||
// `cwd:"repo"` steps (e2e) run once in the repo after the per-worktree pass.
|
||||
// VALIDATE_DRY_RUN=1 skips everything, =fail simulates a failure. A malformed
|
||||
// config fails closed. Returns { ok:true, skipReason? } or { ok:false, step, output }.
|
||||
export function runValidationSteps(ctx, { worktree = "", base = "" } = {}) {
|
||||
if (process.env.VALIDATE_DRY_RUN === "1") {
|
||||
ctx.log("DRY_RUN=1, skipping validation");
|
||||
return { ok: true, skipReason: "dry_run" };
|
||||
}
|
||||
if (process.env.VALIDATE_DRY_RUN === "fail") {
|
||||
ctx.log("DRY_RUN=fail, simulating a failure");
|
||||
return { ok: false, step: "dry-run", output: "Validation failed (simulated)." };
|
||||
}
|
||||
|
||||
let steps;
|
||||
try {
|
||||
steps = validationSteps(loadConfig(ctx.repo));
|
||||
} catch (e) {
|
||||
// Fail closed: a malformed config must block the stop, not silently pass.
|
||||
return { ok: false, step: "config", output: `${e.message}\n` };
|
||||
}
|
||||
|
||||
const { worktrees, skipReason } = getWorktreesToValidate(ctx, {
|
||||
skipAdrOnly: true,
|
||||
only: worktree,
|
||||
base,
|
||||
});
|
||||
if (skipReason) return { ok: true, skipReason };
|
||||
|
||||
const perWorktree = steps.filter((s) => s.cwd !== "repo");
|
||||
const repoLevel = steps.filter((s) => s.cwd === "repo");
|
||||
|
||||
// Enrich the #technical-harness progress log with each validation step as it runs,
|
||||
// so the otherwise-silent multi-minute chain (typecheck, lint, vitest, e2e) shows up
|
||||
// in a `tail -f` / the board / a Monitor. Inert (no-op) on a non-technical run: the
|
||||
// log does not exist there, so appendProgress writes nothing and creates nothing.
|
||||
const progress = (line) => appendProgress(ctx.sessionDir, line);
|
||||
|
||||
for (const wt of worktrees) {
|
||||
const label = basename(wt);
|
||||
for (const step of perWorktree) {
|
||||
if (stepSkipped(ctx, step)) continue;
|
||||
progress(`[validate:${label}] ${step.id}…`);
|
||||
const r = runStep(ctx, step, { cwd: wt, base });
|
||||
if (!r.ok) {
|
||||
progress(`[validate:${label}] ${step.id} FAILED`);
|
||||
ctx.log(`FAIL step=${step.id} wt=${wt}\n${r.output}`);
|
||||
return { ok: false, step: step.id, output: `=== ${step.id} failed in ${wt} ===\n${r.output}\n` };
|
||||
}
|
||||
}
|
||||
progress(`[validate:${label}] checks passed`);
|
||||
ctx.log(`OK wt=${wt}`);
|
||||
}
|
||||
|
||||
for (const step of repoLevel) {
|
||||
if (stepSkipped(ctx, step)) continue;
|
||||
progress(`[validate:repo] ${step.id}…`);
|
||||
const r = runStep(ctx, step, { cwd: ctx.repo, base });
|
||||
if (!r.ok) {
|
||||
progress(`[validate:repo] ${step.id} FAILED`);
|
||||
ctx.log(`FAIL step=${step.id} exit`);
|
||||
return { ok: false, step: step.id, output: r.output + "\n" };
|
||||
}
|
||||
ctx.log(`${step.id} OK`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
88
.claude/hooks/lib/workspace-folders.mjs
Normal file
88
.claude/hooks/lib/workspace-folders.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
// Show a harness worktree in the editor by adding it as a workspace folder, so
|
||||
// its files AND its Source Control diff appear live during a run. Two mechanisms,
|
||||
// auto-detected:
|
||||
//
|
||||
// - A `.code-workspace` file beside the repo (multi-root workspace): edit its
|
||||
// `folders` array. VS Code live-reloads the file, so add AND remove are clean.
|
||||
// - A mono-folder window (no `.code-workspace`): `code --add <path>` asks the
|
||||
// running VS Code window to add the folder (which turns it into an untitled
|
||||
// multi-root workspace). There is no `code --remove` in the VS Code CLI, so
|
||||
// removal is a no-op here; the untitled workspace is not persisted, so the
|
||||
// entry is gone on the next fresh open anyway.
|
||||
//
|
||||
// Every function is best-effort and never throws: a convenience view must not
|
||||
// break a run. A parse failure leaves the file untouched (never corrupt it).
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { exec } from "./process.mjs";
|
||||
|
||||
// The `.code-workspace` file beside the repo (the multi-root workspace root is
|
||||
// dirname(repo)). Returns its absolute path, or null (mono-folder / none).
|
||||
export function workspaceFile(repo) {
|
||||
const root = dirname(repo);
|
||||
try {
|
||||
const name = readdirSync(root).find((n) => n.endsWith(".code-workspace"));
|
||||
return name ? join(root, name) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkspace(file) {
|
||||
try {
|
||||
const ws = JSON.parse(readFileSync(file, "utf8"));
|
||||
return ws && Array.isArray(ws.folders) ? ws : null;
|
||||
} catch {
|
||||
return null; // JSONC/comments or unreadable — do not touch it
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorkspace(file, ws) {
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify(ws, null, 2) + "\n");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// A VS Code window is reachable from a hook subprocess (its IPC env is present).
|
||||
const canDriveWindow = () =>
|
||||
Boolean(process.env.VSCODE_IPC_HOOK_CLI || process.env.REMOTE_CONTAINERS_IPC);
|
||||
|
||||
/**
|
||||
* Add `path` (an absolute worktree path) as a workspace folder named `name`.
|
||||
* @returns {boolean} whether the folder was added (or already present).
|
||||
*/
|
||||
export function addWorktreeFolder(repo, path, name) {
|
||||
if (!existsSync(path)) return false;
|
||||
const file = workspaceFile(repo);
|
||||
if (file) {
|
||||
const ws = readWorkspace(file);
|
||||
if (!ws) return false;
|
||||
if (ws.folders.some((f) => f.path === path)) return true; // idempotent
|
||||
ws.folders.push({ path, name });
|
||||
return writeWorkspace(file, ws);
|
||||
}
|
||||
// Mono-folder window: ask VS Code to add it (add-only, self-heals on reopen).
|
||||
if (canDriveWindow()) return exec("code", ["--add", path]).status === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every workspace folder whose absolute path satisfies `matches(path)`
|
||||
* (e.g. a prefix match on the session's worktree base, or an exact path). Only
|
||||
* the `.code-workspace` mechanism supports removal; mono-folder is a no-op.
|
||||
* @returns {boolean} whether anything was removed.
|
||||
*/
|
||||
export function removeWorktreeFolders(repo, matches) {
|
||||
const file = workspaceFile(repo);
|
||||
if (!file) return false;
|
||||
const ws = readWorkspace(file);
|
||||
if (!ws) return false;
|
||||
const kept = ws.folders.filter((f) => !(f.path && matches(f.path)));
|
||||
if (kept.length === ws.folders.length) return false;
|
||||
ws.folders = kept;
|
||||
return writeWorkspace(file, ws);
|
||||
}
|
||||
28
.claude/hooks/lib/worktree.mjs
Normal file
28
.claude/hooks/lib/worktree.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
// Worktree teardown primitives shared by the cleanup hooks (cleanup-worktree,
|
||||
// cleanup-session). Mutations live here, kept separate from git.mjs's read-only
|
||||
// queries. The harness-revert script reuses getWorktreeEntries (read) directly
|
||||
// but keeps its own mutations because it honours --dry-run.
|
||||
|
||||
import { rmSync } from "node:fs";
|
||||
import { getWorktreeEntries, git } from "./git.mjs";
|
||||
|
||||
// Remove one worktree: `git worktree remove --force`, falling back to a plain
|
||||
// recursive rm when git refuses (e.g. the admin entry is already gone). Callers
|
||||
// prune afterwards (removeWorktreesUnder does; a lone caller should too).
|
||||
export function removeWorktree(path) {
|
||||
if (git(["worktree", "remove", "--force", path]).status !== 0) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove every registered worktree at or under `base`, then prune the admin
|
||||
// refs. Returns the count removed. The main repo worktree is never under a
|
||||
// session base, so it is excluded naturally.
|
||||
export function removeWorktreesUnder(base) {
|
||||
const under = getWorktreeEntries().filter(
|
||||
(e) => e.path === base || e.path.startsWith(base + "/"),
|
||||
);
|
||||
under.forEach((e) => removeWorktree(e.path));
|
||||
git(["worktree", "prune"]);
|
||||
return under.length;
|
||||
}
|
||||
Reference in New Issue
Block a user