@tablation/crew 0.73.1 → 0.73.4

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.
Files changed (2) hide show
  1. package/dist/cli.js +109 -21
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3563,7 +3563,7 @@ function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-
3563
3563
  }
3564
3564
  return null;
3565
3565
  }
3566
- function levelRemoteBranch(cwd, remote, branch) {
3566
+ function levelRemoteBranch(cwd, remote, branch, baseRef) {
3567
3567
  const local = gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]);
3568
3568
  if (!remoteBranchExists(cwd, remote, branch)) return { exists: local !== null };
3569
3569
  if (local === null) {
@@ -3572,6 +3572,20 @@ function levelRemoteBranch(cwd, remote, branch) {
3572
3572
  const remoteTip2 = gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]);
3573
3573
  if (!remoteTip2 || remoteTip2 === local) return { exists: true };
3574
3574
  if (!isAncestor(cwd, local, remoteTip2)) {
3575
+ if (baseRef && isAncestor(cwd, local, baseRef)) {
3576
+ const holder3 = worktrees(cwd).find((w) => w.branch === branch && !w.prunable);
3577
+ if (holder3) {
3578
+ if (gitOk(holder3.path, ["status", "--porcelain"])) {
3579
+ return { exists: true, problem: `${branch} holds only commits already on ${baseRef}, but the worktree ${holder3.path} that holds it has uncommitted changes` };
3580
+ }
3581
+ if (gitOk(holder3.path, ["reset", "--hard", "--quiet", remoteTip2]) === null) {
3582
+ return { exists: true, problem: `could not reset ${branch} to ${remote}/${branch} in ${holder3.path}` };
3583
+ }
3584
+ } else if (gitOk(cwd, ["branch", "-f", branch, remoteTip2]) === null) {
3585
+ return { exists: true, problem: `could not reset ${branch} to ${remote}/${branch}` };
3586
+ }
3587
+ return { exists: true, reset: `${branch} held only commits already on ${baseRef}; reset ${local.slice(0, 8)} -> ${remote}/${branch} at ${remoteTip2.slice(0, 8)}` };
3588
+ }
3575
3589
  const diverged = !isAncestor(cwd, remoteTip2, local);
3576
3590
  const holder2 = diverged ? worktrees(cwd).find((w) => w.branch === branch && !w.prunable) : void 0;
3577
3591
  if (holder2) {
@@ -4225,7 +4239,7 @@ function describeComposition(c) {
4225
4239
 
4226
4240
  ${rows}
4227
4241
 
4228
- The gate runs on the base plus every verified branch above, not on the base alone: reproduce on that composition.`;
4242
+ The gate runs on the base plus every verified branch above, not on the base alone: reproduce on that composition. A fix that compiles only on the composition belongs on one of those branches (the one owning the failing file, after merging the others into it), not on a new branch cut from the base: a separate fix branch conflicts with the branches it contains when the release squash-merges them all. Re-queue the touched ticket to QA and close this alert with a note.`;
4229
4243
  }
4230
4244
 
4231
4245
  // src/shell.ts
@@ -6483,11 +6497,12 @@ function locateBranchForTicket(dir, cfg, t, role, fetchedDirs) {
6483
6497
  const found = remoteBranchForIssue(dir, remote, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role, cfg.branch.lowercase));
6484
6498
  return found ? { branch: found, where: "remote" } : null;
6485
6499
  }
6486
- function materializeBranchForTicket(dir, cfg, t, role, onProblem) {
6500
+ function materializeBranchForTicket(dir, cfg, t, role, onProblem, onReset) {
6487
6501
  const loc = locateBranchForTicket(dir, cfg, t, role);
6488
6502
  if (!loc) return null;
6489
- const r = levelRemoteBranch(dir, cfg.branch.remote, loc.branch);
6503
+ const r = levelRemoteBranch(dir, cfg.branch.remote, loc.branch, `${cfg.branch.remote}/${cfg.branch.base}`);
6490
6504
  if (r.problem) onProblem?.(r.problem);
6505
+ if (r.reset) onReset?.(r.reset);
6491
6506
  return loc.branch;
6492
6507
  }
6493
6508
 
@@ -6774,10 +6789,10 @@ function applyRunWorktree(dir, cfg, plan) {
6774
6789
  case "recut":
6775
6790
  git(dir, ["worktree", "remove", "--force", plan.path]);
6776
6791
  gitOk(dir, ["branch", "-D", plan.branch]);
6777
- git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
6792
+ git(dir, ["worktree", "add", "--no-track", "-b", plan.branch, plan.path, plan.base]);
6778
6793
  return plan.path;
6779
6794
  case "cut-fresh":
6780
- git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
6795
+ git(dir, ["worktree", "add", "--no-track", "-b", plan.branch, plan.path, plan.base]);
6781
6796
  return plan.path;
6782
6797
  }
6783
6798
  }
@@ -6785,6 +6800,19 @@ function worktreeHoldsUnpushed(path, remote) {
6785
6800
  const out = gitOk(path, ["for-each-ref", "--contains", "HEAD", "--count=1", "--format=%(refname)", `refs/remotes/${remote}/`]);
6786
6801
  return out === null || out.trim() === "";
6787
6802
  }
6803
+ function dropEmptyRunBranch(dir, cfg, branch) {
6804
+ const { remote, base } = cfg.branch;
6805
+ const baseRef = remoteConfigured(dir, remote) ? `${remote}/${base}` : base;
6806
+ if (gitOk(dir, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) return false;
6807
+ if (remoteConfigured(dir, remote) && remoteBranchExists(dir, remote, branch)) return false;
6808
+ if (countCommits(dir, `${baseRef}..${branch}`) !== 0) return false;
6809
+ const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
6810
+ if (wt && existsSync14(wt.path)) {
6811
+ if (status(wt.path).length > 0) return false;
6812
+ if (gitOk(dir, ["worktree", "remove", wt.path]) === null) return false;
6813
+ }
6814
+ return gitOk(dir, ["branch", "-D", branch]) !== null;
6815
+ }
6788
6816
  async function finishRunWorktree(dir, cfg, branch) {
6789
6817
  const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
6790
6818
  if (!wt || !existsSync14(wt.path)) return { kind: "none" };
@@ -6953,6 +6981,12 @@ async function guardRunHandoff(route, ship, tracker, role, workingId, log, since
6953
6981
  log
6954
6982
  });
6955
6983
  if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
6984
+ else {
6985
+ const branch = existingBranchForTicket(target.dir, cfg, t, role);
6986
+ if (branch && dropEmptyRunBranch(target.dir, cfg, branch)) {
6987
+ log.emit(`${t.issue_id}: dropped ${branch} \u2014 the run made no commits beyond ${cfg.branch.base}`, { ticket: t.issue_id, step: "handoff" });
6988
+ }
6989
+ }
6956
6990
  if (since2) await guardRunStrays(route, ship, tracker, role, workingId, since2, log);
6957
6991
  return outcome;
6958
6992
  } catch (e) {
@@ -10297,7 +10331,8 @@ var lastActedHandBack = (ticket, events) => {
10297
10331
  return mine.filter((e) => e.event_type === "conflict_handed_back" && pushes.some((p) => p > stampOf(e))).sort((a, b) => stampOf(b).localeCompare(stampOf(a)))[0];
10298
10332
  };
10299
10333
  var MAX_ABSORBED_HANDBACKS = 2;
10300
- function planConflictBounce(cwd, ticket, branch, base, paths, events) {
10334
+ function planConflictBounce(cwd, ticket, branch, base, paths, events, merged = []) {
10335
+ const siblings = merged.filter((m) => m.branch !== branch && paths.some((p) => (gitOk(cwd, ["diff", "--name-only", `${base}...${m.branch}`, "--", p]) ?? "").trim() !== ""));
10301
10336
  return {
10302
10337
  ticket,
10303
10338
  branch,
@@ -10307,7 +10342,8 @@ function planConflictBounce(cwd, ticket, branch, base, paths, events) {
10307
10342
  priorBounces: priorBounces(ticket, events),
10308
10343
  cwd,
10309
10344
  baseSha: gitOk(cwd, ["rev-parse", "--verify", "-q", base]) || null,
10310
- priorBaseSha: baseShaOf(lastActedHandBack(ticket, events) ?? { record_id: "", event_type: "" })
10345
+ priorBaseSha: baseShaOf(lastActedHandBack(ticket, events) ?? { record_id: "", event_type: "" }),
10346
+ siblings
10311
10347
  };
10312
10348
  }
10313
10349
  function refreshBranch(worktree, base) {
@@ -10336,7 +10372,7 @@ function handBackComment(b, merged) {
10336
10372
 
10337
10373
  **Back with the dev lane \u2014 this branch no longer merges.**
10338
10374
 
10339
- \`${b.branch}\` was verified, but squash-merging it into \`${b.base}\` conflicts in ${list}. Work that landed on \`${b.base}\` after this branch was cut has changed the same lines.
10375
+ \`${b.branch}\` was verified, but squash-merging it into \`${b.base}\` conflicts in ${list}. ${b.siblings?.length ? `This conflicts with ${b.siblings.map((x) => `${x.key} (\`${x.branch}\`)`).join(", ")}, merged earlier in the same release, not with a \`${b.base}\` that moved: merging \`${b.base}\` in will change nothing. Merge ${b.siblings.length === 1 ? "that branch" : "those branches"} into this one (or move this change onto ${b.siblings.length === 1 ? "it" : "one of them"}), resolve, and push.` : `Work that landed on \`${b.base}\` after this branch was cut has changed the same lines.`}
10340
10376
 
10341
10377
  ${merged ? `\`${b.base}\` has been merged into \`${b.branch}\` in its worktree and the conflict is left in place, so \`git status\` there shows exactly what disagrees. Resolve it, commit, push as a plain fast-forward (the branch is pushed, so rebasing it is not an option) and hand back to QA as usual \u2014 \`git merge --abort\` backs the merge out if you would rather start differently.` : `This ship has no worktree for \`${b.branch}\`, so nothing has been merged for you. Merge \`${b.base}\` into the branch wherever it lives, resolve the conflict, and commit.`}
10342
10378
 
@@ -10584,6 +10620,19 @@ async function applyStrandedVerified(writer, s, contract, operatorId, memberId,
10584
10620
  }
10585
10621
  }
10586
10622
 
10623
+ // src/local-ref-skip.ts
10624
+ var LOCAL_REF_SKIP_MARKER = calloutHeader("WARNING", "Release Skipped");
10625
+ function localRefSkipComment(shipName2, problem) {
10626
+ return `${LOCAL_REF_SKIP_MARKER}
10627
+ > ${problem}
10628
+
10629
+ **This ticket is verified but was not released: the release ship's local branch disagrees with the remote.**
10630
+
10631
+ Ship ${shipName2} found: ${problem}. The release refuses to squash a ref nobody verified and retries every cycle. If the local branch holds only commits already on the base, run \`git branch -f <branch> <remote>/<branch>\` in the release checkout (the crew now does this itself when it can); otherwise a person needs to reconcile the branch.
10632
+ `;
10633
+ }
10634
+ var localRefSkipReported = (ticket, problem, events) => events.some((e) => e.record_id === ticket.id && e.event_type === "local_ref_skip" && (e.message ?? "").includes(problem));
10635
+
10587
10636
  // src/connect-wizard.ts
10588
10637
  import { delimiter, dirname as dirname10, join as join23 } from "path";
10589
10638
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync21, mkdirSync as mkdirSync13, writeFileSync as writeFileSync15 } from "fs";
@@ -11641,7 +11690,7 @@ function openReleaseCheckout(o) {
11641
11690
  o.emit.emit(`release checkout: ${dir}`, { step: "release" });
11642
11691
  const close = () => closeReleaseCheckout(o.cwd, root, dir, branch);
11643
11692
  try {
11644
- git(o.cwd, ["worktree", "add", "-q", "-b", branch, dir, `${remote}/${base}`]);
11693
+ git(o.cwd, ["worktree", "add", "-q", "--no-track", "-b", branch, dir, `${remote}/${base}`]);
11645
11694
  } catch (e) {
11646
11695
  close();
11647
11696
  throw e;
@@ -11667,10 +11716,15 @@ function reportHeld(o, held) {
11667
11716
  }
11668
11717
  async function runRelease(o) {
11669
11718
  const movedPast2 = [];
11670
- const out = await runReleaseCycle(o, movedPast2);
11671
- return movedPast2.length ? { ...out, movedPast: movedPast2 } : out;
11719
+ const localRefSkips = [];
11720
+ const out = await runReleaseCycle(o, movedPast2, localRefSkips);
11721
+ return {
11722
+ ...out,
11723
+ ...movedPast2.length ? { movedPast: movedPast2 } : {},
11724
+ ...localRefSkips.length ? { localRefSkips } : {}
11725
+ };
11672
11726
  }
11673
- async function runReleaseCycle(o, movedPast2) {
11727
+ async function runReleaseCycle(o, movedPast2, localRefSkips) {
11674
11728
  const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
11675
11729
  const tagPattern = o.repo.release.tagPattern ?? "v*";
11676
11730
  o.emit.enter("release");
@@ -11682,8 +11736,9 @@ async function runReleaseCycle(o, movedPast2) {
11682
11736
  if (t.status !== o.contract.statuses.verified) continue;
11683
11737
  materializeBranchForTicket(o.cwd, o.repo, t, void 0, (problem) => {
11684
11738
  skipped.add(t.id);
11739
+ localRefSkips.push({ ticket: t, problem });
11685
11740
  o.emit.error(`not releasing ${t.issue_id} this cycle: ${problem}`, { ticket: t.issue_id });
11686
- });
11741
+ }, (note) => o.emit.emit(`${t.issue_id}: ${note}`, { ticket: t.issue_id }));
11687
11742
  }
11688
11743
  if (skipped.size) releasable = o.tickets.filter((t) => !skipped.has(t.id));
11689
11744
  }
@@ -11702,6 +11757,10 @@ async function runReleaseCycle(o, movedPast2) {
11702
11757
  `not releasing ${t.issue_id}: local ${branch} at ${moved.tip.slice(0, 8)} is not on ${o.repo.branch.remote}/${branch} (${remoteTip2.slice(0, 8)}); not requeuing it`,
11703
11758
  { ticket: t.issue_id }
11704
11759
  );
11760
+ localRefSkips.push({
11761
+ ticket: t,
11762
+ problem: `local ${branch} at ${moved.tip.slice(0, 8)} is not on ${o.repo.branch.remote}/${branch} (${remoteTip2.slice(0, 8)})`
11763
+ });
11705
11764
  continue;
11706
11765
  }
11707
11766
  movedPast2.push({ ticket: t, verified: moved.verified, head: moved.tip, branch: pushedBranchForTicket(o.repo, t) });
@@ -14692,7 +14751,15 @@ function planFix(f) {
14692
14751
  const actions = [];
14693
14752
  const notes = [];
14694
14753
  for (const r of f.routes) {
14695
- if (!r.enabled && sound(r)) actions.push({ kind: "enable", route: r.route });
14754
+ if (!r.enabled && sound(r)) {
14755
+ actions.push({ kind: "enable", route: r.route });
14756
+ const gaps = r.readiness ?? [];
14757
+ if (gaps.length) {
14758
+ notes.push(`${r.route}: ${gaps.length} release-readiness gap${gaps.length === 1 ? "" : "s"} (${gaps[0]}${gaps.length > 1 ? ", ..." : ""}) \u2014 the route will run, but releases stop at the first missing hook until it is added`);
14759
+ }
14760
+ } else if (!sound(r) && !r.enabled) {
14761
+ notes.push(`${r.route}: not enabled \u2014 ${r.hasRepos ? r.problems[0] : "no checkout is configured"}`);
14762
+ }
14696
14763
  }
14697
14764
  if (f.schedulerAbsent && f.routes.some(sound)) {
14698
14765
  if (f.host === "windows") notes.push("scheduler support is not built for Windows yet \u2014 nothing to install; run `crew run` by hand");
@@ -14702,12 +14769,15 @@ function planFix(f) {
14702
14769
  }
14703
14770
  async function gatherHealth(c, check) {
14704
14771
  let problems;
14772
+ let readiness = [];
14705
14773
  try {
14706
- problems = await check();
14774
+ const r = await check();
14775
+ if (Array.isArray(r)) problems = r;
14776
+ else ({ problems, readiness } = r);
14707
14777
  } catch (e) {
14708
14778
  problems = [`checks could not run: ${e instanceof Error ? e.message : String(e)}`];
14709
14779
  }
14710
- return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems };
14780
+ return { route: c.route, enabled: c.enabled, hasRepos: c.hasRepos, problems, readiness };
14711
14781
  }
14712
14782
  function routesInScope(routes, named) {
14713
14783
  return named ? routes.filter((r) => r.route === named) : routes;
@@ -17039,6 +17109,7 @@ if (command === "passenger-sync-daemon") {
17039
17109
  async function routeProblems(c) {
17040
17110
  const host = hostPlatform();
17041
17111
  const problems = [];
17112
+ const readiness = [];
17042
17113
  if (c.hostPassengers) {
17043
17114
  if (!dockerAvailable()) problems.push("host passengers: docker is not available");
17044
17115
  if (!sshKeygenAvailable()) problems.push("host passengers: ssh-keygen is not available");
@@ -17048,7 +17119,7 @@ if (command === "passenger-sync-daemon") {
17048
17119
  for (const r of await resolvedRepos(c)) {
17049
17120
  if (!existsSync34(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
17050
17121
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
17051
- problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
17122
+ readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
17052
17123
  }
17053
17124
  try {
17054
17125
  const ships = await tracker.shipRows();
@@ -17060,7 +17131,7 @@ if (command === "passenger-sync-daemon") {
17060
17131
  } catch (e) {
17061
17132
  problems.push(`tracker unreachable: ${e instanceof Error ? e.message : String(e)}`);
17062
17133
  }
17063
- return problems;
17134
+ return { problems, readiness };
17064
17135
  }
17065
17136
  async function doctorFix(o) {
17066
17137
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
@@ -17401,6 +17472,18 @@ ${changed2.map((c) => ` - ${c}
17401
17472
  remit.warn(`could not note the red test gate on the board: ${e.message}`, { step: "merge" });
17402
17473
  }
17403
17474
  }
17475
+ if (outcome.localRefSkips?.length && !dryRun) {
17476
+ try {
17477
+ const reported = await tracker.events("local_ref_skip");
17478
+ const seat = releaseSeat(c);
17479
+ for (const s of outcome.localRefSkips) {
17480
+ if (localRefSkipReported(s.ticket, s.problem, reported)) continue;
17481
+ await tracker.postEvent(s.ticket.id, localRefSkipComment(cfg.ship.name, s.problem), seat, "local_ref_skip");
17482
+ }
17483
+ } catch (e) {
17484
+ remit.warn(`could not note the skipped release on the board: ${e.message}`, { step: "merge" });
17485
+ }
17486
+ }
17404
17487
  if (outcome.externalClosures?.length) {
17405
17488
  await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
17406
17489
  }
@@ -17490,7 +17573,8 @@ ${changed2.map((c) => ` - ${c}
17490
17573
  f.candidate.branch,
17491
17574
  repo.branch.base,
17492
17575
  f.paths,
17493
- handedBack
17576
+ handedBack,
17577
+ outcome.merged.filter((m) => m.branch).map((m) => ({ key: m.ticket.issue_id, branch: m.branch }))
17494
17578
  );
17495
17579
  const r = await applyConflictBounce(tracker, bounce, tracker.contract, seat, remit, dryRun);
17496
17580
  if (r.kind === "failed") {
@@ -19835,6 +19919,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
19835
19919
  continue;
19836
19920
  }
19837
19921
  if (!s2.upstream) continue;
19922
+ if (w.branch !== r.config.branch.base && s2.upstream === `${r.config.branch.remote}/${r.config.branch.base}`) continue;
19838
19923
  tracked++;
19839
19924
  if (s2.canFastForward) {
19840
19925
  acted++;
@@ -20334,6 +20419,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20334
20419
  // Additive (CREW-1379): `unsafe` means a ship must not cut a
20335
20420
  // worktree or release from this base. `version` stays 1.
20336
20421
  baseBranch: baseHealth(r),
20422
+ // Additive (CREW-2537): gaps that only matter once a ticket reaches
20423
+ // release (no deploy hook, no CI provider...). They never stop a poll.
20424
+ releaseReadiness: validateEffective(r.config),
20337
20425
  // Additive (CREW-2051): worktrees the sweep refuses to remove.
20338
20426
  heldWorktrees: openShipAttention(state).filter((i) => i.kind === "worktree_held" && i.held?.repo === r.name).map((i) => ({
20339
20427
  path: i.held.path,
@@ -20543,7 +20631,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
20543
20631
  );
20544
20632
  }
20545
20633
  for (const p2 of validateEffective(config)) {
20546
- process.stdout.write(` - ${p2}
20634
+ process.stdout.write(` - release readiness: ${p2}
20547
20635
  `);
20548
20636
  }
20549
20637
  const dh = doctorBaseReport(r, config.branch.base, baseHealth);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.73.1",
3
+ "version": "0.73.4",
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",