@tablation/crew 0.23.1 → 0.25.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/crew.example.yaml CHANGED
@@ -100,6 +100,9 @@ routes:
100
100
  # Only needed for a self-hosted tracker.
101
101
  # baseUrl: "https://app.tablation.com"
102
102
  apiKey: "REPLACE_ME" # or omit if `ship.apiKey` covers this workspace too
103
+ # With no apiKey (and no apiKeyFile/apiKeyVar) crew reads CREW_API_KEY from
104
+ # its process environment. Under launchd that must be set in the job's own
105
+ # environment, not exported from an interactive shell.
103
106
 
104
107
  # Where release state should be SHOWN is a fact about this machine and the
105
108
  # person watching it, not about the code — so unlike every other hook,
package/dist/cli.js CHANGED
@@ -1065,8 +1065,10 @@ function resolveApiKey(r) {
1065
1065
  if (m) return (m[1] ?? "").trim().replace(/^["']|["']$/g, "");
1066
1066
  }
1067
1067
  }
1068
+ const fromEnv = process.env.CREW_API_KEY?.trim();
1069
+ if (fromEnv) return fromEnv;
1068
1070
  throw new ConfigError(
1069
- `route "${r.route}": no API key (apiKey, or ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"})`
1071
+ `route "${r.route}": no API key (looked for, in order: apiKey; ${r.apiKeyVar ?? "VAR"} in ${r.apiKeyFile ?? "<unset>"}; CREW_API_KEY in the environment)`
1070
1072
  );
1071
1073
  }
1072
1074
  var KEYCHAIN_ELIGIBLE_ROUTES = /* @__PURE__ */ new WeakSet();
