@tablation/crew 0.15.0 → 0.17.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/README.md +1 -1
- package/dist/cli.js +864 -194
- package/package.json +1 -1
- package/prompts/default/personas/common.md +28 -5
- package/prompts/default/personas/lane-qa.md +17 -1
- package/prompts/dev-qa/personas/common.md +28 -5
- package/prompts/dev-qa/personas/lane-qa.md +13 -1
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { resolve as resolve4, dirname as
|
|
4
|
+
import { resolve as resolve4, dirname as dirname11, join as join20, basename as basename7 } from "path";
|
|
5
5
|
import { fileURLToPath } from "url";
|
|
6
6
|
import { hostname as hostname2, homedir as homedir5 } from "os";
|
|
7
7
|
import { spawnSync as spawnSync3 } from "child_process";
|
|
@@ -1796,7 +1796,9 @@ var DEFAULT_CONTRACT = {
|
|
|
1796
1796
|
epic: "epic_id",
|
|
1797
1797
|
author: "filed_by_id",
|
|
1798
1798
|
ship: "ship_id",
|
|
1799
|
-
heldBy: "held_by_ship_id"
|
|
1799
|
+
heldBy: "held_by_ship_id",
|
|
1800
|
+
verifiedSha: "verified_sha",
|
|
1801
|
+
prRef: "pr_ref"
|
|
1800
1802
|
},
|
|
1801
1803
|
comments: {
|
|
1802
1804
|
parent: "ticket_id",
|
|
@@ -2782,7 +2784,8 @@ async function detectClosure(o) {
|
|
|
2782
2784
|
const code = await o.mergedHook({
|
|
2783
2785
|
CREW_TICKET: o.key,
|
|
2784
2786
|
CREW_BRANCH: o.pushedBranch,
|
|
2785
|
-
CREW_BASE: base
|
|
2787
|
+
CREW_BASE: base,
|
|
2788
|
+
...o.pr ? { CREW_PR: o.pr } : {}
|
|
2786
2789
|
});
|
|
2787
2790
|
if (code === 0) {
|
|
2788
2791
|
const mergedAt = findKeyOnBase(o.cwd, [o.key, ...aliases], remote, base, o.since) ?? void 0;
|
|
@@ -2952,8 +2955,8 @@ var CHECKOUT_LOCK_STALE_MS = 5 * 60 * 1e3;
|
|
|
2952
2955
|
function checkoutLockPath(dir) {
|
|
2953
2956
|
return `${dir}.sync.lock`;
|
|
2954
2957
|
}
|
|
2955
|
-
function sleepSync(
|
|
2956
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0,
|
|
2958
|
+
function sleepSync(ms2) {
|
|
2959
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
|
|
2957
2960
|
}
|
|
2958
2961
|
function acquireCheckoutLock(dir, timeoutMs = 3e4) {
|
|
2959
2962
|
const lockPath = checkoutLockPath(dir);
|
|
@@ -3046,6 +3049,29 @@ function baseAttention(repo, outcome, remote, base, nowIso = (/* @__PURE__ */ ne
|
|
|
3046
3049
|
if (outcome.action === "level" || outcome.action === "fast-forwarded") return { clear: baseUnsafeKey(repo) };
|
|
3047
3050
|
return null;
|
|
3048
3051
|
}
|
|
3052
|
+
function repoStopFor(items, route) {
|
|
3053
|
+
const stops = new Map(items.filter((i) => i.kind === "base_unsafe").map((i) => [i.key, i.message]));
|
|
3054
|
+
if (stops.size === 0) return () => null;
|
|
3055
|
+
return (t) => {
|
|
3056
|
+
const name = repoTargetFor(route, t.repo_id)?.name;
|
|
3057
|
+
return name ? stops.get(baseUnsafeKey(name)) ?? null : null;
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
function recheckBaseStops(state, route) {
|
|
3061
|
+
const open = state.shipAttention().previous().filter((i) => i.kind === "base_unsafe");
|
|
3062
|
+
const cleared = [];
|
|
3063
|
+
for (const item of open) {
|
|
3064
|
+
const target = reposOf(route).find((t) => baseUnsafeKey(t.name) === item.key);
|
|
3065
|
+
if (!target) continue;
|
|
3066
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), void 0, target.dir);
|
|
3067
|
+
const outcome = refreshBaseBranch(target.dir, cfg.branch.remote, cfg.branch.base, true);
|
|
3068
|
+
if (outcome.action === "level" || outcome.action === "would-fast-forward") {
|
|
3069
|
+
clearShipAttention(state, item.key);
|
|
3070
|
+
cleared.push(target.name);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
return cleared;
|
|
3074
|
+
}
|
|
3049
3075
|
function openShipAttention(state, nowMs = Date.now()) {
|
|
3050
3076
|
const stale = staleRelease(state.releaseHeartbeat().at(), nowMs);
|
|
3051
3077
|
const stored = state.shipAttention().previous();
|
|
@@ -3122,7 +3148,7 @@ function releaseLockScope(route, repo) {
|
|
|
3122
3148
|
}
|
|
3123
3149
|
async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
3124
3150
|
if (!modelId) return { ok: true, release: async () => {
|
|
3125
|
-
} };
|
|
3151
|
+
}, exclusion: "off" };
|
|
3126
3152
|
const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
|
|
3127
3153
|
const rows = await client2.list(modelId, { filters, limit: 1 });
|
|
3128
3154
|
const row = rows[0];
|
|
@@ -3139,6 +3165,7 @@ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs) {
|
|
|
3139
3165
|
}
|
|
3140
3166
|
return {
|
|
3141
3167
|
ok: true,
|
|
3168
|
+
exclusion: "taken",
|
|
3142
3169
|
release: async () => {
|
|
3143
3170
|
try {
|
|
3144
3171
|
await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
|
|
@@ -3605,6 +3632,18 @@ var Tracker = class {
|
|
|
3605
3632
|
shipId: auth.shipId
|
|
3606
3633
|
};
|
|
3607
3634
|
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Which of the optional review columns (`verified_sha`, `pr_ref`) the Issues
|
|
3637
|
+
* table carries. A table without them has not adopted the review hand-off:
|
|
3638
|
+
* the crew then pushes and moves the ticket without recording either.
|
|
3639
|
+
*/
|
|
3640
|
+
async reviewColumns() {
|
|
3641
|
+
const fields = await this.columnNames(this.models.issues);
|
|
3642
|
+
return {
|
|
3643
|
+
verifiedSha: fields.has(this.contract.columns.verifiedSha),
|
|
3644
|
+
prRef: fields.has(this.contract.columns.prRef)
|
|
3645
|
+
};
|
|
3646
|
+
}
|
|
3608
3647
|
/**
|
|
3609
3648
|
* The authorship probe (CREW-1371), read once per process: which optional
|
|
3610
3649
|
* `filed_by_id`/`ship_id` columns exist on Issues and Comments, and this
|
|
@@ -4005,6 +4044,10 @@ function withHoldCheck(affinity, i) {
|
|
|
4005
4044
|
const a = affinityContext(i);
|
|
4006
4045
|
return a ? { ...affinity, isHeldElsewhere: (t) => heldByOtherShip(t, a), isLiveHoldElsewhere: (t) => heldByLiveOtherShip(t, a) } : affinity;
|
|
4007
4046
|
}
|
|
4047
|
+
function notInStoppedRepo(slice, i) {
|
|
4048
|
+
const stop = i.repoStop;
|
|
4049
|
+
return stop ? slice.filter((t) => stop(t) === null) : slice;
|
|
4050
|
+
}
|
|
4008
4051
|
function notHeldByOtherShip(slice, i) {
|
|
4009
4052
|
if (!i.ships) return slice;
|
|
4010
4053
|
const a = { myShipId: i.ships.myShipId, ships: i.ships.rows, contract: i.contract ?? DEFAULT_CONTRACT, now: i.ships.now };
|
|
@@ -4013,7 +4056,7 @@ function notHeldByOtherShip(slice, i) {
|
|
|
4013
4056
|
function buildingRoleHasWork(role, i) {
|
|
4014
4057
|
const me = i.seats[role];
|
|
4015
4058
|
if (!me) return { hasWork: false, reason: "this ship does not crew that role" };
|
|
4016
|
-
const mine = notHeldByOtherShip(sliceFor(i.tickets, role), i);
|
|
4059
|
+
const mine = notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i);
|
|
4017
4060
|
const startable = mine.filter(
|
|
4018
4061
|
(t) => (t.status === "accepted" || t.status === "blocked") && !i.blocked.has(t.id) && !isHeld(t, i.holds)
|
|
4019
4062
|
);
|
|
@@ -4063,7 +4106,7 @@ function roleCandidates(role, i) {
|
|
|
4063
4106
|
if (role === "pair") return [];
|
|
4064
4107
|
const me = i.seats[role];
|
|
4065
4108
|
if (!me) return [];
|
|
4066
|
-
return notHeldByOtherShip(sliceFor(i.tickets, role), i).filter(
|
|
4109
|
+
return notInStoppedRepo(notHeldByOtherShip(sliceFor(i.tickets, role), i), i).filter(
|
|
4067
4110
|
(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
4111
|
);
|
|
4069
4112
|
}
|
|
@@ -4235,6 +4278,16 @@ function attentionTransitions(previous, current) {
|
|
|
4235
4278
|
}
|
|
4236
4279
|
return out;
|
|
4237
4280
|
}
|
|
4281
|
+
function operatorTodo(tickets, contract, operatorId) {
|
|
4282
|
+
const out = [];
|
|
4283
|
+
for (const ticket of tickets) {
|
|
4284
|
+
const reasons = attentionReasons(ticket, contract).filter(
|
|
4285
|
+
(r) => r === "needs_planning" || r === "needs_info" && ticket.assignee_id === operatorId
|
|
4286
|
+
);
|
|
4287
|
+
if (reasons.length > 0) out.push({ ticket, reasons });
|
|
4288
|
+
}
|
|
4289
|
+
return out;
|
|
4290
|
+
}
|
|
4238
4291
|
|
|
4239
4292
|
// src/digest.ts
|
|
4240
4293
|
function displayKey2(t) {
|
|
@@ -4328,9 +4381,21 @@ ${sorted(ts).map(blockedRow).join("\n")}
|
|
|
4328
4381
|
const step1 = i.tickets.filter(
|
|
4329
4382
|
(t) => t.status === "in_progress" && (t.assignee_id === i.me || !t.assignee_id) || t.status === "needs_info"
|
|
4330
4383
|
);
|
|
4384
|
+
const stopOf = (t) => i.repoStop ? i.repoStop(t) : null;
|
|
4331
4385
|
const step2 = i.tickets.filter(
|
|
4332
|
-
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t)
|
|
4386
|
+
(t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) === null
|
|
4333
4387
|
);
|
|
4388
|
+
const held = i.tickets.filter((t) => (t.status === "accepted" || t.status === "blocked") && !isBlocked(t) && stopOf(t) !== null);
|
|
4389
|
+
const heldTable = held.length === 0 ? "" : `
|
|
4390
|
+
### Held repos \u2014 this ship cannot start these right now
|
|
4391
|
+
|
|
4392
|
+
Not yours to start this run: the ship's checkout of their repo is stopped. It
|
|
4393
|
+
resumes by itself once the checkout is level; no one needs to change the ticket.
|
|
4394
|
+
|
|
4395
|
+
| ticket | why |
|
|
4396
|
+
|---|---|
|
|
4397
|
+
${sorted(held).map((t) => `| ${displayKey2(t)} | ${stopOf(t)} |`).join("\n")}
|
|
4398
|
+
`;
|
|
4334
4399
|
return stream([
|
|
4335
4400
|
"## Current queue \u2014 built for you by the poll\n",
|
|
4336
4401
|
"\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",
|
|
@@ -4344,7 +4409,8 @@ ${sorted(ts).map(blockedRow).join("\n")}
|
|
|
4344
4409
|
table(step2, i, header, row),
|
|
4345
4410
|
"\n### Blocked \u2014 waiting on a dependency, not yours to start\n",
|
|
4346
4411
|
'\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))
|
|
4412
|
+
blockedTable(i.tickets.filter(isBlocked)),
|
|
4413
|
+
...heldTable ? [heldTable] : []
|
|
4348
4414
|
]);
|
|
4349
4415
|
}
|
|
4350
4416
|
function qaDigest(i) {
|
|
@@ -4394,6 +4460,9 @@ function branchTemplates(cfg, t) {
|
|
|
4394
4460
|
const push = cfg.provenance["branch.push"] === "default" ? name : cfg.branch.push;
|
|
4395
4461
|
return { name, push };
|
|
4396
4462
|
}
|
|
4463
|
+
function pushedBranchForTicket(cfg, t, role) {
|
|
4464
|
+
return branchRenderer(t, role)(branchTemplates(cfg, t).push);
|
|
4465
|
+
}
|
|
4397
4466
|
function existingBranchForTicket(dir, cfg, t, role) {
|
|
4398
4467
|
return branchForIssue(dir, t.issue_id, branchTemplates(cfg, t), branchRenderer(t, role));
|
|
4399
4468
|
}
|
|
@@ -4419,6 +4488,102 @@ function materializeBranchForTicket(dir, cfg, t, role) {
|
|
|
4419
4488
|
// src/poll.ts
|
|
4420
4489
|
import { writeFileSync as writeFileSync3 } from "fs";
|
|
4421
4490
|
import { join as join6 } from "path";
|
|
4491
|
+
|
|
4492
|
+
// src/stalled.ts
|
|
4493
|
+
var STALLED_AFTER_CYCLES = 30;
|
|
4494
|
+
var CYCLE_MS = 120 * 1e3;
|
|
4495
|
+
var STALLED_AFTER_MS = STALLED_AFTER_CYCLES * CYCLE_MS;
|
|
4496
|
+
var STALLED_MARKER = "<!-- crew:stalled -->";
|
|
4497
|
+
var stalledKey = (issueId) => `stalled:${issueId}`;
|
|
4498
|
+
var ms = (iso) => {
|
|
4499
|
+
const n = iso ? Date.parse(iso) : NaN;
|
|
4500
|
+
return Number.isFinite(n) ? n : 0;
|
|
4501
|
+
};
|
|
4502
|
+
function planStalled(tickets, comments, ships, contract, holds2 = /* @__PURE__ */ new Set(), now = Date.now()) {
|
|
4503
|
+
const watched = /* @__PURE__ */ new Set([contract.statuses.building, contract.statuses.handoff, contract.statuses.verifying]);
|
|
4504
|
+
const byTicket = /* @__PURE__ */ new Map();
|
|
4505
|
+
for (const c of comments) {
|
|
4506
|
+
const list = byTicket.get(c.ticket_id);
|
|
4507
|
+
if (list) list.push(c);
|
|
4508
|
+
else byTicket.set(c.ticket_id, [c]);
|
|
4509
|
+
}
|
|
4510
|
+
const out = [];
|
|
4511
|
+
for (const t of tickets) {
|
|
4512
|
+
if (!watched.has(t.status)) continue;
|
|
4513
|
+
if (t.assignee_id && holds2.has(t.assignee_id)) continue;
|
|
4514
|
+
const mine = byTicket.get(t.id) ?? [];
|
|
4515
|
+
const lastMarker = Math.max(0, ...mine.filter((c) => (c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at)));
|
|
4516
|
+
const lastActivityAt = Math.max(
|
|
4517
|
+
ms(t.updated_at),
|
|
4518
|
+
...mine.filter((c) => !(c.body ?? "").includes(STALLED_MARKER)).map((c) => ms(c.created_at))
|
|
4519
|
+
);
|
|
4520
|
+
if (now - lastActivityAt < STALLED_AFTER_MS) continue;
|
|
4521
|
+
if (ships.some((s) => s.engaged && s.engaged_ticket_id === t.id)) continue;
|
|
4522
|
+
out.push({
|
|
4523
|
+
ticket: t,
|
|
4524
|
+
holderShipId: ticketHolderShipId(t, contract),
|
|
4525
|
+
lastActivityAt,
|
|
4526
|
+
announced: lastMarker > lastActivityAt
|
|
4527
|
+
});
|
|
4528
|
+
}
|
|
4529
|
+
return out;
|
|
4530
|
+
}
|
|
4531
|
+
function stalledComment(s, ships, now = Date.now()) {
|
|
4532
|
+
const mins = Math.round((now - s.lastActivityAt) / 6e4);
|
|
4533
|
+
const who2 = s.holderShipId ? `Held by ${shipName(ships, s.holderShipId)}, which is not engaged on it. That ship has an attention item open; the ticket is otherwise left as it is, and resumes the moment the ship picks it back up.` : `No ship holds it and none is engaged on it. It is waiting for the next ship that polls its lane; its status and assignee are left as they are.`;
|
|
4534
|
+
return `${STALLED_MARKER}
|
|
4535
|
+
**Stalled \u2014 no activity on \`${s.ticket.issue_id}\` for ${mins} minutes (\`${s.ticket.status}\`).**
|
|
4536
|
+
|
|
4537
|
+
${who2}`;
|
|
4538
|
+
}
|
|
4539
|
+
async function applyStalled(writer, stalled, ships, memberId, myShipId, log, dryRun, now = Date.now()) {
|
|
4540
|
+
const r = { announced: 0, failed: 0, attention: [] };
|
|
4541
|
+
for (const s of stalled) {
|
|
4542
|
+
const x = { ticket: s.ticket.issue_id, step: "sweep" };
|
|
4543
|
+
if (s.holderShipId && s.holderShipId === myShipId) {
|
|
4544
|
+
r.attention.push({
|
|
4545
|
+
kind: "stalled",
|
|
4546
|
+
key: stalledKey(s.ticket.issue_id),
|
|
4547
|
+
message: `${s.ticket.issue_id} is held by this ship but has been quiet for ${Math.round((now - s.lastActivityAt) / 6e4)} minutes with nothing engaged on it`,
|
|
4548
|
+
since: new Date(s.lastActivityAt + STALLED_AFTER_MS).toISOString()
|
|
4549
|
+
});
|
|
4550
|
+
}
|
|
4551
|
+
if (s.announced) continue;
|
|
4552
|
+
if (dryRun) {
|
|
4553
|
+
log.emit("would flag as stalled", x);
|
|
4554
|
+
continue;
|
|
4555
|
+
}
|
|
4556
|
+
try {
|
|
4557
|
+
await writer.postEvent(s.ticket.id, stalledComment(s, ships, now), memberId);
|
|
4558
|
+
r.announced++;
|
|
4559
|
+
log.warn(`stalled at ${s.ticket.status} \u2014 ${s.holderShipId ? "flagged on the holder" : "commented, no holder"}`, x);
|
|
4560
|
+
} catch (e) {
|
|
4561
|
+
r.failed++;
|
|
4562
|
+
log.warn(`could not flag as stalled: ${e.message}`, x);
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
return r;
|
|
4566
|
+
}
|
|
4567
|
+
async function runStalledSweep(o, stalled) {
|
|
4568
|
+
const r = await applyStalled(
|
|
4569
|
+
o.tracker,
|
|
4570
|
+
stalled,
|
|
4571
|
+
o.ships,
|
|
4572
|
+
o.memberId,
|
|
4573
|
+
o.myShipId,
|
|
4574
|
+
o.emit,
|
|
4575
|
+
o.dryRun ?? false
|
|
4576
|
+
);
|
|
4577
|
+
if (o.dryRun) return r;
|
|
4578
|
+
const keep = new Set(r.attention.map((i) => i.key));
|
|
4579
|
+
for (const item of r.attention) await raiseShipAttention({ state: o.state, emit: o.emit, route: o.route, ship: o.ship }, item);
|
|
4580
|
+
for (const open of o.state.shipAttention().previous()) {
|
|
4581
|
+
if (open.kind === "stalled" && !keep.has(open.key)) clearShipAttention(o.state, open.key);
|
|
4582
|
+
}
|
|
4583
|
+
return r;
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4586
|
+
// src/poll.ts
|
|
4422
4587
|
async function decideCycle(o) {
|
|
4423
4588
|
const { route, ship, state, emit } = o;
|
|
4424
4589
|
const tracker = new Tracker(route, ship);
|
|
@@ -4467,6 +4632,7 @@ async function decideCycle(o) {
|
|
|
4467
4632
|
}
|
|
4468
4633
|
const sweep = planSweep(tickets, info, blocked);
|
|
4469
4634
|
const holdReleases = planHoldReleases(tickets, tracker.contract);
|
|
4635
|
+
const stalled = planStalled(tickets, comments, ships, tracker.contract, holds2);
|
|
4470
4636
|
const blockedIssues = tickets.filter((t) => blocked.has(t.id)).map((t) => t.issue_id);
|
|
4471
4637
|
emit.emit(`${blockedIssues.length} ticket(s) parked as blocked`, { data: { blocked: blockedIssues } });
|
|
4472
4638
|
const qaHeld = tickets.filter((t) => t.status === tracker.contract.statuses.verifying).map((t) => t.issue_id);
|
|
@@ -4526,7 +4692,11 @@ async function decideCycle(o) {
|
|
|
4526
4692
|
}
|
|
4527
4693
|
emit.enter("select");
|
|
4528
4694
|
const watermark = state.watermark();
|
|
4695
|
+
for (const repo of recheckBaseStops(state, route)) {
|
|
4696
|
+
emit.emit(`${repo}: primary checkout is level again \u2014 building lanes resume for that repo`, { step: "select" });
|
|
4697
|
+
}
|
|
4529
4698
|
const selectionInput = {
|
|
4699
|
+
repoStop: repoStopFor(openShipAttention(state), route),
|
|
4530
4700
|
tickets,
|
|
4531
4701
|
comments,
|
|
4532
4702
|
watermark,
|
|
@@ -4549,6 +4719,7 @@ async function decideCycle(o) {
|
|
|
4549
4719
|
sweep,
|
|
4550
4720
|
epicSync,
|
|
4551
4721
|
holdReleases,
|
|
4722
|
+
stalled,
|
|
4552
4723
|
stranded,
|
|
4553
4724
|
selection,
|
|
4554
4725
|
actionable,
|
|
@@ -4593,6 +4764,7 @@ function writeDigest(o, d, role, stateDir) {
|
|
|
4593
4764
|
const render2 = (t) => branchRenderer(t, role);
|
|
4594
4765
|
const input = {
|
|
4595
4766
|
dirFor,
|
|
4767
|
+
repoStop: repoStopFor(openShipAttention(o.state), o.route),
|
|
4596
4768
|
branchFor: (t) => {
|
|
4597
4769
|
const cfg = repoFor(dirFor(t) ?? o.route.dir);
|
|
4598
4770
|
const { prefix } = ticketBranchContext(t);
|
|
@@ -5617,10 +5789,10 @@ function spawnAgent(plan, emit) {
|
|
|
5617
5789
|
}));
|
|
5618
5790
|
} catch {
|
|
5619
5791
|
}
|
|
5620
|
-
const
|
|
5792
|
+
const ms2 = Date.now() - started;
|
|
5621
5793
|
emit.emit(`agent run finished (exit ${code ?? 1})`, {
|
|
5622
5794
|
data: {
|
|
5623
|
-
ms,
|
|
5795
|
+
ms: ms2,
|
|
5624
5796
|
code: code ?? 1,
|
|
5625
5797
|
...result?.sessionId ? { sessionId: result.sessionId } : {},
|
|
5626
5798
|
...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
|
|
@@ -5648,7 +5820,7 @@ function spawnAgent(plan, emit) {
|
|
|
5648
5820
|
emit.warn(`could not report agent log: ${e.message}`, { step: "agent" });
|
|
5649
5821
|
}
|
|
5650
5822
|
}
|
|
5651
|
-
resolve5({ code: code ?? 1, ms });
|
|
5823
|
+
resolve5({ code: code ?? 1, ms: ms2 });
|
|
5652
5824
|
});
|
|
5653
5825
|
child.stdin.on("error", (err) => {
|
|
5654
5826
|
emit.warn(`agent closed stdin before the prompt was delivered: ${err.message}`, { step: "agent" });
|
|
@@ -5657,14 +5829,348 @@ function spawnAgent(plan, emit) {
|
|
|
5657
5829
|
});
|
|
5658
5830
|
}
|
|
5659
5831
|
|
|
5832
|
+
// src/run-worktree.ts
|
|
5833
|
+
import { existsSync as existsSync9 } from "fs";
|
|
5834
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
5835
|
+
|
|
5836
|
+
// src/ports.ts
|
|
5837
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
5838
|
+
import { existsSync as existsSync8 } from "fs";
|
|
5839
|
+
import { join as join8 } from "path";
|
|
5840
|
+
var run = (cmd, args) => {
|
|
5841
|
+
try {
|
|
5842
|
+
return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
5843
|
+
} catch {
|
|
5844
|
+
return "";
|
|
5845
|
+
}
|
|
5846
|
+
};
|
|
5847
|
+
function listeners() {
|
|
5848
|
+
const out = [];
|
|
5849
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5850
|
+
const add = (pid, port) => {
|
|
5851
|
+
const key = `${pid}:${port}`;
|
|
5852
|
+
if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
|
|
5853
|
+
seen.add(key);
|
|
5854
|
+
out.push({ pid, port });
|
|
5855
|
+
};
|
|
5856
|
+
const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
|
|
5857
|
+
if (lsof) {
|
|
5858
|
+
for (const line of lsof.split("\n").slice(1)) {
|
|
5859
|
+
const cols = line.split(/\s+/);
|
|
5860
|
+
const addr = cols[8];
|
|
5861
|
+
if (!addr) continue;
|
|
5862
|
+
add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
|
|
5863
|
+
}
|
|
5864
|
+
return out;
|
|
5865
|
+
}
|
|
5866
|
+
const ss = run("ss", ["-ltnpH"]);
|
|
5867
|
+
for (const line of ss.split("\n")) {
|
|
5868
|
+
const local = line.trim().split(/\s+/)[3];
|
|
5869
|
+
const pid = /pid=(\d+)/.exec(line)?.[1];
|
|
5870
|
+
if (!local || !pid) continue;
|
|
5871
|
+
add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
|
|
5872
|
+
}
|
|
5873
|
+
return out;
|
|
5874
|
+
}
|
|
5875
|
+
var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
|
|
5876
|
+
var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
|
|
5877
|
+
function ticketForPort(port, s = DEFAULT_PORTS) {
|
|
5878
|
+
if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
|
|
5879
|
+
if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
|
|
5880
|
+
return null;
|
|
5881
|
+
}
|
|
5882
|
+
function worktreeExists(parent, prefix, n) {
|
|
5883
|
+
for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
|
|
5884
|
+
if (existsSync8(join8(parent, name, ".git"))) return true;
|
|
5885
|
+
}
|
|
5886
|
+
return false;
|
|
5887
|
+
}
|
|
5888
|
+
var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
|
|
5889
|
+
function findOrphansIn(where, s = DEFAULT_PORTS) {
|
|
5890
|
+
const orphans = [];
|
|
5891
|
+
for (const l of listeners()) {
|
|
5892
|
+
const t = ticketForPort(l.port, s);
|
|
5893
|
+
if (!t) continue;
|
|
5894
|
+
if (worktreeExistsIn(where, t.n)) continue;
|
|
5895
|
+
orphans.push({ ...l, ...t });
|
|
5896
|
+
}
|
|
5897
|
+
return orphans;
|
|
5898
|
+
}
|
|
5899
|
+
async function killGently(pids, graceMs = 2e3) {
|
|
5900
|
+
for (const pid of pids) {
|
|
5901
|
+
try {
|
|
5902
|
+
process.kill(pid, "SIGTERM");
|
|
5903
|
+
} catch {
|
|
5904
|
+
}
|
|
5905
|
+
}
|
|
5906
|
+
if (pids.length === 0) return;
|
|
5907
|
+
await new Promise((r) => setTimeout(r, graceMs));
|
|
5908
|
+
for (const pid of pids) {
|
|
5909
|
+
try {
|
|
5910
|
+
process.kill(pid, "SIGKILL");
|
|
5911
|
+
} catch {
|
|
5912
|
+
}
|
|
5913
|
+
}
|
|
5914
|
+
}
|
|
5915
|
+
function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
|
|
5916
|
+
const pids = /* @__PURE__ */ new Set();
|
|
5917
|
+
for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
|
|
5918
|
+
const matched = run("pgrep", ["-f", `${path}/`]);
|
|
5919
|
+
for (const line of matched.split("\n")) {
|
|
5920
|
+
const pid = Number(line.trim());
|
|
5921
|
+
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
|
|
5922
|
+
}
|
|
5923
|
+
return [...pids];
|
|
5924
|
+
}
|
|
5925
|
+
|
|
5926
|
+
// src/run-worktree.ts
|
|
5927
|
+
var describeRunWorktreePlan = (p) => {
|
|
5928
|
+
switch (p.action) {
|
|
5929
|
+
case "cut-remote":
|
|
5930
|
+
return `cut ${p.path} from the remote branch ${p.branch}`;
|
|
5931
|
+
case "cut-fresh":
|
|
5932
|
+
return `cut ${p.path} fresh from ${p.base} (no branch ${p.branch} anywhere yet)`;
|
|
5933
|
+
case "reset":
|
|
5934
|
+
return `fast-forwarded ${p.path} to the remote tip of ${p.branch}`;
|
|
5935
|
+
case "reuse":
|
|
5936
|
+
return `reusing ${p.path} (${p.branch} is already level with the remote)`;
|
|
5937
|
+
case "recut":
|
|
5938
|
+
return `${p.branch} is gone from the remote and ${p.path} held no work of its own \u2014 re-cut it fresh from ${p.base}`;
|
|
5939
|
+
case "leave-dirty":
|
|
5940
|
+
return `left ${p.path} alone: it has uncommitted changes (someone may be editing it by hand)`;
|
|
5941
|
+
case "leave-unpushed":
|
|
5942
|
+
return `left ${p.path} alone: ${p.branch} has commits that are not on the remote`;
|
|
5943
|
+
}
|
|
5944
|
+
};
|
|
5945
|
+
var planIsUsable = (p) => p.action !== "leave-dirty" && p.action !== "leave-unpushed";
|
|
5946
|
+
function newBranchName(cfg, t, role) {
|
|
5947
|
+
const { prefix } = ticketBranchContext(t);
|
|
5948
|
+
return branchRenderer(t, role)(effectiveBranchTemplate(cfg, prefix));
|
|
5949
|
+
}
|
|
5950
|
+
function planRunWorktree(dir, cfg, t, role) {
|
|
5951
|
+
const { remote, base } = cfg.branch;
|
|
5952
|
+
const hasRemote = remoteConfigured(dir, remote);
|
|
5953
|
+
if (hasRemote) fetchRemote(dir, remote);
|
|
5954
|
+
const num = t.issue_id.replace(/^\D+/, "");
|
|
5955
|
+
const existing = worktreeForNumber(dir, num);
|
|
5956
|
+
const located = locateBranchForTicket(dir, cfg, t, role);
|
|
5957
|
+
const baseRef = hasRemote ? `${remote}/${base}` : base;
|
|
5958
|
+
if (existing && !existing.prunable && existsSync9(existing.path)) {
|
|
5959
|
+
const branch = existing.branch;
|
|
5960
|
+
if (status(existing.path).length > 0) return { action: "leave-dirty", path: existing.path, branch };
|
|
5961
|
+
if (!branch) return { action: "leave-unpushed", path: existing.path, branch: "(detached HEAD)" };
|
|
5962
|
+
if (!hasRemote) return { action: "reuse", path: existing.path, branch };
|
|
5963
|
+
if (remoteBranchExists(dir, remote, branch)) {
|
|
5964
|
+
const ahead = countCommits(existing.path, `${remote}/${branch}..HEAD`);
|
|
5965
|
+
const behind = countCommits(existing.path, `HEAD..${remote}/${branch}`);
|
|
5966
|
+
if (ahead > 0) return { action: "leave-unpushed", path: existing.path, branch };
|
|
5967
|
+
return behind > 0 ? { action: "reset", path: existing.path, branch } : { action: "reuse", path: existing.path, branch };
|
|
5968
|
+
}
|
|
5969
|
+
if (countCommits(existing.path, `${baseRef}..HEAD`) > 0) return { action: "leave-unpushed", path: existing.path, branch };
|
|
5970
|
+
return { action: "recut", path: existing.path, branch, base: baseRef };
|
|
5971
|
+
}
|
|
5972
|
+
const path = join9(dirname6(dir), worktreeForTicket(dir, cfg, t, role));
|
|
5973
|
+
if (located?.where === "remote" || located && hasRemote && remoteBranchExists(dir, remote, located.branch)) {
|
|
5974
|
+
return { action: "cut-remote", path, branch: located.branch };
|
|
5975
|
+
}
|
|
5976
|
+
if (located) {
|
|
5977
|
+
return { action: "reuse", path, branch: located.branch };
|
|
5978
|
+
}
|
|
5979
|
+
return { action: "cut-fresh", path, branch: newBranchName(cfg, t, role), base: baseRef };
|
|
5980
|
+
}
|
|
5981
|
+
function applyRunWorktree(dir, cfg, plan) {
|
|
5982
|
+
const { remote } = cfg.branch;
|
|
5983
|
+
switch (plan.action) {
|
|
5984
|
+
case "leave-dirty":
|
|
5985
|
+
case "leave-unpushed":
|
|
5986
|
+
return null;
|
|
5987
|
+
case "reuse":
|
|
5988
|
+
if (!worktrees(dir).some((w) => w.path === plan.path)) git(dir, ["worktree", "add", plan.path, plan.branch]);
|
|
5989
|
+
return plan.path;
|
|
5990
|
+
case "reset":
|
|
5991
|
+
git(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
|
|
5992
|
+
return plan.path;
|
|
5993
|
+
case "cut-remote": {
|
|
5994
|
+
const local = gitOk(dir, ["rev-parse", "-q", "--verify", `refs/heads/${plan.branch}`]) !== null;
|
|
5995
|
+
if (local) {
|
|
5996
|
+
git(dir, ["worktree", "add", plan.path, plan.branch]);
|
|
5997
|
+
gitOk(plan.path, ["merge", "--ff-only", `${remote}/${plan.branch}`]);
|
|
5998
|
+
} else {
|
|
5999
|
+
git(dir, ["worktree", "add", "--track", "-b", plan.branch, plan.path, `${remote}/${plan.branch}`]);
|
|
6000
|
+
}
|
|
6001
|
+
return plan.path;
|
|
6002
|
+
}
|
|
6003
|
+
case "recut":
|
|
6004
|
+
git(dir, ["worktree", "remove", "--force", plan.path]);
|
|
6005
|
+
gitOk(dir, ["branch", "-D", plan.branch]);
|
|
6006
|
+
git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
|
|
6007
|
+
return plan.path;
|
|
6008
|
+
case "cut-fresh":
|
|
6009
|
+
git(dir, ["worktree", "add", "-b", plan.branch, plan.path, plan.base]);
|
|
6010
|
+
return plan.path;
|
|
6011
|
+
}
|
|
6012
|
+
}
|
|
6013
|
+
async function finishRunWorktree(dir, cfg, branch) {
|
|
6014
|
+
const wt = worktrees(dir).find((w) => w.branch === branch && !w.prunable && w.path !== dir);
|
|
6015
|
+
if (!wt || !existsSync9(wt.path)) return { kind: "none" };
|
|
6016
|
+
if (cfg.hooks.handoff) return { kind: "kept-handoff", path: wt.path };
|
|
6017
|
+
if (status(wt.path).length > 0) return { kind: "kept-dirty", path: wt.path };
|
|
6018
|
+
const n = Number(wt.path.match(/(\d+)$/)?.[1] ?? NaN);
|
|
6019
|
+
if (Number.isFinite(n)) {
|
|
6020
|
+
const pids = pidsInWorktree(wt.path, n);
|
|
6021
|
+
if (pids.length) await killGently(pids);
|
|
6022
|
+
}
|
|
6023
|
+
gitOk(dir, ["worktree", "remove", wt.path]);
|
|
6024
|
+
return existsSync9(wt.path) ? { kind: "kept-dirty", path: wt.path } : { kind: "removed", path: wt.path };
|
|
6025
|
+
}
|
|
6026
|
+
|
|
6027
|
+
// src/handoff-guard.ts
|
|
6028
|
+
var HANDOFF_BOUNCED_MARKER = "<!-- crew:handoff-bounced -->";
|
|
6029
|
+
var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
|
|
6030
|
+
function remoteTip(dir, remote, branch) {
|
|
6031
|
+
const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
|
|
6032
|
+
if (out === null) return null;
|
|
6033
|
+
return out.split("\n").find((l) => l.endsWith(` refs/heads/${branch}`))?.split(" ")[0] ?? "";
|
|
6034
|
+
}
|
|
6035
|
+
function inspectHandoff(dir, cfg, ticket, role) {
|
|
6036
|
+
const remote = cfg.branch.remote;
|
|
6037
|
+
if (!remoteConfigured(dir, remote)) return { kind: "unverified", why: `no remote \`${remote}\` configured` };
|
|
6038
|
+
const branch = existingBranchForTicket(dir, cfg, ticket, role);
|
|
6039
|
+
if (!branch) {
|
|
6040
|
+
return {
|
|
6041
|
+
kind: "bounce",
|
|
6042
|
+
branch: null,
|
|
6043
|
+
remote,
|
|
6044
|
+
local: null,
|
|
6045
|
+
origin: null,
|
|
6046
|
+
reason: `no branch for this ticket exists in this checkout, so there is nothing on \`${remote}\` to hand to QA`
|
|
6047
|
+
};
|
|
6048
|
+
}
|
|
6049
|
+
const wt = worktrees(dir).find((w) => w.branch === branch);
|
|
6050
|
+
const local = wt ? headSha(wt.path) : gitOk(dir, ["rev-parse", `refs/heads/${branch}`]);
|
|
6051
|
+
const origin = remoteTip(dir, remote, branch);
|
|
6052
|
+
if (origin === null) return { kind: "unverified", why: `could not reach \`${remote}\` to check \`${branch}\`` };
|
|
6053
|
+
if (!origin) {
|
|
6054
|
+
return {
|
|
6055
|
+
kind: "bounce",
|
|
6056
|
+
branch,
|
|
6057
|
+
remote,
|
|
6058
|
+
local,
|
|
6059
|
+
origin: null,
|
|
6060
|
+
reason: `\`${branch}\` does not exist on \`${remote}\` \u2014 it was never pushed (or the push failed)`
|
|
6061
|
+
};
|
|
6062
|
+
}
|
|
6063
|
+
if (origin !== local) {
|
|
6064
|
+
return {
|
|
6065
|
+
kind: "bounce",
|
|
6066
|
+
branch,
|
|
6067
|
+
remote,
|
|
6068
|
+
local,
|
|
6069
|
+
origin,
|
|
6070
|
+
reason: `\`${remote}/${branch}\` is at \`${origin}\`, not at this worktree's HEAD \u2014 the latest commits were not pushed, or the remote moved`
|
|
6071
|
+
};
|
|
6072
|
+
}
|
|
6073
|
+
return { kind: "pushed", branch, sha: origin, remote };
|
|
6074
|
+
}
|
|
6075
|
+
function bounceComment(v, shipName2) {
|
|
6076
|
+
return `${HANDOFF_BOUNCED_MARKER}
|
|
6077
|
+
**Hand-off bounced: the branch is not on \`${v.remote}\` at the tip this ticket was built to.**
|
|
6078
|
+
|
|
6079
|
+
${shipName2 ? `Ship: ${shipName2}
|
|
6080
|
+
` : ""}Local HEAD: ${v.local ? `\`${v.local}\`` : "(no local branch)"}
|
|
6081
|
+
\`${v.remote}\`: ${v.origin ? `\`${v.origin}\`` : v.branch ? "(branch missing)" : "(no branch to look for)"}
|
|
6082
|
+
|
|
6083
|
+
${v.reason[0].toUpperCase()}${v.reason.slice(1)}. Set back to \`in_progress\`, still held by this ship; push with a plain \`git push --set-upstream ${v.remote} <branch>\` (never \`--force\`) and set \`fixed\` again.`;
|
|
6084
|
+
}
|
|
6085
|
+
async function enforceHandoff(i) {
|
|
6086
|
+
try {
|
|
6087
|
+
const t = await i.writer.ticket(i.ticketId);
|
|
6088
|
+
if (t.status !== i.contract.statuses.handoff) return { kind: "not-handed-off" };
|
|
6089
|
+
const log = { ticket: t.issue_id, step: "handoff" };
|
|
6090
|
+
const v = inspectHandoff(i.dir, i.cfg, t, i.role);
|
|
6091
|
+
if (v.kind === "unverified") {
|
|
6092
|
+
i.log.warn(`${t.issue_id} is at ${t.status} but the push was not checked: ${v.why}`, log);
|
|
6093
|
+
return { kind: "unverified", why: v.why };
|
|
6094
|
+
}
|
|
6095
|
+
if (v.kind === "pushed") {
|
|
6096
|
+
await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId);
|
|
6097
|
+
i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
|
|
6098
|
+
return { kind: "pushed", sha: v.sha };
|
|
6099
|
+
}
|
|
6100
|
+
const heldBy = i.hold ? t[i.hold.column] : void 0;
|
|
6101
|
+
await i.writer.updateTicket(t.id, {
|
|
6102
|
+
status: i.contract.statuses.building,
|
|
6103
|
+
...t.assignee_id ? {} : { assignee_id: i.seatId },
|
|
6104
|
+
...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
|
|
6105
|
+
});
|
|
6106
|
+
await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId).catch(() => {
|
|
6107
|
+
i.log.warn("bounced, but the note failed to post", log);
|
|
6108
|
+
});
|
|
6109
|
+
i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
|
|
6110
|
+
return { kind: "bounced" };
|
|
6111
|
+
} catch (e) {
|
|
6112
|
+
i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
6113
|
+
return { kind: "failed", why: e.message };
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
async function guardRunHandoff(route, ship, tracker, role, workingId, log) {
|
|
6117
|
+
if (role !== "dev" && role !== "design" || !workingId) return null;
|
|
6118
|
+
const seatId = route.resolved?.seats[role];
|
|
6119
|
+
if (!seatId) return null;
|
|
6120
|
+
try {
|
|
6121
|
+
const t = await tracker.ticket(workingId);
|
|
6122
|
+
const target = repoTargetFor(route, t.repo_id);
|
|
6123
|
+
if (!target) return null;
|
|
6124
|
+
const o = route.repoOverrides[target.name];
|
|
6125
|
+
const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
|
|
6126
|
+
hooks: { ...route.hooks, ...o?.hooks },
|
|
6127
|
+
labels: { ...route.labels, ...o?.labels },
|
|
6128
|
+
branch: { ...route.branch, ...o?.branch }
|
|
6129
|
+
}, target.dir);
|
|
6130
|
+
const affinity = await tracker.claimAffinity();
|
|
6131
|
+
const outcome = await enforceHandoff({
|
|
6132
|
+
writer: tracker,
|
|
6133
|
+
contract: tracker.contract,
|
|
6134
|
+
ticketId: workingId,
|
|
6135
|
+
role,
|
|
6136
|
+
dir: target.dir,
|
|
6137
|
+
cfg,
|
|
6138
|
+
seatId,
|
|
6139
|
+
shipName: ship.name,
|
|
6140
|
+
hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
|
|
6141
|
+
log
|
|
6142
|
+
});
|
|
6143
|
+
if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
|
|
6144
|
+
return outcome;
|
|
6145
|
+
} catch (e) {
|
|
6146
|
+
log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
|
|
6147
|
+
return null;
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
|
|
6151
|
+
try {
|
|
6152
|
+
const branch = existingBranchForTicket(dir, cfg, t, role);
|
|
6153
|
+
if (!branch) return;
|
|
6154
|
+
const o = await finishRunWorktree(dir, cfg, branch);
|
|
6155
|
+
const at = { ticket: t.issue_id, step: "handoff" };
|
|
6156
|
+
if (o.kind === "removed") log.emit(`${t.issue_id}: removed the run's worktree ${o.path} \u2014 the branch is on ${cfg.branch.remote}`, at);
|
|
6157
|
+
else if (o.kind === "kept-handoff") {
|
|
6158
|
+
log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
|
|
6159
|
+
await tracker.postEvent(t.id, `worktree kept on ship ${shipName2} at \`${o.path}\`: the repo's \`handoff\` hook serves from it. QA on another ship cuts its own from \`${cfg.branch.remote}/${branch}\` instead.`, seatId);
|
|
6160
|
+
} else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
|
|
6161
|
+
} catch (e) {
|
|
6162
|
+
log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
|
|
6163
|
+
}
|
|
6164
|
+
}
|
|
6165
|
+
|
|
5660
6166
|
// src/connect-wizard.ts
|
|
5661
|
-
import { delimiter, dirname as
|
|
5662
|
-
import { accessSync as accessSync2, constants as constants2, existsSync as
|
|
6167
|
+
import { delimiter, dirname as dirname7, join as join10 } from "path";
|
|
6168
|
+
import { accessSync as accessSync2, constants as constants2, existsSync as existsSync10, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
5663
6169
|
import { stringify } from "yaml";
|
|
5664
6170
|
var HOST_PASSENGERS_DOCS_URL = "https://crew.tablation.dev/host-passengers";
|
|
5665
6171
|
function shouldRunWizard(opts) {
|
|
5666
6172
|
if (!opts.isTTY) return false;
|
|
5667
|
-
const exists = opts.configExists ??
|
|
6173
|
+
const exists = opts.configExists ?? existsSync10;
|
|
5668
6174
|
return !configSearchPath(opts.crewHome).some((p) => exists(p));
|
|
5669
6175
|
}
|
|
5670
6176
|
function firstRunConfigPath() {
|
|
@@ -5681,7 +6187,7 @@ function defaultIsExecutable(p) {
|
|
|
5681
6187
|
function findOnPath2(name, pathEnv, isExecutable = defaultIsExecutable) {
|
|
5682
6188
|
for (const dir of pathEnv.split(delimiter)) {
|
|
5683
6189
|
if (!dir) continue;
|
|
5684
|
-
const candidate =
|
|
6190
|
+
const candidate = join10(dir, name);
|
|
5685
6191
|
if (isExecutable(candidate)) return candidate;
|
|
5686
6192
|
}
|
|
5687
6193
|
return void 0;
|
|
@@ -5707,9 +6213,9 @@ async function runWizard(p, env) {
|
|
|
5707
6213
|
if (entered && isExec(entered)) agentBin = entered;
|
|
5708
6214
|
else p.say(entered ? `${entered} is not an executable file.` : "A path is required.");
|
|
5709
6215
|
}
|
|
5710
|
-
const nodeDir =
|
|
6216
|
+
const nodeDir = dirname7(env.nodePath);
|
|
5711
6217
|
const pnpmPath = findOnPath2("pnpm", env.pathEnv, isExec);
|
|
5712
|
-
const baseDirs = [nodeDir, pnpmPath ?
|
|
6218
|
+
const baseDirs = [nodeDir, pnpmPath ? dirname7(pnpmPath) : void 0];
|
|
5713
6219
|
p.say(
|
|
5714
6220
|
`
|
|
5715
6221
|
Host Passengers lets this machine run the agent sessions that other people's
|
|
@@ -5720,7 +6226,7 @@ Tablation workspaces ask for, in a container. More: ${HOST_PASSENGERS_DOCS_URL}`
|
|
|
5720
6226
|
p.say("Docker is not available here, so nothing will be hosted until it is. Your answer is recorded anyway.");
|
|
5721
6227
|
}
|
|
5722
6228
|
const dockerPath = hostPassengers ? findOnPath2("docker", env.pathEnv, isExec) : void 0;
|
|
5723
|
-
const extraPath = deriveExtraPath([...baseDirs, dockerPath ?
|
|
6229
|
+
const extraPath = deriveExtraPath([...baseDirs, dockerPath ? dirname7(dockerPath) : void 0]);
|
|
5724
6230
|
if (extraPath) {
|
|
5725
6231
|
p.say(`Hooks will get ${extraPath} on their PATH (where node, pnpm${dockerPath ? " and docker" : ""} live).`);
|
|
5726
6232
|
}
|
|
@@ -5742,7 +6248,7 @@ routes:
|
|
|
5742
6248
|
${routeBlock}`;
|
|
5743
6249
|
}
|
|
5744
6250
|
function writeNewConfig(path, text) {
|
|
5745
|
-
mkdirSync6(
|
|
6251
|
+
mkdirSync6(dirname7(path), { recursive: true });
|
|
5746
6252
|
try {
|
|
5747
6253
|
writeFileSync5(path, text, { mode: 384, flag: "wx" });
|
|
5748
6254
|
return true;
|
|
@@ -5776,8 +6282,8 @@ Wrote ${o.configPath}.
|
|
|
5776
6282
|
}
|
|
5777
6283
|
|
|
5778
6284
|
// src/release-run.ts
|
|
5779
|
-
import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as
|
|
5780
|
-
import { dirname as
|
|
6285
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync6, existsSync as existsSync11, copyFileSync, mkdirSync as mkdirSync7, mkdtempSync, rmSync as rmSync2 } from "fs";
|
|
6286
|
+
import { dirname as dirname8, join as join11 } from "path";
|
|
5781
6287
|
import { tmpdir as tmpdir2 } from "os";
|
|
5782
6288
|
|
|
5783
6289
|
// src/release.ts
|
|
@@ -6037,6 +6543,139 @@ async function applyExternalClosures(tracker, closures, contract, emit, dryRun,
|
|
|
6037
6543
|
return stamped;
|
|
6038
6544
|
}
|
|
6039
6545
|
|
|
6546
|
+
// src/review.ts
|
|
6547
|
+
function reviewApplies(repo, contract) {
|
|
6548
|
+
return repo.release.mode === "external" && !!contract.statuses.reviewing;
|
|
6549
|
+
}
|
|
6550
|
+
function reviewProblems(repo, contract) {
|
|
6551
|
+
if (!reviewApplies(repo, contract)) return [];
|
|
6552
|
+
const problems = [];
|
|
6553
|
+
const template = repo.branch.push ?? repo.branch.name;
|
|
6554
|
+
if (!/\{(key|number|tag)\}/.test(template)) {
|
|
6555
|
+
problems.push(
|
|
6556
|
+
`branch.push "${template}" names none of {key}, {number} or {tag} \u2014 with release.mode "external" and a reviewing status, the pushed branch is the only link back to its ticket`
|
|
6557
|
+
);
|
|
6558
|
+
}
|
|
6559
|
+
return problems;
|
|
6560
|
+
}
|
|
6561
|
+
function parsePrRef(output) {
|
|
6562
|
+
const lines = output.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
6563
|
+
if (lines.length === 1) return lines[0].slice(0, 500);
|
|
6564
|
+
for (const l of [...lines].reverse()) {
|
|
6565
|
+
if (/^https?:\/\/\S+$/.test(l) || /^#?\d+$/.test(l)) return l;
|
|
6566
|
+
}
|
|
6567
|
+
return void 0;
|
|
6568
|
+
}
|
|
6569
|
+
function columnValue(t, column) {
|
|
6570
|
+
const v = t[column];
|
|
6571
|
+
return typeof v === "string" ? v.trim() : "";
|
|
6572
|
+
}
|
|
6573
|
+
function movedPast(r, t, branch, pushedAs) {
|
|
6574
|
+
if (!r.columns.verifiedSha) return null;
|
|
6575
|
+
const verified = columnValue(t, r.contract.columns.verifiedSha);
|
|
6576
|
+
if (!verified) return null;
|
|
6577
|
+
const head = resolve2(r.cwd, branch);
|
|
6578
|
+
const remoteHead = resolve2(r.cwd, `${r.repo.branch.remote}/${pushedAs}`);
|
|
6579
|
+
if (remoteHead && !isAncestor(r.cwd, remoteHead, verified) && remoteHead !== head) {
|
|
6580
|
+
return { ticket: t, verified, head: remoteHead };
|
|
6581
|
+
}
|
|
6582
|
+
if (head && head !== verified && !isAncestor(r.cwd, head, verified)) {
|
|
6583
|
+
return { ticket: t, verified, head };
|
|
6584
|
+
}
|
|
6585
|
+
return null;
|
|
6586
|
+
}
|
|
6587
|
+
async function runReview(r, verified, reviewing) {
|
|
6588
|
+
const out = { handoffs: [], requeues: [] };
|
|
6589
|
+
const remote = r.repo.branch.remote;
|
|
6590
|
+
for (const { ticket, branch } of reviewing) {
|
|
6591
|
+
const moved = movedPast(r, ticket, branch, pushedBranchForTicket(r.repo, ticket));
|
|
6592
|
+
if (moved) out.requeues.push(moved);
|
|
6593
|
+
}
|
|
6594
|
+
for (const { ticket, branch } of verified) {
|
|
6595
|
+
const pushedAs = pushedBranchForTicket(r.repo, ticket);
|
|
6596
|
+
const moved = movedPast(r, ticket, branch, pushedAs);
|
|
6597
|
+
if (moved) {
|
|
6598
|
+
out.requeues.push(moved);
|
|
6599
|
+
continue;
|
|
6600
|
+
}
|
|
6601
|
+
const head = resolve2(r.cwd, branch);
|
|
6602
|
+
if (!head) continue;
|
|
6603
|
+
if (r.dryRun) {
|
|
6604
|
+
r.emit.emit(`would push ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
|
|
6605
|
+
continue;
|
|
6606
|
+
}
|
|
6607
|
+
try {
|
|
6608
|
+
pushBranch(r.cwd, remote, branch, pushedAs);
|
|
6609
|
+
} catch (e) {
|
|
6610
|
+
r.emit.error(
|
|
6611
|
+
`could not push ${branch} to ${remote}/${pushedAs} for review \u2014 ${e.message.split("\n")[0]}; left at verified, retried next cycle`,
|
|
6612
|
+
{ ticket: ticket.issue_id }
|
|
6613
|
+
);
|
|
6614
|
+
continue;
|
|
6615
|
+
}
|
|
6616
|
+
r.emit.emit(`pushed ${branch} to ${remote}/${pushedAs} for review`, { ticket: ticket.issue_id });
|
|
6617
|
+
const handoff = { ticket, pushedAs, head };
|
|
6618
|
+
const existing = r.columns.prRef ? columnValue(ticket, r.contract.columns.prRef) : "";
|
|
6619
|
+
if (existing) {
|
|
6620
|
+
handoff.prRef = existing;
|
|
6621
|
+
} else {
|
|
6622
|
+
const res = await r.runPrHook({
|
|
6623
|
+
CREW_TICKET: ticket.issue_id,
|
|
6624
|
+
CREW_BRANCH: pushedAs,
|
|
6625
|
+
CREW_BASE: r.repo.branch.base,
|
|
6626
|
+
CREW_TITLE: `${displayKey(ticket)}: ${ticket.title ?? ""}`.trim()
|
|
6627
|
+
});
|
|
6628
|
+
if (res && res.code !== 0) handoff.prFailure = { code: res.code, tail: lastLines(res.output, 5).trim() };
|
|
6629
|
+
else if (res) handoff.prRef = parsePrRef(res.output);
|
|
6630
|
+
}
|
|
6631
|
+
out.handoffs.push(handoff);
|
|
6632
|
+
}
|
|
6633
|
+
return out;
|
|
6634
|
+
}
|
|
6635
|
+
async function applyReview(tracker, contract, outcome, remote, emit, seat, columns, dryRun) {
|
|
6636
|
+
const reviewing = contract.statuses.reviewing;
|
|
6637
|
+
for (const h of outcome.handoffs) {
|
|
6638
|
+
if (!reviewing) break;
|
|
6639
|
+
const t = h.ticket;
|
|
6640
|
+
const lines = [`Pushed \`${h.pushedAs}\` (${h.head.slice(0, 8)}) to \`${remote}\` for review; moved to ${reviewing}.`];
|
|
6641
|
+
if (h.prRef) lines.push(`Pull request: ${h.prRef}`);
|
|
6642
|
+
if (h.prFailure) {
|
|
6643
|
+
lines.push(
|
|
6644
|
+
`\`hooks.pr\` exited ${h.prFailure.code}, so no pull request was recorded \u2014 the branch is pushed, open one by hand.
|
|
6645
|
+
|
|
6646
|
+
\`\`\`
|
|
6647
|
+
${h.prFailure.tail}
|
|
6648
|
+
\`\`\``
|
|
6649
|
+
);
|
|
6650
|
+
}
|
|
6651
|
+
try {
|
|
6652
|
+
await tracker.updateTicket(t.id, {
|
|
6653
|
+
[contract.columns.status]: reviewing,
|
|
6654
|
+
...h.prRef && columns.prRef ? { [contract.columns.prRef]: h.prRef } : {}
|
|
6655
|
+
}, t.updated_at);
|
|
6656
|
+
await tracker.postEvent(t.id, lines.join("\n\n"), seat);
|
|
6657
|
+
emit.emit(`moved to ${reviewing}`, { ticket: t.issue_id });
|
|
6658
|
+
} catch (e) {
|
|
6659
|
+
emit.warn(`could not move to ${reviewing}: ${e.message}`, { ticket: t.issue_id });
|
|
6660
|
+
}
|
|
6661
|
+
}
|
|
6662
|
+
for (const q of outcome.requeues) {
|
|
6663
|
+
const t = q.ticket;
|
|
6664
|
+
const body = `The branch is no longer what was verified: \`verified_sha\` is ${q.verified.slice(0, 8)}, the branch is now at ${q.head.slice(0, 8)}. Returned to ${contract.statuses.handoff} for QA to check what changed.`;
|
|
6665
|
+
if (dryRun) {
|
|
6666
|
+
emit.emit(`would return to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
|
|
6667
|
+
continue;
|
|
6668
|
+
}
|
|
6669
|
+
try {
|
|
6670
|
+
await tracker.updateTicket(t.id, { [contract.columns.status]: contract.statuses.handoff }, t.updated_at);
|
|
6671
|
+
await tracker.postEvent(t.id, body, seat);
|
|
6672
|
+
emit.emit(`returned to ${contract.statuses.handoff} \u2014 branch moved past ${q.verified.slice(0, 8)}`, { ticket: t.issue_id });
|
|
6673
|
+
} catch (e) {
|
|
6674
|
+
emit.warn(`could not return to ${contract.statuses.handoff}: ${e.message}`, { ticket: t.issue_id });
|
|
6675
|
+
}
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
|
|
6040
6679
|
// src/release-run.ts
|
|
6041
6680
|
function emitReleaseSummary(emit, summaries) {
|
|
6042
6681
|
if (summaries.length <= 1) return;
|
|
@@ -6136,11 +6775,15 @@ async function detectExternalClosures(o, candidates) {
|
|
|
6136
6775
|
const out = [];
|
|
6137
6776
|
for (const c of candidates) {
|
|
6138
6777
|
if (!c.branch) continue;
|
|
6778
|
+
const prRef = c.ticket[o.contract.columns.prRef];
|
|
6139
6779
|
const closure = await detectClosure({
|
|
6140
6780
|
cwd: o.cwd,
|
|
6141
6781
|
key: c.ticket.issue_id,
|
|
6142
6782
|
aliases: referenceKeys(c.ticket).slice(1),
|
|
6143
|
-
|
|
6783
|
+
// The name the remote knows it by — `branch.push`, which is not the
|
|
6784
|
+
// local branch's when the repo sets one.
|
|
6785
|
+
pushedBranch: pushedBranchForTicket(o.repo, c.ticket),
|
|
6786
|
+
pr: typeof prRef === "string" && prRef.trim() ? prRef.trim() : void 0,
|
|
6144
6787
|
remote: o.repo.branch.remote,
|
|
6145
6788
|
base: o.repo.branch.base,
|
|
6146
6789
|
mergedHook
|
|
@@ -6150,6 +6793,29 @@ async function detectExternalClosures(o, candidates) {
|
|
|
6150
6793
|
}
|
|
6151
6794
|
return out;
|
|
6152
6795
|
}
|
|
6796
|
+
async function reviewHandoff(o, closures, verifiedCandidates, reviewing) {
|
|
6797
|
+
if (!reviewApplies(o.repo, o.contract)) return void 0;
|
|
6798
|
+
const problems = reviewProblems(o.repo, o.contract);
|
|
6799
|
+
if (problems.length) {
|
|
6800
|
+
for (const p of problems) o.emit.error(`${o.scope ?? "release"}: ${p}`);
|
|
6801
|
+
return void 0;
|
|
6802
|
+
}
|
|
6803
|
+
const open = new Set(closures.filter((c) => c.closure.state !== "merged").map((c) => c.ticket.id));
|
|
6804
|
+
const verified = verifiedCandidates.filter((c) => !!c.branch && open.has(c.ticket.id)).map((c) => ({ ticket: c.ticket, branch: c.branch }));
|
|
6805
|
+
const outcome = await runReview({
|
|
6806
|
+
cwd: o.cwd,
|
|
6807
|
+
repo: o.repo,
|
|
6808
|
+
contract: o.contract,
|
|
6809
|
+
emit: o.emit,
|
|
6810
|
+
dryRun: o.dryRun,
|
|
6811
|
+
columns: o.reviewColumns ?? { verifiedSha: false, prRef: false },
|
|
6812
|
+
runPrHook: async (env) => {
|
|
6813
|
+
const r = await hook(o, "pr", env);
|
|
6814
|
+
return r ? { code: r.code, output: r.output } : null;
|
|
6815
|
+
}
|
|
6816
|
+
}, verified, reviewing.filter((r) => open.has(r.ticket.id)));
|
|
6817
|
+
return outcome.handoffs.length || outcome.requeues.length ? outcome : void 0;
|
|
6818
|
+
}
|
|
6153
6819
|
async function confirmExternalReleased(o, closures) {
|
|
6154
6820
|
if (!o.repo.hooks.released) return closures;
|
|
6155
6821
|
const landed = closures.filter((c) => c.closure.state === "merged" && c.closure.mergedAt);
|
|
@@ -6240,9 +6906,9 @@ async function bump(o, size) {
|
|
|
6240
6906
|
}
|
|
6241
6907
|
return printed;
|
|
6242
6908
|
}
|
|
6243
|
-
const files = o.repo.release.versionFiles.map((f) =>
|
|
6909
|
+
const files = o.repo.release.versionFiles.map((f) => join11(o.cwd, f));
|
|
6244
6910
|
const first = files[0];
|
|
6245
|
-
if (!
|
|
6911
|
+
if (!existsSync11(first)) {
|
|
6246
6912
|
o.emit.error(`no ${o.repo.release.versionFiles[0]} to read a version from`);
|
|
6247
6913
|
return null;
|
|
6248
6914
|
}
|
|
@@ -6272,13 +6938,13 @@ async function bump(o, size) {
|
|
|
6272
6938
|
function writeChangelog(o, version, entries) {
|
|
6273
6939
|
const file = o.repo.release.changelog;
|
|
6274
6940
|
if (!file || entries.length === 0) return;
|
|
6275
|
-
const path =
|
|
6941
|
+
const path = join11(o.cwd, file);
|
|
6276
6942
|
const section = renderChangelogSection(version, (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), entries);
|
|
6277
6943
|
if (o.dryRun) {
|
|
6278
6944
|
o.emit.emit(`would prepend ${entries.length} entr(ies) to ${file}`);
|
|
6279
6945
|
return;
|
|
6280
6946
|
}
|
|
6281
|
-
const existing =
|
|
6947
|
+
const existing = existsSync11(path) ? readFileSync7(path, "utf8") : "# Changelog\n";
|
|
6282
6948
|
writeFileSync6(path, insertChangelogSection(existing, section));
|
|
6283
6949
|
}
|
|
6284
6950
|
async function confirm(o, expected) {
|
|
@@ -6360,8 +7026,8 @@ async function confirmLastRelease(o, decision) {
|
|
|
6360
7026
|
function openReleaseCheckout(o) {
|
|
6361
7027
|
const { remote, base } = o.repo.branch;
|
|
6362
7028
|
const branch = `crew/release-${Date.now()}`;
|
|
6363
|
-
const root = mkdtempSync(
|
|
6364
|
-
const dir =
|
|
7029
|
+
const root = mkdtempSync(join11(tmpdir2(), "crew-release-"));
|
|
7030
|
+
const dir = join11(root, "checkout");
|
|
6365
7031
|
const close = () => {
|
|
6366
7032
|
gitOk(o.cwd, ["worktree", "remove", "--force", dir]);
|
|
6367
7033
|
rmSync2(root, { recursive: true, force: true });
|
|
@@ -6375,10 +7041,10 @@ function openReleaseCheckout(o) {
|
|
|
6375
7041
|
throw e;
|
|
6376
7042
|
}
|
|
6377
7043
|
for (const f of o.repo.worktrees.copy) {
|
|
6378
|
-
const src =
|
|
6379
|
-
if (!
|
|
6380
|
-
mkdirSync7(
|
|
6381
|
-
copyFileSync(src,
|
|
7044
|
+
const src = join11(o.cwd, f);
|
|
7045
|
+
if (!existsSync11(src)) continue;
|
|
7046
|
+
mkdirSync7(dirname8(join11(dir, f)), { recursive: true });
|
|
7047
|
+
copyFileSync(src, join11(dir, f));
|
|
6382
7048
|
}
|
|
6383
7049
|
return { dir, branch, close };
|
|
6384
7050
|
}
|
|
@@ -6424,9 +7090,18 @@ async function runRelease(o) {
|
|
|
6424
7090
|
}
|
|
6425
7091
|
if (o.repo.release.mode === "external") {
|
|
6426
7092
|
o.emit.emit("release.mode is external \u2014 the crew hands work off and does not release");
|
|
6427
|
-
const
|
|
7093
|
+
const reviewingStatus = o.contract.statuses.reviewing;
|
|
7094
|
+
const reviewing = [];
|
|
7095
|
+
if (reviewingStatus) {
|
|
7096
|
+
for (const t of o.tickets.filter((x) => x.status === reviewingStatus)) {
|
|
7097
|
+
const branch = locateBranchForTicket(o.cwd, o.repo, t)?.branch;
|
|
7098
|
+
if (branch) reviewing.push({ ticket: t, branch });
|
|
7099
|
+
}
|
|
7100
|
+
}
|
|
7101
|
+
const landed = await detectExternalClosures(o, [...decision.merges, ...reviewing]);
|
|
6428
7102
|
const externalClosures = await confirmExternalReleased(o, landed);
|
|
6429
|
-
|
|
7103
|
+
const review = await reviewHandoff(o, externalClosures, decision.merges, reviewing);
|
|
7104
|
+
return { merged: [], deployed: false, stopped: "external", decision, externalClosures, review };
|
|
6430
7105
|
}
|
|
6431
7106
|
const { remote, base } = o.repo.branch;
|
|
6432
7107
|
if (o.dryRun || o.mergeOnly || !remoteBranchExists(o.cwd, remote, base)) {
|
|
@@ -6781,7 +7456,7 @@ function missingReleaserWarning(repo, release, ships, now = Date.now()) {
|
|
|
6781
7456
|
}
|
|
6782
7457
|
|
|
6783
7458
|
// src/watch.ts
|
|
6784
|
-
import { watch as watchFile, existsSync as
|
|
7459
|
+
import { watch as watchFile, existsSync as existsSync12, statSync as statSync5, openSync as openSync2, readSync, closeSync as closeSync2 } from "fs";
|
|
6785
7460
|
var E = "\x1B[";
|
|
6786
7461
|
var dim = (s) => `${E}2m${s}${E}0m`;
|
|
6787
7462
|
var bold = (s) => `${E}1m${s}${E}0m`;
|
|
@@ -6882,7 +7557,7 @@ function renderHeaders(states, now = Date.now()) {
|
|
|
6882
7557
|
return states.map((s) => renderHeader(s, now)).join("");
|
|
6883
7558
|
}
|
|
6884
7559
|
function readFrom(file, from) {
|
|
6885
|
-
if (!
|
|
7560
|
+
if (!existsSync12(file)) return { events: [], offset: from };
|
|
6886
7561
|
const size = statSync5(file).size;
|
|
6887
7562
|
if (size < from) return readFrom(file, 0);
|
|
6888
7563
|
if (size === from) return { events: [], offset: from };
|
|
@@ -6961,96 +7636,6 @@ function startWatch(o) {
|
|
|
6961
7636
|
return stop;
|
|
6962
7637
|
}
|
|
6963
7638
|
|
|
6964
|
-
// src/ports.ts
|
|
6965
|
-
import { execFileSync as execFileSync2 } from "child_process";
|
|
6966
|
-
import { existsSync as existsSync11 } from "fs";
|
|
6967
|
-
import { join as join10 } from "path";
|
|
6968
|
-
var run = (cmd, args) => {
|
|
6969
|
-
try {
|
|
6970
|
-
return execFileSync2(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
6971
|
-
} catch {
|
|
6972
|
-
return "";
|
|
6973
|
-
}
|
|
6974
|
-
};
|
|
6975
|
-
function listeners() {
|
|
6976
|
-
const out = [];
|
|
6977
|
-
const seen = /* @__PURE__ */ new Set();
|
|
6978
|
-
const add = (pid, port) => {
|
|
6979
|
-
const key = `${pid}:${port}`;
|
|
6980
|
-
if (!Number.isFinite(pid) || !Number.isFinite(port) || seen.has(key)) return;
|
|
6981
|
-
seen.add(key);
|
|
6982
|
-
out.push({ pid, port });
|
|
6983
|
-
};
|
|
6984
|
-
const lsof = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"]);
|
|
6985
|
-
if (lsof) {
|
|
6986
|
-
for (const line of lsof.split("\n").slice(1)) {
|
|
6987
|
-
const cols = line.split(/\s+/);
|
|
6988
|
-
const addr = cols[8];
|
|
6989
|
-
if (!addr) continue;
|
|
6990
|
-
add(Number(cols[1]), Number(addr.slice(addr.lastIndexOf(":") + 1)));
|
|
6991
|
-
}
|
|
6992
|
-
return out;
|
|
6993
|
-
}
|
|
6994
|
-
const ss = run("ss", ["-ltnpH"]);
|
|
6995
|
-
for (const line of ss.split("\n")) {
|
|
6996
|
-
const local = line.trim().split(/\s+/)[3];
|
|
6997
|
-
const pid = /pid=(\d+)/.exec(line)?.[1];
|
|
6998
|
-
if (!local || !pid) continue;
|
|
6999
|
-
add(Number(pid), Number(local.slice(local.lastIndexOf(":") + 1)));
|
|
7000
|
-
}
|
|
7001
|
-
return out;
|
|
7002
|
-
}
|
|
7003
|
-
var pidsOnPort = (port) => listeners().filter((l) => l.port === port).map((l) => l.pid);
|
|
7004
|
-
var DEFAULT_PORTS = { backendBase: 3e4, frontendBase: 4e4, span: 1e3 };
|
|
7005
|
-
function ticketForPort(port, s = DEFAULT_PORTS) {
|
|
7006
|
-
if (port >= s.backendBase && port < s.backendBase + s.span) return { n: port - s.backendBase, role: "backend" };
|
|
7007
|
-
if (port >= s.frontendBase && port < s.frontendBase + s.span) return { n: port - s.frontendBase, role: "frontend" };
|
|
7008
|
-
return null;
|
|
7009
|
-
}
|
|
7010
|
-
function worktreeExists(parent, prefix, n) {
|
|
7011
|
-
for (const name of [`${prefix}${n}`, `${prefix}${String(n).padStart(3, "0")}`]) {
|
|
7012
|
-
if (existsSync11(join10(parent, name, ".git"))) return true;
|
|
7013
|
-
}
|
|
7014
|
-
return false;
|
|
7015
|
-
}
|
|
7016
|
-
var worktreeExistsIn = (where, n) => where.some((w) => worktreeExists(w.parent, w.prefix, n));
|
|
7017
|
-
function findOrphansIn(where, s = DEFAULT_PORTS) {
|
|
7018
|
-
const orphans = [];
|
|
7019
|
-
for (const l of listeners()) {
|
|
7020
|
-
const t = ticketForPort(l.port, s);
|
|
7021
|
-
if (!t) continue;
|
|
7022
|
-
if (worktreeExistsIn(where, t.n)) continue;
|
|
7023
|
-
orphans.push({ ...l, ...t });
|
|
7024
|
-
}
|
|
7025
|
-
return orphans;
|
|
7026
|
-
}
|
|
7027
|
-
async function killGently(pids, graceMs = 2e3) {
|
|
7028
|
-
for (const pid of pids) {
|
|
7029
|
-
try {
|
|
7030
|
-
process.kill(pid, "SIGTERM");
|
|
7031
|
-
} catch {
|
|
7032
|
-
}
|
|
7033
|
-
}
|
|
7034
|
-
if (pids.length === 0) return;
|
|
7035
|
-
await new Promise((r) => setTimeout(r, graceMs));
|
|
7036
|
-
for (const pid of pids) {
|
|
7037
|
-
try {
|
|
7038
|
-
process.kill(pid, "SIGKILL");
|
|
7039
|
-
} catch {
|
|
7040
|
-
}
|
|
7041
|
-
}
|
|
7042
|
-
}
|
|
7043
|
-
function pidsInWorktree(path, n, s = DEFAULT_PORTS) {
|
|
7044
|
-
const pids = /* @__PURE__ */ new Set();
|
|
7045
|
-
for (const port of [s.backendBase + n, s.frontendBase + n]) for (const p of pidsOnPort(port)) pids.add(p);
|
|
7046
|
-
const matched = run("pgrep", ["-f", `${path}/`]);
|
|
7047
|
-
for (const line of matched.split("\n")) {
|
|
7048
|
-
const pid = Number(line.trim());
|
|
7049
|
-
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) pids.add(pid);
|
|
7050
|
-
}
|
|
7051
|
-
return [...pids];
|
|
7052
|
-
}
|
|
7053
|
-
|
|
7054
7639
|
// src/inbox.ts
|
|
7055
7640
|
function matchMembers(rows, query) {
|
|
7056
7641
|
const q = query.trim().toLowerCase();
|
|
@@ -7176,8 +7761,8 @@ async function decideFleet(o) {
|
|
|
7176
7761
|
}
|
|
7177
7762
|
var LOUD_STREAK = 5;
|
|
7178
7763
|
function since(iso, now = Date.now()) {
|
|
7179
|
-
const
|
|
7180
|
-
const mins = Math.round(
|
|
7764
|
+
const ms2 = Math.max(0, now - Date.parse(iso));
|
|
7765
|
+
const mins = Math.round(ms2 / 6e4);
|
|
7181
7766
|
if (mins < 60) return `${mins}m`;
|
|
7182
7767
|
const hours = Math.round(mins / 60);
|
|
7183
7768
|
if (hours < 48) return `${hours}h`;
|
|
@@ -7212,8 +7797,8 @@ function nextRoles(decision, ran) {
|
|
|
7212
7797
|
}
|
|
7213
7798
|
|
|
7214
7799
|
// src/agents.ts
|
|
7215
|
-
import { readFileSync as readFileSync8, existsSync as
|
|
7216
|
-
import { join as
|
|
7800
|
+
import { readFileSync as readFileSync8, existsSync as existsSync13 } from "fs";
|
|
7801
|
+
import { join as join12 } from "path";
|
|
7217
7802
|
import { TablationClient as TablationClient2, StaleWriteError as StaleWriteError6, TablationApiError } from "@tablation/client";
|
|
7218
7803
|
var PERSONA_NAME = {
|
|
7219
7804
|
dev: "Developer",
|
|
@@ -7227,11 +7812,11 @@ var CREW_AGENT_COLUMN = "agent_id";
|
|
|
7227
7812
|
var AgentsSyncError = class extends Error {
|
|
7228
7813
|
};
|
|
7229
7814
|
function personaDefaultPrompt(promptsDir, role) {
|
|
7230
|
-
const briefPath =
|
|
7231
|
-
if (!
|
|
7815
|
+
const briefPath = join12(promptsDir, "personas", `lane-${role}.md`);
|
|
7816
|
+
if (!existsSync13(briefPath)) throw new AgentsSyncError(`no brief at ${briefPath}`);
|
|
7232
7817
|
if (role === "pair") return readFileSync8(briefPath, "utf8");
|
|
7233
|
-
const commonPath =
|
|
7234
|
-
if (!
|
|
7818
|
+
const commonPath = join12(promptsDir, "personas", "common.md");
|
|
7819
|
+
if (!existsSync13(commonPath)) throw new AgentsSyncError(`no shared policy at ${commonPath}`);
|
|
7235
7820
|
return `${readFileSync8(commonPath, "utf8")}${readFileSync8(briefPath, "utf8")}`;
|
|
7236
7821
|
}
|
|
7237
7822
|
async function resolveAgentId(route, role, opts) {
|
|
@@ -7898,8 +8483,8 @@ import {
|
|
|
7898
8483
|
} from "@tablation/client";
|
|
7899
8484
|
|
|
7900
8485
|
// src/skills.ts
|
|
7901
|
-
import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as
|
|
7902
|
-
import { join as
|
|
8486
|
+
import { readFileSync as readFileSync9, readdirSync as readdirSync2, existsSync as existsSync14 } from "fs";
|
|
8487
|
+
import { join as join13 } from "path";
|
|
7903
8488
|
import { parse as parseYaml } from "yaml";
|
|
7904
8489
|
import { TablationClient as TablationClient3, StaleWriteError as StaleWriteError7, TablationApiError as TablationApiError2 } from "@tablation/client";
|
|
7905
8490
|
var SkillSyncError = class extends Error {
|
|
@@ -7918,9 +8503,9 @@ function parseSkillFile(path) {
|
|
|
7918
8503
|
return { name: meta.name, description: meta.description, prompt: body, file: path };
|
|
7919
8504
|
}
|
|
7920
8505
|
function listLocalSkills(promptsDir) {
|
|
7921
|
-
const dir =
|
|
7922
|
-
if (!
|
|
7923
|
-
return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(
|
|
8506
|
+
const dir = join13(promptsDir, "skills");
|
|
8507
|
+
if (!existsSync14(dir)) return [];
|
|
8508
|
+
return readdirSync2(dir).filter((f) => f.endsWith(".md")).map((f) => parseSkillFile(join13(dir, f)));
|
|
7924
8509
|
}
|
|
7925
8510
|
async function syncSkills(route, opts) {
|
|
7926
8511
|
if (!route.resolved) {
|
|
@@ -8247,7 +8832,7 @@ function resolveRepoProject(projects, opt, routeAreaId) {
|
|
|
8247
8832
|
import { spawnSync as spawnSync2 } from "child_process";
|
|
8248
8833
|
import { readFileSync as readFileSync10 } from "fs";
|
|
8249
8834
|
import { homedir as homedir4 } from "os";
|
|
8250
|
-
import { join as
|
|
8835
|
+
import { join as join14 } from "path";
|
|
8251
8836
|
var MCP_SERVER_NAME = "tablation";
|
|
8252
8837
|
var redact = (s, key) => key ? s.split(key).join("sk_***") : s;
|
|
8253
8838
|
function mcpUrlFor(baseUrl) {
|
|
@@ -8260,7 +8845,7 @@ function runClaude(bin, args) {
|
|
|
8260
8845
|
}
|
|
8261
8846
|
function userScopeEntry(home) {
|
|
8262
8847
|
try {
|
|
8263
|
-
const j = JSON.parse(readFileSync10(
|
|
8848
|
+
const j = JSON.parse(readFileSync10(join14(home, ".claude.json"), "utf8"));
|
|
8264
8849
|
return j.mcpServers?.[MCP_SERVER_NAME];
|
|
8265
8850
|
} catch {
|
|
8266
8851
|
return void 0;
|
|
@@ -8347,7 +8932,7 @@ function schedulerInstalled(plan, exists, crontab) {
|
|
|
8347
8932
|
}
|
|
8348
8933
|
|
|
8349
8934
|
// src/worktree-sweep.ts
|
|
8350
|
-
import { existsSync as
|
|
8935
|
+
import { existsSync as existsSync15 } from "fs";
|
|
8351
8936
|
import { basename as basename6 } from "path";
|
|
8352
8937
|
function planWorktreeSweep(target, tickets, contract) {
|
|
8353
8938
|
const terminal = new Set(contract.statuses.resolved.filter((s) => s !== contract.statuses.verified));
|
|
@@ -8371,7 +8956,7 @@ async function applyWorktreeSweep(cwd, actions, dryRun, log, remote = "origin")
|
|
|
8371
8956
|
let removed = 0;
|
|
8372
8957
|
let keptBranches = 0;
|
|
8373
8958
|
for (const a of actions) {
|
|
8374
|
-
if (!dryRun &&
|
|
8959
|
+
if (!dryRun && existsSync15(a.path)) {
|
|
8375
8960
|
let dirty = false;
|
|
8376
8961
|
try {
|
|
8377
8962
|
dirty = status(a.path).length > 0;
|
|
@@ -8459,14 +9044,14 @@ function applyRemoteBranchCleanup(cwd, remote, branches2, dryRun, log) {
|
|
|
8459
9044
|
}
|
|
8460
9045
|
|
|
8461
9046
|
// src/stream-sweep.ts
|
|
8462
|
-
import { existsSync as
|
|
8463
|
-
import { join as
|
|
9047
|
+
import { existsSync as existsSync16, readdirSync as readdirSync3, rmSync as rmSync3, statSync as statSync6 } from "fs";
|
|
9048
|
+
import { join as join15 } from "path";
|
|
8464
9049
|
var DAY_MS2 = 24 * 60 * 60 * 1e3;
|
|
8465
9050
|
function planStreamSweep(stateDir, retentionDays, now = Date.now()) {
|
|
8466
|
-
const dir =
|
|
8467
|
-
if (!
|
|
9051
|
+
const dir = join15(stateDir, "streams");
|
|
9052
|
+
if (!existsSync16(dir)) return [];
|
|
8468
9053
|
const cutoff = now - retentionDays * DAY_MS2;
|
|
8469
|
-
return readdirSync3(dir).map((name) =>
|
|
9054
|
+
return readdirSync3(dir).map((name) => join15(dir, name)).filter((path) => {
|
|
8470
9055
|
try {
|
|
8471
9056
|
return statSync6(path).mtimeMs < cutoff;
|
|
8472
9057
|
} catch {
|
|
@@ -8493,24 +9078,24 @@ function applyStreamSweep(paths, dryRun, log) {
|
|
|
8493
9078
|
}
|
|
8494
9079
|
|
|
8495
9080
|
// src/cli.ts
|
|
8496
|
-
import { readFileSync as readFileSync14, existsSync as
|
|
9081
|
+
import { readFileSync as readFileSync14, existsSync as existsSync20, mkdirSync as mkdirSync11, writeFileSync as writeFileSync9, unlinkSync as unlinkSync5, copyFileSync as copyFileSync2, renameSync } from "fs";
|
|
8497
9082
|
import { createInterface } from "readline/promises";
|
|
8498
9083
|
import { dirname as dirOf, resolve as resolvePath } from "path";
|
|
8499
9084
|
|
|
8500
9085
|
// src/ssh-keys.ts
|
|
8501
9086
|
import { execFileSync as execFileSync3 } from "child_process";
|
|
8502
|
-
import { existsSync as
|
|
8503
|
-
import { dirname as
|
|
9087
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync8, readFileSync as readFileSync11, chmodSync } from "fs";
|
|
9088
|
+
import { dirname as dirname10, join as join16 } from "path";
|
|
8504
9089
|
var SshKeyError = class extends Error {
|
|
8505
9090
|
};
|
|
8506
9091
|
function shipSshKeyPathFor(stateDir) {
|
|
8507
|
-
return
|
|
9092
|
+
return join16(stateDir, "ssh", "id_ed25519");
|
|
8508
9093
|
}
|
|
8509
9094
|
function ensureShipSshKeypair(stateDir) {
|
|
8510
9095
|
const privateKeyPath = shipSshKeyPathFor(stateDir);
|
|
8511
9096
|
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
8512
|
-
if (!
|
|
8513
|
-
mkdirSync8(
|
|
9097
|
+
if (!existsSync17(privateKeyPath)) {
|
|
9098
|
+
mkdirSync8(dirname10(privateKeyPath), { recursive: true });
|
|
8514
9099
|
try {
|
|
8515
9100
|
execFileSync3("ssh-keygen", [
|
|
8516
9101
|
"-t",
|
|
@@ -8530,7 +9115,7 @@ function ensureShipSshKeypair(stateDir) {
|
|
|
8530
9115
|
}
|
|
8531
9116
|
chmodSync(privateKeyPath, 384);
|
|
8532
9117
|
}
|
|
8533
|
-
if (!
|
|
9118
|
+
if (!existsSync17(publicKeyPath)) {
|
|
8534
9119
|
throw new SshKeyError(`${privateKeyPath} exists but ${publicKeyPath} does not \u2014 regenerate or restore it`);
|
|
8535
9120
|
}
|
|
8536
9121
|
const publicKey = readFileSync11(publicKeyPath, "utf8").trim();
|
|
@@ -8747,9 +9332,9 @@ function syncAllPassengerCheckouts(cfg) {
|
|
|
8747
9332
|
|
|
8748
9333
|
// src/tunnel.ts
|
|
8749
9334
|
import { spawn as spawn3 } from "child_process";
|
|
8750
|
-
import { existsSync as
|
|
9335
|
+
import { existsSync as existsSync18, mkdirSync as mkdirSync9, readFileSync as readFileSync12, writeFileSync as writeFileSync7, unlinkSync as unlinkSync3, readdirSync as readdirSync4 } from "fs";
|
|
8751
9336
|
import { randomInt } from "crypto";
|
|
8752
|
-
import { join as
|
|
9337
|
+
import { join as join17 } from "path";
|
|
8753
9338
|
var DEFAULT_SPAWN_OPTIONS = { stdio: "ignore", detached: true };
|
|
8754
9339
|
var defaultSpawn = (cmd, args) => spawn3(cmd, args, DEFAULT_SPAWN_OPTIONS);
|
|
8755
9340
|
var SLUG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
@@ -8816,11 +9401,11 @@ function startTunnel(opts) {
|
|
|
8816
9401
|
};
|
|
8817
9402
|
}
|
|
8818
9403
|
function tunnelStatePath(stateDir, workspaceId) {
|
|
8819
|
-
return
|
|
9404
|
+
return join17(stateDir, "passengers", `${workspaceId}.json`);
|
|
8820
9405
|
}
|
|
8821
9406
|
function readPersistedTunnel(stateDir, workspaceId) {
|
|
8822
9407
|
const path = tunnelStatePath(stateDir, workspaceId);
|
|
8823
|
-
if (!
|
|
9408
|
+
if (!existsSync18(path)) return void 0;
|
|
8824
9409
|
try {
|
|
8825
9410
|
return JSON.parse(readFileSync12(path, "utf8"));
|
|
8826
9411
|
} catch {
|
|
@@ -8829,7 +9414,7 @@ function readPersistedTunnel(stateDir, workspaceId) {
|
|
|
8829
9414
|
}
|
|
8830
9415
|
function writePersistedTunnel(stateDir, state) {
|
|
8831
9416
|
const path = tunnelStatePath(stateDir, state.workspaceId);
|
|
8832
|
-
mkdirSync9(
|
|
9417
|
+
mkdirSync9(join17(stateDir, "passengers"), { recursive: true });
|
|
8833
9418
|
writeFileSync7(path, `${JSON.stringify(state, null, 2)}
|
|
8834
9419
|
`);
|
|
8835
9420
|
}
|
|
@@ -8927,8 +9512,8 @@ function syncPassengerTunnels(plans, relayHost, relayPort, privateKeyPath, state
|
|
|
8927
9512
|
}
|
|
8928
9513
|
}
|
|
8929
9514
|
function readAllPersistedWorkspaceIds(stateDir) {
|
|
8930
|
-
const dir =
|
|
8931
|
-
if (!
|
|
9515
|
+
const dir = join17(stateDir, "passengers");
|
|
9516
|
+
if (!existsSync18(dir)) return [];
|
|
8932
9517
|
return readdirSync4(dir).filter((f) => f.endsWith(".json") && !f.endsWith("-sync.json")).map((f) => f.slice(0, -".json".length));
|
|
8933
9518
|
}
|
|
8934
9519
|
|
|
@@ -9049,8 +9634,8 @@ async function unregisterEndpoint(workspaceId, info, d) {
|
|
|
9049
9634
|
import { createServer as createHttpServer } from "http";
|
|
9050
9635
|
import { spawn as spawn4 } from "child_process";
|
|
9051
9636
|
import { randomBytes } from "crypto";
|
|
9052
|
-
import { existsSync as
|
|
9053
|
-
import { join as
|
|
9637
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync10, readFileSync as readFileSync13, writeFileSync as writeFileSync8, unlinkSync as unlinkSync4, readdirSync as readdirSync5 } from "fs";
|
|
9638
|
+
import { join as join18 } from "path";
|
|
9054
9639
|
|
|
9055
9640
|
// src/secret-compare.ts
|
|
9056
9641
|
import { timingSafeEqual } from "crypto";
|
|
@@ -9139,11 +9724,11 @@ var defaultSpawn2 = (cmd, args, env) => {
|
|
|
9139
9724
|
return child;
|
|
9140
9725
|
};
|
|
9141
9726
|
function syncDaemonStatePath(stateDir, workspaceId) {
|
|
9142
|
-
return
|
|
9727
|
+
return join18(stateDir, "passengers", `${workspaceId}-sync.json`);
|
|
9143
9728
|
}
|
|
9144
9729
|
function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
9145
9730
|
const path = syncDaemonStatePath(stateDir, workspaceId);
|
|
9146
|
-
if (!
|
|
9731
|
+
if (!existsSync19(path)) return void 0;
|
|
9147
9732
|
try {
|
|
9148
9733
|
return JSON.parse(readFileSync13(path, "utf8"));
|
|
9149
9734
|
} catch {
|
|
@@ -9152,7 +9737,7 @@ function readPersistedSyncDaemon(stateDir, workspaceId) {
|
|
|
9152
9737
|
}
|
|
9153
9738
|
function writePersistedSyncDaemon(stateDir, state) {
|
|
9154
9739
|
const path = syncDaemonStatePath(stateDir, state.workspaceId);
|
|
9155
|
-
mkdirSync10(
|
|
9740
|
+
mkdirSync10(join18(stateDir, "passengers"), { recursive: true });
|
|
9156
9741
|
writeFileSync8(path, `${JSON.stringify(state, null, 2)}
|
|
9157
9742
|
`);
|
|
9158
9743
|
}
|
|
@@ -9163,8 +9748,8 @@ function removePersistedSyncDaemon(stateDir, workspaceId) {
|
|
|
9163
9748
|
} catch {
|
|
9164
9749
|
}
|
|
9165
9750
|
}
|
|
9166
|
-
function sleepSync2(
|
|
9167
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0,
|
|
9751
|
+
function sleepSync2(ms2) {
|
|
9752
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms2);
|
|
9168
9753
|
}
|
|
9169
9754
|
function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
9170
9755
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -9173,8 +9758,8 @@ function waitForExit(pid, alive, timeoutMs = 5e3) {
|
|
|
9173
9758
|
}
|
|
9174
9759
|
}
|
|
9175
9760
|
function readAllPersistedSyncWorkspaceIds(stateDir) {
|
|
9176
|
-
const dir =
|
|
9177
|
-
if (!
|
|
9761
|
+
const dir = join18(stateDir, "passengers");
|
|
9762
|
+
if (!existsSync19(dir)) return [];
|
|
9178
9763
|
const suffix = "-sync.json";
|
|
9179
9764
|
return readdirSync5(dir).filter((f) => f.endsWith(suffix)).map((f) => f.slice(0, -suffix.length));
|
|
9180
9765
|
}
|
|
@@ -9232,10 +9817,10 @@ function syncPassengerSyncDaemons(plans, cfg, stateDir, deps = {}) {
|
|
|
9232
9817
|
|
|
9233
9818
|
// src/daemon.ts
|
|
9234
9819
|
import { readdirSync as readdirSync6, statSync as statSync7 } from "fs";
|
|
9235
|
-
import { join as
|
|
9820
|
+
import { join as join19 } from "path";
|
|
9236
9821
|
var DEFAULT_FLOOR_MS = 5e3;
|
|
9237
9822
|
var DEFAULT_CEILING_MS = INTERVAL_SECONDS * 1e3;
|
|
9238
|
-
var defaultSleep = (
|
|
9823
|
+
var defaultSleep = (ms2) => new Promise((resolve5) => setTimeout(resolve5, ms2));
|
|
9239
9824
|
async function runDaemonLoop(o) {
|
|
9240
9825
|
const floor = o.floorMs ?? DEFAULT_FLOOR_MS;
|
|
9241
9826
|
const ceiling = o.ceilingMs ?? DEFAULT_CEILING_MS;
|
|
@@ -9271,7 +9856,7 @@ function latestMtime(path) {
|
|
|
9271
9856
|
if (!stat.isDirectory()) return 0;
|
|
9272
9857
|
let latest = stat.mtimeMs;
|
|
9273
9858
|
for (const entry of readdirSync6(path, { withFileTypes: true })) {
|
|
9274
|
-
latest = Math.max(latest, latestMtime(
|
|
9859
|
+
latest = Math.max(latest, latestMtime(join19(path, entry.name)));
|
|
9275
9860
|
}
|
|
9276
9861
|
return latest;
|
|
9277
9862
|
}
|
|
@@ -9338,6 +9923,24 @@ async function runOnePass(o) {
|
|
|
9338
9923
|
const tracker = new Tracker(o.route, o.ship);
|
|
9339
9924
|
await applyHoldReleases(tracker, decision.holdReleases, tracker.contract, emit);
|
|
9340
9925
|
}
|
|
9926
|
+
{
|
|
9927
|
+
const tracker = new Tracker(o.route, o.ship);
|
|
9928
|
+
const memberId = o.route.resolved?.seats.qa ?? o.route.resolved?.seats.dev;
|
|
9929
|
+
try {
|
|
9930
|
+
if (memberId) await runStalledSweep({
|
|
9931
|
+
tracker,
|
|
9932
|
+
state: o.state,
|
|
9933
|
+
emit,
|
|
9934
|
+
route: o.route,
|
|
9935
|
+
ship: o.ship,
|
|
9936
|
+
ships: decision.ships,
|
|
9937
|
+
memberId,
|
|
9938
|
+
myShipId: shipIdByName(decision.ships, o.ship.name)
|
|
9939
|
+
}, decision.stalled ?? []);
|
|
9940
|
+
} catch (e) {
|
|
9941
|
+
emit.warn(`could not sweep stalled tickets: ${e.message}`, { step: "sweep" });
|
|
9942
|
+
}
|
|
9943
|
+
}
|
|
9341
9944
|
const order = [...decision.selection.pending].sort(
|
|
9342
9945
|
(a, b) => (decision.selection.ranks[a] ?? Number.POSITIVE_INFINITY) - (decision.selection.ranks[b] ?? Number.POSITIVE_INFINITY)
|
|
9343
9946
|
);
|
|
@@ -9499,6 +10102,7 @@ async function runRoleAgent(role, decision, ctx) {
|
|
|
9499
10102
|
}
|
|
9500
10103
|
try {
|
|
9501
10104
|
await spawnAgent(plan, emit);
|
|
10105
|
+
await guardRunHandoff(route, ship, tracker, role, workingId, emit);
|
|
9502
10106
|
} finally {
|
|
9503
10107
|
if (memberId) {
|
|
9504
10108
|
try {
|
|
@@ -9536,7 +10140,7 @@ async function buildEnvironment(route, ship, ticket) {
|
|
|
9536
10140
|
}
|
|
9537
10141
|
|
|
9538
10142
|
// src/cli.ts
|
|
9539
|
-
var CREW_HOME = isCompiledBinary(import.meta.url) ?
|
|
10143
|
+
var CREW_HOME = isCompiledBinary(import.meta.url) ? dirname11(process.execPath) : resolve4(dirname11(fileURLToPath(import.meta.url)), "..");
|
|
9540
10144
|
var RELEASE_LOCK_TTL_MS = 20 * 60 * 1e3;
|
|
9541
10145
|
var CYCLE_SECONDS2 = 120;
|
|
9542
10146
|
var STALE_CYCLES = 3;
|
|
@@ -9661,6 +10265,7 @@ function usage() {
|
|
|
9661
10265
|
crew reap [route] kill orphaned servers, drop worktrees for closed tickets
|
|
9662
10266
|
crew drop [route] NNN remove a merged ticket's worktree and branch
|
|
9663
10267
|
crew rotate-passenger-url [route] force a Host Passengers tunnel to reconnect with a fresh public URL
|
|
10268
|
+
crew worktree [route] NNN cut (or refresh) this run's worktree for a ticket from the remote; prints its path
|
|
9664
10269
|
crew unassign [route] NNN hand back a session's ticket \u2014 clears assignee, next cycle picks it up
|
|
9665
10270
|
crew sync [route] fast-forward the checkout and its worktrees from the remote
|
|
9666
10271
|
crew pause|resume [route] [R|release] pause everything, one role, or releases only (merge/deploy/release; passengers keeps running).
|
|
@@ -10025,8 +10630,8 @@ if (command === "passenger-sync-daemon") {
|
|
|
10025
10630
|
}
|
|
10026
10631
|
if (host === "linux" && hasSystemd) {
|
|
10027
10632
|
const label = labelFor(CREW_HOME, "run");
|
|
10028
|
-
const staleTimer =
|
|
10029
|
-
if (
|
|
10633
|
+
const staleTimer = join20(homedir5(), ".config", "systemd", "user", `${label}.timer`);
|
|
10634
|
+
if (existsSync20(staleTimer)) {
|
|
10030
10635
|
process.stdout.write(
|
|
10031
10636
|
`removing stale run timer from before the persistent-service cutover: ${staleTimer}${dryRun ? " (dry run)" : ""}
|
|
10032
10637
|
`
|
|
@@ -10052,7 +10657,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10052
10657
|
const tracker = new Tracker(c, cfg.ship);
|
|
10053
10658
|
problems.push(...validateContract(tracker.contract).map((p) => `contract: ${p}`));
|
|
10054
10659
|
for (const r of await resolvedRepos(c)) {
|
|
10055
|
-
if (!
|
|
10660
|
+
if (!existsSync20(r.dir)) problems.push(`repo ${r.name}: ${r.dir} is missing on disk`);
|
|
10056
10661
|
if (!satisfies(host, r.config.platform)) problems.push(`repo ${r.name}: requires ${r.config.platform}, this host is ${host}`);
|
|
10057
10662
|
problems.push(...validateEffective(r.config).map((p) => `repo ${r.name}: ${p}`));
|
|
10058
10663
|
}
|
|
@@ -10070,7 +10675,7 @@ if (command === "passenger-sync-daemon") {
|
|
|
10070
10675
|
}
|
|
10071
10676
|
async function doctorFix(o) {
|
|
10072
10677
|
const routes = await Promise.all(routesInScope(cfg.routes, named).map((c) => gatherHealth({ route: c.route, enabled: c.enabled, hasRepos: reposOf(c).length > 0 }, () => routeProblems(c))));
|
|
10073
|
-
const unitExists = (p) =>
|
|
10678
|
+
const unitExists = (p) => existsSync20(p);
|
|
10074
10679
|
const crontab = o.host === "linux" ? spawnSync3("crontab", ["-l"], { encoding: "utf8" }).stdout ?? "" : "";
|
|
10075
10680
|
const schedulerAbsent = o.host !== "windows" ? !schedulerInstalled(planInstall(cfg.ship, CREW_HOME, o.host, detectSystemd(), "run"), unitExists, crontab) : true;
|
|
10076
10681
|
const plan = planFix({ routes, schedulerAbsent, host: o.host });
|
|
@@ -10213,6 +10818,10 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10213
10818
|
return { scope, tests: "skipped", outcome: "skipped", detail: `release ${why} on the board` };
|
|
10214
10819
|
}
|
|
10215
10820
|
boardLock = got;
|
|
10821
|
+
remit.emit(
|
|
10822
|
+
got.exclusion === "taken" ? `took board lock ${scope}` : `this board has no Locks table \u2014 cross-ship exclusion for ${scope} is OFF`,
|
|
10823
|
+
{ step: "release" }
|
|
10824
|
+
);
|
|
10216
10825
|
}
|
|
10217
10826
|
const all = await tracker.openTickets();
|
|
10218
10827
|
const { byRepo, unplaceable } = ticketsByRepo(c, all);
|
|
@@ -10226,6 +10835,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10226
10835
|
emit: remit,
|
|
10227
10836
|
scope,
|
|
10228
10837
|
state: state.release(scope),
|
|
10838
|
+
reviewColumns: repo.release.mode === "external" ? await tracker.reviewColumns() : void 0,
|
|
10229
10839
|
dryRun,
|
|
10230
10840
|
skipTests: flag("skip-tests"),
|
|
10231
10841
|
shell: cfg.ship.shell,
|
|
@@ -10288,6 +10898,18 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10288
10898
|
if (outcome.externalClosures?.length) {
|
|
10289
10899
|
await applyExternalClosures(tracker, outcome.externalClosures, tracker.contract, remit, dryRun, target.dir);
|
|
10290
10900
|
}
|
|
10901
|
+
if (outcome.review) {
|
|
10902
|
+
await applyReview(
|
|
10903
|
+
tracker,
|
|
10904
|
+
tracker.contract,
|
|
10905
|
+
outcome.review,
|
|
10906
|
+
repo.branch.remote,
|
|
10907
|
+
remit,
|
|
10908
|
+
releaseSeat(c),
|
|
10909
|
+
await tracker.reviewColumns(),
|
|
10910
|
+
dryRun
|
|
10911
|
+
);
|
|
10912
|
+
}
|
|
10291
10913
|
const news = describeRelease(outcome, scope);
|
|
10292
10914
|
if (news) {
|
|
10293
10915
|
const notified = await notify(c, cfg.ship, news, remit, dryRun);
|
|
@@ -10594,6 +11216,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10594
11216
|
}
|
|
10595
11217
|
try {
|
|
10596
11218
|
await spawnAgent(fleetPlan, wemit);
|
|
11219
|
+
await guardRunHandoff(w.route, cfg.ship, fleetTracker, w.role, fleetWorkingId, wemit);
|
|
10597
11220
|
} finally {
|
|
10598
11221
|
dropLock();
|
|
10599
11222
|
if (fleetMemberId) {
|
|
@@ -10804,6 +11427,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10804
11427
|
}
|
|
10805
11428
|
try {
|
|
10806
11429
|
await spawnAgent(plan, emit);
|
|
11430
|
+
await guardRunHandoff(route, cfg.ship, tracker2, current, workingId, emit);
|
|
10807
11431
|
} finally {
|
|
10808
11432
|
dropLock();
|
|
10809
11433
|
if (memberId) {
|
|
@@ -10901,7 +11525,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
10901
11525
|
process.exit(1);
|
|
10902
11526
|
}
|
|
10903
11527
|
requireArmed("run the daemon loop");
|
|
10904
|
-
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [
|
|
11528
|
+
const watchPaths = isCompiledBinary(import.meta.url) ? [process.execPath] : [join20(CREW_HOME, "src"), join20(CREW_HOME, "dist"), join20(CREW_HOME, "bin")];
|
|
10905
11529
|
const spawnBreaker = new SpawnBreaker();
|
|
10906
11530
|
const stopReason = await runDaemonLoop({
|
|
10907
11531
|
runPass: () => runOnePass({
|
|
@@ -11049,7 +11673,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11049
11673
|
`);
|
|
11050
11674
|
if (!dryRun) {
|
|
11051
11675
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
11052
|
-
const raw =
|
|
11676
|
+
const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
|
|
11053
11677
|
raw.agentPersonas = personaResult.agentPersonas;
|
|
11054
11678
|
raw.agentSkills = skillResult.agentSkills;
|
|
11055
11679
|
mkdirSync11(dirOf(resolvedPath), { recursive: true });
|
|
@@ -11098,7 +11722,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11098
11722
|
`);
|
|
11099
11723
|
if (!dryRun) {
|
|
11100
11724
|
const resolvedPath = resolvedPathFor(cfg.ship.stateDir, target.route);
|
|
11101
|
-
const raw =
|
|
11725
|
+
const raw = existsSync20(resolvedPath) ? JSON.parse(readFileSync14(resolvedPath, "utf8")) : {};
|
|
11102
11726
|
raw.agentSkills = result.agentSkills;
|
|
11103
11727
|
mkdirSync11(dirOf(resolvedPath), { recursive: true });
|
|
11104
11728
|
writeFileSync9(resolvedPath, `${JSON.stringify(raw, null, 2)}
|
|
@@ -11121,7 +11745,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11121
11745
|
if (sub === "list" && !routeName) usage2();
|
|
11122
11746
|
if (sub === "add") {
|
|
11123
11747
|
try {
|
|
11124
|
-
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) =>
|
|
11748
|
+
const a = parseAddArgs(positional.slice(2), routeNamesOf(configText), (p) => existsSync20(resolvePath(p.replace(/^~(?=\/|$)/, homedir5()))));
|
|
11125
11749
|
routeName = a.route;
|
|
11126
11750
|
addPath = a.path;
|
|
11127
11751
|
addName = a.name;
|
|
@@ -11136,7 +11760,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11136
11760
|
}
|
|
11137
11761
|
const resolvedFile = resolvedPathFor(cfg.ship.stateDir, routeName);
|
|
11138
11762
|
let resolvedRemotes;
|
|
11139
|
-
if (
|
|
11763
|
+
if (existsSync20(resolvedFile)) {
|
|
11140
11764
|
try {
|
|
11141
11765
|
resolvedRemotes = JSON.parse(readFileSync14(resolvedFile, "utf8"));
|
|
11142
11766
|
} catch {
|
|
@@ -11144,7 +11768,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11144
11768
|
}
|
|
11145
11769
|
const originOf = (dir) => gitOk(dir, ["remote", "get-url", "origin"]) ?? void 0;
|
|
11146
11770
|
const text = configText;
|
|
11147
|
-
const base =
|
|
11771
|
+
const base = dirname11(cfg.configFile);
|
|
11148
11772
|
try {
|
|
11149
11773
|
if (sub === "list") {
|
|
11150
11774
|
const repos = configuredRepos(text, routeName, base);
|
|
@@ -11153,14 +11777,14 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11153
11777
|
for (const r of repos) {
|
|
11154
11778
|
const origin2 = originOf(r.dir);
|
|
11155
11779
|
const row = matchRepoName(origin2, resolvedRemotes);
|
|
11156
|
-
const note = !
|
|
11780
|
+
const note = !existsSync20(r.dir) ? "missing on disk" : row ? `Repos row: ${row}` : `no Repos row matches ${normalizeRemote(origin2) ?? "this checkout (no origin remote)"}`;
|
|
11157
11781
|
process.stdout.write(`${r.name} ${r.dir} ${note}
|
|
11158
11782
|
`);
|
|
11159
11783
|
}
|
|
11160
11784
|
break;
|
|
11161
11785
|
}
|
|
11162
11786
|
const target = resolvePath(addPath.replace(/^~(?=\/|$)/, homedir5()));
|
|
11163
|
-
const top =
|
|
11787
|
+
const top = existsSync20(target) ? gitOk(target, ["rev-parse", "--show-toplevel"]) : null;
|
|
11164
11788
|
if (!top) throw new ReposError(`${target} is not a git checkout`);
|
|
11165
11789
|
const origin = originOf(top);
|
|
11166
11790
|
const plan = planRepoAdd({
|
|
@@ -11208,7 +11832,7 @@ ${changed2.map((c) => ` - ${c}
|
|
|
11208
11832
|
const created = await rowTracker.createRepoRow(rowPayload);
|
|
11209
11833
|
process.stdout.write(`created Repos row ${String(rowPayload.name)} (${String(rowPayload.remote)}) ${created.id}
|
|
11210
11834
|
`);
|
|
11211
|
-
if (
|
|
11835
|
+
if (existsSync20(resolvedFile)) {
|
|
11212
11836
|
const raw = JSON.parse(readFileSync14(resolvedFile, "utf8"));
|
|
11213
11837
|
raw.repoNames = { ...raw.repoNames, [created.id]: String(rowPayload.name) };
|
|
11214
11838
|
raw.repoRemotes = { ...raw.repoRemotes, [created.id]: String(rowPayload.remote) };
|
|
@@ -11453,7 +12077,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11453
12077
|
operator = await pickOperator(found.holds);
|
|
11454
12078
|
}
|
|
11455
12079
|
let previous;
|
|
11456
|
-
const hadPreviousFile =
|
|
12080
|
+
const hadPreviousFile = existsSync20(resolvedPath);
|
|
11457
12081
|
if (hadPreviousFile) {
|
|
11458
12082
|
try {
|
|
11459
12083
|
previous = JSON.parse(readFileSync14(resolvedPath, "utf8"));
|
|
@@ -11517,7 +12141,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11517
12141
|
process.stderr.write(`(dry run) would write resolved ids to ${resolvedPath}
|
|
11518
12142
|
`);
|
|
11519
12143
|
} else {
|
|
11520
|
-
mkdirSync11(
|
|
12144
|
+
mkdirSync11(dirname11(resolvedPath), { recursive: true });
|
|
11521
12145
|
if (hadPreviousFile) copyFileSync2(resolvedPath, `${resolvedPath}.bak`);
|
|
11522
12146
|
const tmpPath = `${resolvedPath}.tmp${process.pid}`;
|
|
11523
12147
|
writeFileSync9(tmpPath, `${JSON.stringify(resolved, null, 2)}
|
|
@@ -11534,7 +12158,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11534
12158
|
process.stderr.write(`(dry run) would write the minted API key to ${apiKeyFile}
|
|
11535
12159
|
`);
|
|
11536
12160
|
} else {
|
|
11537
|
-
mkdirSync11(
|
|
12161
|
+
mkdirSync11(dirname11(apiKeyFile), { recursive: true });
|
|
11538
12162
|
writeFileSync9(apiKeyFile, `${apiKeyVar}=${apiKey}
|
|
11539
12163
|
`, { mode: 384 });
|
|
11540
12164
|
process.stderr.write(`API key written to ${apiKeyFile}
|
|
@@ -11544,7 +12168,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11544
12168
|
const keyPersisted = keychainBacked || apiKeyFile !== void 0;
|
|
11545
12169
|
const wizard = firstRun;
|
|
11546
12170
|
const writeFirstRun = wizard !== void 0 && !dryRun && keyPersisted;
|
|
11547
|
-
const routeDir = value("dir") ?? (writeFirstRun ?
|
|
12171
|
+
const routeDir = value("dir") ?? (writeFirstRun ? join20(DEFAULT_REPOS_BASE_PATH, found.workspaceSlug) : void 0);
|
|
11548
12172
|
const routeBlock = renderConnection(
|
|
11549
12173
|
found,
|
|
11550
12174
|
route2,
|
|
@@ -11859,6 +12483,40 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
11859
12483
|
emit.emit(`rotated the passenger tunnel for ${route.route} \u2014 it reconnects under a fresh slug and the registered endpoint follows on the next passengers cycle`);
|
|
11860
12484
|
break;
|
|
11861
12485
|
}
|
|
12486
|
+
case "worktree": {
|
|
12487
|
+
emit.enter("worktree");
|
|
12488
|
+
const rawWt = positional[2] ?? positional[1];
|
|
12489
|
+
const nWt = rawWt ? parseTicketNumber(rawWt) : null;
|
|
12490
|
+
if (!nWt) {
|
|
12491
|
+
process.stderr.write("worktree: need a ticket number (326, ISSUE-326, or TABL-326)\n");
|
|
12492
|
+
process.exit(2);
|
|
12493
|
+
}
|
|
12494
|
+
const trackerWt = new Tracker(route, cfg.ship);
|
|
12495
|
+
const cWt = trackerWt.contract;
|
|
12496
|
+
const ticketWt = (await trackerWt.openTickets()).find((t) => t[cWt.columns.key] === `ISSUE-${nWt}`);
|
|
12497
|
+
if (!ticketWt) {
|
|
12498
|
+
process.stderr.write(`worktree: no open ticket ISSUE-${nWt} on ${route.route}
|
|
12499
|
+
`);
|
|
12500
|
+
process.exit(2);
|
|
12501
|
+
}
|
|
12502
|
+
const repoWt = (await resolvedRepos(route)).find((r) => r.id === ticketWt.repo_id) ?? (await resolvedRepos(route))[0];
|
|
12503
|
+
if (!repoWt) {
|
|
12504
|
+
process.stderr.write("worktree: this route has no repository\n");
|
|
12505
|
+
process.exit(2);
|
|
12506
|
+
}
|
|
12507
|
+
const roleWt = positional.find((p) => p === "dev" || p === "design" || p === "qa");
|
|
12508
|
+
const planWt = planRunWorktree(repoWt.dir, repoWt.config, ticketWt, roleWt);
|
|
12509
|
+
if (dryRun) {
|
|
12510
|
+
emit.emit(`would ${describeRunWorktreePlan(planWt)}`);
|
|
12511
|
+
break;
|
|
12512
|
+
}
|
|
12513
|
+
const pathWt = applyRunWorktree(repoWt.dir, repoWt.config, planWt);
|
|
12514
|
+
emit.emit(describeRunWorktreePlan(planWt));
|
|
12515
|
+
if (pathWt) process.stdout.write(`${pathWt}
|
|
12516
|
+
`);
|
|
12517
|
+
if (!planIsUsable(planWt)) process.exitCode = 1;
|
|
12518
|
+
break;
|
|
12519
|
+
}
|
|
11862
12520
|
case "unassign": {
|
|
11863
12521
|
emit.enter("select");
|
|
11864
12522
|
const rawUnassign = positional[2] ?? positional[1];
|
|
@@ -12027,6 +12685,7 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12027
12685
|
const engagedTicketIds = shipRows.map((s) => s.engaged_ticket_id).filter((id) => !!id);
|
|
12028
12686
|
const engagedTickets = engagedTicketIds.length ? await statusTracker.ticketsByIds(engagedTicketIds) : [];
|
|
12029
12687
|
const issueLabelById = new Map(engagedTickets.map((t) => [t.id, t.issue_id]));
|
|
12688
|
+
const todo = await statusTracker.openTickets().then((open) => operatorTodo(open, statusTracker.contract, route.resolved?.operator ?? "")).catch(() => []);
|
|
12030
12689
|
if (flag("json")) {
|
|
12031
12690
|
const repos = await resolvedRepos(route);
|
|
12032
12691
|
const seatIds = route.resolved?.seats ?? {};
|
|
@@ -12063,6 +12722,8 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12063
12722
|
// derived `release_stale`. `version` stays 1.
|
|
12064
12723
|
attention: openShipAttention(state)
|
|
12065
12724
|
},
|
|
12725
|
+
// Additive (CREW-1402): what the operator owes. `version` stays 1.
|
|
12726
|
+
todo: todo.map((i) => ({ ticket: displayKey(i.ticket), title: i.ticket.title ?? null, reasons: i.reasons })),
|
|
12066
12727
|
crew: {
|
|
12067
12728
|
paused: state.isPaused(),
|
|
12068
12729
|
// The release timer honors the whole-crew pause too, so this is
|
|
@@ -12109,6 +12770,15 @@ Signed in as ${result.identity.email} \u2014 minted an API key in workspace "${r
|
|
|
12109
12770
|
`);
|
|
12110
12771
|
break;
|
|
12111
12772
|
}
|
|
12773
|
+
const owed = [
|
|
12774
|
+
...todo.map((i) => `${displayKey(i.ticket)} \u2014 ${i.reasons.join(", ")}${i.ticket.title ? ` \u2014 ${i.ticket.title}` : ""}`),
|
|
12775
|
+
...openShipAttention(state).map((i) => `ship ${cfg.ship.name}: ${i.kind} \u2014 ${i.message}`),
|
|
12776
|
+
...shipRows.filter((s) => s.name !== cfg.ship.name).flatMap((s) => (s.attention ?? []).map((i) => `ship ${s.name}: ${i.kind} \u2014 ${i.message}`))
|
|
12777
|
+
];
|
|
12778
|
+
if (owed.length) process.stdout.write(`to do (${owed.length}):
|
|
12779
|
+
${owed.map((l) => ` ${l}
|
|
12780
|
+
`).join("")}
|
|
12781
|
+
`);
|
|
12112
12782
|
process.stdout.write(
|
|
12113
12783
|
`ship: ${cfg.ship.name} (${cfg.ship.platform})
|
|
12114
12784
|
route: ${route.route} -> ${route.dir}
|