@tablation/crew 0.21.0 → 0.23.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
@@ -3273,6 +3273,26 @@ function isAutomatic(t, wm) {
3273
3273
  if (!wm || wm.modes.size === 0) return true;
3274
3274
  return modeOf(t, wm) === "automatic";
3275
3275
  }
3276
+ function skippedByMode(tickets, wm) {
3277
+ const out = { manual: 0, hybrid: 0 };
3278
+ if (!wm || wm.modes.size === 0) return out;
3279
+ const kept = new Set(withinWorkMode(tickets, wm));
3280
+ for (const t of tickets) {
3281
+ if (kept.has(t)) continue;
3282
+ out[modeOf(t, wm) === "manual" ? "manual" : "hybrid"]++;
3283
+ }
3284
+ return out;
3285
+ }
3286
+ function withoutManualVerified(tickets, verifiedStatus, wm) {
3287
+ if (!wm || wm.modes.size === 0) return tickets;
3288
+ return tickets.filter((t) => t.status !== verifiedStatus || modeOf(t, wm) !== "manual");
3289
+ }
3290
+ function projectModeLabels(rows) {
3291
+ return rows.map((r) => {
3292
+ const name = r.name ?? r.id;
3293
+ return { id: r.id, name, mode: r.mode, label: `${name} (${r.mode})` };
3294
+ });
3295
+ }
3276
3296
 
3277
3297
  // src/board-lock.ts
3278
3298
  import { StaleWriteError } from "@tablation/client";
@@ -3517,6 +3537,12 @@ var Tracker = class {
3517
3537
  if (!model) return [];
3518
3538
  return this.client.records.list(model, { limit: 200 });
3519
3539
  }
3540
+ /** Every Projects row with its raw `work_mode`, for `crew status`. */
3541
+ async projectRowsWithModes() {
3542
+ const rows = await this.projectRows();
3543
+ const modes = await this.projectWorkModes();
3544
+ return rows.map((r) => ({ ...r, mode: modes.get(r.id) ?? "automatic" }));
3545
+ }
3520
3546
  /** Create a `Repos` row. Throws when the workspace has no Repos table. */