@@ -1475,6 +1477,7 @@ var State = class _State {
1475
1477
  const failed = `.deploy-failed-sha-${suffix}`;
1476
1478
  const gateFailed = `.test-gate-failed-${suffix}`;
1477
1479
  const gateReported = `.test-gate-reported-${suffix}`;
1480
+ const heldReported = `.release-held-${suffix}`;
1478
1481
  const read = (f) => {
1479
1482
  try {
1480
1483
  return readFileSync3(this.path(f), "utf8").trim() || null;
@@ -1525,6 +1528,20 @@ var State = class _State {
1525
1528
  rmSync(this.path(gateFailed), { force: true });
1526
1529
  },
1527
1530
  /** Whether the board was already told about this head's red gate. */
1531
+ heldReported: () => {
1532
+ try {
1533
+ return readFileSync3(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
1534
+ } catch {
1535
+ return null;
1536
+ }
1537
+ },
1538
+ noteHeldReported: (signature) => {
1539
+ writeFileSync(this.path(heldReported), `${signature}
1540
+ `);
1541
+ },
1542
+ clearHeldReported: () => {
1543
+ rmSync(this.path(heldReported), { force: true });
1544
+ },
1528
1545
  testGateReported: (sha) => read(gateReported) === sha,
1529
1546
  noteTestGateReported: (sha) => {
1530
1547
  writeFileSync(this.path(gateReported), `${sha}
@@ -3934,15 +3951,18 @@ function blockerInfoMap(tickets, extra = []) {
3934
3951
  }
3935
3952
  function computeBlockedIds(tickets, info) {
3936
3953
  const blocked = /* @__PURE__ */ new Set();
3937
- for (const t of tickets) {
3938
- const unresolved = (t.blocked_by ?? []).some((id) => {
3939
- const status2 = info[id]?.status;
3940
- return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
3941
- });
3942
- if (unresolved) blocked.add(t.id);
3943
- }
3954
+ for (const t of tickets) if (unresolvedBlockers(t, info).length > 0) blocked.add(t.id);
3944
3955
  return blocked;
3945
3956
  }
3957
+ function unresolvedBlockers(t, info) {
3958
+ return (t.blocked_by ?? []).filter((id) => {
3959
+ const status2 = info[id]?.status;
3960
+ return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
3961
+ });
3962
+ }
3963
+ function describeUnresolved(t, info) {
3964
+ return unresolvedBlockers(t, info).map((id) => `${info[id]?.issue_tag ?? info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
3965
+ }
3946
3966
  function sweepDiagnostics(tickets, info) {
3947
3967
  const dangling = [];
3948
3968
  const selfBlocked = [];
@@ -4258,9 +4278,12 @@ function buildingRoleHasWork(role, i) {
4258
4278
  }
4259
4279
  return { hasWork: false, reason: "nothing startable and no new comments" };
4260
4280
  }
4281
+ function qaCandidates(i) {
4282
+ return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds) && !i.blocked.has(t.id));
4283
+ }
4261
4284
  function qaRoleHasWork(i) {
4262
4285
  if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
4263
- const n = withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds)).length;
4286
+ const n = qaCandidates(i).length;
4264
4287
  return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
4265
4288
  }
4266
4289
  function triageRoleHasWork(i) {
@@ -4276,7 +4299,7 @@ function roleHasWork(role, i) {
4276
4299
  return buildingRoleHasWork(role, i);
4277
4300
  }
4278
4301
  function roleCandidates(role, i) {
4279
- if (role === "qa") return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds));
4302
+ if (role === "qa") return qaCandidates(i);
4280
4303
  if (role === "triage") return triageSlice(i.tickets, i.seats.triage, i.workModes).filter((t) => !isHeld(t, i.holds));
4281
4304
  if (role === "pair") return [];
4282
4305
  const me = i.seats[role];
@@ -4610,15 +4633,30 @@ function qaDigest(i) {
4610
4633
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4611
4634
  return `| ${displayKey2(t)} | ${repo} | ${modeCell(t, i)} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
4612
4635
  };
4636
+ const waiting = i.tickets.filter((t) => (t.status === "qa" || t.status === "fixed") && i.blocked.has(t.id));
4637
+ const takeable = (status2) => i.tickets.filter((t) => t.status === status2 && !i.blocked.has(t.id));
4638
+ const waitingTable = waiting.length === 0 ? "" : `
4639
+ ### Waiting on blockers \u2014 not yours to verify yet
4640
+
4641
+ A \`fixed\` or \`qa\` ticket whose \`Blocked by\` names something not yet resolved
4642
+ (\`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`) is held out of
4643
+ verification: its change needs the blocker released first. It becomes yours on
4644
+ the first poll after the last blocker resolves. No one needs to change the ticket.
4645
+
4646
+ | ticket | status | blocked by |
4647
+ |---|---|---|
4648
+ ${waiting.map((t) => `| ${displayKey2(t)} | ${t.status} | ${blockers(t, i)} |`).join("\n")}
4649
+ `;
4613
4650
  return stream([
4614
4651
  "## Current queue \u2014 built for you by the poll\n",
4615
4652
  '\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
4616
4653
  '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
4617
4654
  "\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
4618
4655
  "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
4619
- table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
4656
+ table(takeable("qa"), i, header, row),
4620
4657
  "\n### Awaiting verification, in pick order\n",
4621
- table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
4658
+ table(takeable("fixed"), i, header, row),
4659
+ ...waitingTable ? [waitingTable] : []
4622
4660
  ]);
4623
4661
  }
4624
4662
 
@@ -6562,10 +6600,16 @@ function checkGuards(cwd, base = "main") {
6562
6600
  detail: `working tree has ${changes.length} change(s)${untrackedNote}: ${names}${more}`
6563
6601
  };
6564
6602
  }
6603
+ function heldByBlockers(tickets, verifiedStatus, info) {
6604
+ if (!info) return [];
6605
+ return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info) }));
6606
+ }
6607
+ var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
6565
6608
  var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
6566
6609
  var BUMP_LINE = /^Bump:[ \t]*(major|minor|patch)\b/gim;
6567
- function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main") {
6568
- const verified = tickets.filter((t) => t.status === contract.statuses.verified).sort((a, b) => Number.parseInt(a.issue_id.replace(/^\D+/, ""), 10) - Number.parseInt(b.issue_id.replace(/^\D+/, ""), 10));
6610
+ function planMerge(cwd, tickets, contract, lastReleased, branchFor = (t) => branchForIssue(cwd, t.issue_id), base = "main", blockerInfo) {
6611
+ const held = new Set(heldByBlockers(tickets, contract.statuses.verified, blockerInfo).map((h) => h.ticket.id));
6612
+ 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));
6569
6613
  return verified.map((ticket) => {
6570
6614
  const branch = branchFor(ticket);
6571
6615
  if (!branch) {
@@ -6683,12 +6727,13 @@ function decideRelease(cwd, tickets, contract, opts = {}) {
6683
6727
  const lastTag = latestReleaseTag(cwd, pattern, "HEAD");
6684
6728
  const lastReleased = lastTag ? tagCommit(cwd, lastTag) : null;
6685
6729
  const head = resolve2(cwd, "HEAD") ?? "";
6686
- const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base);
6730
+ const merges = block ? [] : planMerge(cwd, tickets, contract, lastReleased, opts.branchFor ?? ((t) => branchForIssue(cwd, t.issue_id)), base, opts.blockerInfo);
6687
6731
  const unreleasedCommits = lastReleased ? countCommits(cwd, `${lastReleased}..HEAD`) : 0;
6688
6732
  const unreleasedWork = lastReleased ? (gitOk(cwd, ["log", "--format=%s", `${lastReleased}..HEAD`]) ?? "").split("\n").filter((l) => l && !RELEASE_COMMIT_SUBJECT.test(l)).length : 0;
6689
6733
  return {
6690
6734
  block,
6691
6735
  merges,
6736
+ held: heldByBlockers(tickets, contract.statuses.verified, opts.blockerInfo),
6692
6737
  head,
6693
6738
  lastTag,
6694
6739
  lastReleased,
@@ -7293,6 +7338,17 @@ function openReleaseCheckout(o) {
7293
7338
  }
7294
7339
  return { dir, branch, close };
7295
7340
  }
7341
+ function reportHeld(o, held) {
7342
+ if (held.length === 0) {
7343
+ o.state?.clearHeldReported?.();
7344
+ return;
7345
+ }
7346
+ const lines = describeHeld(held);
7347
+ const signature = lines.join("\n");
7348
+ if (o.dryRun || o.state?.heldReported?.() === signature) return;
7349
+ for (const line of lines) o.emit.emit(line, { step: "release" });
7350
+ o.state?.noteHeldReported?.(signature);
7351
+ }
7296
7352
  async function runRelease(o) {
7297
7353
  const scoped = (msg) => o.scope ? `${o.scope}: ${msg}` : msg;
7298
7354
  const tagPattern = o.repo.release.tagPattern ?? "v*";
@@ -7301,11 +7357,13 @@ async function runRelease(o) {
7301
7357
  const decision = decideRelease(o.cwd, o.tickets, o.contract, {
7302
7358
  tagPattern,
7303
7359
  base: o.repo.branch.base,
7360
+ blockerInfo: o.blockerInfo,
7304
7361
  // CREW-1364: a branch another ship built and pushed at `fixed` exists
7305
7362
  // here only on the remote. Materialize it so the squash below has a ref.
7306
7363
  // A dry run only looks.
7307
7364
  branchFor: (t) => o.dryRun ? locateBranchForTicket(o.cwd, o.repo, t)?.branch ?? null : materializeBranchForTicket(o.cwd, o.repo, t)
7308
7365
  });
7366
+ reportHeld(o, decision.held);
7309
7367
  if (!fresh.ok) {
7310
7368
  o.emit.error(scoped(`refusing to release \u2014 ${fresh.why}; a person has to reconcile the two`));
7311
7369
  return { merged: [], conflicts: [], deployed: false, stopped: fresh.why, decision };
@@ -7684,6 +7742,45 @@ Still failing: ${n.detail ?? n.headline}`, memberId);
7684
7742
  }
