snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
276
.claude/hooks/setup-worktree.mjs
Executable file
276
.claude/hooks/setup-worktree.mjs
Executable file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env node
|
||||
// PreToolUse(Agent) — before a developer subagent starts, create its git
|
||||
// worktree (forked from the session integration branch) and provision
|
||||
// node_modules. Fires on the orchestrator's Agent dispatch, where the dispatched
|
||||
// identity (subagent_type, name, prompt) is available — unlike SubagentStart,
|
||||
// which in a parallel wave cannot tell which of N developers is starting, so it
|
||||
// cannot know the TASK_ID / worktree to create.
|
||||
//
|
||||
// Identity: a real TASK id (TASK-001) from the prompt's TASK_ID line or the
|
||||
// dispatch name (developer-TASK-001) → <base>/<TASK_ID> (per-ticket wave); a
|
||||
// developer dispatch on the <short>/simple branch (rollback-conflict / migration,
|
||||
// no task) → <base>/simple. Paths are derived from topology (authoritative),
|
||||
// matching the value the orchestrator substitutes into WORKTREE_PATH.
|
||||
//
|
||||
// Also resets stale review verdicts for a (re)dispatched developer, so a changed
|
||||
// diff cannot pass block-merger-without-review on a previous attempt's APPROVED.
|
||||
//
|
||||
// Recovery: already-registered worktree → skip (restart / retry); orphan dir →
|
||||
// rm -rf then recreate; orphan branch with no worktree → force-delete so -b works.
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createHookContext } from "./lib/context.mjs";
|
||||
import { parseDispatch } from "./lib/dispatch-parse.mjs";
|
||||
import { getBaseBranch, getWorktreePaths, git } from "./lib/git.mjs";
|
||||
import { REVIEW_ROLES, reviewFlag } from "./lib/reviews.mjs";
|
||||
import { getFirstTaskId } from "./lib/teams.mjs";
|
||||
import { addWorktreeFolder } from "./lib/workspace-folders.mjs";
|
||||
import {
|
||||
simpleBranch,
|
||||
simpleWorktreePath,
|
||||
sessionBaseBranch,
|
||||
sessionBranch,
|
||||
sessionWorktreePath,
|
||||
taskBranch,
|
||||
taskWorktreePath,
|
||||
} from "./lib/topology.mjs";
|
||||
|
||||
// Advisory-lock tuning (see the acquire loop below). LOCK_ACQUIRE_TIMEOUT_MS must
|
||||
// stay comfortably under this hook's PreToolUse timeout in settings.json so a
|
||||
// waiter gives up and proceeds best-effort rather than being killed mid-run.
|
||||
const LOCK_ACQUIRE_TIMEOUT_MS = 20_000;
|
||||
const LOCK_STALE_MS = 60_000;
|
||||
const LOCK_SPIN_MS = 100;
|
||||
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
const ctx = createHookContext(input, "setup-worktree");
|
||||
const d = parseDispatch(input);
|
||||
|
||||
// Only act on developer dispatches; reviewers, merger, planner and documentator
|
||||
// reuse (or never touch) a worktree.
|
||||
if (d.subagentType !== "developer") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// A developer dispatch whose branch/worktree is <short>/simple runs single-shot on
|
||||
// the shared <base>/simple worktree (rollback-conflict replay, deploy-time
|
||||
// migration) instead of a per-ticket one. The /simple branch is the discriminator;
|
||||
// every other developer dispatch is a per-ticket wave developer.
|
||||
const isSimple =
|
||||
/\/simple$/.test(d.branchName) || /\/simple$/.test(d.worktreePath);
|
||||
|
||||
const taskId =
|
||||
(/^TASK-\d+$/.test(d.taskId) && d.taskId) || getFirstTaskId(d.name);
|
||||
|
||||
let worktreePath;
|
||||
let branchName;
|
||||
if (taskId) {
|
||||
worktreePath = taskWorktreePath(ctx, taskId);
|
||||
branchName = taskBranch(ctx, taskId);
|
||||
} else if (isSimple) {
|
||||
worktreePath = simpleWorktreePath(ctx);
|
||||
branchName = simpleBranch(ctx);
|
||||
} else if (d.role === "promotion-conflict-resolver") {
|
||||
// The sanctioned $REPO-on-main exception (see enforce-dev-dispatch /
|
||||
// worktree-scope): it works directly in the repo under the promote lock and
|
||||
// owns no task worktree, so there is nothing to create here.
|
||||
ctx.accept(
|
||||
"promotion-conflict-resolver — operates in $REPO on main, no worktree",
|
||||
);
|
||||
} else {
|
||||
// A developer that passed enforce-dev-dispatch (so it carries WORKTREE_PATH)
|
||||
// but is neither a resolvable TASK-XXX nor an <short>/simple dispatch. We can't
|
||||
// derive the canonical worktree path; accepting would let the developer cd into
|
||||
// a directory that was never created. Fail closed instead.
|
||||
ctx.fail(
|
||||
"developer dispatch carries WORKTREE_PATH but no resolvable TASK-XXX id and no <short>/simple branch (expected a 'TASK_ID: TASK-XXX' line, a 'developer-TASK-XXX' name, or 'BRANCH_NAME: <SESSION_SHORT_ID>/simple'). Use the STATE B dispatch template.",
|
||||
{ log: "BLOCK unresolvable task id" },
|
||||
);
|
||||
}
|
||||
|
||||
mkdirSync(ctx.sessionDir, { recursive: true });
|
||||
|
||||
// Serialise the git-mutation region per session: a wave dispatches N
|
||||
// developers in ONE orchestrator message, so N PreToolUse hooks can fire nearly
|
||||
// together and race on session-branch / _session creation and git's internal
|
||||
// worktree locks. A best-effort advisory lock (atomic mkdir + bounded spin,
|
||||
// 60s stale-steal) serialises them; released on any exit via the exit handler.
|
||||
const lockDir = join(ctx.sessionDir, ".setup-worktree.lock");
|
||||
const sleepSync = (ms) =>
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
let locked = false;
|
||||
const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
mkdirSync(lockDir);
|
||||
locked = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e.code !== "EEXIST") break; // can't lock — proceed best-effort
|
||||
try {
|
||||
if (Date.now() - statSync(lockDir).mtimeMs > LOCK_STALE_MS) {
|
||||
rmSync(lockDir, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue; // lock vanished — retry immediately
|
||||
}
|
||||
sleepSync(LOCK_SPIN_MS);
|
||||
}
|
||||
}
|
||||
// Release the advisory lock. Idempotent (guarded by `locked`) so it is safe to
|
||||
// call explicitly once the git-mutation region is done — releasing before the
|
||||
// slow node_modules provisioning keeps the critical section short — and again on
|
||||
// exit as a backstop.
|
||||
const releaseLock = () => {
|
||||
if (!locked) return;
|
||||
try {
|
||||
rmdirSync(lockDir);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
locked = false;
|
||||
};
|
||||
process.on("exit", releaseLock);
|
||||
|
||||
const base = getBaseBranch();
|
||||
const integrationBranch = sessionBranch(ctx);
|
||||
const sessionWt = sessionWorktreePath(ctx);
|
||||
|
||||
// Worktrees whose node_modules is provisioned AFTER the lock is released (see
|
||||
// the explicit releaseLock() below). cp -a of node_modules can take 20-40s on a
|
||||
// cross-mount dev container; doing it under the lock made parallel-wave waiters
|
||||
// exceed the acquire deadline and run the git-mutation region unlocked.
|
||||
const toProvision = [];
|
||||
|
||||
if (
|
||||
git(["show-ref", "--verify", "--quiet", `refs/heads/${integrationBranch}`])
|
||||
.status !== 0
|
||||
) {
|
||||
git(["branch", integrationBranch, base]);
|
||||
git(["branch", sessionBaseBranch(ctx), base]);
|
||||
}
|
||||
|
||||
// Record the fork-base branch NAME so promotion (merger Stage B) merges the
|
||||
// session's work back into the branch it was forked from — the source branch the
|
||||
// user is on — instead of always targeting the repo default (main). The
|
||||
// session-base/<short> ref records the fork COMMIT but not the name, so the name
|
||||
// is captured here. Written only when missing: that pins the value at fork time
|
||||
// and never overwrites it with a later, possibly drifted, $REPO HEAD. The key
|
||||
// lives for the whole session — like session/<short> and session-base/<short> it
|
||||
// is NOT torn down per request (cleanup-worktree leaves it in place);
|
||||
// harness-revert.mjs (the /harness-revert command) removes it at session teardown.
|
||||
// Keyed by session short id (a config subsection,
|
||||
// so any short id is valid).
|
||||
const baseBranchKey = `sessionbase.${ctx.sessionShort}.branch`;
|
||||
if (!git(["config", "--get", baseBranchKey]).stdout.trim()) {
|
||||
git(["config", "--local", baseBranchKey, base]);
|
||||
}
|
||||
|
||||
if (!existsSync(join(sessionWt, ".git"))) {
|
||||
rmSync(sessionWt, { recursive: true, force: true });
|
||||
mkdirSync(dirname(sessionWt), { recursive: true });
|
||||
git(["worktree", "prune"]);
|
||||
const add = git(["worktree", "add", sessionWt, integrationBranch]);
|
||||
if (add.status === 0) {
|
||||
toProvision.push(sessionWt);
|
||||
ctx.log(`SESSION-BRANCH created ${integrationBranch} from ${base}`);
|
||||
} else {
|
||||
ctx.log(
|
||||
`SESSION-BRANCH FAILED _session ${integrationBranch} err=${add.stderr.replace(/\n/g, " ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A (re)dispatched developer means the diff will change — invalidate any prior
|
||||
// review verdicts for this ticket so a stale APPROVED can't let the merger
|
||||
// through before the new attempt is re-reviewed.
|
||||
if (taskId) {
|
||||
for (const r of REVIEW_ROLES) {
|
||||
try {
|
||||
rmSync(reviewFlag(ctx, taskId, r), { force: true });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log(
|
||||
`START agent=${d.subagentType}${d.mode ? ` mode=${d.mode}` : ""} path=${worktreePath} branch=${branchName}`,
|
||||
);
|
||||
|
||||
if (getWorktreePaths().includes(worktreePath)) {
|
||||
ctx.accept(`already registered (${worktreePath})`);
|
||||
}
|
||||
|
||||
if (existsSync(worktreePath)) {
|
||||
rmSync(worktreePath, { recursive: true, force: true });
|
||||
ctx.log(`REMOVED orphan dir ${worktreePath}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
|
||||
if (git(["branch", "--list", branchName]).stdout.trim()) {
|
||||
git(["branch", "-D", branchName]);
|
||||
ctx.log(`DELETED orphan branch ${branchName}`);
|
||||
}
|
||||
|
||||
const add = git([
|
||||
"worktree",
|
||||
"add",
|
||||
worktreePath,
|
||||
"-b",
|
||||
branchName,
|
||||
integrationBranch,
|
||||
]);
|
||||
if (add.status !== 0) {
|
||||
ctx.fail(
|
||||
`Cannot create worktree at ${worktreePath} (branch=${branchName}): ${add.stderr}\n`,
|
||||
{ log: `path=${worktreePath} err=${add.stderr}` },
|
||||
);
|
||||
}
|
||||
ctx.log(`CREATED branch=${branchName} path=${worktreePath}`);
|
||||
toProvision.push(worktreePath);
|
||||
|
||||
// Surface this worktree in the editor (files + live Source Control) for a
|
||||
// technical run, so the developer can watch the code being modified. Done under
|
||||
// the session lock so a parallel wave's edits to the shared .code-workspace do
|
||||
// not race. No-op under a managed launcher (owns its own UI) or a non-technical
|
||||
// run (no progress log), matching the render-status board's gating.
|
||||
if (
|
||||
!process.env.CHAT_SESSION_DIR &&
|
||||
existsSync(join(ctx.sessionDir, "harness-progress.log"))
|
||||
) {
|
||||
addWorktreeFolder(
|
||||
ctx.repo,
|
||||
worktreePath,
|
||||
`🎫 ${taskId || "simple"} · ${ctx.sessionShort}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Git-mutation region is done — release the lock before provisioning so a
|
||||
// parallel-wave waiter can serialise its own git ops while this hook copies
|
||||
// node_modules. Distinct target dirs, copied from $REPO, so the copies can run
|
||||
// concurrently without racing.
|
||||
releaseLock();
|
||||
|
||||
try {
|
||||
for (const wt of toProvision) ctx.provisionWorktree(wt);
|
||||
} catch (e) {
|
||||
// Fail closed (exit 2 -> block the dispatch): a developer must not start in a
|
||||
// worktree with no dependencies. An uncaught throw would exit 1, which a
|
||||
// PreToolUse hook treats as non-blocking, letting the broken dispatch run.
|
||||
ctx.fail(`worktree provisioning failed for ${worktreePath}: ${e.message}`, {
|
||||
log: `provision-failed wt=${worktreePath}`,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.accept(`OK wt=${worktreePath}`);
|
||||
Reference in New Issue
Block a user