@tablation/crew 0.78.1 → 0.79.1

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
@@ -7131,6 +7131,28 @@ async function finishRunWorktree(dir, cfg, branch) {
7131
7131
  // src/handoff-guard.ts
7132
7132
  var HANDOFF_BOUNCED_MARKER = calloutHeader("WARNING", "Hand-off Bounced");
7133
7133
  var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
7134
+ function acceptanceLines(description) {
7135
+ if (typeof description !== "string") return [];
7136
+ const out = [];
7137
+ let inside = false;
7138
+ for (const raw of description.split("\n")) {
7139
+ const heading = /^#{1,6}\s+(.*?)\s*$/.exec(raw);
7140
+ if (heading) {
7141
+ inside = /^acceptance\b/i.test(heading[1]);
7142
+ continue;
7143
+ }
7144
+ if (!inside) continue;
7145
+ const bullet = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/.exec(raw);
7146
+ if (bullet) out.push(bullet[1]);
7147
+ }
7148
+ return out;
7149
+ }
7150
+ var flat = (s) => s.replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
7151
+ function unquotedAcceptance(lines, comment) {
7152
+ const body = flat(comment);
7153
+ return lines.filter((l) => !body.includes(flat(l)));
7154
+ }
7155
+ var ACCEPTANCE_BOUNCED_MARKER = calloutHeader("WARNING", "Acceptance Checklist Missing");
7134
7156
  function remoteTip(dir, remote, branch) {
7135
7157
  const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
7136
7158
  if (out === null) return null;
@@ -7206,6 +7228,27 @@ async function takenOverSince(i, ticketRow) {
7206
7228
  return !!by && by !== i.shipName;
7207
7229
  });
7208
7230
  }