7685
7743
  }
7686
7744
 
7745
+ // src/projects-cmd.ts
7746
+ async function buildProjectsReport(routes, open) {
7747
+ return Promise.all(routes.map(async (r) => {
7748
+ const areaId = r.areaId ?? null;
7749
+ try {
7750
+ const rows = await open(r.route).projectRowsWithModes();
7751
+ return {
7752
+ route: r.route,
7753
+ areaId,
7754
+ projects: rows.map((p) => ({
7755
+ id: p.id,
7756
+ name: p.name ?? p.id,
7757
+ issuePrefix: p.issue_prefix ?? null,
7758
+ workMode: p.mode,
7759
+ current: p.id === areaId
7760
+ }))
7761
+ };
7762
+ } catch (e) {
7763
+ return { route: r.route, areaId, error: e.message };
7764
+ }
7765
+ }));
7766
+ }
7767
+ function formatProjectsReport(report) {
7768
+ const lines = [];
7769
+ for (const r of report) {
7770
+ lines.push(`route ${r.route}`);
7771
+ if ("error" in r) {
7772
+ lines.push(` error: ${r.error}`);
7773
+ continue;
7774
+ }
7775
+ if (r.projects.length === 0) lines.push(" (no projects readable)");
7776
+ for (const p of r.projects) {
7777
+ lines.push(` ${p.current ? "*" : " "} ${p.name} ${p.issuePrefix ?? "-"} ${p.workMode} ${p.id}`);
7778
+ }
7779
+ }
7780
+ return `${lines.join("\n")}
7781
+ `;
7782
+ }
7783
+
7687
7784
  // src/cli.ts
