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,168 @@
#!/usr/bin/env node
// ─────────────────────────────────────────────────────────────
// apply-migrations — Apply committed migrations to the local
// Supabase instance.
//
// Usage:
// node apply-migrations.mjs
//
// Behaviour:
// Migrations are already committed to supabase/migrations/ on
// the base branch (written by the deploy-time migration round and
// merged by the merger) before this script runs. There is nothing
// to promote — this script only applies what is already there.
//
// If Supabase is not running: start it (initial start applies
// every migration in supabase/migrations/).
// If Supabase is already running: run `npx supabase migration up`.
//
// After applying, the PostgREST schema cache is reloaded so
// new columns/tables are visible immediately.
//
// Portability (works on both crm-builder and a bare AtomicCRM checkout):
// APP_DIR — repo root. Defaults to $CLAUDE_PROJECT_DIR, then /app.
// Migrations are always applied regardless of the app's data mode:
// crm-builder runs in demo (FakeRest) by default at migration time,
// but the committed migrations must still land in local Supabase so
// they are ready when the app is switched to live data. The demo|full
// MODE distinction belongs only to the live-switch step, not here.
// Supabase is driven via the `supabase` CLI, which manages its own
// Docker containers — we do NOT gate on /var/run/docker.sock so the
// same script works wherever the CLI is available (e.g. `make`).
// ─────────────────────────────────────────────────────────────
import { spawnSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
// Resolve the repo root robustly. Prefer an explicit override / the env var, then
// the git toplevel — works on any surface (local, devcontainer, CRM Builder)
// without relying on CLAUDE_PROJECT_DIR being EXPORTED to this node process. The
// old "/app" hardcoded fallback was a CRM-Builder-ism: on a bare checkout where the
// env var wasn't exported, it chdir'd into a non-writable /app → EACCES on mkdir.
// git is only spawned as the fallback — when an env var is set (the common harness
// case) we never shell out.
const gitToplevel = () => {
const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
encoding: "utf8",
});
return (r.status === 0 && r.stdout.trim()) || "";
};
const APP_DIR =
process.env.APP_DIR ||
process.env.CLAUDE_PROJECT_DIR ||
gitToplevel() ||
process.cwd();
const GREEN = "\x1b[0;32m";
const YELLOW = "\x1b[1;33m";
const RED = "\x1b[0;31m";
const BOLD = "\x1b[1m";
const NC = "\x1b[0m";
const log = (msg) => process.stdout.write(`${msg}\n`);
const err = (msg) => process.stderr.write(`${msg}\n`);
process.chdir(APP_DIR);
mkdirSync("supabase/migrations", { recursive: true });
// Run a command, capturing combined stdout+stderr as text.
function run(cmd, args) {
const res = spawnSync(cmd, args, { encoding: "utf8" });
const out = (res.stdout || "") + (res.stderr || "");
return { out, code: res.status ?? (res.error ? 1 : 0) };
}
async function isSupabaseUp() {
try {
await fetch("http://localhost:54321", {
signal: AbortSignal.timeout(2000),
});
return true;
} catch {
return false;
}
}
function reloadSchemaCache() {
const { code } = run("npx", [
"supabase",
"db",
"query",
"--local",
"SELECT pg_notify('pgrst', 'reload schema');",
]);
if (code === 0) {
log(`${GREEN}PostgREST schema cache reloaded.${NC}`);
} else {
log(`${YELLOW}Could not reload PostgREST schema cache (non-fatal).${NC}`);
}
}
if (await isSupabaseUp()) {
// ── Apply migrations to running Supabase ──
log(`${BOLD}Applying pending migrations to running Supabase...${NC}`);
let { out, code } = run("npx", ["supabase", "migration", "up", "--local"]);
if (code !== 0) {
// Auto-repair phantom versions that are recorded in Supabase but absent from
// the local migrations/ directory (happens when a prior session's worktree was
// cleaned up after applying the migration but before it landed in git).
if (
out.includes(
"Remote migration versions not found in local migrations directory",
)
) {
const phantomVersions = [...new Set(out.match(/\d{14}/g) ?? [])];
if (phantomVersions.length > 0) {
log(
`${YELLOW}Auto-repairing phantom migration versions: ${phantomVersions.join(" ")}${NC}`,
);
run("npx", [
"supabase",
"migration",
"repair",
"--local",
"--status",
"reverted",
...phantomVersions,
]);
({ out, code } = run("npx", [
"supabase",
"migration",
"up",
"--local",
]));
}
}
if (code !== 0) {
err(out);
err(`${RED}Migration up failed.${NC}`);
process.exit(1);
}
}
log(`${GREEN}Migrations applied.${NC}`);
reloadSchemaCache();
} else {
// ── Initial start applies all migrations ──
log(
`${BOLD}Starting Supabase (initial start applies all migrations)...${NC}`,
);
log(`${YELLOW}First run can take up to ~2 min to pull images.${NC}`);
const { out } = run("npx", ["supabase", "start"]);
for (const line of out.split("\n")) {
if (/✓|✗|Error|Started|API URL/.test(line)) log(line);
}
log(`${BOLD}Waiting for Supabase API (localhost:54321)...${NC}`);
let retries = 120;
while (!(await isSupabaseUp())) {
retries -= 1;
if (retries <= 0) {
err(`${RED}Supabase did not respond after 120s.${NC}`);
process.exit(1);
}
await sleep(1000);
}
log(`${GREEN}Supabase ready and all migrations applied.${NC}`);
reloadSchemaCache();
}

View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// Verify that every agent-role referenced by a settings.json SubagentStop matcher
// is a declared role in harness.config.json (config.roles). Claude Code requires
// settings.json matchers to be literal strings (they cannot be computed from the
// config), so this check is the bridge that keeps the two in sync: rename a role
// in config AND settings -> the matcher token still resolves, pass; rename it in
// config only -> the stale settings token is not a config role, fail.
//
// Usage: node .claude/scripts/check-config-sync.mjs [--app <repo>]
// Exit 0 = in sync; exit 1 = drift (prints the offending tokens).
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { loadConfig, roleNames } from "../hooks/lib/config.mjs";
function resolveRepo(argv) {
const flag = argv.indexOf("--app");
if (flag !== -1 && argv[flag + 1]) return argv[flag + 1];
return process.env.APP_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
}
// SubagentStop matchers are role tokens (possibly `a|b`); PreToolUse/PostToolUse
// matchers are tool names (Bash, Agent, Write|Edit) and are NOT roles, so only
// SubagentStop is checked here.
function settingsRoleTokens(settings) {
const entries = settings.hooks?.SubagentStop ?? [];
const tokens = new Set();
for (const entry of entries) {
const matcher = entry.matcher;
if (!matcher || typeof matcher !== "string") continue;
for (const t of matcher
.split("|")
.map((s) => s.trim())
.filter(Boolean)) {
tokens.add(t);
}
}
return [...tokens];
}
export function checkConfigSync(repo) {
const settingsPath = join(repo, ".claude", "settings.json");
if (!existsSync(settingsPath)) {
return { ok: false, error: `settings.json not found at ${settingsPath}` };
}
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch (e) {
return {
ok: false,
error: `settings.json is not valid JSON: ${e.message}`,
};
}
let roles;
try {
roles = new Set(roleNames(loadConfig(repo)));
} catch (e) {
return { ok: false, error: e.message };
}
const tokens = settingsRoleTokens(settings);
const missing = tokens.filter((t) => !roles.has(t));
return { ok: missing.length === 0, missing, tokens, roles: [...roles] };
}
// Run as CLI when invoked directly (not when imported by a test).
if (import.meta.url === `file://${process.argv[1]}`) {
const repo = resolveRepo(process.argv.slice(2));
const r = checkConfigSync(repo);
if (r.error) {
console.error(`check-config-sync: ${r.error}`);
process.exit(1);
}
if (!r.ok) {
console.error(
`check-config-sync: settings.json SubagentStop matcher(s) reference roles not in harness.config.json: ${r.missing.join(", ")}.\n` +
`Declared roles: ${r.roles.join(", ")}.\n` +
`Rename the role in BOTH files, or add it to config.roles.`,
);
process.exit(1);
}
process.stdout.write(
`check-config-sync: OK (${r.tokens.length} role matcher(s) all declared)\n`,
);
process.exit(0);
}

155
.claude/scripts/e2e-smoke.sh Executable file
View File

