@tablation/crew 0.81.0 → 0.82.1

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 +1677 -1567
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -4215,6 +4215,15 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
4215
4215
  if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
4216
4216
  return null;
4217
4217
  }
4218
+ var agentLogRefusedKey = (route) => `agent_log_refused:${route}`;
4219
+ function agentLogRefusedItem(route, role, status2, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
4220
+ return {
4221
+ kind: "agent_log_refused",
4222
+ key: agentLogRefusedKey(route),
4223
+ since: nowIso,
4224
+ 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`
4225
+ };
4226
+ }
4218
4227
  var diskLowKey = (repo) => `disk_low:${repo}`;
4219
4228
  var LOW_DISK_BYTES = 2 * 1024 ** 3;
4220
4229
  var gib = (bytes) => (bytes / 1024 ** 3).toFixed(1);
@@ -4587,1431 +4596,1441 @@ import { finished } from "stream/promises";
4587
4596
  import { join as join19, dirname as dirname7, basename as basename7 } from "path";
4588
4597
  import { createHash as createHash3 } from "crypto";
4589
4598
 
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";
4599
+ // src/state.ts
4600
+ import { readFileSync as readFileSync11, writeFileSync as writeFileSync7, existsSync as existsSync11, mkdirSync as mkdirSync6, rmSync as rmSync6, statSync as statSync4, readdirSync as readdirSync4 } from "fs";
4601
+ import { join as join12 } from "path";
4602
+ var EPOCH = "1970-01-01T00:00:00.000Z";
4603
+ var State = class _State {
4604
+ dir;
4605
+ constructor(dir) {
4606
+ this.dir = dir;
4607
+ mkdirSync6(dir, { recursive: true });
4614
4608
  }
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
- );
4609
+ path(...parts) {
4610
+ return join12(this.dir, ...parts);
4678
4611
  }
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
- );
4612
+ /**
4613
+ * The timestamp every "new since last poll" check is measured against.
4614
+ *
4615
+ * Read once per cycle and advanced only AFTER every role has been
4616
+ * evaluated: bumping it inside the first role's check would hide that
4617
+ * role's new comments from the second.
4618
+ */
4619
+ watermark() {
4620
+ try {
4621
+ const v = readFileSync11(this.path(".poll-watermark"), "utf8").trim();
4622
+ return v || EPOCH;
4623
+ } catch {
4624
+ return EPOCH;
4625
+ }
4688
4626
  }
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
- );
4627
+ setWatermark(at) {
4628
+ writeFileSync7(this.path(".poll-watermark"), `${at}
4629
+ `);
4699
4630
  }
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
- );
4631
+ /**
4632
+ * Advance to the newest thing this cycle actually saw, never to "now".
4633
+ *
4634
+ * Using the clock would silently skip any comment written between the fetch
4635
+ * and the write — a race that loses work rather than repeating it, which is
4636
+ * the wrong way round. Repeating a cycle is free; missing one is not.
4637
+ */
4638
+ advanceWatermark(seen) {
4639
+ const stamps = seen.flatMap((s) => [s.created_at, s.updated_at]).filter((v) => typeof v === "string" && v.length > 0);
4640
+ const next = stamps.length ? stamps.reduce((a, b) => a > b ? a : b) : this.watermark();
4641
+ this.setWatermark(next);
4642
+ return next;
4736
4643
  }
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
- }
4644
+ /** Whole-crew pause, and the per-role sentinels one level down. */
4645
+ isPaused() {
4646
+ return existsSync11(this.path(".crew-paused"));
4785
4647
  }
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
- );
4648
+ isRolePaused(role) {
4649
+ return existsSync11(this.path(`.role-paused-${role}`));
4794
4650
  }
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
- );
4651
+ pausedRoles(roles) {
4652
+ return new Set(roles.filter((r) => this.isRolePaused(r)));
4806
4653
  }
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`);
5012
- }
5013
- if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
5014
- problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
5015
- }
5016
- if (!resolved.has(c.statuses.deployed)) {
5017
- problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
4654
+ pause(role) {
4655
+ writeFileSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
5018
4656
  }
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
- );
4657
+ resume(role) {
4658
+ rmSync6(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
5023
4659
  }
5024
- if (!resolved.has(c.statuses.wontFix)) {
5025
- problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
4660
+ /**
4661
+ * The release phase (merge, deploy, release) has its own sentinel, one
4662
+ * level below the whole-crew pause: `crew pause release` stops shipping
4663
+ * while agents keep working.
4664
+ */
4665
+ isReleasePaused() {
4666
+ return existsSync11(this.path(".release-paused"));
5026
4667
  }
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
- );
4668
+ setReleasePaused(paused) {
4669
+ if (paused) writeFileSync7(this.path(".release-paused"), "");
4670
+ else rmSync6(this.path(".release-paused"), { force: true });
5031
4671
  }
5032
- if (resolved.has(c.statuses.approved)) {
5033
- problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
4672
+ /**
4673
+ * Why a release cycle must not run right now, or null when it may. Every
4674
+ * release entry point checks this first — the release timer, `crew release`,
4675
+ * `crew merge`, `crew deploy` and the inline release a `run` cycle used to
4676
+ * do — so a paused ship cannot merge, deploy, or file release alerts.
4677
+ * A release already in flight when the pause lands finishes; only new
4678
+ * cycles are refused.
4679
+ */
4680
+ releaseSkipReason() {
4681
+ if (this.isPaused()) return "the crew is paused";
4682
+ if (this.isReleasePaused()) return "releases are paused";
4683
+ return null;
5034
4684
  }
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");
4685
+ /**
4686
+ * Take an exclusive lock, or report who holds it.
4687
+ *
4688
+ * A timer fires every couple of minutes and an agent run takes minutes, so
4689
+ * overlapping fires are certain rather than hypothetical — and two agents
4690
+ * on one checkout would fight over the same worktree and the same tickets.
4691
+ *
4692
+ * A lock naming a DEAD pid is stale and taken over: a run killed mid-cycle
4693
+ * (a reboot, a Ctrl-C) must not wedge the crew until someone notices.
4694
+ */
4695
+ /**
4696
+ * Lock names become filenames, so anything that could be read as a path has
4697
+ * to go. A scope like `route/repo` otherwise produced
4698
+ * `.release-route/repo.lock` — a write into a directory that does not exist.
4699
+ */
4700
+ static safe(name) {
4701
+ return name.replace(/[^A-Za-z0-9_-]/g, "_");
5037
4702
  }
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");
4703
+ acquire(rawName) {
4704
+ const name = _State.safe(rawName);
4705
+ const file = this.path(`.${name}.lock`);
4706
+ try {
4707
+ const existing = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
4708
+ if (Number.isFinite(existing) && existing > 0) {
4709
+ try {
4710
+ process.kill(existing, 0);
4711
+ return { ok: false, heldBy: existing };
4712
+ } catch {
4713
+ }
4714
+ }
4715
+ } catch {
4716
+ }
4717
+ writeFileSync7(file, `${process.pid}
4718
+ `);
4719
+ return {
4720
+ ok: true,
4721
+ release: () => {
4722
+ try {
4723
+ rmSync6(file, { force: true });
4724
+ } catch {
4725
+ }
4726
+ }
4727
+ };
5041
4728
  }
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`);
4729
+ /**
4730
+ * Whether a release is running right now: any `.release-<scope>.lock` in the
4731
+ * state directory naming a live process (the locks `releasePhase` takes per
4732
+ * scope). A lock naming a dead pid is ignored, as `acquire` ignores it.
4733
+ */
4734
+ releaseInProgress() {
4735
+ let names;
4736
+ try {
4737
+ names = readdirSync4(this.dir);
4738
+ } catch {
4739
+ return false;
4740
+ }
4741
+ for (const n of names) {
4742
+ if (!/^\.release-.+\.lock$/.test(n)) continue;
4743
+ let pid = 0;
4744
+ try {
4745
+ pid = Number.parseInt(readFileSync11(this.path(n), "utf8").trim(), 10);
4746
+ } catch {
4747
+ continue;
4748
+ }
4749
+ if (!Number.isFinite(pid) || pid <= 0) continue;
4750
+ try {
4751
+ process.kill(pid, 0);
4752
+ return true;
4753
+ } catch {
4754
+ }
4755
+ }
4756
+ return false;
5046
4757
  }
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;
4758
+ /**
4759
+ * Like `acquire`, but for a resource with `max` interchangeable slots
4760
+ * rather than one exclusive holder — this machine's overall concurrency
4761
+ * capacity (ISSUE-381), which nothing needs to name a particular slot to
4762
+ * use. Takes whichever numbered slot is free first.
4763
+ */
4764
+ acquireN(rawPrefix, max) {
4765
+ const heldBy = [];
4766
+ for (let i = 1; i <= max; i++) {
4767
+ const got = this.acquire(`${rawPrefix}-${i}`);
4768
+ if (got.ok) return got;
4769
+ heldBy.push(got.heldBy);
4770
+ }
4771
+ return { ok: false, heldBy };
5066
4772
  }
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);
4773
+ /**
4774
+ * The run lock (ISSUE-381, CREW-2821): role-capped AND capacity-limited,
4775
+ * checked together.
4776
+ *
4777
+ * Two sessions of one role must never take the same ticket. Before
4778
+ * CREW-2821 that was guaranteed by an exclusive per-role lock; now the
4779
+ * guarantee is the per-ticket lock (`acquireTicket`), so the role lock is a
4780
+ * counted one: `roleMax` interchangeable slots, default 1. Slot 1 keeps the
4781
+ * historical name `crew-role-<role>` (crew-macos reads it for the live pid),
4782
+ * further slots are `crew-role-<role>-<n>`. `max` is this machine's separate
4783
+ * limit on how many runs of ANY role may overlap; a role that clears its own
4784
+ * lock but finds every slot taken must give its role lock back, or a
4785
+ * locked-but-not-running role would wedge itself out for the rest of the
4786
+ * machine's uptime.
4787
+ */
4788
+ acquireRun(role, max, roleMax = 1) {
4789
+ let roleLock;
4790
+ const roleHolders = [];
4791
+ for (let i = 1; i <= roleMax; i++) {
4792
+ const got = this.acquire(i === 1 ? `crew-role-${role}` : `crew-role-${role}-${i}`);
4793
+ if (got.ok) {
4794
+ roleLock = got;
4795
+ break;
4796
+ }
4797
+ roleHolders.push(got.heldBy);
4798
+ }
4799
+ if (!roleLock) {
4800
+ return {
4801
+ ok: false,
4802
+ 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(", ")})`
4803
+ };
4804
+ }
4805
+ const slot = this.acquireN("crew-slot", max);
4806
+ if (!slot.ok) {
4807
+ roleLock.release();
4808
+ return {
4809
+ ok: false,
4810
+ reason: `at capacity \u2014 ${max} agent(s) already running (pid${slot.heldBy.length > 1 ? "s" : ""} ${slot.heldBy.join(", ")})`
4811
+ };
4812
+ }
4813
+ return { ok: true, release: () => {
4814
+ slot.release();
4815
+ roleLock.release();
4816
+ } };
5090
4817
  }
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 };
4818
+ /**
4819
+ * The per-ticket run lock (CREW-2821): held for the life of a run that works
4820
+ * the ticket (`crew-ticket-<record id>`), so a second run of the same role
4821
+ * on this ship walks past it instead of being handed the first run's
4822
+ * ticket. Same stale-pid rule as every lock here.
4823
+ */
4824
+ acquireTicket(recordId) {
4825
+ return this.acquire(`crew-ticket-${recordId}`);
5120
4826
  }
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) {
4827
+ /** Whether a live run on this ship holds `recordId`'s ticket lock. */
4828
+ ticketInFlight(recordId) {
4829
+ const file = this.path(`.${_State.safe(`crew-ticket-${recordId}`)}.lock`);
5141
4830
  try {
5142
- ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
4831
+ const pid = Number.parseInt(readFileSync11(file, "utf8").trim(), 10);
4832
+ if (!Number.isFinite(pid) || pid <= 0) return false;
4833
+ process.kill(pid, 0);
4834
+ return true;
5143
4835
  } catch {
4836
+ return false;
5144
4837
  }
5145
4838
  }
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) {
4839
+ /**
4840
+ * How many consecutive cycles the release phase has refused to deploy.
4841
+ *
4842
+ * Refusing is correct — a dirty tree or a non-main checkout must never be
4843
+ * released — but it used to be SILENT, and eight commits once sat unreleased
4844
+ * behind a single untracked file with nothing saying so (ISSUE-174). One
4845
+ * blocked cycle is normal; the count is what makes a persistent one loud.
4846
+ */
4847
+ /**
4848
+ * Release bookkeeping, scoped to one route.
4849
+ *
4850
+ * Scoped because a ship serves several boards and releases each repo
4851
+ * independently (ISSUE-338). These were single global files, so a deploy
4852
+ * failure on one route suppressed releases on every other one, and a
4853
+ * release blocked in one repo counted cycles for all of them — a dirty tree
4854
+ * in one checkout would have silently held up an unrelated project.
4855
+ */
4856
+ release(route) {
4857
+ const suffix = _State.safe(route);
4858
+ const blocked = `.release-blocked-${suffix}`;
4859
+ const failed = `.deploy-failed-sha-${suffix}`;
4860
+ const gateFailed = `.test-gate-failed-${suffix}`;
4861
+ const gateReported = `.test-gate-reported-${suffix}`;
4862
+ const heldReported = `.release-held-${suffix}`;
4863
+ const read = (f) => {
4864
+ try {
4865
+ return readFileSync11(this.path(f), "utf8").trim() || null;
4866
+ } catch {
4867
+ return null;
4868
+ }
4869
+ };
5209
4870
  return {
5210
- warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
5211
- ...isRetryable(e) ? { retry: write } : {}
4871
+ blockedCount: () => Number.parseInt(read(blocked) ?? "", 10) || 0,
4872
+ noteBlocked: () => {
4873
+ const n = (Number.parseInt(read(blocked) ?? "", 10) || 0) + 1;
4874
+ writeFileSync7(this.path(blocked), `${n}
4875
+ `);
4876
+ return n;
4877
+ },
4878
+ clearBlocked: () => {
4879
+ rmSync6(this.path(blocked), { force: true });
4880
+ },
4881
+ /**
4882
+ * The commit a deploy last failed on.
4883
+ *
4884
+ * Without this the crew retries a broken deploy on every cycle — every
4885
+ * two minutes, indefinitely — hammering the target and burying the
4886
+ * original failure under identical ones. A failed commit is retried
4887
+ * only when a NEW commit lands, or when an operator forces it by hand.
4888
+ */
4889
+ deployFailedSha: () => read(failed),
4890
+ noteDeployFailed: (sha) => {
4891
+ writeFileSync7(this.path(failed), `${sha}
4892
+ `);
4893
+ },
4894
+ clearDeployFailed: () => {
4895
+ rmSync6(this.path(failed), { force: true });
4896
+ },
4897
+ /**
4898
+ * Consecutive test-gate failures on the same head (CREW-1368). A failure
4899
+ * on a new head restarts the count. Held on disk because one process is
4900
+ * one cycle, and "failed three cycles running" only exists across them.
4901
+ */
4902
+ noteTestGateFailed: (sha) => {
4903
+ const [prev, n] = (read(gateFailed) ?? "").split(" ");
4904
+ const next = prev === sha ? (Number.parseInt(n ?? "", 10) || 0) + 1 : 1;
4905
+ writeFileSync7(this.path(gateFailed), `${sha} ${next}
4906
+ `);
4907
+ return next;
4908
+ },
4909
+ clearTestGateFailed: () => {
4910
+ rmSync6(this.path(gateFailed), { force: true });
4911
+ },
4912
+ /** Whether the board was already told about this head's red gate. */
4913
+ heldReported: () => {
4914
+ try {
4915
+ return readFileSync11(this.path(heldReported), "utf8").replace(/\n$/, "") || null;
4916
+ } catch {
4917
+ return null;
4918
+ }
4919
+ },
4920
+ noteHeldReported: (signature) => {
4921
+ writeFileSync7(this.path(heldReported), `${signature}
4922
+ `);
4923
+ },
4924
+ clearHeldReported: () => {
4925
+ rmSync6(this.path(heldReported), { force: true });
4926
+ },
4927
+ testGateReported: (sha) => read(gateReported) === sha,
4928
+ noteTestGateReported: (sha) => {
4929
+ writeFileSync7(this.path(gateReported), `${sha}
4930
+ `);
4931
+ }
5212
4932
  };
5213
4933
  }
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;
5227
- try {
5228
- q = JSON.parse(readFileSync11(path, "utf8"));
5229
- } catch {
5230
- continue;
5231
- }
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}`);
5254
- continue;
4934
+ /**
4935
+ * Starvation bookkeeping, scoped to one route (ISSUE-382).
4936
+ *
4937
+ * `decideFleet` already renders the winner and each role's rank; what it
4938
+ * throws away is the other side — a route that had work and did not
4939
+ * win, and the same ticket sitting at the front of that route's queue
4940
+ * cycle after cycle. This is state, not memory: a single process is one
4941
+ * cycle, so "how long has this been waiting" only exists if it survives
4942
+ * between them.
4943
+ */
4944
+ fairness(route) {
4945
+ const suffix = _State.safe(route);
4946
+ const streakFile = `.fairness-streak-${suffix}`;
4947
+ const ticketFile = `.fairness-ticket-${suffix}`;
4948
+ const sinceFile = `.fairness-since-${suffix}`;
4949
+ const ticketStreakFile = `.fairness-ticket-streak-${suffix}`;
4950
+ const read = (f) => {
4951
+ try {
4952
+ return readFileSync11(this.path(f), "utf8").trim() || null;
4953
+ } catch {
4954
+ return null;
5255
4955
  }
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`);
5264
- }
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;
4956
+ };
4957
+ const clear2 = () => {
4958
+ for (const f of [streakFile, ticketFile, sinceFile, ticketStreakFile]) rmSync6(this.path(f), { force: true });
4959
+ };
4960
+ return {
4961
+ /** Consecutive cycles this route had actionable work and did not win. */
4962
+ streak: () => Number.parseInt(read(streakFile) ?? "", 10) || 0,
4963
+ /**
4964
+ * The ticket sitting at the front of this route's queue, how long
4965
+ * it has been there and its own consecutive-passed-over count — or
4966
+ * undefined if nothing is waiting.
4967
+ */
4968
+ waiting: () => {
4969
+ const ticket = read(ticketFile);
4970
+ const since2 = read(sinceFile);
4971
+ if (!ticket || !since2) return void 0;
4972
+ return { ticket, since: since2, streak: Number.parseInt(read(ticketStreakFile) ?? "", 10) || 0 };
4973
+ },
4974
+ /**
4975
+ * Record what one cycle saw for this route: how many tickets were
4976
+ * actionable, which one was most urgent, and whether this route
4977
+ * won. Winning or running dry both reset the streak — there is nothing
4978
+ * left being passed over.
4979
+ */
4980
+ record: (actionable, topTicket, won) => {
4981
+ if (won || actionable === 0) {
4982
+ clear2();
4983
+ return;
4984
+ }
4985
+ writeFileSync7(this.path(streakFile), `${this.readInt(streakFile) + 1}
4986
+ `);
4987
+ const prior = read(ticketFile);
4988
+ if (topTicket && topTicket === prior) {
4989
+ writeFileSync7(this.path(ticketStreakFile), `${this.readInt(ticketStreakFile) + 1}
4990
+ `);
4991
+ } else if (topTicket) {
4992
+ writeFileSync7(this.path(ticketFile), `${topTicket}
4993
+ `);
4994
+ writeFileSync7(this.path(sinceFile), `${(/* @__PURE__ */ new Date()).toISOString()}
4995
+ `);
4996
+ writeFileSync7(this.path(ticketStreakFile), "1\n");
4997
+ }
5298
4998
  }
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
- }
4999
+ };
5308
5000
  }
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
5001
  /**
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).
5002
+ * The previous cycle's "needs a person" set, scoped to one route
5003
+ * (ISSUE-928) — `{ ticketId: [reason, ...] }`. `poll.ts` reads it once
5004
+ * per cycle to compute transitions and the caller (`cli.ts`'s run path,
5005
+ * `fleet.ts`'s per-route loop) persists the new set afterward, beside the
5006
+ * existing `fairness(route).record(...)` call — never inside `decideCycle`
5007
+ * itself, which does no writes. A corrupt or missing file reads as empty,
5008
+ * which is also deliberately what a fresh install sees: everything
5009
+ * currently needing a person emits once, rather than being silently
5010
+ * skipped as though already notified.
5329
5011
  */
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];
5012
+ attention(route) {
5013
+ const file = `.attention-${_State.safe(route)}.json`;
5014
+ return {
5015
+ previous: () => {
5016
+ try {
5017
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5018
+ return parsed && typeof parsed === "object" ? parsed : {};
5019
+ } catch {
5020
+ return {};
5021
+ }
5022
+ },
5023
+ persist: (next) => {
5024
+ writeFileSync7(this.path(file), JSON.stringify(next));
5025
+ }
5026
+ };
5337
5027
  }
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);
5028
+ /**
5029
+ * The previous cycle's unroutable ticket ids for one route (CREW-2548): open
5030
+ * tickets with no repository that are not exempt. `poll.ts` diffs against it
5031
+ * so each ticket warns once when it becomes unroutable, not every cycle; the
5032
+ * caller persists the new set beside `attention(route).persist(...)`. A
5033
+ * missing or corrupt file reads as empty, so a fresh install warns once.
5034
+ */
5035
+ unroutable(route) {
5036
+ const file = `.unroutable-${_State.safe(route)}.json`;
5037
+ return {
5038
+ previous: () => {
5039
+ try {
5040
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5041
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : [];
5042
+ } catch {
5043
+ return [];
5044
+ }
5045
+ },
5046
+ persist: (next) => {
5047
+ writeFileSync7(this.path(file), JSON.stringify(next));
5048
+ }
5049
+ };
5345
5050
  }
