@tablation/crew 0.19.4 → 0.20.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
@@ -1792,6 +1792,7 @@ var DEFAULT_CONTRACT = {
1792
1792
  blockedBy: "blocked_by",
1793
1793
  updatedAt: "updated_at",
1794
1794
  needsDesign: "needs_design",
1795
+ workMode: "work_mode",
1795
1796
  reportType: "report_type",
1796
1797
  epic: "epic_id",
1797
1798
  author: "filed_by_id",
@@ -1840,6 +1841,7 @@ var DEFAULT_CONTRACT = {
1840
1841
  severityOrder: ["s1", "s2", "s3", "s4"],
1841
1842
  unknownPriorityRank: 2,
1842
1843
  coordinatingValue: "coordinating",
1844
+ manualWorkMode: "manual",
1843
1845
  repoExemptReportTypes: ["coordinating", "question", "investigation"]
1844
1846
  };
1845
1847
  function closedStatuses(c) {
@@ -1860,6 +1862,7 @@ function resolveContract(override) {
1860
1862
  severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
1861
1863
  unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
1862
1864
  coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
1865
+ manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
1863
1866
  // A workspace overriding `coordinatingValue` alone (renaming the rollup
1864
1867
  // marker) still gets that renamed value exempted here by default,
1865
1868
  // without also having to repeat it — only an explicit
@@ -3239,6 +3242,17 @@ var SHELL_WORDS = /* @__PURE__ */ new Set([
3239
3242
  "ok"
3240
3243
  ]);
3241
3244
 
3245
+ // src/work-mode.ts
3246
+ function manualProjectIds(rows, contract) {
3247
+ const out = /* @__PURE__ */ new Set();
3248
+ for (const r of rows) if (r[contract.columns.workMode] === contract.manualWorkMode) out.add(r.id);
3249
+ return out;
3250
+ }
3251
+ function withinWorkMode(tickets, me, manual) {
3252
+ if (!manual || manual.size === 0) return tickets;
3253
+ return tickets.filter((t) => !t.project_id || !manual.has(t.project_id) || !!me && t.assignee_id === me);
3254
+ }
3255
+
3242
3256
  // src/board-lock.ts
3243
3257
  import { StaleWriteError } from "@tablation/client";
3244
3258
  function releaseLockScope(route, repo) {
@@ -3460,6 +3474,22 @@ var Tracker = class {
3460
3474
  return void 0;
3461
3475
  }
3462
3476
  }
3477
+ /**
3478
+ * Ids of the Projects (area) rows in `manual` work mode (CREW-1445). One
3479
+ * extra read per cycle. A workspace with no Projects table, no `work_mode`
3480
+ * column, a null value or an unreadable table yields the empty set: every
3481
+ * project stays automatic, exactly as before the column existed.
3482
+ */
3483
+ async manualProjectIds() {
3484
+ const model = this.route.resolved?.areaModelId;
3485
+ if (!model) return /* @__PURE__ */ new Set();
3486
+ try {
3487
+ const rows = await this.client.records.list(model, { limit: 200 });
3488
+ return manualProjectIds(rows, this.contract);
3489
+ } catch {
3490
+ return /* @__PURE__ */ new Set();
3491
+ }
3492
+ }
3463
3493
  /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
3464
3494
  async projectRows() {
3465
3495
  const model = this.route.resolved?.areaModelId;
@@ -4154,7 +4184,7 @@ function notHeldByOtherShip(slice, i) {
4154
4184
  function buildingRoleHasWork(role, i) {
4155
4185
  const me = i.seats[role];
4156
4186
  if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
4157
- const mine = notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i);
4187
+ const mine = withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects);
4158
4188
  const startable = mine.filter(
4159
4189
  (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
4160
4190
  );
@@ -4204,7 +4234,7 @@ function roleCandidates(role, i) {
4204
4234
  if (role === "pair") return [];
4205
4235
  const me = i.seats[role];
4206
4236
  if (!me) return [];
4207
- return notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i).filter(
4237
+ return withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects).filter(
4208
4238
  (t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
4209
4239
  );
4210
4240
  }
@@ -4499,6 +4529,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
4499
4529
  "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
4500
4530
  '\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',
4501
4531
  "\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
4532
+ ...i.manualProjects && i.manualProjects.size > 0 ? ["\n**Manual projects.** Some projects on this board are in manual work mode: their tickets appear below only when a person has assigned them to YOU. An unassigned `accepted` ticket in such a project is not yours to claim and is deliberately absent \u2014 do not go looking for it.\n"] : [],
4502
4533
  "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
4503
4534
  "\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
4504
4535
  table(step1, i, header, row),
@@ -4686,12 +4717,13 @@ async function decideCycle(o) {
4686
4717
  const { route, ship, state, emit } = o;
4687
4718
  const tracker = new Tracker(route, ship);
4688
4719
  emit.enter("poll");
4689
- const [tickets, comments, crewRows, epicRows, ships] = await Promise.all([
4720
+ const [tickets, comments, crewRows, epicRows, ships, manualProjects] = await Promise.all([
4690
4721
  tracker.openTickets(),
4691
4722
  tracker.comments(200),
4692
4723
  tracker.crewRows(),
4693
4724
  tracker.epicRows(),
4694
- tracker.shipRows()
4725
+ tracker.shipRows(),
4726
+ tracker.manualProjectIds()
4695
4727
  ]);
4696
4728
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
4697
4729
  data: { tickets: tickets.length, comments: comments.length }
@@ -4799,6 +4831,7 @@ async function decideCycle(o) {
4799
4831
  await checkRepoDisks({ state, emit, route, ship });
4800
4832
  const selectionInput = {
4801
4833
  repoStop: repoStopFor(openShipAttention(state), route),
4834
+ manualProjects,
4802
4835
  tickets,
4803
4836
  comments,
4804
4837
  watermark,
@@ -4893,7 +4926,8 @@ function writeDigest(o, d, role, stateDir) {
4893
4926
  }
4894
4927
  return existingBranchForTicket(dir, repoFor(dir), t, role);
4895
4928
  },
4896
- tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : sliceFor(d.tickets, role),
4929
+ tickets: role === "triage" ? d.tickets.filter((t) => t.assignee_id === o.route.resolved?.seats.triage) : role === "dev" || role === "design" ? withinWorkMode(sliceFor(d.tickets, role), me, d.selectionInput.manualProjects) : sliceFor(d.tickets, role),
4930
+ manualProjects: d.selectionInput.manualProjects,
4897
4931
  comments: d.comments,
4898
4932
  me,
4899
4933
  roster: d.roster,
@@ -6434,6 +6468,15 @@ function describeUnplaceable(unplaceable, verifiedStatus) {
6434
6468
  }
6435
6469
  return notes;
6436
6470
  }
6471
+ var unplaceableReported = /* @__PURE__ */ new Set();
6472
+ function reportUnplaceable(routeName, unplaceable, verified, emit) {
6473
+ if (unplaceableReported.has(routeName)) return;
6474
+ unplaceableReported.add(routeName);
6475
+ for (const note of describeUnplaceable(unplaceable, verified)) {
6476
+ if (note.level === "warn") emit.warn(note.message, { step: "release" });
6477
+ else emit.emit(note.message, { step: "release" });
6478
+ }
6479
+ }
6437
6480
  function checkGuards(cwd, base = "main") {
6438
6481
  const branch = currentBranch(cwd);
6439
6482
  if (branch !== base) {
@@ -6585,6 +6628,10 @@ function decideRelease(cwd, tickets, contract, opts = {}) {
6585
6628
  unseeded: lastTag === null
6586
6629
  };
6587
6630
  }
6631
+ function releaseIdle(i) {
6632
+ if (i.force || i.external || i.unseeded || !i.upToDate || i.unstamped > 0) return false;
6633
+ return !i.tickets.some((t) => t.status === i.verifiedStatus);
6634
+ }
6588
6635
  var renderTag = (template, version) => template.replace(/\{version\}/g, version);
6589
6636
 
6590
6637
  // src/stamp.ts
@@ -10526,15 +10573,8 @@ if (command === "passenger-sync-daemon") {
10526
10573
  const out = refreshBaseBranch(r.dir, remote, base, true);
10527
10574
  const unsafe = baseBranchUnsafe(out);
10528
10575
  return { action: out.action, unsafe, detail: unsafe ? describeUnsafeBase(out, remote, base) : out.detail };
10529
- }, reportUnplaceable = function(c, unplaceable, verified, emit2) {
10530
- if (unplaceableReported.has(c.route)) return;
10531
- unplaceableReported.add(c.route);
10532
- for (const note of describeUnplaceable(unplaceable, verified)) {
10533
- if (note.level === "warn") emit2.warn(note.message, { step: "release" });
10534
- else emit2.emit(note.message, { step: "release" });
10535
- }
10536
10576
  };
10537
- requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, baseHealth2 = baseHealth, reportUnplaceable2 = reportUnplaceable;
10577
+ requireArmed2 = requireArmed, parseTicketNumber2 = parseTicketNumber, passengerApiKeys2 = passengerApiKeys, endpointApiFor2 = endpointApiFor, endpointReconcileDeps2 = endpointReconcileDeps, releaseRefused2 = releaseRefused, baseHealth2 = baseHealth;
10538
10578
  let cfg;
10539
10579
  let firstRun;
10540
10580
  try {
@@ -10668,6 +10708,7 @@ if (command === "passenger-sync-daemon") {
10668
10708
  }
10669
10709
  async function releaseFleet(opts = {}) {
10670
10710
  if (releaseRefused()) return;
10711
+ precheckTickets.clear();
10671
10712
  const summaries = [];
10672
10713
  if (!dryRun) state.releaseHeartbeat().touch();
10673
10714
  for (const c of cfg.routes) {
@@ -10679,6 +10720,7 @@ if (command === "passenger-sync-daemon") {
10679
10720
  }
10680
10721
  async function releaseTargets(opts = {}) {
10681
10722
  if (releaseRefused()) return;
10723
+ precheckTickets.clear();
10682
10724
  const only = value("repo");
10683
10725
  const targets = reposOf(route);
10684
10726
  const chosen = only ? targets.filter((t) => t.name === only) : targets;
@@ -10915,7 +10957,7 @@ ${changed2.map((c) => ` - ${c}
10915
10957
  }
10916
10958
  return `${who2}; ${lock2}`;
10917
10959
  }
10918
- const unplaceableReported = /* @__PURE__ */ new Set();
10960
+ const precheckTickets = /* @__PURE__ */ new Map();
10919
10961
  async function releasePhase(c, target, opts = {}) {
10920
10962
  const scope = releaseLockScope(c.route, target.name);
10921
10963
  const remit = emit.forRoute(c.route);
@@ -10950,6 +10992,38 @@ ${changed2.map((c) => ` - ${c}
10950
10992
  }
10951
10993
  if (!dryRun && !c.enabled) return { scope, tests: "skipped", outcome: "skipped", detail: "route not enabled" };
10952
10994
  const tracker = new Tracker(c, cfg.ship);
10995
+ if (!dryRun) {
10996
+ const force2 = command === "deploy" || flag("force");
10997
+ let seen = precheckTickets.get(c.route);
10998
+ if (!seen) precheckTickets.set(c.route, seen = tracker.openTickets());
10999
+ const { byRepo: preByRepo, unplaceable: preUnplaceable } = ticketsByRepo(c, await seen);
11000
+ const pre = preByRepo.get(target.name) ?? [];
11001
+ reportUnplaceable(c.route, preUnplaceable, tracker.contract.statuses.verified, remit);
11002
+ const d = decideRelease(target.dir, pre, tracker.contract, {
11003
+ tagPattern: repo.release.tagPattern ?? "v*",
11004
+ base: repo.branch.base
11005
+ });
11006
+ const unstamped = planReleasedBranches(
11007
+ target.dir,
11008
+ pre,
11009
+ tracker.contract,
11010
+ (t) => locateBranchForTicket(target.dir, repo, t)?.branch ?? null,
11011
+ repo.branch.base,
11012
+ repo.release.tagPattern ?? "v*"
11013
+ ).length;
11014
+ if (releaseIdle({
11015
+ tickets: pre,
11016
+ verifiedStatus: tracker.contract.statuses.verified,
11017
+ force: force2,
11018
+ external: repo.release.mode === "external",
11019
+ upToDate: d.upToDate,
11020
+ unseeded: d.unseeded,
11021
+ unstamped
11022
+ })) {
11023
+ remit.emit(`${target.name}: nothing verified \u2014 not locking`, { step: "release" });
11024
+ return { scope, tests: "skipped", outcome: "skipped", detail: "nothing verified \u2014 not locking" };
11025
+ }
11026
+ }
10953
11027
  if (!dryRun) {
10954
11028
  const holderLabel = `${cfg.ship.name}:${process.pid}`;
10955
11029
  const got = await tracker.acquireBoardLock(scope, holderLabel, RELEASE_LOCK_TTL_MS);
@@ -10972,7 +11046,7 @@ ${changed2.map((c) => ` - ${c}
10972
11046
  const all = await tracker.openTickets();
10973
11047
  const { byRepo, unplaceable } = ticketsByRepo(c, all);
10974
11048
  const tickets = byRepo.get(target.name) ?? [];
10975
- reportUnplaceable(c, unplaceable, tracker.contract.statuses.verified, remit);
11049
+ reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
10976
11050
  const outcome = await runRelease({
10977
11051
  cwd: target.dir,
10978
11052
  repo,
@@ -13258,4 +13332,3 @@ var endpointApiFor2;
13258
13332
  var endpointReconcileDeps2;
13259
13333
  var releaseRefused2;
13260
13334
  var baseHealth2;
13261
- var reportUnplaceable2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.19.4",
3
+ "version": "0.20.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",
@@ -305,6 +305,11 @@ Never start work on a ticket without first setting `assignee_id` to your
305
305
  own id (Step 3.3 already does this) — that claim is itself part of what
306
306
  keeps a concurrent interactive session from re-entering the same ticket.
307
307
 
308
+ A project in **manual work mode** (its Projects row says so) is never consumed
309
+ automatically: the digest lists its tickets only when a person has assigned them to
310
+ your seat, and status alone never makes one yours. The digest says so when it
311
+ applies; do not go looking for the missing tickets. QA is unaffected.
312
+
308
313
  Never touch a ticket still at `new` — only `accepted` tickets are yours to
309
314
  pick up; triage (a separate process) is what promotes `new` → `accepted`/
310
315
  `needs_info`.