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