7688
7785
  import { StaleWriteError as StaleWriteError8 } from "@tablation/client";
7689
7786
 
@@ -10522,6 +10619,7 @@ function usage() {
10522
10619
  crew deploy [route] release now, even with nothing new to merge
10523
10620
  crew watch [route] live view of what the crew is doing
10524
10621
  crew status [route] [--json] paused/running state; --json for a machine reader
10622
+ crew projects [--route R] [--json] the Projects rows each route can read (id, name, prefix, work mode)
10525
10623
  crew doctor [route] [--fix] preflight; --fix enables clean routes and offers crew install
10526
10624
  crew ports [route] which checkout owns which ports, and what is up
10527
10625
  crew reap [route] kill orphaned servers, drop worktrees for closed tickets
@@ -10685,7 +10783,7 @@ if (command === "passenger-sync-daemon") {
10685
10783
  const releaseFleetWide = ["merge", "deploy", "release"].includes(command) && flag("fleet");
10686
10784
  const DAEMON_SUBCOMMANDS = /* @__PURE__ */ new Set(["start", "stop", "status", "restart"]);
10687
10785
  const named = command === "daemon" && DAEMON_SUBCOMMANDS.has(positional[1] ?? "") ? positional[2] : positional[1];
10688
- const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || releaseFleetWide || command === "pause" || command === "resume" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
10786
+ const fleetWide = command === "inbox" || command === "connect" || command === "agents" || command === "skills" || command === "logbook" || command === "repos" || command === "projects" || releaseFleetWide || command === "pause" || command === "resume" || FLEET_CAPABLE.has(command) && !named && cfg.routes.length > 1;
10689
10787
  let route;
10690
10788
  try {
10691
10789
  route = fleetWide ? cfg.routes[0] : findRoute(cfg, named);
@@ -11126,11 +11224,13 @@ ${changed2.map((c) => ` - ${c}
11126
11224
  releaseModes
11127
11225
  );
11128
11226
  reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
11227
+ const blockerInfo = blockerInfoMap(all, await tracker.ticketsByIds(missingBlockerIds(all)));
11129
11228
  const outcome = await runRelease({
11130
11229
  cwd: target.dir,
11131
11230
  repo,
11132
11231
  contract: tracker.contract,
11133
11232
  tickets,
11233
+ blockerInfo,
11134
11234
  emit: remit,
11135
11235
  scope,
11136
11236
  state: state.release(scope),
@@ -13000,6 +13100,23 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13000
13100
  }
13001
13101
  break;
13002
13102
  }
13103
+ case "projects": {
13104
+ const only = value("route") ?? positional[1];
13105
+ const wanted = only ? cfg.routes.filter((r) => r.route === only) : cfg.routes;
13106
+ if (only && wanted.length === 0) {
13107
+ process.stderr.write(`crew projects: no route "${only}" (have: ${cfg.routes.map((r) => r.route).join(", ")})
13108
+ `);
13109
+ process.exit(2);
13110
+ }
13111
+ const report = await buildProjectsReport(
13112
+ wanted.map((r) => ({ route: r.route, areaId: r.resolved?.areaId })),
13113
+ (name) => new Tracker(wanted.find((r) => r.route === name), cfg.ship)
13114
+ );
13115
+ process.stdout.write(flag("json") ? `${JSON.stringify({ routes: report }, null, 2)}
13116
+ ` : formatProjectsReport(report));
13117
+ if (report.length > 0 && report.every((r) => "error" in r)) process.exitCode = 1;
13118
+ break;
13119
+ }
13003
13120
  case "status": {
13004
13121
  const fairness = state.fairness(route.route);
13005
13122
  const waiting = fairness.waiting();
@@ -13130,6 +13247,17 @@ watermark: ${state.watermark()}
13130
13247
  `);
13131
13248
  process.stdout.write(`releaser: ${r.name} \u2014 ${await releaserLine(statusTracker, route.route, r.name, r.config.release)}
13132
13249
  `);
13250
+ }
13251
+ try {
13252
+ const open = await statusTracker.openTickets();
13253
+ const held = heldByBlockers(
13254
+ open,
13255
+ statusTracker.contract.statuses.verified,
13256
+ blockerInfoMap(open, await statusTracker.ticketsByIds(missingBlockerIds(open)))
13257
+ );
13258
+ for (const line of describeHeld(held)) process.stdout.write(`release: ${line}
13259
+ `);
13260
+ } catch {
13133
13261
  }
13134
13262
  if (waiting) {
13135
13263
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.23.1",
3
+ "version": "0.25.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",
@@ -684,9 +684,42 @@ post. They record who filed the ticket and from where. The block lists a column
684
684
  only when this workspace has it, so a column it does not name is not yours to set.
685
685
 
686
686
  Name the ticket you were working when you noticed it in the new ticket's
687
- description. Only put it in the new ticket's `blocked by` column if your
688
- own ticket genuinely cannot proceed without the new one being fixed
689
- first — merely having noticed it nearby is not a dependency.
687
+ description. Only add the new ticket to **your own ticket's** `blocked_by`
688
+ (the column named `blocked by` in the Environment section) if your own
689
+ ticket genuinely cannot proceed without the new one being fixed first —
690
+ merely having noticed it nearby is not a dependency. The dependency always
691
+ points from the ticket that waits to the ticket it waits on, never the
692
+ other way round.
693
+
694
+ ### Work your ticket needs in another repository
695
+
696
+ Out-of-scope breakage and **in-scope work that lives somewhere else** are
697
+ different things. If your ticket's acceptance depends on a change in another
698
+ repository (or on a change this repository cannot make), do not ship a
699
+ stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
700
+ docs or hints promise behaviour the dependency has not delivered is a defect
701
+ that QA passes and the release phase ships. Instead:
702
+
703
+ 1. File that work as its own ticket in the owning repository's project (the
704
+ Environment section names the projects and repos; if the owning repo is
705
+ not listed there, file it against the project and say which repo in the
706
+ description), setting `repo_id`, `project_id`, `filed_by_id` and
707
+ `ship_id` as in the filing block, and naming your ticket in its
708
+ description.
709
+ 2. Add the new ticket's id to **your own ticket's** `blocked_by`.
710
+ 3. Post a comment on your ticket naming each dependency and what it must
711
+ deliver.
712
+ 4. Set your ticket back to `accepted`. The poll's sweep parks it at
713
+ `blocked` and restores it to `accepted` once every blocker resolves.
714
+ In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
715
+ hybrid ticket is off limits to every lane); in an `automatic` project
716
+ clear it as for any other re-queue.
717
+ 5. Leave the branch and worktree in place, committed and pushed if they hold
718
+ work, so the lane that resumes the ticket continues from them.
719
+
720
+ Hand a ticket off as `fixed` only when the branch works against
721
+ dependencies that are already released. A hand-off comment that says "worth
722
+ separate tickets" for work the ticket needs is a hand-off that is not ready.
690
723
 
691
724
  ## Step 4 — you never merge, and you never deploy
692
725
 
@@ -82,6 +82,12 @@ stopping for this run, do the following instead:
82
82
  is actually blocked on the operator's input, not as a substitute for
83
83
  the ordinary ending above.
84
84
 
85
+ Before you post, check the comment: it must not name needed-but-unfiled
86
+ work. A design that depends on a change in another repository files that
87
+ ticket and links it in your ticket's `blocked_by` (see "Work your ticket
88
+ needs in another repository"), rather than listing it as a loose
89
+ follow-up.
90
+
85
91
  Either way, stop this ticket's work for this run once the flag is set and
86
92
  the comment posted. The operator reviews what you posted and takes it
87
93
  from there:
@@ -24,4 +24,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
24
24
  with a comment saying what still fails. Take that bounce seriously — it is
25
25
  the only reading your work gets before it is deployed.
26
26
 
27
- **No extra steps.** Follow the shared policy as written.
27
+ **Before you hand a ticket to QA, check the hand-off comment.** It must not
28
+ name needed-but-unfiled work: anything the ticket's acceptance depends on in
29
+ another repository is a filed ticket in your ticket's `blocked_by` (see "Work
30
+ your ticket needs in another repository"), not a line saying "worth separate
31
+ tickets". If it would name such work, the ticket goes back to `accepted`
32
+ instead of forward to `fixed`.
33
+
34
+ **No extra steps.** Otherwise follow the shared policy as written.
@@ -117,6 +117,13 @@ human review, so "probably fine" is not a pass.
117
117
  Missing or stale docs are a reason to bounce the ticket back to the dev
118
118
  seat, with a comment naming the page that needs the change. If the
119
119
  repository names no such location, skip this check.
120
+ 7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
121
+ comment names work that is needed in another repository (or that this
122
+ repository cannot do) and no ticket for it exists in the ticket's
123
+ `blocked_by`, that is a bounce: say so in your comment and ask for the
124
+ ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
125
+ hints promise behaviour the dependency has not yet delivered is a defect
126
+ even if every test passes. Bounce it like any other failure (step 8).
120
127
  7a. **Ask whether the fix is covered.** A behaviour change with no test that
121
128
  would catch its regression is worth naming in your comment; whether it
122
129
  is worth bouncing the ticket over is your judgement, and depends on how
@@ -666,9 +666,42 @@ post. They record who filed the ticket and from where. The block lists a column
666
666
  only when this workspace has it, so a column it does not name is not yours to set.
667
667
 
668
668
  Name the ticket you were working when you noticed it in the new ticket's
669
- description. Only put it in the new ticket's `blocked by` column if your
670
- own ticket genuinely cannot proceed without the new one being fixed
671
- first — merely having noticed it nearby is not a dependency.
669
+ description. Only add the new ticket to **your own ticket's** `blocked_by`
670
+ (the column named `blocked by` in the Environment section) if your own
671
+ ticket genuinely cannot proceed without the new one being fixed first —
672
+ merely having noticed it nearby is not a dependency. The dependency always
673
+ points from the ticket that waits to the ticket it waits on, never the
674
+ other way round.
675
+
676
+ ### Work your ticket needs in another repository
677
+
678
+ Out-of-scope breakage and **in-scope work that lives somewhere else** are
679
+ different things. If your ticket's acceptance depends on a change in another
680
+ repository (or on a change this repository cannot make), do not ship a
681
+ stand-in for the missing piece and hand the ticket off. A stand-in whose UI,
682
+ docs or hints promise behaviour the dependency has not delivered is a defect
683
+ that QA passes and the release phase ships. Instead:
684
+
685
+ 1. File that work as its own ticket in the owning repository's project (the
686
+ Environment section names the projects and repos; if the owning repo is
687
+ not listed there, file it against the project and say which repo in the
688
+ description), setting `repo_id`, `project_id`, `filed_by_id` and
689
+ `ship_id` as in the filing block, and naming your ticket in its
690
+ description.
691
+ 2. Add the new ticket's id to **your own ticket's** `blocked_by`.
692
+ 3. Post a comment on your ticket naming each dependency and what it must
693
+ deliver.
694
+ 4. Set your ticket back to `accepted`. The poll's sweep parks it at
695
+ `blocked` and restores it to `accepted` once every blocker resolves.
696
+ In a `hybrid` project keep `assignee_id` as your own seat (an unassigned
697
+ hybrid ticket is off limits to every lane); in an `automatic` project
698
+ clear it as for any other re-queue.
699
+ 5. Leave the branch and worktree in place, committed and pushed if they hold
700
+ work, so the lane that resumes the ticket continues from them.
701
+
702
+ Hand a ticket off as `fixed` only when the branch works against
703
+ dependencies that are already released. A hand-off comment that says "worth
704
+ separate tickets" for work the ticket needs is a hand-off that is not ready.
672
705
 
673
706
  ## Step 4 — you never merge, and you never deploy
674
707
 
@@ -23,4 +23,11 @@ release phase merges and ships it) or hands it back to you as `in_progress`
23
23
  with a comment saying what still fails. Take that bounce seriously — it is