5346
5051
  /**
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.
5052
+ * Open ship-level attention items (CREW-1373), ship-wide rather than
5053
+ * per-route: a parked role or a missing hook command is about this
5054
+ * machine. A corrupt or missing file reads as empty.
5352
5055
  */
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;
5056
+ shipAttention() {
5057
+ const file = ".ship-attention.json";
5058
+ return {
5059
+ previous: () => {
5060
+ try {
5061
+ const parsed = JSON.parse(readFileSync11(this.path(file), "utf8"));
5062
+ return Array.isArray(parsed) ? parsed : [];
5063
+ } catch {
5064
+ return [];
5065
+ }
5066
+ },
5067
+ persist: (next) => {
5068
+ writeFileSync7(this.path(file), JSON.stringify(next));
5069
+ }
5070
+ };
5361
5071
  }
5362
5072
  /**
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.
5073
+ * When the release phase last ran a cycle (CREW-1373), as the mtime of a
5074
+ * marker file. `touch()` is called at the start and the end of every cycle
5075
+ * so a long deploy does not read as a stopped timer.
5374
5076
  */
5375
- decide(hint, opts = {}) {
5376
- for (const by of ["handoff", "claim", "comment"]) {
5377
- const ticket = this.#lastBy[by];
5378
- if (ticket) return { ticket, by };
5379
- }
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" };
5077
+ releaseHeartbeat() {
5078
+ const file = ".release-last-run";
5079
+ return {
5080
+ touch: () => {
5081
+ writeFileSync7(this.path(file), (/* @__PURE__ */ new Date()).toISOString());
5082
+ },
5083
+ at: () => {
5084
+ try {
5085
+ return statSync4(this.path(file)).mtimeMs;
5086
+ } catch {
5087
+ return null;
5088
+ }
5089
+ }
5090
+ };
5091
+ }
5092
+ readInt(file) {
5093
+ try {
5094
+ return Number.parseInt(readFileSync11(this.path(file), "utf8").trim(), 10) || 0;
5095
+ } catch {
5096
+ return 0;
5389
5097
  }
5390
- return { ticket: hint, by: "hint" };
5391
5098
  }
5392
- /**
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.
5397
- */
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;
5099
+ };
5100
+
5101
+ // src/environment.ts
5102
+ import { basename as basename5 } from "path";
5103
+ var API_KEY_VAR = "CREW_API_KEY";
5104
+ function authorshipBullets(a, contract) {
5105
+ if (!a) return [];
5106
+ return [
5107
+ ...a.issueAuthor ? [`- \`${contract.columns.author}\`: your own Crew row id, from "Your crew" above \u2014 who filed it`] : [],
5108
+ ...a.issueShip && a.shipId ? [`- \`${contract.columns.ship}\`: \`${a.shipId}\` \u2014 this ship, where it was filed from`] : [],
5109
+ ...a.commentShip && a.shipId ? [`- \`${contract.comments.ship}\` on every comment you post: \`${a.shipId}\` \u2014 this ship`] : []
5110
+ ];
5111
+ }
5112
+ var HOOK_PURPOSE = [
5113
+ ["setup", "make a fresh worktree usable \u2014 dependencies, generated clients"],
5114
+ ["isolate", "point this worktree at state of its own; prints KEY=value overrides to export"],
5115
+ ["ports", "this worktree's port assignments; prints KEY=value overrides to export"],
5116
+ ["handoff", "leave the operator able to open what you built; put its output in your progress comment"],
5117
+ ["test", "run the suite"],
5118
+ ["build", "build it"],
5119
+ ["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"]
5120
+ ];
5121
+ function hookTable(repo) {
5122
+ const declared = HOOK_PURPOSE.filter(([name]) => repo.hooks[name]);
5123
+ if (declared.length === 0) {
5124
+ return "This repository declares no hooks. Do not guess commands for it \u2014 say so in your progress comment and stop.\n";
5125
+ }
5126
+ const inline = (cmd) => cmd.includes("\n") ? "_(below)_" : `\`${cmd}\``;
5127
+ const blocks = declared.filter(([name]) => repo.hooks[name].includes("\n")).map(([name]) => [`### \`${name}\``, "", "```sh", repo.hooks[name].trimEnd(), "```", ""].join("\n"));
5128
+ const undeclared = HOOK_PURPOSE.filter(([n]) => !repo.hooks[n]);
5129
+ return [
5130
+ "| hook | what it is for | what it runs |",
5131
+ "| --- | --- | --- |",
5132
+ ...declared.map(([name, purpose]) => `| \`${name}\` | ${purpose} | ${inline(repo.hooks[name])} |`),
5133
+ "",
5134
+ "Run these **verbatim**, from the worktree. They come from the repository's own",
5135
+ "configuration, which is the only thing that knows them. A command you invented",
5136
+ "yourself is wrong even when it works, because the next repository will differ.",
5137
+ "",
5138
+ ...blocks,
5139
+ ...undeclared.length ? [`Not declared here, so there is nothing to run for them: ${undeclared.map(([n]) => `\`${n}\``).join(", ")}.`, ""] : []
5140
+ ].join("\n");
5141
+ }
5142
+ function repoPrefix(repo) {
5143
+ return repo.provenance["worktrees.prefix"] !== "default" ? repo.worktrees.prefix.replace(/-$/, "") : void 0;
5144
+ }
5145
+ function branchExample(repo, key) {
5146
+ const prefix = repoPrefix(repo);
5147
+ const template = effectiveBranchTemplate(repo, prefix);
5148
+ return renderBranchName(template, { key, title: "Fix the widget", role: "dev", prefix, lowercase: repo.branch.lowercase });
5149
+ }
5150
+ function repoSection(r, key, heading) {
5151
+ const repo = r.config;
5152
+ const branchTemplate = effectiveBranchTemplate(repo, repoPrefix(repo));
5153
+ const branchName = branchExample(repo, key);
5154
+ const checkoutName = basename5(r.dir.replace(/[/\\]+$/, ""));
5155
+ const worktreeName = effectiveWorktreeDirName(
5156
+ repo,
5157
+ r.dir,
5158
+ branchName,
5159
+ key.replace(/^\D+/, ""),
5160
+ // The example ticket has no tag of its own, so `{issue}` is the key.
5161
+ (template) => renderWorktreeName(template, { dir: checkoutName, key, title: "Fix the widget", role: "dev", prefix: repoPrefix(repo) })
5162
+ );
5163
+ const worktreePattern = repo.worktrees.name ?? (repo.provenance["worktrees.prefix"] !== "default" ? `${repo.worktrees.prefix}<number>` : `${checkoutName}-<branch>`);
5164
+ const lines = [
5165
+ "",
5166
+ heading,
5167
+ "",
5168
+ `- Cut it from \`${repo.branch.base}\`, beside the checkout, at`,
5169
+ ` \`../${worktreePattern}\` \u2014 for ${key} that is \`../${worktreeName}\`.`,
5170
+ `- Name the branch the way this repository names branches: \`${branchTemplate}\``,
5171
+ ` \u2014 for ${key} that is \`${branchName}\`.`,
5172
+ "- Never work in the main checkout. It is the operator's, and the release phase",
5173
+ " uses it.",
5174
+ "",
5175
+ `${key} above is a placeholder, not a real ticket: this section is rendered`,
5176
+ "before any ticket is chosen, so it cannot know a specific ticket's own",
5177
+ "project prefix. Once you have a ticket, its digest row's `branch` and",
5178
+ "`worktree` columns are the real, per-ticket values \u2014 use those verbatim",
5179
+ "over this section's worked example if the two ever disagree."
5180
+ ];
5181
+ if (repo.worktrees.copy.length) {
5182
+ lines.push(
5183
+ "",
5184
+ "A worktree is a clean checkout, so it is missing exactly the files git ignores.",
5185
+ "Copy these across from the main checkout before running anything:",
5186
+ "",
5187
+ ...repo.worktrees.copy.map((c) => `- \`${c}\``)
5188
+ );
5189
+ }
5190
+ if (repo.docs.designGuide) {
5191
+ lines.push(
5192
+ "",
5193
+ "## This project's design brief",
5194
+ "",
5195
+ `\`${repo.docs.designGuide}\`, in the repository. Read it from the primary`,
5196
+ "checkout before you `cd` into any worktree \u2014 a worktree cut from the base",
5197
+ "branch only has it once it has been committed there."
5198
+ );
5408
5199
  }
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 };
5200
+ if (repo.docs.triagePolicy) {
5201
+ lines.push(
5202
+ "",
5203
+ "## This project's triage policy",
5204
+ "",
5205
+ `\`${repo.docs.triagePolicy}\`, in the repository. Where it and any brief`,
5206
+ "disagree about what a status means or when a ticket may be accepted, the",
5207
+ "policy document wins: it is the project's own decision, and the brief is",
5208
+ "shared with projects that decided differently."
5209
+ );
5425
5210
  }
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 "";
5211
+ return lines;
5436
5212
  }
