@tablation/crew 0.40.1 → 0.42.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
@@ -3103,7 +3103,8 @@ var DEFAULT_CONTRACT = {
3103
3103
  ship: "ship_id",
3104
3104
  heldBy: "held_by_ship_id",
3105
3105
  verifiedSha: "verified_sha",
3106
- prRef: "pr_ref"
3106
+ prRef: "pr_ref",
3107
+ position: "position"
3107
3108
  },
3108
3109
  comments: {
3109
3110
  parent: "ticket_id",
@@ -5232,7 +5233,14 @@ function missingBlockerIds(tickets) {
5232
5233
  function blockerInfoMap(tickets, extra = []) {
5233
5234
  const info = {};
5234
5235
  for (const t of [...tickets, ...extra]) {
5235
- info[t.id] = { issue_id: t.issue_id, issue_tag: t.issue_tag, status: t.status };
5236
+ info[t.id] = {
5237
+ issue_id: t.issue_id,
5238
+ issue_tag: t.issue_tag,
5239
+ status: t.status,
5240
+ repo_id: t.repo_id,
5241
+ merged_at: t.merged_at,
5242
+ commit_sha: t.commit_sha
5243
+ };
5236
5244
  }
5237
5245
  return info;
5238
5246
  }
@@ -5241,14 +5249,18 @@ function computeBlockedIds(tickets, info) {
5241
5249
  for (const t of tickets) if (unresolvedBlockers(t, info).length > 0) blocked.add(t.id);
5242
5250
  return blocked;
5243
5251
  }
5244
- function unresolvedBlockers(t, info) {
5252
+ function unresolvedBlockers(t, info, opts = {}) {
5253
+ const mergeGated = opts.mergeGated ?? true;
5245
5254
  return (t.blocked_by ?? []).filter((id) => {
5246
- const status2 = info[id]?.status;
5247
- return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
5255
+ const b = info[id];
5256
+ const status2 = b?.status;
5257
+ if (!b || status2 === void 0) return false;
5258
+ if (!RESOLVED_STATUSES.has(status2)) return true;
5259
+ return mergeGated && status2 === "verified" && !!t.repo_id && b.repo_id === t.repo_id && !b.merged_at && !b.commit_sha;
5248
5260
  });
5249
5261
  }
5250
- function describeUnresolved(t, info) {
5251
- return unresolvedBlockers(t, info).map((id) => `${info[id]?.issue_tag ?? info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
5262
+ function describeUnresolved(t, info, opts = {}) {
5263
+ return unresolvedBlockers(t, info, opts).map((id) => `${info[id]?.issue_tag ?? info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
5252
5264
  }
5253
5265
  function sweepDiagnostics(tickets, info) {
5254
5266
  const dangling = [];
@@ -5317,7 +5329,7 @@ function filingErrors(tickets, opts = {}) {
5317
5329
  function sweepComment(step) {
5318
5330
  return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
5319
5331
 
5320
- This is automatic and reversible: an approved ticket whose \`Blocked by\` entries are not all resolved is parked out of the queue, and restored on the first poll after the last one resolves. Nothing else about the ticket changes. A blocker counts as resolved at \`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\` \u2014 not at \`fixed\`, which is still an unmerged branch awaiting QA.` : `Restored to **Approved** by the crew \u2014 every blocker has resolved${step.blockers && step.blockers !== "\u2014" ? ` (${step.blockers})` : ""}.
5332
+ This is automatic and reversible: an approved ticket whose \`Blocked by\` entries are not all resolved is parked out of the queue, and restored on the first poll after the last one resolves. Nothing else about the ticket changes. A blocker counts as resolved at \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`, or at \`verified\` when it is in a different repository or already merged (\`merged_at\` set) \u2014 not at \`fixed\`, which is still an unmerged branch awaiting QA, and not at \`verified\` for a blocker in this ticket's own repository until the release has merged it, so nothing gets built stacked on an unmerged branch.` : `Restored to **Approved** by the crew \u2014 every blocker has resolved${step.blockers && step.blockers !== "\u2014" ? ` (${step.blockers})` : ""}.
5321
5333
 
5322
5334
  This restores the status it was approved at before being parked; it is back in the queue and a role may pick it up. If a blocker resolved as \`closed_wont_fix\` or \`closed_duplicate\` this ticket may still not be buildable as written \u2014 that is a call for a human, not the crew.`;
5323
5335
  }
@@ -5380,20 +5392,25 @@ function issueNumber(t) {
5380
5392
  function epicRank(t) {
5381
5393
  return t.epicInProgress ? 0 : 1;
5382
5394
  }
5395
+ function positionRank(t) {
5396
+ const p = t.position;
5397
+ return typeof p === "number" && Number.isFinite(p) ? p : Number.POSITIVE_INFINITY;
5398
+ }
5383
5399
  function rank(t, c = DEFAULT_CONTRACT) {
5384
- return [effectivePriority(t, c), severityRank(t, c), epicRank(t), issueNumber(t)];
5400
+ return [effectivePriority(t, c), positionRank(t), severityRank(t, c), epicRank(t), issueNumber(t)];
5385
5401
  }
5386
5402
  function compareRank(a, b, c = DEFAULT_CONTRACT) {
5387
5403
  const ra = rank(a, c);
5388
5404
  const rb = rank(b, c);
5389
5405
  for (let i = 0; i < ra.length; i++) {
5390
- const d = ra[i] - rb[i];
5391
- if (d !== 0) return d;
5406
+ const x = ra[i];
5407
+ const y = rb[i];
5408
+ if (x !== y) return x < y ? -1 : 1;
5392
5409
  }
5393
5410
  return 0;
5394
5411
  }
5395
5412
  function rankScalar(t, c = DEFAULT_CONTRACT) {
5396
- const [p, s, e, n] = rank(t, c);
5413
+ const [p, , s, e, n] = rank(t, c);
5397
5414
  return p * 1e9 + s * 1e7 + e * 1e6 + n;
5398
5415
  }
5399
5416
  var NOTHING_ACTIONABLE = 999999999999;
@@ -5600,7 +5617,7 @@ function claimStatuses(role, c) {
5600
5617
  }
5601
5618
  function rankedCandidates(role, i) {
5602
5619
  const c = i.contract ?? DEFAULT_CONTRACT;
5603
- return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
5620
+ return [...roleCandidates(role, i)].sort((a, b) => compareRank(a, b, c));
5604
5621
  }
5605
5622
  function roleTopRank(role, i) {
5606
5623
  if (role !== "qa" && role !== "triage" && !i.seats[role]) return NOTHING_ACTIONABLE;
@@ -5613,7 +5630,7 @@ function actionableSummary(i, pending) {
5613
5630
  const c = i.contract ?? DEFAULT_CONTRACT;
5614
5631
  const all = pending.flatMap((role) => roleCandidates(role, i));
5615
5632
  if (all.length === 0) return { count: 0 };
5616
- const top = all.reduce((best, t) => rankScalar(t, c) < rankScalar(best, c) ? t : best);
5633
+ const top = all.reduce((best, t) => compareRank(t, best, c) < 0 ? t : best);
5617
5634
  return { count: all.length, top };
5618
5635
  }
5619
5636
  var FIXED_RANK = { qa: -2, triage: -1 };
@@ -5839,6 +5856,7 @@ function blockers(t, i) {
5839
5856
  function modeCell(t, i) {
5840
5857
  return i.workModes && i.workModes.modes.size > 0 ? modeOf(t, i.workModes) : "\u2014";
5841
5858
  }
5859
+ var effCell = (t) => `p${effectivePriority(t)}${positionRank(t) === Number.POSITIVE_INFINITY ? "" : ` #${positionRank(t)}`}`;
5842
5860
  var sorted = (ts) => [...ts].sort(compareRank);
5843
5861
  var stream = (segments) => `${segments.join("\n")}
5844
5862
  `;
@@ -5857,7 +5875,7 @@ function buildingDigest(i) {
5857
5875
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
5858
5876
  const dir = i.dirFor ? i.dirFor(t) : null;
5859
5877
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
5860
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
5878
+ return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${modeCell(t, i)} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
5861
5879
  };
5862
5880
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
5863
5881
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -5897,7 +5915,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
5897
5915
  "\nWork the first one you are not required to skip. Tickets waiting on an\nunresolved dependency are already out of this table \u2014 see Blocked below.\n",
5898
5916
  table(step2, i, header, row),
5899
5917
  "\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
5900
- '\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
5918
+ '\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`, or at `verified` when it is in another\nrepository or already merged; `fixed` is still an unmerged branch awaiting QA,\nand a `verified` blocker in the same repository as the blocked ticket is unmerged until the\nrelease merges it, so neither counts.\n',
5901
5919
  blockedTable(i.tickets.filter(isBlocked)),
5902
5920
  ...heldTable ? [heldTable] : []
5903
5921
  ]);
@@ -5918,7 +5936,7 @@ function qaDigest(i) {
5918
5936
  const n = newFromOthers(i, t.id);
5919
5937
  const dir = i.dirFor ? i.dirFor(t) : null;
5920
5938
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
5921
- 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"} |`;
5939
+ return `| ${displayKey2(t)} | ${repo} | ${modeCell(t, i)} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | ${effCell(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
5922
5940
  };
5923
5941
  const waiting = i.tickets.filter((t) => (t.status === "qa" || t.status === "fixed") && i.blocked.has(t.id));
5924
5942
  const takeable = (status2) => i.tickets.filter((t) => t.status === status2 && !i.blocked.has(t.id));
@@ -5926,7 +5944,7 @@ function qaDigest(i) {
5926
5944
  ### Waiting on blockers \u2014 not yours to verify yet
5927
5945
 
5928
5946
  A \`fixed\` or \`qa\` ticket whose \`Blocked by\` names something not yet resolved
5929
- (\`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`) is held out of
5947
+ (\`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\`, or \`verified\` once merged or in another repository) is held out of
5930
5948
  verification: its change needs the blocker released first. It becomes yours on
5931
5949
  the first poll after the last blocker resolves. No one needs to change the ticket.
5932
5950
 
@@ -6113,6 +6131,7 @@ async function decideCycle(o) {
6113
6131
  data: { tickets: tickets.length, comments: comments.length }
6114
6132
  });
6115
6133
  const epicColumn = tracker.contract.columns.epic;
6134
+ const positionColumn = tracker.contract.columns.position;
6116
6135
  const epicBuilding = tracker.contract.epics.statuses.building;
6117
6136
  const epicStatusColumn = tracker.contract.epics.statusColumn;
6118
6137
  const epicStatusById = new Map(epicRows.map((e) => [e.id, e[epicStatusColumn] ?? null]));
@@ -6132,6 +6151,9 @@ async function decideCycle(o) {
6132
6151
  for (const t of tickets) {
6133
6152
  const epicId = t[epicColumn];
6134
6153
  t.epicInProgress = typeof epicId === "string" && epicStatusById.get(epicId) === epicBuilding;
6154
+ const pos = t[positionColumn];
6155
+ const n = typeof pos === "string" && pos.trim() !== "" ? Number(pos) : pos;
6156
+ t.position = typeof n === "number" && Number.isFinite(n) ? n : null;
6135
6157
  }
6136
6158
  const extra = await tracker.ticketsByIds(missingBlockerIds(tickets));
6137
6159
  const info = blockerInfoMap(tickets, extra);
@@ -8421,7 +8443,7 @@ function checkGuards(cwd, base = "main") {
8421
8443
  }
8422
8444
  function heldByBlockers(tickets, verifiedStatus, info) {
8423
8445
  if (!info) return [];
8424
- return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info) }));
8446
+ return tickets.filter((t) => t.status === verifiedStatus && unresolvedBlockers(t, info, { mergeGated: false }).length > 0).map((ticket) => ({ ticket, blockers: describeUnresolved(ticket, info, { mergeGated: false }) }));
8425
8447
  }
8426
8448
  var describeHeld = (held) => held.map((h) => `${h.ticket.issue_tag ?? h.ticket.issue_id} held: blocked by ${h.blockers}`);
8427
8449
  var CHANGELOG_LINE = /^Changelog:[ \t]*(.*)$/gm;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.40.1",
3
+ "version": "0.42.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",
@@ -183,13 +183,17 @@ should is Step 1's call, not a filesystem check.
183
183
  and then it is a stop for a person (see "Stopping"), never a bare comment.
184
184
  - Otherwise pick from the digest's "Step 2" table, which is already
185
185
  `accepted`, in your lane and in order. Absent a digest, fetch those tickets
186
- and order them by **effective priority, then severity, then `issue_id`**:
186
+ and order them by **effective priority, then manual position, then severity, then `issue_id`**:
187
187
  - **Effective priority** is the stronger of the ticket's own `priority`
188
188
  and one derived from `severity` (`s1`→p0, `s2`→p1, `s3`→p2, `s4`→p3;
189
189
  neither set → p2). p0 beats p1 beats p2 beats p3, so an explicit
190
190
  priority can only move a ticket *forward*. Priority is an escalation
191
191
  lever; to rank something down, lower its severity.
192
- - Within one tier, more severe first (`s1` … `s4`, then unset).
192
+ - Within one tier, a ticket with a manual **`position`** (a lower number
193
+ is earlier) goes before one without; position only orders tickets of the
194
+ same effective priority and never lets a lower tier jump a higher one. A
195
+ workspace with no position column skips this clause.
196
+ - Then more severe first (`s1` … `s4`, then unset).
193
197
  - Within one severity, a ticket whose **epic is already in progress** goes
194
198
  first (finishing beats starting); a `planned` epic or none ranks the
195
199
  same. Only then oldest `issue_id` first.
@@ -183,13 +183,17 @@ should is Step 1's call, not a filesystem check.
183
183
  and then it is a stop for a person (see "Stopping"), never a bare comment.
184
184
  - Otherwise pick from the digest's "Step 2" table, which is already
185
185
  `accepted`, in your lane and in order. Absent a digest, fetch those tickets
186
- and order them by **effective priority, then severity, then `issue_id`**:
186
+ and order them by **effective priority, then manual position, then severity, then `issue_id`**:
187
187
  - **Effective priority** is the stronger of the ticket's own `priority`
188
188
  and one derived from `severity` (`s1`→p0, `s2`→p1, `s3`→p2, `s4`→p3;
189
189
  neither set → p2). p0 beats p1 beats p2 beats p3, so an explicit
190
190
  priority can only move a ticket *forward*. Priority is an escalation
191
191
  lever; to rank something down, lower its severity.
192
- - Within one tier, more severe first (`s1` … `s4`, then unset).
192
+ - Within one tier, a ticket with a manual **`position`** (a lower number
193
+ is earlier) goes before one without; position only orders tickets of the
194
+ same effective priority and never lets a lower tier jump a higher one. A
195
+ workspace with no position column skips this clause.
196
+ - Then more severe first (`s1` … `s4`, then unset).
193
197
  - Within one severity, a ticket whose **epic is already in progress** goes
194
198
  first (finishing beats starting); a `planned` epic or none ranks the
195
199
  same. Only then oldest `issue_id` first.