@tablation/crew 0.20.2 → 0.21.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.
Files changed (2) hide show
  1. package/dist/cli.js +49 -27
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1842,6 +1842,7 @@ var DEFAULT_CONTRACT = {
1842
1842
  unknownPriorityRank: 2,
1843
1843
  coordinatingValue: "coordinating",
1844
1844
  manualWorkMode: "manual",
1845
+ hybridWorkMode: "hybrid",
1845
1846
  repoExemptReportTypes: ["coordinating", "question", "investigation"]
1846
1847
  };
1847
1848
  function closedStatuses(c) {
@@ -1863,6 +1864,7 @@ function resolveContract(override) {
1863
1864
  unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
1864
1865
  coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
1865
1866
  manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
1867
+ hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
1866
1868
  // A workspace overriding `coordinatingValue` alone (renaming the rollup
1867
1869
  // marker) still gets that renamed value exempted here by default,
1868
1870
  // without also having to repeat it — only an explicit
@@ -3243,14 +3245,33 @@ var SHELL_WORDS = /* @__PURE__ */ new Set([
3243
3245
  ]);
3244
3246
 
3245
3247
  // 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);
3248
+ function projectWorkModes(rows, contract) {
3249
+ const out = /* @__PURE__ */ new Map();
3250
+ for (const r of rows) {
3251
+ const v = r[contract.columns.workMode];
3252
+ out.set(r.id, v === contract.manualWorkMode ? "manual" : v === contract.hybridWorkMode ? "hybrid" : "automatic");
3253
+ }
3249
3254
  return out;
3250
3255
  }
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);
3256
+ function agentAssigneeIds(crewRows, isHoldRow) {
3257
+ return new Set(crewRows.filter((r) => !!r.agent_id && !isHoldRow(r.id)).map((r) => r.id));
3258
+ }
3259
+ function modeOf(t, wm) {
3260
+ if (!t.project_id) return "manual";
3261
+ return wm.modes.get(t.project_id) ?? "automatic";
3262
+ }
3263
+ function withinWorkMode(tickets, wm) {
3264
+ if (!wm || wm.modes.size === 0) return tickets;
3265
+ return tickets.filter((t) => {
3266
+ const mode = modeOf(t, wm);
3267
+ if (mode === "automatic") return true;
3268
+ if (mode === "manual") return false;
3269
+ return !!t.assignee_id && wm.agentAssignees.has(t.assignee_id);
3270
+ });
3271
+ }
3272
+ function isAutomatic(t, wm) {
3273
+ if (!wm || wm.modes.size === 0) return true;
3274
+ return modeOf(t, wm) === "automatic";
3254
3275
  }
3255
3276
 
3256
3277
  // src/board-lock.ts
@@ -3475,19 +3496,19 @@ var Tracker = class {
3475
3496
  }
3476
3497
  }
3477
3498
  /**
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.
3499
+ * Every Projects (area) row's work mode (CREW-1445, CREW-1493). One extra
3500
+ * read per cycle. A workspace with no Projects table, no `work_mode` column
3501
+ * or an unreadable table yields the empty map, which the selection reads as
3502
+ * "no filtering"; a null value is `automatic`.
3482
3503
  */
3483
- async manualProjectIds() {
3504
+ async projectWorkModes() {
3484
3505
  const model = this.route.resolved?.areaModelId;
3485
- if (!model) return /* @__PURE__ */ new Set();
3506
+ if (!model) return /* @__PURE__ */ new Map();
3486
3507
  try {
3487
3508
  const rows = await this.client.records.list(model, { limit: 200 });
3488
- return manualProjectIds(rows, this.contract);
3509
+ return projectWorkModes(rows, this.contract);
3489
3510
  } catch {
3490
- return /* @__PURE__ */ new Set();
3511
+ return /* @__PURE__ */ new Map();
3491
3512
  }
3492
3513
  }
3493
3514
  /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
@@ -4156,7 +4177,7 @@ function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
4156
4177
  // src/select.ts
4157
4178
  var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
4158
4179
  var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
4159
- var triageSlice = (tickets, triageSeat) => triageSeat ? tickets.filter((t) => t.assignee_id === triageSeat) : [];
4180
+ var triageSlice = (tickets, triageSeat, wm) => triageSeat ? tickets.filter((t) => t.assignee_id === triageSeat && isAutomatic(t, wm)) : [];
4160
4181
  function sliceFor(tickets, role) {
4161
4182
  if (role === "qa") return qaSlice(tickets);
4162
4183
  if (role === "triage") return triageSlice(tickets);
@@ -4184,7 +4205,7 @@ function notHeldByOtherShip(slice, i) {
4184
4205
  function buildingRoleHasWork(role, i) {
4185
4206
  const me = i.seats[role];
4186
4207
  if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
4187
- const mine = withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects);
4208
+ const mine = withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), i.workModes);
4188
4209
  const startable = mine.filter(
4189
4210
  (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
4190
4211
  );
@@ -4213,12 +4234,12 @@ function buildingRoleHasWork(role, i) {
4213
4234
  }
4214
4235
  function qaRoleHasWork(i) {
4215
4236
  if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
4216
- const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
4237
+ const n = withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds)).length;
4217
4238
  return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
4218
4239
  }