5437
- function isTransportFailure(isError, text) {
5438
- if (!isError) {
5439
- 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
- }
5445
- } catch {
5213
+ function renderEnvironment(i) {
5214
+ const { route, contract } = i;
5215
+ const repos = i.repos;
5216
+ const only = repos.length === 1 ? repos[0] : void 0;
5217
+ const models = route.resolved?.models;
5218
+ const epicsModelId = route.resolved?.epicsModelId;
5219
+ const mediaLibraryModelId = i.attachments?.mediaLibraryModelId ?? null;
5220
+ const key = "ISSUE-000";
5221
+ const lines = [
5222
+ "# Your environment",
5223
+ "",
5224
+ "Supplied by the crew for THIS run. The brief below is shared by every project",
5225
+ "the crew works on and deliberately names nothing specific; this section is where",
5226
+ "those names come from. Where the two appear to disagree, this section wins.",
5227
+ "",
5228
+ "## The board",
5229
+ "",
5230
+ `- Base URL: \`${route.baseUrl}\``,
5231
+ `- Your key is in the \`${API_KEY_VAR}\` environment variable. Send it as`,
5232
+ ` \`Authorization: Bearer $${API_KEY_VAR}\`. **Do not read it out of any file**,`,
5233
+ " and never write it into a comment, a commit or a log line.",
5234
+ `- Send \`User-Agent: ${i.userAgent}\` on every request.`
5235
+ ];
5236
+ if (models) {
5237
+ lines.push(
5238
+ "",
5239
+ "Tables, by id \u2014 use these directly rather than listing the workspace to find them:",
5240
+ "",
5241
+ `- Issues: \`${models.issues}\``,
5242
+ `- Comments: \`${models.comments}\``,
5243
+ `- Crew: \`${models.crew}\``,
5244
+ ...epicsModelId ? [`- Epics: \`${epicsModelId}\``] : [],
5245
+ ...mediaLibraryModelId ? [`- Media Library: \`${mediaLibraryModelId}\` (what a ticket's \`${contract.columns.attachments}\` ids point into)`] : []
5246
+ );
5247
+ }
5248
+ const workspaceId = route.resolved?.workspaceId;
5249
+ if (workspaceId) {
5250
+ const base = route.baseUrl.replace(/\/+$/, "");
5251
+ lines.push(
5252
+ "",
5253
+ `- Workspace id: \`${workspaceId}\``,
5254
+ "",
5255
+ "## Attaching a file to a comment",
5256
+ "",
5257
+ "A screenshot or mockup goes on a comment's `attachments` field, which holds",
5258
+ "Media Library record ids, not URLs. Upload the file first, then put the",
5259
+ "returned `id` in the comment's `attachments` array:",
5260
+ "",
5261
+ "```sh",
5262
+ `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5263
+ ` -X POST "${base}/api/workspaces/${workspaceId}/files" \\`,
5264
+ ' -F "file=@shot.png;type=image/png" -F targetMediaLibrary=true',
5265
+ "```",
5266
+ "",
5267
+ "The response is JSON with `id` and `storageKey`. `targetMediaLibrary=true`",
5268
+ "means no field type id has to be looked up. If the upload fails, post the HTTP",
5269
+ "status and response body in your comment \u2014 do not write that uploads are",
5270
+ "impossible."
5271
+ );
5272
+ if (mediaLibraryModelId) {
5273
+ lines.push(
5274
+ "",
5275
+ "## Opening a ticket's attachments",
5276
+ "",
5277
+ `A ticket's or a comment's \`${contract.columns.attachments}\` value is a list of Media Library`,
5278
+ "**record ids** \u2014 not file ids and not URLs. `files/<id>`, `files/<id>/download` and",
5279
+ "`media-library/<id>/...` do not exist for such an id and return 404. For each id,",
5280
+ "read the record, then fetch the file by its storage key:",
5281
+ "",
5282
+ "```sh",
5283
+ `curl -s -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5284
+ ` "${base}/api/workspaces/${workspaceId}/data-models/${mediaLibraryModelId}/records/<attachment id>"`,
5285
+ '# \u2192 JSON with `storage_key` ("<uuid>/<filename>") and `mime_type`',
5286
+ `curl -s -o <scratch dir>/<filename> -H "Authorization: Bearer $${API_KEY_VAR}" -H "User-Agent: ${i.userAgent}" \\`,
5287
+ ` "${base}/api/workspaces/${workspaceId}/files/<storage_key>"`,
5288
+ "```",
5289
+ "",
5290
+ "The MCP forms are `records_controller_find_one` (`dataModelId` = the Media Library",
5291
+ "id above) and `files_controller_download` (`uuid` and `filename` = the two halves",
5292
+ "of the storage key). Save the file outside the repository and open it; where it",
5293
+ "disagrees with the description, the picture is the reproduction."
5294
+ );
5446
5295
  }
5447
5296
  }
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
- }
5297
+ if (i.shipName) {
5298
+ lines.push(
5299
+ "",
5300
+ "## Ships",
5301
+ "",
5302
+ `This run is on the ship **${i.shipName}**. When a comment names a ship \u2014 this one or`,
5303
+ "another (the digest prints other ships by name) \u2014 write its name, never a Ships row id."
5304
+ );
5305
+ }
5306
+ const seatIds = Object.entries(route.resolved?.seats ?? {}).filter(([, id]) => !!id);
5307
+ if (seatIds.length > 0) {
5308
+ lines.push(
5309
+ "",
5310
+ "## Seats on this ship",
5311
+ "",
5312
+ "The Crew row id of each role's seat on this ship, to write verbatim where a hand-off",
5313
+ "assigns the next lane (a ticket whose digest `mode` is `hybrid`):",
5314
+ "",
5315
+ ...seatIds.map(([role, id]) => `- ${role}: \`${id}\``)
5316
+ );
5317
+ }
5318
+ lines.push(
5319
+ "",
5320
+ "## What the columns are called here",
5321
+ "",
5322
+ "Every workspace names these itself. Use these names, not the ones you would",
5323
+ "guess, and not the ones another project used.",
5324
+ "",
5325
+ "| meaning | column |",
5326
+ "| --- | --- |",
5327
+ `| ticket key | \`${contract.columns.key}\` |`,
5328
+ `| status | \`${contract.columns.status}\` |`,
5329
+ `| assignee | \`${contract.columns.assignee}\` |`,
5330
+ `| severity | \`${contract.columns.severity}\` |`,
5331
+ `| priority | \`${contract.columns.priority}\` |`,
5332
+ `| blocked by | \`${contract.columns.blockedBy}\` |`,
5333
+ `| needs design | \`${contract.columns.needsDesign}\` |`,
5334
+ `| project | \`${contract.columns.slice}\` |`,
5335
+ `| repo | \`${contract.columns.repo}\` |`,
5336
+ ...epicsModelId ? [`| epic | \`${contract.columns.epic}\` |`] : [],
5337
+ "",
5338
+ ...epicsModelId ? [
5339
+ "An epic is a body of work a ticket belongs to. Set it when a ticket clearly",
5340
+ "fits an existing epic; proposing a new one is a taxonomy decision \u2014 leave it",
5341
+ "to a person rather than inventing one yourself.",
5342
+ "",
5343
+ "An epic's own status is kept by the crew runner, from its tickets: done once",
5344
+ "every one of them is closed, in progress while any is being worked. Do not",
5345
+ "set it yourself.",
5346
+ ""
5347
+ ] : [],
5348
+ "Statuses that matter to you:",
5349
+ "",
5350
+ `- Work you may pick up: \`${contract.statuses.approved}\``,
5351
+ `- While you are working it: \`${contract.statuses.building}\``,
5352
+ `- When you hand it on to be checked: \`${contract.statuses.handoff}\``,
5353
+ `- Being checked: \`${contract.statuses.verifying}\``,
5354
+ `- Waiting on a person to answer something: \`${contract.statuses.needsHuman}\``,
5355
+ `- Parked on an unresolved dependency, set by the crew and not by you: \`${contract.statuses.parked}\``,
5356
+ "",
5357
+ `\`${contract.statuses.verified}\` and the closed statuses are **never yours to set**.`
5358
+ );
5359
+ const areaId = route.resolved?.areaId;
5360
+ if (areaId || repos.some((r) => r.id)) {
5361
+ lines.push(
5362
+ "",
5363
+ "## Filing a new ticket",
5364
+ "",
5365
+ "Something broken that is out of scope for the ticket you are working is not",
5366
+ "yours to fix inline \u2014 file it as its own new ticket instead, and keep working",
5367
+ "the one you were on. A ticket filed without its project and repo sits unrouted",
5368
+ "until a person notices and fixes it by hand, so always set:",
5369
+ "",
5370
+ ...areaId ? [`- \`${contract.columns.slice}\`: \`${areaId}\` (this route's project, for every ticket you file)`] : [],
5371
+ ...only?.id ? [`- \`${contract.columns.repo}\`: \`${only.id}\` (this repository)`] : repos.filter((r) => r.id).map(
5372
+ (r) => `- \`${contract.columns.repo}\`: \`${r.id}\` \u2014 when the new ticket belongs to ${r.name}`
5373
+ ),
5374
+ ...authorshipBullets(i.authorship, contract),
5375
+ "",
5376
+ ...i.sourceTicket ? [
5377
+ `Name ${i.sourceTicket} \u2014 the ticket you were working when you noticed this \u2014 in the new`,
5378
+ "ticket's description. Only put it in the new ticket's `blockedBy` if your own ticket",
5379
+ "genuinely cannot proceed without the new one being fixed first; merely having noticed it",
5380
+ "nearby is not a dependency.",
5381
+ ""
5382
+ ] : []
5383
+ );
5384
+ }
5385
+ if (only) {
5386
+ lines.push(...repoSection(only, key, "## Your worktree"));
5387
+ } else {
5388
+ lines.push(
5389
+ "",
5390
+ "## The repositories this board covers",
5391
+ "",
5392
+ "This board spans several repositories, and they do not share a branch name,",
5393
+ "a worktree location or a test command. **The `repo` column of your queue",
5394
+ "says which one your ticket belongs to** \u2014 read the section for THAT one and",
5395
+ "ignore the others. A ticket whose repo is not listed here has no checkout on",
5396
+ "this machine and is not yours to work."
5397
+ );
5398
+ for (const r of repos) {
5399
+ lines.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
5400
+ lines.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
5485
5401
  }
5486
5402
  }
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
- };
5403
+ if (i.repoDir) {
5404
+ lines.push("", `This ticket's work happens in \`${i.repoDir}\`.`);
5499
5405
  }
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";
5406
+ if (only) lines.push("", "## Hooks \u2014 the only commands you should run", "", hookTable(only.config));
5407
+ return lines.join("\n");
5408
+ }
5512
5409
 
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);
5525
- }
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
- }
5540
- }
5541
- setWatermark(at) {
5542
- writeFileSync8(this.path(".poll-watermark"), `${at}
5543
- `);
5410
+ // src/contract.ts
5411
+ var DEFAULT_CONTRACT = {
5412
+ columns: {
5413
+ key: "issue_id",
5414
+ tag: "issue_tag",
5415
+ title: "title",
5416
+ status: "status",
5417
+ assignee: "assignee_id",
5418
+ priority: "priority",
5419
+ severity: "severity",
5420
+ slice: "project_id",
5421
+ repo: "repo_id",
5422
+ parent: "parent_id",
5423
+ blockedBy: "blocked_by",
5424
+ updatedAt: "updated_at",
5425
+ needsDesign: "needs_design",
5426
+ attachments: "attachments",
5427
+ workMode: "work_mode",
5428
+ reportType: "report_type",
5429
+ epic: "epic_id",
5430
+ author: "filed_by_id",
5431
+ ship: "ship_id",
5432
+ heldBy: "held_by_ship_id",
5433
+ resolutionNote: "resolution_note",
5434
+ verifiedSha: "verified_sha",
5435
+ prRef: "pr_ref",
5436
+ externalId: "external_id",
5437
+ position: "position",
5438
+ agentRuns: "agent_runs",
5439
+ tokensIn: "tokens_in",
5440
+ tokensOut: "tokens_out",
5441
+ cacheReadTokens: "cache_read_tokens",
5442
+ cacheWriteTokens: "cache_write_tokens",
5443
+ costUsd: "cost_usd"
5444
+ },
5445
+ comments: {
5446
+ parent: "ticket_id",
5447
+ body: "body",
5448
+ author: "team_member_id",
5449
+ createdAt: "created_at",
5450
+ ship: "ship_id",
5451
+ publicReply: "is_public_reply",
5452
+ authorName: "author_name"
5453
+ },
5454
+ statuses: {
5455
+ open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
5456
+ resolved: [
5457
+ "verified",
5458
+ "closed_deployed",
5459
+ "closed_wont_fix",
5460
+ "closed_duplicate",
5461
+ "closed_completed",
5462
+ "closed_cant_reproduce"
5463
+ ],
5464
+ approved: "accepted",
5465
+ parked: "blocked",
5466
+ building: "in_progress",
5467
+ handoff: "fixed",
5468
+ verifying: "qa",
5469
+ verified: "verified",
5470
+ deployed: "closed_deployed",
5471
+ needsHuman: "needs_info",
5472
+ reviewing: null,
5473
+ wontFix: "closed_wont_fix",
5474
+ draft: "draft"
5475
+ },
5476
+ epics: {
5477
+ statusColumn: "status",
5478
+ keyColumn: "epic_id",
5479
+ statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
5480
+ },
5481
+ priorityOrder: ["p0", "p1", "p2", "p3"],
5482
+ severityOrder: ["s1", "s2", "s3", "s4"],
5483
+ unknownPriorityRank: 2,
5484
+ coordinatingValue: "coordinating",
5485
+ manualWorkMode: "manual",
5486
+ hybridWorkMode: "hybrid",
5487
+ repoExemptReportTypes: ["coordinating", "question", "investigation"]
5488
+ };
5489
+ function closedStatuses(c) {
5490
+ return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
5491
+ }
5492
+ function resolveContract(override) {
5493
+ if (!override) return DEFAULT_CONTRACT;
5494
+ return {
5495
+ columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
5496
+ comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
5497
+ statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
5498
+ epics: {
5499
+ ...DEFAULT_CONTRACT.epics,
5500
+ ...override.epics,
5501
+ statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
5502
+ },
5503
+ priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
5504
+ severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
5505
+ unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
5506
+ coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
5507
+ manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
5508
+ hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
5509
+ // A workspace overriding `coordinatingValue` alone (renaming the rollup
5510
+ // marker) still gets that renamed value exempted here by default,
5511
+ // without also having to repeat it — only an explicit
5512
+ // `repoExemptReportTypes` overrides this derived default.
5513
+ repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
5514
+ };
5515
+ }
5516
+ function validateContract(c) {
5517
+ const problems = [];
5518
+ const open = new Set(c.statuses.open);
5519
+ const resolved = new Set(c.statuses.resolved);
5520
+ for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
5521
+ const v = c.statuses[role];
5522
+ if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
5544
5523
  }
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;
5524
+ if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
5525
+ problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
5557
5526
  }
