@tablation/crew 0.43.3 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1432,6 +1432,7 @@ var RELEASE_KEYS = /* @__PURE__ */ new Set([
1432
1432
  "ship",
1433
1433
  "ci",
1434
1434
  "verify",
1435
+ "isolateFailures",
1435
1436
  "versioning",
1436
1437
  "versionFiles",
1437
1438
  "changelog",
@@ -1441,6 +1442,14 @@ var RELEASE_KEYS = /* @__PURE__ */ new Set([
1441
1442
  var VERSIONINGS = ["auto", "none"];
1442
1443
  var CI_KEYS = /* @__PURE__ */ new Set(["provider", "ref"]);
1443
1444
  var VERIFY_KEYS = /* @__PURE__ */ new Set(["match", "timeoutSeconds", "intervalSeconds"]);
1445
+ var ISOLATE_KEYS = /* @__PURE__ */ new Set(["maxCandidates"]);
1446
+ var DEFAULT_ISOLATE_MAX_CANDIDATES = 4;
1447
+ function parseIsolateMax(raw, where, fail) {
1448
+ if (raw === void 0 || raw === null) return void 0;
1449
+ const n = Number(raw);
1450
+ if (!Number.isInteger(n) || n < 0) throw fail(`${where}.maxCandidates must be a non-negative integer (got "${raw}")`);
1451
+ return n;
1452
+ }
1444
1453
  var MATCHES = ["commit", "version"];
1445
1454
  var MODES = ["local", "integrate", "ci_manual", "ci_auto", "external"];
1446
1455
  var PROVIDERS = ["github", "buildkite", "other", "none"];
@@ -1474,6 +1483,7 @@ function parseRepoConfig(text, file) {
1474
1483
  rejectUnknown(raw.release, RELEASE_KEYS, "release", file);
1475
1484
  rejectUnknown(raw.release?.ci, CI_KEYS, "release.ci", file);
1476
1485
  rejectUnknown(raw.release?.verify, VERIFY_KEYS, "release.verify", file);
1486
+ rejectUnknown(raw.release?.isolateFailures, ISOLATE_KEYS, "release.isolateFailures", file);
1477
1487
  rejectUnknown(raw.branch, BRANCH_KEYS, "branch", file);
1478
1488
  rejectUnknown(raw.worktrees, /* @__PURE__ */ new Set(["copy", "prefix", "name"]), "worktrees", file);
1479
1489
  rejectUnknown(raw.docs, /* @__PURE__ */ new Set(["triagePolicy", "designGuide"]), "docs", file);
@@ -1611,6 +1621,11 @@ function parseRepoConfig(text, file) {
1611
1621
  `${file}: release.verify.intervalSeconds (${verify.intervalSeconds}) exceeds timeoutSeconds (${verify.timeoutSeconds}) \u2014 it would never be asked twice`
1612
1622
  );
1613
1623
  }
1624
+ const isolateMax = parseIsolateMax(
1625
+ raw.release?.isolateFailures?.maxCandidates,
1626
+ "release.isolateFailures",
1627
+ (m) => new RepoConfigError(`${file}: ${m}`)
1628
+ );
1614
1629
  const versioning = String(raw.release?.versioning ?? "auto");
1615
1630
  if (!VERSIONINGS.includes(versioning)) {
1616
1631
  throw new RepoConfigError(`${file}: release.versioning must be auto|none (got "${versioning}")`);
@@ -1664,6 +1679,7 @@ function parseRepoConfig(text, file) {
1664
1679
  ship,
1665
1680
  ci: { provider, ref: raw.release?.ci?.ref ? String(raw.release.ci.ref) : void 0 },
1666
1681
  verify,
1682
+ isolateFailures: isolateMax === void 0 ? void 0 : { maxCandidates: isolateMax },
1667
1683
  versioning,
1668
1684
  versionFiles,
1669
1685
  tag,
@@ -1848,6 +1864,14 @@ function resolveRepoConfig(repo, ship, dir) {
1848
1864
  DEFAULTS.intervalSeconds
1849
1865
  )
1850
1866
  },
1867
+ isolateFailures: {
1868
+ maxCandidates: pick(
1869
+ "release.isolateFailures.maxCandidates",
1870
+ repo?.release.isolateFailures?.maxCandidates,
1871
+ ship?.release?.isolateFailures?.maxCandidates,
1872
+ DEFAULT_ISOLATE_MAX_CANDIDATES
1873
+ )
1874
+ },
1851
1875
  versioning: pick(
1852
1876
  "release.versioning",
1853
1877
  repo?.release.versioning === "auto" ? void 0 : repo?.release.versioning,
@@ -1999,6 +2023,7 @@ var RELEASE_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
1999
2023
  "mode",
2000
2024
  "ci",
2001
2025
  "verify",
2026
+ "isolateFailures",
2002
2027
  "versioning",
2003
2028
  "versionFiles",
2004
2029
  "versionFile",
@@ -2008,6 +2033,7 @@ var RELEASE_OVERRIDE_KEYS = /* @__PURE__ */ new Set([
2008
2033
  ]);
2009
2034
  var CI_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["provider", "ref"]);
2010
2035
  var VERIFY_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["match", "timeoutSeconds", "intervalSeconds"]);
2036
+ var ISOLATE_OVERRIDE_KEYS = /* @__PURE__ */ new Set(["maxCandidates"]);
2011
2037
  function rejectUnknownKeys(obj, allowed, where, file) {
2012
2038
  if (!obj || typeof obj !== "object") return;
2013
2039
  const unknown = Object.keys(obj).filter((k) => !allowed.has(k));
@@ -2022,6 +2048,7 @@ function parseReleaseOverride(raw, where, file) {
2022
2048
  rejectUnknownKeys(raw, RELEASE_OVERRIDE_KEYS, `${where}.release`, file);
2023
2049
  if (raw.ci !== void 0) rejectUnknownKeys(raw.ci, CI_OVERRIDE_KEYS, `${where}.release.ci`, file);
2024
2050
  if (raw.verify !== void 0) rejectUnknownKeys(raw.verify, VERIFY_OVERRIDE_KEYS, `${where}.release.verify`, file);
2051
+ if (raw.isolateFailures !== void 0) rejectUnknownKeys(raw.isolateFailures, ISOLATE_OVERRIDE_KEYS, `${where}.release.isolateFailures`, file);
2025
2052
  const mode = raw.mode === void 0 ? void 0 : String(raw.mode);
2026
2053
  if (mode !== void 0 && !MODES.includes(mode)) {
2027
2054
  throw new ConfigError(`${file}: ${where}.release.mode must be one of ${MODES.join("|")} (got "${raw.mode}")`);
@@ -2050,11 +2077,17 @@ function parseReleaseOverride(raw, where, file) {
2050
2077
  };
2051
2078
  const timeoutSeconds = num(raw.verify?.timeoutSeconds, "timeoutSeconds");
2052
2079
  const intervalSeconds = num(raw.verify?.intervalSeconds, "intervalSeconds");
2080
+ const isolateMax = parseIsolateMax(
2081
+ raw.isolateFailures?.maxCandidates,
2082
+ `${where}.release.isolateFailures`,
2083
+ (m) => new ConfigError(`${file}: ${m}`)
2084
+ );
2053
2085
  const versionFiles = Array.isArray(raw.versionFiles) ? raw.versionFiles : raw.versionFile ? [String(raw.versionFile)] : void 0;
2054
2086
  return {
2055
2087
  mode,
2056
2088
  ci: provider !== void 0 || raw.ci?.ref !== void 0 ? { provider, ref: raw.ci?.ref !== void 0 ? String(raw.ci.ref) : void 0 } : void 0,
2057
2089
  verify: match !== void 0 || timeoutSeconds !== void 0 || intervalSeconds !== void 0 ? { match, timeoutSeconds, intervalSeconds } : void 0,
2090
+ ...isolateMax === void 0 ? {} : { isolateFailures: { maxCandidates: isolateMax } },
2058
2091
  versioning,
2059
2092
  versionFiles,
2060
2093
  changelog: raw.changelog !== void 0 ? String(raw.changelog) : void 0,
@@ -2078,10 +2111,12 @@ function mergeRouteRelease(route, override) {
2078
2111
  const match = override?.verify?.match ?? route.verify?.match;
2079
2112
  const timeoutSeconds = override?.verify?.timeoutSeconds ?? route.verify?.timeoutSeconds;
2080
2113
  const intervalSeconds = override?.verify?.intervalSeconds ?? route.verify?.intervalSeconds;
2114
+ const isolateMax = override?.isolateFailures?.maxCandidates ?? route.isolateFailures?.maxCandidates;
2081
2115
  return {
2082
2116
  mode: override?.mode ?? route.mode,
2083
2117
  ci: provider !== void 0 || ref !== void 0 ? { provider, ref } : void 0,
2084
2118
  verify: match !== void 0 || timeoutSeconds !== void 0 || intervalSeconds !== void 0 ? { match, timeoutSeconds, intervalSeconds } : void 0,
2119
+ ...isolateMax === void 0 ? {} : { isolateFailures: { maxCandidates: isolateMax } },
2085
2120
  versioning: override?.versioning ?? route.versioning,
2086
2121
  versionFiles: override?.versionFiles ?? route.versionFiles,
2087
2122
  changelog: override?.changelog ?? route.changelog,
@@ -6675,6 +6710,82 @@ async function applyConflictBounce(writer, b, contract, memberId, log, dryRun) {
6675
6710
  }
6676
6711
  }
6677
6712
 
6713
+ // src/build-failure.ts
6714
+ import { StaleWriteError as StaleWriteError6 } from "@tablation/client";
6715
+ var BUILD_FAILURE_MARKER = "<!-- crew:build-failure -->";
6716
+ function failingPaths(output, cwd) {
6717
+ const found = /* @__PURE__ */ new Set();
6718
+ const ext = String.raw`[\w.-]+\.[A-Za-z]{1,6}`;
6719
+ const dir = String.raw`(?:[\w@.~-]+/)*`;
6720
+ const patterns = [
6721
+ new RegExp(String.raw`^\s*(${dir}${ext})\(\d+,\d+\)\s*:`, "gm"),
6722
+ new RegExp(String.raw`(?:^|\s)(/?${dir}${ext}):\d+(?::\d+)?`, "gm"),
6723
+ new RegExp(String.raw`(?:FAIL|✖|✕|not ok \d+ -)\s+(/?${dir}${ext})`, "gm")
6724
+ ];
6725
+ const root = cwd.endsWith("/") ? cwd : `${cwd}/`;
6726
+ for (const re of patterns) {
6727
+ for (const m of output.matchAll(re)) {
6728
+ let p = m[1];
6729
+ if (p.startsWith("/")) {
6730
+ if (!p.startsWith(root)) continue;
6731
+ p = p.slice(root.length);
6732
+ }
6733
+ p = p.replace(/^\.\//, "");
6734
+ if (p.includes("node_modules/")) continue;
6735
+ found.add(p);
6736
+ }
6737
+ }
6738
+ return [...found];
6739
+ }
6740
+ function culpritByFiles(candidates, paths) {
6741
+ if (paths.length === 0) return null;
6742
+ const hits = candidates.filter((c) => paths.some((p) => c.changed.has(p)));
6743
+ return hits.length === 1 ? hits[0] : null;
6744
+ }
6745
+ function planBuildFailureBounce(cwd, c, base, remote) {
6746
+ return { ...c, base, remote, worktree: worktreeForBranch(cwd, c.branch) };
6747
+ }
6748
+ function buildFailureComment(b) {
6749
+ const how = b.how === "files" ? `The failing output names files that only \`${b.branch}\` changed among the branches in that release.` : `Built alone on the previous \`${b.base}\`, \`${b.branch}\` fails the same hook.`;
6750
+ return `${BUILD_FAILURE_MARKER}
6751
+ **Back with the dev lane \u2014 this branch builds on its own but not on current \`${b.base}\`.**
6752
+
6753
+ \`${b.branch}\` was verified, but the release's \`${b.hook}\` hook failed once it was merged with the other branches in the same release. ${how}
6754
+
6755
+ First step: \`git merge ${b.remote}/${b.base}\` in ${b.worktree ? `\`${b.worktree}\`` : "the branch's worktree"}, rebuild, and fix what disagrees \u2014 the merge is clean at the text level, so this is a semantic conflict (a duplicate identifier, a changed signature) with work that landed on \`${b.base}\` after this branch was cut. Push as a plain fast-forward and hand back to QA as usual.
6756
+
6757
+ \`\`\`
6758
+ ${b.tail.trimEnd()}
6759
+ \`\`\`
6760
+
6761
+ Nothing was written to \`${b.base}\` for this ticket; the rest of the release shipped without it.`;
6762
+ }
6763
+ async function applyBuildFailureBounce(writer, b, contract, memberId, log, dryRun) {
6764
+ const t = { ticket: b.ticket.issue_id, step: "release" };
6765
+ if (dryRun) {
6766
+ log.emit(`would hand ${b.ticket.issue_id} back to ${contract.statuses.building} \u2014 its branch breaks the ${b.hook} hook`, t);
6767
+ return { kind: "handed-back" };
6768
+ }
6769
+ try {
6770
+ await writer.updateTicket(
6771
+ b.ticket.id,
6772
+ { status: contract.statuses.building, assignee_id: null },
6773
+ b.ticket.updated_at
6774
+ );
6775
+ await writer.postEvent(b.ticket.id, buildFailureComment(b), memberId).catch(() => {
6776
+ log.warn("handed back, but the note failed to post", t);
6777
+ });
6778
+ log.emit(`handed back to ${contract.statuses.building} \u2014 breaks the ${b.hook} hook once merged with the rest`, t);
6779
+ return { kind: "handed-back" };
6780
+ } catch (e) {
6781
+ if (e instanceof StaleWriteError6) {
6782
+ log.emit(`${b.ticket.issue_id} changed on the board since this run read it \u2014 not handing it back over it`, t);
6783
+ return { kind: "stale" };
6784
+ }
6785
+ return { kind: "failed", why: e.message };
6786
+ }
6787
+ }
6788
+
6678
6789
  // src/stranded-verified.ts
6679
6790
  var STRANDED_MARKER = "<!-- crew:stranded-verified -->";
6680
6791
  var STRANDED_ESCALATE_AFTER_MS = 30 * 60 * 1e3;
@@ -7196,12 +7307,12 @@ function expandSkillMarkers(text, lookup, opts = {}) {
7196
7307
  import { readFileSync as readFileSync10, readdirSync as readdirSync3, existsSync as existsSync11 } from "fs";
7197
7308
  import { join as join12 } from "path";
7198
7309
  import { parse as parseYaml } from "yaml";
7199
- import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
7310
+ import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError8, TablationApiError as TablationApiError2 } from "@tablation/client";
7200
7311
 
7201
7312
  // src/agents.ts
7202
7313
  import { readFileSync as readFileSync9, existsSync as existsSync10 } from "fs";
7203
7314
  import { join as join11 } from "path";
7204
- import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
7315
+ import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError7, TablationApiError } from "@tablation/client";
7205
7316
  var PERSONA_NAME = {
7206
7317
  dev: "Developer",
7207
7318
  design: "Design",
@@ -7380,7 +7491,7 @@ async function syncPersonas(route, opts) {
7380
7491
  try {
7381
7492
  anchoredAt = (await client2.records.update(agentsModel.id, row.id, promptSourceFields(agentsModel, defaultPrompt), row.updated_at)).updated_at;
7382
7493
  } catch (e) {
7383
- if (!(e instanceof StaleWriteError6) && !isPromptGateRejection(e)) throw e;
7494
+ if (!(e instanceof StaleWriteError7) && !isPromptGateRejection(e)) throw e;
7384
7495
  }
7385
7496
  }
7386
7497
  const historyId = await fetchLatestHistoryId(route, agentsModel.id, row.id, opts.userAgent);
@@ -7452,7 +7563,7 @@ async function syncPersonas(route, opts) {
7452
7563
  agentPersonas[role] = { agentId: row.id, lastSyncedUpdatedAt: updated.updated_at, ...historyId ? { historyId } : {} };
7453
7564
  outcomes.push({ role, action: "updated", agentId: row.id });
7454
7565
  } catch (e) {
7455
- if (e instanceof StaleWriteError6) {
7566
+ if (e instanceof StaleWriteError7) {
7456
7567
  outcomes.push({ role, action: "diverged", agentId: row.id });
7457
7568
  continue;
7458
7569
  }
@@ -7636,7 +7747,7 @@ async function syncSkills(route, opts) {
7636
7747
  row.updated_at
7637
7748
  )).updated_at;
7638
7749
  } catch (e) {
7639
- if (!(e instanceof StaleWriteError7) && !isPromptGateRejection(e)) throw e;
7750
+ if (!(e instanceof StaleWriteError8) && !isPromptGateRejection(e)) throw e;
7640
7751
  }
7641
7752
  }
7642
7753
  agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: anchoredAt };
@@ -7692,7 +7803,7 @@ async function syncSkills(route, opts) {
7692
7803
  agentSkills[skill.name] = { skillId: row.id, lastSyncedUpdatedAt: updated.updated_at };
7693
7804
  outcomes.push({ name: skill.name, action: "updated", skillId: row.id });
7694
7805
  } catch (e) {
7695
- if (e instanceof StaleWriteError7) {
7806
+ if (e instanceof StaleWriteError8) {
7696
7807
  outcomes.push({ name: skill.name, action: "diverged", skillId: row.id });
7697
7808
  continue;
7698
7809
  }
@@ -9474,8 +9585,62 @@ async function runRelease(o) {
9474
9585
  }
9475
9586
  }
9476
9587
  }
9477
- async function mergeAndRelease(o, decision, fresh, releaseBranch) {
9588
+ var isolateCap = (o) => o.repo.release.isolateFailures?.maxCandidates ?? DEFAULT_ISOLATE_MAX_CANDIDATES;
9589
+ async function isolateAndRetry(o, name, output, merged, preMerge, decision, fresh, releaseBranch) {
9590
+ const applied = merged.filter((c) => c.sha && c.branch);
9591
+ const cap = isolateCap(o);
9592
+ if (o.dryRun || !preMerge || applied.length < 2 || cap === 0) return null;
9593
+ const head = headSha(o.cwd);
9594
+ const withFiles = applied.map((c) => ({
9595
+ c,
9596
+ changed: new Set(
9597
+ (gitOk(o.cwd, ["diff", "--name-only", `${preMerge}...${c.branch}`]) ?? "").split("\n").filter(Boolean)
9598
+ )
9599
+ }));
9600
+ const culprits = [];
9601
+ const byFile = culpritByFiles(withFiles, failingPaths(output, o.cwd));
9602
+ if (byFile) {
9603
+ o.emit.warn(`${name} failed; its output names files only ${byFile.c.branch} changed \u2014 treating it as the culprit`, { ticket: byFile.c.ticket.issue_id });
9604
+ culprits.push({ c: byFile.c, how: "files", tail: lastLines(output, HOOK_FAILURE_TAIL_LINES) });
9605
+ } else if (applied.length > cap) {
9606
+ o.emit.warn(`${name} failed with ${applied.length} branches merged, over release.isolateFailures.maxCandidates (${cap}) \u2014 not isolating`);
9607
+ return null;
9608
+ } else {
9609
+ o.emit.warn(`${name} failed with ${applied.length} branches merged \u2014 building each alone to find the culprit`);
9610
+ for (const c of applied) {
9611
+ gitOk(o.cwd, ["reset", "--hard", preMerge]);
9612
+ const m = mergeOne(o, c);
9613
+ if (typeof m === "object" && "conflict" in m) continue;
9614
+ const r = await hook(o, name);
9615
+ if (r && r.code !== 0) {
9616
+ o.emit.warn(`${c.branch} fails the ${name} hook on its own`, { ticket: c.ticket.issue_id });
9617
+ culprits.push({ c, how: "solo", tail: lastLines(r.output, HOOK_FAILURE_TAIL_LINES) });
9618
+ }
9619
+ }
9620
+ }
9621
+ gitOk(o.cwd, ["reset", "--hard", preMerge]);
9622
+ if (culprits.length === 0) {
9623
+ o.emit.warn("no single branch could be blamed \u2014 failing the release as a whole");
9624
+ gitOk(o.cwd, ["reset", "--hard", head]);
9625
+ return null;
9626
+ }
9627
+ const out = new Set(culprits.map((x) => x.c.ticket.id));
9628
+ const rest = decision.merges.filter((c) => !out.has(c.ticket.id));
9629
+ const retried = await mergeAndRelease(o, { ...decision, merges: rest }, fresh, releaseBranch, true);
9630
+ return {
9631
+ ...retried,
9632
+ buildCulprits: culprits.map((x) => ({
9633
+ ticket: x.c.ticket,
9634
+ branch: x.c.branch,
9635
+ hook: name,
9636
+ tail: x.tail,
9637
+ how: x.how
9638
+ }))
9639
+ };
9640
+ }
9641
+ async function mergeAndRelease(o, decision, fresh, releaseBranch, isolated = false) {
9478
9642
  o.emit.enter("merge");
9643
+ const preMerge = o.dryRun ? "" : headSha(o.cwd);
9479
9644
  const merged = [];
9480
9645
  const conflicts = [];
9481
9646
  const unbuildable = [];
@@ -9590,6 +9755,10 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
9590
9755
  o.emit.warn("test gate failed with what looks like a worker crash, not a real failure \u2014 retrying once");
9591
9756
  r = await hook(o, "test");
9592
9757
  }
9758
+ if (r && r.code !== 0 && !isolated) {
9759
+ const out = await isolateAndRetry(o, "test", r.output, merged, preMerge, decision, fresh, releaseBranch);
9760
+ if (out) return out;
9761
+ }
9593
9762
  if (r && r.code !== 0) {
9594
9763
  o.emit.error(`test gate FAILED \u2014 not deploying; the target stays on the previous release`);
9595
9764
  const sha = headSha(o.cwd);
@@ -9619,13 +9788,23 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
9619
9788
  }
9620
9789
  }
9621
9790
  if (o.repo.hooks.build) {
9622
- if (o.dryRun) o.emit.emit(`would run: ${hookLabel(o.repo, "build")}`);
9623
- else {
9791
+ if (o.dryRun) {
9792
+ o.emit.emit(`would run: ${hookLabel(o.repo, "build")}`);
9793
+ if (applied > 1) {
9794
+ o.emit.emit(
9795
+ `if it fails, would find the one of ${applied} merged branches that broke it (by the files it names, else by building each alone, up to ${isolateCap(o)}), hand that back to the dev lane and ship the rest`
9796
+ );
9797
+ }
9798
+ } else {
9624
9799
  let r = await hook(o, "build");
9625
9800
  if (r && r.code !== 0 && looksLikeTransientResolveFailure(r.output)) {
9626
9801
  o.emit.warn("build failed with what looks like a transient dependency-resolution glitch, not a real failure \u2014 retrying once");
9627
9802
  r = await hook(o, "build");
9628
9803
  }
9804
+ if (r && r.code !== 0 && !isolated) {
9805
+ const out = await isolateAndRetry(o, "build", r.output, merged, preMerge, decision, fresh, releaseBranch);
9806
+ if (out) return out;
9807
+ }
9629
9808
  if (r && r.code !== 0) {
9630
9809
  o.emit.error("build failed \u2014 not deploying");
9631
9810
  if (version && !o.dryRun && headSha(o.cwd) !== preBump) {
@@ -9824,7 +10003,7 @@ function formatProjectsReport(report) {
9824
10003
  }
9825
10004
 
9826
10005
  // src/cli.ts
9827
- import { StaleWriteError as StaleWriteError8 } from "@tablation/client";
10006
+ import { StaleWriteError as StaleWriteError9 } from "@tablation/client";
9828
10007
 
9829
10008
  // src/release-ship.ts
9830
10009
  var DAY_MS = 24 * 60 * 60 * 1e3;
@@ -12963,6 +13142,20 @@ ${changed2.map((c) => ` - ${c}
12963
13142
  });
12964
13143
  }
12965
13144
  }
13145
+ if (outcome.buildCulprits?.length) {
13146
+ const seat = releaseSeat(c);
13147
+ for (const cu of outcome.buildCulprits) {
13148
+ try {
13149
+ const bounce = planBuildFailureBounce(target.dir, cu, repo.branch.base, repo.branch.remote);
13150
+ const r = await applyBuildFailureBounce(tracker, bounce, tracker.contract, seat, remit, dryRun);
13151
+ if (r.kind === "failed") {
13152
+ remit.warn(`could not hand back ${cu.ticket.issue_id}: ${r.why}`, { step: "release" });
13153
+ }
13154
+ } catch (e) {
13155
+ remit.warn(`could not hand back ${cu.ticket.issue_id}: ${e.message}`, { step: "release" });
13156
+ }
13157
+ }
13158
+ }
12966
13159
  if (outcome.conflicts?.length || outcome.unbuildable?.length) {
12967
13160
  const comments = await tracker.comments();
12968
13161
  const seat = releaseSeat(c);
@@ -14658,7 +14851,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
14658
14851
  await tracker3.updateTicket(ticket.id, { [c.columns.assignee]: null }, ticket.updated_at);
14659
14852
  emit.emit(`cleared assignee on ${label} \u2014 back up for grabs next cycle`);
14660
14853
  } catch (e) {
14661
- if (e instanceof StaleWriteError8) {
14854
+ if (e instanceof StaleWriteError9) {
14662
14855
  process.stderr.write(`unassign: ${label} changed on the board since it was read \u2014 re-run to retry
14663
14856
  `);
14664
14857
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.43.3",
3
+ "version": "0.44.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -161,6 +161,11 @@ pass.
161
161
  base, the bounce says to merge `<remote>/<base>` into the branch, resolve and
162
162
  push fast-forward; a rebase would need a force push the builder cannot make.
163
163
  Or pass the ticket and let the release's conflict path do the merge.
164
+ - **A branch that came back from a conflict or build-failure bounce is
165
+ re-verified against the current base, not only its own tip.** Before
166
+ passing it, merge current `<remote>/<base>` into the worktree (or run the
167
+ build on a throwaway merge) and run the build: a clean text merge can still
168
+ fail to compile, and it is far cheaper to find that here than at release.
164
169
  - **You only ever enter the worktree of the ticket you are verifying.**
165
170
  - Throwaway verification scripts go in a scratch directory outside the
166
171
  repository — not the worktree root either.
@@ -116,6 +116,10 @@ Map go active now, or stay a draft?**
116
116
  - Any `draft` Issue tied to this Epic that this session's ticket
117
117
  set does NOT include (i.e. dropped by this revision) → flip to
118
118
  `status: closed_obsolete`.
119
+ 5. Set the Epic itself to `status: planned` (Epics table) if it is
120
+ still `new`. An active Map is the planning decision for the Epic;
121
+ leave an Epic that has already moved on (`in_progress`, `done`,
122
+ `cancelled`) alone.
119
123
 
120
124
  Do all of this yourself via direct MCP writes. There is no automation
121
125
  watching for this — if you don't do it, it doesn't happen.
@@ -160,6 +160,11 @@ pass.
160
160
  base, the bounce says to merge `<remote>/<base>` into the branch, resolve and
161
161
  push fast-forward; a rebase would need a force push the builder cannot make.
162
162
  Or pass the ticket and let the release's conflict path do the merge.
163
+ - **A branch that came back from a conflict or build-failure bounce is
164
+ re-verified against the current base, not only its own tip.** Before
165
+ passing it, merge current `<remote>/<base>` into the worktree (or run the
166
+ build on a throwaway merge) and run the build: a clean text merge can still
167
+ fail to compile, and it is far cheaper to find that here than at release.
163
168
  - **You only ever enter the worktree of the ticket you are verifying.**
164
169
  - Throwaway verification scripts go in a scratch directory outside the
165
170
  repository — not the worktree root either.
@@ -116,6 +116,10 @@ Map go active now, or stay a draft?**
116
116
  - Any `draft` Issue tied to this Epic that this session's ticket
117
117
  set does NOT include (i.e. dropped by this revision) → flip to
118
118
  `status: closed_obsolete`.
119
+ 5. Set the Epic itself to `status: planned` (Epics table) if it is
120
+ still `new`. An active Map is the planning decision for the Epic;
121
+ leave an Epic that has already moved on (`in_progress`, `done`,
122
+ `cancelled`) alone.
119
123
 
120
124
  Do all of this yourself via direct MCP writes. There is no automation
121
125
  watching for this — if you don't do it, it doesn't happen.