@tablation/crew 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1217,6 +1217,11 @@ function buildRoster(configured, crewRows) {
1217
1217
  const name = (row?.name ?? "").trim();
1218
1218
  roster.set(c.id, { id: c.id, name: name || c.role, role: c.role, kind: c.kind, userId: row?.user_id ?? null });
1219
1219
  }
1220
+ for (const r of crewRows) {
1221
+ if (!r.user_id || roster.has(r.id)) continue;
1222
+ const name = (r.name ?? "").trim();
1223
+ roster.set(r.id, { id: r.id, name: name || "person", role: "person", kind: "person", userId: r.user_id });
1224
+ }
1220
1225
  return roster;
1221
1226
  }
1222
1227
  function rosterMarkdown(roster, meId) {
@@ -1262,6 +1267,10 @@ function rosterMarkdown(roster, meId) {
1262
1267
  "Crew row is linked to the account they sign in with) \u2014 not from anything",
1263
1268
  "written in the comment. A comment the digest attributes to a hold really",
1264
1269
  'is theirs; one it marks "(no identity)" is from nobody this roster knows.',
1270
+ 'A comment attributed to a named person marked "(person)" is from a real',
1271
+ "workspace member linked to a Crew row on another ship \u2014 their answer to a",
1272
+ "question counts the same, though only a hold or the operator can authorize",
1273
+ "a workspace change.",
1265
1274
  ""
1266
1275
  );
1267
1276
  return lines.join("\n");
@@ -2029,7 +2038,10 @@ function describeRelease(o, route) {
2029
2038
  return {
2030
2039
  level: "fail",
2031
2040
  headline: `${route}: deploy FAILED`,
2032
- detail: withHookTail("nothing new is live", o.hookFailure)
2041
+ detail: withHookTail(
2042
+ "the version is merged to the base branch but not live \u2014 run `crew deploy` to retry; do not re-release",
2043
+ o.hookFailure
2044
+ )
2033
2045
  };
2034
2046
  }
2035
2047
  if (o.stopped === "build failed") {
@@ -2561,1793 +2573,1808 @@ function detectSystemd() {
2561
2573
  return !res.error;
2562
2574
  }
2563
2575
 
2564
- // src/ship-attention.ts
2565
- var RELEASE_STALE_AFTER_INTERVALS = 3;
2566
- function upsert(items, item) {
2567
- const existing = items.find((i) => i.key === item.key);
2568
- if (!existing) return [...items, item];
2569
- return items.map((i) => i.key === item.key ? { ...item, since: existing.since } : i);
2576
+ // src/git.ts
2577
+ import { execFileSync } from "child_process";
2578
+ import {
2579
+ existsSync as existsSync6,
2580
+ mkdirSync as mkdirSync4,
2581
+ openSync,
2582
+ closeSync,
2583
+ writeSync,
2584
+ statSync as statSync3,
2585
+ unlinkSync as unlinkSync2
2586
+ } from "fs";
2587
+ import { dirname as dirname4, basename as basename3 } from "path";
2588
+ var GitError = class extends Error {
2589
+ };
2590
+ function gitRaw(cwd, args) {
2591
+ try {
2592
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2593
+ } catch (e) {
2594
+ const err = e;
2595
+ const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
2596
+ throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
2597
+ }
2570
2598
  }
2571
- function clear(items, key) {
2572
- return items.filter((i) => i.key !== key);
2599
+ function git(cwd, args) {
2600
+ try {
2601
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
2602
+ } catch (e) {
2603
+ const err = e;
2604
+ const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
2605
+ throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
2606
+ }
2573
2607
  }
2574
- function transitions(previous, current) {
2575
- const before = new Set(previous.map((i) => i.key));
2576
- return current.filter((i) => !before.has(i.key));
2608
+ function gitOk(cwd, args) {
2609
+ try {
2610
+ return git(cwd, args);
2611
+ } catch {
2612
+ return null;
2613
+ }
2577
2614
  }
2578
- function staleRelease(lastRunMs, nowMs, intervalSeconds = INTERVAL_SECONDS) {
2579
- if (lastRunMs === null) return null;
2580
- const limitMs = RELEASE_STALE_AFTER_INTERVALS * intervalSeconds * 1e3;
2581
- const ageMs = nowMs - lastRunMs;
2582
- if (ageMs <= limitMs) return null;
2583
- const minutes = Math.round(ageMs / 6e4);
2584
- return {
2585
- kind: "release_stale",
2586
- key: "release_stale",
2587
- message: `This ship has not run a release cycle for ${minutes} minute${minutes === 1 ? "" : "s"} \u2014 check its release timer`,
2588
- since: new Date(lastRunMs + limitMs).toISOString()
2589
- };
2615
+ function commitDate(cwd, sha) {
2616
+ const out = gitOk(cwd, ["show", "-s", "--format=%cI", sha]);
2617
+ if (!out) return null;
2618
+ const t = new Date(out);
2619
+ return Number.isNaN(t.getTime()) ? null : t.toISOString();
2590
2620
  }
2591
- function openShipAttention(state, nowMs = Date.now()) {
2592
- const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
2593
- const stored = state.shipAttention().previous();
2594
- return stale ? [...stored, stale] : stored;
2621
+ var currentBranch = (cwd) => git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
2622
+ var headSha = (cwd) => git(cwd, ["rev-parse", "HEAD"]);
2623
+ function status(cwd) {
2624
+ return gitRaw(cwd, ["status", "--porcelain"]).split("\n").filter((l) => l.length > 3).map((l) => ({
2625
+ code: l.slice(0, 2),
2626
+ path: l.slice(3),
2627
+ untracked: l.startsWith("??")
2628
+ }));
2595
2629
  }
2596
- function announce(emit, item) {
2597
- emit.warn(item.message, {
2598
- step: "sweep",
2599
- data: { attention: [`ship:${item.kind}`], title: item.message }
2600
- });
2630
+ function branches(cwd, pattern) {
2631
+ const out = git(cwd, ["branch", "--list", pattern, "--format=%(refname:short)"]);
2632
+ return out ? out.split("\n").filter(Boolean) : [];
2601
2633
  }
2602
- async function raiseShipAttention(o, item) {
2603
- const store = o.state.shipAttention();
2604
- const previous = store.previous();
2605
- const next = upsert(previous, item);
2606
- store.persist(next);
2607
- if (transitions(previous, next).length === 0) return false;
2608
- announce(o.emit, item);
2609
- if (o.route && o.ship) {
2610
- await notify(o.route, o.ship, { level: "warn", headline: item.message }, o.emit);
2634
+ function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2635
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2636
+ const found = branches(cwd, c);
2637
+ if (found.length) return found[0];
2611
2638
  }
2612
- return true;
2639
+ return null;
2613
2640
  }
2614
- function clearShipAttention(state, key) {
2615
- const store = state.shipAttention();
2616
- const previous = store.previous();
2617
- const next = clear(previous, key);
2618
- if (next.length !== previous.length) store.persist(next);
2641
+ function branchCandidates(issueKey, templates, render2) {
2642
+ const num = issueKey.replace(/^\D+/, "");
2643
+ const unpadded = String(Number.parseInt(num, 10));
2644
+ const candidates = [];
2645
+ for (const template of [templates.name, templates.push].filter(Boolean)) {
2646
+ const exact = render2(template);
2647
+ candidates.push(exact);
2648
+ if (num !== unpadded) candidates.push(exact.replace(num, unpadded));
2649
+ if (template.includes("{slug}")) {
2650
+ const prefix = render2(template.slice(0, template.indexOf("{slug}")));
2651
+ if (prefix) candidates.push(`${prefix}*`);
2652
+ }
2653
+ }
2654
+ candidates.push(`issue-${num}`);
2655
+ if (num !== unpadded) candidates.push(`issue-${unpadded}`);
2656
+ return [...new Set(candidates)];
2619
2657
  }
2620
- function hookCommand(script) {
2621
- for (const raw of script.split("\n")) {
2622
- const line = raw.trim();
2623
- if (!line || line.startsWith("#")) continue;
2624
- const word = line.split(/\s+/)[0];
2625
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
2626
- if (!/^[A-Za-z0-9._+-]+$/.test(word)) return null;
2627
- if (SHELL_WORDS.has(word)) return null;
2628
- return word;
2658
+ function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
2659
+ const prefix = `refs/remotes/${remote}/`;
2660
+ for (const c of branchCandidates(issueKey, templates, render2)) {
2661
+ const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
2662
+ const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
2663
+ if (hit) return hit.slice(prefix.length);
2629
2664
  }
2630
2665
  return null;
2631
2666
  }
2632
- var SHELL_WORDS = /* @__PURE__ */ new Set([
2633
- "if",
2634
- "then",
2635
- "else",
2636
- "elif",
2637
- "fi",
2638
- "for",
2639
- "while",
2640
- "until",
2641
- "do",
2642
- "done",
2643
- "case",
2644
- "esac",
2645
- "cd",
2646
- "echo",
2647
- "export",
2648
- "set",
2649
- "exit",
2650
- "test",
2651
- "true",
2652
- "false",
2653
- "source",
2654
- ".",
2655
- "eval",
2656
- "exec",
2657
- "ok"
2658
- ]);
2659
-
2660
- // src/board-lock.ts
2661
- import { StaleWriteError } from "@tablation/client";
2662
- function releaseLockScope(route, repo) {
2663
- return `${route}/${repo}`;
2664
- }
2665
- async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
2666
- if (!modelId) return { ok: true, release: async () => {
2667
- } };
2668
- const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
2669
- const rows = await client2.list(modelId, { filters, limit: 1 });
2670
- const row = rows[0];
2671
- if (!row) return { ok: false, reason: "missing" };
2672
- const age = Date.now() - new Date(row.updated_at).getTime();
2673
- const free = !row.holder || row.holder === holderLabel || age > ttlMs;
2674
- if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
2675
- let claimed;
2676
- try {
2677
- claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
2678
- } catch (e) {
2679
- if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
2680
- throw e;
2667
+ function materializeRemoteBranch(cwd, remote, branch) {
2668
+ if (!remoteBranchExists(cwd, remote, branch)) {
2669
+ return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
2681
2670
  }
2682
- return {
2683
- ok: true,
2684
- release: async () => {
2685
- try {
2686
- await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
2687
- } catch {
2688
- }
2689
- }
2690
- };
2691
- }
2692
- async function lockRowState(client2, modelId, scope) {
2693
- const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
2694
- const row = rows[0];
2695
- if (!row) return { exists: false };
2696
- return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
2697
- }
2698
- var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
2699
- async function ensureLockRows(client2, modelId, scopes) {
2700
- const created = [];
2701
- for (const scope of scopes) {
2702
- const filters = scopeFilter(scope);
2703
- if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
2704
- let mine;
2705
- try {
2706
- mine = await client2.create(modelId, { scope, holder: "" });
2707
- } catch (e) {
2708
- const status2 = e.status;
2709
- if (status2 === 409 || status2 === 400) {
2710
- if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
2711
- }
2712
- throw e;
2713
- }
2714
- const all = await client2.list(modelId, { filters, limit: 20 });
2715
- const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
2716
- if (keeper && keeper.id !== mine.id) {
2717
- await client2.remove(modelId, mine.id).catch(() => {
2718
- });
2719
- continue;
2720
- }
2721
- created.push(scope);
2671
+ if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
2672
+ return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
2722
2673
  }
2723
- return created;
2724
- }
2725
-
2726
- // src/tracker.ts
2727
- function ticketBranchContext(t) {
2728
- const tag = t.issue_tag ?? void 0;
2729
- if (!tag) return {};
2730
- const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
2731
- return { tag, prefix };
2674
+ gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
2675
+ return true;
2732
2676
  }
2733
- function displayKey(t) {
2734
- return t.issue_tag ?? t.issue_id;
2677
+ function deleteRemoteBranch(cwd, remote, branch) {
2678
+ if (!remoteBranchExists(cwd, remote, branch)) return true;
2679
+ return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
2735
2680
  }
2736
- function referenceKeys(t) {
2737
- return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
2681
+ function worktreeForNumber(cwd, num) {
2682
+ const re = new RegExp(`(?:^|-)${num}$`);
2683
+ return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
2738
2684
  }
2739
- var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
2740
- var encodeFilters = (f) => JSON.stringify(f);
2741
- var encodeSort = (s) => JSON.stringify(s);
2742
- var Tracker = class {
2743
- // NB: explicit fields, not TypeScript parameter properties — node's
2744
- // --experimental-strip-types cannot transform those, and the test runner
2745
- // uses it. The same rule applies everywhere in src/.
2746
- route;
2747
- /** This workspace's rules. A ship holds several, one per route. */
2748
- contract;
2749
- client;
2750
- models;
2751
- userAgent;
2752
- shipName;
2753
- /**
2754
- * The once-per-process authorship probe (CREW-1371), memoised as a promise
2755
- * so concurrent first writes share one round of field-metadata reads.
2756
- */
2757
- authorship;
2758
- stateDir;
2759
- constructor(route, ship) {
2760
- this.route = route;
2761
- if (!route.resolved) {
2762
- throw new ConfigError(
2763
- `route "${route.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
2764
- );
2765
- }
2766
- this.models = route.resolved.models;
2767
- this.contract = resolveContract(route.contract);
2768
- this.userAgent = ship.userAgent;
2769
- this.shipName = ship.name;
2770
- this.stateDir = ship.stateDir;
2771
- this.client = new TablationClient({
2772
- baseUrl: `${route.baseUrl}/api`,
2773
- apiKey: resolveApiKey(route),
2774
- // Cloudflare 403s (error 1010) default agents on this host.
2775
- headers: { "User-Agent": ship.userAgent }
2776
- });
2685
+ var commitBodies = (cwd, range) => gitOk(cwd, ["log", range, "--format=%B"]) ?? "";
2686
+ var resolve2 = (cwd, ref) => gitOk(cwd, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
2687
+ var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--count", range]) ?? "0", 10);
2688
+ function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
2689
+ return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
2690
+ }
2691
+ function firstReleaseTagContaining(cwd, sha, pattern = "v*") {
2692
+ const out = gitOk(cwd, ["tag", "--contains", sha, "--list", pattern, "--sort=creatordate"]);
2693
+ return out?.split("\n").map((l) => l.trim()).find(Boolean) ?? null;
2694
+ }
2695
+ var tagCommit = (cwd, tag) => resolve2(cwd, tag);
2696
+ function createReleaseTag(cwd, tag, sha, message) {
2697
+ git(cwd, ["tag", "-a", tag, sha, "-m", message]);
2698
+ }
2699
+ var tagExists = (cwd, tag) => gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]) !== null;
2700
+ function pushTag(cwd, remote, tag) {
2701
+ git(cwd, ["push", remote, tag]);
2702
+ }
2703
+ function fetchRemote(cwd, remote = "origin") {
2704
+ return gitOk(cwd, ["fetch", "--prune", "--quiet", remote]) !== null;
2705
+ }
2706
+ function remoteConfigured(cwd, remote = "origin") {
2707
+ return gitOk(cwd, ["remote", "get-url", remote]) !== null;
2708
+ }
2709
+ function remoteBranchExists(cwd, remote, branch) {
2710
+ return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]) !== null;
2711
+ }
2712
+ function findKeyInRange(cwd, key, range) {
2713
+ return keyCommitsInRange(cwd, key, range)[0] ?? null;
2714
+ }
2715
+ function keyCommitsInRange(cwd, key, range) {
2716
+ const keys = Array.isArray(key) ? key : [key];
2717
+ const escaped = keys.map((k) => k.replace(/[.[\]{}()*+?^$|\\]/g, "\\$&"));
2718
+ const pattern = `(^|[^0-9A-Za-z_-])(${escaped.join("|")})([^0-9]|$)`;
2719
+ const out = gitOk(cwd, ["log", range, "--format=%H %s", "--extended-regexp", `--grep=${pattern}`]);
2720
+ if (!out) return [];
2721
+ return out.split("\n").map((l) => l.split(" ")[0]).filter(Boolean);
2722
+ }
2723
+ function patchIdOf(cwd, from, to) {
2724
+ try {
2725
+ const diff = execFileSync("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
2726
+ if (!diff) return null;
2727
+ const out = execFileSync("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
2728
+ return out.split(" ")[0]?.trim() || null;
2729
+ } catch {
2730
+ return null;
2777
2731
  }
2778
- /**
2779
- * Every non-terminal ticket for this route's area, in one call.
2780
- *
2781
- * The area filter is what keeps two routes on one tracker out of each
2782
- * other's queue: a ship working `synthesis` and `tablation-js` from the same
2783
- * Issues project must not have either seat pick up the other's tickets. A
2784
- * route with no area sees everything, which is right for a tracker
2785
- * nobody has sliced yet.
2786
- */
2787
- async openTickets() {
2788
- const c = this.contract;
2789
- const filters = [
2790
- { columnName: c.columns.status, operator: "IN", value: c.statuses.open }
2791
- ];
2792
- const areaId = this.route.resolved?.areaId;
2793
- if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
2794
- return this.client.records.list(this.models.issues, {
2795
- filters: encodeFilters(filters),
2796
- limit: 500
2797
- });
2732
+ }
2733
+ function findSquashOfBranch(cwd, key, branch, base, range) {
2734
+ const candidates = keyCommitsInRange(cwd, key, range);
2735
+ if (candidates.length === 0) return null;
2736
+ const mb = gitOk(cwd, ["merge-base", base, branch]);
2737
+ const branchPatch = mb ? patchIdOf(cwd, mb, branch) : null;
2738
+ for (const sha of candidates) {
2739
+ if (gitOk(cwd, ["diff", "--quiet", branch, sha]) !== null) return sha;
2740
+ if (branchPatch && gitOk(cwd, ["rev-parse", "--verify", "-q", `${sha}^`]) !== null && patchIdOf(cwd, `${sha}^`, sha) === branchPatch) return sha;
2798
2741
  }
2799
- /**
2800
- * Tickets at a terminal, no-longer-open status — the worktree sweep's
2801
- * input (ISSUE-346).
2802
- *
2803
- * The exact complement of `openTickets()`: `closed_deployed`,
2804
- * `closed_wont_fix`, `closed_duplicate`. `verified` is excluded even
2805
- * though the contract counts it as resolved for a *blocker*'s purposes —
2806
- * it is still pre-release, and its worktree is exactly what the release
2807
- * phase is about to merge.
2808
- *
2809
- * Sorted most-recently-updated first: a board can hold far more than
2810
- * `limit` closed tickets (this one already has), and an unordered fetch
2811
- * has no guarantee which ones a truncated page contains. Ordering by
2812
- * `updatedAt` descending means a truncated page still holds the tickets
2813
- * most likely to need their worktree swept — the ones that closed most
2814
- * recently — rather than an arbitrary, possibly stale slice (ISSUE-403).
2815
- */
2816
- async terminalTickets() {
2817
- const c = this.contract;
2818
- const statuses = closedStatuses(c);
2819
- if (!statuses.length) return [];
2820
- const filters = [
2821
- { columnName: c.columns.status, operator: "IN", value: statuses }
2822
- ];
2823
- const areaId = this.route.resolved?.areaId;
2824
- if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
2825
- const params = {
2826
- filters: encodeFilters(filters),
2827
- limit: 500,
2828
- sort: encodeSort([{ columnName: c.columns.updatedAt, direction: "desc" }])
2742
+ return null;
2743
+ }
2744
+ function isAncestor(cwd, ancestor, descendant) {
2745
+ return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]) !== null;
2746
+ }
2747
+ function fileAtRef(cwd, ref, path) {
2748
+ return gitOk(cwd, ["show", `${ref}:${path}`]);
2749
+ }
2750
+ function findKeyOnBase(cwd, key, remote, base, since2) {
2751
+ const range = since2 ? `${since2}..${remote}/${base}` : `${remote}/${base}`;
2752
+ return findKeyInRange(cwd, key, range);
2753
+ }
2754
+ function detectClosureHeuristically(cwd, key, pushedBranch, remote = "origin", base = "main", since2, aliases = []) {
2755
+ const merged = findKeyOnBase(cwd, [key, ...aliases], remote, base, since2);
2756
+ if (merged) {
2757
+ return {
2758
+ state: "merged",
2759
+ confidence: "heuristic",
2760
+ mergedAt: merged,
2761
+ detail: `${key} appears on ${remote}/${base} at ${merged.slice(0, 8)}`
2829
2762
  };
2830
- return this.client.records.list(this.models.issues, params);
2831
- }
2832
- /**
2833
- * Tickets by id, for blockers that have already closed and so are absent
2834
- * from the open set. Bounded to the ids actually referenced.
2835
- */
2836
- async ticketsByIds(ids) {
2837
- if (ids.length === 0) return [];
2838
- return this.client.records.list(this.models.issues, {
2839
- filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
2840
- limit: 500
2841
- });
2842
- }
2843
- async ticket(id) {
2844
- return this.client.records.get(this.models.issues, id);
2845
2763
  }
2846
- async comments(limit = 500) {
2847
- return this.client.records.list(this.models.comments, { limit });
2848
- }
2849
- async crewRows() {
2850
- return this.client.records.list(this.models.crew, { limit: 200 });
2764
+ if (remoteBranchExists(cwd, remote, pushedBranch)) {
2765
+ return {
2766
+ state: "open",
2767
+ confidence: "heuristic",
2768
+ detail: `${remote}/${pushedBranch} still exists and ${key} is not on ${base}`
2769
+ };
2851
2770
  }
2852
- /**
2853
- * One row of the `Repos` table, for whatever it can tell the crew that
2854
- * isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
2855
- * particular. `undefined` on a workspace with no Repos table, or if the
2856
- * row is gone; neither is fatal to the caller.
2857
- */
2858
- async repoRow(id) {
2859
- const model = this.route.resolved?.reposModelId;
2860
- if (!model) return void 0;
2861
- try {
2862
- return await this.client.records.get(model, id);
2863
- } catch {
2864
- return void 0;
2771
+ return {
2772
+ state: "abandoned",
2773
+ confidence: "heuristic",
2774
+ detail: `${remote}/${pushedBranch} no longer exists and ${key} never reached ${base} \u2014 this is also what a squash merge with a rewritten subject looks like`
2775
+ };
2776
+ }
2777
+ async function detectClosure(o) {
2778
+ const remote = o.remote ?? "origin";
2779
+ const base = o.base ?? "main";
2780
+ const aliases = o.aliases ?? [];
2781
+ if (o.mergedHook) {
2782
+ const code = await o.mergedHook({
2783
+ CREW_TICKET: o.key,
2784
+ CREW_BRANCH: o.pushedBranch,
2785
+ CREW_BASE: base
2786
+ });
2787
+ if (code === 0) {
2788
+ const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
2789
+ return {
2790
+ state: "merged",
2791
+ confidence: "definitive",
2792
+ mergedAt,
2793
+ detail: `the repo's merged hook says ${o.key} landed`
2794
+ };
2865
2795
  }
2796
+ return remoteBranchExists(o.cwd, remote, o.pushedBranch) ? { state: "open", confidence: "definitive", detail: `the repo's merged hook says ${o.key} has not landed` } : { state: "abandoned", confidence: "definitive", detail: `${o.key} has not landed and ${remote}/${o.pushedBranch} is gone` };
2866
2797
  }
2867
- /**
2868
- * One row of the `Projects` (area) table. `undefined` on a workspace
2869
- * with no such table, or if the row is gone.
2870
- */
2871
- async projectRow(id) {
2872
- const model = this.route.resolved?.areaModelId;
2873
- if (!model) return void 0;
2874
- try {
2875
- return await this.client.records.get(model, id);
2876
- } catch {
2877
- return void 0;
2878
- }
2798
+ return detectClosureHeuristically(o.cwd, o.key, o.pushedBranch, remote, base, o.since, aliases);
2799
+ }
2800
+ function upstreamOf(cwd, branch) {
2801
+ return gitOk(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`]);
2802
+ }
2803
+ function syncState(cwd, branch) {
2804
+ const upstream = upstreamOf(cwd, branch);
2805
+ if (!upstream) {
2806
+ return {
2807
+ branch,
2808
+ upstream: null,
2809
+ ahead: 0,
2810
+ behind: 0,
2811
+ dirty: status(cwd).length > 0,
2812
+ canFastForward: false,
2813
+ detail: `${branch} tracks no remote branch`
2814
+ };
2879
2815
  }
2880
- /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
2881
- async projectRows() {
2882
- const model = this.route.resolved?.areaModelId;
2883
- if (!model) return [];
2884
- return this.client.records.list(model, { limit: 200 });
2885
- }
2886
- /** Create a `Repos` row. Throws when the workspace has no Repos table. */
2887
- async createRepoRow(fields) {
2888
- const model = this.route.resolved?.reposModelId;
2889
- if (!model) throw new Error("this workspace has no Repos table");
2890
- return this.client.records.create(model, fields);
2816
+ const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${branch}`]) ?? "0 0";
2817
+ const [behindStr, aheadStr] = counts.split(/\s+/);
2818
+ const behind = Number.parseInt(behindStr ?? "0", 10);
2819
+ const ahead = Number.parseInt(aheadStr ?? "0", 10);
2820
+ const dirty = status(cwd).length > 0;
2821
+ let detail;
2822
+ if (behind === 0 && ahead === 0) detail = `level with ${upstream}`;
2823
+ else if (dirty) detail = `${behind} behind ${upstream}, but the worktree is dirty \u2014 not touching it`;
2824
+ else if (ahead > 0 && behind > 0) detail = `diverged from ${upstream} (${ahead} ahead, ${behind} behind) \u2014 needs a human`;
2825
+ else if (behind > 0) detail = `${behind} behind ${upstream} \u2014 fast-forwardable`;
2826
+ else detail = `${ahead} ahead of ${upstream}, nothing to take`;
2827
+ return {
2828
+ branch,
2829
+ upstream,
2830
+ ahead,
2831
+ behind,
2832
+ dirty,
2833
+ canFastForward: behind > 0 && ahead === 0 && !dirty,
2834
+ detail
2835
+ };
2836
+ }
2837
+ function fastForward(cwd, branch) {
2838
+ const s = syncState(cwd, branch);
2839
+ if (!s.canFastForward) return false;
2840
+ return gitOk(cwd, ["merge", "--ff-only", s.upstream]) !== null;
2841
+ }
2842
+ function baseBranchUnsafe(r) {
2843
+ return r.action === "diverged" || r.action === "ahead" || r.action === "ff-failed";
2844
+ }
2845
+ function describeUnsafeBase(r, remote, base) {
2846
+ const counts = r.action === "diverged" ? `${r.ahead} ahead, ${r.behind} behind ${remote}/${base}` : r.action === "ahead" ? `${r.ahead} ahead of ${remote}/${base}` : `${r.behind} behind ${remote}/${base}, cannot fast-forward`;
2847
+ return `${r.detail} [${counts}] \u2014 reconcile it (\`crew sync\` afterwards), or \`git reset --hard ${remote}/${base}\` if the local commits duplicate a release`;
2848
+ }
2849
+ function refreshBaseBranch(cwd, remote, base, dryRun = false) {
2850
+ if (currentBranch(cwd) !== base || status(cwd).length > 0) {
2851
+ return { action: "not-applicable", detail: `${cwd} is not on a clean ${base}` };
2891
2852
  }
2892
- /**
2893
- * The Ships rows of this workspace, if it has a Ships table.
2894
- *
2895
- * Optional by design: a workspace that has not adopted ships still works,
2896
- * and the crew must not require a table it did not create.
2897
- */
2898
- async shipRows() {
2899
- const model = this.route.resolved?.shipsModelId;
2900
- if (!model) return [];
2901
- try {
2902
- return await this.client.records.list(model, { limit: 200 });
2903
- } catch {
2904
- return [];
2905
- }
2853
+ if (!remoteConfigured(cwd, remote)) {
2854
+ return { action: "not-applicable", detail: `${remote} is not configured` };
2906
2855
  }
2907
- /**
2908
- * This ship's own row on the `Ships` table, matched by name — the same
2909
- * exact-string match `doctor` has always used (see the callsign comment
2910
- * on ISSUE-380: name is what an operator recognises on the board).
2911
- * `undefined` when the workspace has no Ships table, no row named for
2912
- * this ship, or more than one — ambiguous is not a row to write to.
2913
- */
2914
- async myShipRow(shipName2) {
2915
- const rows = await this.shipRows();
2916
- const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
2917
- return mine.length === 1 ? mine[0] : void 0;
2856
+ if (!fetchRemote(cwd, remote)) {
2857
+ return { action: "fetch-failed", detail: `could not fetch ${remote}` };
2918
2858
  }
2919
- /**
2920
- * The `attention` field of a Ships-row patch (CREW-1373): this ship's open
2921
- * items, so every heartbeat carries the current set and a cleared item
2922
- * disappears from the board on the next one. Empty when there is nothing
2923
- * to write: no state directory to read, or a Ships table without the
2924
- * column (probed by the key's absence from the row, the way `owner_id` is
2925
- * skipped on an install that has not taken the template update).
2926
- */
2927
- attentionPatch(row) {
2928
- if (!this.stateDir || !("attention" in row)) return {};
2929
- const items = openShipAttention(new State(this.stateDir));
2930
- return { attention: items.length ? items : null };
2859
+ if (!remoteBranchExists(cwd, remote, base)) {
2860
+ return { action: "not-applicable", detail: `${remote}/${base} does not exist` };
2931
2861
  }
2932
- /**
2933
- * Writes this ship's attention items on their own, without touching
2934
- * `last_seen` — for the daemon, which raises one between heartbeats and
2935
- * should not wait for the next engaged/idle beat to show it. Skips the
2936
- * write when the row already holds the same set.
2937
- */
2938
- async publishShipAttention(shipName2) {
2939
- const row = await this.myShipRow(shipName2);
2940
- if (!row) return;
2941
- const patch2 = this.attentionPatch(row);
2942
- if (!("attention" in patch2)) return;
2943
- if (JSON.stringify(patch2.attention) === JSON.stringify(row.attention ?? null)) return;
2944
- await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
2862
+ const upstream = `${remote}/${base}`;
2863
+ const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${base}`]) ?? "0 0";
2864
+ const [behindStr, aheadStr] = counts.split(/\s+/);
2865
+ const behind = Number.parseInt(behindStr ?? "0", 10);
2866
+ const ahead = Number.parseInt(aheadStr ?? "0", 10);
2867
+ const named = () => {
2868
+ const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
2869
+ return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
2870
+ };
2871
+ if (ahead > 0 && behind > 0) {
2872
+ return {
2873
+ action: "diverged",
2874
+ ahead,
2875
+ behind,
2876
+ detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
2877
+ };
2945
2878
  }
2946
- /**
2947
- * Ship-level heartbeat (ISSUE-380): "is this ship online" — independent
2948
- * of any one seat's Working/Idle status (`setCrewStatus`), which only
2949
- * exists for the duration of an actual agent run. This one fires once
2950
- * per `crew run`/`poll` cycle **regardless of outcome**, including a
2951
- * cycle that finds nothing to do, so a ship that stops firing (powered
2952
- * off, asleep, closed for the weekend) simply stops updating `last_seen`
2953
- * — silence then means exactly one thing, rather than being ambiguous
2954
- * with "idle".
2955
- *
2956
- * Does not touch the `engaged*` columns — those are set only by
2957
- * `beatEngaged`/`beatIdle`, paired around the part of the cycle that
2958
- * actually spawns an agent, so a plain heartbeat mid-engagement can't
2959
- * blow away `engaged_since`.
2960
- *
2961
- * A no-op, like `shipRows()`, on a workspace with no Ships table or no
2962
- * row for this ship's name.
2963
- */
2964
- async beatShip(shipName2) {
2965
- const row = await this.myShipRow(shipName2);
2966
- if (!row) return;
2967
- await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
2968
- last_seen: (/* @__PURE__ */ new Date()).toISOString(),
2969
- host: hostname(),
2970
- pid: process.pid,
2971
- ...this.attentionPatch(row)
2972
- });
2879
+ if (behind === 0 && ahead > 0) {
2880
+ return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not${named()}` };
2973
2881
  }
2974
- /**
2975
- * Marks this ship's row engaged, naming what it's working and since when
2976
- * (ISSUE-380's "an engaged ship names what it is working and since
2977
- * when"). Call once, right before `spawnAgent`, paired with
2978
- * `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
2979
- * working/idle pair, one level up (ship, not seat).
2980
- */
2981
- async beatEngaged(shipName2, connectionName, ticketRecordId) {
2982
- const row = await this.myShipRow(shipName2);
2983
- if (!row) return;
2984
- await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
2985
- last_seen: (/* @__PURE__ */ new Date()).toISOString(),
2986
- host: hostname(),
2987
- pid: process.pid,
2988
- engaged: true,
2989
- engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
2990
- engaged_connection: connectionName,
2991
- engaged_ticket_id: ticketRecordId,
2992
- ...this.attentionPatch(row)
2993
- });
2882
+ if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
2883
+ if (dryRun) {
2884
+ return { action: "would-fast-forward", behind, detail: `would fast-forward ${base}: ${behind} behind ${upstream}` };
2994
2885
  }
2995
- /**
2996
- * Writes this ship's own tunnel state for Host Passengers (ISSUE-553):
2997
- * `tunnel_status` (disconnected/connecting/connected), as
2998
- * the tunnel client (`tunnel.ts`) brings a workspace's container's tunnel
2999
- * up or down. A no-op, like every other `myShipRow`-backed write here, on
3000
- * a workspace with no Ships table or no row for this ship's name — the
3001
- * tunnel client itself still runs the tunnel either way, it just has
3002
- * nowhere on the board to report the state.
3003
- */
3004
- async updateTunnelState(shipName2, patch2) {
3005
- const row = await this.myShipRow(shipName2);
3006
- if (!row) return;
3007
- await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
2886
+ if (gitOk(cwd, ["merge", "--ff-only", upstream]) === null) {
2887
+ return { action: "ff-failed", behind, detail: `${base} is ${behind} behind ${upstream} and would not fast-forward` };
3008
2888
  }
3009
- /** The other half of `beatEngaged` — clears engagement back to idle. */
3010
- async beatIdle(shipName2) {
3011
- const row = await this.myShipRow(shipName2);
3012
- if (!row) return;
3013
- await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
3014
- last_seen: (/* @__PURE__ */ new Date()).toISOString(),
3015
- host: hostname(),
3016
- pid: process.pid,
3017
- engaged: false,
3018
- engaged_since: null,
3019
- engaged_connection: null,
3020
- engaged_ticket_id: null,
3021
- ...this.attentionPatch(row)
3022
- });
2889
+ return {
2890
+ action: "fast-forwarded",
2891
+ behind,
2892
+ detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
2893
+ };
2894
+ }
2895
+ function recoverStrandedReleaseCheckouts(cwd) {
2896
+ gitOk(cwd, ["worktree", "prune"]);
2897
+ const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
2898
+ let path = "";
2899
+ for (const line of out.split("\n")) {
2900
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
2901
+ else if (line.startsWith("branch refs/heads/crew/release-") && path) {
2902
+ gitOk(cwd, ["worktree", "remove", "--force", path]);
2903
+ }
3023
2904
  }
3024
- /**
3025
- * The Epics rows of this workspace, if it has an Epics table (ISSUE-384).
3026
- *
3027
- * Optional by design, the same way `shipRows()` is: a workspace that has
3028
- * not adopted epics still works, and the crew must not require a table it
3029
- * did not create.
3030
- */
3031
- async epicRows() {
3032
- const model = this.route.resolved?.epicsModelId;
3033
- if (!model) return [];
3034
- try {
3035
- return await this.client.records.list(model, { limit: 500 });
3036
- } catch {
3037
- return [];
2905
+ const stranded = (gitOk(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/crew/release-*"]) ?? "").split("\n").filter(Boolean);
2906
+ for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
2907
+ return stranded;
2908
+ }
2909
+ function pushBranch(cwd, remote, branch, dst = branch) {
2910
+ git(cwd, ["push", remote, `${branch}:${dst}`]);
2911
+ }
2912
+ function worktrees(cwd) {
2913
+ const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
2914
+ const list = [];
2915
+ let current = {};
2916
+ for (const line of out.split("\n")) {
2917
+ if (line.startsWith("worktree ")) {
2918
+ if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
2919
+ current = { path: line.slice("worktree ".length) };
2920
+ } else if (line.startsWith("branch ")) {
2921
+ current.branch = line.slice("branch refs/heads/".length);
2922
+ } else if (line === "detached") {
2923
+ current.branch = null;
2924
+ } else if (line.startsWith("prunable ")) {
2925
+ current.prunable = true;
3038
2926
  }
3039
2927
  }
3040
- /**
3041
- * The tickets of these epics that are closed (`closed: true`) or not
3042
- * (`closed: false`), for the epic sync (CREW-1255).
3043
- *
3044
- * **Not area-filtered, unlike `openTickets()`**: an epic spans areas, so
3045
- * whether it is finished can only be judged across all of them. Also not
3046
- * limited to `statuses.open` — a status this contract has never heard of
3047
- * (a pre-triage `draft`, say) is still a ticket that is not closed.
3048
- *
3049
- * Paged, because an epic's history is unbounded and a truncated page
3050
- * would silently read as "nothing else outstanding".
3051
- */
3052
- async epicTickets(epicIds, o) {
3053
- if (epicIds.length === 0) return [];
3054
- const c = this.contract;
3055
- const statuses = closedStatuses(c);
3056
- if (!statuses.length) return [];
3057
- const filters = encodeFilters([
3058
- { columnName: c.columns.epic, operator: "IN", value: epicIds },
3059
- { columnName: c.columns.status, operator: o.closed ? "IN" : "NOT_IN", value: statuses }
3060
- ]);
3061
- const pageSize = 500;
3062
- const out = [];
3063
- for (let page = 0; page < 20; page++) {
3064
- const rows = await this.client.records.list(this.models.issues, {
3065
- filters,
3066
- limit: pageSize,
3067
- offset: page * pageSize
3068
- });
3069
- out.push(...rows);
3070
- if (rows.length < pageSize) break;
3071
- }
3072
- return out;
3073
- }
3074
- /** Conditional on `expectedUpdatedAt`, exactly as `updateTicket` is. */
3075
- async updateEpic(id, patch2, expectedUpdatedAt) {
3076
- const model = this.route.resolved?.epicsModelId;
3077
- if (!model) throw new ConfigError(`route "${this.route.route}" has no Epics table`);
3078
- return this.client.records.update(model, id, patch2, expectedUpdatedAt);
3079
- }
3080
- /**
3081
- * `expectedUpdatedAt` makes this a conditional write (ISSUE-395, the same
3082
- * `RecordsResource.update` primitive board-lock.ts uses): pass the
3083
- * `updated_at` this caller last read, and the write throws
3084
- * `StaleWriteError` instead of applying if the row has moved since —
3085
- * exactly what claim.ts needs to let a contended ticket claim fail loudly
3086
- * rather than silently overwrite a winning ship's write.
3087
- */
3088
- async updateTicket(id, patch2, expectedUpdatedAt) {
3089
- return this.client.records.update(this.models.issues, id, patch2, expectedUpdatedAt);
3090
- }
3091
- /**
3092
- * Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
3093
- * for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
3094
- * no-op release, only when this workspace has no Locks table at all. A
3095
- * Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
3096
- * (CREW-1384): `crew connect` provisions the row.
3097
- */
3098
- acquireBoardLock(scope, holderLabel, ttlMs) {
3099
- return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
3100
- }
3101
- /** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
3102
- async lockState(scope) {
3103
- const model = this.route.resolved?.locksModelId;
3104
- return model ? lockRowState(this.client.records, model, scope) : void 0;
3105
- }
3106
- /**
3107
- * Reports one crew member's live status (ISSUE-325) — "Working" for the
3108
- * duration of an agent run, "Idle" once it ends.
3109
- *
3110
- * `ticketRecordId` is the ticket's own row id (`Ticket.id`), not its
3111
- * `issue_id` label — the field it fills is a Reference, not text.
3112
- * Omitted (as on the idle transition) leaves the column alone, so it
3113
- * keeps reading as "current/last issue" rather than clearing to blank
3114
- * the moment a run ends.
3115
- */
3116
- async setCrewStatus(memberId, status2, ticketRecordId) {
3117
- const patch2 = { status: status2, status_updated_at: (/* @__PURE__ */ new Date()).toISOString() };
3118
- if (ticketRecordId !== void 0) patch2.current_issue_id = ticketRecordId;
3119
- await this.client.records.update(this.models.crew, memberId, patch2);
2928
+ if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
2929
+ return list;
2930
+ }
2931
+ function cloneUrlFor(remote) {
2932
+ return `git@github.com:${remote}.git`;
2933
+ }
2934
+ function ensureRepoCheckout(dir, remote) {
2935
+ if (existsSync6(`${dir}/.git`)) return false;
2936
+ if (!remote) {
2937
+ throw new GitError(
2938
+ `${dir} does not exist and this repo has no discovered remote to clone from \u2014 re-run \`crew connect\`, or check out the repository yourself and point \`repos:\` at it`
2939
+ );
3120
2940
  }
3121
- /**
3122
- * An `event` comment — the runner's own audit trail, distinct from a
3123
- * seat's prose. `kind` is what keeps these out of the "new comment from
3124
- * someone else" wake signal.
3125
- */
3126
- async postEvent(ticketId, body, memberId) {
3127
- const auth = await this.authorshipProbe();
3128
- await this.client.records.create(this.models.comments, {
3129
- ticket_id: ticketId,
3130
- body,
3131
- team_member_id: memberId,
3132
- kind: "event",
3133
- ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
3134
- });
2941
+ mkdirSync4(dirname4(dir), { recursive: true });
2942
+ try {
2943
+ execFileSync("git", ["clone", cloneUrlFor(remote), dir], { stdio: ["ignore", "pipe", "pipe"] });
2944
+ } catch (e) {
2945
+ const err = e;
2946
+ const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
2947
+ throw new GitError(`git clone ${cloneUrlFor(remote)} ${dir}: ${why || err.message.trim()}`);
3135
2948
  }
3136
- /**
3137
- * Ship affinity for a claim (CREW-1386): stamp this ship's Ships row on the
3138
- * ticket in the claim's own write, in the `held_by_ship_id` column (not
3139
- * `ship_id`, which means "filed from"). `undefined` — claim exactly as
3140
- * before — when the Issues table has no hold column or this ship has no row.
3141
- */
3142
- async claimAffinity() {
3143
- const auth = await this.authorshipProbe();
3144
- if (!auth.issueHeld || !auth.shipId) return void 0;
3145
- return {
3146
- column: this.contract.columns.heldBy,
3147
- shipId: auth.shipId
3148
- };
2949
+ return true;
2950
+ }
2951
+ var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
2952
+ function checkoutLockPath(dir) {
2953
+ return `${dir}.sync.lock`;
2954
+ }
2955
+ function sleepSync(ms) {
2956
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2957
+ }
2958
+ function acquireCheckoutLock(dir, timeoutMs = 3e4) {
2959
+ const lockPath = checkoutLockPath(dir);
2960
+ mkdirSync4(dirname4(lockPath), { recursive: true });
2961
+ const deadline = Date.now() + timeoutMs;
2962
+ for (; ; ) {
2963
+ try {
2964
+ const fd = openSync(lockPath, "wx");
2965
+ writeSync(fd, `${process.pid}
2966
+ `);
2967
+ closeSync(fd);
2968
+ return () => {
2969
+ try {
2970
+ unlinkSync2(lockPath);
2971
+ } catch {
2972
+ }
2973
+ };
2974
+ } catch (e) {
2975
+ const err = e;
2976
+ if (err.code !== "EEXIST") throw err;
2977
+ try {
2978
+ if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
2979
+ try {
2980
+ unlinkSync2(lockPath);
2981
+ } catch {
2982
+ }
2983
+ continue;
2984
+ }
2985
+ } catch {
2986
+ }
2987
+ if (Date.now() > deadline) {
2988
+ throw new GitError(`timed out waiting for the sync lock on ${dir} (held by another sync)`);
2989
+ }
2990
+ sleepSync(50);
2991
+ }
3149
2992
  }
3150
- /**
3151
- * The authorship probe (CREW-1371), read once per process: which optional
3152
- * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
3153
- * ship's own Ships row id. Memoised as a promise so concurrent first writes
3154
- * share one round of metadata reads. A table whose metadata cannot be read
3155
- * reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
3156
- */
3157
- authorshipProbe() {
3158
- this.authorship ??= this.probeAuthorship();
3159
- return this.authorship;
2993
+ }
2994
+ function syncPassengerCheckout(dir, githubRemote, gitRemoteName, base) {
2995
+ const release = acquireCheckoutLock(dir);
2996
+ try {
2997
+ if (ensureRepoCheckout(dir, githubRemote)) {
2998
+ if (currentBranch(dir) !== base) git(dir, ["checkout", base]);
2999
+ return { action: "cloned", detail: `cloned ${githubRemote} into ${dir}` };
3000
+ }
3001
+ return refreshBaseBranch(dir, gitRemoteName, base);
3002
+ } finally {
3003
+ release();
3160
3004
  }
3161
- async probeAuthorship() {
3162
- const [issueFields, commentFields, shipRow] = await Promise.all([
3163
- this.columnNames(this.models.issues),
3164
- this.columnNames(this.models.comments),
3165
- // No name (a caller that only beats or patches, never stamps) means no row to find.
3166
- this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
3167
- ]);
3005
+ }
3006
+
3007
+ // src/ship-attention.ts
3008
+ var RELEASE_STALE_AFTER_INTERVALS = 3;
3009
+ function upsert(items, item) {
3010
+ const existing = items.find((i) => i.key === item.key);
3011
+ if (!existing) return [...items, item];
3012
+ return items.map((i) => i.key === item.key ? { ...item, since: existing.since } : i);
3013
+ }
3014
+ function clear(items, key) {
3015
+ return items.filter((i) => i.key !== key);
3016
+ }
3017
+ function transitions(previous, current) {
3018
+ const before = new Set(previous.map((i) => i.key));
3019
+ return current.filter((i) => !before.has(i.key));
3020
+ }
3021
+ function staleRelease(lastRunMs, nowMs, intervalSeconds = INTERVAL_SECONDS) {
3022
+ if (lastRunMs === null) return null;
3023
+ const limitMs = RELEASE_STALE_AFTER_INTERVALS * intervalSeconds * 1e3;
3024
+ const ageMs = nowMs - lastRunMs;
3025
+ if (ageMs <= limitMs) return null;
3026
+ const minutes = Math.round(ageMs / 6e4);
3027
+ return {
3028
+ kind: "release_stale",
3029
+ key: "release_stale",
3030
+ message: `This ship has not run a release cycle for ${minutes} minute${minutes === 1 ? "" : "s"} \u2014 check its release timer`,
3031
+ since: new Date(lastRunMs + limitMs).toISOString()
3032
+ };
3033
+ }
3034
+ var baseUnsafeKey = (repo) => `base_unsafe:${repo}`;
3035
+ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
3036
+ if (baseBranchUnsafe(outcome)) {
3168
3037
  return {
3169
- issueAuthor: issueFields.has(this.contract.columns.author),
3170
- issueShip: issueFields.has(this.contract.columns.ship),
3171
- issueHeld: issueFields.has(this.contract.columns.heldBy),
3172
- commentShip: commentFields.has(this.contract.comments.ship),
3173
- shipId: shipRow?.id ?? null
3038
+ raise: {
3039
+ kind: "base_unsafe",
3040
+ key: baseUnsafeKey(repo),
3041
+ since: nowIso,
3042
+ message: `${repo}: no worktree can be cut until the primary checkout's ${base} is reconciled \u2014 ${describeUnsafeBase(outcome, remote, base)}. Resumes automatically once it is level with ${remote}/${base}`
3043
+ }
3174
3044
  };
3175
3045
  }
3176
- async columnNames(modelId) {
3177
- try {
3178
- const model = await this.client.dataModels.get(modelId);
3179
- return new Set(model.fields.map((f) => f.columnName));
3180
- } catch {
3181
- return /* @__PURE__ */ new Set();
3182
- }
3046
+ if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
3047
+ return null;
3048
+ }
3049
+ function openShipAttention(state, nowMs = Date.now()) {
3050
+ const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
3051
+ const stored = state.shipAttention().previous();
3052
+ return stale ? [...stored, stale] : stored;
3053
+ }
3054
+ function announce(emit, item) {
3055
+ emit.warn(item.message, {
3056
+ step: "sweep",
3057
+ data: { attention: [`ship:${item.kind}`], title: item.message }
3058
+ });
3059
+ }
3060
+ async function raiseShipAttention(o, item) {
3061
+ const store = o.state.shipAttention();
3062
+ const previous = store.previous();
3063
+ const next = upsert(previous, item);
3064
+ store.persist(next);
3065
+ if (transitions(previous, next).length === 0) return false;
3066
+ announce(o.emit, item);
3067
+ if (o.route && o.ship) {
3068
+ await notify(o.route, o.ship, { level: "warn", headline: item.message }, o.emit);
3183
3069
  }
3184
- /**
3185
- * The authorship columns this workspace is missing, as `table.column` names
3186
- * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
3187
- */
3188
- async missingAuthorshipColumns() {
3189
- const auth = await this.authorshipProbe();
3190
- const missing = [];
3191
- if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
3192
- if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
3193
- if (!auth.issueHeld) missing.push(`Issues.${this.contract.columns.heldBy}`);
3194
- if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
3195
- return missing;
3196
- }
3197
- /**
3198
- * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
3199
- * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
3200
- * call for the same (view, model, record) returns the same URL and even
3201
- * un-retires a retired one — right for a ticket that just went back to
3202
- * `needs_info` after having been resolved once before.
3203
- *
3204
- * `undefined` when this route has no `recordLinkViewId` resolved, or the
3205
- * mint call fails for any reason (network, 404, an unexpected response
3206
- * shape) — the caller (poll.ts's attention sweep) still emits its
3207
- * notification event without a link rather than losing the whole event.
3208
- */
3209
- async recordLinkUrl(recordId) {
3210
- const viewId = this.route.resolved?.recordLinkViewId;
3211
- if (!viewId) return void 0;
3212
- try {
3213
- const res = await fetch(`${this.route.baseUrl}/api/views/${viewId}/record-links`, {
3214
- method: "POST",
3215
- headers: {
3216
- Authorization: `Bearer ${resolveApiKey(this.route)}`,
3217
- "User-Agent": this.userAgent,
3218
- "Content-Type": "application/json"
3219
- },
3220
- body: JSON.stringify({ dataModelId: this.models.issues, recordId })
3221
- });
3222
- if (!res.ok) return void 0;
3223
- const body = await res.json();
3224
- return typeof body.url === "string" ? body.url : void 0;
3225
- } catch {
3226
- return void 0;
3227
- }
3228
- }
3229
- /**
3230
- * File a brand new ticket. Unlike every other write on this class, the
3231
- * field names are the fixed Bug Reports schema (`title`, `description`,
3232
- * `report_type`, `severity`, `reporter_name`) rather than anything in
3233
- * `Contract.columns` — that contract only names the lifecycle fields a
3234
- * workspace can rename, and creation-only fields like `description` are
3235
- * not among them (`postEvent` above hardcodes its own fields the same
3236
- * way).
3237
- */
3238
- async fileTicket(fields, memberId) {
3239
- const auth = await this.authorshipProbe();
3240
- return this.client.records.create(this.models.issues, {
3241
- ...fields,
3242
- ...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
3243
- ...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
3244
- });
3070
+ return true;
3071
+ }
3072
+ function clearShipAttention(state, key) {
3073
+ const store = state.shipAttention();
3074
+ const previous = store.previous();
3075
+ const next = clear(previous, key);
3076
+ if (next.length !== previous.length) store.persist(next);
3077
+ }
3078
+ function hookCommand(script) {
3079
+ for (const raw of script.split("\n")) {
3080
+ const line = raw.trim();
3081
+ if (!line || line.startsWith("#")) continue;
3082
+ const word = line.split(/\s+/)[0];
3083
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue;
3084
+ if (!/^[A-Za-z0-9._+-]+$/.test(word)) return null;
3085
+ if (SHELL_WORDS.has(word)) return null;
3086
+ return word;
3245
3087
  }
3246
- };
3088
+ return null;
3089
+ }
3090
+ var SHELL_WORDS = /* @__PURE__ */ new Set([
3091
+ "if",
3092
+ "then",
3093
+ "else",
3094
+ "elif",
3095
+ "fi",
3096
+ "for",
3097
+ "while",
3098
+ "until",
3099
+ "do",
3100
+ "done",
3101
+ "case",
3102
+ "esac",
3103
+ "cd",
3104
+ "echo",
3105
+ "export",
3106
+ "set",
3107
+ "exit",
3108
+ "test",
3109
+ "true",
3110
+ "false",
3111
+ "source",
3112
+ ".",
3113
+ "eval",
3114
+ "exec",
3115
+ "ok"
3116
+ ]);
3247
3117
 
3248
- // src/blocked.ts
3249
- import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
3250
- function missingBlockerIds(tickets) {
3251
- const known = new Set(tickets.map((t) => t.id));
3252
- const missing = /* @__PURE__ */ new Set();
3253
- for (const t of tickets) for (const id of t.blocked_by ?? []) if (!known.has(id)) missing.add(id);
3254
- return [...missing];
3118
+ // src/board-lock.ts
3119
+ import { StaleWriteError } from "@tablation/client";
3120
+ function releaseLockScope(route, repo) {
3121
+ return `${route}/${repo}`;
3255
3122
  }
3256
- function blockerInfoMap(tickets, extra = []) {
3257
- const info = {};
3258
- for (const t of [...tickets, ...extra]) {
3259
- info[t.id] = { issue_id: t.issue_id, issue_tag: t.issue_tag, status: t.status };
3123
+ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
3124
+ if (!modelId) return { ok: true, release: async () => {
3125
+ } };
3126
+ const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
3127
+ const rows = await client2.list(modelId, { filters, limit: 1 });
3128
+ const row = rows[0];
3129
+ if (!row) return { ok: false, reason: "missing" };
3130
+ const age = Date.now() - new Date(row.updated_at).getTime();
3131
+ const free = !row.holder || row.holder === holderLabel || age > ttlMs;
3132
+ if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
3133
+ let claimed;
3134
+ try {
3135
+ claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
3136
+ } catch (e) {
3137
+ if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
3138
+ throw e;
3260
3139
  }
3261
- return info;
3140
+ return {
3141
+ ok: true,
3142
+ release: async () => {
3143
+ try {
3144
+ await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
3145
+ } catch {
3146
+ }
3147
+ }
3148
+ };
3262
3149
  }
3263
- function computeBlockedIds(tickets, info) {
3264
- const blocked = /* @__PURE__ */ new Set();
3265
- for (const t of tickets) {
3266
- const unresolved = (t.blocked_by ?? []).some((id) => {
3267
- const status2 = info[id]?.status;
3268
- return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
3269
- });
3270
- if (unresolved) blocked.add(t.id);
3271
- }
3272
- return blocked;
3150
+ async function lockRowState(client2, modelId, scope) {
3151
+ const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
3152
+ const row = rows[0];
3153
+ if (!row) return { exists: false };
3154
+ return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
3273
3155
  }
3274
- function sweepDiagnostics(tickets, info) {
3275
- const dangling = [];
3276
- const selfBlocked = [];
3277
- for (const t of tickets) {
3278
- for (const id of t.blocked_by ?? []) {
3279
- if (info[id] === void 0) dangling.push(`${t.issue_id} -> ${id}`);
3280
- if (id === t.id) selfBlocked.push(t.issue_id);
3156
+ var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
3157
+ async function ensureLockRows(client2, modelId, scopes) {
3158
+ const created = [];
3159
+ for (const scope of scopes) {
3160
+ const filters = scopeFilter(scope);
3161
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
3162
+ let mine;
3163
+ try {
3164
+ mine = await client2.create(modelId, { scope, holder: "" });
3165
+ } catch (e) {
3166
+ const status2 = e.status;
3167
+ if (status2 === 409 || status2 === 400) {
3168
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
3169
+ }
3170
+ throw e;
3281
3171
  }
3282
- }
3283
- return { dangling, selfBlocked: [...new Set(selfBlocked)] };
3284
- }
3285
- var describeBlockers = (t, info) => (t.blocked_by ?? []).map((id) => `${info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
3286
- function planSweep(tickets, info, blocked) {
3287
- const steps = [];
3288
- for (const t of tickets) {
3289
- if (t.status === "accepted" && blocked.has(t.id)) {
3290
- steps.push({ action: "park", ticket: t, blockers: describeBlockers(t, info), to: "blocked" });
3291
- } else if (t.status === "blocked" && !blocked.has(t.id)) {
3292
- steps.push({ action: "restore", ticket: t, blockers: describeBlockers(t, info), to: "accepted" });
3172
+ const all = await client2.list(modelId, { filters, limit: 20 });
3173
+ const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
3174
+ if (keeper && keeper.id !== mine.id) {
3175
+ await client2.remove(modelId, mine.id).catch(() => {
3176
+ });
3177
+ continue;
3293
3178
  }
3179
+ created.push(scope);
3294
3180
  }
3295
- return steps;
3181
+ return created;
3296
3182
  }
3297
- function strandedNeedsInfo(tickets, blocked, holds2 = /* @__PURE__ */ new Set()) {
3298
- return tickets.filter(
3299
- (t) => t.status === "needs_info" && (t.blocked_by ?? []).length > 0 && !blocked.has(t.id) && !(t.assignee_id && holds2.has(t.assignee_id))
3300
- );
3183
+
3184
+ // src/tracker.ts
3185
+ function ticketBranchContext(t) {
3186
+ const tag = t.issue_tag ?? void 0;
3187
+ if (!tag) return {};
3188
+ const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
3189
+ return { tag, prefix };
3301
3190
  }
3302
- var COORDINATING_DEFAULTS = {
3303
- reportTypeColumn: "report_type",
3304
- coordinatingValue: "coordinating"
3305
- };
3306
- var isCoordinating = (t, opts) => t[opts.reportTypeColumn] === opts.coordinatingValue;
3307
- var FILING_ERROR_DEFAULT_EXEMPT = ["coordinating", "question", "investigation"];
3308
- function rollUpParents(tickets, done = RESOLVED_STATUSES, opts = {}) {
3309
- const resolved = { ...COORDINATING_DEFAULTS, ...opts };
3310
- const byParent = /* @__PURE__ */ new Map();
3311
- for (const t of tickets) {
3312
- const p = t.parent_id;
3313
- if (!p) continue;
3314
- const list = byParent.get(p);
3315
- if (list) list.push(t);
3316
- else byParent.set(p, [t]);
3317
- }
3318
- const rollups = [];
3319
- for (const t of tickets) {
3320
- const children = byParent.get(t.id);
3321
- if (!isCoordinating(t, resolved) || !children) continue;
3322
- const outstanding = children.filter((c) => !done.has(c.status));
3323
- rollups.push({ parent: t, children, outstanding, complete: outstanding.length === 0 });
3324
- }
3325
- return rollups;
3191
+ function displayKey(t) {
3192
+ return t.issue_tag ?? t.issue_id;
3326
3193
  }
3327
- function filingErrors(tickets, opts = {}) {
3328
- const reportTypeColumn = opts.reportTypeColumn ?? COORDINATING_DEFAULTS.reportTypeColumn;
3329
- const coordinatingValue = opts.coordinatingValue ?? COORDINATING_DEFAULTS.coordinatingValue;
3330
- const exempt = new Set(
3331
- opts.repoExemptValues ?? [coordinatingValue, ...FILING_ERROR_DEFAULT_EXEMPT.filter((v) => v !== "coordinating")]
3332
- );
3333
- return tickets.filter((t) => {
3334
- if (t.repo_id) return false;
3335
- return !exempt.has(t[reportTypeColumn]);
3336
- });
3194
+ function referenceKeys(t) {
3195
+ return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
3337
3196
  }
3338
- function sweepComment(step) {
3339
- return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
3340
-
3341
- This is automatic and reversible: an approved ticket whose \`Blocked by\` entries are not all resolved is parked out of the queue, and restored on the first poll after the last one resolves. Nothing else about the ticket changes. A blocker counts as resolved at \`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\` \u2014 not at \`fixed\`, which is still an unmerged branch awaiting QA.` : `Restored to **Approved** by the crew \u2014 every blocker has resolved${step.blockers && step.blockers !== "\u2014" ? ` (${step.blockers})` : ""}.
3342
-
3343
- This restores the status it was approved at before being parked; it is back in the queue and a role may pick it up. If a blocker resolved as \`closed_wont_fix\` or \`closed_duplicate\` this ticket may still not be buildable as written \u2014 that is a call for a human, not the crew.`;
3344
- }
3345
- async function applySweep(writer, steps, memberId, log) {
3346
- let parked = 0;
3347
- let restored = 0;
3348
- let failed = 0;
3349
- let contended = 0;
3350
- for (const step of steps) {
3351
- try {
3352
- await writer.updateTicket(step.ticket.id, { status: step.to }, step.ticket.updated_at);
3353
- if (step.action === "park") parked++;
3354
- else restored++;
3355
- log.emit(
3356
- step.action === "park" ? `parked (waiting on ${step.blockers})` : `restored to ${step.to} (blockers resolved: ${step.blockers || "none"})`,
3357
- { ticket: step.ticket.issue_id, step: "sweep" }
3197
+ var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
3198
+ var encodeFilters = (f) => JSON.stringify(f);
3199
+ var encodeSort = (s) => JSON.stringify(s);
3200
+ var Tracker = class {
3201
+ // NB: explicit fields, not TypeScript parameter properties — node's
3202
+ // --experimental-strip-types cannot transform those, and the test runner
3203
+ // uses it. The same rule applies everywhere in src/.
3204
+ route;
3205
+ /** This workspace's rules. A ship holds several, one per route. */
3206
+ contract;
3207
+ client;
3208
+ models;
3209
+ userAgent;
3210
+ shipName;
3211
+ /**
3212
+ * The once-per-process authorship probe (CREW-1371), memoised as a promise
3213
+ * so concurrent first writes share one round of field-metadata reads.
3214
+ */
3215
+ authorship;
3216
+ stateDir;
3217
+ constructor(route, ship) {
3218
+ this.route = route;
3219
+ if (!route.resolved) {
3220
+ throw new ConfigError(
3221
+ `route "${route.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
3358
3222
  );
3359
- try {
3360
- await writer.postEvent(step.ticket.id, sweepComment(step), memberId);
3361
- } catch {
3362
- log.warn("status changed but the note failed to post", { ticket: step.ticket.issue_id, step: "sweep" });
3363
- }
3364
- } catch (e) {
3365
- if (e instanceof StaleWriteError2) {
3366
- contended++;
3367
- log.warn(`skipped ${step.action} \u2014 ticket changed since the sweep was planned`, {
3368
- ticket: step.ticket.issue_id,
3369
- step: "sweep"
3370
- });
3371
- continue;
3372
- }
3373
- failed++;
3374
- log.warn(`could not ${step.action}: ${e.message}`, { ticket: step.ticket.issue_id, step: "sweep" });
3375
3223
  }
3224
+ this.models = route.resolved.models;
3225
+ this.contract = resolveContract(route.contract);
3226
+ this.userAgent = ship.userAgent;
3227
+ this.shipName = ship.name;
3228
+ this.stateDir = ship.stateDir;
3229
+ this.client = new TablationClient({
3230
+ baseUrl: `${route.baseUrl}/api`,
3231
+ apiKey: resolveApiKey(route),
3232
+ // Cloudflare 403s (error 1010) default agents on this host.
3233
+ headers: { "User-Agent": ship.userAgent }
3234
+ });
3376
3235
  }
3377
- return { parked, restored, failed, contended };
3378
- }
3379
-
3380
- // src/priority.ts
3381
- var indexOf = (order) => Object.fromEntries(order.map((v, i) => [v, i]));
3382
- function derivedPriority(t, c = DEFAULT_CONTRACT) {
3383
- return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.unknownPriorityRank;
3384
- }
3385
- function explicitPriority(t, c = DEFAULT_CONTRACT) {
3386
- return indexOf(c.priorityOrder)[t.priority ?? ""] ?? null;
3387
- }
3388
- function effectivePriority(t, c = DEFAULT_CONTRACT) {
3389
- const explicit = explicitPriority(t, c);
3390
- const derived = derivedPriority(t, c);
3391
- return explicit === null ? derived : Math.min(explicit, derived);
3392
- }
3393
- function severityRank(t, c = DEFAULT_CONTRACT) {
3394
- return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.severityOrder.length;
3395
- }
3396
- function issueNumber(t) {
3397
- const id = t.issue_id ?? "ISSUE-0";
3398
- const n = Number.parseInt(id.split("-")[1] ?? "0", 10);
3399
- return Number.isNaN(n) ? 0 : n;
3400
- }
3401
- function epicRank(t) {
3402
- return t.epicInProgress ? 0 : 1;
3403
- }
3404
- function rank(t, c = DEFAULT_CONTRACT) {
3405
- return [effectivePriority(t, c), severityRank(t, c), epicRank(t), issueNumber(t)];
3406
- }
3407
- function compareRank(a, b, c = DEFAULT_CONTRACT) {
3408
- const ra = rank(a, c);
3409
- const rb = rank(b, c);
3410
- for (let i = 0; i < ra.length; i++) {
3411
- const d = ra[i] - rb[i];
3412
- if (d !== 0) return d;
3236
+ /**
3237
+ * Every non-terminal ticket for this route's area, in one call.
3238
+ *
3239
+ * The area filter is what keeps two routes on one tracker out of each
3240
+ * other's queue: a ship working `synthesis` and `tablation-js` from the same
3241
+ * Issues project must not have either seat pick up the other's tickets. A
3242
+ * route with no area sees everything, which is right for a tracker
3243
+ * nobody has sliced yet.
3244
+ */
3245
+ async openTickets() {
3246
+ const c = this.contract;
3247
+ const filters = [
3248
+ { columnName: c.columns.status, operator: "IN", value: c.statuses.open }
3249
+ ];
3250
+ const areaId = this.route.resolved?.areaId;
3251
+ if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
3252
+ return this.client.records.list(this.models.issues, {
3253
+ filters: encodeFilters(filters),
3254
+ limit: 500
3255
+ });
3413
3256
  }
3414
- return 0;
3415
- }
3416
- function rankScalar(t, c = DEFAULT_CONTRACT) {
3417
- const [p, s, e, n] = rank(t, c);
3418
- return p * 1e9 + s * 1e7 + e * 1e6 + n;
3419
- }
3420
- var NOTHING_ACTIONABLE = 999999999999;
3421
- var AGE_HOURS_TO_ERODE = 48;
3422
- function agedRankScalar(rawRank, aging, now) {
3423
- if (!aging) return rawRank;
3424
- const elapsedHours = Math.max(0, (now - Date.parse(aging.since)) / 36e5);
3425
- const weight = aging.weight ?? 1;
3426
- const fraction = Math.min(1, elapsedHours * weight / AGE_HOURS_TO_ERODE);
3427
- return Math.round(rawRank * (1 - fraction));
3428
- }
3429
-
3430
- // src/ship-affinity.ts
3431
- var CYCLE_SECONDS = 120;
3432
- var SHIP_DEAD_AFTER_CYCLES = 10;
3433
- function shipIsAlive(ship, now) {
3434
- if (!ship?.last_seen) return false;
3435
- const seen = Date.parse(ship.last_seen);
3436
- return Number.isFinite(seen) && now - seen <= SHIP_DEAD_AFTER_CYCLES * CYCLE_SECONDS * 1e3;
3437
- }
3438
- function ticketHolderShipId(t, contract) {
3439
- const v = t[contract.columns.heldBy];
3440
- return typeof v === "string" && v ? v : null;
3441
- }
3442
- function heldByOtherShip(t, a) {
3443
- const statuses = a.contract.statuses;
3444
- if (t.status === statuses.handoff) return false;
3445
- return heldByLiveOtherShip(t, a);
3446
- }
3447
- function heldByLiveOtherShip(t, a) {
3448
- if (!a.myShipId) return false;
3449
- const holder = ticketHolderShipId(t, a.contract);
3450
- if (!holder || holder === a.myShipId) return false;
3451
- const owner = a.ships.find((s) => s.id === holder);
3452
- return shipIsAlive(owner, a.now ?? Date.now());
3453
- }
3454
- function shipName(ships, id) {
3455
- if (!id) return "an unknown ship";
3456
- return ships.find((s) => s.id === id)?.name?.trim() || id;
3457
- }
3458
- function shipIdByName(ships, name) {
3459
- const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
3460
- return mine.length === 1 ? mine[0].id : null;
3461
- }
3462
- async function noteReaccepted(tracker, result, seat, shipLabel) {
3463
- if (!result.reaccepted || !result.ticket) return;
3464
- try {
3465
- await tracker.postEvent(
3466
- result.ticket.id,
3467
- `Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
3468
- seat
3469
- );
3470
- } catch {
3257
+ /**
3258
+ * Tickets at a terminal, no-longer-open status — the worktree sweep's
3259
+ * input (ISSUE-346).
3260
+ *
3261
+ * The exact complement of `openTickets()`: `closed_deployed`,
3262
+ * `closed_wont_fix`, `closed_duplicate`. `verified` is excluded even
3263
+ * though the contract counts it as resolved for a *blocker*'s purposes —
3264
+ * it is still pre-release, and its worktree is exactly what the release
3265
+ * phase is about to merge.
3266
+ *
3267
+ * Sorted most-recently-updated first: a board can hold far more than
3268
+ * `limit` closed tickets (this one already has), and an unordered fetch
3269
+ * has no guarantee which ones a truncated page contains. Ordering by
3270
+ * `updatedAt` descending means a truncated page still holds the tickets
3271
+ * most likely to need their worktree swept — the ones that closed most
3272
+ * recently — rather than an arbitrary, possibly stale slice (ISSUE-403).
3273
+ */
3274
+ async terminalTickets() {
3275
+ const c = this.contract;
3276
+ const statuses = closedStatuses(c);
3277
+ if (!statuses.length) return [];
3278
+ const filters = [
3279
+ { columnName: c.columns.status, operator: "IN", value: statuses }
3280
+ ];
3281
+ const areaId = this.route.resolved?.areaId;
3282
+ if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
3283
+ const params = {
3284
+ filters: encodeFilters(filters),
3285
+ limit: 500,
3286
+ sort: encodeSort([{ columnName: c.columns.updatedAt, direction: "desc" }])
3287
+ };
3288
+ return this.client.records.list(this.models.issues, params);
3471
3289
  }
3472
- }
3473
- function planHoldReleases(tickets, contract) {
3474
- const ends = [contract.statuses.handoff, contract.statuses.verified];
3475
- return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
3476
- }
3477
- async function applyHoldReleases(writer, tickets, contract, emit) {
3478
- let cleared = 0;
3479
- for (const t of tickets) {
3290
+ /**
3291
+ * Tickets by id, for blockers that have already closed and so are absent
3292
+ * from the open set. Bounded to the ids actually referenced.
3293
+ */
3294
+ async ticketsByIds(ids) {
3295
+ if (ids.length === 0) return [];
3296
+ return this.client.records.list(this.models.issues, {
3297
+ filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
3298
+ limit: 500
3299
+ });
3300
+ }
3301
+ async ticket(id) {
3302
+ return this.client.records.get(this.models.issues, id);
3303
+ }
3304
+ async comments(limit = 500) {
3305
+ return this.client.records.list(this.models.comments, { limit });
3306
+ }
3307
+ async crewRows() {
3308
+ return this.client.records.list(this.models.crew, { limit: 200 });
3309
+ }
3310
+ /**
3311
+ * One row of the `Repos` table, for whatever it can tell the crew that
3312
+ * isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
3313
+ * particular. `undefined` on a workspace with no Repos table, or if the
3314
+ * row is gone; neither is fatal to the caller.
3315
+ */
3316
+ async repoRow(id) {
3317
+ const model = this.route.resolved?.reposModelId;
3318
+ if (!model) return void 0;
3480
3319
  try {
3481
- await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
3482
- cleared++;
3483
- emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
3484
- } catch (e) {
3485
- emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
3486
- }
3320
+ return await this.client.records.get(model, id);
3321
+ } catch {
3322
+ return void 0;
3323
+ }
3324
+ }
3325
+ /**
3326
+ * One row of the `Projects` (area) table. `undefined` on a workspace
3327
+ * with no such table, or if the row is gone.
3328
+ */
3329
+ async projectRow(id) {
3330
+ const model = this.route.resolved?.areaModelId;
3331
+ if (!model) return void 0;
3332
+ try {
3333
+ return await this.client.records.get(model, id);
3334
+ } catch {
3335
+ return void 0;
3336
+ }
3337
+ }
3338
+ /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
3339
+ async projectRows() {
3340
+ const model = this.route.resolved?.areaModelId;
3341
+ if (!model) return [];
3342
+ return this.client.records.list(model, { limit: 200 });
3343
+ }
3344
+ /** Create a `Repos` row. Throws when the workspace has no Repos table. */
3345
+ async createRepoRow(fields) {
3346
+ const model = this.route.resolved?.reposModelId;
3347
+ if (!model) throw new Error("this workspace has no Repos table");
3348
+ return this.client.records.create(model, fields);
3349
+ }
3350
+ /**
3351
+ * The Ships rows of this workspace, if it has a Ships table.
3352
+ *
3353
+ * Optional by design: a workspace that has not adopted ships still works,
3354
+ * and the crew must not require a table it did not create.
3355
+ */
3356
+ async shipRows() {
3357
+ const model = this.route.resolved?.shipsModelId;
3358
+ if (!model) return [];
3359
+ try {
3360
+ return await this.client.records.list(model, { limit: 200 });
3361
+ } catch {
3362
+ return [];
3363
+ }
3364
+ }
3365
+ /**
3366
+ * This ship's own row on the `Ships` table, matched by name — the same
3367
+ * exact-string match `doctor` has always used (see the callsign comment
3368
+ * on ISSUE-380: name is what an operator recognises on the board).
3369
+ * `undefined` when the workspace has no Ships table, no row named for
3370
+ * this ship, or more than one — ambiguous is not a row to write to.
3371
+ */
3372
+ async myShipRow(shipName2) {
3373
+ const rows = await this.shipRows();
3374
+ const mine = rows.filter((r) => (r.name ?? "").trim() === shipName2.trim());
3375
+ return mine.length === 1 ? mine[0] : void 0;
3376
+ }
3377
+ /**
3378
+ * The `attention` field of a Ships-row patch (CREW-1373): this ship's open
3379
+ * items, so every heartbeat carries the current set and a cleared item
3380
+ * disappears from the board on the next one. Empty when there is nothing
3381
+ * to write: no state directory to read, or a Ships table without the
3382
+ * column (probed by the key's absence from the row, the way `owner_id` is
3383
+ * skipped on an install that has not taken the template update).
3384
+ */
3385
+ attentionPatch(row) {
3386
+ if (!this.stateDir || !("attention" in row)) return {};
3387
+ const items = openShipAttention(new State(this.stateDir));
3388
+ return { attention: items.length ? items : null };
3389
+ }
3390
+ /**
3391
+ * Writes this ship's attention items on their own, without touching
3392
+ * `last_seen` — for the daemon, which raises one between heartbeats and
3393
+ * should not wait for the next engaged/idle beat to show it. Skips the
3394
+ * write when the row already holds the same set.
3395
+ */
3396
+ async publishShipAttention(shipName2) {
3397
+ const row = await this.myShipRow(shipName2);
3398
+ if (!row) return;
3399
+ const patch2 = this.attentionPatch(row);
3400
+ if (!("attention" in patch2)) return;
3401
+ if (JSON.stringify(patch2.attention) === JSON.stringify(row.attention ?? null)) return;
3402
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
3403
+ }
3404
+ /**
3405
+ * Ship-level heartbeat (ISSUE-380): "is this ship online" — independent
3406
+ * of any one seat's Working/Idle status (`setCrewStatus`), which only
3407
+ * exists for the duration of an actual agent run. This one fires once
3408
+ * per `crew run`/`poll` cycle **regardless of outcome**, including a
3409
+ * cycle that finds nothing to do, so a ship that stops firing (powered
3410
+ * off, asleep, closed for the weekend) simply stops updating `last_seen`
3411
+ * — silence then means exactly one thing, rather than being ambiguous
3412
+ * with "idle".
3413
+ *
3414
+ * Does not touch the `engaged*` columns — those are set only by
3415
+ * `beatEngaged`/`beatIdle`, paired around the part of the cycle that
3416
+ * actually spawns an agent, so a plain heartbeat mid-engagement can't
3417
+ * blow away `engaged_since`.
3418
+ *
3419
+ * A no-op, like `shipRows()`, on a workspace with no Ships table or no
3420
+ * row for this ship's name.
3421
+ */
3422
+ async beatShip(shipName2) {
3423
+ const row = await this.myShipRow(shipName2);
3424
+ if (!row) return;
3425
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
3426
+ last_seen: (/* @__PURE__ */ new Date()).toISOString(),
3427
+ host: hostname(),
3428
+ pid: process.pid,
3429
+ ...this.attentionPatch(row)
3430
+ });
3431
+ }
3432
+ /**
3433
+ * Marks this ship's row engaged, naming what it's working and since when
3434
+ * (ISSUE-380's "an engaged ship names what it is working and since
3435
+ * when"). Call once, right before `spawnAgent`, paired with
3436
+ * `beatIdle` in a `finally` — the same shape as `setCrewStatus`'s
3437
+ * working/idle pair, one level up (ship, not seat).
3438
+ */
3439
+ async beatEngaged(shipName2, connectionName, ticketRecordId) {
3440
+ const row = await this.myShipRow(shipName2);
3441
+ if (!row) return;
3442
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
3443
+ last_seen: (/* @__PURE__ */ new Date()).toISOString(),
3444
+ host: hostname(),
3445
+ pid: process.pid,
3446
+ engaged: true,
3447
+ engaged_since: (/* @__PURE__ */ new Date()).toISOString(),
3448
+ engaged_connection: connectionName,
3449
+ engaged_ticket_id: ticketRecordId,
3450
+ ...this.attentionPatch(row)
3451
+ });
3452
+ }
3453
+ /**
3454
+ * Writes this ship's own tunnel state for Host Passengers (ISSUE-553):
3455
+ * `tunnel_status` (disconnected/connecting/connected), as
3456
+ * the tunnel client (`tunnel.ts`) brings a workspace's container's tunnel
3457
+ * up or down. A no-op, like every other `myShipRow`-backed write here, on
3458
+ * a workspace with no Ships table or no row for this ship's name — the
3459
+ * tunnel client itself still runs the tunnel either way, it just has
3460
+ * nowhere on the board to report the state.
3461
+ */
3462
+ async updateTunnelState(shipName2, patch2) {
3463
+ const row = await this.myShipRow(shipName2);
3464
+ if (!row) return;
3465
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, patch2);
3466
+ }
3467
+ /** The other half of `beatEngaged` — clears engagement back to idle. */
3468
+ async beatIdle(shipName2) {
3469
+ const row = await this.myShipRow(shipName2);
3470
+ if (!row) return;
3471
+ await this.client.records.update(this.route.resolved.shipsModelId, row.id, {
3472
+ last_seen: (/* @__PURE__ */ new Date()).toISOString(),
3473
+ host: hostname(),
3474
+ pid: process.pid,
3475
+ engaged: false,
3476
+ engaged_since: null,
3477
+ engaged_connection: null,
3478
+ engaged_ticket_id: null,
3479
+ ...this.attentionPatch(row)
3480
+ });
3481
+ }
3482
+ /**
3483
+ * The Epics rows of this workspace, if it has an Epics table (ISSUE-384).
3484
+ *
3485
+ * Optional by design, the same way `shipRows()` is: a workspace that has
3486
+ * not adopted epics still works, and the crew must not require a table it
3487
+ * did not create.
3488
+ */
3489
+ async epicRows() {
3490
+ const model = this.route.resolved?.epicsModelId;
3491
+ if (!model) return [];
3492
+ try {
3493
+ return await this.client.records.list(model, { limit: 500 });
3494
+ } catch {
3495
+ return [];
3496
+ }
3497
+ }
3498
+ /**
3499
+ * The tickets of these epics that are closed (`closed: true`) or not
3500
+ * (`closed: false`), for the epic sync (CREW-1255).
3501
+ *
3502
+ * **Not area-filtered, unlike `openTickets()`**: an epic spans areas, so
3503
+ * whether it is finished can only be judged across all of them. Also not
3504
+ * limited to `statuses.open` — a status this contract has never heard of
3505
+ * (a pre-triage `draft`, say) is still a ticket that is not closed.
3506
+ *
3507
+ * Paged, because an epic's history is unbounded and a truncated page
3508
+ * would silently read as "nothing else outstanding".
3509
+ */
3510
+ async epicTickets(epicIds, o) {
3511
+ if (epicIds.length === 0) return [];
3512
+ const c = this.contract;
3513
+ const statuses = closedStatuses(c);
3514
+ if (!statuses.length) return [];
3515
+ const filters = encodeFilters([
3516
+ { columnName: c.columns.epic, operator: "IN", value: epicIds },
3517
+ { columnName: c.columns.status, operator: o.closed ? "IN" : "NOT_IN", value: statuses }
3518
+ ]);
3519
+ const pageSize = 500;
3520
+ const out = [];
3521
+ for (let page = 0; page < 20; page++) {
3522
+ const rows = await this.client.records.list(this.models.issues, {
3523
+ filters,
3524
+ limit: pageSize,
3525
+ offset: page * pageSize
3526
+ });
3527
+ out.push(...rows);
3528
+ if (rows.length < pageSize) break;
3529
+ }
3530
+ return out;
3531
+ }
3532
+ /** Conditional on `expectedUpdatedAt`, exactly as `updateTicket` is. */
3533
+ async updateEpic(id, patch2, expectedUpdatedAt) {
3534
+ const model = this.route.resolved?.epicsModelId;
3535
+ if (!model) throw new ConfigError(`route "${this.route.route}" has no Epics table`);
3536
+ return this.client.records.update(model, id, patch2, expectedUpdatedAt);
3537
+ }
3538
+ /**
3539
+ * `expectedUpdatedAt` makes this a conditional write (ISSUE-395, the same
3540
+ * `RecordsResource.update` primitive board-lock.ts uses): pass the
3541
+ * `updated_at` this caller last read, and the write throws
3542
+ * `StaleWriteError` instead of applying if the row has moved since —
3543
+ * exactly what claim.ts needs to let a contended ticket claim fail loudly
3544
+ * rather than silently overwrite a winning ship's write.
3545
+ */
3546
+ async updateTicket(id, patch2, expectedUpdatedAt) {
3547
+ return this.client.records.update(this.models.issues, id, patch2, expectedUpdatedAt);
3548
+ }
3549
+ /**
3550
+ * Claim a board-visible lock for `scope` (ISSUE-394) — see board-lock.ts
3551
+ * for the CAS mechanics. Resolves to `{ ok: true }` immediately, with a
3552
+ * no-op release, only when this workspace has no Locks table at all. A
3553
+ * Locks table with no row for `scope` is `{ ok: false, reason: 'missing' }`
3554
+ * (CREW-1384): `crew connect` provisions the row.
3555
+ */
3556
+ acquireBoardLock(scope, holderLabel, ttlMs) {
3557
+ return acquireBoardLock(this.client.records, this.route.resolved?.locksModelId, scope, holderLabel, ttlMs);
3558
+ }
3559
+ /** The Locks row for `scope` — present? held by whom? Undefined when this workspace has no Locks table. */
3560
+ async lockState(scope) {
3561
+ const model = this.route.resolved?.locksModelId;
3562
+ return model ? lockRowState(this.client.records, model, scope) : void 0;
3563
+ }
3564
+ /**
3565
+ * Reports one crew member's live status (ISSUE-325) — "Working" for the
3566
+ * duration of an agent run, "Idle" once it ends.
3567
+ *
3568
+ * `ticketRecordId` is the ticket's own row id (`Ticket.id`), not its
3569
+ * `issue_id` label — the field it fills is a Reference, not text.
3570
+ * Omitted (as on the idle transition) leaves the column alone, so it
3571
+ * keeps reading as "current/last issue" rather than clearing to blank
3572
+ * the moment a run ends.
3573
+ */
3574
+ async setCrewStatus(memberId, status2, ticketRecordId) {
3575
+ const patch2 = { status: status2, status_updated_at: (/* @__PURE__ */ new Date()).toISOString() };
3576
+ if (ticketRecordId !== void 0) patch2.current_issue_id = ticketRecordId;
3577
+ await this.client.records.update(this.models.crew, memberId, patch2);
3578
+ }
3579
+ /**
3580
+ * An `event` comment — the runner's own audit trail, distinct from a
3581
+ * seat's prose. `kind` is what keeps these out of the "new comment from
3582
+ * someone else" wake signal.
3583
+ */
3584
+ async postEvent(ticketId, body, memberId) {
3585
+ const auth = await this.authorshipProbe();
3586
+ await this.client.records.create(this.models.comments, {
3587
+ ticket_id: ticketId,
3588
+ body,
3589
+ team_member_id: memberId,
3590
+ kind: "event",
3591
+ ...auth.commentShip && auth.shipId ? { [this.contract.comments.ship]: auth.shipId } : {}
3592
+ });
3487
3593
  }
3488
- return cleared;
3489
- }
3490
- async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
3491
- if (!result.tookOverFrom || !result.ticket) return;
3492
- let sha = null;
3493
- try {
3494
- sha = continuingFrom(result.ticket);
3495
- } catch {
3594
+ /**
3595
+ * Ship affinity for a claim (CREW-1386): stamp this ship's Ships row on the
3596
+ * ticket in the claim's own write, in the `held_by_ship_id` column (not
3597
+ * `ship_id`, which means "filed from"). `undefined` — claim exactly as
3598
+ * before — when the Issues table has no hold column or this ship has no row.
3599
+ */
3600
+ async claimAffinity() {
3601
+ const auth = await this.authorshipProbe();
3602
+ if (!auth.issueHeld || !auth.shipId) return void 0;
3603
+ return {
3604
+ column: this.contract.columns.heldBy,
3605
+ shipId: auth.shipId
3606
+ };
3496
3607
  }
3497
- try {
3498
- await tracker.postEvent(
3499
- result.ticket.id,
3500
- `Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
3501
- seat
3502
- );
3503
- } catch {
3608
+ /**
3609
+ * The authorship probe (CREW-1371), read once per process: which optional
3610
+ * `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
3611
+ * ship's own Ships row id. Memoised as a promise so concurrent first writes
3612
+ * share one round of metadata reads. A table whose metadata cannot be read
3613
+ * reads as "column absent", the same way `connect.ts`'s `owner_id` probe does.
3614
+ */
3615
+ authorshipProbe() {
3616
+ this.authorship ??= this.probeAuthorship();
3617
+ return this.authorship;
3504
3618
  }
3505
- }
3506
- function originBranchSha(dir, t, rev) {
3507
- const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
3508
- const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
3509
- for (const n of names) {
3510
- const sha = rev(dir, `origin/${n}`);
3511
- if (sha) return sha.slice(0, 7);
3619
+ async probeAuthorship() {
3620
+ const [issueFields, commentFields, shipRow] = await Promise.all([
3621
+ this.columnNames(this.models.issues),
3622
+ this.columnNames(this.models.comments),
3623
+ // No name (a caller that only beats or patches, never stamps) means no row to find.
3624
+ this.shipName ? this.myShipRow(this.shipName) : Promise.resolve(void 0)
3625
+ ]);
3626
+ return {
3627
+ issueAuthor: issueFields.has(this.contract.columns.author),
3628
+ issueShip: issueFields.has(this.contract.columns.ship),
3629
+ issueHeld: issueFields.has(this.contract.columns.heldBy),
3630
+ commentShip: commentFields.has(this.contract.comments.ship),
3631
+ shipId: shipRow?.id ?? null
3632
+ };
3512
3633
  }
3513
- return null;
3514
- }
3515
- function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
3516
- const inFlight = [contract.statuses.building, contract.statuses.verifying];
3517
- const out = [];
3518
- for (const t of tickets) {
3519
- if (!inFlight.includes(t.status)) continue;
3520
- if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
3521
- const holder = ticketHolderShipId(t, contract);
3522
- if (!holder) {
3523
- out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
3524
- } else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
3525
- out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
3634
+ async columnNames(modelId) {
3635
+ try {
3636
+ const model = await this.client.dataModels.get(modelId);
3637
+ return new Set(model.fields.map((f) => f.columnName));
3638
+ } catch {
3639
+ return /* @__PURE__ */ new Set();
3526
3640
  }
3527
3641
  }
3528
- return out;
3529
- }
3530
-
3531
- // src/select.ts
3532
- var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
3533
- var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
3534
- var triageSlice = (tickets, triageSeat) => triageSeat ? tickets.filter((t) => t.assignee_id === triageSeat) : [];
3535
- function sliceFor(tickets, role) {
3536
- if (role === "qa") return qaSlice(tickets);
3537
- if (role === "triage") return triageSlice(tickets);
3538
- if (role === "pair") return [];
3539
- return buildingSlice(tickets, role);
3540
- }
3541
- var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
3542
- function affinityContext(i) {
3543
- return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
3544
- }
3545
- function withHoldCheck(affinity, i) {
3546
- if (!affinity) return void 0;
3547
- const a = affinityContext(i);
3548
- return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
3549
- }
3550
- function notHeldByOtherShip(slice, i) {
3551
- if (!i.ships) return slice;
3552
- const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
3553
- return slice.filter((t) => !heldByOtherShip(t, a));
3554
- }
3555
- function buildingRoleHasWork(role, i) {
3556
- const me = i.seats[role];
3557
- if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
3558
- const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
3559
- const startable = mine.filter(
3560
- (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
3561
- );
3562
- const watched = mine.filter(
3563
- (t) => (t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id) || t.status === "needs_info") && !isHeld(t, i.holds)
3564
- );
3565
- const watchedIds = new Set(watched.map((t) => t.id));
3566
- const newComments = i.comments.filter(
3567
- (c) => c.created_at > i.watermark && c.team_member_id !== me && (c.kind ?? "comment") !== "event" && watchedIds.has(c.ticket_id)
3568
- ).length;
3569
- const unassigned = mine.filter(
3570
- (t) => t.status === "in_progress" && !t.assignee_id && !i.blocked.has(t.id)
3571
- ).length;
3572
- const resumable = mine.filter(
3573
- (t) => t.status === "in_progress" && t.assignee_id === me && !t.needs_planning && !t.needs_review && !isHeld(t, i.holds)
3574
- ).length;
3575
- if (startable.length > 0 || newComments > 0 || unassigned > 0 || resumable > 0) {
3576
- const parts = [];
3577
- if (startable.length) parts.push(`${startable.length} startable`);
3578
- if (newComments) parts.push(`${newComments} new comment(s)`);
3579
- if (unassigned) parts.push(`${unassigned} unassigned in_progress`);
3580
- if (resumable) parts.push(`${resumable} resumable in_progress`);
3581
- return { hasWork: true, reason: parts.join(", ") };
3582
- }
3583
- return { hasWork: false, reason: "nothing startable and no new comments" };
3584
- }
3585
- function qaRoleHasWork(i) {
3586
- if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
3587
- const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
3588
- return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
3589
- }
3590
- function triageRoleHasWork(i) {
3591
- if (!i.seats.triage) return { hasWork: false, reason: "this ship does not crew that role" };
3592
- const n = triageSlice(i.tickets, i.seats.triage).length;
3593
- return n > 0 ? { hasWork: true, reason: `${n} report(s) assigned to triage` } : { hasWork: false, reason: "nothing assigned to triage" };
3594
- }
3595
- function roleHasWork(role, i) {
3596
- if (i.paused?.has(role)) return { hasWork: false, reason: "paused" };
3597
- if (role === "qa") return qaRoleHasWork(i);
3598
- if (role === "triage") return triageRoleHasWork(i);
3599
- if (role === "pair") return { hasWork: false, reason: "pair is interactive, not polled" };
3600
- return buildingRoleHasWork(role, i);
3601
- }
3602
- function roleCandidates(role, i) {
3603
- if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
3604
- if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
3605
- if (role === "pair") return [];
3606
- const me = i.seats[role];
3607
- if (!me) return [];
3608
- return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
3609
- (t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
3610
- );
3611
- }
3612
- function claimStatuses(role, c) {
3613
- return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
3614
- }
3615
- function rankedCandidates(role, i) {
3616
- const c = i.contract ?? DEFAULT_CONTRACT;
3617
- return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
3618
- }
3619
- function roleTopRank(role, i) {
3620
- if (role !== "qa" && role !== "triage" && !i.seats[role]) return NOTHING_ACTIONABLE;
3621
- const candidates = roleCandidates(role, i);
3622
- if (candidates.length === 0) return NOTHING_ACTIONABLE;
3623
- const c = i.contract ?? DEFAULT_CONTRACT;
3624
- return Math.min(...candidates.map((t) => rankScalar(t, c)));
3625
- }
3626
- function actionableSummary(i, pending) {
3627
- const c = i.contract ?? DEFAULT_CONTRACT;
3628
- const all = pending.flatMap((role) => roleCandidates(role, i));
3629
- if (all.length === 0) return { count: 0 };
3630
- const top = all.reduce((best, t) => rankScalar(t, c) < rankScalar(best, c) ? t : best);
3631
- return { count: all.length, top };
3632
- }
3633
- var FIXED_RANK = { qa: -2, triage: -1 };
3634
- function selectRole(i) {
3635
- const order = ["qa", "triage", "dev", "design"];
3636
- const pending = [];
3637
- const ranks = {};
3638
- const reasons = {};
3639
- for (const role of order) {
3640
- const { hasWork, reason } = roleHasWork(role, i);
3641
- reasons[role] = reason;
3642
- if (hasWork) pending.push(role);
3642
+ /**
3643
+ * The authorship columns this workspace is missing, as `table.column` names
3644
+ * for `crew doctor` to report (CREW-1371). Empty on a fully updated install.
3645
+ */
3646
+ async missingAuthorshipColumns() {
3647
+ const auth = await this.authorshipProbe();
3648
+ const missing = [];
3649
+ if (!auth.issueAuthor) missing.push(`Issues.${this.contract.columns.author}`);
3650
+ if (!auth.issueShip) missing.push(`Issues.${this.contract.columns.ship}`);
3651
+ if (!auth.issueHeld) missing.push(`Issues.${this.contract.columns.heldBy}`);
3652
+ if (!auth.commentShip) missing.push(`Comments.${this.contract.comments.ship}`);
3653
+ return missing;
3643
3654
  }
3644
- if (pending.length === 0) return { pending, selected: null, ranks, reasons };
3645
- let selected = null;
3646
- let best = Infinity;
3647
- for (const role of pending) {
3648
- const rank2 = FIXED_RANK[role] ?? roleTopRank(role, i);
3649
- ranks[role] = rank2;
3650
- if (rank2 < best) {
3651
- best = rank2;
3652
- selected = role;
3655
+ /**
3656
+ * The public `/v/<slug>/r/<token>` link for one ticket (ISSUE-928) —
3657
+ * fetch-or-create via `POST /views/:viewId/record-links`, so a repeated
3658
+ * call for the same (view, model, record) returns the same URL and even
3659
+ * un-retires a retired one — right for a ticket that just went back to
3660
+ * `needs_info` after having been resolved once before.
3661
+ *
3662
+ * `undefined` when this route has no `recordLinkViewId` resolved, or the
3663
+ * mint call fails for any reason (network, 404, an unexpected response
3664
+ * shape) — the caller (poll.ts's attention sweep) still emits its
3665
+ * notification event without a link rather than losing the whole event.
3666
+ */
3667
+ async recordLinkUrl(recordId) {
3668
+ const viewId = this.route.resolved?.recordLinkViewId;
3669
+ if (!viewId) return void 0;
3670
+ try {
3671
+ const res = await fetch(`${this.route.baseUrl}/api/views/${viewId}/record-links`, {
3672
+ method: "POST",
3673
+ headers: {
3674
+ Authorization: `Bearer ${resolveApiKey(this.route)}`,
3675
+ "User-Agent": this.userAgent,
3676
+ "Content-Type": "application/json"
3677
+ },
3678
+ body: JSON.stringify({ dataModelId: this.models.issues, recordId })
3679
+ });
3680
+ if (!res.ok) return void 0;
3681
+ const body = await res.json();
3682
+ return typeof body.url === "string" ? body.url : void 0;
3683
+ } catch {
3684
+ return void 0;
3653
3685
  }
3654
3686
  }
3655
- return { pending, selected, ranks, reasons };
3656
- }
3657
-
3658
- // src/epics.ts
3659
- import { StaleWriteError as StaleWriteError3 } from "@tablation/client";
3660
- var epicOf = (t, c) => {
3661
- const v = t[c.columns.epic];
3662
- return typeof v === "string" && v ? v : null;
3663
- };
3664
- var statusOf = (e, c) => {
3665
- const v = e[c.epics.statusColumn];
3666
- return typeof v === "string" && v ? v : null;
3687
+ /**
3688
+ * File a brand new ticket. Unlike every other write on this class, the
3689
+ * field names are the fixed Bug Reports schema (`title`, `description`,
3690
+ * `report_type`, `severity`, `reporter_name`) rather than anything in
3691
+ * `Contract.columns` — that contract only names the lifecycle fields a
3692
+ * workspace can rename, and creation-only fields like `description` are
3693
+ * not among them (`postEvent` above hardcodes its own fields the same
3694
+ * way).
3695
+ */
3696
+ async fileTicket(fields, memberId) {
3697
+ const auth = await this.authorshipProbe();
3698
+ return this.client.records.create(this.models.issues, {
3699
+ ...fields,
3700
+ ...auth.issueAuthor && memberId ? { [this.contract.columns.author]: memberId } : {},
3701
+ ...auth.issueShip && auth.shipId ? { [this.contract.columns.ship]: auth.shipId } : {}
3702
+ });
3703
+ }
3667
3704
  };
3668
- function epicLabel(e, c) {
3669
- const v = e[c.epics.keyColumn];
3670
- return typeof v === "string" && v ? v : e.id;
3671
- }
3672
- function trackedEpics(epics, c) {
3673
- const s = c.epics.statuses;
3674
- const movable = /* @__PURE__ */ new Set([s.planned, s.building, s.done]);
3675
- return epics.filter((e) => {
3676
- const status2 = statusOf(e, c);
3677
- return status2 !== null && movable.has(status2);
3678
- });
3679
- }
3680
- function workedStatuses(c) {
3681
- const s = c.statuses;
3682
- return new Set(
3683
- [s.building, s.handoff, s.verifying, s.verified, s.reviewing].filter((v) => !!v)
3684
- );
3705
+
3706
+ // src/blocked.ts
3707
+ import { StaleWriteError as StaleWriteError2 } from "@tablation/client";
3708
+ function missingBlockerIds(tickets) {
3709
+ const known = new Set(tickets.map((t) => t.id));
3710
+ const missing = /* @__PURE__ */ new Set();
3711
+ for (const t of tickets) for (const id of t.blocked_by ?? []) if (!known.has(id)) missing.add(id);
3712
+ return [...missing];
3685
3713
  }
3686
- function epicsNeedingClosedCheck(epics, outstanding, c) {
3687
- const hasOutstanding = new Set(outstanding.map((t) => epicOf(t, c)));
3688
- return trackedEpics(epics, c).filter((e) => statusOf(e, c) !== c.epics.statuses.done && !hasOutstanding.has(e.id)).map((e) => e.id);
3714
+ function blockerInfoMap(tickets, extra = []) {
3715
+ const info = {};
3716
+ for (const t of [...tickets, ...extra]) {
3717
+ info[t.id] = { issue_id: t.issue_id, issue_tag: t.issue_tag, status: t.status };
3718
+ }
3719
+ return info;
3689
3720
  }
3690
- function planEpicSync(epics, outstanding, closed, c) {
3691
- const s = c.epics.statuses;
3692
- const isClosed = new Set(closedStatuses(c));
3693
- const worked = workedStatuses(c);
3694
- const openByEpic = /* @__PURE__ */ new Map();
3695
- for (const t of outstanding) {
3696
- const id = epicOf(t, c);
3697
- if (!id || isClosed.has(t.status)) continue;
3698
- openByEpic.set(id, [...openByEpic.get(id) ?? [], t]);
3721
+ function computeBlockedIds(tickets, info) {
3722
+ const blocked = /* @__PURE__ */ new Set();
3723
+ for (const t of tickets) {
3724
+ const unresolved = (t.blocked_by ?? []).some((id) => {
3725
+ const status2 = info[id]?.status;
3726
+ return status2 !== void 0 && !RESOLVED_STATUSES.has(status2);
3727
+ });
3728
+ if (unresolved) blocked.add(t.id);
3699
3729
  }
3700
- const closedCount = /* @__PURE__ */ new Map();
3701
- for (const t of closed) {
3702
- const id = epicOf(t, c);
3703
- if (!id || !isClosed.has(t.status)) continue;
3704
- closedCount.set(id, (closedCount.get(id) ?? 0) + 1);
3730
+ return blocked;
3731
+ }
3732
+ function sweepDiagnostics(tickets, info) {
3733
+ const dangling = [];
3734
+ const selfBlocked = [];
3735
+ for (const t of tickets) {
3736
+ for (const id of t.blocked_by ?? []) {
3737
+ if (info[id] === void 0) dangling.push(`${t.issue_id} -> ${id}`);
3738
+ if (id === t.id) selfBlocked.push(t.issue_id);
3739
+ }
3705
3740
  }
3741
+ return { dangling, selfBlocked: [...new Set(selfBlocked)] };
3742
+ }
3743
+ var describeBlockers = (t, info) => (t.blocked_by ?? []).map((id) => `${info[id]?.issue_id ?? "?"} (${info[id]?.status ?? "unknown"})`).join(", ");
3744
+ function planSweep(tickets, info, blocked) {
3706
3745
  const steps = [];
3707
- for (const epic of trackedEpics(epics, c)) {
3708
- const from = statusOf(epic, c);
3709
- const open = openByEpic.get(epic.id) ?? [];
3710
- const label = epicLabel(epic, c);
3711
- if (open.length === 0) {
3712
- const n = closedCount.get(epic.id) ?? 0;
3713
- if (from !== s.done && n > 0) {
3714
- steps.push({
3715
- action: "complete",
3716
- epic,
3717
- label,
3718
- from,
3719
- to: s.done,
3720
- reason: n === 1 ? "its only ticket is closed" : `all ${n} tickets closed`
3721
- });
3722
- }
3723
- continue;
3724
- }
3725
- const active = open.filter((t) => worked.has(t.status));
3726
- if (active.length && from !== s.building) {
3727
- steps.push({
3728
- action: from === s.done ? "reopen" : "start",
3729
- epic,
3730
- label,
3731
- from,
3732
- to: s.building,
3733
- reason: active.map((t) => `${t.issue_tag ?? t.issue_id} (${t.status})`).join(", ")
3734
- });
3746
+ for (const t of tickets) {
3747
+ if (t.status === "accepted" && blocked.has(t.id)) {
3748
+ steps.push({ action: "park", ticket: t, blockers: describeBlockers(t, info), to: "blocked" });
3749
+ } else if (t.status === "blocked" && !blocked.has(t.id)) {
3750
+ steps.push({ action: "restore", ticket: t, blockers: describeBlockers(t, info), to: "accepted" });
3735
3751
  }
3736
3752
  }
3737
3753
  return steps;
3738
3754
  }
3739
- var describeEpicStep = (s) => `epic ${s.label}: ${s.from} -> ${s.to} (${s.reason})`;
3740
- async function applyEpicSync(writer, steps, c, log) {
3741
- let updated = 0;
3742
- let contended = 0;
3755
+ function strandedNeedsInfo(tickets, blocked, holds2 = /* @__PURE__ */ new Set()) {
3756
+ return tickets.filter(
3757
+ (t) => t.status === "needs_info" && (t.blocked_by ?? []).length > 0 && !blocked.has(t.id) && !(t.assignee_id && holds2.has(t.assignee_id))
3758
+ );
3759
+ }
3760
+ var COORDINATING_DEFAULTS = {
3761
+ reportTypeColumn: "report_type",
3762
+ coordinatingValue: "coordinating"
3763
+ };
3764
+ var isCoordinating = (t, opts) => t[opts.reportTypeColumn] === opts.coordinatingValue;
3765
+ var FILING_ERROR_DEFAULT_EXEMPT = ["coordinating", "question", "investigation"];
3766
+ function rollUpParents(tickets, done = RESOLVED_STATUSES, opts = {}) {
3767
+ const resolved = { ...COORDINATING_DEFAULTS, ...opts };
3768
+ const byParent = /* @__PURE__ */ new Map();
3769
+ for (const t of tickets) {
3770
+ const p = t.parent_id;
3771
+ if (!p) continue;
3772
+ const list = byParent.get(p);
3773
+ if (list) list.push(t);
3774
+ else byParent.set(p, [t]);
3775
+ }
3776
+ const rollups = [];
3777
+ for (const t of tickets) {
3778
+ const children = byParent.get(t.id);
3779
+ if (!isCoordinating(t, resolved) || !children) continue;
3780
+ const outstanding = children.filter((c) => !done.has(c.status));
3781
+ rollups.push({ parent: t, children, outstanding, complete: outstanding.length === 0 });
3782
+ }
3783
+ return rollups;
3784
+ }
3785
+ function filingErrors(tickets, opts = {}) {
3786
+ const reportTypeColumn = opts.reportTypeColumn ?? COORDINATING_DEFAULTS.reportTypeColumn;
3787
+ const coordinatingValue = opts.coordinatingValue ?? COORDINATING_DEFAULTS.coordinatingValue;
3788
+ const exempt = new Set(
3789
+ opts.repoExemptValues ?? [coordinatingValue, ...FILING_ERROR_DEFAULT_EXEMPT.filter((v) => v !== "coordinating")]
3790
+ );
3791
+ return tickets.filter((t) => {
3792
+ if (t.repo_id) return false;
3793
+ return !exempt.has(t[reportTypeColumn]);
3794
+ });
3795
+ }
3796
+ function sweepComment(step) {
3797
+ return step.action === "park" ? `Parked as **Blocked** by the crew \u2014 waiting on: ${step.blockers}.
3798
+
3799
+ This is automatic and reversible: an approved ticket whose \`Blocked by\` entries are not all resolved is parked out of the queue, and restored on the first poll after the last one resolves. Nothing else about the ticket changes. A blocker counts as resolved at \`verified\`, \`closed_deployed\`, \`closed_wont_fix\` or \`closed_duplicate\` \u2014 not at \`fixed\`, which is still an unmerged branch awaiting QA.` : `Restored to **Approved** by the crew \u2014 every blocker has resolved${step.blockers && step.blockers !== "\u2014" ? ` (${step.blockers})` : ""}.
3800
+
3801
+ This restores the status it was approved at before being parked; it is back in the queue and a role may pick it up. If a blocker resolved as \`closed_wont_fix\` or \`closed_duplicate\` this ticket may still not be buildable as written \u2014 that is a call for a human, not the crew.`;
3802
+ }
3803
+ async function applySweep(writer, steps, memberId, log) {
3804
+ let parked = 0;
3805
+ let restored = 0;
3743
3806
  let failed = 0;
3807
+ let contended = 0;
3744
3808
  for (const step of steps) {
3745
- const data = { epic: step.label, from: step.from, to: step.to };
3746
3809
  try {
3747
- await writer.updateEpic(step.epic.id, { [c.epics.statusColumn]: step.to }, step.epic.updated_at);
3748
- updated++;
3749
- log.emit(describeEpicStep(step), { step: "sweep", data });
3810
+ await writer.updateTicket(step.ticket.id, { status: step.to }, step.ticket.updated_at);
3811
+ if (step.action === "park") parked++;
3812
+ else restored++;
3813
+ log.emit(
3814
+ step.action === "park" ? `parked (waiting on ${step.blockers})` : `restored to ${step.to} (blockers resolved: ${step.blockers || "none"})`,
3815
+ { ticket: step.ticket.issue_id, step: "sweep" }
3816
+ );
3817
+ try {
3818
+ await writer.postEvent(step.ticket.id, sweepComment(step), memberId);
3819
+ } catch {
3820
+ log.warn("status changed but the note failed to post", { ticket: step.ticket.issue_id, step: "sweep" });
3821
+ }
3750
3822
  } catch (e) {
3751
- if (e instanceof StaleWriteError3) {
3823
+ if (e instanceof StaleWriteError2) {
3752
3824
  contended++;
3753
- log.emit(`epic ${step.label} changed since it was read \u2014 left for the next cycle`, { step: "sweep", data });
3825
+ log.warn(`skipped ${step.action} \u2014 ticket changed since the sweep was planned`, {
3826
+ ticket: step.ticket.issue_id,
3827
+ step: "sweep"
3828
+ });
3754
3829
  continue;
3755
3830
  }
3756
3831
  failed++;
3757
- log.warn(`could not move epic ${step.label} to ${step.to}: ${e.message}`, { step: "sweep", data });
3758
- }
3759
- }
3760
- return { updated, contended, failed };
3761
- }
3762
-
3763
- // src/attention.ts
3764
- function attentionReasons(ticket, contract) {
3765
- const reasons = [];
3766
- if (ticket.status === contract.statuses.needsHuman) reasons.push("needs_info");
3767
- if (ticket.needs_planning === true) reasons.push("needs_planning");
3768
- if (ticket.needs_review === true) reasons.push("needs_review");
3769
- return reasons;
3770
- }
3771
- function attentionTransitions(previous, current) {
3772
- const out = [];
3773
- for (const [ticketId, reasons] of Object.entries(current)) {
3774
- const before = new Set(previous[ticketId] ?? []);
3775
- const newReasons = reasons.filter((r) => !before.has(r));
3776
- if (newReasons.length > 0) out.push({ ticketId, reasons: newReasons });
3777
- }
3778
- return out;
3779
- }
3780
-
3781
- // src/digest.ts
3782
- function displayKey2(t) {
3783
- return t.issue_tag ?? t.issue_id ?? "?";
3784
- }
3785
- function author(c, i) {
3786
- const { me, roster } = i;
3787
- if (c.team_member_id === me) return "you";
3788
- if (c.team_member_id === null || c.team_member_id === void 0) {
3789
- const byIdentity = memberByIdentity(roster, c.created_by_id);
3790
- if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
3791
- return `${c.reporter_name ?? "anon"} (no identity)`;
3792
- }
3793
- return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
3794
- }
3795
- function shipSuffix(c, i) {
3796
- if (!c.ship_id || c.ship_id === i.myShipId) return "";
3797
- const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
3798
- return name ? ` (${name})` : "";
3799
- }
3800
- function filedBy(t, i) {
3801
- const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
3802
- if (member) {
3803
- if (member.id === i.me) return "you";
3804
- const label = crewLabel(member) ?? member.name;
3805
- const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
3806
- return ship ? `${label} (${ship})` : label;
3832
+ log.warn(`could not ${step.action}: ${e.message}`, { ticket: step.ticket.issue_id, step: "sweep" });
3833
+ }
3807
3834
  }
3808
- return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
3835
+ return { parked, restored, failed, contended };
3809
3836
  }
3810
- function lastComment(i, ticketId) {
3811
- const forTicket = i.comments.filter((c) => c.ticket_id === ticketId).sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
3812
- const last = forTicket[forTicket.length - 1];
3813
- if (!last) return "\u2014";
3814
- return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
3837
+
3838
+ // src/priority.ts
3839
+ var indexOf = (order) => Object.fromEntries(order.map((v, i) => [v, i]));
3840
+ function derivedPriority(t, c = DEFAULT_CONTRACT) {
3841
+ return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.unknownPriorityRank;
3815
3842
  }
3816
- function newFromOthers(i, ticketId) {
3817
- return i.comments.filter(
3818
- (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark && (c.kind ?? "comment") !== "event"
3819
- ).length;
3843
+ function explicitPriority(t, c = DEFAULT_CONTRACT) {
3844
+ return indexOf(c.priorityOrder)[t.priority ?? ""] ?? null;
3820
3845
  }
3821
- function who(t, i) {
3822
- return whoAssigned(t, i) + heldBySuffix(t, i);
3846
+ function effectivePriority(t, c = DEFAULT_CONTRACT) {
3847
+ const explicit = explicitPriority(t, c);
3848
+ const derived = derivedPriority(t, c);
3849
+ return explicit === null ? derived : Math.min(explicit, derived);
3823
3850
  }
3824
- function heldBySuffix(t, i) {
3825
- if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
3826
- const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
3827
- return ` (held by ${name || "an unknown ship"})`;
3851
+ function severityRank(t, c = DEFAULT_CONTRACT) {
3852
+ return indexOf(c.severityOrder)[t.severity ?? ""] ?? c.severityOrder.length;
3828
3853
  }
3829
- function whoAssigned(t, i) {
3830
- if (!t.assignee_id) return "unassigned";
3831
- if (t.assignee_id === i.me) return "you";
3832
- const label = crewLabel(i.roster.get(t.assignee_id));
3833
- if (label === null) return "someone off this ship";
3834
- return isHold(i.roster, t.assignee_id) ? `**${label} \u2014 HOLD**` : label;
3854
+ function issueNumber(t) {
3855
+ const id = t.issue_id ?? "ISSUE-0";
3856
+ const n = Number.parseInt(id.split("-")[1] ?? "0", 10);
3857
+ return Number.isNaN(n) ? 0 : n;
3835
3858
  }
3836
- function blockers(t, i) {
3837
- const list = (t.blocked_by ?? []).map((id) => {
3838
- const info = i.blockerInfo[id];
3839
- return `${info ? displayKey2(info) : "?"} (${info?.status ?? "unknown"})`;
3840
- });
3841
- return list.length === 0 ? "\u2014" : list.join(", ");
3859
+ function epicRank(t) {
3860
+ return t.epicInProgress ? 0 : 1;
3842
3861
  }
3843
- var sorted = (ts) => [...ts].sort(compareRank);
3844
- var stream = (segments) => `${segments.join("\n")}
3845
- `;
3846
- function table(ts, i, header, row) {
3847
- if (ts.length === 0) return "\n_None._\n";
3848
- return `
3849
- ${header}
3850
- ${sorted(ts).map(row).join("\n")}
3851
- `;
3862
+ function rank(t, c = DEFAULT_CONTRACT) {
3863
+ return [effectivePriority(t, c), severityRank(t, c), epicRank(t), issueNumber(t)];
3852
3864
  }
3853
- function buildingDigest(i) {
3854
- const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3855
- const row = (t) => {
3856
- const n = newFromOthers(i, t.id);
3857
- const branch = i.branchFor ? i.branchFor(t) : "";
3858
- const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
3859
- const dir = i.dirFor ? i.dirFor(t) : null;
3860
- const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3861
- return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3862
- };
3863
- const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
3864
- const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
3865
- | ticket | status | assignee | eff | blocked by |
3866
- |---|---|---|---|---|
3867
- ${sorted(ts).map(blockedRow).join("\n")}
3868
- `;
3869
- const isBlocked = (t) => i.blocked.has(t.id);
3870
- const step1 = i.tickets.filter(
3871
- (t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
3872
- );
3873
- const step2 = i.tickets.filter(
3874
- (t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
3875
- );
3876
- return stream([
3877
- "## Current queue \u2014 built for you by the poll\n",
3878
- "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
3879
- '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
3880
- "\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
3881
- "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
3882
- "\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
3883
- table(step1, i, header, row),
3884
- "\n### Step 2 \u2014 accepted tickets, in pick order\n",
3885
- "\nWork the first one you are not required to skip. Tickets waiting on an\nunresolved dependency are already out of this table \u2014 see Blocked below.\n",
3886
- table(step2, i, header, row),
3887
- "\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
3888
- '\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
3889
- blockedTable(i.tickets.filter(isBlocked))
3890
- ]);
3865
+ function compareRank(a, b, c = DEFAULT_CONTRACT) {
3866
+ const ra = rank(a, c);
3867
+ const rb = rank(b, c);
3868
+ for (let i = 0; i < ra.length; i++) {
3869
+ const d = ra[i] - rb[i];
3870
+ if (d !== 0) return d;
3871
+ }
3872
+ return 0;
3891
3873
  }
3892
- function qaDigest(i) {
3893
- const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
3894
- const branchCell = (t) => {
3895
- if (i.dirFor && i.dirFor(t) === null) return "\u2014";
3896
- if (!i.existingBranchFor) return "";
3897
- return i.existingBranchFor(t) ?? "**MISSING**";
3898
- };
3899
- const worktreeCell = (t) => {
3900
- if (i.dirFor && i.dirFor(t) === null) return "\u2014";
3901
- return i.worktreeFor ? i.worktreeFor(t) : "";
3902
- };
3903
- const builtBy = (t) => t.needs_design === true ? "design" : "dev";
3904
- const row = (t) => {
3905
- const n = newFromOthers(i, t.id);
3906
- const dir = i.dirFor ? i.dirFor(t) : null;
3907
- const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
3908
- return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
3909
- };
3910
- return stream([
3911
- "## Current queue \u2014 built for you by the poll\n",
3912
- '\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
3913
- '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
3914
- "\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
3915
- "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
3916
- table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
3917
- "\n### Awaiting verification, in pick order\n",
3918
- table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
3919
- ]);
3874
+ function rankScalar(t, c = DEFAULT_CONTRACT) {
3875
+ const [p, s, e, n] = rank(t, c);
3876
+ return p * 1e9 + s * 1e7 + e * 1e6 + n;
3877
+ }
3878
+ var NOTHING_ACTIONABLE = 999999999999;
3879
+ var AGE_HOURS_TO_ERODE = 48;
3880
+ function agedRankScalar(rawRank, aging, now) {
3881
+ if (!aging) return rawRank;
3882
+ const elapsedHours = Math.max(0, (now - Date.parse(aging.since)) / 36e5);
3883
+ const weight = aging.weight ?? 1;
3884
+ const fraction = Math.min(1, elapsedHours * weight / AGE_HOURS_TO_ERODE);
3885
+ return Math.round(rawRank * (1 - fraction));
3920
3886
  }
3921
3887
 
3922
- // src/git.ts
3923
- import { execFileSync } from "child_process";
3924
- import {
3925
- existsSync as existsSync6,
3926
- mkdirSync as mkdirSync4,
3927
- openSync,
3928
- closeSync,
3929
- writeSync,
3930
- statSync as statSync3,
3931
- unlinkSync as unlinkSync2
3932
- } from "fs";
3933
- import { dirname as dirname4, basename as basename3 } from "path";
3934
- var GitError = class extends Error {
3935
- };
3936
- function gitRaw(cwd, args) {
3937
- try {
3938
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
3939
- } catch (e) {
3940
- const err = e;
3941
- const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
3942
- throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
3943
- }
3888
+ // src/ship-affinity.ts
3889
+ var CYCLE_SECONDS = 120;
3890
+ var SHIP_DEAD_AFTER_CYCLES = 10;
3891
+ function shipIsAlive(ship, now) {
3892
+ if (!ship?.last_seen) return false;
3893
+ const seen = Date.parse(ship.last_seen);
3894
+ return Number.isFinite(seen) && now - seen <= SHIP_DEAD_AFTER_CYCLES * CYCLE_SECONDS * 1e3;
3944
3895
  }
3945
- function git(cwd, args) {
3946
- try {
3947
- return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
3948
- } catch (e) {
3949
- const err = e;
3950
- const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
3951
- throw new GitError(`git ${args.join(" ")}: ${why || err.message.trim()}`);
3952
- }
3896
+ function ticketHolderShipId(t, contract) {
3897
+ const v = t[contract.columns.heldBy];
3898
+ return typeof v === "string" && v ? v : null;
3953
3899
  }
3954
- function gitOk(cwd, args) {
3955
- try {
3956
- return git(cwd, args);
3957
- } catch {
3958
- return null;
3959
- }
3900
+ function heldByOtherShip(t, a) {
3901
+ const statuses = a.contract.statuses;
3902
+ if (t.status === statuses.handoff) return false;
3903
+ return heldByLiveOtherShip(t, a);
3960
3904
  }
3961
- function commitDate(cwd, sha) {
3962
- const out = gitOk(cwd, ["show", "-s", "--format=%cI", sha]);
3963
- if (!out) return null;
3964
- const t = new Date(out);
3965
- return Number.isNaN(t.getTime()) ? null : t.toISOString();
3905
+ function heldByLiveOtherShip(t, a) {
3906
+ if (!a.myShipId) return false;
3907
+ const holder = ticketHolderShipId(t, a.contract);
3908
+ if (!holder || holder === a.myShipId) return false;
3909
+ const owner = a.ships.find((s) => s.id === holder);
3910
+ return shipIsAlive(owner, a.now ?? Date.now());
3966
3911
  }
3967
- var currentBranch = (cwd) => git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
3968
- var headSha = (cwd) => git(cwd, ["rev-parse", "HEAD"]);
3969
- function status(cwd) {
3970
- return gitRaw(cwd, ["status", "--porcelain"]).split("\n").filter((l) => l.length > 3).map((l) => ({
3971
- code: l.slice(0, 2),
3972
- path: l.slice(3),
3973
- untracked: l.startsWith("??")
3974
- }));
3912
+ function shipName(ships, id) {
3913
+ if (!id) return "an unknown ship";
3914
+ return ships.find((s) => s.id === id)?.name?.trim() || id;
3975
3915
  }
3976
- function branches(cwd, pattern) {
3977
- const out = git(cwd, ["branch", "--list", pattern, "--format=%(refname:short)"]);
3978
- return out ? out.split("\n").filter(Boolean) : [];
3916
+ function shipIdByName(ships, name) {
3917
+ const mine = ships.filter((r) => (r.name ?? "").trim() === name.trim());
3918
+ return mine.length === 1 ? mine[0].id : null;
3979
3919
  }
3980
- function branchForIssue(cwd, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
3981
- for (const c of branchCandidates(issueKey, templates, render2)) {
3982
- const found = branches(cwd, c);
3983
- if (found.length) return found[0];
3920
+ async function noteReaccepted(tracker, result, seat, shipLabel) {
3921
+ if (!result.reaccepted || !result.ticket) return;
3922
+ try {
3923
+ await tracker.postEvent(
3924
+ result.ticket.id,
3925
+ `Re-accepted while held by ${shipLabel}; resuming there, not re-claiming. Setting a held ticket back to \`accepted\` is not needed \u2014 to move it to another ship, clear \`held_by_ship_id\`.`,
3926
+ seat
3927
+ );
3928
+ } catch {
3984
3929
  }
3985
- return null;
3986
3930
  }
3987
- function branchCandidates(issueKey, templates, render2) {
3988
- const num = issueKey.replace(/^\D+/, "");
3989
- const unpadded = String(Number.parseInt(num, 10));
3990
- const candidates = [];
3991
- for (const template of [templates.name, templates.push].filter(Boolean)) {
3992
- const exact = render2(template);
3993
- candidates.push(exact);
3994
- if (num !== unpadded) candidates.push(exact.replace(num, unpadded));
3995
- if (template.includes("{slug}")) {
3996
- const prefix = render2(template.slice(0, template.indexOf("{slug}")));
3997
- if (prefix) candidates.push(`${prefix}*`);
3998
- }
3999
- }
4000
- candidates.push(`issue-${num}`);
4001
- if (num !== unpadded) candidates.push(`issue-${unpadded}`);
4002
- return [...new Set(candidates)];
3931
+ function planHoldReleases(tickets, contract) {
3932
+ const ends = [contract.statuses.handoff, contract.statuses.verified];
3933
+ return tickets.filter((t) => ends.includes(t.status) && ticketHolderShipId(t, contract) !== null);
4003
3934
  }
4004
- function remoteBranchForIssue(cwd, remote, issueKey, templates = { name: "issue-{number}" }, render2 = (t) => t.replace(/\{number\}/g, issueKey.replace(/^\D+/, ""))) {
4005
- const prefix = `refs/remotes/${remote}/`;
4006
- for (const c of branchCandidates(issueKey, templates, render2)) {
4007
- const out = gitOk(cwd, ["for-each-ref", "--format=%(refname)", `${prefix}${c}`]);
4008
- const hit = out?.split("\n").filter((r) => r.startsWith(prefix) && r !== `${prefix}HEAD`)[0];
4009
- if (hit) return hit.slice(prefix.length);
3935
+ async function applyHoldReleases(writer, tickets, contract, emit) {
3936
+ let cleared = 0;
3937
+ for (const t of tickets) {
3938
+ try {
3939
+ await writer.updateTicket(t.id, { [contract.columns.heldBy]: null }, t.updated_at);
3940
+ cleared++;
3941
+ emit.emit(`released ship hold (${t.status})`, { ticket: t.issue_id, step: "sweep" });
3942
+ } catch (e) {
3943
+ emit.warn(`could not release ship hold: ${e.message}`, { ticket: t.issue_id, step: "sweep" });
3944
+ }
4010
3945
  }
4011
- return null;
3946
+ return cleared;
4012
3947
  }
4013
- function materializeRemoteBranch(cwd, remote, branch) {
4014
- if (!remoteBranchExists(cwd, remote, branch)) {
4015
- return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) !== null;
3948
+ async function noteTakeover(tracker, result, seat, ships, myShipName, continuingFrom) {
3949
+ if (!result.tookOverFrom || !result.ticket) return;
3950
+ let sha = null;
3951
+ try {
3952
+ sha = continuingFrom(result.ticket);
3953
+ } catch {
4016
3954
  }
4017
- if (gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/heads/${branch}`]) === null) {
4018
- return gitOk(cwd, ["branch", "--track", branch, `${remote}/${branch}`]) !== null;
3955
+ try {
3956
+ await tracker.postEvent(
3957
+ result.ticket.id,
3958
+ `Taken over from ${shipName(ships, result.tookOverFrom)} (not seen recently) by ${myShipName}; ` + (sha ? `continuing from \`${sha}\` on origin.` : "no branch commit was found on origin to continue from."),
3959
+ seat
3960
+ );
3961
+ } catch {
4019
3962
  }
4020
- gitOk(cwd, ["fetch", "--quiet", remote, `${branch}:${branch}`]);
4021
- return true;
4022
- }
4023
- function deleteRemoteBranch(cwd, remote, branch) {
4024
- if (!remoteBranchExists(cwd, remote, branch)) return true;
4025
- return gitOk(cwd, ["push", remote, "--delete", branch]) !== null;
4026
- }
4027
- function worktreeForNumber(cwd, num) {
4028
- const re = new RegExp(`(?:^|-)${num}$`);
4029
- return worktrees(cwd).find((w) => re.test(basename3(w.path))) ?? null;
4030
3963
  }
4031
- var commitBodies = (cwd, range) => gitOk(cwd, ["log", range, "--format=%B"]) ?? "";
4032
- var resolve2 = (cwd, ref) => gitOk(cwd, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]);
4033
- var countCommits = (cwd, range) => Number.parseInt(gitOk(cwd, ["rev-list", "--count", range]) ?? "0", 10);
4034
- function latestReleaseTag(cwd, pattern = "v*", ref = "HEAD") {
4035
- return gitOk(cwd, ["describe", "--tags", "--abbrev=0", "--match", pattern, ref]);
3964
+ function originBranchSha(dir, t, rev) {
3965
+ const num = (t.issue_tag ?? t.issue_id).replace(/^\D+/, "");
3966
+ const names = [...new Set([(t.issue_tag ?? "").toLowerCase(), `issue-${num}`].filter(Boolean))];
3967
+ for (const n of names) {
3968
+ const sha = rev(dir, `origin/${n}`);
3969
+ if (sha) return sha.slice(0, 7);
3970
+ }
3971
+ return null;
4036
3972
  }
4037
- function firstReleaseTagContaining(cwd, sha, pattern = "v*") {
4038
- const out = gitOk(cwd, ["tag", "--contains", sha, "--list", pattern, "--sort=creatordate"]);
4039
- return out?.split("\n").map((l) => l.trim()).find(Boolean) ?? null;
3973
+ function holdWarnings(tickets, ships, contract, humanHolds, now = Date.now()) {
3974
+ const inFlight = [contract.statuses.building, contract.statuses.verifying];
3975
+ const out = [];
3976
+ for (const t of tickets) {
3977
+ if (!inFlight.includes(t.status)) continue;
3978
+ if (t.assignee_id && humanHolds.has(t.assignee_id)) continue;
3979
+ const holder = ticketHolderShipId(t, contract);
3980
+ if (!holder) {
3981
+ out.push(`${t.issue_id} (${t.status}) has no ${contract.columns.heldBy} \u2014 any ship may take it`);
3982
+ } else if (!shipIsAlive(ships.find((s) => s.id === holder), now)) {
3983
+ out.push(`${t.issue_id} (${t.status}) is held by ${shipName(ships, holder)}, which has not been seen for ${SHIP_DEAD_AFTER_CYCLES}+ cycles`);
3984
+ }
3985
+ }
3986
+ return out;
4040
3987
  }
4041
- var tagCommit = (cwd, tag) => resolve2(cwd, tag);
4042
- function createReleaseTag(cwd, tag, sha, message) {
4043
- git(cwd, ["tag", "-a", tag, sha, "-m", message]);
3988
+
3989
+ // src/select.ts
3990
+ var qaSlice = (tickets) => tickets.filter((t) => t.status === "fixed" || t.status === "qa");
3991
+ var buildingSlice = (tickets, role) => tickets.filter((t) => role === "design" ? t.needs_design === true : t.needs_design !== true);
3992
+ var triageSlice = (tickets, triageSeat) => triageSeat ? tickets.filter((t) => t.assignee_id === triageSeat) : [];
3993
+ function sliceFor(tickets, role) {
3994
+ if (role === "qa") return qaSlice(tickets);
3995
+ if (role === "triage") return triageSlice(tickets);
3996
+ if (role === "pair") return [];
3997
+ return buildingSlice(tickets, role);
4044
3998
  }
4045
- var tagExists = (cwd, tag) => gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/tags/${tag}`]) !== null;
4046
- function pushTag(cwd, remote, tag) {
4047
- git(cwd, ["push", remote, tag]);
3999
+ var isHeld = (t, holds2) => !!t.assignee_id && holds2.has(t.assignee_id);
4000
+ function affinityContext(i) {
4001
+ return i.ships ? { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now } : null;
4048
4002
  }
4049
- function fetchRemote(cwd, remote = "origin") {
4050
- return gitOk(cwd, ["fetch", "--prune", "--quiet", remote]) !== null;
4003
+ function withHoldCheck(affinity, i) {
4004
+ if (!affinity) return void 0;
4005
+ const a = affinityContext(i);
4006
+ return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
4051
4007
  }
4052
- function remoteConfigured(cwd, remote = "origin") {
4053
- return gitOk(cwd, ["remote", "get-url", remote]) !== null;
4008
+ function notHeldByOtherShip(slice, i) {
4009
+ if (!i.ships) return slice;
4010
+ const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
4011
+ return slice.filter((t) => !heldByOtherShip(t, a));
4054
4012
  }
4055
- function remoteBranchExists(cwd, remote, branch) {
4056
- return gitOk(cwd, ["rev-parse", "-q", "--verify", `refs/remotes/${remote}/${branch}`]) !== null;
4013
+ function buildingRoleHasWork(role, i) {
4014
+ const me = i.seats[role];
4015
+ if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
4016
+ const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
4017
+ const startable = mine.filter(
4018
+ (t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
4019
+ );
4020
+ const watched = mine.filter(
4021
+ (t) => (t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id) || t.status === "needs_info") && !isHeld(t, i.holds)
4022
+ );
4023
+ const watchedIds = new Set(watched.map((t) => t.id));
4024
+ const newComments = i.comments.filter(
4025
+ (c) => c.created_at > i.watermark && c.team_member_id !== me && (c.kind ?? "comment") !== "event" && watchedIds.has(c.ticket_id)
4026
+ ).length;
4027
+ const unassigned = mine.filter(
4028
+ (t) => t.status === "in_progress" && !t.assignee_id && !i.blocked.has(t.id)
4029
+ ).length;
4030
+ const resumable = mine.filter(
4031
+ (t) => t.status === "in_progress" && t.assignee_id === me && !t.needs_planning && !t.needs_review && !isHeld(t, i.holds)
4032
+ ).length;
4033
+ if (startable.length > 0 || newComments > 0 || unassigned > 0 || resumable > 0) {
4034
+ const parts = [];
4035
+ if (startable.length) parts.push(`${startable.length} startable`);
4036
+ if (newComments) parts.push(`${newComments} new comment(s)`);
4037
+ if (unassigned) parts.push(`${unassigned} unassigned in_progress`);
4038
+ if (resumable) parts.push(`${resumable} resumable in_progress`);
4039
+ return { hasWork: true, reason: parts.join(", ") };
4040
+ }
4041
+ return { hasWork: false, reason: "nothing startable and no new comments" };
4057
4042
  }
4058
- function findKeyInRange(cwd, key, range) {
4059
- return keyCommitsInRange(cwd, key, range)[0] ?? null;
4043
+ function qaRoleHasWork(i) {
4044
+ if (!i.seats.qa) return { hasWork: false, reason: "this ship does not crew that role" };
4045
+ const n = notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds)).length;
4046
+ return n > 0 ? { hasWork: true, reason: `${n} ticket(s) awaiting or in verification` } : { hasWork: false, reason: "nothing awaiting verification" };
4060
4047
  }
4061
- function keyCommitsInRange(cwd, key, range) {
4062
- const keys = Array.isArray(key) ? key : [key];
4063
- const escaped = keys.map((k) => k.replace(/[.[\]{}()*+?^$|\\]/g, "\\$&"));
4064
- const pattern = `(^|[^0-9A-Za-z_-])(${escaped.join("|")})([^0-9]|$)`;
4065
- const out = gitOk(cwd, ["log", range, "--format=%H %s", "--extended-regexp", `--grep=${pattern}`]);
4066
- if (!out) return [];
4067
- return out.split("\n").map((l) => l.split(" ")[0]).filter(Boolean);
4048
+ function triageRoleHasWork(i) {
4049
+ if (!i.seats.triage) return { hasWork: false, reason: "this ship does not crew that role" };
4050
+ const n = triageSlice(i.tickets, i.seats.triage).length;
4051
+ return n > 0 ? { hasWork: true, reason: `${n} report(s) assigned to triage` } : { hasWork: false, reason: "nothing assigned to triage" };
4068
4052
  }
4069
- function patchIdOf(cwd, from, to) {
4070
- try {
4071
- const diff = execFileSync("git", ["diff", from, to], { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
4072
- if (!diff) return null;
4073
- const out = execFileSync("git", ["patch-id", "--stable"], { cwd, encoding: "utf8", input: diff });
4074
- return out.split(" ")[0]?.trim() || null;
4075
- } catch {
4076
- return null;
4077
- }
4053
+ function roleHasWork(role, i) {
4054
+ if (i.paused?.has(role)) return { hasWork: false, reason: "paused" };
4055
+ if (role === "qa") return qaRoleHasWork(i);
4056
+ if (role === "triage") return triageRoleHasWork(i);
4057
+ if (role === "pair") return { hasWork: false, reason: "pair is interactive, not polled" };
4058
+ return buildingRoleHasWork(role, i);
4078
4059
  }
4079
- function findSquashOfBranch(cwd, key, branch, base, range) {
4080
- const candidates = keyCommitsInRange(cwd, key, range);
4081
- if (candidates.length === 0) return null;
4082
- const mb = gitOk(cwd, ["merge-base", base, branch]);
4083
- const branchPatch = mb ? patchIdOf(cwd, mb, branch) : null;
4084
- for (const sha of candidates) {
4085
- if (gitOk(cwd, ["diff", "--quiet", branch, sha]) !== null) return sha;
4086
- if (branchPatch && gitOk(cwd, ["rev-parse", "--verify", "-q", `${sha}^`]) !== null && patchIdOf(cwd, `${sha}^`, sha) === branchPatch) return sha;
4087
- }
4088
- return null;
4060
+ function roleCandidates(role, i) {
4061
+ if (role === "qa") return notHeldByOtherShip(qaSlice(i.tickets), i).filter((t) => !isHeld(t, i.holds));
4062
+ if (role === "triage") return triageSlice(i.tickets, i.seats.triage).filter((t) => !isHeld(t, i.holds));
4063
+ if (role === "pair") return [];
4064
+ const me = i.seats[role];
4065
+ if (!me) return [];
4066
+ return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
4067
+ (t) => ((t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) || t.status === "in_progress" && (t.assignee_id === me || !t.assignee_id)) && !isHeld(t, i.holds)
4068
+ );
4089
4069
  }
4090
- function isAncestor(cwd, ancestor, descendant) {
4091
- return gitOk(cwd, ["merge-base", "--is-ancestor", ancestor, descendant]) !== null;
4070
+ function claimStatuses(role, c) {
4071
+ return role === "qa" ? { approved: c.statuses.handoff, building: c.statuses.verifying } : { approved: c.statuses.approved, building: c.statuses.building };
4092
4072
  }
4093
- function fileAtRef(cwd, ref, path) {
4094
- return gitOk(cwd, ["show", `${ref}:${path}`]);
4073
+ function rankedCandidates(role, i) {
4074
+ const c = i.contract ?? DEFAULT_CONTRACT;
4075
+ return [...roleCandidates(role, i)].sort((a, b) => rankScalar(a, c) - rankScalar(b, c));
4095
4076
  }
4096
- function findKeyOnBase(cwd, key, remote, base, since2) {
4097
- const range = since2 ? `${since2}..${remote}/${base}` : `${remote}/${base}`;
4098
- return findKeyInRange(cwd, key, range);
4077
+ function roleTopRank(role, i) {
4078
+ if (role !== "qa" && role !== "triage" && !i.seats[role]) return NOTHING_ACTIONABLE;
4079
+ const candidates = roleCandidates(role, i);
4080
+ if (candidates.length === 0) return NOTHING_ACTIONABLE;
4081
+ const c = i.contract ?? DEFAULT_CONTRACT;
4082
+ return Math.min(...candidates.map((t) => rankScalar(t, c)));
4099
4083
  }
4100
- function detectClosureHeuristically(cwd, key, pushedBranch, remote = "origin", base = "main", since2, aliases = []) {
4101
- const merged = findKeyOnBase(cwd, [key, ...aliases], remote, base, since2);
4102
- if (merged) {
4103
- return {
4104
- state: "merged",
4105
- confidence: "heuristic",
4106
- mergedAt: merged,
4107
- detail: `${key} appears on ${remote}/${base} at ${merged.slice(0, 8)}`
4108
- };
4109
- }
4110
- if (remoteBranchExists(cwd, remote, pushedBranch)) {
4111
- return {
4112
- state: "open",
4113
- confidence: "heuristic",
4114
- detail: `${remote}/${pushedBranch} still exists and ${key} is not on ${base}`
4115
- };
4116
- }
4117
- return {
4118
- state: "abandoned",
4119
- confidence: "heuristic",
4120
- detail: `${remote}/${pushedBranch} no longer exists and ${key} never reached ${base} \u2014 this is also what a squash merge with a rewritten subject looks like`
4121
- };
4084
+ function actionableSummary(i, pending) {
4085
+ const c = i.contract ?? DEFAULT_CONTRACT;
4086
+ const all = pending.flatMap((role) => roleCandidates(role, i));
4087
+ if (all.length === 0) return { count: 0 };
4088
+ const top = all.reduce((best, t) => rankScalar(t, c) < rankScalar(best, c) ? t : best);
4089
+ return { count: all.length, top };
4122
4090
  }
4123
- async function detectClosure(o) {
4124
- const remote = o.remote ?? "origin";
4125
- const base = o.base ?? "main";
4126
- const aliases = o.aliases ?? [];
4127
- if (o.mergedHook) {
4128
- const code = await o.mergedHook({
4129
- CREW_TICKET: o.key,
4130
- CREW_BRANCH: o.pushedBranch,
4131
- CREW_BASE: base
4132
- });
4133
- if (code === 0) {
4134
- const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
4135
- return {
4136
- state: "merged",
4137
- confidence: "definitive",
4138
- mergedAt,
4139
- detail: `the repo's merged hook says ${o.key} landed`
4140
- };
4091
+ var FIXED_RANK = { qa: -2, triage: -1 };
4092
+ function selectRole(i) {
4093
+ const order = ["qa", "triage", "dev", "design"];
4094
+ const pending = [];
4095
+ const ranks = {};
4096
+ const reasons = {};
4097
+ for (const role of order) {
4098
+ const { hasWork, reason } = roleHasWork(role, i);
4099
+ reasons[role] = reason;
4100
+ if (hasWork) pending.push(role);
4101
+ }
4102
+ if (pending.length === 0) return { pending, selected: null, ranks, reasons };
4103
+ let selected = null;
4104
+ let best = Infinity;
4105
+ for (const role of pending) {
4106
+ const rank2 = FIXED_RANK[role] ?? roleTopRank(role, i);
4107
+ ranks[role] = rank2;
4108
+ if (rank2 < best) {
4109
+ best = rank2;
4110
+ selected = role;
4141
4111
  }
4142
- return remoteBranchExists(o.cwd, remote, o.pushedBranch) ? { state: "open", confidence: "definitive", detail: `the repo's merged hook says ${o.key} has not landed` } : { state: "abandoned", confidence: "definitive", detail: `${o.key} has not landed and ${remote}/${o.pushedBranch} is gone` };
4143
4112
  }
4144
- return detectClosureHeuristically(o.cwd, o.key, o.pushedBranch, remote, base, o.since, aliases);
4145
- }
4146
- function upstreamOf(cwd, branch) {
4147
- return gitOk(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{upstream}`]);
4113
+ return { pending, selected, ranks, reasons };
4148
4114
  }
4149
- function syncState(cwd, branch) {
4150
- const upstream = upstreamOf(cwd, branch);
4151
- if (!upstream) {
4152
- return {
4153
- branch,
4154
- upstream: null,
4155
- ahead: 0,
4156
- behind: 0,
4157
- dirty: status(cwd).length > 0,
4158
- canFastForward: false,
4159
- detail: `${branch} tracks no remote branch`
4160
- };
4161
- }
4162
- const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${branch}`]) ?? "0 0";
4163
- const [behindStr, aheadStr] = counts.split(/\s+/);
4164
- const behind = Number.parseInt(behindStr ?? "0", 10);
4165
- const ahead = Number.parseInt(aheadStr ?? "0", 10);
4166
- const dirty = status(cwd).length > 0;
4167
- let detail;
4168
- if (behind === 0 && ahead === 0) detail = `level with ${upstream}`;
4169
- else if (dirty) detail = `${behind} behind ${upstream}, but the worktree is dirty \u2014 not touching it`;
4170
- else if (ahead > 0 && behind > 0) detail = `diverged from ${upstream} (${ahead} ahead, ${behind} behind) \u2014 needs a human`;
4171
- else if (behind > 0) detail = `${behind} behind ${upstream} \u2014 fast-forwardable`;
4172
- else detail = `${ahead} ahead of ${upstream}, nothing to take`;
4173
- return {
4174
- branch,
4175
- upstream,
4176
- ahead,
4177
- behind,
4178
- dirty,
4179
- canFastForward: behind > 0 && ahead === 0 && !dirty,
4180
- detail
4181
- };
4115
+
4116
+ // src/epics.ts
4117
+ import { StaleWriteError as StaleWriteError3 } from "@tablation/client";
4118
+ var epicOf = (t, c) => {
4119
+ const v = t[c.columns.epic];
4120
+ return typeof v === "string" && v ? v : null;
4121
+ };
4122
+ var statusOf = (e, c) => {
4123
+ const v = e[c.epics.statusColumn];
4124
+ return typeof v === "string" && v ? v : null;
4125
+ };
4126
+ function epicLabel(e, c) {
4127
+ const v = e[c.epics.keyColumn];
4128
+ return typeof v === "string" && v ? v : e.id;
4182
4129
  }
4183
- function fastForward(cwd, branch) {
4184
- const s = syncState(cwd, branch);
4185
- if (!s.canFastForward) return false;
4186
- return gitOk(cwd, ["merge", "--ff-only", s.upstream]) !== null;
4130
+ function trackedEpics(epics, c) {
4131
+ const s = c.epics.statuses;
4132
+ const movable = /* @__PURE__ */ new Set([s.planned, s.building, s.done]);
4133
+ return epics.filter((e) => {
4134
+ const status2 = statusOf(e, c);
4135
+ return status2 !== null && movable.has(status2);
4136
+ });
4187
4137
  }
4188
- function baseBranchUnsafe(r) {
4189
- return r.action === "diverged" || r.action === "ahead" || r.action === "ff-failed";
4138
+ function workedStatuses(c) {
4139
+ const s = c.statuses;
4140
+ return new Set(
4141
+ [s.building, s.handoff, s.verifying, s.verified, s.reviewing].filter((v) => !!v)
4142
+ );
4190
4143
  }
4191
- function describeUnsafeBase(r, remote, base) {
4192
- const counts = r.action === "diverged" ? `${r.ahead} ahead, ${r.behind} behind ${remote}/${base}` : r.action === "ahead" ? `${r.ahead} ahead of ${remote}/${base}` : `${r.behind} behind ${remote}/${base}, cannot fast-forward`;
4193
- return `${r.detail} [${counts}] \u2014 reconcile it (\`crew sync\` afterwards), or \`git reset --hard ${remote}/${base}\` if the local commits duplicate a release`;
4144
+ function epicsNeedingClosedCheck(epics, outstanding, c) {
4145
+ const hasOutstanding = new Set(outstanding.map((t) => epicOf(t, c)));
4146
+ return trackedEpics(epics, c).filter((e) => statusOf(e, c) !== c.epics.statuses.done && !hasOutstanding.has(e.id)).map((e) => e.id);
4194
4147
  }
4195
- function refreshBaseBranch(cwd, remote, base, dryRun = false) {
4196
- if (currentBranch(cwd) !== base || status(cwd).length > 0) {
4197
- return { action: "not-applicable", detail: `${cwd} is not on a clean ${base}` };
4198
- }
4199
- if (!remoteConfigured(cwd, remote)) {
4200
- return { action: "not-applicable", detail: `${remote} is not configured` };
4201
- }
4202
- if (!fetchRemote(cwd, remote)) {
4203
- return { action: "fetch-failed", detail: `could not fetch ${remote}` };
4204
- }
4205
- if (!remoteBranchExists(cwd, remote, base)) {
4206
- return { action: "not-applicable", detail: `${remote}/${base} does not exist` };
4207
- }
4208
- const upstream = `${remote}/${base}`;
4209
- const counts = gitOk(cwd, ["rev-list", "--left-right", "--count", `${upstream}...${base}`]) ?? "0 0";
4210
- const [behindStr, aheadStr] = counts.split(/\s+/);
4211
- const behind = Number.parseInt(behindStr ?? "0", 10);
4212
- const ahead = Number.parseInt(aheadStr ?? "0", 10);
4213
- const named = () => {
4214
- const subjects = (gitOk(cwd, ["log", "--format=%h %s", "-5", `${upstream}..${base}`]) ?? "").split("\n").filter(Boolean);
4215
- return subjects.length ? `: ${subjects.join("; ")}${ahead > subjects.length ? "; \u2026" : ""}` : "";
4216
- };
4217
- if (ahead > 0 && behind > 0) {
4218
- return {
4219
- action: "diverged",
4220
- ahead,
4221
- behind,
4222
- detail: `${base} has diverged from ${upstream} (${ahead} ahead, ${behind} behind)${named()}`
4223
- };
4224
- }
4225
- if (behind === 0 && ahead > 0) {
4226
- return { action: "ahead", ahead, detail: `${base} has ${ahead} commit(s) ${upstream} does not${named()}` };
4148
+ function planEpicSync(epics, outstanding, closed, c) {
4149
+ const s = c.epics.statuses;
4150
+ const isClosed = new Set(closedStatuses(c));
4151
+ const worked = workedStatuses(c);
4152
+ const openByEpic = /* @__PURE__ */ new Map();
4153
+ for (const t of outstanding) {
4154
+ const id = epicOf(t, c);
4155
+ if (!id || isClosed.has(t.status)) continue;
4156
+ openByEpic.set(id, [...openByEpic.get(id) ?? [], t]);
4227
4157
  }
4228
- if (behind === 0) return { action: "level", detail: `${base} is level with ${upstream}` };
4229
- if (dryRun) {
4230
- return { action: "would-fast-forward", behind, detail: `would fast-forward ${base}: ${behind} behind ${upstream}` };
4158
+ const closedCount = /* @__PURE__ */ new Map();
4159
+ for (const t of closed) {
4160
+ const id = epicOf(t, c);
4161
+ if (!id || !isClosed.has(t.status)) continue;
4162
+ closedCount.set(id, (closedCount.get(id) ?? 0) + 1);
4231
4163
  }
4232
- if (gitOk(cwd, ["merge", "--ff-only", upstream]) === null) {
4233
- return { action: "ff-failed", behind, detail: `${base} is ${behind} behind ${upstream} and would not fast-forward` };
4164
+ const steps = [];
4165
+ for (const epic of trackedEpics(epics, c)) {
4166
+ const from = statusOf(epic, c);
4167
+ const open = openByEpic.get(epic.id) ?? [];
4168
+ const label = epicLabel(epic, c);
4169
+ if (open.length === 0) {
4170
+ const n = closedCount.get(epic.id) ?? 0;
4171
+ if (from !== s.done && n > 0) {
4172
+ steps.push({
4173
+ action: "complete",
4174
+ epic,
4175
+ label,
4176
+ from,
4177
+ to: s.done,
4178
+ reason: n === 1 ? "its only ticket is closed" : `all ${n} tickets closed`
4179
+ });
4180
+ }
4181
+ continue;
4182
+ }
4183
+ const active = open.filter((t) => worked.has(t.status));
4184
+ if (active.length && from !== s.building) {
4185
+ steps.push({
4186
+ action: from === s.done ? "reopen" : "start",
4187
+ epic,
4188
+ label,
4189
+ from,
4190
+ to: s.building,
4191
+ reason: active.map((t) => `${t.issue_tag ?? t.issue_id} (${t.status})`).join(", ")
4192
+ });
4193
+ }
4234
4194
  }
4235
- return {
4236
- action: "fast-forwarded",
4237
- behind,
4238
- detail: `fast-forwarded ${base} to ${upstream} (${behind} commit(s) from elsewhere)`
4239
- };
4195
+ return steps;
4240
4196
  }
4241
- function recoverStrandedReleaseCheckouts(cwd) {
4242
- gitOk(cwd, ["worktree", "prune"]);
4243
- const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
4244
- let path = "";
4245
- for (const line of out.split("\n")) {
4246
- if (line.startsWith("worktree ")) path = line.slice("worktree ".length);
4247
- else if (line.startsWith("branch refs/heads/crew/release-") && path) {
4248
- gitOk(cwd, ["worktree", "remove", "--force", path]);
4197
+ var describeEpicStep = (s) => `epic ${s.label}: ${s.from} -> ${s.to} (${s.reason})`;
4198
+ async function applyEpicSync(writer, steps, c, log) {
4199
+ let updated = 0;
4200
+ let contended = 0;
4201
+ let failed = 0;
4202
+ for (const step of steps) {
4203
+ const data = { epic: step.label, from: step.from, to: step.to };
4204
+ try {
4205
+ await writer.updateEpic(step.epic.id, { [c.epics.statusColumn]: step.to }, step.epic.updated_at);
4206
+ updated++;
4207
+ log.emit(describeEpicStep(step), { step: "sweep", data });
4208
+ } catch (e) {
4209
+ if (e instanceof StaleWriteError3) {
4210
+ contended++;
4211
+ log.emit(`epic ${step.label} changed since it was read \u2014 left for the next cycle`, { step: "sweep", data });
4212
+ continue;
4213
+ }
4214
+ failed++;
4215
+ log.warn(`could not move epic ${step.label} to ${step.to}: ${e.message}`, { step: "sweep", data });
4249
4216
  }
4250
4217
  }
4251
- const stranded = (gitOk(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads/crew/release-*"]) ?? "").split("\n").filter(Boolean);
4252
- for (const b of stranded) gitOk(cwd, ["branch", "-q", "-D", b]);
4253
- return stranded;
4218
+ return { updated, contended, failed };
4254
4219
  }
4255
- function pushBranch(cwd, remote, branch, dst = branch) {
4256
- git(cwd, ["push", remote, `${branch}:${dst}`]);
4220
+
4221
+ // src/attention.ts
4222
+ function attentionReasons(ticket, contract) {
4223
+ const reasons = [];
4224
+ if (ticket.status === contract.statuses.needsHuman) reasons.push("needs_info");
4225
+ if (ticket.needs_planning === true) reasons.push("needs_planning");
4226
+ if (ticket.needs_review === true) reasons.push("needs_review");
4227
+ return reasons;
4257
4228
  }
4258
- function worktrees(cwd) {
4259
- const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
4260
- const list = [];
4261
- let current = {};
4262
- for (const line of out.split("\n")) {
4263
- if (line.startsWith("worktree ")) {
4264
- if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
4265
- current = { path: line.slice("worktree ".length) };
4266
- } else if (line.startsWith("branch ")) {
4267
- current.branch = line.slice("branch refs/heads/".length);
4268
- } else if (line === "detached") {
4269
- current.branch = null;
4270
- } else if (line.startsWith("prunable ")) {
4271
- current.prunable = true;
4272
- }
4229
+ function attentionTransitions(previous, current) {
4230
+ const out = [];
4231
+ for (const [ticketId, reasons] of Object.entries(current)) {
4232
+ const before = new Set(previous[ticketId] ?? []);
4233
+ const newReasons = reasons.filter((r) => !before.has(r));
4234
+ if (newReasons.length > 0) out.push({ ticketId, reasons: newReasons });
4235
+ }
4236
+ return out;
4237
+ }
4238
+
4239
+ // src/digest.ts
4240
+ function displayKey2(t) {
4241
+ return t.issue_tag ?? t.issue_id ?? "?";
4242
+ }
4243
+ function author(c, i) {
4244
+ const { me, roster } = i;
4245
+ if (c.team_member_id === me) return "you";
4246
+ if (c.team_member_id === null || c.team_member_id === void 0) {
4247
+ const byIdentity = memberByIdentity(roster, c.created_by_id);
4248
+ if (byIdentity) return byIdentity.id === me ? "you" : crewLabel(byIdentity) ?? byIdentity.name;
4249
+ return `${c.reporter_name ?? "anon"} (no identity)`;
4273
4250
  }
4274
- if (current.path) list.push({ path: current.path, branch: current.branch ?? null, prunable: current.prunable ?? false });
4275
- return list;
4251
+ return (crewLabel(roster.get(c.team_member_id)) ?? "someone off this ship") + shipSuffix(c, i);
4276
4252
  }
4277
- function cloneUrlFor(remote) {
4278
- return `git@github.com:${remote}.git`;
4253
+ function shipSuffix(c, i) {
4254
+ if (!c.ship_id || c.ship_id === i.myShipId) return "";
4255
+ const name = i.ships?.find((s) => s.id === c.ship_id)?.name;
4256
+ return name ? ` (${name})` : "";
4279
4257
  }
4280
- function ensureRepoCheckout(dir, remote) {
4281
- if (existsSync6(`${dir}/.git`)) return false;
4282
- if (!remote) {
4283
- throw new GitError(
4284
- `${dir} does not exist and this repo has no discovered remote to clone from \u2014 re-run \`crew connect\`, or check out the repository yourself and point \`repos:\` at it`
4285
- );
4286
- }
4287
- mkdirSync4(dirname4(dir), { recursive: true });
4288
- try {
4289
- execFileSync("git", ["clone", cloneUrlFor(remote), dir], { stdio: ["ignore", "pipe", "pipe"] });
4290
- } catch (e) {
4291
- const err = e;
4292
- const why = [err.stderr, err.stdout].map((x) => (x ?? "").trim()).filter(Boolean).join(" / ");
4293
- throw new GitError(`git clone ${cloneUrlFor(remote)} ${dir}: ${why || err.message.trim()}`);
4258
+ function filedBy(t, i) {
4259
+ const member = (t.filed_by_id ? i.roster.get(t.filed_by_id) : void 0) ?? memberByIdentity(i.roster, t.created_by_id);
4260
+ if (member) {
4261
+ if (member.id === i.me) return "you";
4262
+ const label = crewLabel(member) ?? member.name;
4263
+ const ship = t.ship_id && t.ship_id !== i.myShipId ? i.ships?.find((s) => s.id === t.ship_id)?.name : void 0;
4264
+ return ship ? `${label} (${ship})` : label;
4294
4265
  }
4295
- return true;
4266
+ return t.reporter_name ? `${t.reporter_name} (unverified)` : "\u2014";
4296
4267
  }
4297
- var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
4298
- function checkoutLockPath(dir) {
4299
- return `${dir}.sync.lock`;
4268
+ function lastComment(i, ticketId) {
4269
+ const forTicket = i.comments.filter((c) => c.ticket_id === ticketId).sort((a, b) => a.created_at < b.created_at ? -1 : a.created_at > b.created_at ? 1 : 0);
4270
+ const last = forTicket[forTicket.length - 1];
4271
+ if (!last) return "\u2014";
4272
+ return `${last.created_at.slice(0, 16)}Z ${author(last, i)}`;
4300
4273
  }
4301
- function sleepSync(ms) {
4302
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
4274
+ function newFromOthers(i, ticketId) {
4275
+ return i.comments.filter(
4276
+ (c) => c.ticket_id === ticketId && c.team_member_id !== i.me && c.created_at > i.watermark && (c.kind ?? "comment") !== "event"
4277
+ ).length;
4303
4278
  }
4304
- function acquireCheckoutLock(dir, timeoutMs = 3e4) {
4305
- const lockPath = checkoutLockPath(dir);
4306
- mkdirSync4(dirname4(lockPath), { recursive: true });
4307
- const deadline = Date.now() + timeoutMs;
4308
- for (; ; ) {
4309
- try {
4310
- const fd = openSync(lockPath, "wx");
4311
- writeSync(fd, `${process.pid}
4312
- `);
4313
- closeSync(fd);
4314
- return () => {
4315
- try {
4316
- unlinkSync2(lockPath);
4317
- } catch {
4318
- }
4319
- };
4320
- } catch (e) {
4321
- const err = e;
4322
- if (err.code !== "EEXIST") throw err;
4323
- try {
4324
- if (Date.now() - statSync3(lockPath).mtimeMs > CHECKOUT_LOCK_STALE_MS) {
4325
- try {
4326
- unlinkSync2(lockPath);
4327
- } catch {
4328
- }
4329
- continue;
4330
- }
4331
- } catch {
4332
- }
4333
- if (Date.now() > deadline) {
4334
- throw new GitError(`timed out waiting for the sync lock on ${dir} (held by another sync)`);
4335
- }
4336
- sleepSync(50);
4337
- }
4338
- }
4279
+ function who(t, i) {
4280
+ return whoAssigned(t, i) + heldBySuffix(t, i);
4339
4281
  }
4340
- function syncPassengerCheckout(dir, githubRemote, gitRemoteName, base) {
4341
- const release = acquireCheckoutLock(dir);
4342
- try {
4343
- if (ensureRepoCheckout(dir, githubRemote)) {
4344
- if (currentBranch(dir) !== base) git(dir, ["checkout", base]);
4345
- return { action: "cloned", detail: `cloned ${githubRemote} into ${dir}` };
4346
- }
4347
- return refreshBaseBranch(dir, gitRemoteName, base);
4348
- } finally {
4349
- release();
4350
- }
4282
+ function heldBySuffix(t, i) {
4283
+ if (!t.held_by_ship_id || t.held_by_ship_id === i.myShipId) return "";
4284
+ const name = i.ships?.find((s) => s.id === t.held_by_ship_id)?.name?.trim();
4285
+ return ` (held by ${name || "an unknown ship"})`;
4286
+ }
4287
+ function whoAssigned(t, i) {
4288
+ if (!t.assignee_id) return "unassigned";
4289
+ if (t.assignee_id === i.me) return "you";
4290
+ const label = crewLabel(i.roster.get(t.assignee_id));
4291
+ if (label === null) return "someone off this ship";
4292
+ return isHold(i.roster, t.assignee_id) ? `**${label} \u2014 HOLD**` : label;
4293
+ }
4294
+ function blockers(t, i) {
4295
+ const list = (t.blocked_by ?? []).map((id) => {
4296
+ const info = i.blockerInfo[id];
4297
+ return `${info ? displayKey2(info) : "?"} (${info?.status ?? "unknown"})`;
4298
+ });
4299
+ return list.length === 0 ? "\u2014" : list.join(", ");
4300
+ }
4301
+ var sorted = (ts) => [...ts].sort(compareRank);
4302
+ var stream = (segments) => `${segments.join("\n")}
4303
+ `;
4304
+ function table(ts, i, header, row) {
4305
+ if (ts.length === 0) return "\n_None._\n";
4306
+ return `
4307
+ ${header}
4308
+ ${sorted(ts).map(row).join("\n")}
4309
+ `;
4310
+ }
4311
+ function buildingDigest(i) {
4312
+ const header = "| ticket | repo | branch | worktree | status | assignee | filed by | sev | pri | eff | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4313
+ const row = (t) => {
4314
+ const n = newFromOthers(i, t.id);
4315
+ const branch = i.branchFor ? i.branchFor(t) : "";
4316
+ const worktree = i.worktreeFor ? i.worktreeFor(t) : "";
4317
+ const dir = i.dirFor ? i.dirFor(t) : null;
4318
+ const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4319
+ return `| ${displayKey2(t)} | ${repo} | ${branch} | ${worktree} | ${t.status} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
4320
+ };
4321
+ const blockedRow = (t) => `| ${displayKey2(t)} | ${t.status} | ${who(t, i)} | p${effectivePriority(t)} | ${blockers(t, i)} |`;
4322
+ const blockedTable = (ts) => ts.length === 0 ? "\n_None._\n" : `
4323
+ | ticket | status | assignee | eff | blocked by |
4324
+ |---|---|---|---|---|
4325
+ ${sorted(ts).map(blockedRow).join("\n")}
4326
+ `;
4327
+ const isBlocked = (t) => i.blocked.has(t.id);
4328
+ const step1 = i.tickets.filter(
4329
+ (t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
4330
+ );
4331
+ const step2 = i.tickets.filter(
4332
+ (t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
4333
+ );
4334
+ return stream([
4335
+ "## Current queue \u2014 built for you by the poll\n",
4336
+ "\nAlready filtered to your lane, and already ordered by the Step 2 rule.\nTicket bodies are deliberately omitted: fetch the full record of only the\nticket you actually pick up. **Do not re-fetch the whole tracker.** This\ndigest comes from the same API call the poll just made, moments ago.\n",
4337
+ '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
4338
+ "\n**`repo` is the checkout a ticket's work happens in** \u2014 an area spans\nseveral repositories, so cut the worktree beside THAT directory, not beside\nwhichever one you started in. A ticket marked **NO CHECKOUT** is not yours:\nthis ship has no clone of its repository, and another ship may serve it.\n\n**Use the `branch` and `worktree` columns verbatim.** They are that\nrepository's own naming convention, rendered for THIS ticket \u2014 cut the\nworktree at `<repo>/../<worktree>`, on branch `<branch>`. Do not derive\neither yourself, and prefer these over the Environment section's own worked\nexample if the two ever disagree \u2014 that example is illustrative only,\nrendered before any ticket is picked.\n",
4339
+ "\n### Step 1 \u2014 open tickets that may be yours to act on\n",
4340
+ "\n`fixed` tickets are deliberately absent: they belong to the QA lane.\n",
4341
+ table(step1, i, header, row),
4342
+ "\n### Step 2 \u2014 accepted tickets, in pick order\n",
4343
+ "\nWork the first one you are not required to skip. Tickets waiting on an\nunresolved dependency are already out of this table \u2014 see Blocked below.\n",
4344
+ table(step2, i, header, row),
4345
+ "\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
4346
+ '\nListed rather than hidden so "nothing to do" stays distinguishable from\n"everything is parked". The loop parks and restores these itself, on every\npoll: an approved ticket whose `Blocked by` entries are unresolved moves to\n`blocked`, and back to `accepted` once the last one resolves. **Do not pick\none up, do not set or clear `blocked` by hand, and do not "unblock" one by\nediting its `Blocked by` field** \u2014 if a blocker looks wrong, say so in your\nrun summary. A blocker counts as resolved at `verified`, `closed_deployed`,\n`closed_wont_fix` or `closed_duplicate`; `fixed` is still an unmerged branch\nawaiting QA, so it does not count.\n',
4347
+ blockedTable(i.tickets.filter(isBlocked))
4348
+ ]);
4349
+ }
4350
+ function qaDigest(i) {
4351
+ const header = "| ticket | repo | status | built by | assignee | filed by | sev | pri | eff | branch | worktree | updated | last comment | new since last poll |\n|---|---|---|---|---|---|---|---|---|---|---|---|---|---|";
4352
+ const branchCell = (t) => {
4353
+ if (i.dirFor && i.dirFor(t) === null) return "\u2014";
4354
+ if (!i.existingBranchFor) return "";
4355
+ return i.existingBranchFor(t) ?? "**MISSING**";
4356
+ };
4357
+ const worktreeCell = (t) => {
4358
+ if (i.dirFor && i.dirFor(t) === null) return "\u2014";
4359
+ return i.worktreeFor ? i.worktreeFor(t) : "";
4360
+ };
4361
+ const builtBy = (t) => t.needs_design === true ? "design" : "dev";
4362
+ const row = (t) => {
4363
+ const n = newFromOthers(i, t.id);
4364
+ const dir = i.dirFor ? i.dirFor(t) : null;
4365
+ const repo = i.dirFor ? dir ?? "**NO CHECKOUT**" : "";
4366
+ return `| ${displayKey2(t)} | ${repo} | ${t.status} | ${builtBy(t)} | ${who(t, i)} | ${filedBy(t, i)} | ${t.severity ?? "\u2014"} | ${t.priority ?? "\u2014"} | p${effectivePriority(t)} | ${branchCell(t)} | ${worktreeCell(t)} | ${t.updated_at.slice(0, 16)}Z | ${lastComment(i, t.id)} | ${n > 0 ? `**${n} new**` : "\u2014"} |`;
4367
+ };
4368
+ return stream([
4369
+ "## Current queue \u2014 built for you by the poll\n",
4370
+ '\nAlready filtered to your lane: every ticket at `qa` ("Verification" \u2014\nyours, unfinished) or `fixed` (nobody has checked it yet), from both\nbuilding lanes. Ticket bodies are deliberately omitted: fetch the full\nrecord and the comments of the one ticket you actually pick up. **Do not\nre-fetch the whole tracker.** This digest comes from the same API call the\npoll just made, moments ago.\n',
4371
+ '\n"new since last poll" counts comments from someone other than you since\nthe poll watermark \u2014 the same signal that woke this run.\n',
4372
+ "\n**`repo` is the checkout the ticket's work happens in** \u2014 an area spans\nseveral repositories, so the worktree you verify in sits beside THAT\ndirectory, not beside whichever one this session started in. A ticket\nmarked **NO CHECKOUT** is not yours: this ship has no clone of its\nrepository, and another ship may serve it.\n\n**`branch` is that repository's own branch for the ticket**, found there\nrather than derived. **`<name> (on origin only)`** means another ship built it\nand pushed it: it is testable \u2014 cut a worktree from the remote branch\n(`git worktree add <worktree> <name>`). **MISSING** means the branch is gone\nlocally AND on the remote, so there is nothing left to verify \u2014 say so on the ticket. A `\u2014` means the branch\ncould not be looked for at all, because the repo has no checkout here.\n\n**`worktree` is where that branch should be checked out**, `<repo>/../<worktree>`\n\u2014 rendered the same way the dev/design digest computes it, so you land in\nthe same directory the building lane used.\n",
4373
+ "\n### Still in verification \u2014 yours, unfinished (take these first)\n",
4374
+ table(i.tickets.filter((t) => t.status === "qa"), i, header, row),
4375
+ "\n### Awaiting verification, in pick order\n",
4376
+ table(i.tickets.filter((t) => t.status === "fixed"), i, header, row)
4377
+ ]);
4351
4378
  }
4352
4379
 
4353
4380
  // src/ticket-branch.ts
@@ -6599,6 +6626,30 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
6599
6626
  }
6600
6627
  }
6601
6628
  }
6629
+ if (remoteConfigured(o.cwd, o.repo.branch.remote)) {
6630
+ if (o.dryRun) {
6631
+ o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6632
+ } else {
6633
+ try {
6634
+ pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
6635
+ o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6636
+ } catch (e) {
6637
+ o.emit.error(
6638
+ `failed to push ${o.repo.branch.base} to ${o.repo.branch.remote} \u2014 ${e.message}; not deploying`
6639
+ );
6640
+ return {
6641
+ merged,
6642
+ conflicts,
6643
+ unbuildable,
6644
+ version,
6645
+ deployed: false,
6646
+ integrated: false,
6647
+ decision,
6648
+ stopped: "base branch push failed"
6649
+ };
6650
+ }
6651
+ }
6652
+ }
6602
6653
  let deployed = false;
6603
6654
  let integrated = false;
6604
6655
  if (o.repo.release.mode === "integrate") {
@@ -6613,7 +6664,9 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
6613
6664
  r = await hook(o, "deploy");
6614
6665
  }
6615
6666
  if (r && r.code !== 0) {
6616
- o.emit.error(`deploy FAILED (exit ${r.code}) \u2014 the target may be partially deployed`);
6667
+ o.emit.error(
6668
+ `deploy FAILED (exit ${r.code}) \u2014 the target may be partially deployed` + (remoteConfigured(o.cwd, o.repo.branch.remote) ? `; ${version ?? "this release"} is already merged to ${o.repo.branch.remote}/${o.repo.branch.base} but NOT live \u2014 run \`crew deploy\`, do not re-release` : "")
6669
+ );
6617
6670
  o.state?.noteDeployFailed(headSha(o.cwd));
6618
6671
  return { merged, conflicts, unbuildable, version, deployed: false, stopped: "deploy failed", decision, hookFailure: hookFailure(o, "deploy", r.output) };
6619
6672
  }
@@ -6623,30 +6676,6 @@ async function mergeAndRelease(o, decision, fresh, releaseBranch) {
6623
6676
  } else if (o.repo.release.mode !== "local") {
6624
6677
  o.emit.emit(`release.mode is ${o.repo.release.mode} \u2014 CI takes it from here`);
6625
6678
  }
6626
- if (remoteConfigured(o.cwd, o.repo.branch.remote)) {
6627
- if (o.dryRun) {
6628
- o.emit.emit(`would push ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6629
- } else {
6630
- try {
6631
- pushBranch(o.cwd, o.repo.branch.remote, releaseBranch ?? o.repo.branch.base, o.repo.branch.base);
6632
- o.emit.emit(`pushed ${o.repo.branch.base} to ${o.repo.branch.remote}`);
6633
- } catch (e) {
6634
- o.emit.error(
6635
- `failed to push ${o.repo.branch.base} to ${o.repo.branch.remote} \u2014 ${e.message}`
6636
- );
6637
- return {
6638
- merged,
6639
- conflicts,
6640
- unbuildable,
6641
- version,
6642
- deployed,
6643
- integrated,
6644
- decision,
6645
- stopped: "base branch push failed"
6646
- };
6647
- }
6648
- }
6649
- }
6650
6679
  let tag;
6651
6680
  if (!version && o.dryRun && o.repo.hooks.bump && o.repo.release.tag) {
6652
6681
  o.emit.emit(`would then tag using ${o.repo.release.tag}, with whatever version the hook printed`);
@@ -11540,6 +11569,13 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11540
11569
  process.stderr.write(`${ensureClaudeMcp({ bin: cfg.ship.agent.bin, url: mcpUrlFor(baseUrl), key: apiKey, replace, dryRun })}
11541
11570
  `);
11542
11571
  }
11572
+ if (found.holds.length) {
11573
+ process.stderr.write(
11574
+ `
11575
+ Holds resolved (${found.holds.length}): ${found.holds.map((h) => `${h.name || h.id}${h.id === operator ? " [operator]" : ""}`).join(", ")}
11576
+ `
11577
+ );
11578
+ }
11543
11579
  if (found.provisioning.length) {
11544
11580
  process.stderr.write(`
11545
11581
  Provisioned for this machine (${ship.name}):
@@ -11873,6 +11909,9 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
11873
11909
  let baseUnsafe = 0;
11874
11910
  for (const r of await resolvedRepos(route)) {
11875
11911
  const base = refreshBaseBranch(r.dir, r.config.branch.remote, r.config.branch.base, dryRun);
11912
+ const att = dryRun ? null : baseAttention(r.name, base, r.config.branch.remote, r.config.branch.base);
11913
+ if (att && "raise" in att) await raiseShipAttention({ state, emit, route, ship: cfg.ship }, att.raise);
11914
+ else if (att) clearShipAttention(state, att.clear);
11876
11915
  if (base.action !== "not-applicable") {
11877
11916
  baseChecked++;
11878
11917
  if (base.action === "fetch-failed") emit.warn(base.detail);
@@ -12193,6 +12232,31 @@ crew manifest: ${seats2.length ? seats2.map((c) => c.name).join(", ") : "non
12193
12232
  );
12194
12233
  }
12195
12234
  }
12235
+ {
12236
+ const op = route.resolved?.operator;
12237
+ const row = op ? crewRows.find((c) => c.id === op) : void 0;
12238
+ if (!op) {
12239
+ process.stdout.write("operator identity: no operator on this route \u2014 run `crew connect`\n");
12240
+ } else if (!row) {
12241
+ process.stdout.write(`operator identity: ERROR operator ${op} is not a row of the Crew table
12242
+ `);
12243
+ process.exitCode = 1;
12244
+ } else if (!row.user_id) {
12245
+ process.stdout.write(
12246
+ `operator identity: ERROR Crew row "${row.name ?? op}" has no user_id \u2014 their UI comments will read "(no identity)"; link it to the account they sign in with
12247
+ `
12248
+ );
12249
+ process.exitCode = 1;
12250
+ } else {
12251
+ const resolves = memberByIdentity(buildRoster(configuredMembers(route), crewRows), row.user_id);
12252
+ process.stdout.write(
12253
+ resolves ? `operator identity: comments from ${row.name ?? op} resolve to ${crewLabel(resolves)}
12254
+ ` : `operator identity: ERROR user_id ${row.user_id} does not resolve through the roster
12255
+ `
12256
+ );
12257
+ if (!resolves) process.exitCode = 1;
12258
+ }
12259
+ }
12196
12260
  if (ships.length > 0) {
12197
12261
  try {
12198
12262
  const humanHolds = new Set(holdIds(buildRoster(configuredMembers(route), crewRows)));