5558
- /** Whole-crew pause, and the per-role sentinels one level down. */
5559
- isPaused() {
5560
- return existsSync12(this.path(".crew-paused"));
5527
+ if (!resolved.has(c.statuses.deployed)) {
5528
+ problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
5561
5529
  }
5562
- isRolePaused(role) {
5563
- return existsSync12(this.path(`.role-paused-${role}`));
5530
+ if (resolved.has(c.statuses.handoff)) {
5531
+ problems.push(
5532
+ `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`
5533
+ );
5564
5534
  }
5565
- pausedRoles(roles) {
5566
- return new Set(roles.filter((r) => this.isRolePaused(r)));
5535
+ if (!resolved.has(c.statuses.wontFix)) {
5536
+ problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
5567
5537
  }
5568
- pause(role) {
5569
- writeFileSync8(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), "");
5538
+ if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
5539
+ problems.push(
5540
+ `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`
5541
+ );
5570
5542
  }
5571
- resume(role) {
5572
- rmSync7(role ? this.path(`.role-paused-${role}`) : this.path(".crew-paused"), { force: true });
5543
+ if (resolved.has(c.statuses.approved)) {
5544
+ problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
5573
5545
  }
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"));
5546
+ if (c.statuses.parked === c.statuses.approved) {
5547
+ problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
5581
5548
  }
5582
- setReleasePaused(paused) {
5583
- if (paused) writeFileSync8(this.path(".release-paused"), "");
5584
- else rmSync7(this.path(".release-paused"), { force: true });
5549
+ const epicValues = Object.values(c.epics.statuses);
5550
+ if (new Set(epicValues).size !== epicValues.length) {
5551
+ problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
5585
5552
  }
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;
5553
+ if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
5554
+ if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
5555
+ for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
5556
+ if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
5598
5557
  }
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, "_");
5558
+ return problems;
5559
+ }
5560
+ 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.";
5561
+
5562
+ // src/agent-log.ts
5563
+ import { existsSync as existsSync12, mkdirSync as mkdirSync7, readdirSync as readdirSync5, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "fs";
5564
+ import { join as join13 } from "path";
5565
+
5566
+ // src/pending-backoff.ts
5567
+ var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
5568
+
5569
+ // src/agent-log.ts
5570
+ var AgentLogUnreachableError = class extends Error {
5571
+ };
5572
+ var AgentLogHttpError = class extends Error {
5573
+ status;
5574
+ /** 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). */
5575
+ row = false;
5576
+ constructor(message, status2) {
5577
+ super(message);
5578
+ this.status = status2;
5616
5579
  }
5617
- acquire(rawName) {
5618
- const name = _State.safe(rawName);
5619
- const file = this.path(`.${name}.lock`);
5620
- 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
- }
5629
- } catch {
5630
- }
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
- };
5580
+ };
5581
+ async function request(target, method, path, body) {
5582
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
5583
+ const res = await fetch(url, {
5584
+ method,
5585
+ headers: {
5586
+ Authorization: `Bearer ${target.apiKey}`,
5587
+ "User-Agent": target.userAgent,
5588
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
5589
+ },
5590
+ body: body === void 0 ? void 0 : JSON.stringify(body)
5591
+ });
5592
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5593
+ return await res.json();
5594
+ }
5595
+ var issueColumns = /* @__PURE__ */ new Map();
5596
+ function columnsOf(target, modelId) {
5597
+ const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
5598
+ let p = issueColumns.get(key);
5599
+ if (!p) {
5600
+ p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
5601
+ p.catch(() => issueColumns.delete(key));
5602
+ issueColumns.set(key, p);
5642
5603
  }
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;
5604
+ return p;
5605
+ }
5606
+ async function findTicketRecord(target, t, ref) {
5607
+ const filters = {
5608
+ combinator: "OR",
5609
+ conditions: [
5610
+ { columnName: t.keyColumn, operator: "EQ", value: ref },
5611
+ { columnName: t.tagColumn, operator: "EQ", value: ref }
5612
+ ]
5613
+ };
5614
+ const rows = await request(
5615
+ target,
5616
+ "GET",
5617
+ `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
5618
+ );
5619
+ return rows.length === 1 ? rows[0].id : void 0;
5620
+ }
5621
+ function counterIncrements(run2, cols, have) {
5622
+ const figures = [
5623
+ [cols.agentRuns, 1],
5624
+ [cols.tokensIn, run2.tokensIn],
5625
+ [cols.tokensOut, run2.tokensOut],
5626
+ [cols.cacheReadTokens, run2.cacheReadTokens],
5627
+ [cols.cacheWriteTokens, run2.cacheWriteTokens],
5628
+ [cols.costUsd, run2.costUsd]
5629
+ ];
5630
+ const out = {};
5631
+ for (const [column, value2] of figures) {
5632
+ if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
5633
+ }
5634
+ return out;
5635
+ }
5636
+ async function post(target, path, body) {
5637
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
5638
+ const res = await fetch(url, {
5639
+ method: "POST",
5640
+ headers: {
5641
+ Authorization: `Bearer ${target.apiKey}`,
5642
+ // Cloudflare 403s default agents on this host — same header connect.ts sends.
5643
+ "User-Agent": target.userAgent,
5644
+ "Content-Type": "application/json"
5645
+ },
5646
+ body: JSON.stringify(body)
5647
+ });
5648
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5649
+ return await res.json();
5650
+ }
5651
+ async function reportAgentRun(target, run2) {
5652
+ let ticketRecordId;
5653
+ if (target.tickets && run2.ticketReference) {
5650
5654
  try {
5651
- names = readdirSync5(this.dir);
5655
+ ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
5652
5656
  } 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
- }
5669
- }
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);
5684
5657
  }
5685
- return { ok: false, heldBy };
5686
5658
  }
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
- };
5659
+ const body = {
5660
+ agentId: target.agentId,
5661
+ ticketReference: run2.ticketReference,
5662
+ outcome: run2.outcome,
5663
+ startedAt: run2.startedAt,
5664
+ finishedAt: run2.finishedAt,
5665
+ source: "client",
5666
+ client: "crew",
5667
+ model: run2.model,
5668
+ tokensIn: run2.tokensIn,
5669
+ tokensOut: run2.tokensOut,
5670
+ cacheReadTokens: run2.cacheReadTokens,
5671
+ cacheWriteTokens: run2.cacheWriteTokens,
5672
+ costUsd: run2.costUsd,
5673
+ numTurns: run2.numTurns,
5674
+ prefixTokens: run2.prefixTokens,
5675
+ promptVersion: run2.promptVersion,
5676
+ promptSha: run2.promptSha
5677
+ };
5678
+ const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
5679
+ let entry;
5680
+ try {
5681
+ entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
5682
+ } catch (e) {
5683
+ if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
5684
+ if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
5685
+ if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) {
5686
+ e.row = true;
5687
+ throw e;
5718
5688
  }
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
- };
5689
+ try {
5690
+ entry = await post(target, "/agents/log", body);
5691
+ } catch (e2) {
5692
+ if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
5693
+ throw e2;
5726
5694
  }
5727
- return { ok: true, release: () => {
5728
- slot.release();
5729
- roleLock.release();
5730
- } };
5731
5695
  }
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}`);
5696
+ for (const cycle of run2.cycles) {
5697
+ await post(target, `/agents/log/${entry.id}/cycles`, {
5698
+ cycleIndex: cycle.cycleIndex,
5699
+ occurredAt: cycle.occurredAt,
5700
+ thinking: cycle.thinking,
5701
+ ...cycle.role ? { role: cycle.role } : {}
5702
+ });
5740
5703
  }
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`);
5704
+ const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
5705
+ return {
5706
+ agentLogId: entry.id,
5707
+ ...counters?.warning ? { counterWarning: counters.warning } : {},
5708
+ ...counters?.retry ? { counterRetry: counters.retry } : {}
5709
+ };
5710
+ }
5711
+ var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
5712
+ async function writeCounters(target, w) {
5713
+ const have = await columnsOf(target, w.issuesModelId);
5714
+ const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
5715
+ if (Object.keys(body).length === 0) return;
5716
+ await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
5717
+ }
5718
+ async function addRunToTicket(target, t, recordId, run2) {
5719
+ const write = {
5720
+ issuesModelId: t.issuesModelId,
5721
+ recordId,
5722
+ ticketReference: run2.ticketReference,
5723
+ counters: t.counters,
5724
+ body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
5725
+ };
5726
+ try {
5727
+ await writeCounters(target, write);
5728
+ return {};
5729
+ } catch (e) {
5730
+ return {
5731
+ warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
5732
+ ...isRetryable(e) ? { retry: write } : {}
5733
+ };
5734
+ }
5735
+ }
5736
+ var queueDir = (stateDir) => join13(stateDir, "pending-agent-logs");
5737
+ function queueAgentRun(stateDir, name, q) {
5738
+ mkdirSync7(queueDir(stateDir), { recursive: true });
5739
+ writeFileSync8(join13(queueDir(stateDir), `${name}.json`), JSON.stringify(q));
5740
+ }
5741
+ async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5742
+ const dir = queueDir(stateDir);
5743
+ if (!existsSync12(dir)) return 0;
5744
+ let sent = 0;
5745
+ for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
5746
+ const path = join13(dir, f);
5747
+ let q;
5744
5748
  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;
5749
+ q = JSON.parse(readFileSync12(path, "utf8"));
5749
5750
  } catch {
5750
- return false;
5751
+ continue;
5751
5752
  }
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
- `);
5753
+ if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5754
+ try {
5755
+ const apiKey = await keyFor(q.role);
5756
+ if (!apiKey) continue;
5757
+ const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
5758
+ if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
5759
+ if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
5760
+ version: 1,
5761
+ route: q.route,
5762
+ role: q.role,
5763
+ target: q.target,
5764
+ write: sentRun.counterRetry,
5765
+ attempts: 0,
5766
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
5767
+ });
5768
+ rmSync7(path, { force: true });
5769
+ sent += 1;
5770
+ log.emit(`sent a queued Agent Log report (${f})`);
5771
+ } catch (e) {
5772
+ if (!(e instanceof AgentLogUnreachableError)) {
5773
+ rmSync7(path, { force: true });
5774
+ log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
5775
+ continue;
5845
5776
  }
5846
- };
5777
+ const attempts = q.attempts + 1;
5778
+ writeFileSync8(path, JSON.stringify({
5779
+ ...q,
5780
+ attempts,
5781
+ warned: true,
5782
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5783
+ }));
5784
+ if (!q.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
5785
+ }
5847
5786
  }
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
- }
5787
+ return sent;
5788
+ }
5789
+ var countersDir = (stateDir) => join13(stateDir, "pending-ticket-counters");
5790
+ function queueTicketCounters(stateDir, name, q) {
5791
+ mkdirSync7(countersDir(stateDir), { recursive: true });
5792
+ writeFileSync8(join13(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
5793
+ }
5794
+ async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5795
+ const dir = countersDir(stateDir);
5796
+ if (!existsSync12(dir)) return 0;
5797
+ let sent = 0;
5798
+ for (const f of readdirSync5(dir).filter((n) => n.endsWith(".json")).sort()) {
5799
+ const path = join13(dir, f);
5800
+ let q;
5801
+ try {
5802
+ q = JSON.parse(readFileSync12(path, "utf8"));
5803
+ } catch {
5804
+ continue;
5805
+ }
5806
+ if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5807
+ try {
5808
+ const apiKey = await keyFor(q.role);
5809
+ if (!apiKey) continue;
5810
+ await writeCounters({ ...q.target, apiKey }, q.write);
5811
+ rmSync7(path, { force: true });
5812
+ sent += 1;
5813
+ log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
5814
+ } catch (e) {
5815
+ if (!isRetryable(e)) {
5816
+ rmSync7(path, { force: true });
5817
+ log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
5818
+ continue;
5912
5819
  }
5913
- };
5820
+ const attempts = q.attempts + 1;
5821
+ writeFileSync8(path, JSON.stringify({
5822
+ ...q,
5823
+ attempts,
5824
+ warned: true,
5825
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5826
+ }));
5827
+ if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
5828
+ }
5914
5829
  }
5830
+ return sent;
5831
+ }
5832
+
5833
+ // src/ticket-attribution.ts
5834
+ var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
5835
+ function ticketMentions(text) {
5836
+ return [...text.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
5837
+ }
5838
+ var TicketAttributionTracker = class {
5839
+ #tokensByTicket = /* @__PURE__ */ new Map();
5840
+ #turns = 0;
5841
+ #current;
5842
+ /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
5843
+ #keyByRecord = /* @__PURE__ */ new Map();
5844
+ #lastBy = {};
5915
5845
  /**
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
+ * The ticket key most recently named in the transcript, or undefined before any.
5847
+ * Only the FIRST key in a turn counts: a closing summary names its own
5848
+ * ticket first and then lists others ("Other tickets", "Blocked by", "See
5849
+ * also"), and those trailing keys are not what the run worked (CREW-2598).
5925
5850
  */
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
- };
5851
+ get current() {
5852
+ return this.#current;
5853
+ }
5854
+ /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
5855
+ note(text) {
5856
+ const mentions = ticketMentions(text);
5857
+ if (mentions.length > 0) this.#current = mentions[0];
5858
+ }
5859
+ /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
5860
+ add(text, tokens) {
5861
+ const mentions = ticketMentions(text);
5862
+ if (mentions.length > 0) this.#current = mentions[0];
5863
+ if (this.#current === void 0 || tokens <= 0) return;
5864
+ this.#turns++;
5865
+ this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
5941
5866
  }
5942
5867
  /**
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.
5868
+ * Record a tool call if it is a ticket-targeted tracker write (claim,
5869
+ * hand-off or comment). The ticket key comes from a record uuid seen on an
5870
+ * earlier write when known, else from the key last named in the transcript
5871
+ * (a claim or hand-off announces its ticket). A comment on a record never
5872
+ * seen before is ignored: its uuid cannot be resolved to a key.
5948
5873
  */
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
- };
5874
+ noteWrite(tool, input) {
5875
+ const w = classifyWrite(tool, input);
5876
+ if (!w) return;
5877
+ let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
5878
+ if (w.kind !== "comment") key ??= this.#current;
5879
+ if (!key) return;
5880
+ if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
5881
+ this.#lastBy[w.kind] = key;
5964
5882
  }
5965
5883
  /**
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.
5884
+ * How the attribution is decided: a hand-off write beats a claim, a claim
5885
+ * beats a comment (so a follow-up ticket filed or commented on while
5886
+ * working never displaces the claimed one), and any write beats everything
5887
+ * below. Next, `hintMoved` — the post-run read-back showed the hinted
5888
+ * ticket changed status or assignee during the run — is a hand-off signal
5889
+ * for a run that wrote through MCP tools crew cannot see. Then the
5890
+ * mention-token heuristic, which with a hint may override it only when a
5891
+ * different ticket holds a strict majority of the credited tokens (a single
5892
+ * credited turn, like a lone end-of-run summary naming another ticket,
5893
+ * must not). A run with no hint
5894
+ * keeps the plain winner. The pre-run hint is last.
5969
5895
  */
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
- };
5896
+ decide(hint, opts = {}) {
5897
+ for (const by of ["handoff", "claim", "comment"]) {
5898
+ const ticket = this.#lastBy[by];
5899
+ if (ticket) return { ticket, by };
5900
+ }
5901
+ if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
5902
+ if (this.#tokensByTicket.size > 0) {
5903
+ if (!hint) return { ticket: this.winner(), by: "mentions" };
5904
+ const winner = this.winner(hint);
5905
+ if (winner === hint) return { ticket: hint, by: "mentions" };
5906
+ let total = 0;
5907
+ for (const n of this.#tokensByTicket.values()) total += n;
5908
+ const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
5909
+ return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
5910
+ }
5911
+ return { ticket: hint, by: "hint" };
5985
5912
  }
5986
5913
  /**
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.
5914
+ * The ticket key the run spent the most tokens on, or `fallback` (the
5915
+ * pre-run poll hint) when the transcript never named one — an
5916
+ * administrative run that touches the tracker without discussing a
5917
+ * specific ticket in its visible text still has to cite something.
5990
5918
  */
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
- }
5919
+ winner(fallback) {
5920
+ let best = fallback;
5921
+ let bestTokens = -1;
5922
+ for (const [ticket, tokens] of this.#tokensByTicket) {
5923
+ if (tokens > bestTokens) {
5924
+ best = ticket;
5925
+ bestTokens = tokens;
6003
5926
  }
6004
- };
5927
+ }
5928
+ return best;
6005
5929
  }
6006
- readInt(file) {
5930
+ };
5931
+ function isClaimWrite(tool, input) {
5932
+ if (!tool || !/records_controller_update$/.test(tool)) return false;
5933
+ const body = input?.body;
5934
+ return typeof body === "object" && body !== null && body.status === "in_progress";
5935
+ }
5936
+ var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
5937
+ function classifyWrite(tool, input) {
5938
+ if (!tool) return void 0;
5939
+ const body = input?.body;
5940
+ if (typeof body !== "object" || body === null) return void 0;
5941
+ const b = body;
5942
+ if (/records_controller_update$/.test(tool)) {
5943
+ const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
5944
+ if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
5945
+ if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
5946
+ }
5947
+ if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
5948
+ return void 0;
5949
+ }
5950
+
5951
+ // src/handoff-transcript.ts
5952
+ 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;
5953
+ function resultText(content) {
5954
+ if (typeof content === "string") return content;
5955
+ if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
5956
+ return "";
5957
+ }
5958
+ function isTransportFailure(isError, text) {
5959
+ if (!isError) {
6007
5960
  try {
6008
- return Number.parseInt(readFileSync12(this.path(file), "utf8").trim(), 10) || 0;
5961
+ const doc = JSON.parse(text);
5962
+ if (doc && typeof doc === "object") {
5963
+ const code = doc.statusCode;
5964
+ return typeof code === "number" && code >= 500;
5965
+ }
6009
5966
  } catch {
6010
- return 0;
6011
5967
  }
6012
5968
  }
5969
+ return TRANSPORT_FAILURE.test(text);
5970
+ }
5971
+ var HandoffTranscriptTracker = class {
5972
+ open = /* @__PURE__ */ new Map();
5973
+ /** Per ticket record: the last hand-off status write's outcome. */
5974
+ status = /* @__PURE__ */ new Map();
5975
+ /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
5976
+ comment = /* @__PURE__ */ new Map();
5977
+ landed = /* @__PURE__ */ new Set();
5978
+ lastText = "";
5979
+ resultLine = "";
5980
+ feed(parsed) {
5981
+ const line = parsed;
5982
+ if (!line || typeof line !== "object") return;
5983
+ if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
5984
+ const content = line.message?.content;
5985
+ if (!Array.isArray(content)) return;
5986
+ for (const b of content) {
5987
+ if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
5988
+ else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
5989
+ const w = classifyWrite(b.name, b.input);
5990
+ const body = b.input?.body;
5991
+ if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
5992
+ this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
5993
+ }
5994
+ } else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
5995
+ const w = this.open.get(b.tool_use_id);
5996
+ if (!w) continue;
5997
+ this.open.delete(b.tool_use_id);
5998
+ const failed = isTransportFailure(b.is_error === true, resultText(b.content));
5999
+ if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
6000
+ else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
6001
+ else if (!failed) {
6002
+ this.comment.delete(w.recordId);
6003
+ this.landed.add(w.recordId);
6004
+ }
6005
+ }
6006
+ }
6007
+ }
6008
+ /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
6009
+ pending() {
6010
+ const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
6011
+ if (!failed) return null;
6012
+ const [ticketRecordId, s] = failed;
6013
+ return {
6014
+ ticketRecordId,
6015
+ patch: s.patch,
6016
+ ...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
6017
+ ...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
6018
+ finalText: this.lastText || this.resultLine
6019
+ };
6020
+ }
6013
6021
  };
