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

This commit is contained in:
upstream-mirror
2026-09-18 01:48:50 +05:00
commit 93ecc1a6f8
787 changed files with 75240 additions and 0 deletions

View 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;
}
}

View 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,
);

View 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,
};
}

View 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
View 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
View 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");
}

View 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, "_");
}

View 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);
}

View 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;
}
}

View 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}`);

View 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 };
}
}

View 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 || "");

View 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";

View 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 };
}

View 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);
}

View 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;
}