@tablation/crew 0.15.0 → 0.16.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
@@ -5657,6 +5657,128 @@ function spawnAgent(plan, emit) {
5657
5657
  });
5658
5658
  }
5659
5659
 
5660
+ // src/handoff-guard.ts
5661
+ var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
5662
+ var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
5663
+ function remoteTip(dir, remote, branch) {
5664
+ const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
5665
+ if (out === null) return null;
5666
+ return out.split("\n").find((l) => l.endsWith(` refs/heads/${branch}`))?.split(" ")[0] ?? "";
5667
+ }
5668
+ function inspectHandoff(dir, cfg, ticket, role) {
5669
+ const remote = cfg.branch.remote;
5670
+ if (!remoteConfigured(dir, remote)) return { kind: "unverified", why: `no remote \`${remote}\` configured` };
5671
+ const branch = existingBranchForTicket(dir, cfg, ticket, role);
5672
+ if (!branch) {
5673
+ return {
5674
+ kind: "bounce",
5675
+ branch: null,
5676
+ remote,
5677
+ local: null,
5678
+ origin: null,
5679
+ reason: `no branch for this ticket exists in this checkout, so there is nothing on \`${remote}\` to hand to QA`
5680
+ };
5681
+ }
5682
+ const wt = worktrees(dir).find((w) => w.branch === branch);
5683
+ const local = wt ? headSha(wt.path) : gitOk(dir, ["rev-parse", `refs/heads/${branch}`]);
5684
+ const origin = remoteTip(dir, remote, branch);
5685
+ if (origin === null) return { kind: "unverified", why: `could not reach \`${remote}\` to check \`${branch}\`` };
5686
+ if (!origin) {
5687
+ return {
5688
+ kind: "bounce",
5689
+ branch,
5690
+ remote,
5691
+ local,
5692
+ origin: null,
5693
+ reason: `\`${branch}\` does not exist on \`${remote}\` \u2014 it was never pushed (or the push failed)`
5694
+ };
5695
+ }
5696
+ if (origin !== local) {
5697
+ return {
5698
+ kind: "bounce",
5699
+ branch,
5700
+ remote,
5701
+ local,
5702
+ origin,
5703
+ reason: `\`${remote}/${branch}\` is at \`${origin}\`, not at this worktree's HEAD \u2014 the latest commits were not pushed, or the remote moved`
5704
+ };
5705
+ }
5706
+ return { kind: "pushed", branch, sha: origin, remote };
5707
+ }
5708
+ function bounceComment(v, shipName2) {
5709
+ return `${HANDOFF_BOUNCED_MARKER}
5710
+ **Hand-off bounced: the branch is not on \`${v.remote}\` at the tip this ticket was built to.**
5711
+
5712
+ ${shipName2 ? `Ship: ${shipName2}
5713
+ ` : ""}Local HEAD: ${v.local ? `\`${v.local}\`` : "(no local branch)"}
5714
+ \`${v.remote}\`: ${v.origin ? `\`${v.origin}\`` : v.branch ? "(branch missing)" : "(no branch to look for)"}
5715
+
5716
+ ${v.reason[0].toUpperCase()}${v.reason.slice(1)}. Set back to \`in_progress\`, still held by this ship; push with a plain \`git push --set-upstream ${v.remote} <branch>\` (never \`--force\`) and set \`fixed\` again.`;
5717
+ }
5718
+ async function enforceHandoff(i) {
5719
+ try {
5720
+ const t = await i.writer.ticket(i.ticketId);
5721
+ if (t.status !== i.contract.statuses.handoff) return { kind: "not-handed-off" };
5722
+ const log = { ticket: t.issue_id, step: "handoff" };
5723
+ const v = inspectHandoff(i.dir, i.cfg, t, i.role);
5724
+ if (v.kind === "unverified") {
5725
+ i.log.warn(`${t.issue_id} is at ${t.status} but the push was not checked: ${v.why}`, log);
5726
+ return { kind: "unverified", why: v.why };
5727
+ }
5728
+ if (v.kind === "pushed") {
5729
+ await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId);
5730
+ i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
5731
+ return { kind: "pushed", sha: v.sha };
5732
+ }
5733
+ const heldBy = i.hold ? t[i.hold.column] : void 0;
5734
+ await i.writer.updateTicket(t.id, {
5735
+ status: i.contract.statuses.building,
5736
+ ...t.assignee_id ? {} : { assignee_id: i.seatId },
5737
+ ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
5738
+ });
5739
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId).catch(() => {
5740
+ i.log.warn("bounced, but the note failed to post", log);
5741
+ });
5742
+ i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
5743
+ return { kind: "bounced" };
5744
+ } catch (e) {
5745
+ i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
5746
+ return { kind: "failed", why: e.message };
5747
+ }
5748
+ }
5749
+ async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
5750
+ if (role !== "dev" && role !== "design" || !workingId) return null;
5751
+ const seatId = route.resolved?.seats[role];
5752
+ if (!seatId) return null;
5753
+ try {
5754
+ const t = await tracker.ticket(workingId);
5755
+ const target = repoTargetFor(route, t.repo_id);
5756
+ if (!target) return null;
5757
+ const o = route.repoOverrides[target.name];
5758
+ const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
5759
+ hooks: { ...route.hooks, ...o?.hooks },
5760
+ labels: { ...route.labels, ...o?.labels },
5761
+ branch: { ...route.branch, ...o?.branch }
5762
+ }, target.dir);
5763
+ const affinity = await tracker.claimAffinity();
5764
+ return await enforceHandoff({
5765
+ writer: tracker,
5766
+ contract: tracker.contract,
5767
+ ticketId: workingId,
5768
+ role,
5769
+ dir: target.dir,
5770
+ cfg,
5771
+ seatId,
5772
+ shipName: ship.name,
5773
+ hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
5774
+ log
5775
+ });
5776
+ } catch (e) {
5777
+ log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
5778
+ return null;
5779
+ }
5780
+ }
5781
+
5660
5782
  // src/connect-wizard.ts