6014
6022
 
6023
+ // src/pending-handoff.ts
6024
+ import { existsSync as existsSync15, mkdirSync as mkdirSync8, readdirSync as readdirSync6, readFileSync as readFileSync13, rmSync as rmSync8, writeFileSync as writeFileSync9 } from "fs";
6025
+ import { join as join16 } from "path";
6026
+
6027
+ // src/ticket-branch.ts
6028
+ import { basename as basename6 } from "path";
6029
+
6030
+ // src/tracker.ts
6031
+ import { hostname } from "os";
6032
+ import { TablationClient } from "@tablation/client";
6033
+
6015
6034
  // src/work-mode.ts
6016
6035
  function projectWorkModes(rows, contract) {
6017
6036
  const out = /* @__PURE__ */ new Map();
@@ -6479,7 +6498,7 @@ var Tracker = class {
6479
6498
  * `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
6480
6499
  * working/idle pair, one level up (ship, not seat).
6481
6500
  */
6482
- async beatEngaged(shipName2, connectionName, ticketRecordId) {
6501
+ async beatEngaged(shipName2, connectionName, ticketRecordId, since2) {
6483
6502
  const row = await this.myShipRow(shipName2);
6484
6503
  if (!row) return;
6485
6504
  await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
@@ -6487,7 +6506,7 @@ var Tracker = class {
6487
6506
  host: hostname(),
6488
6507
  pid: process.pid,
6489
6508
  engaged: true,
6490
- engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
6509
+ engaged_since: since2 ?? (/* @__PURE__ */ new Date()).toISOString(),
6491
6510
  engaged_connection: connectionName,
6492
6511
  engaged_ticket_id: ticketRecordId,
6493
6512
  ...this.attentionPatch(row)
@@ -8561,6 +8580,8 @@ function planAgentRun(o) {
8561
8580
  eventsPath: join19(streamsDir, `${base}.events.jsonl`),
8562
8581
  ticket: o.ticket,
8563
8582
  ticketRecordId: o.ticketRecordId ?? null,
8583
+ repo: o.repo ?? null,
8584
+ routeName: o.route.route,
8564
8585
  agentLog,
8565
8586
  promptVersion: o.route.resolved?.agentPersonas?.[o.role]?.historyId,
8566
8587
  promptSha
@@ -8905,6 +8926,10 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
8905
8926
  };
8906
8927
  try {
8907
8928
  const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
8929
+ try {
8930
+ clearShipAttention(new State(stateDirOfStream(id.streamPath)), agentLogRefusedKey(id.route));
8931
+ } catch {
8932
+ }
8908
8933
  if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
8909
8934
  if (sent.counterRetry) {
8910
8935
  try {
@@ -8937,6 +8962,15 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved)
8937
8962
  } catch {
8938
8963
  }
8939
8964
  }
8965
+ if (e instanceof AgentLogHttpError && e.row && e.status >= 400 && e.status < 500) {
8966
+ try {
8967
+ await raiseShipAttention(
8968
+ { state: new State(stateDirOfStream(id.streamPath)), emit },
8969
+ agentLogRefusedItem(id.route, id.role, e.status)
8970
+ );
8971
+ } catch {
8972
+ }
8973
+ }
8940
8974
  emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
8941
8975
  }
8942
8976
  }
@@ -8975,6 +9009,8 @@ function spawnAgent(plan, emit, hintMoved) {
8975
9009
  route: plan.route,
8976
9010
  ticket: plan.ticket ?? null,
8977
9011
  ticketRecordId: plan.ticketRecordId ?? null,
9012
+ repo: plan.repo ?? null,
9013
+ routeName: plan.routeName,
8978
9014
  model: plan.model,
8979
9015
  toolchain: id.toolchain,
8980
9016
  harness: id.harness,
@@ -9546,7 +9582,7 @@ function doctorSelftestLines(stored, toolchains) {
9546
9582
  }
9547
9583
 
9548
9584
  // src/cli.ts
9549
- import { resolve as resolve5, dirname as dirname20, join as join37, basename as basename12 } from "path";
9585
+ import { resolve as resolve5, dirname as dirname20, join as join38, basename as basename12 } from "path";
9550
9586
  import { fileURLToPath } from "url";
9551
9587
  import { hostname as hostname2, homedir as homedir10 } from "os";
9552
9588
  import { spawnSync as spawnSync7 } from "child_process";
@@ -13379,6 +13415,164 @@ Passed: ${passed}.${willClose ? " Closing this alert." : ""}`,
13379
13415
  return out;
13380
13416
  }
13381
13417
 
13418
+ // src/live-runs.ts
13419
+ import { existsSync as existsSync24, readdirSync as readdirSync10 } from "fs";
13420
+ import { join as join28 } from "path";
13421
+
13422
+ // src/interrupted.ts
13423
+ import { existsSync as existsSync23, readdirSync as readdirSync9, readFileSync as readFileSync21 } from "fs";
13424
+ import { join as join27 } from "path";
13425
+ var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
13426
+ function interruptedComment(b) {
13427
+ const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
13428
+ return `${INTERRUPTED_MARKER}
13429
+
13430
+ The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
13431
+ }
13432
+ var handled = /* @__PURE__ */ new Set();
13433
+ var pidAlive = (pid) => {
13434
+ try {
13435
+ process.kill(pid, 0);
13436
+ return true;
13437
+ } catch (e) {
13438
+ return e.code === "EPERM";
13439
+ }
13440
+ };
13441
+ function runFinished(streamPath) {
13442
+ const path = `${streamPath}.meta.json`;
13443
+ if (!existsSync23(path)) return false;
13444
+ try {
13445
+ return JSON.parse(readFileSync21(path, "utf8")).running !== true;
13446
+ } catch {
13447
+ return true;
13448
+ }
13449
+ }
13450
+ function orphanedStreams(o) {
13451
+ const dir = join27(o.stateDir, "streams");
13452
+ if (!existsSync23(dir)) return [];
13453
+ const ownerAlive = o.isOwnerAlive ?? pidAlive;
13454
+ const out = [];
13455
+ for (const name of readdirSync9(dir)) {
13456
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13457
+ const streamPath = join27(dir, name);
13458
+ if (runFinished(streamPath)) continue;
13459
+ const sidecar = readRunSidecar(streamPath);
13460
+ if (!sidecar || sidecar.route !== o.routeLabel) continue;
13461
+ if (ownerAlive(sidecar.ownerPid)) continue;
13462
+ out.push({ streamPath, sidecar });
13463
+ }
13464
+ return out;
13465
+ }
13466
+ async function reconcileRuns(o) {
13467
+ let adopted = 0;
13468
+ let interrupted = 0;
13469
+ for (const { streamPath, sidecar } of orphanedStreams(o)) {
13470
+ if (handled.has(streamPath)) continue;
13471
+ handled.add(streamPath);
13472
+ const isAlive = o.isAlive ?? runIsAlive;
13473
+ const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
13474
+ const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
13475
+ const work = (async () => {
13476
+ try {
13477
+ const result = await reattachRun(streamPath, sidecar, o.emit, {
13478
+ agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
13479
+ isAlive: o.isAlive,
13480
+ pollMs: o.pollMs,
13481
+ onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
13482
+ });
13483
+ if (result.adopted) adopted++;
13484
+ else interrupted++;
13485
+ try {
13486
+ await o.afterRun?.(sidecar, result);
13487
+ } catch (e) {
13488
+ o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
13489
+ }
13490
+ } catch (e) {
13491
+ o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
13492
+ } finally {
13493
+ if (lock?.ok) lock.release();
13494
+ if (ticketLock?.ok) ticketLock.release();
13495
+ }
13496
+ })();
13497
+ if (isAlive(sidecar)) {
13498
+ if (o.inFlight) {
13499
+ o.inFlight.add(work);
13500
+ void work.finally(() => o.inFlight.delete(work));
13501
+ }
13502
+ } else {
13503
+ await work;
13504
+ }
13505
+ }
13506
+ return { adopted, interrupted };
13507
+ }
13508
+
13509
+ // src/live-runs.ts
13510
+ function liveRuns(stateDir, isAlive = runIsAlive) {
13511
+ const dir = join28(stateDir, "streams");
13512
+ if (!existsSync24(dir)) return [];
13513
+ const out = [];
13514
+ for (const name of readdirSync10(dir)) {
13515
+ if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
13516
+ const streamPath = join28(dir, name);
13517
+ if (runFinished(streamPath)) continue;
13518
+ const sidecar = readRunSidecar(streamPath);
13519
+ if (!sidecar || !isAlive(sidecar)) continue;
13520
+ out.push({ ...sidecar, streamPath });
13521
+ }
13522
+ return out.sort((a, b) => a.spawnedAt.localeCompare(b.spawnedAt));
13523
+ }
13524
+ function siblingRuns(runs, ownTicketRecordId) {
13525
+ return runs.filter((r) => !ownTicketRecordId || r.ticketRecordId !== ownTicketRecordId);
13526
+ }
13527
+ async function settleRunEnd(o) {
13528
+ const { tracker, emit } = o;
13529
+ const all = o.runs ?? liveRuns(o.stateDir);
13530
+ const slug = routeSlug(o.route.route);
13531
+ const seatSiblings = siblingRuns(all.filter((r) => r.role === o.role && r.route === slug), o.ownTicketRecordId);
13532
+ const shipSiblings = siblingRuns(all, o.ownTicketRecordId);
13533
+ const newest = (rs) => rs[rs.length - 1];
13534
+ if (o.memberId) {
13535
+ try {
13536
+ if (seatSiblings.length) await tracker.setCrewStatus(o.memberId, "working", newest(seatSiblings).ticketRecordId);
13537
+ else await tracker.setCrewStatus(o.memberId, "idle");
13538
+ } catch (e) {
13539
+ emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
13540
+ }
13541
+ }
13542
+ try {
13543
+ if (shipSiblings.length) {
13544
+ const n = newest(shipSiblings);
13545
+ await tracker.beatEngaged(o.shipName, n.routeName ?? n.route, n.ticketRecordId, n.spawnedAt);
13546
+ } else {
13547
+ await tracker.beatIdle(o.shipName);
13548
+ }
13549
+ } catch (e) {
13550
+ emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
13551
+ }
13552
+ }
13553
+ function describeRun(run2, route, seatIds, worktreeOf = (d, n) => {
13554
+ try {
13555
+ return worktreeForNumber(d, n)?.path ?? null;
13556
+ } catch {
13557
+ return null;
13558
+ }
13559
+ }) {
13560
+ const ownRoute = run2.route === routeSlug(route.route);
13561
+ const repoDir = run2.repo ? reposOf(route).find((t) => t.name === run2.repo)?.dir : void 0;
13562
+ const num = run2.ticket?.replace(/^\D+/, "") ?? "";
13563
+ return {
13564
+ role: run2.role,
13565
+ seat: ownRoute ? seatIds[run2.role] ?? null : null,
13566
+ ticket: run2.ticket,
13567
+ ticketRecordId: run2.ticketRecordId,
13568
+ repo: run2.repo ?? null,
13569
+ worktree: repoDir && num ? worktreeOf(repoDir, num) : null,
13570
+ stream: run2.streamPath,
13571
+ pid: run2.pid,
13572
+ startedAt: run2.spawnedAt
13573
+ };
13574
+ }
13575
+
13382
13576
  // src/projects-cmd.ts
13383
13577
  async function buildProjectsReport(routes, open) {
13384
13578
  return Promise.all(routes.map(async (r) => {
@@ -13588,7 +13782,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
13588
13782
  }
13589
13783
 
13590
13784
  // src/watch.ts
13591
- import { watch as watchFile, existsSync as existsSync23, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
13785
+ import { watch as watchFile, existsSync as existsSync25, statSync as statSync7, openSync as openSync4, readSync as readSync2, closeSync as closeSync4 } from "fs";
13592
13786
  var E = "\x1B[";
13593
13787
  var dim = (s) => `${E}2m${s}${E}0m`;
13594
13788
  var bold = (s) => `${E}1m${s}${E}0m`;
@@ -13689,7 +13883,7 @@ function renderHeaders(states, now = Date.now()) {
13689
13883
  return states.map((s) => renderHeader(s, now)).join("");
13690
13884
  }
13691
13885
  function readFrom(file, from) {
13692
- if (!existsSync23(file)) return { events: [], offset: from };
13886
+ if (!existsSync25(file)) return { events: [], offset: from };
13693
13887
  const size = statSync7(file).size;
13694
13888
  if (size < from) return readFrom(file, 0);
13695
13889
  if (size === from) return { events: [], offset: from };
@@ -13932,17 +14126,17 @@ function nextRoles(decision, ran) {
13932
14126
  }
13933
14127
 
13934
14128
  // src/ship-name.ts
13935
- import { existsSync as existsSync25, mkdirSync as mkdirSync16, readFileSync as readFileSync22, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
14129
+ import { existsSync as existsSync27, mkdirSync as mkdirSync16, readFileSync as readFileSync23, renameSync as renameSync2, writeFileSync as writeFileSync18 } from "fs";
13936
14130
  import { dirname as dirname14 } from "path";
13937
14131
  import { parseDocument } from "yaml";
13938
14132
 
13939
14133
  // src/machine-id.ts
13940
14134
  import { execFileSync as execFileSync4 } from "child_process";
13941
14135
  import { createHash as createHash4 } from "crypto";
13942
- import { existsSync as existsSync24, mkdirSync as mkdirSync15, readFileSync as readFileSync21, writeFileSync as writeFileSync17 } from "fs";
13943
- import { dirname as dirname13, join as join27 } from "path";
14136
+ import { existsSync as existsSync26, mkdirSync as mkdirSync15, readFileSync as readFileSync22, writeFileSync as writeFileSync17 } from "fs";
14137
+ import { dirname as dirname13, join as join29 } from "path";
13944
14138
  function machineIdPathFor(stateDir) {
13945
- return join27(stateDir, "machine-id");
14139
+ return join29(stateDir, "machine-id");
13946
14140
  }
13947
14141
  var sha256 = (s) => createHash4("sha256").update(s).digest("hex");
13948
14142
  function readRawMachineId(platform2 = process.platform) {
@@ -13955,8 +14149,8 @@ function readRawMachineId(platform2 = process.platform) {
13955
14149
  return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out)?.[1];
13956
14150
  }
13957
14151
  for (const p of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
13958
- if (existsSync24(p)) {
13959
- const id = readFileSync21(p, "utf8").trim();
14152
+ if (existsSync26(p)) {
14153
+ const id = readFileSync22(p, "utf8").trim();
13960
14154
  if (id) return id;
13961
14155
  }
13962
14156
  }
@@ -13967,8 +14161,8 @@ function readRawMachineId(platform2 = process.platform) {
13967
14161
  function ensureMachineId(stateDir, opts = {}) {
13968
14162
  if (opts.override?.trim()) return sha256(opts.override.trim());
13969
14163
  const path = machineIdPathFor(stateDir);
13970
- if (existsSync24(path)) {
13971
- const cached = readFileSync21(path, "utf8").trim();
14164
+ if (existsSync26(path)) {
14165
+ const cached = readFileSync22(path, "utf8").trim();
13972
14166
  if (/^[0-9a-f]{64}$/.test(cached)) return cached;
13973
14167
  }
13974
14168
  const raw = (opts.read ?? readRawMachineId)();
@@ -14046,19 +14240,19 @@ async function syncRouteName(i) {
14046
14240
  return { status: "ok", ...base, action, name: i.local };
14047
14241
  }
14048
14242
  function readSyncedName(resolvedFile) {
14049
- if (!existsSync25(resolvedFile)) return void 0;
14243
+ if (!existsSync27(resolvedFile)) return void 0;
14050
14244
  try {
14051
- const v = JSON.parse(readFileSync22(resolvedFile, "utf8")).shipName;
14245
+ const v = JSON.parse(readFileSync23(resolvedFile, "utf8")).shipName;
14052
14246
  return typeof v === "string" ? v : void 0;
14053
14247
  } catch {
14054
14248
  return void 0;
14055
14249
  }
14056
14250
  }
14057
14251
  function writeSyncedName(resolvedFile, name) {
14058
- if (!existsSync25(resolvedFile)) return;
14252
+ if (!existsSync27(resolvedFile)) return;
14059
14253
  let raw;
14060
14254
  try {
14061
- raw = JSON.parse(readFileSync22(resolvedFile, "utf8"));
14255
+ raw = JSON.parse(readFileSync23(resolvedFile, "utf8"));
14062
14256
  } catch {
14063
14257
  return;
14064
14258
  }
@@ -14073,7 +14267,7 @@ function writeSyncedName(resolvedFile, name) {
14073
14267
  async function reconcileShipName(i) {
14074
14268
  let local = i.ship.name;
14075
14269
  try {
14076
- local = shipNameOfYaml(readFileSync22(i.configFile, "utf8")) ?? local;
14270
+ local = shipNameOfYaml(readFileSync23(i.configFile, "utf8")) ?? local;
14077
14271
  } catch {
14078
14272
  }
14079
14273
  const resolvedFile = resolvedPathFor(i.stateDir, i.route);
@@ -14097,7 +14291,7 @@ async function reconcileShipName(i) {
14097
14291
  }
14098
14292
  if (result.status !== "ok") return result;
14099
14293
  if (result.action === "adopt" || result.action === "row-wins") {
14100
- const text = readFileSync22(i.configFile, "utf8");
14294
+ const text = readFileSync23(i.configFile, "utf8");
14101
14295
  writeFileSync18(i.configFile, setShipNameInYaml(text, result.name));
14102
14296
  i.ship.name = result.name;
14103
14297
  if (result.action === "row-wins") {
@@ -14743,7 +14937,7 @@ function describeCapability(i) {
14743
14937
  }
14744
14938
 
14745
14939
  // src/agents-sync-run.ts
14746
- import { existsSync as existsSync26, mkdirSync as mkdirSync17, readFileSync as readFileSync23, writeFileSync as writeFileSync19 } from "fs";
14940
+ import { existsSync as existsSync28, mkdirSync as mkdirSync17, readFileSync as readFileSync24, writeFileSync as writeFileSync19 } from "fs";
14747
14941
  import { dirname as dirname15 } from "path";
14748
14942
  async function runAgentsSync(target, o) {
14749
14943
  const dryRun = o.dryRun ?? false;
@@ -14777,7 +14971,7 @@ async function runAgentsSync(target, o) {
14777
14971
  for (const s of skillResult?.outcomes ?? []) o.say(describeSkillSyncOutcome(s));
14778
14972
  if (!dryRun) {
14779
14973
  const resolvedPath = resolvedPathFor(o.stateDir, target.route);
14780
- const raw = existsSync26(resolvedPath) ? JSON.parse(readFileSync23(resolvedPath, "utf8")) : {};
14974
+ const raw = existsSync28(resolvedPath) ? JSON.parse(readFileSync24(resolvedPath, "utf8")) : {};
14781
14975
  raw.agentPersonas = personaResult.agentPersonas;
14782
14976
  if (skillResult) raw.agentSkills = skillResult.agentSkills;
14783
14977
  if (personaResult.releaseAgentId) raw.releaseAgentId = personaResult.releaseAgentId;
@@ -14886,7 +15080,7 @@ async function showLogEntry(route, entryId, opts = {}) {
14886
15080
 
14887
15081
  // src/repos-check.ts
14888
15082
  import { spawnSync as spawnSync6 } from "child_process";
14889
- import { readFileSync as readFileSync24 } from "fs";
15083
+ import { readFileSync as readFileSync25 } from "fs";
14890
15084
  import { basename as basename8 } from "path";
14891
15085
  var POSIX_SHELL = /^(?:ba|z|da|k|a|mk)?sh$|^fish$/;
14892
15086
  function checkHookSyntax(script, shell) {
@@ -14927,7 +15121,7 @@ function checkRepoSpec(dir, opts = {}) {
14927
15121
  }
14928
15122
  let parsed;
14929
15123
  try {
14930
- parsed = parseRepoConfig(readFileSync24(file, "utf8"), file);
15124
+ parsed = parseRepoConfig(readFileSync25(file, "utf8"), file);
14931
15125
  } catch (e) {
14932
15126
  if (!(e instanceof RepoConfigError)) throw e;
14933
15127
  return { ...empty, file, error: e.message, exitCode: 1 };
@@ -14984,7 +15178,7 @@ import { isMap as isMap2, parseDocument as parseDocument3, YAMLMap } from "yaml"
14984
15178
 
14985
15179
  // src/repos-cmd.ts
14986
15180
  import { homedir as homedir9 } from "os";
14987
- import { basename as basename9, join as join28, resolve as resolve3 } from "path";
15181
+ import { basename as basename9, join as join30, resolve as resolve3 } from "path";
14988
15182
  import { isMap, isSeq, parseDocument as parseDocument2, stringify as stringify2 } from "yaml";
14989
15183
  var ReposError = class extends Error {
14990
15184
  };
@@ -15262,7 +15456,7 @@ function planRepoClone(i) {
15262
15456
  }
15263
15457
  const ship = doc.get("ship", true);
15264
15458
  const basePath = String(m.get("reposBasePath") ?? (isMap(ship) ? ship.get("reposBasePath") : void 0) ?? "~/Crew");
15265
- const target = i.dir ? expandDir(i.dir, i.base) : join28(expandDir(basePath, i.base), i.route.split("/")[0], name);
15459
+ const target = i.dir ? expandDir(i.dir, i.base) : join30(expandDir(basePath, i.base), i.route.split("/")[0], name);
15266
15460
  const add = planRepoAdd({ text: i.text, base: i.base, route: i.route, path: target, origin: remote, resolved: i.resolved, name, isCheckout: i.isCheckout });
15267
15461
  return { ...add, remote, target };
15268
15462
  }
@@ -15661,7 +15855,7 @@ ${run2.err}` });
15661
15855
  }