3521
3547
  async createRepoRow(fields) {
3522
3548
  const model = this.route.resolved?.reposModelId;
@@ -3929,9 +3955,9 @@ function sweepDiagnostics(tickets, info) {
3929
3955
  return { dangling, selfBlocked: [...new Set(selfBlocked)] };
3930
3956
  }
3931
3957
  var describeBlockers = (t, info) => (t.blocked_by ?? []).map((id) => `${info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
3932
- function planSweep(tickets, info, blocked) {
3958
+ function planSweep(tickets, info, blocked, wm) {
3933
3959
  const steps = [];
3934
- for (const t of tickets) {
3960
+ for (const t of withinWorkMode(tickets, wm)) {
3935
3961
  if (t.status === "accepted" && blocked.has(t.id)) {
3936
3962
  steps.push({ action: "park", ticket: t, blockers: describeBlockers(t, info), to: "blocked" });
3937
3963
  } else if (t.status === "blocked" && !blocked.has(t.id)) {
@@ -4500,6 +4526,9 @@ function blockers(t, i) {
4500
4526
  });
4501
4527
  return list.length === 0 ? "\u2014" : list.join(", ");
4502
4528
  }
4529
+ function modeCell(t, i) {
4530
+ return i.workModes && i.workModes.modes.size > 0 ? modeOf(t, i.workModes) : "\u2014";
4531
+ }
4503
4532
  var sorted = (ts) => [...ts].sort(compareRank);
4504
4533
  var stream = (segments) => `${segments.join("\n")}
4505
4534
  `;
@@ -4511,14 +4540,14 @@ ${sorted(ts).map(row).join("\n")}
4511
4540
  `;
4512
4541
  }
4513
4542
  function buildingDigest(i) {
4514
- const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4543
+ const header = "| ticket | repo | branch | worktree | mode | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4515
4544
  const row = (t) => {
4516
4545
  const n = newFromOthers(i, t.id);
4517
4546
  const branch = i.branchFor ? i.branchFor(t) : "";
4518
4547
  const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
4519
4548
  const dir = i.dirFor ? i.dirFor(t) : null;
4520
4549
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4521
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${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"} |`;
4550
+ 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"} |`;
4522
4551
  };
4523
4552
  const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
4524
4553
  const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
@@ -4564,7 +4593,7 @@ ${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
4564
4593
  ]);
4565
4594
  }
4566
4595
  function qaDigest(i) {
4567
- const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4596
+ const header = "| ticket | repo | mode | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4568
4597
  const branchCell = (t) => {
4569
4598
  if (i.dirFor && i.dirFor(t) === null) return "\u2014";
4570
4599
  if (!i.existingBranchFor) return "";
@@ -4579,7 +4608,7 @@ function qaDigest(i) {
4579
4608
  const n = newFromOthers(i, t.id);
4580
4609
  const dir = i.dirFor ? i.dirFor(t) : null;
4581
4610
  const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4582
- return `| ${displayKey2(t)} | ${repo} | ${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"} |`;
4611
+ 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"} |`;
4583
4612
  };
4584
4613
  return stream([
4585
4614
  "## Current queue \u2014 built for you by the poll\n",
@@ -4649,7 +4678,7 @@ var ms = (iso) => {
4649
4678
  const n = iso ? Date.parse(iso) : NaN;
4650
4679
  return Number.isFinite(n) ? n : 0;
4651
4680
  };
4652
- function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now()) {
4681
+ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now(), wm) {
4653
4682
  const watched = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.handoff, contract.statuses.verifying]);
4654
4683
  const byTicket = /* @__PURE__ */ new Map();
4655
4684
  for (const c of comments) {
@@ -4658,7 +4687,7 @@ function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ *
4658
4687
  else byTicket.set(c.ticket_id, [c]);
4659
4688
  }
4660
4689
  const out = [];
4661
- for (const t of tickets) {
4690
+ for (const t of withinWorkMode(tickets, wm)) {
4662
4691
  if (!watched.has(t.status)) continue;
4663
4692
  if (t.assignee_id && holds2.has(t.assignee_id)) continue;
4664
4693
  const mine = byTicket.get(t.id) ?? [];
@@ -4782,9 +4811,15 @@ async function decideCycle(o) {
4782
4811
  if (diag.selfBlocked.length) {
4783
4812
  emit.warn(`ticket(s) blocking themselves, parked permanently: ${diag.selfBlocked.join(", ")}`);
4784
4813
  }
4785
- const sweep = planSweep(tickets, info, blocked);
4814
+ const sweep = planSweep(tickets, info, blocked, workModes);
4786
4815
  const holdReleases = planHoldReleases(tickets, tracker.contract);
4787
- const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2);
4816
+ const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2, Date.now(), workModes);
4817
+ const skipped = skippedByMode(tickets, workModes);
4818
+ if (skipped.manual || skipped.hybrid) {
4819
+ emit.emit(`sweeps skipped ${skipped.manual} manual/project-less and ${skipped.hybrid} unassigned hybrid ticket(s)`, {
4820
+ data: { sweepSkipped: skipped }
4821
+ });
4822
+ }
4788
4823
  const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
4789
4824
  emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
4790
4825
  const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
@@ -5446,6 +5481,18 @@ function renderEnvironment(i) {
5446
5481
  "another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
5447
5482
  );
5448
5483
  }
5484
+ const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
5485
+ if (seatIds.length > 0) {
5486
+ lines.push(
5487
+ "",
5488
+ "## Seats on this ship",
5489
+ "",
5490
+ "The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
5491
+ "assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
5492
+ "",
5493
+ ...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
5494
+ );
5495
+ }
5449
5496
  lines.push(
5450
5497
  "",
5451
5498
  "## What the columns are called here",
@@ -11019,7 +11066,12 @@ ${changed2.map((c) => ` - ${c}
11019
11066
  let seen = precheckTickets.get(c.route);
11020
11067
  if (!seen) precheckTickets.set(c.route, seen = tracker.openTickets());
11021
11068
  const { byRepo: preByRepo, unplaceable: preUnplaceable } = ticketsByRepo(c, await seen);
11022
- const pre = preByRepo.get(target.name) ?? [];
11069
+ const preModes = { modes: await tracker.projectWorkModes(), agentAssignees: /* @__PURE__ */ new Set() };
11070
+ const pre = withoutManualVerified(
11071
+ preByRepo.get(target.name) ?? [],
11072
+ tracker.contract.statuses.verified,
11073
+ preModes
11074
+ );
11023
11075
  reportUnplaceable(c.route, preUnplaceable, tracker.contract.statuses.verified, remit);
11024
11076
  const d = decideRelease(target.dir, pre, tracker.contract, {
11025
11077
  tagPattern: repo.release.tagPattern ?? "v*",
@@ -11067,7 +11119,12 @@ ${changed2.map((c) => ` - ${c}
11067
11119
  }
11068
11120
  const all = await tracker.openTickets();
11069
11121
  const { byRepo, unplaceable } = ticketsByRepo(c, all);
11070
- const tickets = byRepo.get(target.name) ?? [];
11122
+ const releaseModes = { modes: await tracker.projectWorkModes(), agentAssignees: /* @__PURE__ */ new Set() };
11123
+ const tickets = withoutManualVerified(
11124
+ byRepo.get(target.name) ?? [],
11125
+ tracker.contract.statuses.verified,
11126
+ releaseModes
11127
+ );
11071
11128
  reportUnplaceable(c.route, unplaceable, tracker.contract.statuses.verified, remit);
11072
11129
  const outcome = await runRelease({
11073
11130
  cwd: target.dir,
@@ -12952,6 +13009,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
12952
13009
  const engagedTickets = engagedTicketIds.length ? await statusTracker.ticketsByIds(engagedTicketIds) : [];
12953
13010
  const issueLabelById = new Map(engagedTickets.map((t) => [t.id, t.issue_id]));
12954
13011
  const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
13012
+ const projects = projectModeLabels(await statusTracker.projectRowsWithModes().catch(() => []));
12955
13013
  if (flag("json")) {
12956
13014
  const repos = await resolvedRepos(route);
12957
13015
  const seatIds = route.resolved?.seats ?? {};
@@ -13001,6 +13059,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
13001
13059
  watermark: state.watermark(),
13002
13060
  route: {
13003
13061
  name: route.route,
13062
+ // Additive (CREW-1496): each project and its work mode. `version` stays 1.
13063
+ projects: projects.map((p) => ({ id: p.id, name: p.name, workMode: p.mode })),
13004
13064
  dir: route.dir,
13005
13065
  enabled: route.enabled,
13006
13066
  area: route.area ?? null,
@@ -13058,6 +13118,8 @@ watermark: ${state.watermark()}
13058
13118
  if (state.isRolePaused(r)) process.stdout.write(`role ${r}: paused
13059
13119
  `);
13060
13120
  }
13121
+ if (projects.length) process.stdout.write(`projects: ${projects.map((p) => p.label).join(", ")}
13122
+ `);
13061
13123
  for (const i of openShipAttention(state)) {
13062
13124
  process.stdout.write(`attention: ${i.kind} \u2014 ${i.message}
13063
13125
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.21.0",
3
+ "version": "0.23.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,10 +305,18 @@ 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.
308
+ Every ticket's project has a **work mode**, shown in the digest's `mode`
309
+ column: `automatic` (lanes work tickets by status), `hybrid` (a lane considers a
310
+ ticket only when it is assigned to an agent — a Crew row backed by an Agent, not a
311
+ hold), or `manual` (no automation touches it; a ticket with no project counts as
312
+ manual). The digest says so when a project is not automatic; do not go looking for
313
+ a ticket it leaves out. **One hand-off rule:** in a `hybrid` project, hand a ticket
314
+ to the next lane by *assigning that lane's seat on this ship* (ids under "Seats on
315
+ this ship" in the Environment section, else the roster); in an `automatic` project,
316
+ clear `assignee_id` as before. If there is no seat for the target role, assign any
317
+ Crew row with that role's Agent; if there is none, clear it and say so in the
318
+ hand-off comment. Parks (`needs_info`, `needs_planning` → the operator) are the
319
+ same in every mode.
312
320
 
313
321
  Never touch a ticket still at `new` — only `accepted` tickets are yours to
314
322
  pick up; triage (a separate process) is what promotes `new` → `accepted`/
@@ -558,7 +566,9 @@ if `report_type` is either of those.
558
566
  this is your own isolated worktree, no special permission needed for this
559
567
  part).
560
568
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
561
- and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
569
+ and hand the ticket to the QA lane: in an `automatic` project **clear `assignee_id`**; in a
570
+ `hybrid` project **set `assignee_id` to this ship's QA seat** instead (the digest's `mode`
571
+ column says which). That pair is the hand-off to the QA lane.
562
572
  If the Issues table has a `held_by_ship_id` column, clear it in the same
563
573
  write (the hold is for the building roles; QA on any ship may take a `fixed`
564
574
  ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
@@ -98,6 +98,11 @@ stopping for this run, do the following instead:
98
98
  it) — revise the comp against what they said and post again. This cycle
99
99
  repeats as many times as the operator wants.
100
100
 
101
+ In a `hybrid` project (the digest's `mode` column), where the operator
102
+ leaves the hand-off to you, assign this ship's Developer seat when
103
+ `needs_design` is cleared rather than leaving the assignee blank, so the dev lane
104
+ still sees the ticket; in an `automatic` project nothing changes.
105
+
101
106
  Never implement past the comp to "just finish it" because the fix looks
102
107
  small, and never set `needs_design`, `accepted`, or clear
103
108
  `needs_planning`/`needs_review` yourself — all of that is the operator's
@@ -141,6 +141,11 @@ human review, so "probably fine" is not a pass.
141
141
  bump the version, and deploy this cycle or the next. Say in
142
142
  the comment what you exercised, so the record shows what "verified"
143
143
  covered.
144
+ - **Person-built hybrid ticket with no branch** — a `hybrid` ticket at
145
+ `fixed` whose branch under the repo's naming convention does not exist
146
+ (the digest's `branch` column says **MISSING**) is not a bounce to a
147
+ builder: set `needs_info` and assign the operator, with a comment naming the
148
+ branch you expected. A person built it; the operator has to push it.
144
149
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the building
145
150
  lane's own Crew row id from the roster above — the dev seat if
146
151
  `needs_design` is false or null, the design seat if true. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
@@ -286,6 +286,19 @@ Never start work on a ticket without first setting `assignee_id` to your
286
286
  own id (Step 3.3 already does this) — that claim is itself part of what
287
287
  keeps a concurrent interactive session from re-entering the same ticket.
288
288
 
289
+ Every ticket's project has a **work mode**, shown in the digest's `mode`
290
+ column: `automatic` (lanes work tickets by status), `hybrid` (a lane considers a
291
+ ticket only when it is assigned to an agent — a Crew row backed by an Agent, not a
292
+ hold), or `manual` (no automation touches it; a ticket with no project counts as
293
+ manual). The digest says so when a project is not automatic; do not go looking for
294
+ a ticket it leaves out. **One hand-off rule:** in a `hybrid` project, hand a ticket
295
+ to the next lane by *assigning that lane's seat on this ship* (ids under "Seats on
296
+ this ship" in the Environment section, else the roster); in an `automatic` project,
297
+ clear `assignee_id` as before. If there is no seat for the target role, assign any
298
+ Crew row with that role's Agent; if there is none, clear it and say so in the
299
+ hand-off comment. Parks (`needs_info`, `needs_planning` → the operator) are the
300
+ same in every mode.
301
+
289
302
  Never touch a ticket still at `new` — only `accepted` tickets are yours to
290
303
  pick up; triage (a separate process) is what promotes `new` → `accepted`/
291
304
  `needs_info`.
@@ -535,7 +548,9 @@ if `report_type` is either of those.
535
548
  this is your own isolated worktree, no special permission needed for this
536
549
  part).
537
550
  9. Set the ticket's `status` to `fixed` once you have verified it yourself,
538
- and **clear `assignee_id`** — that pair is the hand-off to the QA lane.
551
+ and hand the ticket to the QA lane: in an `automatic` project **clear `assignee_id`**; in a
552
+ `hybrid` project **set `assignee_id` to this ship's QA seat** instead (the digest's `mode`
553
+ column says which). That pair is the hand-off to the QA lane.
539
554
  If the Issues table has a `held_by_ship_id` column, clear it in the same
540
555
  write (the hold is for the building roles; QA on any ship may take a `fixed`
541
556
  ticket). A QA bounce or `needs_info` park leaves it set; QA clears it again
@@ -128,6 +128,11 @@ human review, so "probably fine" is not a pass.
128
128
  bump the version, and deploy this cycle or the next. Say in
129
129
  the comment what you exercised, so the record shows what "verified"
130
130
  covered.
131
+ - **Person-built hybrid ticket with no branch** — a `hybrid` ticket at
132
+ `fixed` whose branch under the repo's naming convention does not exist
133
+ (the digest's `branch` column says **MISSING**) is not a bounce to a
134
+ builder: set `needs_info` and assign the operator, with a comment naming the
135
+ branch you expected. A person built it; the operator has to push it.
131
136
  - **It doesn't** → `status` = `in_progress`, `assignee_id` = the dev
132
137
  seat's own Crew row id from the roster above. **Exception:** when the Issues table has a `held_by_ship_id` column, **clear** `assignee_id` *and* `held_by_ship_id` instead of naming a seat — the
133
138
  hold on a `qa` ticket is yours, as verifier, and seats are per ship, so