24
24
  the only reading your work gets before it is deployed.
25
25
 
26
- **No extra steps.** Follow the shared policy as written.
26
+ **Before you hand a ticket to QA, check the hand-off comment.** It must not
27
+ name needed-but-unfiled work: anything the ticket's acceptance depends on in
28
+ another repository is a filed ticket in your ticket's `blocked_by` (see "Work
29
+ your ticket needs in another repository"), not a line saying "worth separate
30
+ tickets". If it would name such work, the ticket goes back to `accepted`
31
+ instead of forward to `fixed`.
32
+
33
+ **No extra steps.** Otherwise follow the shared policy as written.
@@ -104,6 +104,13 @@ human review, so "probably fine" is not a pass.
104
104
  Missing or stale docs are a reason to bounce the ticket back to the dev
105
105
  seat, with a comment naming the page that needs the change. If the
106
106
  repository names no such location, skip this check.
107
+ 7b. **Check the hand-off for needed-but-unfiled work.** If the dev or design
108
+ comment names work that is needed in another repository (or that this
109
+ repository cannot do) and no ticket for it exists in the ticket's
110
+ `blocked_by`, that is a bounce: say so in your comment and ask for the
111
+ ticket and the `blocked_by` link. A shipped stand-in whose UI, docs or
112
+ hints promise behaviour the dependency has not yet delivered is a defect
113
+ even if every test passes. Bounce it like any other failure (step 8).
107
114
  7a. **Ask whether the fix is covered.** A behaviour change with no test that
108
115
  would catch its regression is worth naming in your comment; whether it
109
116
  is worth bouncing the ticket over is your judgement, and depends on how