4219
4240
  function triageRoleHasWork(i) {
4220
4241
  if (!i.seats.triage) return { hasWork: false, reason: "this ship does not crew that role" };
4221
- const n = triageSlice(i.tickets, i.seats.triage).length;
4242
+ const n = triageSlice(i.tickets, i.seats.triage, i.workModes).length;
4222
4243
  return n > 0 ? { hasWork: true, reason: `${n} report(s) assigned to triage` } : { hasWork: false, reason: "nothing assigned to triage" };
4223
4244
  }
4224
4245
  function roleHasWork(role, i) {
@@ -4229,12 +4250,12 @@ function roleHasWork(role, i) {
4229
4250
  return buildingRoleHasWork(role, i);
4230
4251
  }
4231
4252
  function roleCandidates(role, i) {
4232
- if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
4233
- if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
4253
+ if (role === "qa") return withinWorkMode(notHeldByOtherShip(qaSlice(i.tickets), i), i.workModes).filter((t) => !isHeld(t, i.holds));
4254
+ if (role === "triage") return triageSlice(i.tickets, i.seats.triage, i.workModes).filter((t) => !isHeld(t, i.holds));
4234
4255
  if (role === "pair") return [];
4235
4256
  const me = i.seats[role];
4236
4257
  if (!me) return [];
4237
- return withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), me, i.manualProjects).filter(
4258
+ return withinWorkMode(notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i), i.workModes).filter(
4238
4259
  (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)
4239
4260
  );
4240
4261
  }
@@ -4529,7 +4550,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
4529
4550
  "\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",
4530
4551
  '\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',
4531
4552
  "\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"] : [],
4553
+ ...i.workModes && [...i.workModes.modes.values()].some((m) => m !== "automatic") ? ["\n**Manual / hybrid projects.** Some projects on this board are not fully automatic. In a `manual` project no lane touches any ticket, and a ticket with no project is treated the same way, so none appear below. In a `hybrid` project a ticket appears below only when it is assigned to an agent. An `accepted` ticket that is absent for either reason is not yours to claim \u2014 do not go looking for it.\n"] : [],
4533
4554
  "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
4534
4555
  "\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
4535
4556
  table(step1, i, header, row),
@@ -4717,13 +4738,13 @@ async function decideCycle(o) {
4717
4738
  const { route, ship, state, emit } = o;
4718
4739
  const tracker = new Tracker(route, ship);
4719
4740
  emit.enter("poll");
4720
- const [tickets, comments, crewRows, epicRows, ships, manualProjects] = await Promise.all([
4741
+ const [tickets, comments, crewRows, epicRows, ships, projectModes] = await Promise.all([
4721
4742
  tracker.openTickets(),
4722
4743
  tracker.comments(200),
4723
4744
  tracker.crewRows(),
4724
4745
  tracker.epicRows(),
4725
4746
  tracker.shipRows(),
4726
- tracker.manualProjectIds()
4747
+ tracker.projectWorkModes()
4727
4748
  ]);
4728
4749
  emit.emit(`${tickets.length} open ticket(s), ${comments.length} comment(s)`, {
4729
4750
  data: { tickets: tickets.length, comments: comments.length }
@@ -4755,6 +4776,7 @@ async function decideCycle(o) {
4755
4776
  emit.enter("sweep");
4756
4777
  const roster = buildRoster(configuredMembers(route), crewRows);
4757
4778
  const holds2 = new Set(holdIds(roster));
4779
+ const workModes = { modes: projectModes, agentAssignees: agentAssigneeIds(crewRows, (id) => holds2.has(id)) };
4758
4780
  const diag = sweepDiagnostics(tickets, info);
4759
4781
  for (const d of diag.dangling) emit.warn(`dangling blocked_by reference, ignored: ${d}`);
4760
4782
  if (diag.selfBlocked.length) {
@@ -4831,7 +4853,7 @@ async function decideCycle(o) {
4831
4853
  await checkRepoDisks({ state, emit, route, ship });
4832
4854
  const selectionInput = {
4833
4855
  repoStop: repoStopFor(openShipAttention(state), route),
4834
- manualProjects,
4856
+ workModes,
4835
4857
  tickets,
4836
4858
  comments,
4837
4859
  watermark,
@@ -4926,8 +4948,8 @@ function writeDigest(o, d, role, stateDir) {
4926
4948
  }
4927
4949
  return existingBranchForTicket(dir, repoFor(dir), t, role);
4928
4950
  },
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,
4951
+ tickets: role === "triage" ? triageSlice(d.tickets, o.route.resolved?.seats.triage, d.selectionInput.workModes) : withinWorkMode(sliceFor(d.tickets, role), d.selectionInput.workModes),
4952
+ workModes: d.selectionInput.workModes,
4931
4953
  comments: d.comments,
4932
4954
  me,
4933
4955
  roster: d.roster,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.20.2",
3
+ "version": "0.21.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",