@tablation/crew 0.82.0 → 0.83.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 +1406 -1332
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1448,6 +1448,7 @@ var HOOK_NAMES = [
1448
1448
  "ports",
1449
1449
  "isolate",
1450
1450
  "handoff",
1451
+ "preview",
1451
1452
  "ci_status",
1452
1453
  "version",
1453
1454
  "bump",
@@ -2316,6 +2317,7 @@ function parseResolved(raw, m, where) {
2316
2317
  areaId: raw.areaId,
2317
2318
  shipsModelId: raw.shipsModelId,
2318
2319
  epicsModelId: raw.epicsModelId,
2320
+ periscopesModelId: raw.periscopesModelId,
2319
2321
  locksModelId: raw.locksModelId,
2320
2322
  recordLinkViewId: raw.recordLinkViewId,
2321
2323
  reposModelId: raw.reposModelId,
@@ -4215,6 +4217,15 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
4215
4217
  if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
4216
4218
  return null;
4217
4219
  }
4220
+ var agentLogRefusedKey = (route) => `agent_log_refused:${route}`;
4221
+ function agentLogRefusedItem(route, role, status2, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
4222
+ return {
4223
+ kind: "agent_log_refused",
4224
+ key: agentLogRefusedKey(route),
4225
+ since: nowIso,
4226
+ message: `${route}: the tracker refused the ${role} seat's run report (${status2}) \u2014 runs and ticket spend are not being recorded; check the seat key's role`
4227
+ };
4228
+ }
4218
4229
  var diskLowKey = (repo) => `disk_low:${repo}`;
4219
4230
  var LOW_DISK_BYTES = 2 * 1024 ** 3;
4220
4231
  var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
@@ -4587,1431 +4598,1455 @@ import { finished } from "stream/promises";
4587
4598
  import { join as join19, dirname as dirname7, basename as basename7 } from "path";
4588
4599
  import { createHash as createHash3 } from "crypto";
4589
4600
 
4590
- // src/environment.ts
4591
- import { basename as basename5 } from "path";
4592
- var API_KEY_VAR = "CREW_API_KEY";
4593
- function authorshipBullets(a, contract) {
4594
- if (!a) return [];
4595
- return [
4596
- ...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
4597
- ...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
4598
- ...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
4599
- ];
4600
- }
4601
- var HOOK_PURPOSE = [
4602
- ["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
4603
- ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
4604
- ["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
4605
- ["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
4606
- ["test", "run the suite"],
4607
- ["build", "build it"],
4608
- ["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
4609
- ];
4610
- function hookTable(repo) {
4611
- const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
4612
- if (declared.length === 0) {
4613
- return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
4601
+ // src/state.ts
4602
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11, mkdirSync as mkdirSync6, rmSync as rmSync6, statSync as statSync4, readdirSync as readdirSync4 } from "fs";
4603
+ import { join as join12 } from "path";
4604
+ var EPOCH = "1970-01-01T00:00:00.000Z";
4605
+ var State = class _State {
4606
+ dir;
4607
+ constructor(dir) {
4608
+ this.dir = dir;
4609
+ mkdirSync6(dir, { recursive: true });
4614
4610
  }
4615
- const inline = (cmd) => cmd.includes("\n") ? "_(below)_" : `\`${cmd}\``;
4616
- const blocks = declared.filter(([name]) => repo.hooks[name].includes("\n")).map(([name]) => [`### \`${name}\``, "", "```sh", repo.hooks[name].trimEnd(), "```", ""].join("\n"));
4617
- const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
4618
- return [
4619
- "| hook | what it is for | what it runs |",
4620
- "| --- | --- | --- |",
4621
- ...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
4622
- "",
4623
- "Run these **verbatim**, from the worktree. They come from the repository's own",
4624
- "configuration, which is the only thing that knows them. A command you invented",
4625
- "yourself is wrong even when it works, because the next repository will differ.",
4626
- "",
4627
- ...blocks,
4628
- ...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
4629
- ].join("\n");
4630
- }
4631
- function repoPrefix(repo) {
4632
- return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
4633
- }
4634
- function branchExample(repo, key) {
4635
- const prefix = repoPrefix(repo);
4636
- const template = effectiveBranchTemplate(repo, prefix);
4637
- return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
4638
- }
4639
- function repoSection(r, key, heading) {
4640
- const repo = r.config;
4641
- const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
4642
- const branchName = branchExample(repo, key);
4643
- const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
4644
- const worktreeName = effectiveWorktreeDirName(
4645
- repo,
4646
- r.dir,
4647
- branchName,
4648
- key.replace(/^\D+/, ""),
4649
- // The example ticket has no tag of its own, so `{issue}` is the key.
4650
- (template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
4651
- );
4652
- const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
4653
- const lines = [
4654
- "",
4655
- heading,
4656
- "",
4657
- `- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
4658
- ` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
4659
- `- Name the branch the way this repository names branches: \`${branchTemplate}\``,
4660
- ` \u2014 for ${key} that is \`${branchName}\`.`,
4661
- "- Never work in the main checkout. It is the operator's, and the release phase",
4662
- " uses it.",
4663
- "",
4664
- `${key} above is a placeholder, not a real ticket: this section is rendered`,
4665
- "before any ticket is chosen, so it cannot know a specific ticket's own",
4666
- "project prefix. Once you have a ticket, its digest row's `branch` and",
4667
- "`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
4668
- "over this section's worked example if the two ever disagree."
4669
- ];
4670
- if (repo.worktrees.copy.length) {
4671
- lines.push(
4672
- "",
4673
- "A worktree is a clean checkout, so it is missing exactly the files git ignores.",
4674
- "Copy these across from the main checkout before running anything:",
4675
- "",
4676
- ...repo.worktrees.copy.map((c) => `- \`${c}\``)
4677
- );
4611
+ path(...parts) {
4612
+ return join12(this.dir, ...parts);
4678
4613
  }
4679
- if (repo.docs.designGuide) {
4680
- lines.push(
4681
- "",
4682
- "## This project's design brief",
4683
- "",
4684
- `\`${repo.docs.designGuide}\`, in the repository. Read it from the primary`,
4685
- "checkout before you `cd` into any worktree \u2014 a worktree cut from the base",
4686
- "branch only has it once it has been committed there."
4687
- );
4614
+ /**
4615
+ * The timestamp every "new since last poll" check is measured against.
4616
+ *
4617
+ * Read once per cycle and advanced only AFTER every role has been
4618
+ * evaluated: bumping it inside the first role's check would hide that
4619
+ * role's new comments from the second.
4620
+ */
4621
+ watermark() {
4622
+ try {
4623
+ const v = readFileSync11(this.path(".poll-watermark"), "utf8").trim();
4624
+ return v || EPOCH;
4625
+ } catch {
4626
+ return EPOCH;
4627
+ }
4688
4628
  }
4689
- if (repo.docs.triagePolicy) {
4690
- lines.push(
4691
- "",
4692
- "## This project's triage policy",
4693
- "",
4694
- `\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
4695
- "disagree about what a status means or when a ticket may be accepted, the",
4696
- "policy document wins: it is the project's own decision, and the brief is",
4697
- "shared with projects that decided differently."
4698
- );
4629
+ setWatermark(at) {
4630
+ writeFileSync7(this.path(".poll-watermark"), `${at}
4631
+ `);
4699
4632
  }
4700
- return lines;
4701
- }
4702
- function renderEnvironment(i) {
4703
- const { route, contract } = i;
4704
- const repos = i.repos;
4705
- const only = repos.length === 1 ? repos[0] : void 0;
4706
- const models = route.resolved?.models;
4707
- const epicsModelId = route.resolved?.epicsModelId;
4708
- const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
4709
- const key = "ISSUE-000";
4710
- const lines = [
4711
- "# Your environment",
4712
- "",
4713
- "Supplied by the crew for THIS run. The brief below is shared by every project",
4714
- "the crew works on and deliberately names nothing specific; this section is where",
4715
- "those names come from. Where the two appear to disagree, this section wins.",
4716
- "",
4717
- "## The board",
4718
- "",
4719
- `- Base URL: \`${route.baseUrl}\``,
4720
- `- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
4721
- ` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
4722
- " and never write it into a comment, a commit or a log line.",
4723
- `- Send \`User-Agent: ${i.userAgent}\` on every request.`
4724
- ];
4725
- if (models) {
4726
- lines.push(
4727
- "",
4728
- "Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
4729
- "",
4730
- `- Issues: \`${models.issues}\``,
4731
- `- Comments: \`${models.comments}\``,
4732
- `- Crew: \`${models.crew}\``,
4733
- ...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
4734
- ...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
4735
- );
4633
+ /**
4634
+ * Advance to the newest thing this cycle actually saw, never to "now".
4635
+ *
4636
+ * Using the clock would silently skip any comment written between the fetch
4637
+ * and the write — a race that loses work rather than repeating it, which is
4638
+ * the wrong way round. Repeating a cycle is free; missing one is not.
4639
+ */
4640
+ advanceWatermark(seen) {
4641
+ const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
4642
+ const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
4643
+ this.setWatermark(next);
4644
+ return next;
4736
4645
  }
4737
- const workspaceId = route.resolved?.workspaceId;
4738
- if (workspaceId) {
4739
- const base = route.baseUrl.replace(/\/+$/, "");
4740
- lines.push(
4741
- "",
4742
- `- Workspace id: \`${workspaceId}\``,
4743
- "",
4744
- "## Attaching a file to a comment",
4745
- "",
4746
- "A screenshot or mockup goes on a comment's `attachments` field, which holds",
4747
- "Media Library record ids, not URLs. Upload the file first, then put the",
4748
- "returned `id` in the comment's `attachments` array:",
4749
- "",
4750
- "```sh",
4751
- `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
4752
- ` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
4753
- ' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
4754
- "```",
4755
- "",
4756
- "The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
4757
- "means no field type id has to be looked up. If the upload fails, post the HTTP",
4758
- "status and response body in your comment \u2014 do not write that uploads are",
4759
- "impossible."
4760
- );
4761
- if (mediaLibraryModelId) {
4762
- lines.push(
4763
- "",
4764
- "## Opening a ticket's attachments",
4765
- "",
4766
- `A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
4767
- "**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
4768
- "`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
4769
- "read the record, then fetch the file by its storage key:",
4770
- "",
4771
- "```sh",
4772
- `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
4773
- ` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
4774
- '# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
4775
- `curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
4776
- ` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
4777
- "```",
4778
- "",
4779
- "The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
4780
- "id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
4781
- "of the storage key). Save the file outside the repository and open it; where it",
4782
- "disagrees with the description, the picture is the reproduction."
4783
- );
4784
- }
4646
+ /** Whole-crew pause, and the per-role sentinels one level down. */
4647
+ isPaused() {
4648
+ return existsSync11(this.path(".crew-paused"));
4785
4649
  }
4786
- if (i.shipName) {
4787
- lines.push(
4788
- "",
4789
- "## Ships",
4790
- "",
4791
- `This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
4792
- "another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
4793
- );
4650
+ isRolePaused(role) {
4651
+ return existsSync11(this.path(`.role-paused-${role}`));
4794
4652
  }
4795
- const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
4796
- if (seatIds.length > 0) {
4797
- lines.push(
4798
- "",
4799
- "## Seats on this ship",
4800
- "",
4801
- "The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
4802
- "assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
4803
- "",
4804
- ...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
4805
- );
4653
+ pausedRoles(roles) {
4654
+ return new Set(roles.filter((r) => this.isRolePaused(r)));
4806
4655
  }
4807
- lines.push(
4808
- "",
4809
- "## What the columns are called here",
4810
- "",
4811
- "Every workspace names these itself. Use these names, not the ones you would",
4812
- "guess, and not the ones another project used.",
4813
- "",
4814
- "| meaning | column |",
4815
- "| --- | --- |",
4816
- `| ticket key | \`${contract.columns.key}\` |`,
4817
- `| status | \`${contract.columns.status}\` |`,
4818
- `| assignee | \`${contract.columns.assignee}\` |`,
4819
- `| severity | \`${contract.columns.severity}\` |`,
4820
- `| priority | \`${contract.columns.priority}\` |`,
4821
- `| blocked by | \`${contract.columns.blockedBy}\` |`,
4822
- `| needs design | \`${contract.columns.needsDesign}\` |`,
4823
- `| project | \`${contract.columns.slice}\` |`,
4824
- `| repo | \`${contract.columns.repo}\` |`,
4825
- ...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
4826
- "",
4827
- ...epicsModelId ? [
4828
- "An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
4829
- "fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
4830
- "to a person rather than inventing one yourself.",
4831
- "",
4832
- "An epic's own status is kept by the crew runner, from its tickets: done once",
4833
- "every one of them is closed, in progress while any is being worked. Do not",
4834
- "set it yourself.",
4835
- ""
4836
- ] : [],
4837
- "Statuses that matter to you:",
4838
- "",
4839
- `- Work you may pick up: \`${contract.statuses.approved}\``,
4840
- `- While you are working it: \`${contract.statuses.building}\``,
4841
- `- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
4842
- `- Being checked: \`${contract.statuses.verifying}\``,
4843
- `- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
4844
- `- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
4845
- "",
4846
- `\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
4847
- );
4848
- const areaId = route.resolved?.areaId;
4849
- if (areaId || repos.some((r) => r.id)) {
4850
- lines.push(
4851
- "",
4852
- "## Filing a new ticket",
4853
- "",
4854
- "Something broken that is out of scope for the ticket you are working is not",
4855
- "yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
4856
- "the one you were on. A ticket filed without its project and repo sits unrouted",
4857
- "until a person notices and fixes it by hand, so always set:",
4858
- "",
4859
- ...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
4860
- ...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
4861
- (r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
4862
- ),
4863
- ...authorshipBullets(i.authorship, contract),
4864
- "",
4865
- ...i.sourceTicket ? [
4866
- `Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
4867
- "ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
4868
- "genuinely cannot proceed without the new one being fixed first; merely having noticed it",
4869
- "nearby is not a dependency.",
4870
- ""
4871
- ] : []
4872
- );
4873
- }
4874
- if (only) {
4875
- lines.push(...repoSection(only, key, "## Your worktree"));
4876
- } else {
4877
- lines.push(
4878
- "",
4879
- "## The repositories this board covers",
4880
- "",
4881
- "This board spans several repositories, and they do not share a branch name,",
4882
- "a worktree location or a test command. **The `repo` column of your queue",
4883
- "says which one your ticket belongs to** \u2014 read the section for THAT one and",
4884
- "ignore the others. A ticket whose repo is not listed here has no checkout on",
4885
- "this machine and is not yours to work."
4886
- );
4887
- for (const r of repos) {
4888
- lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
4889
- lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
4890
- }
4891
- }
4892
- if (i.repoDir) {
4893
- lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
4894
- }
4895
- if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
4896
- return lines.join("\n");
4897
- }
4898
-
4899
- // src/contract.ts
4900
- var DEFAULT_CONTRACT = {
4901
- columns: {
4902
- key: "issue_id",
4903
- tag: "issue_tag",
4904
- title: "title",
4905
- status: "status",
4906
- assignee: "assignee_id",
4907
- priority: "priority",
4908
- severity: "severity",
4909
- slice: "project_id",
4910
- repo: "repo_id",
4911
- parent: "parent_id",
4912
- blockedBy: "blocked_by",
4913
- updatedAt: "updated_at",
4914
- needsDesign: "needs_design",
4915
- attachments: "attachments",
4916
- workMode: "work_mode",
4917
- reportType: "report_type",
4918
- epic: "epic_id",
4919
- author: "filed_by_id",
4920
- ship: "ship_id",
4921
- heldBy: "held_by_ship_id",
4922
- resolutionNote: "resolution_note",
4923
- verifiedSha: "verified_sha",
4924
- prRef: "pr_ref",
4925
- externalId: "external_id",
4926
- position: "position",
4927
- agentRuns: "agent_runs",
4928
- tokensIn: "tokens_in",
4929
- tokensOut: "tokens_out",
4930
- cacheReadTokens: "cache_read_tokens",
4931
- cacheWriteTokens: "cache_write_tokens",
4932
- costUsd: "cost_usd"
4933
- },
4934
- comments: {
4935
- parent: "ticket_id",
4936
- body: "body",
4937
- author: "team_member_id",
4938
- createdAt: "created_at",
4939
- ship: "ship_id",
4940
- publicReply: "is_public_reply",
4941
- authorName: "author_name"
4942
- },
4943
- statuses: {
4944
- open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
4945
- resolved: [
4946
- "verified",
4947
- "closed_deployed",
4948
- "closed_wont_fix",
4949
- "closed_duplicate",
4950
- "closed_completed",
4951
- "closed_cant_reproduce"
4952
- ],
4953
- approved: "accepted",
4954
- parked: "blocked",
4955
- building: "in_progress",
4956
- handoff: "fixed",
4957
- verifying: "qa",
4958
- verified: "verified",
4959
- deployed: "closed_deployed",
4960
- needsHuman: "needs_info",
4961
- reviewing: null,
4962
- wontFix: "closed_wont_fix",
4963
- draft: "draft"
4964
- },
4965
- epics: {
4966
- statusColumn: "status",
4967
- keyColumn: "epic_id",
4968
- statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
4969
- },
4970
- priorityOrder: ["p0", "p1", "p2", "p3"],
4971
- severityOrder: ["s1", "s2", "s3", "s4"],
4972
- unknownPriorityRank: 2,
4973
- coordinatingValue: "coordinating",
4974
- manualWorkMode: "manual",
4975
- hybridWorkMode: "hybrid",
4976
- repoExemptReportTypes: ["coordinating", "question", "investigation"]
4977
- };
4978
- function closedStatuses(c) {
4979
- return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
4980
- }
4981
- function resolveContract(override) {
4982
- if (!override) return DEFAULT_CONTRACT;
4983
- return {
4984
- columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
4985
- comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
4986
- statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
4987
- epics: {
4988
- ...DEFAULT_CONTRACT.epics,
4989
- ...override.epics,
4990
- statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
4991
- },
4992
- priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
4993
- severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
4994
- unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
4995
- coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
4996
- manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
4997
- hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
4998
- // A workspace overriding `coordinatingValue` alone (renaming the rollup
4999
- // marker) still gets that renamed value exempted here by default,
5000
- // without also having to repeat it — only an explicit
5001
- // `repoExemptReportTypes` overrides this derived default.
5002
- repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
5003
- };
5004
- }
5005
- function validateContract(c) {
5006
- const problems = [];
5007
- const open = new Set(c.statuses.open);
5008
- const resolved = new Set(c.statuses.resolved);
5009
- for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
5010
- const v = c.statuses[role];
5011
- if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
4656
+ pause(role) {
4657
+ writeFileSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
5012
4658
  }
5013
- if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
5014
- problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
4659
+ resume(role) {
4660
+ rmSync6(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
5015
4661
  }
5016
- if (!resolved.has(c.statuses.deployed)) {
5017
- problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
4662
+ /**
4663
+ * The release phase (merge, deploy, release) has its own sentinel, one
4664
+ * level below the whole-crew pause: `crew pause release` stops shipping
4665
+ * while agents keep working.
4666
+ */
4667
+ isReleasePaused() {
4668
+ return existsSync11(this.path(".release-paused"));
5018
4669
  }
5019
- if (resolved.has(c.statuses.handoff)) {
5020
- problems.push(
5021
- `statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
5022
- );
4670
+ setReleasePaused(paused) {
4671
+ if (paused) writeFileSync7(this.path(".release-paused"), "");
4672
+ else rmSync6(this.path(".release-paused"), { force: true });
5023
4673
  }
5024
- if (!resolved.has(c.statuses.wontFix)) {
5025
- problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
4674
+ /**
4675
+ * Why a release cycle must not run right now, or null when it may. Every
4676
+ * release entry point checks this first — the release timer, `crew release`,
4677
+ * `crew merge`, `crew deploy` and the inline release a `run` cycle used to
4678
+ * do — so a paused ship cannot merge, deploy, or file release alerts.
4679
+ * A release already in flight when the pause lands finishes; only new
4680
+ * cycles are refused.
4681
+ */
4682
+ releaseSkipReason() {
4683
+ if (this.isPaused()) return "the crew is paused";
4684
+ if (this.isReleasePaused()) return "releases are paused";
4685
+ return null;
5026
4686
  }
5027
- if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
5028
- problems.push(
5029
- `statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
5030
- );
4687
+ /**
4688
+ * Take an exclusive lock, or report who holds it.
4689
+ *
4690
+ * A timer fires every couple of minutes and an agent run takes minutes, so
4691
+ * overlapping fires are certain rather than hypothetical — and two agents
4692
+ * on one checkout would fight over the same worktree and the same tickets.
4693
+ *
4694
+ * A lock naming a DEAD pid is stale and taken over: a run killed mid-cycle
4695
+ * (a reboot, a Ctrl-C) must not wedge the crew until someone notices.
4696
+ */
4697
+ /**
4698
+ * Lock names become filenames, so anything that could be read as a path has
4699
+ * to go. A scope like `route/repo` otherwise produced
4700
+ * `.release-route/repo.lock` — a write into a directory that does not exist.
4701
+ */
4702
+ static safe(name) {
4703
+ return name.replace(/[^A-Za-z0-9_-]/g, "_");
5031
4704
  }
5032
- if (resolved.has(c.statuses.approved)) {
5033
- problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
5034
- }
5035
- if (c.statuses.parked === c.statuses.approved) {
5036
- problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
5037
- }
5038
- const epicValues = Object.values(c.epics.statuses);
5039
- if (new Set(epicValues).size !== epicValues.length) {
5040
- problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
5041
- }
5042
- if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
5043
- if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
5044
- for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
5045
- if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
5046
- }
5047
- return problems;
5048
- }
5049
- var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5050
-
5051
- // src/agent-log.ts
5052
- import { existsSync as existsSync11, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync11, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
5053
- import { join as join12 } from "path";
5054
-
5055
- // src/pending-backoff.ts
5056
- var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
5057
-
5058
- // src/agent-log.ts
5059
- var AgentLogUnreachableError = class extends Error {
5060
- };
5061
- var AgentLogHttpError = class extends Error {
5062
- status;
5063
- constructor(message, status2) {
5064
- super(message);
5065
- this.status = status2;
5066
- }
5067
- };
5068
- async function request(target, method, path, body) {
5069
- const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
5070
- const res = await fetch(url, {
5071
- method,
5072
- headers: {
5073
- Authorization: `Bearer ${target.apiKey}`,
5074
- "User-Agent": target.userAgent,
5075
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
5076
- },
5077
- body: body === void 0 ? void 0 : JSON.stringify(body)
5078
- });
5079
- if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5080
- return await res.json();
5081
- }
5082
- var issueColumns = /* @__PURE__ */ new Map();
5083
- function columnsOf(target, modelId) {
5084
- const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
5085
- let p = issueColumns.get(key);
5086
- if (!p) {
5087
- p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
5088
- p.catch(() => issueColumns.delete(key));
5089
- issueColumns.set(key, p);
5090
- }
5091
- return p;
5092
- }
5093
- async function findTicketRecord(target, t, ref) {
5094
- const filters = {
5095
- combinator: "OR",
5096
- conditions: [
5097
- { columnName: t.keyColumn, operator: "EQ", value: ref },
5098
- { columnName: t.tagColumn, operator: "EQ", value: ref }
5099
- ]
5100
- };
5101
- const rows = await request(
5102
- target,
5103
- "GET",
5104
- `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
5105
- );
5106
- return rows.length === 1 ? rows[0].id : void 0;
5107
- }
5108
- function counterIncrements(run2, cols, have) {
5109
- const figures = [
5110
- [cols.agentRuns, 1],
5111
- [cols.tokensIn, run2.tokensIn],
5112
- [cols.tokensOut, run2.tokensOut],
5113
- [cols.cacheReadTokens, run2.cacheReadTokens],
5114
- [cols.cacheWriteTokens, run2.cacheWriteTokens],
5115
- [cols.costUsd, run2.costUsd]
5116
- ];
5117
- const out = {};
5118
- for (const [column, value2] of figures) {
5119
- if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
5120
- }
5121
- return out;
5122
- }
5123
- async function post(target, path, body) {
5124
- const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
5125
- const res = await fetch(url, {
5126
- method: "POST",
5127
- headers: {
5128
- Authorization: `Bearer ${target.apiKey}`,
5129
- // Cloudflare 403s default agents on this host — same header connect.ts sends.
5130
- "User-Agent": target.userAgent,
5131
- "Content-Type": "application/json"
5132
- },
5133
- body: JSON.stringify(body)
5134
- });
5135
- if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5136
- return await res.json();
5137
- }
5138
- async function reportAgentRun(target, run2) {
5139
- let ticketRecordId;
5140
- if (target.tickets && run2.ticketReference) {
4705
+ acquire(rawName) {
4706
+ const name = _State.safe(rawName);
4707
+ const file = this.path(`.${name}.lock`);
5141
4708
  try {
5142
- ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
4709
+ const existing = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
4710
+ if (Number.isFinite(existing) && existing > 0) {
4711
+ try {
4712
+ process.kill(existing, 0);
4713
+ return { ok: false, heldBy: existing };
4714
+ } catch {
4715
+ }
4716
+ }
5143
4717
  } catch {
5144
4718
  }
5145
- }
5146
- const body = {
5147
- agentId: target.agentId,
5148
- ticketReference: run2.ticketReference,
5149
- outcome: run2.outcome,
5150
- startedAt: run2.startedAt,
5151
- finishedAt: run2.finishedAt,
5152
- source: "client",
5153
- client: "crew",
5154
- model: run2.model,
5155
- tokensIn: run2.tokensIn,
5156
- tokensOut: run2.tokensOut,
5157
- cacheReadTokens: run2.cacheReadTokens,
5158
- cacheWriteTokens: run2.cacheWriteTokens,
5159
- costUsd: run2.costUsd,
5160
- numTurns: run2.numTurns,
5161
- prefixTokens: run2.prefixTokens,
5162
- promptVersion: run2.promptVersion,
5163
- promptSha: run2.promptSha
5164
- };
5165
- const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
5166
- let entry;
5167
- try {
5168
- entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
5169
- } catch (e) {
5170
- if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
5171
- if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
5172
- if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) throw e;
5173
- entry = await post(target, "/agents/log", body);
5174
- }
5175
- for (const cycle of run2.cycles) {
5176
- await post(target, `/agents/log/${entry.id}/cycles`, {
5177
- cycleIndex: cycle.cycleIndex,
5178
- occurredAt: cycle.occurredAt,
5179
- thinking: cycle.thinking,
5180
- ...cycle.role ? { role: cycle.role } : {}
5181
- });
5182
- }
5183
- const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
5184
- return {
5185
- agentLogId: entry.id,
5186
- ...counters?.warning ? { counterWarning: counters.warning } : {},
5187
- ...counters?.retry ? { counterRetry: counters.retry } : {}
5188
- };
5189
- }
5190
- var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
5191
- async function writeCounters(target, w) {
5192
- const have = await columnsOf(target, w.issuesModelId);
5193
- const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
5194
- if (Object.keys(body).length === 0) return;
5195
- await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
5196
- }
5197
- async function addRunToTicket(target, t, recordId, run2) {
5198
- const write = {
5199
- issuesModelId: t.issuesModelId,
5200
- recordId,
5201
- ticketReference: run2.ticketReference,
5202
- counters: t.counters,
5203
- body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
5204
- };
5205
- try {
5206
- await writeCounters(target, write);
5207
- return {};
5208
- } catch (e) {
4719
+ writeFileSync7(file, `${process.pid}
4720
+ `);
5209
4721
  return {
5210
- warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
5211
- ...isRetryable(e) ? { retry: write } : {}
4722
+ ok: true,
4723
+ release: () => {
4724
+ try {
4725
+ rmSync6(file, { force: true });
4726
+ } catch {
4727
+ }
4728
+ }
5212
4729
  };
5213
4730
  }
5214
- }
5215
- var queueDir = (stateDir) => join12(stateDir, "pending-agent-logs");
5216
- function queueAgentRun(stateDir, name, q) {
5217
- mkdirSync6(queueDir(stateDir), { recursive: true });
5218
- writeFileSync7(join12(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
5219
- }
5220
- async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5221
- const dir = queueDir(stateDir);
5222
- if (!existsSync11(dir)) return 0;
5223
- let sent = 0;
5224
- for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
5225
- const path = join12(dir, f);
5226
- let q;
4731
+ /**
4732
+ * Whether a release is running right now: any `.release-<scope>.lock` in the
4733
+ * state directory naming a live process (the locks `releasePhase` takes per
4734
+ * scope). A lock naming a dead pid is ignored, as `acquire` ignores it.
4735
+ */
4736
+ releaseInProgress() {
4737
+ let names;
5227
4738
  try {
5228
- q = JSON.parse(readFileSync11(path, "utf8"));
4739
+ names = readdirSync4(this.dir);
5229
4740
  } catch {
5230
- continue;
4741
+ return false;
5231
4742
  }
5232
- if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5233
- try {
5234
- const apiKey = await keyFor(q.role);
5235
- if (!apiKey) continue;
5236
- const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
5237
- if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
5238
- if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
5239
- version: 1,
5240
- route: q.route,
5241
- role: q.role,
5242
- target: q.target,
5243
- write: sentRun.counterRetry,
5244
- attempts: 0,
5245
- nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
5246
- });
5247
- rmSync6(path, { force: true });
5248
- sent += 1;
5249
- log.emit(`sent a queued Agent Log report (${f})`);
5250
- } catch (e) {
5251
- if (!(e instanceof AgentLogUnreachableError)) {
5252
- rmSync6(path, { force: true });
5253
- log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
4743
+ for (const n of names) {
4744
+ if (!/^\.release-.+\.lock$/.test(n)) continue;
4745
+ let pid = 0;
4746
+ try {
4747
+ pid = Number.parseInt(readFileSync11(this.path(n), "utf8").trim(), 10);
4748
+ } catch {
5254
4749
  continue;
5255
4750
  }
5256
- const attempts = q.attempts + 1;
5257
- writeFileSync7(path, JSON.stringify({
5258
- ...q,
5259
- attempts,
5260
- warned: true,
5261
- nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5262
- }));
5263
- if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
4751
+ if (!Number.isFinite(pid) || pid <= 0) continue;
4752
+ try {
4753
+ process.kill(pid, 0);
4754
+ return true;
4755
+ } catch {
4756
+ }
5264
4757
  }
5265
- }
5266
- return sent;
5267
- }
5268
- var countersDir = (stateDir) => join12(stateDir, "pending-ticket-counters");
5269
- function queueTicketCounters(stateDir, name, q) {
5270
- mkdirSync6(countersDir(stateDir), { recursive: true });
5271
- writeFileSync7(join12(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
5272
- }
5273
- async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5274
- const dir = countersDir(stateDir);
5275
- if (!existsSync11(dir)) return 0;
5276
- let sent = 0;
5277
- for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
5278
- const path = join12(dir, f);
5279
- let q;
5280
- try {
5281
- q = JSON.parse(readFileSync11(path, "utf8"));
5282
- } catch {
5283
- continue;
5284
- }
5285
- if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5286
- try {
5287
- const apiKey = await keyFor(q.role);
5288
- if (!apiKey) continue;
5289
- await writeCounters({ ...q.target, apiKey }, q.write);
5290
- rmSync6(path, { force: true });
5291
- sent += 1;
5292
- log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
5293
- } catch (e) {
5294
- if (!isRetryable(e)) {
5295
- rmSync6(path, { force: true });
5296
- log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
5297
- continue;
5298
- }
5299
- const attempts = q.attempts + 1;
5300
- writeFileSync7(path, JSON.stringify({
5301
- ...q,
5302
- attempts,
5303
- warned: true,
5304
- nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5305
- }));
5306
- if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
5307
- }
5308
- }
5309
- return sent;
5310
- }
5311
-
5312
- // src/ticket-attribution.ts
5313
- var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
5314
- function ticketMentions(text) {
5315
- return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
5316
- }
5317
- var TicketAttributionTracker = class {
5318
- #tokensByTicket = /* @__PURE__ */ new Map();
5319
- #turns = 0;
5320
- #current;
5321
- /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
5322
- #keyByRecord = /* @__PURE__ */ new Map();
5323
- #lastBy = {};
5324
- /**
5325
- * The ticket key most recently named in the transcript, or undefined before any.
5326
- * Only the FIRST key in a turn counts: a closing summary names its own
5327
- * ticket first and then lists others ("Other tickets", "Blocked by", "See
5328
- * also"), and those trailing keys are not what the run worked (CREW-2598).
5329
- */
5330
- get current() {
5331
- return this.#current;
5332
- }
5333
- /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
5334
- note(text) {
5335
- const mentions = ticketMentions(text);
5336
- if (mentions.length > 0) this.#current = mentions[0];
5337
- }
5338
- /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
5339
- add(text, tokens) {
5340
- const mentions = ticketMentions(text);
5341
- if (mentions.length > 0) this.#current = mentions[0];
5342
- if (this.#current === void 0 || tokens <= 0) return;
5343
- this.#turns++;
5344
- this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
4758
+ return false;
5345
4759
  }
5346
4760
  /**
5347
- * Record a tool call if it is a ticket-targeted tracker write (claim,
5348
- * hand-off or comment). The ticket key comes from a record uuid seen on an
5349
- * earlier write when known, else from the key last named in the transcript
5350
- * (a claim or hand-off announces its ticket). A comment on a record never
5351
- * seen before is ignored: its uuid cannot be resolved to a key.
4761
+ * Like `acquire`, but for a resource with `max` interchangeable slots
4762
+ * rather than one exclusive holder — this machine's overall concurrency
4763
+ * capacity (ISSUE-381), which nothing needs to name a particular slot to
4764
+ * use. Takes whichever numbered slot is free first.
5352
4765
  */
5353
- noteWrite(tool, input) {
5354
- const w = classifyWrite(tool, input);
5355
- if (!w) return;
5356
- let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
5357
- if (w.kind !== "comment") key ??= this.#current;
5358
- if (!key) return;
5359
- if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
5360
- this.#lastBy[w.kind] = key;
4766
+ acquireN(rawPrefix, max) {
4767
+ const heldBy = [];
4768
+ for (let i = 1; i <= max; i++) {
4769
+ const got = this.acquire(`${rawPrefix}-${i}`);
4770
+ if (got.ok) return got;
4771
+ heldBy.push(got.heldBy);
4772
+ }
4773
+ return { ok: false, heldBy };
5361
4774
  }
5362
4775
  /**
5363
- * How the attribution is decided: a hand-off write beats a claim, a claim
5364
- * beats a comment (so a follow-up ticket filed or commented on while
5365
- * working never displaces the claimed one), and any write beats everything
5366
- * below. Next, `hintMoved` — the post-run read-back showed the hinted
5367
- * ticket changed status or assignee during the run — is a hand-off signal
5368
- * for a run that wrote through MCP tools crew cannot see. Then the
5369
- * mention-token heuristic, which with a hint may override it only when a
5370
- * different ticket holds a strict majority of the credited tokens (a single
5371
- * credited turn, like a lone end-of-run summary naming another ticket,
5372
- * must not). A run with no hint
5373
- * keeps the plain winner. The pre-run hint is last.
4776
+ * The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
4777
+ * checked together.
4778
+ *
4779
+ * Two sessions of one role must never take the same ticket. Before
4780
+ * CREW-2821 that was guaranteed by an exclusive per-role lock; now the
4781
+ * guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
4782
+ * counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
4783
+ * historical name `crew-role-<role>` (crew-macos reads it for the live pid),
4784
+ * further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
4785
+ * limit on how many runs of ANY role may overlap; a role that clears its own
4786
+ * lock but finds every slot taken must give its role lock back, or a
4787
+ * locked-but-not-running role would wedge itself out for the rest of the
4788
+ * machine's uptime.
5374
4789
  */
5375
- decide(hint, opts = {}) {
5376
- for (const by of ["handoff", "claim", "comment"]) {
5377
- const ticket = this.#lastBy[by];
5378
- if (ticket) return { ticket, by };
4790
+ acquireRun(role, max, roleMax = 1) {
4791
+ let roleLock;
4792
+ const roleHolders = [];
4793
+ for (let i = 1; i <= roleMax; i++) {
4794
+ const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
4795
+ if (got.ok) {
4796
+ roleLock = got;
4797
+ break;
4798
+ }
4799
+ roleHolders.push(got.heldBy);
5379
4800
  }
5380
- if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
5381
- if (this.#tokensByTicket.size > 0) {
5382
- if (!hint) return { ticket: this.winner(), by: "mentions" };
5383
- const winner = this.winner(hint);
5384
- if (winner === hint) return { ticket: hint, by: "mentions" };
5385
- let total = 0;
5386
- for (const n of this.#tokensByTicket.values()) total += n;
5387
- const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
5388
- return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
4801
+ if (!roleLock) {
4802
+ return {
4803
+ ok: false,
4804
+ reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
4805
+ };
5389
4806
  }
5390
- return { ticket: hint, by: "hint" };
4807
+ const slot = this.acquireN("crew-slot", max);
4808
+ if (!slot.ok) {
4809
+ roleLock.release();
4810
+ return {
4811
+ ok: false,
4812
+ reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
4813
+ };
4814
+ }
4815
+ return { ok: true, release: () => {
4816
+ slot.release();
4817
+ roleLock.release();
4818
+ } };
5391
4819
  }
5392
4820
  /**
5393
- * The ticket key the run spent the most tokens on, or `fallback` (the
5394
- * pre-run poll hint) when the transcript never named one — an
5395
- * administrative run that touches the tracker without discussing a
5396
- * specific ticket in its visible text still has to cite something.
4821
+ * The per-ticket run lock (CREW-2821): held for the life of a run that works
4822
+ * the ticket (`crew-ticket-<record id>`), so a second run of the same role
4823
+ * on this ship walks past it instead of being handed the first run's
4824
+ * ticket. Same stale-pid rule as every lock here.
5397
4825
  */
5398
- winner(fallback) {
5399
- let best = fallback;
5400
- let bestTokens = -1;
5401
- for (const [ticket, tokens] of this.#tokensByTicket) {
5402
- if (tokens > bestTokens) {
5403
- best = ticket;
5404
- bestTokens = tokens;
5405
- }
5406
- }
5407
- return best;
5408
- }
5409
- };
5410
- function isClaimWrite(tool, input) {
5411
- if (!tool || !/records_controller_update$/.test(tool)) return false;
5412
- const body = input?.body;
5413
- return typeof body === "object" && body !== null && body.status === "in_progress";
5414
- }
5415
- var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
5416
- function classifyWrite(tool, input) {
5417
- if (!tool) return void 0;
5418
- const body = input?.body;
5419
- if (typeof body !== "object" || body === null) return void 0;
5420
- const b = body;
5421
- if (/records_controller_update$/.test(tool)) {
5422
- const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
5423
- if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
5424
- if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
4826
+ acquireTicket(recordId) {
4827
+ return this.acquire(`crew-ticket-${recordId}`);
5425
4828
  }
5426
- if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
5427
- return void 0;
5428
- }
5429
-
5430
- // src/handoff-transcript.ts
5431
- var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
5432
- function resultText(content) {
5433
- if (typeof content === "string") return content;
5434
- if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
5435
- return "";
5436
- }
5437
- function isTransportFailure(isError, text) {
5438
- if (!isError) {
4829
+ /** Whether a live run on this ship holds `recordId`'s ticket lock. */
4830
+ ticketInFlight(recordId) {
4831
+ const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
5439
4832
  try {
5440
- const doc = JSON.parse(text);
5441
- if (doc && typeof doc === "object") {
5442
- const code = doc.statusCode;
5443
- return typeof code === "number" && code >= 500;
5444
- }
4833
+ const pid = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
4834
+ if (!Number.isFinite(pid) || pid <= 0) return false;
4835
+ process.kill(pid, 0);
4836
+ return true;
5445
4837
  } catch {
4838
+ return false;
5446
4839
  }
5447
4840
  }
5448
- return TRANSPORT_FAILURE.test(text);
5449
- }
5450
- var HandoffTranscriptTracker = class {
5451
- open = /* @__PURE__ */ new Map();
5452
- /** Per ticket record: the last hand-off status write's outcome. */
5453
- status = /* @__PURE__ */ new Map();
5454
- /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
5455
- comment = /* @__PURE__ */ new Map();
5456
- landed = /* @__PURE__ */ new Set();
5457
- lastText = "";
5458
- resultLine = "";
5459
- feed(parsed) {
5460
- const line = parsed;
5461
- if (!line || typeof line !== "object") return;
5462
- if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
5463
- const content = line.message?.content;
5464
- if (!Array.isArray(content)) return;
5465
- for (const b of content) {
5466
- if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
5467
- else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
5468
- const w = classifyWrite(b.name, b.input);
5469
- const body = b.input?.body;
5470
- if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
5471
- this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
5472
- }
5473
- } else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
5474
- const w = this.open.get(b.tool_use_id);
5475
- if (!w) continue;
5476
- this.open.delete(b.tool_use_id);
5477
- const failed = isTransportFailure(b.is_error === true, resultText(b.content));
5478
- if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
5479
- else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
5480
- else if (!failed) {
5481
- this.comment.delete(w.recordId);
5482
- this.landed.add(w.recordId);
5483
- }
5484
- }
4841
+ /**
4842
+ * How many consecutive cycles the release phase has refused to deploy.
4843
+ *
4844
+ * Refusing is correct — a dirty tree or a non-main checkout must never be
4845
+ * released — but it used to be SILENT, and eight commits once sat unreleased
4846
+ * behind a single untracked file with nothing saying so (ISSUE-174). One
4847
+ * blocked cycle is normal; the count is what makes a persistent one loud.
4848
+ */
4849
+ /**
4850
+ * Release bookkeeping, scoped to one route.
4851
+ *
4852
+ * Scoped because a ship serves several boards and releases each repo
4853
+ * independently (ISSUE-338). These were single global files, so a deploy
4854
+ * failure on one route suppressed releases on every other one, and a
4855
+ * release blocked in one repo counted cycles for all of them — a dirty tree
4856
+ * in one checkout would have silently held up an unrelated project.
4857
+ */
4858
+ release(route) {
4859
+ const suffix = _State.safe(route);
4860
+ const blocked = `.release-blocked-${suffix}`;
4861
+ const failed = `.deploy-failed-sha-${suffix}`;
4862
+ const gateFailed = `.test-gate-failed-${suffix}`;
4863
+ const gateReported = `.test-gate-reported-${suffix}`;
4864
+ const heldReported = `.release-held-${suffix}`;
4865
+ const read = (f) => {
4866
+ try {
4867
+ return readFileSync11(this.path(f), "utf8").trim() || null;
4868
+ } catch {
4869
+ return null;
4870
+ }
4871
+ };
4872
+ return {
4873
+ blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
4874
+ noteBlocked: () => {
4875
+ const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
4876
+ writeFileSync7(this.path(blocked), `${n}
4877
+ `);
4878
+ return n;
4879
+ },
4880
+ clearBlocked: () => {
4881
+ rmSync6(this.path(blocked), { force: true });
4882
+ },
4883
+ /**
4884
+ * The commit a deploy last failed on.
4885
+ *
4886
+ * Without this the crew retries a broken deploy on every cycle — every
4887
+ * two minutes, indefinitely — hammering the target and burying the
4888
+ * original failure under identical ones. A failed commit is retried
4889
+ * only when a NEW commit lands, or when an operator forces it by hand.
4890
+ */
4891
+ deployFailedSha: () => read(failed),
4892
+ noteDeployFailed: (sha) => {
4893
+ writeFileSync7(this.path(failed), `${sha}
4894
+ `);
4895
+ },
4896
+ clearDeployFailed: () => {
4897
+ rmSync6(this.path(failed), { force: true });
4898
+ },
4899
+ /**
4900
+ * Consecutive test-gate failures on the same head (CREW-1368). A failure
4901
+ * on a new head restarts the count. Held on disk because one process is
4902
+ * one cycle, and "failed three cycles running" only exists across them.
4903
+ */
4904
+ noteTestGateFailed: (sha) => {
4905
+ const [prev, n] = (read(gateFailed) ?? "").split(" ");
4906
+ const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
4907
+ writeFileSync7(this.path(gateFailed), `${sha} ${next}
4908
+ `);
4909
+ return next;
4910
+ },
4911
+ clearTestGateFailed: () => {
4912
+ rmSync6(this.path(gateFailed), { force: true });
4913
+ },
4914
+ /** Whether the board was already told about this head's red gate. */
4915
+ heldReported: () => {
4916
+ try {
4917
+ return readFileSync11(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
4918
+ } catch {
4919
+ return null;
4920
+ }
4921
+ },
4922
+ noteHeldReported: (signature) => {
4923
+ writeFileSync7(this.path(heldReported), `${signature}
4924
+ `);
4925
+ },
4926
+ clearHeldReported: () => {
4927
+ rmSync6(this.path(heldReported), { force: true });
4928
+ },
4929
+ testGateReported: (sha) => read(gateReported) === sha,
4930
+ noteTestGateReported: (sha) => {
4931
+ writeFileSync7(this.path(gateReported), `${sha}
4932
+ `);
4933
+ }
4934
+ };
4935
+ }
4936
+ /**
4937
+ * Starvation bookkeeping, scoped to one route (ISSUE-382).
4938
+ *
4939
+ * `decideFleet` already renders the winner and each role's rank; what it
4940
+ * throws away is the other side — a route that had work and did not
4941
+ * win, and the same ticket sitting at the front of that route's queue
4942
+ * cycle after cycle. This is state, not memory: a single process is one
4943
+ * cycle, so "how long has this been waiting" only exists if it survives
4944
+ * between them.
4945
+ */
4946
+ fairness(route) {
4947
+ const suffix = _State.safe(route);
4948
+ const streakFile = `.fairness-streak-${suffix}`;
4949
+ const ticketFile = `.fairness-ticket-${suffix}`;
4950
+ const sinceFile = `.fairness-since-${suffix}`;
4951
+ const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
4952
+ const read = (f) => {
4953
+ try {
4954
+ return readFileSync11(this.path(f), "utf8").trim() || null;
4955
+ } catch {
4956
+ return null;
4957
+ }
4958
+ };
4959
+ const clear2 = () => {
4960
+ for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync6(this.path(f), { force: true });
4961
+ };
4962
+ return {
4963
+ /** Consecutive cycles this route had actionable work and did not win. */
4964
+ streak: () => Number.parseInt(read(streakFile) ?? "", 10) || 0,
4965
+ /**
4966
+ * The ticket sitting at the front of this route's queue, how long
4967
+ * it has been there and its own consecutive-passed-over count — or
4968
+ * undefined if nothing is waiting.
4969
+ */
4970
+ waiting: () => {
4971
+ const ticket = read(ticketFile);
4972
+ const since2 = read(sinceFile);
4973
+ if (!ticket || !since2) return void 0;
4974
+ return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
4975
+ },
4976
+ /**
4977
+ * Record what one cycle saw for this route: how many tickets were
4978
+ * actionable, which one was most urgent, and whether this route
4979
+ * won. Winning or running dry both reset the streak — there is nothing
4980
+ * left being passed over.
4981
+ */
4982
+ record: (actionable, topTicket, won) => {
4983
+ if (won || actionable === 0) {
4984
+ clear2();
4985
+ return;
4986
+ }
4987
+ writeFileSync7(this.path(streakFile), `${this.readInt(streakFile) + 1}
4988
+ `);
4989
+ const prior = read(ticketFile);
4990
+ if (topTicket && topTicket === prior) {
4991
+ writeFileSync7(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
4992
+ `);
4993
+ } else if (topTicket) {
4994
+ writeFileSync7(this.path(ticketFile), `${topTicket}
4995
+ `);
4996
+ writeFileSync7(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
4997
+ `);
4998
+ writeFileSync7(this.path(ticketStreakFile), "1\n");
4999
+ }
5000
+ }
5001
+ };
5002
+ }
5003
+ /**
5004
+ * The previous cycle's "needs a person" set, scoped to one route
5005
+ * (ISSUE-928) — `{ ticketId: [reason, ...] }`. `poll.ts` reads it once
5006
+ * per cycle to compute transitions and the caller (`cli.ts`'s run path,
5007
+ * `fleet.ts`'s per-route loop) persists the new set afterward, beside the
5008
+ * existing `fairness(route).record(...)` call — never inside `decideCycle`
5009
+ * itself, which does no writes. A corrupt or missing file reads as empty,
5010
+ * which is also deliberately what a fresh install sees: everything
5011
+ * currently needing a person emits once, rather than being silently
5012
+ * skipped as though already notified.
5013
+ */
5014
+ attention(route) {
5015
+ const file = `.attention-${_State.safe(route)}.json`;
5016
+ return {
5017
+ previous: () => {
5018
+ try {
5019
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5020
+ return parsed && typeof parsed === "object" ? parsed : {};
5021
+ } catch {
5022
+ return {};
5023
+ }
5024
+ },
5025
+ persist: (next) => {
5026
+ writeFileSync7(this.path(file), JSON.stringify(next));
5027
+ }
5028
+ };
5029
+ }
5030
+ /**
5031
+ * The previous cycle's unroutable ticket ids for one route (CREW-2548): open
5032
+ * tickets with no repository that are not exempt. `poll.ts` diffs against it
5033
+ * so each ticket warns once when it becomes unroutable, not every cycle; the
5034
+ * caller persists the new set beside `attention(route).persist(...)`. A
5035
+ * missing or corrupt file reads as empty, so a fresh install warns once.
5036
+ */
5037
+ unroutable(route) {
5038
+ const file = `.unroutable-${_State.safe(route)}.json`;
5039
+ return {
5040
+ previous: () => {
5041
+ try {
5042
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5043
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
5044
+ } catch {
5045
+ return [];
5046
+ }
5047
+ },
5048
+ persist: (next) => {
5049
+ writeFileSync7(this.path(file), JSON.stringify(next));
5050
+ }
5051
+ };
5052
+ }
5053
+ /**
5054
+ * Open ship-level attention items (CREW-1373), ship-wide rather than
5055
+ * per-route: a parked role or a missing hook command is about this
5056
+ * machine. A corrupt or missing file reads as empty.
5057
+ */
5058
+ shipAttention() {
5059
+ const file = ".ship-attention.json";
5060
+ return {
5061
+ previous: () => {
5062
+ try {
5063
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5064
+ return Array.isArray(parsed) ? parsed : [];
5065
+ } catch {
5066
+ return [];
5067
+ }
5068
+ },
5069
+ persist: (next) => {
5070
+ writeFileSync7(this.path(file), JSON.stringify(next));
5071
+ }
5072
+ };
5073
+ }
5074
+ /**
5075
+ * When the release phase last ran a cycle (CREW-1373), as the mtime of a
5076
+ * marker file. `touch()` is called at the start and the end of every cycle
5077
+ * so a long deploy does not read as a stopped timer.
5078
+ */
5079
+ releaseHeartbeat() {
5080
+ const file = ".release-last-run";
5081
+ return {
5082
+ touch: () => {
5083
+ writeFileSync7(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
5084
+ },
5085
+ at: () => {
5086
+ try {
5087
+ return statSync4(this.path(file)).mtimeMs;
5088
+ } catch {
5089
+ return null;
5090
+ }
5091
+ }
5092
+ };
5093
+ }
5094
+ readInt(file) {
5095
+ try {
5096
+ return Number.parseInt(readFileSync11(this.path(file), "utf8").trim(), 10) || 0;
5097
+ } catch {
5098
+ return 0;
5099
+ }
5100
+ }
5101
+ };
5102
+
5103
+ // src/environment.ts
5104
+ import { basename as basename5 } from "path";
5105
+ var API_KEY_VAR = "CREW_API_KEY";
5106
+ function authorshipBullets(a, contract) {
5107
+ if (!a) return [];
5108
+ return [
5109
+ ...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
5110
+ ...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
5111
+ ...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
5112
+ ];
5113
+ }
5114
+ var HOOK_PURPOSE = [
5115
+ ["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
5116
+ ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
5117
+ ["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
5118
+ ["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
5119
+ ["preview", "start this worktree's dev server for a viewer, bound to CREW_PREVIEW_HOST, detached; its last stdout line is the URL"],
5120
+ ["test", "run the suite"],
5121
+ ["build", "build it"],
5122
+ ["ci_status", "after you push and before you hand off: exit 0 means CI is green for your branch; on failure fix and push again, and say in your progress comment if it is still pending"]
5123
+ ];
5124
+ function hookTable(repo) {
5125
+ const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
5126
+ if (declared.length === 0) {
5127
+ return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
5128
+ }
5129
+ const inline = (cmd) => cmd.includes("\n") ? "_(below)_" : `\`${cmd}\``;
5130
+ const blocks = declared.filter(([name]) => repo.hooks[name].includes("\n")).map(([name]) => [`### \`${name}\``, "", "```sh", repo.hooks[name].trimEnd(), "```", ""].join("\n"));
5131
+ const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
5132
+ return [
5133
+ "| hook | what it is for | what it runs |",
5134
+ "| --- | --- | --- |",
5135
+ ...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
5136
+ "",
5137
+ "Run these **verbatim**, from the worktree. They come from the repository's own",
5138
+ "configuration, which is the only thing that knows them. A command you invented",
5139
+ "yourself is wrong even when it works, because the next repository will differ.",
5140
+ "",
5141
+ ...blocks,
5142
+ ...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
5143
+ ].join("\n");
5144
+ }
5145
+ function repoPrefix(repo) {
5146
+ return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
5147
+ }
5148
+ function branchExample(repo, key) {
5149
+ const prefix = repoPrefix(repo);
5150
+ const template = effectiveBranchTemplate(repo, prefix);
5151
+ return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
5152
+ }
5153
+ function repoSection(r, key, heading) {
5154
+ const repo = r.config;
5155
+ const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
5156
+ const branchName = branchExample(repo, key);
5157
+ const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
5158
+ const worktreeName = effectiveWorktreeDirName(
5159
+ repo,
5160
+ r.dir,
5161
+ branchName,
5162
+ key.replace(/^\D+/, ""),
5163
+ // The example ticket has no tag of its own, so `{issue}` is the key.
5164
+ (template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
5165
+ );
5166
+ const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
5167
+ const lines = [
5168
+ "",
5169
+ heading,
5170
+ "",
5171
+ `- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
5172
+ ` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
5173
+ `- Name the branch the way this repository names branches: \`${branchTemplate}\``,
5174
+ ` \u2014 for ${key} that is \`${branchName}\`.`,
5175
+ "- Never work in the main checkout. It is the operator's, and the release phase",
5176
+ " uses it.",
5177
+ "",
5178
+ `${key} above is a placeholder, not a real ticket: this section is rendered`,
5179
+ "before any ticket is chosen, so it cannot know a specific ticket's own",
5180
+ "project prefix. Once you have a ticket, its digest row's `branch` and",
5181
+ "`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
5182
+ "over this section's worked example if the two ever disagree."
5183
+ ];
5184
+ if (repo.worktrees.copy.length) {
5185
+ lines.push(
5186
+ "",
5187
+ "A worktree is a clean checkout, so it is missing exactly the files git ignores.",
5188
+ "Copy these across from the main checkout before running anything:",
5189
+ "",
5190
+ ...repo.worktrees.copy.map((c) => `- \`${c}\``)
5191
+ );
5192
+ }
5193
+ if (repo.docs.designGuide) {
5194
+ lines.push(
5195
+ "",
5196
+ "## This project's design brief",
5197
+ "",
5198
+ `\`${repo.docs.designGuide}\`, in the repository. Read it from the primary`,
5199
+ "checkout before you `cd` into any worktree \u2014 a worktree cut from the base",
5200
+ "branch only has it once it has been committed there."
5201
+ );
5202
+ }
5203
+ if (repo.docs.triagePolicy) {
5204
+ lines.push(
5205
+ "",
5206
+ "## This project's triage policy",
5207
+ "",
5208
+ `\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
5209
+ "disagree about what a status means or when a ticket may be accepted, the",
5210
+ "policy document wins: it is the project's own decision, and the brief is",
5211
+ "shared with projects that decided differently."
5212
+ );
5213
+ }
5214
+ return lines;
5215
+ }
5216
+ function renderEnvironment(i) {
5217
+ const { route, contract } = i;
5218
+ const repos = i.repos;
5219
+ const only = repos.length === 1 ? repos[0] : void 0;
5220
+ const models = route.resolved?.models;
5221
+ const epicsModelId = route.resolved?.epicsModelId;
5222
+ const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
5223
+ const key = "ISSUE-000";
5224
+ const lines = [
5225
+ "# Your environment",
5226
+ "",
5227
+ "Supplied by the crew for THIS run. The brief below is shared by every project",
5228
+ "the crew works on and deliberately names nothing specific; this section is where",
5229
+ "those names come from. Where the two appear to disagree, this section wins.",
5230
+ "",
5231
+ "## The board",
5232
+ "",
5233
+ `- Base URL: \`${route.baseUrl}\``,
5234
+ `- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
5235
+ ` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
5236
+ " and never write it into a comment, a commit or a log line.",
5237
+ `- Send \`User-Agent: ${i.userAgent}\` on every request.`
5238
+ ];
5239
+ if (models) {
5240
+ lines.push(
5241
+ "",
5242
+ "Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
5243
+ "",
5244
+ `- Issues: \`${models.issues}\``,
5245
+ `- Comments: \`${models.comments}\``,
5246
+ `- Crew: \`${models.crew}\``,
5247
+ ...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
5248
+ ...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
5249
+ );
5250
+ }
5251
+ const workspaceId = route.resolved?.workspaceId;
5252
+ if (workspaceId) {
5253
+ const base = route.baseUrl.replace(/\/+$/, "");
5254
+ lines.push(
5255
+ "",
5256
+ `- Workspace id: \`${workspaceId}\``,
5257
+ "",
5258
+ "## Attaching a file to a comment",
5259
+ "",
5260
+ "A screenshot or mockup goes on a comment's `attachments` field, which holds",
5261
+ "Media Library record ids, not URLs. Upload the file first, then put the",
5262
+ "returned `id` in the comment's `attachments` array:",
5263
+ "",
5264
+ "```sh",
5265
+ `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5266
+ ` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
5267
+ ' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
5268
+ "```",
5269
+ "",
5270
+ "The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
5271
+ "means no field type id has to be looked up. If the upload fails, post the HTTP",
5272
+ "status and response body in your comment \u2014 do not write that uploads are",
5273
+ "impossible."
5274
+ );
5275
+ if (mediaLibraryModelId) {
5276
+ lines.push(
5277
+ "",
5278
+ "## Opening a ticket's attachments",
5279
+ "",
5280
+ `A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
5281
+ "**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
5282
+ "`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
5283
+ "read the record, then fetch the file by its storage key:",
5284
+ "",
5285
+ "```sh",
5286
+ `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5287
+ ` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
5288
+ '# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
5289
+ `curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5290
+ ` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
5291
+ "```",
5292
+ "",
5293
+ "The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
5294
+ "id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
5295
+ "of the storage key). Save the file outside the repository and open it; where it",
5296
+ "disagrees with the description, the picture is the reproduction."
5297
+ );
5298
+ }
5299
+ }
5300
+ if (i.shipName) {
5301
+ lines.push(
5302
+ "",
5303
+ "## Ships",
5304
+ "",
5305
+ `This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
5306
+ "another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
5307
+ );
5308
+ }
5309
+ const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
5310
+ if (seatIds.length > 0) {
5311
+ lines.push(
5312
+ "",
5313
+ "## Seats on this ship",
5314
+ "",
5315
+ "The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
5316
+ "assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
5317
+ "",
5318
+ ...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
5319
+ );
5320
+ }
5321
+ lines.push(
5322
+ "",
5323
+ "## What the columns are called here",
5324
+ "",
5325
+ "Every workspace names these itself. Use these names, not the ones you would",
5326
+ "guess, and not the ones another project used.",
5327
+ "",
5328
+ "| meaning | column |",
5329
+ "| --- | --- |",
5330
+ `| ticket key | \`${contract.columns.key}\` |`,
5331
+ `| status | \`${contract.columns.status}\` |`,
5332
+ `| assignee | \`${contract.columns.assignee}\` |`,
5333
+ `| severity | \`${contract.columns.severity}\` |`,
5334
+ `| priority | \`${contract.columns.priority}\` |`,
5335
+ `| blocked by | \`${contract.columns.blockedBy}\` |`,
5336
+ `| needs design | \`${contract.columns.needsDesign}\` |`,
5337
+ `| project | \`${contract.columns.slice}\` |`,
5338
+ `| repo | \`${contract.columns.repo}\` |`,
5339
+ ...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
5340
+ "",
5341
+ ...epicsModelId ? [
5342
+ "An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
5343
+ "fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
5344
+ "to a person rather than inventing one yourself.",
5345
+ "",
5346
+ "An epic's own status is kept by the crew runner, from its tickets: done once",
5347
+ "every one of them is closed, in progress while any is being worked. Do not",
5348
+ "set it yourself.",
5349
+ ""
5350
+ ] : [],
5351
+ "Statuses that matter to you:",
5352
+ "",
5353
+ `- Work you may pick up: \`${contract.statuses.approved}\``,
5354
+ `- While you are working it: \`${contract.statuses.building}\``,
5355
+ `- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
5356
+ `- Being checked: \`${contract.statuses.verifying}\``,
5357
+ `- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
5358
+ `- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
5359
+ "",
5360
+ `\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
5361
+ );
5362
+ const areaId = route.resolved?.areaId;
5363
+ if (areaId || repos.some((r) => r.id)) {
5364
+ lines.push(
5365
+ "",
5366
+ "## Filing a new ticket",
5367
+ "",
5368
+ "Something broken that is out of scope for the ticket you are working is not",
5369
+ "yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
5370
+ "the one you were on. A ticket filed without its project and repo sits unrouted",
5371
+ "until a person notices and fixes it by hand, so always set:",
5372
+ "",
5373
+ ...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
5374
+ ...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
5375
+ (r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
5376
+ ),
5377
+ ...authorshipBullets(i.authorship, contract),
5378
+ "",
5379
+ ...i.sourceTicket ? [
5380
+ `Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
5381
+ "ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
5382
+ "genuinely cannot proceed without the new one being fixed first; merely having noticed it",
5383
+ "nearby is not a dependency.",
5384
+ ""
5385
+ ] : []
5386
+ );
5387
+ }
5388
+ if (only) {
5389
+ lines.push(...repoSection(only, key, "## Your worktree"));
5390
+ } else {
5391
+ lines.push(
5392
+ "",
5393
+ "## The repositories this board covers",
5394
+ "",
5395
+ "This board spans several repositories, and they do not share a branch name,",
5396
+ "a worktree location or a test command. **The `repo` column of your queue",
5397
+ "says which one your ticket belongs to** \u2014 read the section for THAT one and",
5398
+ "ignore the others. A ticket whose repo is not listed here has no checkout on",
5399
+ "this machine and is not yours to work."
5400
+ );
5401
+ for (const r of repos) {
5402
+ lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
5403
+ lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
5485
5404
  }
5486
5405
  }
5487
- /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
5488
- pending() {
5489
- const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
5490
- if (!failed) return null;
5491
- const [ticketRecordId, s] = failed;
5492
- return {
5493
- ticketRecordId,
5494
- patch: s.patch,
5495
- ...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
5496
- ...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
5497
- finalText: this.lastText || this.resultLine
5498
- };
5406
+ if (i.repoDir) {
5407
+ lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
5499
5408
  }
5500
- };
5501
-
5502
- // src/pending-handoff.ts
5503
- import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
5504
- import { join as join16 } from "path";
5505
-
5506
- // src/ticket-branch.ts
5507
- import { basename as basename6 } from "path";
5508
-
5509
- // src/tracker.ts
5510
- import { hostname } from "os";
5511
- import { TablationClient } from "@tablation/client";
5409
+ if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
5410
+ return lines.join("\n");
5411
+ }
5512
5412
 
5513
- // src/state.ts
5514
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, existsSync as existsSync12, mkdirSync as mkdirSync7, rmSync as rmSync7, statSync as statSync4, readdirSync as readdirSync5 } from "fs";
5515
- import { join as join13 } from "path";
5516
- var EPOCH = "1970-01-01T00:00:00.000Z";
5517
- var State = class _State {
5518
- dir;
5519
- constructor(dir) {
5520
- this.dir = dir;
5521
- mkdirSync7(dir, { recursive: true });
5522
- }
5523
- path(...parts) {
5524
- return join13(this.dir, ...parts);
5413
+ // src/contract.ts
5414
+ var DEFAULT_CONTRACT = {
5415
+ columns: {
5416
+ key: "issue_id",
5417
+ tag: "issue_tag",
5418
+ title: "title",
5419
+ status: "status",
5420
+ assignee: "assignee_id",
5421
+ priority: "priority",
5422
+ severity: "severity",
5423
+ slice: "project_id",
5424
+ repo: "repo_id",
5425
+ parent: "parent_id",
5426
+ blockedBy: "blocked_by",
5427
+ updatedAt: "updated_at",
5428
+ needsDesign: "needs_design",
5429
+ attachments: "attachments",
5430
+ workMode: "work_mode",
5431
+ reportType: "report_type",
5432
+ epic: "epic_id",
5433
+ author: "filed_by_id",
5434
+ ship: "ship_id",
5435
+ heldBy: "held_by_ship_id",
5436
+ resolutionNote: "resolution_note",
5437
+ verifiedSha: "verified_sha",
5438
+ prRef: "pr_ref",
5439
+ externalId: "external_id",
5440
+ position: "position",
5441
+ agentRuns: "agent_runs",
5442
+ tokensIn: "tokens_in",
5443
+ tokensOut: "tokens_out",
5444
+ cacheReadTokens: "cache_read_tokens",
5445
+ cacheWriteTokens: "cache_write_tokens",
5446
+ costUsd: "cost_usd"
5447
+ },
5448
+ comments: {
5449
+ parent: "ticket_id",
5450
+ body: "body",
5451
+ author: "team_member_id",
5452
+ createdAt: "created_at",
5453
+ ship: "ship_id",
5454
+ publicReply: "is_public_reply",
5455
+ authorName: "author_name"
5456
+ },
5457
+ statuses: {
5458
+ open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
5459
+ resolved: [
5460
+ "verified",
5461
+ "closed_deployed",
5462
+ "closed_wont_fix",
5463
+ "closed_duplicate",
5464
+ "closed_completed",
5465
+ "closed_cant_reproduce"
5466
+ ],
5467
+ approved: "accepted",
5468
+ parked: "blocked",
5469
+ building: "in_progress",
5470
+ handoff: "fixed",
5471
+ verifying: "qa",
5472
+ verified: "verified",
5473
+ deployed: "closed_deployed",
5474
+ needsHuman: "needs_info",
5475
+ reviewing: null,
5476
+ wontFix: "closed_wont_fix",
5477
+ draft: "draft"
5478
+ },
5479
+ epics: {
5480
+ statusColumn: "status",
5481
+ keyColumn: "epic_id",
5482
+ statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
5483
+ },
5484
+ priorityOrder: ["p0", "p1", "p2", "p3"],
5485
+ severityOrder: ["s1", "s2", "s3", "s4"],
5486
+ unknownPriorityRank: 2,
5487
+ coordinatingValue: "coordinating",
5488
+ manualWorkMode: "manual",
5489
+ hybridWorkMode: "hybrid",
5490
+ repoExemptReportTypes: ["coordinating", "question", "investigation"]
5491
+ };
5492
+ function closedStatuses(c) {
5493
+ return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
5494
+ }
5495
+ function resolveContract(override) {
5496
+ if (!override) return DEFAULT_CONTRACT;
5497
+ return {
5498
+ columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
5499
+ comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
5500
+ statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
5501
+ epics: {
5502
+ ...DEFAULT_CONTRACT.epics,
5503
+ ...override.epics,
5504
+ statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
5505
+ },
5506
+ priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
5507
+ severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
5508
+ unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
5509
+ coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
5510
+ manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
5511
+ hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
5512
+ // A workspace overriding `coordinatingValue` alone (renaming the rollup
5513
+ // marker) still gets that renamed value exempted here by default,
5514
+ // without also having to repeat it — only an explicit
5515
+ // `repoExemptReportTypes` overrides this derived default.
5516
+ repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
5517
+ };
5518
+ }
5519
+ function validateContract(c) {
5520
+ const problems = [];
5521
+ const open = new Set(c.statuses.open);
5522
+ const resolved = new Set(c.statuses.resolved);
5523
+ for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
5524
+ const v = c.statuses[role];
5525
+ if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
5525
5526
  }
5526
- /**
5527
- * The timestamp every "new since last poll" check is measured against.
5528
- *
5529
- * Read once per cycle and advanced only AFTER every role has been
5530
- * evaluated: bumping it inside the first role's check would hide that
5531
- * role's new comments from the second.
5532
- */
5533
- watermark() {
5534
- try {
5535
- const v = readFileSync12(this.path(".poll-watermark"), "utf8").trim();
5536
- return v || EPOCH;
5537
- } catch {
5538
- return EPOCH;
5539
- }
5527
+ if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
5528
+ problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
5540
5529
  }
5541
- setWatermark(at) {
5542
- writeFileSync8(this.path(".poll-watermark"), `${at}
5543
- `);
5530
+ if (!resolved.has(c.statuses.deployed)) {
5531
+ problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
5544
5532
  }
5545
- /**
5546
- * Advance to the newest thing this cycle actually saw, never to "now".
5547
- *
5548
- * Using the clock would silently skip any comment written between the fetch
5549
- * and the write — a race that loses work rather than repeating it, which is
5550
- * the wrong way round. Repeating a cycle is free; missing one is not.
5551
- */
5552
- advanceWatermark(seen) {
5553
- const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
5554
- const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
5555
- this.setWatermark(next);
5556
- return next;
5533
+ if (resolved.has(c.statuses.handoff)) {
5534
+ problems.push(
5535
+ `statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
5536
+ );
5557
5537
  }
5558
- /** Whole-crew pause, and the per-role sentinels one level down. */
5559
- isPaused() {
5560
- return existsSync12(this.path(".crew-paused"));
5538
+ if (!resolved.has(c.statuses.wontFix)) {
5539
+ problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
5561
5540
  }
5562
- isRolePaused(role) {
5563
- return existsSync12(this.path(`.role-paused-${role}`));
5541
+ if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
5542
+ problems.push(
5543
+ `statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
5544
+ );
5564
5545
  }
5565
- pausedRoles(roles) {
5566
- return new Set(roles.filter((r) => this.isRolePaused(r)));
5546
+ if (resolved.has(c.statuses.approved)) {
5547
+ problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
5567
5548
  }
5568
- pause(role) {
5569
- writeFileSync8(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
5549
+ if (c.statuses.parked === c.statuses.approved) {
5550
+ problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
5570
5551
  }
5571
- resume(role) {
5572
- rmSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
5552
+ const epicValues = Object.values(c.epics.statuses);
5553
+ if (new Set(epicValues).size !== epicValues.length) {
5554
+ problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
5573
5555
  }
5574
- /**
5575
- * The release phase (merge, deploy, release) has its own sentinel, one
5576
- * level below the whole-crew pause: `crew pause release` stops shipping
5577
- * while agents keep working.
5578
- */
5579
- isReleasePaused() {
5580
- return existsSync12(this.path(".release-paused"));
5556
+ if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
5557
+ if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
5558
+ for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
5559
+ if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
5581
5560
  }
5582
- setReleasePaused(paused) {
5583
- if (paused) writeFileSync8(this.path(".release-paused"), "");
5584
- else rmSync7(this.path(".release-paused"), { force: true });
5561
+ return problems;
5562
+ }
5563
+ var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5564
+ var PERISCOPE_COLUMNS = [
5565
+ "ticket_id",
5566
+ "ship_id",
5567
+ "url",
5568
+ "host",
5569
+ "worktree",
5570
+ "branch",
5571
+ "state",
5572
+ "isolated",
5573
+ "started_at",
5574
+ "last_activity_at",
5575
+ "expires_at"
5576
+ ];
5577
+
5578
+ // src/agent-log.ts
5579
+ import { existsSync as existsSync12, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
5580
+ import { join as join13 } from "path";
5581
+
5582
+ // src/pending-backoff.ts
5583
+ var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
5584
+
5585
+ // src/agent-log.ts
5586
+ var AgentLogUnreachableError = class extends Error {
5587
+ };
5588
+ var AgentLogHttpError = class extends Error {
5589
+ status;
5590
+ /** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
5591
+ row = false;
5592
+ constructor(message, status2) {
5593
+ super(message);
5594
+ this.status = status2;
5585
5595
  }
5586
- /**
5587
- * Why a release cycle must not run right now, or null when it may. Every
5588
- * release entry point checks this first — the release timer, `crew release`,
5589
- * `crew merge`, `crew deploy` and the inline release a `run` cycle used to
5590
- * do — so a paused ship cannot merge, deploy, or file release alerts.
5591
- * A release already in flight when the pause lands finishes; only new
5592
- * cycles are refused.
5593
- */
5594
- releaseSkipReason() {
5595
- if (this.isPaused()) return "the crew is paused";
5596
- if (this.isReleasePaused()) return "releases are paused";
5597
- return null;
5596
+ };
5597
+ async function request(target, method, path, body) {
5598
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
5599
+ const res = await fetch(url, {
5600
+ method,
5601
+ headers: {
5602
+ Authorization: `Bearer ${target.apiKey}`,
5603
+ "User-Agent": target.userAgent,
5604
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
5605
+ },
5606
+ body: body === void 0 ? void 0 : JSON.stringify(body)
5607
+ });
5608
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5609
+ return await res.json();
5610
+ }
5611
+ var issueColumns = /* @__PURE__ */ new Map();
5612
+ function columnsOf(target, modelId) {
5613
+ const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
5614
+ let p = issueColumns.get(key);
5615
+ if (!p) {
5616
+ p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
5617
+ p.catch(() => issueColumns.delete(key));
5618
+ issueColumns.set(key, p);
5598
5619
  }
5599
- /**
5600
- * Take an exclusive lock, or report who holds it.
5601
- *
5602
- * A timer fires every couple of minutes and an agent run takes minutes, so
5603
- * overlapping fires are certain rather than hypothetical — and two agents
5604
- * on one checkout would fight over the same worktree and the same tickets.
5605
- *
5606
- * A lock naming a DEAD pid is stale and taken over: a run killed mid-cycle
5607
- * (a reboot, a Ctrl-C) must not wedge the crew until someone notices.
5608
- */
5609
- /**
5610
- * Lock names become filenames, so anything that could be read as a path has
5611
- * to go. A scope like `route/repo` otherwise produced
5612
- * `.release-route/repo.lock` — a write into a directory that does not exist.
5613
- */
5614
- static safe(name) {
5615
- return name.replace(/[^A-Za-z0-9_-]/g, "_");
5620
+ return p;
5621
+ }
5622
+ async function findTicketRecord(target, t, ref) {
5623
+ const filters = {
5624
+ combinator: "OR",
5625
+ conditions: [
5626
+ { columnName: t.keyColumn, operator: "EQ", value: ref },
5627
+ { columnName: t.tagColumn, operator: "EQ", value: ref }
5628
+ ]
5629
+ };
5630
+ const rows = await request(
5631
+ target,
5632
+ "GET",
5633
+ `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
5634
+ );
5635
+ return rows.length === 1 ? rows[0].id : void 0;
5636
+ }
5637
+ function counterIncrements(run2, cols, have) {
5638
+ const figures = [
5639
+ [cols.agentRuns, 1],
5640
+ [cols.tokensIn, run2.tokensIn],
5641
+ [cols.tokensOut, run2.tokensOut],
5642
+ [cols.cacheReadTokens, run2.cacheReadTokens],
5643
+ [cols.cacheWriteTokens, run2.cacheWriteTokens],
5644
+ [cols.costUsd, run2.costUsd]
5645
+ ];
5646
+ const out = {};
5647
+ for (const [column, value2] of figures) {
5648
+ if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
5616
5649
  }
5617
- acquire(rawName) {
5618
- const name = _State.safe(rawName);
5619
- const file = this.path(`.${name}.lock`);
5650
+ return out;
5651
+ }
5652
+ async function post(target, path, body) {
5653
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
5654
+ const res = await fetch(url, {
5655
+ method: "POST",
5656
+ headers: {
5657
+ Authorization: `Bearer ${target.apiKey}`,
5658
+ // Cloudflare 403s default agents on this host — same header connect.ts sends.
5659
+ "User-Agent": target.userAgent,
5660
+ "Content-Type": "application/json"
5661
+ },
5662
+ body: JSON.stringify(body)
5663
+ });
5664
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5665
+ return await res.json();
5666
+ }
5667
+ async function reportAgentRun(target, run2) {
5668
+ let ticketRecordId;
5669
+ if (target.tickets && run2.ticketReference) {
5620
5670
  try {
5621
- const existing = Number.parseInt(readFileSync12(file, "utf8").trim(), 10);
5622
- if (Number.isFinite(existing) && existing > 0) {
5623
- try {
5624
- process.kill(existing, 0);
5625
- return { ok: false, heldBy: existing };
5626
- } catch {
5627
- }
5628
- }
5671
+ ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
5629
5672
  } catch {
5630
5673
  }
5631
- writeFileSync8(file, `${process.pid}
5632
- `);
5633
- return {
5634
- ok: true,
5635
- release: () => {
5636
- try {
5637
- rmSync7(file, { force: true });
5638
- } catch {
5639
- }
5640
- }
5641
- };
5642
5674
  }
5643
- /**
5644
- * Whether a release is running right now: any `.release-<scope>.lock` in the
5645
- * state directory naming a live process (the locks `releasePhase` takes per
5646
- * scope). A lock naming a dead pid is ignored, as `acquire` ignores it.
5647
- */
5648
- releaseInProgress() {
5649
- let names;
5650
- try {
5651
- names = readdirSync5(this.dir);
5652
- } catch {
5653
- return false;
5654
- }
5655
- for (const n of names) {
5656
- if (!/^\.release-.+\.lock$/.test(n)) continue;
5657
- let pid = 0;
5658
- try {
5659
- pid = Number.parseInt(readFileSync12(this.path(n), "utf8").trim(), 10);
5660
- } catch {
5661
- continue;
5662
- }
5663
- if (!Number.isFinite(pid) || pid <= 0) continue;
5664
- try {
5665
- process.kill(pid, 0);
5666
- return true;
5667
- } catch {
5668
- }
5675
+ const body = {
5676
+ agentId: target.agentId,
5677
+ ticketReference: run2.ticketReference,
5678
+ outcome: run2.outcome,
5679
+ startedAt: run2.startedAt,
5680
+ finishedAt: run2.finishedAt,
5681
+ source: "client",
5682
+ client: "crew",
5683
+ model: run2.model,
5684
+ tokensIn: run2.tokensIn,
5685
+ tokensOut: run2.tokensOut,
5686
+ cacheReadTokens: run2.cacheReadTokens,
5687
+ cacheWriteTokens: run2.cacheWriteTokens,
5688
+ costUsd: run2.costUsd,
5689
+ numTurns: run2.numTurns,
5690
+ prefixTokens: run2.prefixTokens,
5691
+ promptVersion: run2.promptVersion,
5692
+ promptSha: run2.promptSha
5693
+ };
5694
+ const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
5695
+ let entry;
5696
+ try {
5697
+ entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
5698
+ } catch (e) {
5699
+ if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
5700
+ if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
5701
+ if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) {
5702
+ e.row = true;
5703
+ throw e;
5669
5704
  }
5670
- return false;
5671
- }
5672
- /**
5673
- * Like `acquire`, but for a resource with `max` interchangeable slots
5674
- * rather than one exclusive holder — this machine's overall concurrency
5675
- * capacity (ISSUE-381), which nothing needs to name a particular slot to
5676
- * use. Takes whichever numbered slot is free first.
5677
- */
5678
- acquireN(rawPrefix, max) {
5679
- const heldBy = [];
5680
- for (let i = 1; i <= max; i++) {
5681
- const got = this.acquire(`${rawPrefix}-${i}`);
5682
- if (got.ok) return got;
5683
- heldBy.push(got.heldBy);
5705
+ try {
5706
+ entry = await post(target, "/agents/log", body);
5707
+ } catch (e2) {
5708
+ if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
5709
+ throw e2;
5684
5710
  }
5685
- return { ok: false, heldBy };
5686
5711
  }
5687
- /**
5688
- * The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
5689
- * checked together.
5690
- *
5691
- * Two sessions of one role must never take the same ticket. Before
5692
- * CREW-2821 that was guaranteed by an exclusive per-role lock; now the
5693
- * guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
5694
- * counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
5695
- * historical name `crew-role-<role>` (crew-macos reads it for the live pid),
5696
- * further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
5697
- * limit on how many runs of ANY role may overlap; a role that clears its own
5698
- * lock but finds every slot taken must give its role lock back, or a
5699
- * locked-but-not-running role would wedge itself out for the rest of the
5700
- * machine's uptime.
5701
- */
5702
- acquireRun(role, max, roleMax = 1) {
5703
- let roleLock;
5704
- const roleHolders = [];
5705
- for (let i = 1; i <= roleMax; i++) {
5706
- const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
5707
- if (got.ok) {
5708
- roleLock = got;
5709
- break;
5710
- }
5711
- roleHolders.push(got.heldBy);
5712
- }
5713
- if (!roleLock) {
5714
- return {
5715
- ok: false,
5716
- reason: roleMax === 1 ? `${role} is already running (pid ${roleHolders[0]})` : `${role} is already running ${roleMax} agents (its roleConcurrency cap; pid${roleHolders.length > 1 ? "s" : ""} ${[...new Set(roleHolders)].join(", ")})`
5717
- };
5718
- }
5719
- const slot = this.acquireN("crew-slot", max);
5720
- if (!slot.ok) {
5721
- roleLock.release();
5722
- return {
5723
- ok: false,
5724
- reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
5725
- };
5726
- }
5727
- return { ok: true, release: () => {
5728
- slot.release();
5729
- roleLock.release();
5730
- } };
5712
+ for (const cycle of run2.cycles) {
5713
+ await post(target, `/agents/log/${entry.id}/cycles`, {
5714
+ cycleIndex: cycle.cycleIndex,
5715
+ occurredAt: cycle.occurredAt,
5716
+ thinking: cycle.thinking,
5717
+ ...cycle.role ? { role: cycle.role } : {}
5718
+ });
5731
5719
  }
5732
- /**
5733
- * The per-ticket run lock (CREW-2821): held for the life of a run that works
5734
- * the ticket (`crew-ticket-<record id>`), so a second run of the same role
5735
- * on this ship walks past it instead of being handed the first run's
5736
- * ticket. Same stale-pid rule as every lock here.
5737
- */
5738
- acquireTicket(recordId) {
5739
- return this.acquire(`crew-ticket-${recordId}`);
5720
+ const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
5721
+ return {
5722
+ agentLogId: entry.id,
5723
+ ...counters?.warning ? { counterWarning: counters.warning } : {},
5724
+ ...counters?.retry ? { counterRetry: counters.retry } : {}
5725
+ };
5726
+ }
5727
+ var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
5728
+ async function writeCounters(target, w) {
5729
+ const have = await columnsOf(target, w.issuesModelId);
5730
+ const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
5731
+ if (Object.keys(body).length === 0) return;
5732
+ await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
5733
+ }
5734
+ async function addRunToTicket(target, t, recordId, run2) {
5735
+ const write = {
5736
+ issuesModelId: t.issuesModelId,
5737
+ recordId,
5738
+ ticketReference: run2.ticketReference,
5739
+ counters: t.counters,
5740
+ body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
5741
+ };
5742
+ try {
5743
+ await writeCounters(target, write);
5744
+ return {};
5745
+ } catch (e) {
5746
+ return {
5747
+ warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
5748
+ ...isRetryable(e) ? { retry: write } : {}
5749
+ };
5740
5750
  }
5741
- /** Whether a live run on this ship holds `recordId`'s ticket lock. */
5742
- ticketInFlight(recordId) {
5743
- const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
5751
+ }
5752
+ var queueDir = (stateDir) => join13(stateDir, "pending-agent-logs");
5753
+ function queueAgentRun(stateDir, name, q) {
5754
+ mkdirSync7(queueDir(stateDir), { recursive: true });
5755
+ writeFileSync8(join13(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
5756
+ }
5757
+ async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5758
+ const dir = queueDir(stateDir);
5759
+ if (!existsSync12(dir)) return 0;
5760
+ let sent = 0;
5761
+ for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
5762
+ const path = join13(dir, f);
5763
+ let q;
5744
5764
  try {
5745
- const pid = Number.parseInt(readFileSync12(file, "utf8").trim(), 10);
5746
- if (!Number.isFinite(pid) || pid <= 0) return false;
5747
- process.kill(pid, 0);
5748
- return true;
5765
+ q = JSON.parse(readFileSync12(path, "utf8"));
5749
5766
  } catch {
5750
- return false;
5767
+ continue;
5751
5768
  }
5752
- }
5753
- /**
5754
- * How many consecutive cycles the release phase has refused to deploy.
5755
- *
5756
- * Refusing is correct — a dirty tree or a non-main checkout must never be
5757
- * released — but it used to be SILENT, and eight commits once sat unreleased
5758
- * behind a single untracked file with nothing saying so (ISSUE-174). One
5759
- * blocked cycle is normal; the count is what makes a persistent one loud.
5760
- */
5761
- /**
5762
- * Release bookkeeping, scoped to one route.
5763
- *
5764
- * Scoped because a ship serves several boards and releases each repo
5765
- * independently (ISSUE-338). These were single global files, so a deploy
5766
- * failure on one route suppressed releases on every other one, and a
5767
- * release blocked in one repo counted cycles for all of them — a dirty tree
5768
- * in one checkout would have silently held up an unrelated project.
5769
- */
5770
- release(route) {
5771
- const suffix = _State.safe(route);
5772
- const blocked = `.release-blocked-${suffix}`;
5773
- const failed = `.deploy-failed-sha-${suffix}`;
5774
- const gateFailed = `.test-gate-failed-${suffix}`;
5775
- const gateReported = `.test-gate-reported-${suffix}`;
5776
- const heldReported = `.release-held-${suffix}`;
5777
- const read = (f) => {
5778
- try {
5779
- return readFileSync12(this.path(f), "utf8").trim() || null;
5780
- } catch {
5781
- return null;
5782
- }
5783
- };
5784
- return {
5785
- blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
5786
- noteBlocked: () => {
5787
- const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
5788
- writeFileSync8(this.path(blocked), `${n}
5789
- `);
5790
- return n;
5791
- },
5792
- clearBlocked: () => {
5793
- rmSync7(this.path(blocked), { force: true });
5794
- },
5795
- /**
5796
- * The commit a deploy last failed on.
5797
- *
5798
- * Without this the crew retries a broken deploy on every cycle — every
5799
- * two minutes, indefinitely — hammering the target and burying the
5800
- * original failure under identical ones. A failed commit is retried
5801
- * only when a NEW commit lands, or when an operator forces it by hand.
5802
- */
5803
- deployFailedSha: () => read(failed),
5804
- noteDeployFailed: (sha) => {
5805
- writeFileSync8(this.path(failed), `${sha}
5806
- `);
5807
- },
5808
- clearDeployFailed: () => {
5809
- rmSync7(this.path(failed), { force: true });
5810
- },
5811
- /**
5812
- * Consecutive test-gate failures on the same head (CREW-1368). A failure
5813
- * on a new head restarts the count. Held on disk because one process is
5814
- * one cycle, and "failed three cycles running" only exists across them.
5815
- */
5816
- noteTestGateFailed: (sha) => {
5817
- const [prev, n] = (read(gateFailed) ?? "").split(" ");
5818
- const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
5819
- writeFileSync8(this.path(gateFailed), `${sha} ${next}
5820
- `);
5821
- return next;
5822
- },
5823
- clearTestGateFailed: () => {
5824
- rmSync7(this.path(gateFailed), { force: true });
5825
- },
5826
- /** Whether the board was already told about this head's red gate. */
5827
- heldReported: () => {
5828
- try {
5829
- return readFileSync12(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
5830
- } catch {
5831
- return null;
5832
- }
5833
- },
5834
- noteHeldReported: (signature) => {
5835
- writeFileSync8(this.path(heldReported), `${signature}
5836
- `);
5837
- },
5838
- clearHeldReported: () => {
5839
- rmSync7(this.path(heldReported), { force: true });
5840
- },
5841
- testGateReported: (sha) => read(gateReported) === sha,
5842
- noteTestGateReported: (sha) => {
5843
- writeFileSync8(this.path(gateReported), `${sha}
5844
- `);
5769
+ if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5770
+ try {
5771
+ const apiKey = await keyFor(q.role);
5772
+ if (!apiKey) continue;
5773
+ const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
5774
+ if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
5775
+ if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
5776
+ version: 1,
5777
+ route: q.route,
5778
+ role: q.role,
5779
+ target: q.target,
5780
+ write: sentRun.counterRetry,
5781
+ attempts: 0,
5782
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
5783
+ });
5784
+ rmSync7(path, { force: true });
5785
+ sent += 1;
5786
+ log.emit(`sent a queued Agent Log report (${f})`);
5787
+ } catch (e) {
5788
+ if (!(e instanceof AgentLogUnreachableError)) {
5789
+ rmSync7(path, { force: true });
5790
+ log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
5791
+ continue;
5845
5792
  }
5846
- };
5793
+ const attempts = q.attempts + 1;
5794
+ writeFileSync8(path, JSON.stringify({
5795
+ ...q,
5796
+ attempts,
5797
+ warned: true,
5798
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5799
+ }));
5800
+ if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
5801
+ }
5847
5802
  }
5848
- /**
5849
- * Starvation bookkeeping, scoped to one route (ISSUE-382).
5850
- *
5851
- * `decideFleet` already renders the winner and each role's rank; what it
5852
- * throws away is the other side — a route that had work and did not
5853
- * win, and the same ticket sitting at the front of that route's queue
5854
- * cycle after cycle. This is state, not memory: a single process is one
5855
- * cycle, so "how long has this been waiting" only exists if it survives
5856
- * between them.
5857
- */
5858
- fairness(route) {
5859
- const suffix = _State.safe(route);
5860
- const streakFile = `.fairness-streak-${suffix}`;
5861
- const ticketFile = `.fairness-ticket-${suffix}`;
5862
- const sinceFile = `.fairness-since-${suffix}`;
5863
- const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
5864
- const read = (f) => {
5865
- try {
5866
- return readFileSync12(this.path(f), "utf8").trim() || null;
5867
- } catch {
5868
- return null;
5869
- }
5870
- };
5871
- const clear2 = () => {
5872
- for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync7(this.path(f), { force: true });
5873
- };
5874
- return {
5875
- /** Consecutive cycles this route had actionable work and did not win. */
5876
- streak: () => Number.parseInt(read(streakFile) ?? "", 10) || 0,
5877
- /**
5878
- * The ticket sitting at the front of this route's queue, how long
5879
- * it has been there and its own consecutive-passed-over count — or
5880
- * undefined if nothing is waiting.
5881
- */
5882
- waiting: () => {
5883
- const ticket = read(ticketFile);
5884
- const since2 = read(sinceFile);
5885
- if (!ticket || !since2) return void 0;
5886
- return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
5887
- },
5888
- /**
5889
- * Record what one cycle saw for this route: how many tickets were
5890
- * actionable, which one was most urgent, and whether this route
5891
- * won. Winning or running dry both reset the streak — there is nothing
5892
- * left being passed over.
5893
- */
5894
- record: (actionable, topTicket, won) => {
5895
- if (won || actionable === 0) {
5896
- clear2();
5897
- return;
5898
- }
5899
- writeFileSync8(this.path(streakFile), `${this.readInt(streakFile) + 1}
5900
- `);
5901
- const prior = read(ticketFile);
5902
- if (topTicket && topTicket === prior) {
5903
- writeFileSync8(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
5904
- `);
5905
- } else if (topTicket) {
5906
- writeFileSync8(this.path(ticketFile), `${topTicket}
5907
- `);
5908
- writeFileSync8(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
5909
- `);
5910
- writeFileSync8(this.path(ticketStreakFile), "1\n");
5911
- }
5803
+ return sent;
5804
+ }
5805
+ var countersDir = (stateDir) => join13(stateDir, "pending-ticket-counters");
5806
+ function queueTicketCounters(stateDir, name, q) {
5807
+ mkdirSync7(countersDir(stateDir), { recursive: true });
5808
+ writeFileSync8(join13(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
5809
+ }
5810
+ async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5811
+ const dir = countersDir(stateDir);
5812
+ if (!existsSync12(dir)) return 0;
5813
+ let sent = 0;
5814
+ for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
5815
+ const path = join13(dir, f);
5816
+ let q;
5817
+ try {
5818
+ q = JSON.parse(readFileSync12(path, "utf8"));
5819
+ } catch {
5820
+ continue;
5821
+ }
5822
+ if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5823
+ try {
5824
+ const apiKey = await keyFor(q.role);
5825
+ if (!apiKey) continue;
5826
+ await writeCounters({ ...q.target, apiKey }, q.write);
5827
+ rmSync7(path, { force: true });
5828
+ sent += 1;
5829
+ log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
5830
+ } catch (e) {
5831
+ if (!isRetryable(e)) {
5832
+ rmSync7(path, { force: true });
5833
+ log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
5834
+ continue;
5912
5835
  }
5913
- };
5836
+ const attempts = q.attempts + 1;
5837
+ writeFileSync8(path, JSON.stringify({
5838
+ ...q,
5839
+ attempts,
5840
+ warned: true,
5841
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5842
+ }));
5843
+ if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
5844
+ }
5914
5845
  }
5915
- /**
5916
- * The previous cycle's "needs a person" set, scoped to one route
5917
- * (ISSUE-928) — `{ ticketId: [reason, ...] }`. `poll.ts` reads it once
5918
- * per cycle to compute transitions and the caller (`cli.ts`'s run path,
5919
- * `fleet.ts`'s per-route loop) persists the new set afterward, beside the
5920
- * existing `fairness(route).record(...)` call — never inside `decideCycle`
5921
- * itself, which does no writes. A corrupt or missing file reads as empty,
5922
- * which is also deliberately what a fresh install sees: everything
5923
- * currently needing a person emits once, rather than being silently
5924
- * skipped as though already notified.
5846
+ return sent;
5847
+ }
5848
+
5849
+ // src/ticket-attribution.ts
5850
+ var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
5851
+ function ticketMentions(text) {
5852
+ return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
5853
+ }
5854
+ var TicketAttributionTracker = class {
5855
+ #tokensByTicket = /* @__PURE__ */ new Map();
5856
+ #turns = 0;
5857
+ #current;
5858
+ /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
5859
+ #keyByRecord = /* @__PURE__ */ new Map();
5860
+ #lastBy = {};
5861
+ /**
5862
+ * The ticket key most recently named in the transcript, or undefined before any.
5863
+ * Only the FIRST key in a turn counts: a closing summary names its own
5864
+ * ticket first and then lists others ("Other tickets", "Blocked by", "See
5865
+ * also"), and those trailing keys are not what the run worked (CREW-2598).
5925
5866
  */
5926
- attention(route) {
5927
- const file = `.attention-${_State.safe(route)}.json`;
5928
- return {
5929
- previous: () => {
5930
- try {
5931
- const parsed = JSON.parse(readFileSync12(this.path(file), "utf8"));
5932
- return parsed && typeof parsed === "object" ? parsed : {};
5933
- } catch {
5934
- return {};
5935
- }
5936
- },
5937
- persist: (next) => {
5938
- writeFileSync8(this.path(file), JSON.stringify(next));
5939
- }
5940
- };
5867
+ get current() {
5868
+ return this.#current;
5869
+ }
5870
+ /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
5871
+ note(text) {
5872
+ const mentions = ticketMentions(text);
5873
+ if (mentions.length > 0) this.#current = mentions[0];
5874
+ }
5875
+ /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
5876
+ add(text, tokens) {
5877
+ const mentions = ticketMentions(text);
5878
+ if (mentions.length > 0) this.#current = mentions[0];
5879
+ if (this.#current === void 0 || tokens <= 0) return;
5880
+ this.#turns++;
5881
+ this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
5941
5882
  }
5942
5883
  /**
5943
- * The previous cycle's unroutable ticket ids for one route (CREW-2548): open
5944
- * tickets with no repository that are not exempt. `poll.ts` diffs against it
5945
- * so each ticket warns once when it becomes unroutable, not every cycle; the
5946
- * caller persists the new set beside `attention(route).persist(...)`. A
5947
- * missing or corrupt file reads as empty, so a fresh install warns once.
5884
+ * Record a tool call if it is a ticket-targeted tracker write (claim,
5885
+ * hand-off or comment). The ticket key comes from a record uuid seen on an
5886
+ * earlier write when known, else from the key last named in the transcript
5887
+ * (a claim or hand-off announces its ticket). A comment on a record never
5888
+ * seen before is ignored: its uuid cannot be resolved to a key.
5948
5889
  */
5949
- unroutable(route) {
5950
- const file = `.unroutable-${_State.safe(route)}.json`;
5951
- return {
5952
- previous: () => {
5953
- try {
5954
- const parsed = JSON.parse(readFileSync12(this.path(file), "utf8"));
5955
- return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
5956
- } catch {
5957
- return [];
5958
- }
5959
- },
5960
- persist: (next) => {
5961
- writeFileSync8(this.path(file), JSON.stringify(next));
5962
- }
5963
- };
5890
+ noteWrite(tool, input) {
5891
+ const w = classifyWrite(tool, input);
5892
+ if (!w) return;
5893
+ let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
5894
+ if (w.kind !== "comment") key ??= this.#current;
5895
+ if (!key) return;
5896
+ if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
5897
+ this.#lastBy[w.kind] = key;
5964
5898
  }
5965
5899
  /**
5966
- * Open ship-level attention items (CREW-1373), ship-wide rather than
5967
- * per-route: a parked role or a missing hook command is about this
5968
- * machine. A corrupt or missing file reads as empty.
5900
+ * How the attribution is decided: a hand-off write beats a claim, a claim
5901
+ * beats a comment (so a follow-up ticket filed or commented on while
5902
+ * working never displaces the claimed one), and any write beats everything
5903
+ * below. Next, `hintMoved` — the post-run read-back showed the hinted
5904
+ * ticket changed status or assignee during the run — is a hand-off signal
5905
+ * for a run that wrote through MCP tools crew cannot see. Then the
5906
+ * mention-token heuristic, which with a hint may override it only when a
5907
+ * different ticket holds a strict majority of the credited tokens (a single
5908
+ * credited turn, like a lone end-of-run summary naming another ticket,
5909
+ * must not). A run with no hint
5910
+ * keeps the plain winner. The pre-run hint is last.
5969
5911
  */
5970
- shipAttention() {
5971
- const file = ".ship-attention.json";
5972
- return {
5973
- previous: () => {
5974
- try {
5975
- const parsed = JSON.parse(readFileSync12(this.path(file), "utf8"));
5976
- return Array.isArray(parsed) ? parsed : [];
5977
- } catch {
5978
- return [];
5979
- }
5980
- },
5981
- persist: (next) => {
5982
- writeFileSync8(this.path(file), JSON.stringify(next));
5983
- }
5984
- };
5912
+ decide(hint, opts = {}) {
5913
+ for (const by of ["handoff", "claim", "comment"]) {
5914
+ const ticket = this.#lastBy[by];
5915
+ if (ticket) return { ticket, by };
5916
+ }
5917
+ if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
5918
+ if (this.#tokensByTicket.size > 0) {
5919
+ if (!hint) return { ticket: this.winner(), by: "mentions" };
5920
+ const winner = this.winner(hint);
5921
+ if (winner === hint) return { ticket: hint, by: "mentions" };
5922
+ let total = 0;
5923
+ for (const n of this.#tokensByTicket.values()) total += n;
5924
+ const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
5925
+ return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
5926
+ }
5927
+ return { ticket: hint, by: "hint" };
5985
5928
  }
5986
5929
  /**
5987
- * When the release phase last ran a cycle (CREW-1373), as the mtime of a
5988
- * marker file. `touch()` is called at the start and the end of every cycle
5989
- * so a long deploy does not read as a stopped timer.
5930
+ * The ticket key the run spent the most tokens on, or `fallback` (the
5931
+ * pre-run poll hint) when the transcript never named one — an
5932
+ * administrative run that touches the tracker without discussing a
5933
+ * specific ticket in its visible text still has to cite something.
5990
5934
  */
5991
- releaseHeartbeat() {
5992
- const file = ".release-last-run";
5993
- return {
5994
- touch: () => {
5995
- writeFileSync8(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
5996
- },
5997
- at: () => {
5998
- try {
5999
- return statSync4(this.path(file)).mtimeMs;
6000
- } catch {
6001
- return null;
6002
- }
5935
+ winner(fallback) {
5936
+ let best = fallback;
5937
+ let bestTokens = -1;
5938
+ for (const [ticket, tokens] of this.#tokensByTicket) {
5939
+ if (tokens > bestTokens) {
5940
+ best = ticket;
5941
+ bestTokens = tokens;
6003
5942
  }
6004
- };
5943
+ }
5944
+ return best;
6005
5945
  }
6006
- readInt(file) {
5946
+ };
5947
+ function isClaimWrite(tool, input) {
5948
+ if (!tool || !/records_controller_update$/.test(tool)) return false;
5949
+ const body = input?.body;
5950
+ return typeof body === "object" && body !== null && body.status === "in_progress";
5951
+ }
5952
+ var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
5953
+ function classifyWrite(tool, input) {
5954
+ if (!tool) return void 0;
5955
+ const body = input?.body;
5956
+ if (typeof body !== "object" || body === null) return void 0;
5957
+ const b = body;
5958
+ if (/records_controller_update$/.test(tool)) {
5959
+ const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
5960
+ if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
5961
+ if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
5962
+ }
5963
+ if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
5964
+ return void 0;
5965
+ }
5966
+
5967
+ // src/handoff-transcript.ts
5968
+ var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
5969
+ function resultText(content) {
5970
+ if (typeof content === "string") return content;
5971
+ if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
5972
+ return "";
5973
+ }
5974
+ function isTransportFailure(isError, text) {
5975
+ if (!isError) {
6007
5976
  try {
6008
- return Number.parseInt(readFileSync12(this.path(file), "utf8").trim(), 10) || 0;
5977
+ const doc = JSON.parse(text);
5978
+ if (doc && typeof doc === "object") {
5979
+ const code = doc.statusCode;
5980
+ return typeof code === "number" && code >= 500;
5981
+ }
6009
5982
  } catch {
6010
- return 0;
6011
5983
  }
6012
5984
  }
5985
+ return TRANSPORT_FAILURE.test(text);
5986
+ }
5987
+ var HandoffTranscriptTracker = class {
5988
+ open = /* @__PURE__ */ new Map();
5989
+ /** Per ticket record: the last hand-off status write's outcome. */
5990
+ status = /* @__PURE__ */ new Map();
5991
+ /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
5992
+ comment = /* @__PURE__ */ new Map();
5993
+ landed = /* @__PURE__ */ new Set();
5994
+ lastText = "";
5995
+ resultLine = "";
5996
+ feed(parsed) {
5997
+ const line = parsed;
5998
+ if (!line || typeof line !== "object") return;
5999
+ if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
6000
+ const content = line.message?.content;
6001
+ if (!Array.isArray(content)) return;
6002
+ for (const b of content) {
6003
+ if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
6004
+ else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
6005
+ const w = classifyWrite(b.name, b.input);
6006
+ const body = b.input?.body;
6007
+ if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
6008
+ this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
6009
+ }
6010
+ } else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
6011
+ const w = this.open.get(b.tool_use_id);
6012
+ if (!w) continue;
6013
+ this.open.delete(b.tool_use_id);
6014
+ const failed = isTransportFailure(b.is_error === true, resultText(b.content));
6015
+ if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
6016
+ else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
6017
+ else if (!failed) {
6018
+ this.comment.delete(w.recordId);
6019
+ this.landed.add(w.recordId);
6020
+ }
6021
+ }
6022
+ }
6023
+ }
6024
+ /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
6025
+ pending() {
6026
+ const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
6027
+ if (!failed) return null;
6028
+ const [ticketRecordId, s] = failed;
6029
+ return {
6030
+ ticketRecordId,
6031
+ patch: s.patch,
6032
+ ...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
6033
+ ...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
6034
+ finalText: this.lastText || this.resultLine
6035
+ };
6036
+ }
6013
6037
  };
6014
6038
 
6039
+ // src/pending-handoff.ts
6040
+ import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
6041
+ import { join as join16 } from "path";
6042
+
6043
+ // src/ticket-branch.ts
6044
+ import { basename as basename6 } from "path";
6045
+
6046
+ // src/tracker.ts
6047
+ import { hostname } from "os";
6048
+ import { TablationClient } from "@tablation/client";
6049
+
6015
6050
  // src/work-mode.ts
6016
6051
  function projectWorkModes(rows, contract) {
6017
6052
  const out = /* @__PURE__ */ new Map();
@@ -6787,6 +6822,19 @@ var Tracker = class {
6787
6822
  async hasPublicReplyColumn() {
6788
6823
  return (await this.authorshipProbe()).commentPublicReply;
6789
6824
  }
6825
+ /**
6826
+ * Whether this workspace has the optional Periscopes table (EPIC-077), and
6827
+ * which of the columns the crew writes (`PERISCOPE_COLUMNS`) it lacks.
6828
+ * `present: false` is a legitimate shape, never a fault. A table whose
6829
+ * metadata cannot be read reports every column missing, as the authorship
6830
+ * probe reads an unreadable table as "column absent".
6831
+ */
6832
+ async periscopesStatus() {
6833
+ const modelId = this.route.resolved?.periscopesModelId;
6834
+ if (!modelId) return { present: false, missing: [] };
6835
+ const have = await this.columnNames(modelId);
6836
+ return { present: true, modelId, missing: PERISCOPE_COLUMNS.filter((c) => !have.has(c)) };
6837
+ }
6790
6838
  /**
6791
6839
  * The authorship columns this workspace is missing, as `table.column` names
6792
6840
  * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
@@ -8907,6 +8955,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
8907
8955
  };
8908
8956
  try {
8909
8957
  const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
8958
+ try {
8959
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
8960
+ } catch {
8961
+ }
8910
8962
  if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
8911
8963
  if (sent.counterRetry) {
8912
8964
  try {
@@ -8939,6 +8991,15 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
8939
8991
  } catch {
8940
8992
  }
8941
8993
  }
8994
+ if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
8995
+ try {
8996
+ await raiseShipAttention(
8997
+ { state: new State(stateDirOfStream(id.streamPath)), emit },
8998
+ agentLogRefusedItem(id.route, id.role, e.status)
8999
+ );
9000
+ } catch {
9001
+ }
9002
+ }
8942
9003
  emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
8943
9004
  }
8944
9005
  }
@@ -13687,6 +13748,7 @@ function forbiddenDetail(e, route) {
13687
13748
  [r?.shipsModelId, "Ships"],
13688
13749
  [r?.locksModelId, "Locks"],
13689
13750
  [r?.epicsModelId, "Epics"],
13751
+ [r?.periscopesModelId, "Periscopes"],
13690
13752
  [r?.reposModelId, "Repos"]
13691
13753
  ];
13692
13754
  const table2 = modelId ? known.find(([id]) => id && id === modelId)?.[1] ?? `table ${modelId}` : void 0;
@@ -14531,6 +14593,7 @@ async function discover(o) {
14531
14593
  out.reposModelId = find("Repos");
14532
14594
  out.shipsModelId = find("Ships");
14533
14595
  out.epicsModelId = find("Epics");
14596
+ out.periscopesModelId = find("Periscopes");
14534
14597
  out.locksModelId = find("Locks");
14535
14598
  try {
14536
14599
  const views = await get(o, `/views?workspaceId=${ws.id}`);
@@ -14582,6 +14645,7 @@ async function discover(o) {
14582
14645
  shipColumns = new Set((shipsModel.fields ?? []).map((f) => f.columnName));
14583
14646
  } catch {
14584
14647
  }
14648
+ const hostPeriscopes = shipColumns.has("host_periscopes") ? o.ship.hostPeriscopes : void 0;
14585
14649
  let ownerId;
14586
14650
  if (out.meId) {
14587
14651
  if (shipColumns.has("owner_id")) ownerId = out.meId;
@@ -14624,6 +14688,7 @@ async function discover(o) {
14624
14688
  }
14625
14689
  if (writeMachineId) patchBody.machine_id = machineId;
14626
14690
  if ((mine[0].host_passengers ?? false) !== hostPassengers) patchBody.host_passengers = hostPassengers;
14691
+ if (hostPeriscopes !== void 0 && (mine[0].host_periscopes ?? false) !== hostPeriscopes) patchBody.host_periscopes = hostPeriscopes;
14627
14692
  if (sshPublicKey !== void 0 && (mine[0].ssh_public_key ?? "") !== sshPublicKey) {
14628
14693
  patchBody.ssh_public_key = sshPublicKey;
14629
14694
  }
@@ -14631,6 +14696,7 @@ async function discover(o) {
14631
14696
  if (Object.keys(patchBody).length > 0) {
14632
14697
  await patch(o, `/data-models/${out.shipsModelId}/records/${shipRowId}`, patchBody);
14633
14698
  if ("host_passengers" in patchBody) provisioning.push(`synced host_passengers=${hostPassengers} onto Ships row "${o.ship.name}"`);
14699
+ if ("host_periscopes" in patchBody) provisioning.push(`synced host_periscopes=${hostPeriscopes} onto Ships row "${o.ship.name}"`);
14634
14700
  if ("ssh_public_key" in patchBody) provisioning.push(`synced ssh_public_key onto Ships row "${o.ship.name}"`);
14635
14701
  if ("owner_id" in patchBody) provisioning.push(`recorded owner on Ships row "${o.ship.name}"`);
14636
14702
  }
@@ -14639,6 +14705,7 @@ async function discover(o) {
14639
14705
  name: o.ship.name,
14640
14706
  ...o.ship.platform ? { platform: o.ship.platform } : {},
14641
14707
  host_passengers: hostPassengers,
14708
+ ...hostPeriscopes !== void 0 ? { host_periscopes: hostPeriscopes } : {},
14642
14709
  ...sshPublicKey !== void 0 ? { ssh_public_key: sshPublicKey } : {},
14643
14710
  ...ownerId !== void 0 ? { owner_id: ownerId } : {},
14644
14711
  ...writeMachineId ? { machine_id: machineId } : {}
@@ -20529,6 +20596,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20529
20596
  areaId: found.areaId,
20530
20597
  shipsModelId: found.shipsModelId,
20531
20598
  epicsModelId: found.epicsModelId,
20599
+ periscopesModelId: found.periscopesModelId,
20532
20600
  locksModelId: found.locksModelId,
20533
20601
  recordLinkViewId: found.recordLinkViewId,
20534
20602
  reposModelId: found.reposModelId,
@@ -22011,6 +22079,12 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
22011
22079
  process.stdout.write(
22012
22080
  await tracker.hasPublicReplyColumn() ? `public replies: Comments.${tracker.contract.comments.publicReply} present \u2014 public reporters are labelled in the digest
22013
22081
  ` : `public replies: Comments.${tracker.contract.comments.publicReply} absent \u2014 no public intake form, nothing to guard
22082
+ `
22083
+ );
22084
+ const periscopes = await tracker.periscopesStatus();
22085
+ process.stdout.write(
22086
+ !periscopes.present ? "periscopes: Periscopes table absent \u2014 this workspace cannot host periscopes\n" : periscopes.missing.length === 0 ? `periscopes: Periscopes table present (${periscopes.modelId})
22087
+ ` : `periscopes: Periscopes table present (${periscopes.modelId}) but missing ${periscopes.missing.join(", ")} \u2014 update the Issues template
22014
22088
  `
22015
22089
  );
22016
22090
  process.stdout.write(