15662
15856
 
15663
15857
  // src/sample-ticket.ts
15664
- import { basename as basename10, dirname as dirname17, join as join29 } from "path";
15858
+ import { basename as basename10, dirname as dirname17, join as join31 } from "path";
15665
15859
  var SAMPLE_REPO = "crew-sample";
15666
15860
  var SAMPLE_REMOTE = `local/${SAMPLE_REPO}`;
15667
15861
  var SAMPLE_MARKER = "sample: true";
@@ -15684,8 +15878,8 @@ has something safe to work on: its test and build hooks always pass, it has no
15684
15878
  remote, and \`crew sample-ticket --remove\` deletes it.
15685
15879
  `;
15686
15880
  function sampleDir(existing, reposBase, workspace) {
15687
- if (existing.length > 0) return join29(dirname17(existing[0]), SAMPLE_REPO);
15688
- return join29(reposBase, workspace, SAMPLE_REPO);
15881
+ if (existing.length > 0) return join31(dirname17(existing[0]), SAMPLE_REPO);
15882
+ return join31(reposBase, workspace, SAMPLE_REPO);
15689
15883
  }
15690
15884
  var SAMPLE_TITLE = "Sample: add a documented `greet` function with a test";
15691
15885
  function sampleDescription() {
@@ -15777,8 +15971,8 @@ function schedulerInstalled(plan, exists, crontab) {
15777
15971
  }
15778
15972
 
15779
15973
  // src/worktree-sweep.ts
15780
- import { existsSync as existsSync27, readFileSync as readFileSync25, readdirSync as readdirSync9 } from "fs";
15781
- import { basename as basename11, dirname as dirname18, join as join30, resolve as resolve4 } from "path";
15974
+ import { existsSync as existsSync29, readFileSync as readFileSync26, readdirSync as readdirSync11 } from "fs";
15975
+ import { basename as basename11, dirname as dirname18, join as join32, resolve as resolve4 } from "path";
15782
15976
  function planWorktreeSweep(target, tickets, contract) {
15783
15977
  const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
15784
15978
  const verified = contract.statuses.verified;
@@ -15837,7 +16031,7 @@ function siblingWorktreeDirs(target) {
15837
16031
  const registered = new Set(worktrees(target.dir).map((w) => resolve4(w.path)));
15838
16032
  let names;
15839
16033
  try {
15840
- names = readdirSync9(parent);
16034
+ names = readdirSync11(parent);
15841
16035
  } catch {
15842
16036
  return [];
15843
16037
  }
@@ -15845,7 +16039,7 @@ function siblingWorktreeDirs(target) {
15845
16039
  for (const name of names) {
15846
16040
  if (!name.startsWith(stem)) continue;
15847
16041
  const m = DIR_NUMBER_SUFFIXED.exec(name);
15848
- const path = join30(parent, name);
16042
+ const path = join32(parent, name);
15849
16043
  if (m && !registered.has(resolve4(path))) out.push({ path, number: m[1] });
15850
16044
  }
15851
16045
  return out;
@@ -15880,7 +16074,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15880
16074
  let removed = 0;
15881
16075
  let keptBranches = 0;
15882
16076
  for (const a of actions) {
15883
- if (!dryRun && base && !a.keepBranch && existsSync27(a.path) && isHandBackMerge(a.path, base)) {
16077
+ if (!dryRun && base && !a.keepBranch && existsSync29(a.path) && isHandBackMerge(a.path, base)) {
15884
16078
  if (gitOk(a.path, ["merge", "--abort"]) !== null) {
15885
16079
  log.emit(
15886
16080
  `abandoned the hand-back merge of ${base} in ${a.path}: ${a.ticket.issue_id} deployed without it`,
@@ -15888,7 +16082,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15888
16082
  );
15889
16083
  }
15890
16084
  }
15891
- if (!dryRun && existsSync27(a.path)) {
16085
+ if (!dryRun && existsSync29(a.path)) {
15892
16086
  let dirty = false;
15893
16087
  try {
15894
16088
  dirty = status(a.path).length > 0;
@@ -15899,7 +16093,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin",
15899
16093
  continue;
15900
16094
  }
15901
16095
  }
15902
- if (!dryRun && a.requirePushed && a.branch && existsSync27(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
16096
+ if (!dryRun && a.requirePushed && a.branch && existsSync29(a.path) && remoteConfigured(cwd, remote) && worktreeHoldsUnpushed(a.path, remote)) {
15903
16097
  refuse(a, "unpushed commits", `${a.branch} has commits that are not on ${remote}`);
15904
16098
  continue;
15905
16099
  }
@@ -15948,7 +16142,7 @@ function isHandBackMerge(path, base) {
15948
16142
  if (!msgPath) return false;
15949
16143
  let first;
15950
16144
  try {
15951
- first = readFileSync25(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
16145
+ first = readFileSync26(resolve4(path, msgPath), "utf8").split("\n")[0] ?? "";
15952
16146
  } catch {
15953
16147
  return false;
15954
16148
  }
@@ -16053,14 +16247,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
16053
16247
  }
16054
16248
 
16055
16249
  // src/stream-sweep.ts
16056
- import { existsSync as existsSync28, readdirSync as readdirSync10, rmSync as rmSync13, statSync as statSync8 } from "fs";
16057
- import { join as join31 } from "path";
16250
+ import { existsSync as existsSync30, readdirSync as readdirSync12, rmSync as rmSync13, statSync as statSync8 } from "fs";
16251
+ import { join as join33 } from "path";
16058
16252
  var DAY_MS2 = 24 * 60 * 60 * 1e3;
16059
16253
  function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
16060
- const dir = join31(stateDir, "streams");
16061
- if (!existsSync28(dir)) return [];
16254
+ const dir = join33(stateDir, "streams");
16255
+ if (!existsSync30(dir)) return [];
16062
16256
  const cutoff = now - retentionDays * DAY_MS2;
16063
- return readdirSync10(dir).map((name) => join31(dir, name)).filter((path) => {
16257
+ return readdirSync12(dir).map((name) => join33(dir, name)).filter((path) => {
16064
16258
  try {
16065
16259
  return statSync8(path).mtimeMs < cutoff;
16066
16260
  } catch {
@@ -16086,9 +16280,9 @@ function applyStreamSweep(paths, dryRun, log) {
16086
16280
  return removed;
16087
16281
  }
16088
16282
  function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16089
- if (!existsSync28(stateDir)) return [];
16283
+ if (!existsSync30(stateDir)) return [];
16090
16284
  const cutoff = now - retentionDays * DAY_MS2;
16091
- return readdirSync10(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join31(stateDir, name)).filter((path) => {
16285
+ return readdirSync12(stateDir).filter((name) => ROTATED_EVENTS_RE.test(name)).map((name) => join33(stateDir, name)).filter((path) => {
16092
16286
  try {
16093
16287
  return statSync8(path).mtimeMs < cutoff;
16094
16288
  } catch {
@@ -16098,23 +16292,23 @@ function planEventsSweep(stateDir, retentionDays, now = Date.now()) {
16098
16292
  }
16099
16293
 
16100
16294
  // src/cli.ts
16101
- import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync34, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync15 } from "fs";
16295
+ import { realpathSync as realpathSync3, readFileSync as readFileSync31, existsSync as existsSync35, mkdirSync as mkdirSync22, writeFileSync as writeFileSync23, rmSync as rmSync15, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync as renameSync3, statSync as statSync10, readdirSync as readdirSync16 } from "fs";
16102
16296
  import { createInterface } from "readline/promises";
16103
16297
  import { dirname as dirOf, resolve as resolvePath } from "path";
16104
16298
 
16105
16299
  // src/ssh-keys.ts
16106
16300
  import { execFileSync as execFileSync6 } from "child_process";
16107
- import { existsSync as existsSync29, mkdirSync as mkdirSync18, readFileSync as readFileSync26, chmodSync } from "fs";
16108
- import { dirname as dirname19, join as join32 } from "path";
16301
+ import { existsSync as existsSync31, mkdirSync as mkdirSync18, readFileSync as readFileSync27, chmodSync } from "fs";
16302
+ import { dirname as dirname19, join as join34 } from "path";
16109
16303
  var SshKeyError = class extends Error {
16110
16304
  };
16111
16305
  function shipSshKeyPathFor(stateDir) {
16112
- return join32(stateDir, "ssh", "id_ed25519");
16306
+ return join34(stateDir, "ssh", "id_ed25519");
16113
16307
  }
16114
16308
  function ensureShipSshKeypair(stateDir) {
16115
16309
  const privateKeyPath = shipSshKeyPathFor(stateDir);
16116
16310
  const publicKeyPath = `${privateKeyPath}.pub`;
16117
- if (!existsSync29(privateKeyPath)) {
16311
+ if (!existsSync31(privateKeyPath)) {
16118
16312
  mkdirSync18(dirname19(privateKeyPath), { recursive: true });
16119
16313
  try {
16120
16314
  execFileSync6("ssh-keygen", [
@@ -16135,10 +16329,10 @@ function ensureShipSshKeypair(stateDir) {
16135
16329
  }
16136
16330
  chmodSync(privateKeyPath, 384);
16137
16331
  }
16138
- if (!existsSync29(publicKeyPath)) {
16332
+ if (!existsSync31(publicKeyPath)) {
16139
16333
  throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
16140
16334
  }
16141
- const publicKey = readFileSync26(publicKeyPath, "utf8").trim();
16335
+ const publicKey = readFileSync27(publicKeyPath, "utf8").trim();
16142
16336
  return { privateKeyPath, publicKeyPath, publicKey };
16143
16337
  }
16144
16338
  function sshKeygenAvailable() {
@@ -16355,9 +16549,9 @@ function syncAllPassengerCheckouts(cfg) {
16355
16549
 
16356
16550
  // src/tunnel.ts
16357
16551
  import { spawn as spawn5 } from "child_process";
16358
- import { existsSync as existsSync30, mkdirSync as mkdirSync19, readFileSync as readFileSync27, writeFileSync as writeFileSync20, unlinkSync as unlinkSync3, readdirSync as readdirSync11 } from "fs";
16552
+ import { existsSync as existsSync32, mkdirSync as mkdirSync19, readFileSync as readFileSync28, writeFileSync as writeFileSync20, unlinkSync as unlinkSync3, readdirSync as readdirSync13 } from "fs";
16359
16553
  import { randomInt } from "crypto";
16360
- import { join as join33 } from "path";
16554
+ import { join as join35 } from "path";
16361
16555
  var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
16362
16556
  var defaultSpawn = (cmd, args) => spawn5(cmd, args, DEFAULT_SPAWN_OPTIONS);
16363
16557
  var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
@@ -16424,20 +16618,20 @@ function startTunnel(opts) {
16424
16618
  };
16425
16619
  }
16426
16620
  function tunnelStatePath(stateDir, workspaceId) {
16427
- return join33(stateDir, "passengers", `${workspaceId}.json`);
16621
+ return join35(stateDir, "passengers", `${workspaceId}.json`);
16428
16622
  }
16429
16623
  function readPersistedTunnel(stateDir, workspaceId) {
16430
16624
  const path = tunnelStatePath(stateDir, workspaceId);
16431
- if (!existsSync30(path)) return void 0;
16625
+ if (!existsSync32(path)) return void 0;
16432
16626
  try {
16433
- return JSON.parse(readFileSync27(path, "utf8"));
16627
+ return JSON.parse(readFileSync28(path, "utf8"));
16434
16628
  } catch {
16435
16629
  return void 0;
16436
16630
  }
16437
16631
  }
16438
16632
  function writePersistedTunnel(stateDir, state) {
16439
16633
  const path = tunnelStatePath(stateDir, state.workspaceId);
16440
- mkdirSync19(join33(stateDir, "passengers"), { recursive: true });
16634
+ mkdirSync19(join35(stateDir, "passengers"), { recursive: true });
16441
16635
  writeFileSync20(path, `${JSON.stringify(state, null, 2)}
16442
16636
  `);
16443
16637
  }
@@ -16535,9 +16729,9 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
16535
16729
  }
16536
16730
  }
16537
16731
  function readAllPersistedWorkspaceIds(stateDir) {
16538
- const dir = join33(stateDir, "passengers");
16539
- if (!existsSync30(dir)) return [];
16540
- return readdirSync11(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
16732
+ const dir = join35(stateDir, "passengers");
16733
+ if (!existsSync32(dir)) return [];
16734
+ return readdirSync13(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
16541
16735
  }
16542
16736
 
16543
16737
  // src/mcp-endpoint-registry.ts
@@ -16657,8 +16851,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
16657
16851
  import { createServer as createHttpServer } from "http";
16658
16852
  import { spawn as spawn6 } from "child_process";
16659
16853
  import { randomBytes } from "crypto";
16660
- import { existsSync as existsSync31, mkdirSync as mkdirSync20, readFileSync as readFileSync28, writeFileSync as writeFileSync21, unlinkSync as unlinkSync4, readdirSync as readdirSync12 } from "fs";
16661
- import { join as join34 } from "path";
16854
+ import { existsSync as existsSync33, mkdirSync as mkdirSync20, readFileSync as readFileSync29, writeFileSync as writeFileSync21, unlinkSync as unlinkSync4, readdirSync as readdirSync14 } from "fs";
16855
+ import { join as join36 } from "path";
16662
16856
 
16663
16857
  // src/secret-compare.ts
16664
16858
  import { timingSafeEqual } from "crypto";
@@ -16747,20 +16941,20 @@ var defaultSpawn2 = (cmd, args, env) => {
16747
16941
  return child;
16748
16942
  };
16749
16943
  function syncDaemonStatePath(stateDir, workspaceId) {
16750
- return join34(stateDir, "passengers", `${workspaceId}-sync.json`);
16944
+ return join36(stateDir, "passengers", `${workspaceId}-sync.json`);
16751
16945
  }
16752
16946
  function readPersistedSyncDaemon(stateDir, workspaceId) {
16753
16947
  const path = syncDaemonStatePath(stateDir, workspaceId);
16754
- if (!existsSync31(path)) return void 0;
16948
+ if (!existsSync33(path)) return void 0;
16755
16949
  try {
16756
- return JSON.parse(readFileSync28(path, "utf8"));
16950
+ return JSON.parse(readFileSync29(path, "utf8"));
16757
16951
  } catch {
16758
16952
  return void 0;
16759
16953
  }
16760
16954
  }
16761
16955
  function writePersistedSyncDaemon(stateDir, state) {
16762
16956
  const path = syncDaemonStatePath(stateDir, state.workspaceId);
16763
- mkdirSync20(join34(stateDir, "passengers"), { recursive: true });
16957
+ mkdirSync20(join36(stateDir, "passengers"), { recursive: true });
16764
16958
  writeFileSync21(path, `${JSON.stringify(state, null, 2)}
16765
16959
  `);
16766
16960
  }
@@ -16781,10 +16975,10 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
16781
16975
  }
16782
16976
  }
16783
16977
  function readAllPersistedSyncWorkspaceIds(stateDir) {
16784
- const dir = join34(stateDir, "passengers");
16785
- if (!existsSync31(dir)) return [];
16978
+ const dir = join36(stateDir, "passengers");
16979
+ if (!existsSync33(dir)) return [];
16786
16980
  const suffix = "-sync.json";
16787
- return readdirSync12(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
16981
+ return readdirSync14(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
16788
16982
  }
16789
16983
  function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
16790
16984
  const alive = deps.isPidAlive ?? isPidAlive;
@@ -16870,98 +17064,9 @@ function describeCause(err) {
16870
17064
  }
16871
17065
 
16872
17066
  // src/daemon.ts
16873
- import { existsSync as existsSync33, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync14, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
16874
- import { join as join36 } from "path";
17067
+ import { existsSync as existsSync34, mkdirSync as mkdirSync21, readFileSync as readFileSync30, readdirSync as readdirSync15, rmSync as rmSync14, statSync as statSync9, writeFileSync as writeFileSync22 } from "fs";
17068
+ import { join as join37 } from "path";
16875
17069
  import { randomBytes as randomBytes2 } from "crypto";
16876
-
16877
- // src/interrupted.ts
16878
- import { existsSync as existsSync32, readdirSync as readdirSync13, readFileSync as readFileSync29 } from "fs";
16879
- import { join as join35 } from "path";
16880
- var INTERRUPTED_MARKER = calloutHeader("NOTE", "Interrupted");
16881
- function interruptedComment(b) {
16882
- const tree = b.dirty === null ? "The worktree could not be checked." : b.dirty ? "The worktree has uncommitted changes \u2014 they are this seat's own, from that run." : "The worktree is clean.";
16883
- return `${INTERRUPTED_MARKER}
16884
-
16885
- The agent run on this ticket was cut short (${b.reason}) before it handed on${b.branch ? `; its branch is \`${b.branch}\`` : ""}. ${tree} The next run on this seat resumes from the worktree rather than starting over.`;
16886
- }
16887
- var handled = /* @__PURE__ */ new Set();
16888
- var pidAlive = (pid) => {
16889
- try {
16890
- process.kill(pid, 0);
16891
- return true;
16892
- } catch (e) {
16893
- return e.code === "EPERM";
16894
- }
16895
- };
16896
- function runFinished(streamPath) {
16897
- const path = `${streamPath}.meta.json`;
16898
- if (!existsSync32(path)) return false;
16899
- try {
16900
- return JSON.parse(readFileSync29(path, "utf8")).running !== true;
16901
- } catch {
16902
- return true;
16903
- }
16904
- }
16905
- function orphanedStreams(o) {
16906
- const dir = join35(o.stateDir, "streams");
16907
- if (!existsSync32(dir)) return [];
16908
- const ownerAlive = o.isOwnerAlive ?? pidAlive;
16909
- const out = [];
16910
- for (const name of readdirSync13(dir)) {
16911
- if (!name.endsWith(".jsonl") || name.endsWith(".events.jsonl")) continue;
16912
- const streamPath = join35(dir, name);
16913
- if (runFinished(streamPath)) continue;
16914
- const sidecar = readRunSidecar(streamPath);
16915
- if (!sidecar || sidecar.route !== o.routeLabel) continue;
16916
- if (ownerAlive(sidecar.ownerPid)) continue;
16917
- out.push({ streamPath, sidecar });
16918
- }
16919
- return out;
16920
- }
16921
- async function reconcileRuns(o) {
16922
- let adopted = 0;
16923
- let interrupted = 0;
16924
- for (const { streamPath, sidecar } of orphanedStreams(o)) {
16925
- if (handled.has(streamPath)) continue;
16926
- handled.add(streamPath);
16927
- const isAlive = o.isAlive ?? runIsAlive;
16928
- const lock = isAlive(sidecar) ? o.state.acquireRun(sidecar.role, o.maxConcurrentAgents, o.roleConcurrency?.[sidecar.role] ?? 1) : null;
16929
- const ticketLock = isAlive(sidecar) && sidecar.ticketRecordId ? o.state.acquireTicket(sidecar.ticketRecordId) : null;
16930
- const work = (async () => {
16931
- try {
16932
- const result = await reattachRun(streamPath, sidecar, o.emit, {
16933
- agentLogKey: await o.agentLogKey?.(sidecar.role).catch(() => void 0),
16934
- isAlive: o.isAlive,
16935
- pollMs: o.pollMs,
16936
- onAdopted: () => o.emit.emit(`adopted ${sidecar.role} run (pid ${sidecar.pid}) left by a previous crew process`, { step: "agent", role: sidecar.role })
16937
- });
16938
- if (result.adopted) adopted++;
16939
- else interrupted++;
16940
- try {
16941
- await o.afterRun?.(sidecar, result);
16942
- } catch (e) {
16943
- o.emit.warn(`could not follow up on ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
16944
- }
16945
- } catch (e) {
16946
- o.emit.warn(`could not reattach ${sidecar.role} run ${streamPath}: ${e.message}`, { step: "agent" });
16947
- } finally {
16948
- if (lock?.ok) lock.release();
16949
- if (ticketLock?.ok) ticketLock.release();
16950
- }
16951
- })();
16952
- if (isAlive(sidecar)) {
16953
- if (o.inFlight) {
16954
- o.inFlight.add(work);
16955
- void work.finally(() => o.inFlight.delete(work));
16956
- }
16957
- } else {
16958
- await work;
16959
- }
16960
- }
16961
- return { adopted, interrupted };
16962
- }
16963
-
16964
- // src/daemon.ts
16965
17070
  var DEFAULT_FLOOR_MS = 5e3;
16966
17071
  var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
16967
17072
  var defaultSleep = (ms2) => new Promise((resolve6) => setTimeout(resolve6, ms2));
@@ -17030,8 +17135,8 @@ function latestMtime(path) {
17030
17135
  if (stat.isFile()) return stat.mtimeMs;
17031
17136
  if (!stat.isDirectory()) return 0;
17032
17137
  let latest = stat.mtimeMs;
17033
- for (const entry of readdirSync14(path, { withFileTypes: true })) {
17034
- latest = Math.max(latest, latestMtime(join36(path, entry.name)));
17138
+ for (const entry of readdirSync15(path, { withFileTypes: true })) {
17139
+ latest = Math.max(latest, latestMtime(join37(path, entry.name)));
17035
17140
  }
17036
17141
  return latest;
17037
17142
  }
@@ -17040,13 +17145,13 @@ function computeTreeSignature(paths) {
17040
17145
  }
17041
17146
  function makeStaleChecker(paths, opts = {}) {
17042
17147
  const initial = computeTreeSignature(paths);
17043
- return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync33(opts.reloadSentinel);
17148
+ return () => computeTreeSignature(paths) !== initial || opts.reloadSentinel !== void 0 && existsSync34(opts.reloadSentinel);
17044
17149
  }
17045
17150
  function reloadSentinelFor(stateDir) {
17046
- return join36(stateDir, "reload-requested");
17151
+ return join37(stateDir, "reload-requested");
17047
17152
  }
17048
17153
  function daemonStampFor(stateDir) {
17049
- return join36(stateDir, "daemon.started");
17154
+ return join37(stateDir, "daemon.started");
17050
17155
  }
17051
17156
  function noteDaemonStarted(stateDir, now = Date.now()) {
17052
17157
  mkdirSync21(stateDir, { recursive: true });
@@ -17309,13 +17414,15 @@ async function reconcileForRoute(ctx, o) {
17309
17414
  await guardRunHandoff(route, ship, tracker, sc.role, sc.ticketRecordId, emit, sc.spawnedAt);
17310
17415
  }
17311
17416
  } finally {
17312
- if (memberId) {
17313
- await tracker.setCrewStatus(memberId, "idle").catch((e) => {
17314
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
17315
- });
17316
- }
17317
- await tracker.beatIdle(ship.name).catch((e) => {
17318
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
17417
+ await settleRunEnd({
17418
+ tracker,
17419
+ shipName: ship.name,
17420
+ route,
17421
+ role: sc.role,
17422
+ memberId,
17423
+ ownTicketRecordId: sc.ticketRecordId,
17424
+ stateDir: ship.stateDir,
17425
+ emit
17319
17426
  });
17320
17427
  }
17321
17428
  }
@@ -17469,18 +17576,16 @@ async function runRoleAgentLocked(role, decision, ctx, hooks, held) {
17469
17576
  await spawnAgent(plan, emit, hintMoved);
17470
17577
  await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
17471
17578
  } finally {
17472
- if (memberId) {
17473
- try {
17474
- await tracker.setCrewStatus(memberId, "idle");
17475
- } catch (e) {
17476
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
17477
- }
17478
- }
17479
- try {
17480
- await tracker.beatIdle(ship.name);
17481
- } catch (e) {
17482
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
17483
- }
17579
+ await settleRunEnd({
17580
+ tracker,
17581
+ shipName: ship.name,
17582
+ route,
17583
+ role,
17584
+ memberId,
17585
+ ownTicketRecordId: workingId,
17586
+ stateDir: ship.stateDir,
17587
+ emit
17588
+ });
17484
17589
  }
17485
17590
  }
17486
17591
  async function buildEnvironment(route, ship, ticket) {
@@ -17922,7 +18027,7 @@ if (command === "passenger-sync-daemon") {
17922
18027
  } else {
17923
18028
  const toleratesNoRoutes = command === "repos" || command === "ship" || command === "disconnect" && flag("reset") || command === "connect" && flag("sign-out");
17924
18029
  const configless = command === "connect" && flag("sign-out") || command === "disconnect" && flag("reset");
17925
- if (configless && !existsSync34(findConfigFile(CREW_HOME))) {
18030
+ if (configless && !existsSync35(findConfigFile(CREW_HOME))) {
17926
18031
  cfg = loadConfig(CREW_HOME, firstRunConfigPath(), { text: "ship: {}\n", allowNoRoutes: true });
17927
18032
  } else {
17928
18033
  cfg = loadConfig(CREW_HOME, void 0, toleratesNoRoutes ? { allowNoRoutes: true } : {});
@@ -18160,8 +18265,8 @@ if (command === "passenger-sync-daemon") {
18160
18265
  }
18161
18266
  if (host === "linux" && hasSystemd) {
18162
18267
  const label = labelFor(CREW_HOME, "run");
18163
- const staleTimer = join37(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18164
- if (existsSync34(staleTimer)) {
18268
+ const staleTimer = join38(homedir10(), ".config", "systemd", "user", `${label}.timer`);
18269
+ if (existsSync35(staleTimer)) {
18165
18270
  process.stdout.write(
18166
18271
  `removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
18167
18272
  `
@@ -18188,7 +18293,7 @@ if (command === "passenger-sync-daemon") {
18188
18293
  const tracker = new Tracker(c, cfg.ship);
18189
18294
  problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
18190
18295
  for (const r of await resolvedRepos(c)) {
18191
- if (!existsSync34(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18296
+ if (!existsSync35(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
18192
18297
  if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
18193
18298
  readiness.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
18194
18299
  }
@@ -18206,7 +18311,7 @@ if (command === "passenger-sync-daemon") {
18206
18311
  }
18207
18312
  async function doctorFix(o) {
18208
18313
  const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
18209
- const unitExists = (p) => existsSync34(p);
18314
+ const unitExists = (p) => existsSync35(p);
18210
18315
  const crontab = o.host === "linux" ? spawnSync7("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
18211
18316
  const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
18212
18317
  const plan = planFix({ routes, schedulerAbsent, host: o.host });
@@ -18995,18 +19100,16 @@ ${cu.tail.trimEnd()}
18995
19100
  await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit, runStart);
18996
19101
  } finally {
18997
19102
  dropLock();
18998
- if (fleetMemberId) {
18999
- try {
19000
- await fleetTracker.setCrewStatus(fleetMemberId, "idle");
19001
- } catch (e) {
19002
- wemit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
19003
- }
19004
- }
19005
- try {
19006
- await fleetTracker.beatIdle(cfg.ship.name);
19007
- } catch (e) {
19008
- wemit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
19009
- }
19103
+ await settleRunEnd({
19104
+ tracker: fleetTracker,
19105
+ shipName: cfg.ship.name,
19106
+ route: w.route,
19107
+ role: w.role,
19108
+ memberId: fleetMemberId,
19109
+ ownTicketRecordId: fleetWorkingId,
19110
+ stateDir: cfg.ship.stateDir,
19111
+ emit: wemit
19112
+ });
19010
19113
  }
19011
19114
  if (!skipInlineRelease) await releaseFleet();
19012
19115
  break;
@@ -19236,18 +19339,16 @@ ${cu.tail.trimEnd()}
19236
19339
  await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit, runStart);
19237
19340
  } finally {
19238
19341
  dropLock();
19239
- if (memberId) {
19240
- try {
19241
- await tracker2.setCrewStatus(memberId, "idle");
19242
- } catch (e) {
19243
- emit.warn(`could not clear crew status: ${e.message}`, { step: "agent" });
19244
- }
19245
- }
19246
- try {
19247
- await tracker2.beatIdle(cfg.ship.name);
19248
- } catch (e) {
19249
- emit.warn(`could not beat ship idle: ${e.message}`, { step: "agent" });
19250
- }
19342
+ await settleRunEnd({
19343
+ tracker: tracker2,
19344
+ shipName: cfg.ship.name,
19345
+ route,
19346
+ role: current,
19347
+ memberId,
19348
+ ownTicketRecordId: workingId,
19349
+ stateDir: cfg.ship.stateDir,
19350
+ emit
19351
+ });
19251
19352
  }
19252
19353
  ran.push(current);
19253
19354
  if (ran.length >= maxRoles || explicitRole) break;
@@ -19331,7 +19432,7 @@ ${cu.tail.trimEnd()}
19331
19432
  process.exit(1);
19332
19433
  }
19333
19434
  requireArmed("run the daemon loop");
19334
- const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join37(CREW_HOME, "src"), join37(CREW_HOME, "dist"), join37(CREW_HOME, "bin")];
19435
+ const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join38(CREW_HOME, "src"), join38(CREW_HOME, "dist"), join38(CREW_HOME, "bin")];
19335
19436
  const configPaths = daemonConfigPaths(cfg);
19336
19437
  watchPaths.push(...configPaths);
19337
19438
  noteDaemonStarted(cfg.ship.stateDir);
@@ -19560,7 +19661,7 @@ ${cu.tail.trimEnd()}
19560
19661
  `);
19561
19662
  if (!dryRun) {
19562
19663
  const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
19563
- const raw = existsSync34(resolvedPath) ? JSON.parse(readFileSync31(resolvedPath, "utf8")) : {};
19664
+ const raw = existsSync35(resolvedPath) ? JSON.parse(readFileSync31(resolvedPath, "utf8")) : {};
19564
19665
  raw.agentSkills = result.agentSkills;
19565
19666
  mkdirSync22(dirOf(resolvedPath), { recursive: true });
19566
19667
  writeFileSync23(resolvedPath, `${JSON.stringify(raw, null, 2)}
@@ -19664,10 +19765,10 @@ ${cu.tail.trimEnd()}
19664
19765
  try {
19665
19766
  const a = parseHooksArgs(positional.slice(2), routeNamesOf(configText));
19666
19767
  const dir = repoDirOf(configText, dirname20(cfg.configFile), a.route, a.name);
19667
- if (!existsSync34(dir)) throw new ReposError(`${dir} does not exist on disk`);
19668
- const files = { exists: (r) => existsSync34(resolvePath(dir, r)), list: (r) => {
19768
+ if (!existsSync35(dir)) throw new ReposError(`${dir} does not exist on disk`);
19769
+ const files = { exists: (r) => existsSync35(resolvePath(dir, r)), list: (r) => {
19669
19770
  try {
19670
- return readdirSync15(resolvePath(dir, r));
19771
+ return readdirSync16(resolvePath(dir, r));
19671
19772
  } catch {
19672
19773
  return [];
19673
19774
  }
@@ -19783,7 +19884,7 @@ ${cu.tail.trimEnd()}
19783
19884
  if (sub === "list" && !routeName) usage2();
19784
19885
  if (sub === "add") {
19785
19886
  try {
19786
- const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync34(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
19887
+ const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync35(resolvePath(p.replace(/^~(?=\/|$)/, homedir10()))));
19787
19888
  routeName = a.route;
19788
19889
  addPath = a.path;
19789
19890
  addName = a.name;
@@ -19812,7 +19913,7 @@ ${cu.tail.trimEnd()}
19812
19913
  }
19813
19914
  const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
19814
19915
  let resolvedRemotes;
19815
- if (existsSync34(resolvedFile)) {
19916
+ if (existsSync35(resolvedFile)) {
19816
19917
  try {
19817
19918
  resolvedRemotes = JSON.parse(readFileSync31(resolvedFile, "utf8"));
19818
19919
  } catch {
@@ -19821,7 +19922,7 @@ ${cu.tail.trimEnd()}
19821
19922
  const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
19822
19923
  const text = configText;
19823
19924
  const base = dirname20(cfg.configFile);
19824
- const isCheckout = (d) => existsSync34(`${d}/.git`);
19925
+ const isCheckout = (d) => existsSync35(`${d}/.git`);
19825
19926
  try {
19826
19927
  if (sub === "list") {
19827
19928
  if (flag("json")) {
@@ -19831,7 +19932,7 @@ ${cu.tail.trimEnd()}
19831
19932
  base,
19832
19933
  resolved: resolvedRemotes,
19833
19934
  originOf,
19834
- exists: (d) => existsSync34(d),
19935
+ exists: (d) => existsSync35(d),
19835
19936
  isCheckout,
19836
19937
  repoHasHooks: (d) => REPO_CONFIG_FILENAMES.some((f) => {
19837
19938
  try {
@@ -19852,7 +19953,7 @@ ${cu.tail.trimEnd()}
19852
19953
  for (const r of repos) {
19853
19954
  const origin2 = originOf(r.dir);
19854
19955
  const row = matchRepoName(origin2, resolvedRemotes);
19855
- const note = !existsSync34(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
19956
+ const note = !existsSync35(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
19856
19957
  process.stdout.write(`${r.name} ${r.dir} ${note}
19857
19958
  `);
19858
19959
  }
@@ -19864,9 +19965,9 @@ ${cu.tail.trimEnd()}
19864
19965
  ` : `${human}
19865
19966
  `);
19866
19967
  const plan2 = planRepoClone({ text, base, route: routeName, name: addName, resolved: resolvedRemotes, dir: value("dir"), isCheckout });
19867
- if (existsSync34(plan2.target) && (existsSync34(`${plan2.target}/.git`) || readdirSync15(plan2.target).length > 0)) {
19968
+ if (existsSync35(plan2.target) && (existsSync35(`${plan2.target}/.git`) || readdirSync16(plan2.target).length > 0)) {
19868
19969
  throw new ReposError(
19869
- existsSync34(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
19970
+ existsSync35(`${plan2.target}/.git`) ? `${plan2.target} is already a checkout \u2014 attach it with \`crew repos add ${routeName} ${plan2.name} ${plan2.target}\`` : `${plan2.target} exists and is not empty \u2014 pass --dir to clone somewhere else`
19870
19971
  );
19871
19972
  }
19872
19973
  if (dryRun) {
@@ -19891,7 +19992,7 @@ ${cu.tail.trimEnd()}
19891
19992
  break;
19892
19993
  }
19893
19994
  const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir10()));
19894
- const top = existsSync34(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
19995
+ const top = existsSync35(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
19895
19996
  if (!top) throw new ReposError(`${target} is not a git checkout`);
19896
19997
  const origin = originOf(top);
19897
19998
  const plan = planRepoAdd({
@@ -19943,7 +20044,7 @@ ${cu.tail.trimEnd()}
19943
20044
  const created = await rowTracker.createRepoRow(rowPayload);
19944
20045
  process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
19945
20046
  `);
19946
- if (existsSync34(resolvedFile)) {
20047
+ if (existsSync35(resolvedFile)) {
19947
20048
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
19948
20049
  raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
19949
20050
  raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
@@ -20360,7 +20461,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
20360
20461
  }
20361
20462
  }
20362
20463
  let previous;
20363
- const hadPreviousFile = existsSync34(resolvedPath);
20464
+ const hadPreviousFile = existsSync35(resolvedPath);
20364
20465
  if (hadPreviousFile) {
20365
20466
  try {
20366
20467
  previous = JSON.parse(readFileSync31(resolvedPath, "utf8"));
@@ -21402,7 +21503,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21402
21503
  }
21403
21504
  if (row) {
21404
21505
  await tracker.deleteRepoRow(row.id);
21405
- if (existsSync34(resolvedFile)) {
21506
+ if (existsSync35(resolvedFile)) {
21406
21507
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
21407
21508
  if (raw.repoNames) delete raw.repoNames[row.id];
21408
21509
  if (raw.repoRemotes) delete raw.repoRemotes[row.id];
@@ -21410,21 +21511,21 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21410
21511
  `);
21411
21512
  }
21412
21513
  }
21413
- if (existsSync34(join37(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync15(dir, { recursive: true, force: true });
21514
+ if (existsSync35(join38(dir, ".git")) && basename12(dir) === SAMPLE_REPO) rmSync15(dir, { recursive: true, force: true });
21414
21515
  say("removed", { dir, closed }, `removed the sample repo${closed.length ? `, closed ${closed.join(", ")}` : ""}`);
21415
21516
  break;
21416
21517
  }
21417
21518
  const projectId = route.resolved.areaId ?? resolveRepoProject(await tracker.projectRows(), value("project"));
21418
- const fresh = !existsSync34(join37(dir, ".git"));
21419
- if (existsSync34(dir) && fresh && readdirSync15(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
21519
+ const fresh = !existsSync35(join38(dir, ".git"));
21520
+ if (existsSync35(dir) && fresh && readdirSync16(dir).length > 0) fail(`${dir} exists and is not a git checkout \u2014 move it aside`);
21420
21521
  if (dryRun) {
21421
21522
  say("dry-run", { dir, newCheckout: fresh, repoRow: row?.id ?? null }, `would ${fresh ? "create" : "reuse"} ${dir}${row ? "" : ", create the Repos row"}, and file one ${tracker.contract.statuses.approved} ticket`);
21422
21523
  break;
21423
21524
  }
21424
21525
  if (fresh) {
21425
21526
  mkdirSync22(dir, { recursive: true });
21426
- writeFileSync23(join37(dir, "README.md"), SAMPLE_README);
21427
- writeFileSync23(join37(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
21527
+ writeFileSync23(join38(dir, "README.md"), SAMPLE_README);
21528
+ writeFileSync23(join38(dir, ".crew.yaml"), SAMPLE_CREW_YAML);
21428
21529
  const g = (...a) => {
21429
21530
  const r = spawnSync7("git", a, { cwd: dir, encoding: "utf8" });
21430
21531
  if (r.status !== 0) fail(`git ${a[0]} failed in ${dir}: ${(r.stderr || r.stdout).trim()}`);
@@ -21442,7 +21543,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21442
21543
  spec: { platform: "unix", releaseMode: "integrate", ciProvider: "none" }
21443
21544
  });
21444
21545
  repoId = (await tracker.createRepoRow(payload)).id;
21445
- if (existsSync34(resolvedFile)) {
21546
+ if (existsSync35(resolvedFile)) {
21446
21547
  const raw = JSON.parse(readFileSync31(resolvedFile, "utf8"));
21447
21548
  raw.repoNames = { ...raw.repoNames, [repoId]: SAMPLE_REPO };
21448
21549
  raw.repoRemotes = { ...raw.repoRemotes, [repoId]: SAMPLE_REMOTE };
@@ -21573,6 +21674,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21573
21674
  const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
21574
21675
  const projects = projectModeLabels(await statusTracker.projectRowsWithModes().catch(() => []));
21575
21676
  const crewMinVersion = await readCrewFloor(route, cfg.ship) ?? null;
21677
+ const runs = liveRuns(cfg.ship.stateDir);
21576
21678
  if (flag("json")) {
21577
21679
  const nameSync = await reconcileRouteShipName({ route, ship: cfg.ship, configFile: cfg.configFile, readOnly: true });
21578
21680
  const repos = await resolvedRepos(route);
@@ -21590,7 +21692,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21590
21692
  label: ROLE_LABEL[r],
21591
21693
  crewId: seatId ?? null,
21592
21694
  name: crewRow?.name ?? null,
21593
- personaName
21695
+ personaName,
21696
+ // Additive (CREW-2822): how many runs this seat has live right now.
21697
+ running: runs.filter((x) => x.role === r && x.route === routeSlug(route.route)).length
21594
21698
  };
21595
21699
  });
21596
21700
  process.stdout.write(`${JSON.stringify({
@@ -21633,6 +21737,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
21633
21737
  roles: ROLE_NAMES.map((r) => ({ name: r, label: ROLE_LABEL[r], paused: state.isRolePaused(r) })),
21634
21738
  seats: seats2
21635
21739
  },
21740
+ // Additive (CREW-2822): every live run on this ship. `version` stays 1.
21741
+ runs: runs.map((x) => describeRun(x, route, seatIds)),
21636
21742
  watermark: state.watermark(),
21637
21743
  route: {
21638
21744
  name: route.route,
@@ -21745,6 +21851,10 @@ watermark: ${state.watermark()}
21745
21851
  `
21746
21852
  );
21747
21853
  }
21854
+ for (const x of runs) {
21855
+ process.stdout.write(`running: ${x.role} \u2014 ${x.ticket ?? "(no ticket)"}${x.repo ? ` in ${x.repo}` : ""} (pid ${x.pid}, since ${x.spawnedAt})
21856
+ `);
21857
+ }
21748
21858
  if (shipRows.length) {
21749
21859
  process.stdout.write("ships:\n");
21750
21860
  for (const s of shipRows) {
@@ -21773,7 +21883,7 @@ watermark: ${state.watermark()}
21773
21883
  }
21774
21884
  for (const r of repos) {
21775
21885
  const remote = r.config.branch.remote;
21776
- if (existsSync34(r.dir)) {
21886
+ if (existsSync35(r.dir)) {
21777
21887
  pushAuth.push({
21778
21888
  route: c.route,
21779
21889
  repo: r.name,
@@ -21834,7 +21944,7 @@ routes: ${cfg.routes.map((c) => c.route).join(", ")}
21834
21944
  `);
21835
21945
  try {
21836
21946
  const evFile = eventFileFor(cfg.ship.stateDir);
21837
- const evSize = existsSync34(evFile) ? statSync10(evFile).size : 0;
21947
+ const evSize = existsSync35(evFile) ? statSync10(evFile).size : 0;
21838
21948
  process.stdout.write(
21839
21949
  evSize > cfg.ship.eventsRotateBytes * 4 ? `events log: WARN ${evFile} is ${Math.round(evSize / 1048576)} MB (> 4x eventsRotateBytes); run \`crew reap\` to rotate it
21840
21950
  ` : "events log: ok\n"