5661
5783
  import { delimiter, dirname as dirname6, join as join8 } from "path";
5662
5784
  import { accessSync as accessSync2, constants as constants2, existsSync as existsSync8, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
@@ -9499,6 +9621,7 @@ async function runRoleAgent(role, decision, ctx) {
9499
9621
  }
9500
9622
  try {
9501
9623
  await spawnAgent(plan, emit);
9624
+ await guardRunHandoff(route, ship, tracker, role, workingId, emit);
9502
9625
  } finally {
9503
9626
  if (memberId) {
9504
9627
  try {
@@ -10594,6 +10717,7 @@ ${changed2.map((c) => ` - ${c}
10594
10717
  }
10595
10718
  try {
10596
10719
  await spawnAgent(fleetPlan, wemit);
10720
+ await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
10597
10721
  } finally {
10598
10722
  dropLock();
10599
10723
  if (fleetMemberId) {
@@ -10804,6 +10928,7 @@ ${changed2.map((c) => ` - ${c}
10804
10928
  }
10805
10929
  try {
10806
10930
  await spawnAgent(plan, emit);
10931
+ await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
10807
10932
  } finally {
10808
10933
  dropLock();
10809
10934
  if (memberId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.15.0",
3
+ "version": "0.16.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",
@@ -556,6 +556,12 @@ if `report_type` is either of those.
556
556
  `in_progress` and the next `fixed` push again — a plain fast-forward, never
557
557
  `--force`; if the push is rejected, someone else moved the branch, so stop
558
558
  and say so on the ticket instead of forcing it.
559
+ The runner checks this when your session ends: if the ticket is at `fixed`
560
+ and `<remote>/<branch>` is missing or not at your worktree's HEAD, it sets
561
+ the ticket back to `in_progress` (still yours) with an event saying what it
562
+ found, and QA never sees it. When it matches, the runner records
563
+ `pushed <sha> to <remote>/<branch>` on the ticket itself; you still state the
564
+ sha in your last progress comment.
559
565
  Leave the worktree and branch exactly where they are, unmerged: QA boots
560
566
  *your worktree* on its derived ports to test the fix, so removing it
561
567
  would leave QA nothing to test. Your last progress comment is what QA
@@ -62,6 +62,13 @@ human review, so "probably fine" is not a pass.
62
62
  `git worktree add <the digest's worktree> <name>` from the ticket's repo
63
63
  checkout (git creates the local tracking branch) — and carry on. Only
64
64
  **MISSING** (gone locally and on the remote) means there is nothing to test.
65
+ **Name the sha you test.** Your starting comment states the
66
+ `<remote>/<branch>` tip you fetched. The runner records the pushed tip as an
67
+ event comment on the ticket, `pushed <sha> to <remote>/<branch>`; if the tip
68
+ you fetched differs from the latest such event, say so in that same comment
69
+ and verify the remote tip anyway — the remote is the only git truth, and the
70
+ difference is information, not a reason to refuse. If the branch is not on
71
+ the remote at all, bounce the ticket to `in_progress` with that reason.
65
72
  5. **Run it.** The builder left its worktree in place for you: `cd` into it,
66
73
  `nvm use` if the repository pins a version, and `eval` the **`ports`** hook
67
74
  for this worktree's own ports (anything already listening on them is a dead
@@ -538,6 +538,12 @@ if `report_type` is either of those.
538
538
  `in_progress` and the next `fixed` push again — a plain fast-forward, never
539
539
  `--force`; if the push is rejected, someone else moved the branch, so stop
540
540
  and say so on the ticket instead of forcing it.
541
+ The runner checks this when your session ends: if the ticket is at `fixed`
542
+ and `<remote>/<branch>` is missing or not at your worktree's HEAD, it sets
543
+ the ticket back to `in_progress` (still yours) with an event saying what it
544
+ found, and QA never sees it. When it matches, the runner records
545
+ `pushed <sha> to <remote>/<branch>` on the ticket itself; you still state the
546
+ sha in your last progress comment.
541
547
  Leave the worktree and branch exactly where they are, unmerged: QA boots
542
548
  *your worktree* on its derived ports to test the fix, so removing it
543
549
  would leave QA nothing to test. Your last progress comment is what QA
@@ -54,6 +54,13 @@ human review, so "probably fine" is not a pass.
54
54
  Does the change actually do what the comment claims? Does it handle the
55
55
  empty/error/permission path, or only the happy one? Does it touch
56
56
  anything the ticket never mentioned?
57
+ **Name the sha you test.** Your starting comment states the
58
+ `<remote>/<branch>` tip you fetched. The runner records the pushed tip as an
59
+ event comment on the ticket, `pushed <sha> to <remote>/<branch>`; if the tip
60
+ you fetched differs from the latest such event, say so in that same comment
61
+ and verify the remote tip anyway — the remote is the only git truth, and the
62
+ difference is information, not a reason to refuse. If the branch is not on
63
+ the remote at all, bounce the ticket to `in_progress` with that reason.
57
64
  5. **Run it.** The builder left its worktree in place for you: `cd` into it,
58
65
  `nvm use` if the repository pins a version, and `eval` the **`ports`** hook
59
66
  for this worktree's own ports (anything already listening on them is a dead