@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# e2e-smoke.sh - run the Playwright e2e suite against an ISOLATED, disposable Supabase
# instance so multiple harness sessions can run it in parallel without colliding.
# Guaranteed teardown via `trap ... EXIT`.
#
# Isolation per run: a leased SLOT (0..K-1) gives offset = slot*20, applied to the
# project_id, every Supabase port, and the app port + auth URLs, in a throwaway workdir.
# The flock lease also CAPS concurrency (K stacks max) so we never OOM the host.
#
# Exit codes: 0 = suite passed OR gracefully skipped (no slot / low RAM / cannot start);
# 1 = the e2e suite ran and FAILED. Skips are exit 0 by design (the caller
# treats a skip as "not run here, run it later", not as a failure).
#
# Env: E2E_SMOKE_SLOTS (default 5), E2E_SMOKE_MIN_MB (default 2500), E2E_SMOKE_DRY=1
# (resolve slot/ports/config and print them, then exit WITHOUT Docker - for tests).
set -uo pipefail
REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
# Where to read the app + specs + schema FROM. A feature-smoke must run the INTEGRATED
# session code, not the base-branch checkout ($REPO) - so the orchestrator passes
# E2E_SMOKE_SRC=<WORKTREE_BASE>/_session (the session worktree, on session/<short>, with
# node_modules already provisioned). Default $REPO = a base-branch baseline check.
SRC="${E2E_SMOKE_SRC:-$REPO}"
SLOTS="${E2E_SMOKE_SLOTS:-5}"
MIN_MB="${E2E_SMOKE_MIN_MB:-2500}"
DRY="${E2E_SMOKE_DRY:-0}"
SLOT_LOCK_DIR="/tmp/atomic-crm-e2e-slots"
CONFIG_SRC="$SRC/supabase/config.e2e.toml"
mkdir -p "$SLOT_LOCK_DIR"
skip() { echo "SKIP: $*"; exit 0; } # graceful: not run here, not a failure
# --- memory preflight -------------------------------------------------------
# Each Supabase stack is ~2-3 GB. Don't attempt a boot the host can't hold; skip
# gracefully so the caller defers to a human `make test-e2e` instead of OOM-ing.
avail_mb="$(free -m 2>/dev/null | awk '/^Mem:/{print $7}')"
if [ "$DRY" != "1" ] && [ -n "$avail_mb" ] && [ "$avail_mb" -lt "$MIN_MB" ]; then
skip "only ${avail_mb}MB free, need ~${MIN_MB}MB for a Supabase e2e stack; run 'make test-e2e' locally."
fi
# --- lease a slot (flock; also the concurrency cap) -------------------------
slot=""; SLOT_FD=""
for i in $(seq 0 $((SLOTS - 1))); do
exec {fd}>"$SLOT_LOCK_DIR/slot-$i.lock"
if flock -n "$fd"; then slot="$i"; SLOT_FD="$fd"; break; fi
exec {fd}>&-
done
[ -z "$slot" ] && skip "all $SLOTS e2e slots busy; try again later."
offset=$((slot * 20))
project="atomic-crm-e2e-$slot"
api_port=$((54341 + offset))
app_port=$((5175 + slot))
workroot="$(mktemp -d)"
workdir="$workroot/e2e" # supabase --workdir
mkdir -p "$workdir/supabase"
# --- guaranteed teardown ----------------------------------------------------
APP_PID=""
cleanup() {
local code=$?
[ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true
[ "$DRY" != "1" ] && npx supabase stop --workdir "$workdir" --no-backup >/dev/null 2>&1 || true
[ -n "$SLOT_FD" ] && flock -u "$SLOT_FD" 2>/dev/null || true
rm -rf "$workroot" 2>/dev/null || true
return $code
}
trap cleanup EXIT INT TERM
# --- render the per-slot config ---------------------------------------------
# Offset every Supabase port (`port =`, `shadow_port =`, `vector_port =`) by `offset`,
# set a unique project_id (distinct Docker containers), and point the auth URLs at the
# per-slot app port. perl `/e` evaluates the numeric replacement.
[ -f "$CONFIG_SRC" ] || skip "no $CONFIG_SRC"
perl -pe "s/^(project_id\s*=\s*).*/\$1\"$project\"/;
s/((?:^|_)port\s*=\s*)(\d+)/\$1.(\$2+$offset)/e;
s/5175/$app_port/g" "$CONFIG_SRC" > "$workdir/supabase/config.toml"
# Supabase needs the schema/migrations/seed to build the DB on first start.
for d in migrations schemas functions templates; do
[ -d "$SRC/supabase/$d" ] && cp -r "$SRC/supabase/$d" "$workdir/supabase/$d"
done
for f in seed.sql signing_keys.json; do
[ -f "$SRC/supabase/$f" ] && cp "$SRC/supabase/$f" "$workdir/supabase/$f"
done
if [ "$DRY" = "1" ]; then
echo "DRY-RUN slot=$slot offset=$offset project=$project api_port=$api_port app_port=$app_port workdir=$workdir"
echo "config.project_id=$(grep -E '^project_id' "$workdir/supabase/config.toml")"
echo "config.api_port=$(awk '/^\[api\]/{a=1} a&&/^port =/{print $3; exit}' "$workdir/supabase/config.toml")"
echo "config.site_url=$(grep -E '^site_url' "$workdir/supabase/config.toml" | head -1)"
exit 0
fi
# --- reap a previously killed run of this slot -----------------------------
# The trap cleans up normal exits (EXIT/INT/TERM); a SIGKILL would orphan this slot's
# containers. Before starting fresh, remove any lingering ones for this project_id.
docker ps -aq --filter "name=${project}" 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1 || true
# --- start the isolated stack ----------------------------------------------
echo "e2e-smoke: starting isolated Supabase (slot $slot, api :$api_port, project $project)..."
if ! npx supabase start --workdir "$workdir" >/"$workroot/supabase.log" 2>&1; then
cat "$workroot/supabase.log" >&2 || true
skip "isolated Supabase failed to start (see log); deferring e2e to a human run."
fi
SERVICE_ROLE_KEY="$(npx supabase status --workdir "$workdir" -o env 2>/dev/null | sed -nE 's/^SERVICE_ROLE_KEY="?([^"]+)"?/\1/p')"
ANON_KEY="$(npx supabase status --workdir "$workdir" -o env 2>/dev/null | sed -nE 's/^ANON_KEY="?([^"]+)"?/\1/p')"
export VITE_SUPABASE_URL="http://127.0.0.1:$api_port"
export VITE_SUPABASE_ANON_KEY="$ANON_KEY"
export SERVICE_ROLE_KEY
# --- materialize the session schema the deploy-time migration will carry --------
# Ticket developers never write migrations, so a schema-touching session has
# supabase/schemas/ ahead of supabase/migrations/. The CLI builds the DB from
# migrations/ only, so without this the isolated stack lacks the new columns and
# schema-exercising specs 400 against PostgREST - a false FAIL. When the
# session changed supabase/schemas/, generate a THROWAWAY migration from the delta
# INTO THE WORKDIR (never $SRC) and apply it. Best-effort: on any failure SKIP the
# leg rather than emit a false FAIL (the real migration lands in the deploy round).
schema_changed=0
branch="$(git -C "$SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
case "$branch" in
session/*)
base="session-base/${branch#session/}"
if git -C "$SRC" rev-parse --verify -q "$base" >/dev/null 2>&1 \
&& git -C "$SRC" diff --name-only "$base"...HEAD 2>/dev/null | grep -q '^supabase/schemas/'; then
schema_changed=1
fi
;;
esac
if [ "$schema_changed" = "1" ] && [ -d "$workdir/supabase/schemas" ]; then
echo "e2e-smoke: session changed supabase/schemas/, materializing a throwaway migration..."
if npx supabase db diff --workdir "$workdir" --local -f _e2e_throwaway >"$workroot/dbdiff.log" 2>&1; then
if ls "$workdir"/supabase/migrations/*_e2e_throwaway.sql >/dev/null 2>&1; then
npx supabase migration up --workdir "$workdir" --local >"$workroot/migup.log" 2>&1 \
|| { cat "$workroot/migup.log" >&2; skip "schema pending migration round (throwaway apply failed); deferring the Supabase e2e leg to the deploy-time migration."; }
fi
else
cat "$workroot/dbdiff.log" >&2
skip "schema pending migration round (throwaway diff failed); deferring the Supabase e2e leg to the deploy-time migration."
fi
fi
# --- serve the app (dev server reads env at start, so no per-slot build) ----
( cd "$SRC" && VITE_SUPABASE_URL="$VITE_SUPABASE_URL" npx vite --port "$app_port" --strictPort --mode e2e >"$workroot/app.log" 2>&1 ) &
APP_PID=$!
npx wait-on -t 120000 "http-get://127.0.0.1:$api_port/auth/v1/health" "http-get://127.0.0.1:$app_port" >/dev/null 2>&1 \
|| skip "isolated stack did not become ready in time."
# --- run the suite ----------------------------------------------------------
echo "e2e-smoke: running Playwright against the isolated stack..."
( cd "$SRC" && CI=true PLAYWRIGHT_BASE_URL="http://127.0.0.1:$app_port" npx playwright test )
result=$?
echo "e2e-smoke: suite exit=$result (slot $slot)"
exit $result

View File

@@ -0,0 +1,682 @@
#!/usr/bin/env node
//
// Harness monitor — a terminal view of an agent-workflow session.
//
// Gather informations from three places, all keyed by the session id:
//
// 1. hooks.log <TMP_ROOT>/<sanitized-repo>/<id>/hooks.log
// 2. subagent transcripts ~/.claude/projects/<slug>/<id>/subagents/*.jsonl
// 3. tickets <CHAT_SESSION_DIR or TICKETS_DIR>/TASK-*.json
//
// Usage:
// .claude/scripts/harness-monitor.mjs # latest session, one-shot summary
// .claude/scripts/harness-monitor.mjs --watch # latest session, live (re-renders)
// .claude/scripts/harness-monitor.mjs --session <id> # a specific session id
// .claude/scripts/harness-monitor.mjs --list # list known sessions, newest first
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join, basename } from "node:path";
import { homedir } from "node:os";
import { execSync } from "node:child_process";
// ─── paths & session discovery ──────────────────────────────────────────────
const REPO = (() => {
if (process.env.CLAUDE_PROJECT_DIR) return process.env.CLAUDE_PROJECT_DIR;
try {
return execSync("git rev-parse --show-toplevel", {
encoding: "utf8",
}).trim();
} catch {
return process.cwd();
}
})();
const TMP_ROOT = process.env.CRM_TMP_ROOT || "/tmp";
const sanitize = (p) => String(p).replace(/\//g, "_");
const WORKTREE_ROOT = join(TMP_ROOT, sanitize(REPO)); // /tmp/_workspaces_atomic-crm
const CLAUDE_PROJ = join(
homedir(),
".claude",
"projects",
// Claude Code names the project dir by replacing EVERY non-alphanumeric char
// (slashes AND dots, e.g. ".code-workspace") with "-". Replacing only "/" broke
// discovery for any repo path containing a dot → "no subagent transcripts found".
REPO.replace(/[^a-zA-Z0-9]/g, "-"),
);
// A session id is a worktree-base subdir that has a hooks.log. Test fixtures
// (td-1, td-2, …) are skipped — they are not real sessions.
function listSessions() {
if (!existsSync(WORKTREE_ROOT)) return [];
return readdirSync(WORKTREE_ROOT)
.filter((name) => !/^td-/.test(name))
.map((id) => ({ id, log: join(WORKTREE_ROOT, id, "hooks.log") }))
.filter((s) => existsSync(s.log))
.map((s) => ({ ...s, mtime: statSync(s.log).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime);
}
function sessionPaths(id) {
const base = join(WORKTREE_ROOT, id);
return {
id,
base,
hooksLog: join(base, "hooks.log"),
mainTranscript: join(CLAUDE_PROJ, `${id}.jsonl`),
subagentsDir: join(CLAUDE_PROJ, id, "subagents"),
// Tickets land in the session dir itself (TICKETS_DIR = session_dir = base);
// <base>/tickets is the legacy fallback layout.
ticketDirs: [base, join(base, "tickets")],
};
}
// A worktree path → a short, human label. `simple` is the simple-developer's
// worktree; `_session` is the shared integration worktree the merger merges
// into before promoting to the base branch.
function wtLabel(path) {
if (!path) return "";
const b = basename(path);
if (b === "_session") return "_session (integration)";
if (b === "simple") return "simple (dev worktree)";
return b; // TASK-XXX
}
// ─── colour helpers ─────────────────────────────────────────────────────────
const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
const c = (code, s) => (COLOR ? `\x1b[${code}m${s}\x1b[0m` : s);
const dim = (s) => c("2", s);
const bold = (s) => c("1", s);
const red = (s) => c("31", s);
const green = (s) => c("32", s);
const yellow = (s) => c("33", s);
const blue = (s) => c("36", s);
const mag = (s) => c("35", s);
const hhmmss = (iso) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(11, 19);
};
// ─── hooks.log parsing ──────────────────────────────────────────────────────
// Line shape: `[<iso-ts>] [<hook>] <rest...>`. Lines without that prefix are
// continuation output (e.g. a multi-line validation failure) and attach to the
// previous event as detail.
const LINE_RE = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s+(.*)$/;
function parseHooksLog(path) {
if (!existsSync(path)) return [];
const events = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line) continue;
const m = line.match(LINE_RE);
if (!m) {
if (events.length) events[events.length - 1].detail.push(line);
continue;
}
const [, ts, hook, rest] = m;
const fields = {};
for (const f of rest.matchAll(/(\w+)=(\S+)/g)) fields[f[1]] = f[2];
const state = rest.split(/\s+/)[0];
events.push({ ts, hook, state, rest, fields, detail: [] });
}
return events;
}
// ─── subagent transcripts ───────────────────────────────────────────────────
function readJsonl(path) {
if (!existsSync(path)) return [];
const out = [];
for (const line of readFileSync(path, "utf8").split("\n")) {
if (!line.trim()) continue;
try {
out.push(JSON.parse(line));
} catch {
/* skip malformed line */
}
}
return out;
}
// Claude Code records a failed hook command as an `attachment` whose inner type
// is hook_(non_)blocking_error.
function hookErrorsFrom(records) {
const errs = [];
for (const rec of records) {
const a = rec.attachment;
if (!a || !/hook_(non_)?blocking_error/.test(a.type || "")) continue;
errs.push({
ts: rec.timestamp,
hookName: a.hookName || a.hookEvent || "hook",
stderr: (a.stderr || "").split("\n")[0].slice(0, 120),
command: (a.command || "").slice(0, 80),
exitCode: a.exitCode,
blocking: !/non_blocking/.test(a.type || ""),
});
}
return errs;
}
function readAgents(dir) {
if (!existsSync(dir)) return [];
const agents = [];
for (const file of readdirSync(dir)) {
if (!file.endsWith(".jsonl")) continue;
const agentId = basename(file, ".jsonl");
const meta = (() => {
const p = join(dir, `${agentId}.meta.json`);
try {
return JSON.parse(readFileSync(p, "utf8"));
} catch {
return {};
}
})();
const records = readJsonl(join(dir, file));
const toolCounts = {};
const messages = []; // SendMessage payloads, timestamped
let lastText = "";
let firstTs = null;
let lastTs = null;
for (const rec of records) {
if (rec.timestamp) {
if (!firstTs) firstTs = rec.timestamp;
lastTs = rec.timestamp;
}
const content = rec.message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block.type === "tool_use") {
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
if (block.name === "SendMessage") {
const to = block.input?.to ?? "?";
const msg = block.input?.message;
messages.push({
to,
text: typeof msg === "string" ? msg : JSON.stringify(msg),
ts: rec.timestamp,
});
}
} else if (block.type === "text" && block.text.trim()) {
lastText = block.text.trim();
}
}
}
agents.push({
agentId,
type: meta.agentType || "?",
description: meta.description || "",
toolCounts,
messages,
lastText,
firstTs,
lastTs,
hookErrors: hookErrorsFrom(records),
verdict: classifyVerdict(lastText),
});
}
// Chronological by last activity so repeated dispatches of the same agent
// (retries / resumes) read top-to-bottom in the order they ran.
return agents.sort((a, b) =>
String(a.lastTs || "").localeCompare(String(b.lastTs || "")),
);
}
// The orchestrator is the main session transcript, NOT a subagent.
function readOrchestrator(path) {
const records = readJsonl(path);
if (!records.length) return null;
let lastText = "";
let lastTs = null;
const dispatches = []; // { ts, type } — when the orchestrator spawned each agent
for (const rec of records) {
if (rec.timestamp) lastTs = rec.timestamp;
const content = rec.message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (
block.type === "tool_use" &&
(block.name === "Agent" || block.name === "Task")
)
dispatches.push({
ts: rec.timestamp,
type:
block.input?.subagent_type || block.input?.description || "agent",
});
else if (block.type === "text" && block.text.trim())
lastText = block.text.trim();
}
}
return { lastText, lastTs, dispatches, hookErrors: hookErrorsFrom(records) };
}
// Merge every timestamped signal — orchestrator spawns, agent SendMessages,
// agent completions, and the key hook milestones — into one chronological
// stream. This is the "what happened, in order" view: dev → merger, etc.
function buildEventTimeline(orchestrator, agents, events) {
const items = [];
const push = (ts, color, icon, text) =>
ts && items.push({ ts, color, icon, text });
for (const d of orchestrator?.dispatches ?? [])
push(d.ts, blue, "▶", `orchestrator spawned ${bold(d.type)}`);
for (const a of agents) {
for (const m of a.messages)
push(
m.ts,
mag,
"→",
`${a.type} → ${bold(m.to)}: ${m.text.split("\n")[0].slice(0, 80)}`,
);
const mark =
a.verdict.kind === "done"
? green("✓")
: a.verdict.kind === "fail"
? red("✗")
: dim("■");
const vcolor =
a.verdict.kind === "fail" ? red : a.verdict.kind === "done" ? green : dim;
push(
a.lastTs,
vcolor,
mark,
`${a.type} finished — ${a.verdict.label.slice(0, 80)}`,
);
}
for (const e of events) {
if (e.hook === "setup-worktree" && e.state === "CREATED")
push(e.ts, blue, "⊕", `worktree created ${dim(wtLabel(e.fields.path))}`);
else if (e.hook === "validate" && e.state === "OK")
push(e.ts, green, "✓", `validation OK ${dim(wtLabel(e.fields.wt))}`);
else if (e.hook === "validate" && e.state === "FAIL")
push(
e.ts,
red,
"✗",
`validation FAIL ${e.fields.step || ""}${e.detail.length ? dim(" — " + e.detail.join(" ").slice(0, 60)) : ""}`,
);
else if (e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest))
push(e.ts, red, "⛔", `circuit-breaker BLOCK (count=${e.fields.count})`);
}
if (orchestrator?.lastTs)
push(
orchestrator.lastTs,
dim,
"▣",
`orchestrator last said: ${orchestrator.lastText.split("\n").pop().slice(0, 80)}`,
);
return items.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
}
// Synthesize a single completion verdict for the whole session from the
// strongest available signal: the merger's DONE line, then a clean
// no-dirty-worktree finish, else still-running / unknown.
function sessionStatus(agents, events) {
const merger = agents.find((a) => a.type === "merger");
if (merger) {
const m = merger.lastText.match(/DONE:\s*commit=(\S+)/i);
if (m)
return green(
`✓ COMPLETE — merger promoted to main (commit ${m[1].replace(/[.,]$/, "")})`,
);
if (/promotion merged|merged .*into main/i.test(merger.lastText))
return green("✓ COMPLETE — merger reported the session merged into main");
if (merger.verdict.kind === "fail")
return red(`✗ merger FAILED — ${merger.verdict.label.slice(0, 120)}`);
}
if (agents.some((a) => a.verdict.kind === "fail"))
return red("✗ FAILED — an agent ended in failure (see DIAGNOSIS)");
const lastValidate = [...events]
.reverse()
.find((e) => e.hook === "validate" && e.state === "ACCEPT");
if (lastValidate && /no_dirty_worktree/.test(lastValidate.rest))
return yellow(
"… likely done — last validation found nothing left to do, but no merger DONE was seen",
);
return yellow("… in progress / outcome unclear");
}
// The structured-text contract: agents end with DONE / FAILED / BLOCKED, or a
// reviewer verdict.
function classifyVerdict(text) {
if (!text) return { kind: "running", label: "running / no final message" };
const head =
text
.split("\n")
.find((l) => l.trim())
?.slice(0, 200) ?? "";
if (/^FAILED\b|permanently deadlocked|\bBLOCKED\b/im.test(text))
return { kind: "fail", label: head };
if (
/^DONE:|merged\s+TASK|merged successfully|promotion merged|completed successfully|^Verdict:\s*(APPROVED|GREEN)/im.test(
text,
)
)
return { kind: "done", label: head };
return { kind: "info", label: head };
}
// ─── tickets ────────────────────────────────────────────────────────────────
function readTickets(dirs) {
for (const dir of dirs) {
if (!existsSync(dir)) continue;
const files = readdirSync(dir).filter((f) => /^TASK-\d+\.json$/.test(f));
if (!files.length) continue;
return files.sort().map((f) => {
try {
const t = JSON.parse(readFileSync(join(dir, f), "utf8"));
return {
id: t.id || basename(f, ".json"),
status: t.status || "?",
title: t.title || t.description || "",
};
} catch {
return { id: basename(f, ".json"), status: "unreadable", title: "" };
}
});
}
return [];
}
// ─── diagnosis ──────────────────────────────────────────────────────────────
// Turn the raw event stream into named pathologies.
function diagnose(events, agents, hookErrors = []) {
const findings = [];
// 0. Failed hook commands (from the transcript, not hooks.log). Aggregate by
// hook + first error line so a hook that fires on every tool use collapses
// into one finding with a count.
const byErr = new Map();
for (const h of hookErrors) {
const key = `${h.hookName} → ${h.stderr}`;
const row = byErr.get(key) ?? { ...h, count: 0 };
row.count++;
byErr.set(key, row);
}
for (const h of byErr.values()) {
const sev = h.blocking ? red("✗") : yellow("⚠");
const cmd = h.command ? dim(` [${h.command}]`) : "";
findings.push(
`${sev} ${h.blocking ? "BLOCKING " : ""}hook error ×${h.count}: ${h.hookName} — ${h.stderr} (exit ${h.exitCode})${cmd}`,
);
}
// 1. A validation step that keeps failing on the same worktree.
const validFails = events.filter(
(e) => e.hook === "validate" && e.state === "FAIL" && e.fields.step,
);
const byStep = {};
for (const e of validFails)
byStep[e.fields.step] = (byStep[e.fields.step] || 0) + 1;
for (const [step, n] of Object.entries(byStep)) {
if (n >= 2)
findings.push(
red(
`✗ validation never passes: '${step}' failed ${n}× — the flow cannot complete until this is green.`,
),
);
}
// 2. Circuit-breaker blocks → an agent is re-submitting an identical action.
const blocks = events.filter(
(e) => e.hook === "circuit-breaker" && /^BLOCK/.test(e.rest),
);
if (blocks.length) {
const maxCount = Math.max(
...blocks.map((b) => Number(b.fields.count) || 0),
);
findings.push(
red(
`✗ retry loop: circuit-breaker BLOCKED ${blocks.length}× (up to ${maxCount} identical attempts) — the agent is stuck repeating the same call.`,
),
);
}
// 3. bash-guard blocks → agent fighting the validation/permission rules.
const guard = events.filter(
(e) => e.hook === "bash-guard" && /BLOCK/.test(e.rest),
);
if (guard.length >= 3)
findings.push(
yellow(
`⚠ ${guard.length} bash-guard blocks — the agent tried to run forbidden commands (often a sign it's flailing).`,
),
);
// 4. Agents that reported failure
const seen = new Set();
for (const a of agents) {
const signals = [
a.verdict.kind === "fail" ? a.verdict.label : "",
...a.messages.map((m) => m.text),
];
for (const s of signals) {
const head = s.split("\n")[0];
if (
!/FAILED|BLOCKED|worktree not found|cannot create worktree/i.test(head)
)
continue;
const key = `${a.type}:${head.slice(0, 60)}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push(red(`✗ ${a.type}: ${head.slice(0, 140)}`));
}
}
// 4b. A TASK developer ran but its worktree was never created.
const createdTasks = new Set(
events
.filter(
(e) =>
e.hook === "setup-worktree" &&
/CREATED/.test(e.rest) &&
e.fields.branch,
)
.map((e) => (e.fields.branch.match(/TASK-\d+/) || [])[0])
.filter(Boolean),
);
for (const a of agents) {
const task = (a.type.match(/TASK-\d+/) || [])[0];
if (task && !createdTasks.has(task) && /developer/.test(a.type))
findings.push(
red(
`✗ ${task}: worktree never created (no setup-worktree CREATED entry) — the developer has nowhere to work.`,
),
);
}
// 5. Worktree setup failure.
if (
events.some(
(e) => e.hook === "setup-worktree" && /FAILED|EXIT=2/.test(e.rest),
)
)
findings.push(
red("✗ setup-worktree failed — no usable worktree was created."),
);
if (!findings.length) findings.push(green("✓ no known pathology detected."));
return findings;
}
// ─── rendering ──────────────────────────────────────────────────────────────
function render(id, limit = 0) {
const p = sessionPaths(id);
const events = parseHooksLog(p.hooksLog);
const agents = readAgents(p.subagentsDir);
const orchestrator = readOrchestrator(p.mainTranscript);
const tickets = readTickets(p.ticketDirs);
const out = [];
const line = (s = "") => out.push(s);
line(bold(blue(`━━━ harness session ${id} ━━━`)));
line(dim(`repo: ${REPO}`));
line(dim(`base: ${p.base}`));
if (existsSync(p.hooksLog))
line(
dim(
`hooks.log: ${events.length} events, updated ${hhmmss(new Date(statSync(p.hooksLog).mtime).toISOString())}`,
),
);
else line(red("hooks.log: missing"));
line();
// Headline: did the session complete?
line(bold("STATUS ") + sessionStatus(agents, events));
if (orchestrator)
line(
` ${dim("orchestrator last said:")} ${orchestrator.lastText.split("\n").pop().slice(0, 140) || dim("(none)")}`,
);
else line(` ${dim("orchestrator: main transcript not found")}`);
line();
// Agents
line(bold("AGENTS"));
if (!agents.length) line(dim(" (no subagent transcripts found)"));
for (const a of agents) {
const mark =
a.verdict.kind === "done"
? green("✓")
: a.verdict.kind === "fail"
? red("✗")
: yellow("…");
const tools = Object.entries(a.toolCounts)
.map(([n, k]) => `${n}:${k}`)
.join(" ");
line(` ${mark} ${bold(a.type)} ${dim(a.description)}`);
if (tools) line(` ${dim("tools")} ${tools}`);
for (const m of a.messages)
line(` ${mag("→ " + m.to)} ${m.text.split("\n")[0].slice(0, 100)}`);
if (a.verdict.label)
line(
` ${a.verdict.kind === "fail" ? red(a.verdict.label) : dim(a.verdict.label)}`,
);
}
line();
// Tickets
if (tickets.length) {
line(bold("TICKETS"));
for (const t of tickets) {
const cl =
t.status === "merged"
? green
: t.status === "in_progress"
? yellow
: dim;
line(` ${cl(t.status.padEnd(12))} ${t.id} ${dim(t.title.slice(0, 80))}`);
}
line();
}
// Unified event timeline: orchestrator spawns + agent messages + completions
// + key hook milestones, in chronological order. Consecutive identical rows
// (e.g. the same validation failing 40×) are run-length collapsed to `×N`.
let timeline = buildEventTimeline(orchestrator, agents, events);
const total = timeline.length;
if (limit && total > limit) timeline = timeline.slice(total - limit);
line(
bold("EVENT TIMELINE") +
dim(
` (chronological${limit && total > limit ? `, last ${limit} of ${total}` : ""})`,
),
);
let i = 0;
while (i < timeline.length) {
let j = i;
while (j + 1 < timeline.length && timeline[j + 1].text === timeline[i].text)
j++;
const n = j - i + 1;
const ev = timeline[i];
const span = n > 1 ? dim(` ×${n} (until ${hhmmss(timeline[j].ts)})`) : "";
line(` ${dim(hhmmss(ev.ts))} ${ev.color(ev.icon)} ${ev.text}${span}`);
i = j + 1;
}
line();
// Diagnosis — include hook-command failures gathered from every transcript
// (orchestrator + each subagent), which never appear in hooks.log.
const hookErrors = [
...(orchestrator?.hookErrors ?? []),
...agents.flatMap((a) => a.hookErrors ?? []),
];
line(bold("DIAGNOSIS"));
for (const f of diagnose(events, agents, hookErrors)) line(` ${f}`);
return out.join("\n");
}
// ─── main ───────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const has = (f) => args.includes(f);
const valueOf = (f) => {
const i = args.indexOf(f);
return i >= 0 ? args[i + 1] : null;
};
// This is a CLI tool: its whole job is to write a report to stdout. ESLint's
// no-console rule only permits warn/error, so write to stdout directly.
const print = (s = "") => process.stdout.write(s + "\n");
if (has("--list")) {
const sessions = listSessions();
if (!sessions.length) {
print("No sessions found under " + WORKTREE_ROOT);
process.exit(0);
}
print(bold("Sessions (newest first):"));
for (const s of sessions)
print(
` ${s.id} ${dim(new Date(s.mtime).toISOString().slice(0, 19).replace("T", " "))}`,
);
process.exit(0);
}
const sessionId = valueOf("--session") || listSessions()[0]?.id;
if (!sessionId) {
console.error(
red(
`No session found under ${WORKTREE_ROOT}. Start a harness session first (ask Claude Code for a change).`,
),
);
process.exit(1);
}
if (has("--watch")) {
// Use the terminal's alternate screen buffer (like top/htop/less): the view
// redraws in place and never pollutes the scrollback. We restore the normal
// screen on exit. The timeline is capped to the visible height so the newest
// events are always on screen instead of scrolling off the top.
const enter = () => process.stdout.write("\x1b[?1049h\x1b[?25l");
const leave = () => process.stdout.write("\x1b[?1049l\x1b[?25h");
const tick = () => {
const limit = Math.max(8, (process.stdout.rows || 40) - 18); // leave room for header/agents
process.stdout.write("\x1b[H\x1b[J"); // home + clear to end
process.stdout.write(render(sessionId, limit));
process.stdout.write(
"\n" + dim(`watching ${sessionId} — refresh 1.5s — Ctrl-C to quit`),
);
};
enter();
tick();
const iv = setInterval(tick, 1500);
const quit = () => {
clearInterval(iv);
leave();
process.exit(0);
};
process.on("SIGINT", quit);
process.on("SIGTERM", quit);
} else {
print(render(sessionId));
}

View File

@@ -0,0 +1,290 @@
#!/usr/bin/env node
//
// Per-session revert for the agent harness.
//
// Undoes ONE harness session's promoted work and removes its artifacts, leaving
// every other (possibly parallel) session untouched. Replaces a global hard-reset:
// instead of resetting the branch to a single fork anchor (which would wipe a
// parallel session that landed on the same branch), it reverts only THIS session's
// promotion merge commits, keyed by SESSION_SHORT_ID.
//
// Driven by the `/harness-revert` slash command (portable across CLI / VS Code
// extension / desktop), which resolves the short id and confirms before calling
// this. It can also be run directly.
//
// How a session lands on the base branch: the merger promotes with
// git merge --no-ff session/<short> -m "merge(session): <short>"
// (see .claude/agents/merger.md, Stage B). So this session's footprint on the base
// branch is exactly the merge commits whose subject is "merge(session): <short>".
//
// Usage:
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> # revert + clean up
// .claude/scripts/harness-revert.mjs <SESSION_SHORT_ID> --dry-run # print, change nothing
//
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, rmSync } from "node:fs";
import { join } from "node:path";
const argv = process.argv.slice(2);
const DRY_RUN = argv.includes("--dry-run") || argv.includes("-n");
const HARD = argv.includes("--hard");
const FORCE = argv.includes("--force");
const SHORT = argv.find((a) => !a.startsWith("-"));
const log = (msg) => process.stdout.write(`${msg}\n`);
const err = (msg) => process.stderr.write(`${msg}\n`);
if (!SHORT) {
err(
"usage: harness-revert.mjs <SESSION_SHORT_ID> [--hard] [--force] [--dry-run]",
);
process.exit(2);
}
// Run git, capturing output. Returns { code, out, err }.
function git(args) {
const res = spawnSync("git", args, { encoding: "utf8" });
return {
code: res.status ?? (res.error ? 1 : 0),
out: (res.stdout || "").trim(),
err: (res.stderr || "").trim(),
};
}
// Run a mutating git command, honouring --dry-run.
function gitRun(args) {
if (DRY_RUN) {
log(`[dry-run] git ${args.join(" ")}`);
return { code: 0, out: "", err: "" };
}
return git(args);
}
// Repo root (authoritative via git; falls back to env/cwd).
const top = git(["rev-parse", "--show-toplevel"]);
const REPO_ROOT =
(top.code === 0 && top.out) ||
process.env.CLAUDE_PROJECT_DIR ||
process.cwd();
process.chdir(REPO_ROOT);
const CURRENT_BRANCH = git(["rev-parse", "--abbrev-ref", "HEAD"]).out;
const BASE_BRANCH = git([
"config",
"--local",
"--get",
`sessionbase.${SHORT}.branch`,
]).out;
log(`==> Session: ${SHORT}`);
log(`==> Base branch: ${BASE_BRANCH || "(unknown — cleanup only)"}`);
if (DRY_RUN) log("==> Mode: DRY-RUN (no changes)");
if (HARD)
log("==> Mode: HARD (reset to fork — drops the session's commits)");
// Guard: undoing commits requires being ON the base branch the session promoted to.
const requireOnBase = () => {
if (BASE_BRANCH && CURRENT_BRANCH !== BASE_BRANCH) {
err(
`!! You are on '${CURRENT_BRANCH}' but session ${SHORT} promoted onto '${BASE_BRANCH}'.`,
);
err(` Switch to it first: git checkout ${BASE_BRANCH}`);
process.exit(1);
}
};
// Never let an undo clobber uncommitted work.
const stashDirty = () => {
if (git(["status", "--porcelain", "--untracked-files=no"]).out) {
log("==> Stashing uncommitted changes (recover with: git stash pop)");
gitRun(["stash", "push", "-m", `harness-revert ${SHORT} safety stash`]);
}
};
// Undo the session's promoted work. Two strategies:
// default → `git revert` the promotion merge commit(s): keeps history, adds a
// revert commit, SAFE even if other sessions landed on the same branch
// afterwards (it only backs out THIS session's changes).
// --hard → `git reset --hard` to the session's fork point (session-base/<short>):
// drops the session's commits ENTIRELY (clean history — no merge, no
// revert commit), but ALSO drops anything else committed after the fork.
// Use it when this session is the only thing on the branch (e.g. demos).
if (HARD) {
// Resolve the fork anchor NOW, before the cleanup section deletes it below.
const anchor = `session-base/${SHORT}`;
const fork = git(["rev-parse", "--verify", `refs/heads/${anchor}`]);
if (fork.code !== 0 || !fork.out) {
err(
`!! No fork anchor '${anchor}' — can't hard-reset. Run without --hard to revert instead.`,
);
process.exit(1);
}
const forkSha = fork.out;
requireOnBase();
if (git(["merge-base", "--is-ancestor", forkSha, "HEAD"]).code !== 0) {
err(
`!! ${anchor} (${forkSha.slice(0, 12)}) is not an ancestor of HEAD — history diverged, refusing to hard-reset. Use the default (revert) mode.`,
);
process.exit(1);
}
// Safety: `reset --hard` drops EVERYTHING in forkSha..HEAD, not just the
// session. The session's own footprint here is its promotion merge(s) plus the
// commits reachable from session/<short>; anything else is UNRELATED work (e.g.
// commits you made on the base branch after the session forked) that --hard
// would silently destroy. Refuse when such collateral exists, unless --force.
const sessionRef = `session/${SHORT}`;
const hasSessionBranch =
git(["show-ref", "--verify", "--quiet", `refs/heads/${sessionRef}`])
.code === 0;
const collateral = git(["rev-list", `${forkSha}..HEAD`])
.out.split("\n")
.filter(Boolean)
.filter((sha) => {
if (
git(["show", "-s", "--format=%s", sha]).out ===
`merge(session): ${SHORT}`
)
return false; // this session's promotion merge
if (
hasSessionBranch &&
git(["merge-base", "--is-ancestor", sha, sessionRef]).code === 0
)
return false; // brought in by the session
return true; // unrelated commit
});
if (collateral.length && !FORCE) {
err(
`!! reset --hard to ${anchor} would ALSO drop ${collateral.length} commit(s) NOT part of session ${SHORT}:`,
);
for (const sha of collateral.slice(0, 20))
err(` ${git(["show", "-s", "--format=%h %s", sha]).out}`);
if (collateral.length > 20)
err(` ... and ${collateral.length - 20} more`);
err(
` These would be permanently removed from ${CURRENT_BRANCH}. Use the default (revert) mode (drop --hard) - it never touches unrelated commits - or re-run with --force if you really mean to reset to the fork point.`,
);
if (!DRY_RUN) process.exit(1);
}
stashDirty();
log(
`==> Hard-resetting ${CURRENT_BRANCH} to ${anchor} (${forkSha.slice(0, 12)}) — the session disappears from history`,
);
const r = gitRun(["reset", "--hard", forkSha]);
if (r.code !== 0) {
err(`!! reset --hard failed: ${r.err}`);
process.exit(1);
}
} else {
// Find this session's promotion merge commits on the base branch (newest first,
// so reverting in order unwinds the stack cleanly).
let merges = [];
if (
BASE_BRANCH &&
git(["show-ref", "--verify", "--quiet", `refs/heads/${BASE_BRANCH}`])
.code === 0
) {
const subject = `merge(session): ${SHORT}`;
merges = git(["log", "--merges", "--format=%H%x09%s", BASE_BRANCH])
.out.split("\n")
.filter(Boolean)
.map((line) => line.split("\t"))
.filter(([, subj]) => subj === subject)
.map(([sha]) => sha);
}
if (merges.length === 0) {
log(
`==> No promotion merge found for session ${SHORT} — nothing to revert (cleanup only).`,
);
} else {
requireOnBase();
stashDirty();
// All-or-nothing: capture HEAD before the first revert so that if a later
// merge conflicts we can roll the already-applied reverts back. Otherwise a
// partial run leaves some reverts committed while the original merges still
// match the subject filter, and a naive "resolve and re-run" would re-revert
// the already-reverted merges (revert-of-revert re-applies the session's
// changes). Resetting to preRevertHead keeps re-run idempotent.
const preRevertHead = DRY_RUN ? "" : git(["rev-parse", "HEAD"]).out;
for (const sha of merges) {
log(`==> Reverting promotion merge ${sha.slice(0, 12)}`);
const r = gitRun(["revert", "--no-edit", "-m", "1", sha]);
if (r.code !== 0) {
gitRun(["revert", "--abort"]);
if (!DRY_RUN && preRevertHead)
gitRun(["reset", "--hard", preRevertHead]);
err(
`!! Revert hit a conflict on ${sha.slice(0, 12)} (another session may have touched the same files).`,
);
err(
" Rolled back to the pre-revert state — nothing was reverted or removed. Resolve the conflict source, then re-run.",
);
process.exit(1);
}
}
}
}
// 3. Remove this session's worktrees (matched by their checked-out branch, since
// the worktree path carries the full session id we don't have here).
const worktrees = [];
{
const lines = git(["worktree", "list", "--porcelain"]).out.split("\n");
let wt = "";
for (const line of lines) {
if (line.startsWith("worktree ")) wt = line.slice("worktree ".length);
else if (line.startsWith("branch ")) {
const br = line.slice("branch refs/heads/".length);
if (br === `session/${SHORT}` || br.startsWith(`${SHORT}/`))
worktrees.push(wt);
}
}
}
for (const wt of worktrees) {
log(`==> Removing worktree ${wt}`);
gitRun(["worktree", "remove", "--force", wt]);
}
gitRun(["worktree", "prune"]);
// 4. Delete this session's branches (task/simple, integration, fork anchor).
const branches = git([
"for-each-ref",
"--format=%(refname:short)",
`refs/heads/${SHORT}/*`,
`refs/heads/session/${SHORT}`,
`refs/heads/session-base/${SHORT}`,
])
.out.split("\n")
.filter(Boolean);
for (const br of branches) {
if (br === CURRENT_BRANCH) {
log(` ! skip current branch: ${br}`);
continue;
}
log(`==> Deleting branch ${br}`);
gitRun(["branch", "-D", br]);
}
// 5. Remove the fork-base config subsection.
log(`==> Removing config sessionbase.${SHORT}`);
gitRun(["config", "--local", "--remove-section", `sessionbase.${SHORT}`]);
// 6. Remove this session's /tmp dir(s). Worktree base is /tmp/<slug>/<full-id>;
// the full id is <short>-<rest>, so match on the short id at the UUID-segment
// boundary (exact, or followed by "-"). A bare startsWith(SHORT) would also
// swallow another session whose id merely shares the leading hex block.
const slug = REPO_ROOT.replace(/\//g, "_");
const tmpBase = `/tmp/${slug}`;
if (existsSync(tmpBase)) {
for (const name of readdirSync(tmpBase)) {
if (name !== SHORT && !name.startsWith(`${SHORT}-`)) continue;
const dir = join(tmpBase, name);
log(`==> Removing session dir ${dir}`);
if (DRY_RUN) log(`[dry-run] rm -rf ${dir}`);
else rmSync(dir, { recursive: true, force: true });
}
}
// NOTE: .promote.lock is intentionally left in place — it is shared across
// sessions and removing it could break a concurrent promotion's mutual exclusion.
log("==> Done.");

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Claude Code statusLine command: one compact line summarizing the active
// #technical-harness run, read from <REPO>/.harness/<short>/status.json (written
// by the render-status SubagentStop hook). Prints nothing when no harness run is
// active, so it is safe to leave configured. The statusLine payload JSON arrives
// on stdin; we need session_id to find the right session's board.
//
// NOT wired into the committed settings.json on purpose: statusLine is a personal
// choice and a project-level one would override your own global statusline (same
// reasoning as the opt-in concise-dev output style in AGENTS.md). To use it, add
// to your own .claude/settings.local.json:
//
// "statusLine": {
// "type": "command",
// "command": "$CLAUDE_PROJECT_DIR/.claude/scripts/harness-statusline.mjs"
// }
//
// To KEEP your existing statusline as well, point HARNESS_STATUSLINE_BASE at its
// command: this script runs it (same stdin), then appends the harness segment.
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { REPO } from "../hooks/lib/paths.mjs";
let raw = "";
try {
raw = readFileSync(0, "utf8");
} catch {
raw = "";
}
let payload = {};
try {
payload = JSON.parse(raw);
} catch {
payload = {};
}
const sessionId =
payload.session_id || process.env.CLAUDE_CODE_SESSION_ID || "";
const short = sessionId.split("-")[0];
// Optional: compose with an existing statusline command.
let prefix = "";
const base = process.env.HARNESS_STATUSLINE_BASE;
if (base) {
try {
prefix = execSync(base, { input: raw, encoding: "utf8" }).trim();
} catch {
prefix = "";
}
}
function harnessSegment(shortId) {
if (!shortId) return "";
let s;
try {
s = JSON.parse(
readFileSync(join(REPO, ".harness", shortId, "status.json"), "utf8"),
);
} catch {
return "";
}
const t = s.tickets || {};
const parts = [`⚙ ${t.merged ?? 0}/${t.total ?? 0}`];
const active = (s.active || []).map((a) => `▸${a.task}`).join(" ");
if (active) parts.push(active);
if (s.last) parts.push(String(s.last).slice(0, 40));
return parts.join(" · ");
}
const out = [prefix, harnessSegment(short)].filter(Boolean).join(" ");
// A CLI status command whose job is to print one line to stdout.
if (out) process.stdout.write(out + "\n");

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env node
// pending-deploys: decide whether the session has deploy-relevant changes not
// yet covered by the migrations dir.
//
// pending-deploys --app <APP_DIR> --session <SESSION_SHORT>
//
// Prints a non-empty marker (the changed deploy-relevant paths) when a deploy is
// worth offering; prints nothing otherwise.
//
// Exit codes:
// 0 - decision made (stdout empty = nothing to deploy, non-empty = paths).
// 3 - could NOT decide: --session missing, or the passed <SESSION_SHORT>
// has session-base siblings but no refs of its own (likely a mismatch).
import { execFileSync } from "node:child_process";
import {
loadConfig,
isDeployEnabled,
deployGlobs,
} from "../hooks/lib/config.mjs";
// Convert a deploy-relevant glob (config.deploy.relevantGlobs) into an anchored
// regex source. `**/` -> optional dir prefix, `**` -> any, `*` -> non-slash. The
// git diff paths are repo-relative, so anchor at the start.
export function globToRegexSource(glob) {
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
// Placeholder the glob operators FIRST, expand `*` last, then swap the
// placeholders in. Otherwise the `.*` inserted for `**` would be re-mangled by
// the single-`*` -> `[^/]*` pass.
const body = escaped
.replace(/\*\*\//g, "\0DS\0")
.replace(/\*\*/g, "\0D\0")
.replace(/\*/g, "[^/]*")
.replace(/\0DS\0/g, "(?:.*/)?")
.replace(/\0D\0/g, ".*");
return `^${body}`;
}
// The single deploy-relevance matcher, built from config.deploy.relevantGlobs
// (one definition, shared with the orchestrator's SIMPLE-review gate and
// the planner's schema wording). Empty globs -> matches nothing.
export function relevanceRegex(globs) {
if (!globs.length) return /a^/; // never matches
return new RegExp(globs.map(globToRegexSource).join("|"));
}
function main() {
const args = process.argv.slice(2);
const get = (flag, def) => {
const i = args.indexOf(flag);
return i >= 0 ? args[i + 1] : def;
};
const APP = get("--app", process.env.CLAUDE_PROJECT_DIR || "/app");
const SESSION = get("--session", "");
// Pluggable deploy phase: the migration round exists ONLY if this
// project configures a deploy adapter. With no config.deploy, there is nothing
// to deploy, so exit 0 (empty) and the orchestrator terminates at promotion
// with no PD state.
let cfg;
try {
cfg = loadConfig(APP);
} catch (e) {
process.stderr.write(`pending-deploys: ${e.message}\n`);
process.exit(3);
}
if (!isDeployEnabled(cfg)) process.exit(0);
if (!SESSION) {
// Couldn't decide - exit 3, never 0 (0 + empty stdout reads as "nothing to
// deploy", which would silently skip a real pending deploy).
process.stderr.write("--session <SESSION_SHORT> required\n");
process.exit(3);
}
const refExists = (ref) => {
try {
execFileSync("git", ["-C", APP, "show-ref", "--verify", "--quiet", ref], {
stdio: "ignore",
});
return true;
} catch {
return false;
}
};
// Guard against the SESSION_SHORT_ID mismatch. The session topology
// (session-base/<id> + session/<id>) is created at session start, so missing
// refs for the requested id are suspicious. Distinguish two cases:
// - NO session-base/* branch exists at all -> no topology (e.g. hooks never
// ran, or a non-session context) -> genuinely nothing to deploy -> exit 0.
// - session-base/* branches exist but NOT for <SESSION> -> the caller passed
// the wrong short id -> fail loudly so the deploy isn't silently skipped.
if (
!refExists(`refs/heads/session-base/${SESSION}`) ||
!refExists(`refs/heads/session/${SESSION}`)
) {
let bases = "";
try {
bases = execFileSync(
"git",
[
"-C",
APP,
"for-each-ref",
"--format=%(refname:short)",
"refs/heads/session-base/",
],
{ stdio: ["pipe", "pipe", "pipe"] },
)
.toString()
.trim();
} catch {
/* not a git repo / no refs - treated as no topology below */
}
if (bases) {
process.stderr.write(
`pending-deploys: no session refs for '${SESSION}', but found: ${bases.split("\n").join(", ")}. ` +
`Likely a SESSION_SHORT_ID mismatch - refusing to report "nothing to deploy".\n`,
);
process.exit(3);
}
process.exit(0); // no session topology anywhere -> nothing to deploy
}
// Deploy-relevance from config.deploy.relevantGlobs: one definition of
// "deploy-relevant paths", not a hardcoded regex. A real migration requires a
// change under these globs (the DDL source of truth), so this is both
// necessary and sufficient to decide whether to offer the migration round.
const SCHEMA_RE = relevanceRegex(deployGlobs(cfg));
let changed = "";
try {
changed = execFileSync(
"git",
[
"-C",
APP,
"diff",
"--name-only",
`session-base/${SESSION}..session/${SESSION}`,
],
{ stdio: ["pipe", "pipe", "pipe"] },
).toString();
} catch (e) {
process.stderr.write(
`pending-deploys: git diff failed for '${SESSION}': ${e.message}\n`,
);
process.exit(3);
}
const relevant = changed
.split("\n")
.filter(Boolean)
.filter((p) => SCHEMA_RE.test(p));
// (Idempotency against already-applied schema is enforced later by the
// migration round, which cross-checks the migrations dir and may no-op.)
if (relevant.length) process.stdout.write(`${relevant.join("\n")}\n`);
}
if (import.meta.url === `file://${process.argv[1]}`) main();

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Launch @playwright/mcp against the repo's BUNDLED Chromium (the one
# `make install-playwright-browsers` provisions and the Playwright e2e suite already
# uses), instead of the MCP's default `chrome` channel (/opt/google/chrome/chrome),
# which provisioning never installs. Without this the feature-smoke reviewer's first
# navigate fails with "Chromium distribution 'chrome' is not found" and only works if
# someone symlinks the bundled binary by hand.
#
# The bundled path is version-pinned (chromium-<rev>), so resolve it dynamically via
# playwright-core rather than hardcoding it in .mcp.json. If resolution fails (e.g.
# playwright-core absent), fall back to the MCP default so behavior is no worse than before.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO"
EXE="$(node -e "console.log(require('playwright-core').chromium.executablePath())" 2>/dev/null || true)"
ARGS=(node_modules/@playwright/mcp/cli.js --headless --isolated)
if [ -n "$EXE" ] && [ -f "$EXE" ]; then
ARGS+=(--executable-path "$EXE")
fi
exec node "${ARGS[@]}" "$@"

View File

@@ -0,0 +1,73 @@
// Tests for harness-revert.mjs --hard collateral guard: `reset --hard` to the
// session fork point must NOT silently drop commits that are not part of the
// session (e.g. the user's own commits made on the base branch after the fork).
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, test } from "vitest";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "..", "harness-revert.mjs");
const SHORT = "beef1234";
let TMP = null;
afterEach(() => {
if (TMP) rmSync(TMP, { recursive: true, force: true });
TMP = null;
});
// A repo forked at `base`, a session branch with one change, and one UNRELATED
// commit on main made AFTER the fork (the collateral --hard would drop).
const setup = () => {
TMP = mkdtempSync(join(tmpdir(), "harness-revert-test-"));
const app = join(TMP, "app");
const g = (...a) => spawnSync("git", ["-C", app, ...a], { encoding: "utf8" });
spawnSync("git", ["init", "-q", "-b", "main", app]);
g("config", "user.email", "t@t.t");
g("config", "user.name", "t");
writeFileSync(join(app, "file.txt"), "base\n");
g("add", ".");
g("commit", "-qm", "base");
const forkSha = g("rev-parse", "HEAD").stdout.trim();
g("branch", `session-base/${SHORT}`);
g("branch", `session/${SHORT}`);
// session work lands on the session branch (never merged to main here)
g("checkout", "-q", `session/${SHORT}`);
writeFileSync(join(app, "file.txt"), "session change\n");
g("add", ".");
g("commit", "-qm", "session work");
g("checkout", "-q", "main");
// the user's unrelated commit on main, AFTER the fork
writeFileSync(join(app, "unrelated.txt"), "my own work\n");
g("add", ".");
g("commit", "-qm", "unrelated commit");
g("config", "--local", `sessionbase.${SHORT}.branch`, "main");
const head = () => g("rev-parse", "HEAD").stdout.trim();
const run = (...args) =>
spawnSync("node", [SCRIPT, SHORT, ...args], { cwd: app, encoding: "utf8" });
return { app, forkSha, head, run };
};
describe("harness-revert --hard collateral guard", () => {
test("refuses --hard when it would drop non-session commits", () => {
const { head, run } = setup();
const before = head();
const r = run("--hard");
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/NOT part of session/);
expect(r.stderr).toContain("unrelated commit");
// HEAD untouched — nothing was reset.
expect(head()).toBe(before);
});
test("--force lets --hard reset to the fork point", () => {
const { forkSha, head, run } = setup();
const r = run("--hard", "--force");
expect(r.status).toBe(0);
expect(head()).toBe(forkSha);
});
});