7231
+ async function missingAcceptance(i, t) {
7232
+ const lines = acceptanceLines(t.description);
7233
+ if (lines.length === 0 || !i.writer.comments) return null;
7234
+ const cc = i.contract.comments;
7235
+ const mine = (await i.writer.comments()).map((c) => c).filter((r) => r[cc.parent] === t.id && r[cc.author] === i.seatId).sort((a, b) => String(a[cc.createdAt] ?? "").localeCompare(String(b[cc.createdAt] ?? "")));
7236
+ const last = mine.at(-1);
7237
+ const missing = unquotedAcceptance(lines, last ? String(last[cc.body] ?? "") : "");
7238
+ return missing.length > 0 ? missing : null;
7239
+ }
7240
+ function acceptanceBounceComment(missing, shipName2) {
7241
+ return `${ACCEPTANCE_BOUNCED_MARKER}
7242
+
7243
+ **Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
7244
+
7245
+ ${shipName2 ? `Ship: ${shipName2}
7246
+
7247
+ ` : ""}Not quoted verbatim:
7248
+ ${missing.map((l) => `- ${l}`).join("\n")}
7249
+
7250
+ Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.`;
7251
+ }
7209
7252
  async function enforceHandoff(i) {
7210
7253
  try {
7211
7254
  const t = await i.writer.ticket(i.ticketId);
@@ -7221,6 +7264,26 @@ async function enforceHandoff(i) {
7221
7264
  i.log.warn(`${t.issue_id} is at ${t.status} but the push was not checked: ${v.why}`, log);
7222
7265
  return { kind: "unverified", why: v.why };
7223
7266
  }
7267
+ if (v.kind === "pushed" && !claimed) {
7268
+ const missing = await missingAcceptance(i, t);
7269
+ if (missing) {
7270
+ const heldNow = i.hold ? t[i.hold.column] : void 0;
7271
+ if (i.hold && heldNow && heldNow !== i.hold.shipId || await takenOverSince(i, t.id)) {
7272
+ i.log.warn(`${t.issue_id} is held by another ship now \u2014 the acceptance check is stale, left alone`, log);
7273
+ return { kind: "unverified", why: "ticket is held by another ship" };
7274
+ }
7275
+ await i.writer.updateTicket(t.id, {
7276
+ status: i.contract.statuses.building,
7277
+ ...t.assignee_id ? {} : { assignee_id: i.seatId },
7278
+ ...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
7279
+ });
7280
+ await i.writer.postEvent(t.id, acceptanceBounceComment(missing, i.shipName), i.seatId, "bounced").catch(() => {
7281
+ i.log.warn("bounced, but the note failed to post", log);
7282
+ });
7283
+ i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
7284
+ return { kind: "bounced" };
7285
+ }
7286
+ }
7224
7287
  if (v.kind === "pushed") {
7225
7288
  await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
7226
7289
  i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
@@ -11325,14 +11388,14 @@ function dedupeChangelogEntries(entries, ticket) {
11325
11388
  return out;
11326
11389
  }
11327
11390
  var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
11328
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo) {
11391
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo, ref = "HEAD") {
11329
11392
  const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
11330
11393
  const verified = tickets.filter((t) => t.status === contract.statuses.verified && !held.has(t.id)).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
11331
11394
  return verified.map((ticket) => {
11332
11395
  const branch = branchFor(ticket);
11333
11396
  if (!branch) {
11334
11397
  const keys = referenceKeys(ticket);
11335
- const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..HEAD`) : null;
11398
+ const unreleased = lastReleased ? findKeyInRange(cwd, keys, `${lastReleased}..${ref}`) : null;
11336
11399
  if (unreleased) {
11337
11400
  return {
11338
11401
  ticket,
@@ -11344,7 +11407,7 @@ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => bran
11344
11407
  skipReason: "already-merged"
11345
11408
  };
11346
11409
  }
11347
- const anywhere = findKeyInRange(cwd, keys, "HEAD");
11410
+ const anywhere = findKeyInRange(cwd, keys, ref);
11348
11411
  if (anywhere) {
11349
11412
  return {
11350
11413
  ticket,
@@ -11441,21 +11504,22 @@ ${section}`;
11441
11504
  return [...lines.slice(0, anchor), section, ...lines.slice(anchor)].join("\n");
11442
11505
  }
11443
11506
  var RELEASE_COMMIT_SUBJECT = /^Release \S+$/;
11444
- function subjectsSince(cwd, sha) {
11445
- if (!isAncestor(cwd, sha, "HEAD")) return null;
11446
- return (gitOk(cwd, ["log", "--format=%h %s", `${sha}..HEAD`]) ?? "").split("\n").filter(Boolean);
11507
+ function subjectsSince(cwd, sha, ref = "HEAD") {
11508
+ if (!isAncestor(cwd, sha, ref)) return null;
11509
+ return (gitOk(cwd, ["log", "--format=%h %s", `${sha}..${ref}`]) ?? "").split("\n").filter(Boolean);
11447
11510
  }
11448
11511
  var isReleaseCommitLine = (line) => RELEASE_COMMIT_SUBJECT.test(line.replace(/^\S+ /, ""));
11449
11512
  function decideRelease(cwd, tickets, contract, opts = {}) {
11450
11513
  const pattern = opts.tagPattern ?? "v*";
11451
11514
  const base = opts.base ?? "main";
11452
- const block = checkGuards(cwd, base);
11453
- const lastTag = latestReleaseTag(cwd, pattern, "HEAD");
11515
+ const ref = opts.ref ?? "HEAD";
11516
+ const block = opts.guard === false ? null : checkGuards(cwd, base);
11517
+ const lastTag = latestReleaseTag(cwd, pattern, ref);
11454
11518
  const lastReleased = lastTag ? tagCommit(cwd, lastTag) : null;
11455
- const head = resolve2(cwd, "HEAD") ?? "";
11456
- const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base, opts.blockerInfo);
11457
- const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..HEAD`) : 0;
11458
- const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..HEAD`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
11519
+ const head = resolve2(cwd, ref) ?? "";
11520
+ const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), opts.ref ?? base, opts.blockerInfo, ref);
11521
+ const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..${ref}`) : 0;
11522
+ const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..${ref}`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
11459
11523
  return {
11460
11524
  block,
11461
11525
  merges,
@@ -12170,10 +12234,14 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12170
12234
  }
12171
12235
  if (drop.size) releasable = releasable.filter((t) => !drop.has(t.id));
12172
12236
  }
12237
+ const { remote, base } = o.repo.branch;
12238
+ const inPlace = o.dryRun || !!o.mergeOnly || !remoteBranchExists(o.cwd, remote, base);
12239
+ const remoteRef = `${remote}/${base}`;
12173
12240
  const decision = decideRelease(o.cwd, releasable, o.contract, {
12174
12241
  tagPattern,
12175
- base: o.repo.branch.base,
12242
+ base,
12176
12243
  blockerInfo: o.blockerInfo,
12244
+ ...inPlace ? {} : { ref: remoteRef, guard: false },
12177
12245
  // CREW-1364: a branch another ship built and pushed at `fixed` exists
12178
12246
  // here only on the remote. Materialize it so the squash below has a ref.
12179
12247
  // A dry run only looks.
@@ -12202,7 +12270,7 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12202
12270
  o.state?.clearBlocked();
12203
12271
  const failedAt = o.state?.deployFailedSha();
12204
12272
  if (!o.force && failedAt) {
12205
- const since2 = failedAt === decision.head ? [] : subjectsSince(o.cwd, failedAt);
12273
+ const since2 = failedAt === decision.head ? [] : subjectsSince(o.cwd, failedAt, inPlace ? "HEAD" : remoteRef);
12206
12274
  const real = since2?.filter((l) => !isReleaseCommitLine(l)) ?? null;
12207
12275
  if (real && real.length === 0) {
12208
12276
  o.emit.warn(
@@ -12229,10 +12297,13 @@ async function runReleaseCycle(o, movedPast2, localRefSkips) {
12229
12297
  const review = await reviewHandoff(o, externalClosures, decision.merges, reviewing);
12230
12298
  return { merged: [], deployed: false, stopped: "external", decision, externalClosures, review };
12231
12299
  }
12232
- const { remote, base } = o.repo.branch;
12233
- if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
12300
+ if (inPlace) {
12234
12301
  return mergeAndRelease(o, decision, fresh);
12235
12302
  }
12303
+ const primary = checkGuards(o.cwd, base);
12304
+ if (primary) {
12305
+ o.emit.emit(scoped(`primary checkout not fast-forwarded: ${primary.detail}; its dist/ is stale until rebuilt`), { step: "release" });
12306
+ }
12236
12307
  let checkout;
12237
12308
  const stranded = recoverStrandedReleaseCheckouts(o.cwd);
12238
12309
  if (stranded.length > 0) o.emit.warn(scoped(`dropped release checkout(s) stranded by a crash: ${stranded.join(", ")}`));
@@ -17889,9 +17960,11 @@ ${changed2.map((c) => ` - ${c}
17889
17960
  preModes
17890
17961
  );
17891
17962
  reportUnplaceable(c.route, preUnplaceable, tracker.contract.statuses.verified, remit);
17963
+ const remoteBase = remoteBranchExists(target.dir, repo.branch.remote, repo.branch.base);
17892
17964
  const d = decideRelease(target.dir, pre, tracker.contract, {
17893
17965
  tagPattern: repo.release.tagPattern ?? "v*",
17894
- base: repo.branch.base
17966
+ base: repo.branch.base,
17967
+ ...remoteBase ? { ref: `${repo.branch.remote}/${repo.branch.base}`, guard: false } : {}
17895
17968
  });
17896
17969
  const unstamped = planReleasedBranches(
17897
17970
  target.dir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.78.1",
3
+ "version": "0.79.1",
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",
@@ -94,6 +94,11 @@ pass.
94
94
  Judge by exit code and summary line, and quote the summary in your
95
95
  comment.
96
96
  7. **Check the hand-off itself.**
97
+ - **Acceptance checklist.** Start from the builder's checklist: their
98
+ comment quotes each Acceptance line with the check that exercised it.
99
+ Re-run the checks that matter, and when a line fails, a bounce cites that
100
+ line by its quoted text. A line the builder marked unexercised is yours
101
+ to exercise.
97
102
  - **Documentation.** If the repository's agent instructions (CLAUDE.md,
98
103
  AGENTS.md, GEMINI.md or equivalent) name a user-facing documentation
99
104
  location, a change to a user-facing surface must come with a matching
@@ -416,6 +416,14 @@ before going further, and never change a ticket's `report_type` yourself.
416
416
  used, and anything you could not test yourself. Anything not committed and
417
417
  pushed is gone.
418
418
 
419
+ **When the ticket has an Acceptance section, your last progress comment
420
+ quotes it.** List every line of that section verbatim, each followed by the
421
+ test, command or manual check that exercised it in this run and its result.
422
+ A line you could not exercise is listed with the reason, never omitted. The
423
+ runner checks your newest comment for every line and, if one is missing,
424
+ sets the ticket back to `in_progress` instead of passing it to QA. A ticket
425
+ with no Acceptance section is unaffected.
426
+
419
427
  ## Step 4 — you never merge, and you never deploy
420
428
 
421
429
  **Nothing you do touches the base branch.** The release phase takes every
@@ -93,6 +93,11 @@ pass.
93
93
  Judge by exit code and summary line, and quote the summary in your
94
94
  comment.
95
95
  7. **Check the hand-off itself.**
96
+ - **Acceptance checklist.** Start from the builder's checklist: their
97
+ comment quotes each Acceptance line with the check that exercised it.
98
+ Re-run the checks that matter, and when a line fails, a bounce cites that
99
+ line by its quoted text. A line the builder marked unexercised is yours
100
+ to exercise.
96
101
  - **Documentation.** If the repository's agent instructions (CLAUDE.md,
97
102
  AGENTS.md, GEMINI.md or equivalent) name a user-facing documentation
98
103
  location, a change to a user-facing surface must come with a matching
@@ -411,6 +411,14 @@ before going further, and never change a ticket's `report_type` yourself.
411
411
  used, and anything you could not test yourself. Anything not committed and
412
412
  pushed is gone.
413
413
 
414
+ **When the ticket has an Acceptance section, your last progress comment
415
+ quotes it.** List every line of that section verbatim, each followed by the
416
+ test, command or manual check that exercised it in this run and its result.
417
+ A line you could not exercise is listed with the reason, never omitted. The
418
+ runner checks your newest comment for every line and, if one is missing,
419
+ sets the ticket back to `in_progress` instead of passing it to QA. A ticket
420
+ with no Acceptance section is unaffected.
421
+
414
422
  ## Step 4 — you never merge, and you never deploy
415
423
 
416
424
  **Nothing you do touches the base branch.** The release phase takes every