View File

@@ -0,0 +1,171 @@
// Tests for .claude/scripts/pending-deploys.mjs: decides whether a session has
// deploy-relevant changes worth a migration round. Config-driven:
// the round exists iff config.deploy is present, and relevance comes from
// config.deploy.relevantGlobs (one definition, unified with the orchestrator's
// SIMPLE-review gate).
import { describe, test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { globToRegexSource, relevanceRegex } from "../pending-deploys.mjs";
const SCRIPT = new URL("../pending-deploys.mjs", import.meta.url).pathname;
function git(cwd, ...args) {
return execFileSync("git", args, { cwd }).toString();
}
// A repo with a Supabase deploy adapter configured (so the deploy gate is open),
// a declarative schema file, and the session topology branches.
function setupRepo({ deploy = true } = {}) {
const dir = mkdtempSync(join(tmpdir(), "pd-"));
git(dir, "init", "-q", "-b", "main");
git(dir, "config", "user.email", "t@t.t");
git(dir, "config", "user.name", "t");
mkdirSync(join(dir, "src"), { recursive: true });
mkdirSync(join(dir, "supabase/schemas"), { recursive: true });
mkdirSync(join(dir, "supabase/migrations"), { recursive: true });
writeFileSync(
join(dir, "src/types.ts"),
"export type Contact = { id: string };\n",
);
writeFileSync(
join(dir, "supabase/schemas/01_tables.sql"),
"create table contacts (id uuid primary key);\n",
);
const config = { validation: { steps: [] }, roles: {} };
if (deploy)
config.deploy = { adapter: "supabase", relevantGlobs: ["supabase/**"] };
writeFileSync(join(dir, "harness.config.json"), JSON.stringify(config));
git(dir, "add", ".");
git(dir, "commit", "-qm", "seed");
git(dir, "branch", "session-base/ab12cd34", "main");
git(dir, "branch", "session/ab12cd34", "main");
return dir;
}
const run = (dir, ...args) =>
execFileSync("node", [SCRIPT, "--app", dir, ...args])
.toString()
.trim();
describe("pending-deploys relevance", () => {
test("globToRegexSource anchors and expands ** / *", () => {
expect(globToRegexSource("supabase/**")).toBe("^supabase/.*");
expect(globToRegexSource("**/types.ts")).toBe("^(?:.*/)?types\\.ts");
});
test("relevanceRegex matches configured paths, not others", () => {
const re = relevanceRegex(["supabase/**"]);
expect(re.test("supabase/schemas/01_tables.sql")).toBe(true);
expect(re.test("src/types.ts")).toBe(false);
expect(relevanceRegex([]).test("supabase/schemas/x.sql")).toBe(false);
});
});
describe("pending-deploys", () => {
test("empty when session branch made no deploy-relevant change", () => {
const dir = setupRepo();
expect(run(dir, "--session", "ab12cd34")).toBe("");
rmSync(dir, { recursive: true, force: true });
});
test("non-empty when the session branch changes the declarative schema", () => {
const dir = setupRepo();
git(dir, "checkout", "-q", "session/ab12cd34");
writeFileSync(
join(dir, "supabase/schemas/01_tables.sql"),
"create table contacts (id uuid primary key, priority int);\n",
);
git(dir, "add", ".");
git(dir, "commit", "-qm", "add priority column");
git(dir, "checkout", "-q", "main");
expect(run(dir, "--session", "ab12cd34")).not.toBe("");
rmSync(dir, { recursive: true, force: true });
});
test("a TS-only change (no schema file) is NOT deploy-relevant (unified definition)", () => {
const dir = setupRepo();
git(dir, "checkout", "-q", "session/ab12cd34");
writeFileSync(
join(dir, "src/types.ts"),
"export type Contact = { id: string; priority: number };\n",
);
git(dir, "add", ".");
git(dir, "commit", "-qm", "add priority to type only");
git(dir, "checkout", "-q", "main");
expect(run(dir, "--session", "ab12cd34")).toBe("");
rmSync(dir, { recursive: true, force: true });
});
test("no config.deploy -> exit 0 empty (deploy phase not configured)", () => {
const dir = setupRepo({ deploy: false });
git(dir, "checkout", "-q", "session/ab12cd34");
writeFileSync(
join(dir, "supabase/schemas/01_tables.sql"),
"create table contacts (id uuid primary key, x int);\n",
);
git(dir, "add", ".");
git(dir, "commit", "-qm", "schema change but no deploy adapter");
git(dir, "checkout", "-q", "main");
expect(run(dir, "--session", "ab12cd34")).toBe("");
rmSync(dir, { recursive: true, force: true });
});
test("empty and exit 0 when NO session topology exists at all (hooks never ran)", () => {
const dir = mkdtempSync(join(tmpdir(), "pd-"));
git(dir, "init", "-q", "-b", "main");
git(dir, "config", "user.email", "t@t.t");
git(dir, "config", "user.name", "t");
writeFileSync(join(dir, "x.ts"), "export const x = 1;\n");
writeFileSync(
join(dir, "harness.config.json"),
JSON.stringify({
validation: { steps: [] },
roles: {},
deploy: { adapter: "supabase", relevantGlobs: ["supabase/**"] },
}),
);
git(dir, "add", ".");
git(dir, "commit", "-qm", "seed");
expect(run(dir, "--session", "xx99yy00")).toBe("");
rmSync(dir, { recursive: true, force: true });
});
test("exits 3 (could not decide) when --session is missing", () => {
const dir = setupRepo();
let code = 0,
stderr = "";
try {
execFileSync("node", [SCRIPT, "--app", dir], {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
} catch (e) {
code = e.status;
stderr = (e.stderr || "").toString();
}
expect(code).toBe(3);
expect(stderr).toMatch(/session/i);
rmSync(dir, { recursive: true, force: true });
});
test("fails loudly (exit 3 + stderr) on SESSION_SHORT_ID mismatch", () => {
const dir = setupRepo();
let code = 0,
stderr = "";
try {
execFileSync("node", [SCRIPT, "--app", dir, "--session", "xx99yy00"], {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
} catch (e) {
code = e.status;
stderr = (e.stderr || "").toString();
}
expect(code).toBe(3);
expect(stderr).toMatch(/mismatch/i);
rmSync(dir, { recursive: true, force: true });
});
});