@tablation/crew 0.127.10 → 0.128.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -8280,897 +8280,371 @@ var BeforeFirstEditTracker = class {
8280
8280
  };
8281
8281
 
8282
8282
  // src/agent-log.ts
8283
- import { existsSync as existsSync21, mkdirSync as mkdirSync15, readdirSync as readdirSync6, readFileSync as readFileSync22, rmSync as rmSync12, writeFileSync as writeFileSync16 } from "fs";
8284
- import { join as join23 } from "path";
8283
+ import { existsSync as existsSync23, mkdirSync as mkdirSync16, readdirSync as readdirSync7, readFileSync as readFileSync23, rmSync as rmSync13, writeFileSync as writeFileSync17 } from "fs";
8284
+ import { join as join25 } from "path";
8285
8285
 
8286
8286
  // src/pending-backoff.ts
8287
8287
  var sleepBackoff = (attempts) => Math.min(6e4 * 2 ** Math.max(0, attempts - 1), 30 * 6e4);
8288
8288
 
8289
- // src/agent-log.ts
8290
- var AgentLogUnreachableError = class extends Error {
8291
- };
8292
- var AgentLogHttpError = class extends Error {
8293
- status;
8294
- /** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
8295
- row = false;
8296
- constructor(message, status2) {
8297
- super(message);
8298
- this.status = status2;
8289
+ // src/ticket-branch.ts
8290
+ import { basename as basename7 } from "path";
8291
+
8292
+ // src/tracker.ts
8293
+ import { hostname } from "os";
8294
+ import { TablationClient } from "@tablation/client";
8295
+
8296
+ // src/work-mode.ts
8297
+ function projectWorkModes(rows, contract) {
8298
+ const out = /* @__PURE__ */ new Map();
8299
+ for (const r of rows) {
8300
+ const v = r[contract.columns.workMode];
8301
+ out.set(r.id, v === contract.manualWorkMode ? "manual" : v === contract.hybridWorkMode ? "hybrid" : "automatic");
8299
8302
  }
8300
- };
8301
- async function request(target, method, path, body) {
8302
- const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
8303
- const res = await fetch(url, {
8304
- method,
8305
- headers: {
8306
- Authorization: `Bearer ${target.apiKey}`,
8307
- "User-Agent": target.userAgent,
8308
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
8309
- },
8310
- body: body === void 0 ? void 0 : JSON.stringify(body)
8311
- });
8312
- if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
8313
- return await res.json();
8303
+ return out;
8314
8304
  }
8315
- var issueColumns = /* @__PURE__ */ new Map();
8316
- function columnsOf(target, modelId) {
8317
- const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
8318
- let p = issueColumns.get(key);
8319
- if (!p) {
8320
- p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
8321
- p.catch(() => issueColumns.delete(key));
8322
- issueColumns.set(key, p);
8323
- }
8324
- return p;
8305
+ function agentAssigneeIds(crewRows, isHoldRow) {
8306
+ return new Set(crewRows.filter((r) => !!r.agent_id && !isHoldRow(r.id)).map((r) => r.id));
8325
8307
  }
8326
- async function findTicketRecord(target, t, ref) {
8327
- const filters = {
8328
- combinator: "OR",
8329
- conditions: [
8330
- { columnName: t.keyColumn, operator: "EQ", value: ref },
8331
- { columnName: t.tagColumn, operator: "EQ", value: ref }
8332
- ]
8333
- };
8334
- const rows = await request(
8335
- target,
8336
- "GET",
8337
- `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
8338
- );
8339
- return rows.length === 1 ? rows[0].id : void 0;
8308
+ function modeOf(t, wm) {
8309
+ if (!t.project_id) return "manual";
8310
+ return wm.modes.get(t.project_id) ?? "automatic";
8340
8311
  }
8341
- function counterIncrements(run3, cols, have) {
8342
- const figures = [
8343
- [cols.agentRuns, 1],
8344
- [cols.tokensIn, run3.tokensIn],
8345
- [cols.tokensOut, run3.tokensOut],
8346
- [cols.cacheReadTokens, run3.cacheReadTokens],
8347
- [cols.cacheWriteTokens, run3.cacheWriteTokens],
8348
- [cols.costUsd, run3.costUsd]
8349
- ];
8350
- const out = {};
8351
- for (const [column, value2] of figures) {
8352
- if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
8312
+ function withinWorkMode(tickets, wm) {
8313
+ if (!wm || wm.modes.size === 0) return tickets;
8314
+ return tickets.filter((t) => {
8315
+ const mode = modeOf(t, wm);
8316
+ if (mode === "automatic") return true;
8317
+ if (mode === "manual") return false;
8318
+ return !!t.assignee_id && wm.agentAssignees.has(t.assignee_id);
8319
+ });
8320
+ }
8321
+ function isAutomatic(t, wm) {
8322
+ if (!wm || wm.modes.size === 0) return true;
8323
+ return modeOf(t, wm) === "automatic";
8324
+ }
8325
+ function skippedByMode(tickets, wm) {
8326
+ const out = { manual: 0, hybrid: 0 };
8327
+ if (!wm || wm.modes.size === 0) return out;
8328
+ const kept = new Set(withinWorkMode(tickets, wm));
8329
+ for (const t of tickets) {
8330
+ if (kept.has(t)) continue;
8331
+ out[modeOf(t, wm) === "manual" ? "manual" : "hybrid"]++;
8353
8332
  }
8354
8333
  return out;
8355
8334
  }
8356
- var BEFORE_EDIT_COLUMNS = {
8357
- search: "calls_before_edit_search",
8358
- symbol: "calls_before_edit_symbol",
8359
- other: "calls_before_edit_other",
8360
- seconds: "seconds_to_first_edit",
8361
- edited: "edited"
8362
- };
8363
- function beforeFirstEditBody(b) {
8335
+ function withoutManualVerified(tickets, verifiedStatus, wm) {
8336
+ if (!wm || wm.modes.size === 0) return tickets;
8337
+ return tickets.filter((t) => t.status !== verifiedStatus || modeOf(t, wm) !== "manual");
8338
+ }
8339
+ function projectModeLabels(rows) {
8340
+ return rows.map((r) => {
8341
+ const name = r.name ?? r.id;
8342
+ return { id: r.id, name, mode: r.mode, label: `${name} (${r.mode})` };
8343
+ });
8344
+ }
8345
+
8346
+ // src/board-lock.ts
8347
+ import { StaleWriteError } from "@tablation/client";
8348
+ function releaseLockScope(route2, repo) {
8349
+ return `${route2}/${repo}`;
8350
+ }
8351
+ async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs, log = {}) {
8352
+ if (!modelId) return { ok: true, release: async () => {
8353
+ }, exclusion: "off" };
8354
+ const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
8355
+ const rows = await client2.list(modelId, { filters, limit: 1 });
8356
+ const row = rows[0];
8357
+ if (!row) return { ok: false, reason: "missing" };
8358
+ const age = Date.now() - new Date(row.updated_at).getTime();
8359
+ const staleOwn = !!row.holder && row.holder !== holderLabel && isDeadOwnClaim(row.holder, holderLabel);
8360
+ const free = !row.holder || row.holder === holderLabel || age > ttlMs || staleOwn;
8361
+ if (staleOwn) log.warn?.(`reclaimed stale own claim ${row.holder} on ${scope} (process is gone)`);
8362
+ if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
8363
+ let claimed;
8364
+ try {
8365
+ claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
8366
+ } catch (e) {
8367
+ if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
8368
+ throw e;
8369
+ }
8364
8370
  return {
8365
- [BEFORE_EDIT_COLUMNS.search]: b.calls.search,
8366
- [BEFORE_EDIT_COLUMNS.symbol]: b.calls.symbol,
8367
- [BEFORE_EDIT_COLUMNS.other]: b.calls.other,
8368
- [BEFORE_EDIT_COLUMNS.edited]: b.edited,
8369
- ...b.secondsToFirstEdit !== void 0 ? { [BEFORE_EDIT_COLUMNS.seconds]: b.secondsToFirstEdit } : {}
8371
+ ok: true,
8372
+ exclusion: "taken",
8373
+ release: async () => {
8374
+ try {
8375
+ try {
8376
+ await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
8377
+ } catch (e) {
8378
+ if (!(e instanceof StaleWriteError)) throw e;
8379
+ const [now] = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
8380
+ if (now?.holder === holderLabel) await client2.update(modelId, row.id, { holder: "" }, now.updated_at);
8381
+ }
8382
+ log.debug?.(`lock released for ${scope}`);
8383
+ } catch (e) {
8384
+ log.warn?.(`lock release failed for ${scope}: ${e.message}`);
8385
+ }
8386
+ }
8370
8387
  };
8371
8388
  }
8372
- async function writeBeforeFirstEdit(target, rowId, b) {
8389
+ function isDeadOwnClaim(holder, mine) {
8390
+ const split = (l) => {
8391
+ const i = l.lastIndexOf(":");
8392
+ return i < 0 ? null : { ship: l.slice(0, i), pid: Number(l.slice(i + 1)) };
8393
+ };
8394
+ const h = split(holder), m = split(mine);
8395
+ if (!h || !m || h.ship !== m.ship || !Number.isInteger(h.pid) || h.pid <= 0) return false;
8373
8396
  try {
8374
- const model = await request(
8375
- target,
8376
- "GET",
8377
- `/data-models/agent_log?workspaceId=${encodeURIComponent(target.workspaceId)}`
8378
- );
8379
- const have = await columnsOf(target, model.id);
8380
- const body = Object.fromEntries(Object.entries(beforeFirstEditBody(b)).filter(([column]) => have.has(column)));
8381
- if (Object.keys(body).length === 0) return;
8382
- await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, body);
8383
- } catch {
8397
+ process.kill(h.pid, 0);
8398
+ return false;
8399
+ } catch (e) {
8400
+ return e.code === "ESRCH";
8384
8401
  }
8385
8402
  }
8386
- async function post(target, path, body) {
8387
- const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
8388
- const res = await fetch(url, {
8389
- method: "POST",
8390
- headers: {
8391
- Authorization: `Bearer ${target.apiKey}`,
8392
- // Cloudflare 403s default agents on this host — same header connect.ts sends.
8393
- "User-Agent": target.userAgent,
8394
- "Content-Type": "application/json"
8395
- },
8396
- body: JSON.stringify(body)
8397
- });
8398
- if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
8399
- return await res.json();
8403
+ async function lockRowState(client2, modelId, scope) {
8404
+ const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
8405
+ const row = rows[0];
8406
+ if (!row) return { exists: false };
8407
+ return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
8400
8408
  }
8401
- async function reportAgentRun(target, run3) {
8402
- let ticketRecordId;
8403
- if (target.tickets && run3.ticketReference) {
8409
+ var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
8410
+ async function ensureLockRows(client2, modelId, scopes) {
8411
+ const created = [];
8412
+ for (const scope of scopes) {
8413
+ const filters = scopeFilter(scope);
8414
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
8415
+ let mine;
8404
8416
  try {
8405
- ticketRecordId = await findTicketRecord(target, target.tickets, run3.ticketReference);
8406
- } catch {
8407
- }
8408
- }
8409
- const body = {
8410
- agentId: target.agentId,
8411
- ticketReference: run3.ticketReference,
8412
- outcome: run3.outcome,
8413
- startedAt: run3.startedAt,
8414
- finishedAt: run3.finishedAt,
8415
- source: "client",
8416
- client: "crew",
8417
- model: run3.model,
8418
- tokensIn: run3.tokensIn,
8419
- tokensOut: run3.tokensOut,
8420
- cacheReadTokens: run3.cacheReadTokens,
8421
- cacheWriteTokens: run3.cacheWriteTokens,
8422
- costUsd: run3.costUsd,
8423
- numTurns: run3.numTurns,
8424
- prefixTokens: run3.prefixTokens,
8425
- promptVersion: run3.promptVersion,
8426
- promptSha: run3.promptSha
8427
- };
8428
- const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
8429
- let entry;
8430
- try {
8431
- entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run3.harness, provider: run3.provider });
8432
- } catch (e) {
8433
- if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
8434
- if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
8435
- if (e.status !== 400 || !run3.harness && !run3.provider && !ticketRecordId) {
8436
- e.row = true;
8417
+ mine = await client2.create(modelId, { scope, holder: "" });
8418
+ } catch (e) {
8419
+ const status2 = e.status;
8420
+ if (status2 === 409 || status2 === 400) {
8421
+ if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
8422
+ }
8437
8423
  throw e;
8438
8424
  }
8439
- try {
8440
- entry = await post(target, "/agents/log", body);
8441
- } catch (e2) {
8442
- if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
8443
- throw e2;
8444
- }
8445
- }
8446
- for (const cycle of run3.cycles) {
8447
- await post(target, `/agents/log/${entry.id}/cycles`, {
8448
- cycleIndex: cycle.cycleIndex,
8449
- occurredAt: cycle.occurredAt,
8450
- thinking: cycle.thinking,
8451
- ...cycle.role ? { role: cycle.role } : {}
8452
- });
8453
- }
8454
- if (run3.beforeFirstEdit) await writeBeforeFirstEdit(target, entry.id, run3.beforeFirstEdit);
8455
- const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run3) : void 0;
8456
- return {
8457
- agentLogId: entry.id,
8458
- ...counters?.warning ? { counterWarning: counters.warning } : {},
8459
- ...counters?.retry ? { counterRetry: counters.retry } : {}
8460
- };
8461
- }
8462
- var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
8463
- async function writeCounters(target, w) {
8464
- const have = await columnsOf(target, w.issuesModelId);
8465
- const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
8466
- if (Object.keys(body).length === 0) return;
8467
- await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
8468
- }
8469
- async function addRunToTicket(target, t, recordId, run3) {
8470
- const write = {
8471
- issuesModelId: t.issuesModelId,
8472
- recordId,
8473
- ticketReference: run3.ticketReference,
8474
- counters: t.counters,
8475
- body: counterIncrements(run3, t.counters, new Set(Object.values(t.counters)))
8476
- };
8477
- try {
8478
- await writeCounters(target, write);
8479
- return {};
8480
- } catch (e) {
8481
- return {
8482
- warning: `could not add run spend to ${run3.ticketReference}: ${e.message}`,
8483
- ...isRetryable(e) ? { retry: write } : {}
8484
- };
8485
- }
8486
- }
8487
- var queueDir = (stateDir) => join23(stateDir, "pending-agent-logs");
8488
- function queueAgentRun(stateDir, name, q2) {
8489
- mkdirSync15(queueDir(stateDir), { recursive: true });
8490
- writeFileSync16(join23(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
8491
- }
8492
- async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
8493
- const dir = queueDir(stateDir);
8494
- if (!existsSync21(dir)) return 0;
8495
- let sent = 0;
8496
- for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
8497
- const path = join23(dir, f);
8498
- let q2;
8499
- try {
8500
- q2 = JSON.parse(readFileSync22(path, "utf8"));
8501
- } catch {
8502
- continue;
8503
- }
8504
- if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
8505
- try {
8506
- const apiKey = await keyFor(q2.role);
8507
- if (!apiKey) continue;
8508
- const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
8509
- if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
8510
- if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
8511
- version: 1,
8512
- route: q2.route,
8513
- role: q2.role,
8514
- target: q2.target,
8515
- write: sentRun.counterRetry,
8516
- attempts: 0,
8517
- nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
8425
+ const all = await client2.list(modelId, { filters, limit: 20 });
8426
+ const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
8427
+ if (keeper && keeper.id !== mine.id) {
8428
+ await client2.remove(modelId, mine.id).catch(() => {
8518
8429
  });
8519
- rmSync12(path, { force: true });
8520
- sent += 1;
8521
- log.emit(`sent a queued Agent Log report (${f})`);
8522
- } catch (e) {
8523
- if (!(e instanceof AgentLogUnreachableError)) {
8524
- rmSync12(path, { force: true });
8525
- log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
8526
- continue;
8527
- }
8528
- const attempts = q2.attempts + 1;
8529
- writeFileSync16(path, JSON.stringify({
8530
- ...q2,
8531
- attempts,
8532
- warned: true,
8533
- nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
8534
- }));
8535
- if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
8536
- }
8537
- }
8538
- return sent;
8539
- }
8540
- var countersDir = (stateDir) => join23(stateDir, "pending-ticket-counters");
8541
- function queueTicketCounters(stateDir, name, q2) {
8542
- mkdirSync15(countersDir(stateDir), { recursive: true });
8543
- writeFileSync16(join23(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
8544
- }
8545
- async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
8546
- const dir = countersDir(stateDir);
8547
- if (!existsSync21(dir)) return 0;
8548
- let sent = 0;
8549
- for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
8550
- const path = join23(dir, f);
8551
- let q2;
8552
- try {
8553
- q2 = JSON.parse(readFileSync22(path, "utf8"));
8554
- } catch {
8555
8430
  continue;
8556
8431
  }
8557
- if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
8558
- try {
8559
- const apiKey = await keyFor(q2.role);
8560
- if (!apiKey) continue;
8561
- await writeCounters({ ...q2.target, apiKey }, q2.write);
8562
- rmSync12(path, { force: true });
8563
- sent += 1;
8564
- log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
8565
- } catch (e) {
8566
- if (!isRetryable(e)) {
8567
- rmSync12(path, { force: true });
8568
- log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
8569
- continue;
8570
- }
8571
- const attempts = q2.attempts + 1;
8572
- writeFileSync16(path, JSON.stringify({
8573
- ...q2,
8574
- attempts,
8575
- warned: true,
8576
- nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
8577
- }));
8578
- if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
8579
- }
8432
+ created.push(scope);
8580
8433
  }
8581
- return sent;
8434
+ return created;
8582
8435
  }
8583
8436
 
8584
- // src/ticket-attribution.ts
8585
- var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
8586
- function ticketMentions(text2) {
8587
- return [...text2.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
8437
+ // src/tracker.ts
8438
+ function ticketBranchContext(t) {
8439
+ const tag = t.issue_tag ?? void 0;
8440
+ if (!tag) return {};
8441
+ const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
8442
+ return { tag, prefix };
8588
8443
  }
8589
- var TicketAttributionTracker = class {
8590
- #tokensByTicket = /* @__PURE__ */ new Map();
8591
- #turns = 0;
8592
- #current;
8593
- /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
8594
- #keyByRecord = /* @__PURE__ */ new Map();
8595
- #lastBy = {};
8444
+ function displayKey(t) {
8445
+ return t.issue_tag ?? t.issue_id;
8446
+ }
8447
+ function referenceKeys(t) {
8448
+ return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
8449
+ }
8450
+ var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
8451
+ var encodeFilters = (f) => JSON.stringify(f);
8452
+ var Tracker = class {
8453
+ // NB: explicit fields, not TypeScript parameter properties — node's
8454
+ // --experimental-strip-types cannot transform those, and the test runner
8455
+ // uses it. The same rule applies everywhere in src/.
8456
+ route;
8457
+ /** This workspace's rules. A ship holds several, one per route. */
8458
+ contract;
8459
+ client;
8460
+ models;
8461
+ userAgent;
8462
+ shipName;
8596
8463
  /**
8597
- * The ticket key most recently named in the transcript, or undefined before any.
8598
- * Only the FIRST key in a turn counts: a closing summary names its own
8599
- * ticket first and then lists others ("Other tickets", "Blocked by", "See
8600
- * also"), and those trailing keys are not what the run worked (CREW-2598).
8464
+ * The once-per-process authorship probe (CREW-1371), memoised as a promise
8465
+ * so concurrent first writes share one round of field-metadata reads.
8601
8466
  */
8602
- get current() {
8603
- return this.#current;
8604
- }
8605
- /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
8606
- note(text2) {
8607
- const mentions = ticketMentions(text2);
8608
- if (mentions.length > 0) this.#current = mentions[0];
8609
- }
8610
- /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
8611
- add(text2, tokens) {
8612
- const mentions = ticketMentions(text2);
8613
- if (mentions.length > 0) this.#current = mentions[0];
8614
- if (this.#current === void 0 || tokens <= 0) return;
8615
- this.#turns++;
8616
- this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
8467
+ authorship;
8468
+ attachments;
8469
+ stateDir;
8470
+ /**
8471
+ * `opts.apiKey` overrides the route's ship key (CREW-1603): a lane's own
8472
+ * writes pass its seat's agent key so the event log and rule context see an
8473
+ * agent, not the ship's human principal.
8474
+ */
8475
+ /** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
8476
+ onReadFailure;
8477
+ constructor(route2, ship, opts = {}) {
8478
+ this.route = route2;
8479
+ if (!route2.resolved) {
8480
+ throw new ConfigError(
8481
+ `route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
8482
+ );
8483
+ }
8484
+ this.models = route2.resolved.models;
8485
+ this.contract = resolveContract(route2.contract);
8486
+ this.userAgent = ship.userAgent;
8487
+ this.shipName = ship.name;
8488
+ this.stateDir = ship.stateDir;
8489
+ this.client = new TablationClient({
8490
+ baseUrl: `${route2.baseUrl}/api`,
8491
+ apiKey: opts.apiKey ?? resolveApiKey(route2),
8492
+ // Cloudflare 403s (error 1010) default agents on this host.
8493
+ headers: { "User-Agent": ship.userAgent }
8494
+ });
8617
8495
  }
8618
8496
  /**
8619
- * Record a tool call if it is a ticket-targeted tracker write (claim,
8620
- * hand-off or comment). The ticket key comes from a record uuid seen on an
8621
- * earlier write when known, else from the key last named in the transcript
8622
- * (a claim or hand-off announces its ticket). A comment on a record never
8623
- * seen before is ignored: its uuid cannot be resolved to a key.
8497
+ * Every non-terminal ticket for this route's area, in one call.
8498
+ *
8499
+ * The area filter is what keeps two routes on one tracker out of each
8500
+ * other's queue: a ship working `synthesis` and `tablation-js` from the same
8501
+ * Issues project must not have either seat pick up the other's tickets. A
8502
+ * route with no area sees everything, which is right for a tracker
8503
+ * nobody has sliced yet.
8624
8504
  */
8625
- noteWrite(tool, input) {
8626
- const w = classifyWrite(tool, input);
8627
- if (!w) return;
8628
- let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
8629
- if (w.kind !== "comment") key ??= this.#current;
8630
- if (!key) return;
8631
- if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
8632
- this.#lastBy[w.kind] = key;
8505
+ async openTickets() {
8506
+ const c = this.contract;
8507
+ const filters = [
8508
+ { columnName: c.columns.status, operator: "IN", value: c.statuses.open }
8509
+ ];
8510
+ const areaId = this.route.resolved?.areaId;
8511
+ if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
8512
+ return this.client.records.list(this.models.issues, {
8513
+ filters: encodeFilters(filters),
8514
+ limit: 500
8515
+ });
8633
8516
  }
8634
8517
  /**
8635
- * How the attribution is decided: a hand-off write beats a claim, a claim
8636
- * beats a comment (so a follow-up ticket filed or commented on while
8637
- * working never displaces the claimed one), and any write beats everything
8638
- * below. Next, `hintMoved` — the post-run read-back showed the hinted
8639
- * ticket changed status or assignee during the run — is a hand-off signal
8640
- * for a run that wrote through MCP tools crew cannot see. Then the
8641
- * mention-token heuristic, which with a hint may override it only when a
8642
- * different ticket holds a strict majority of the credited tokens (a single
8643
- * credited turn, like a lone end-of-run summary naming another ticket,
8644
- * must not). A run with no hint
8645
- * keeps the plain winner. The pre-run hint is last.
8518
+ * Tickets at the hand-off status whose row changed at or after `since`,
8519
+ * other than `exceptId` (CREW-1759: a run's stray hand-offs).
8646
8520
  */
8647
- decide(hint, opts = {}) {
8648
- for (const by of ["handoff", "claim", "comment"]) {
8649
- const ticket = this.#lastBy[by];
8650
- if (ticket) return { ticket, by };
8521
+ async handedOffSince(since2, exceptId) {
8522
+ const c = this.contract;
8523
+ const rows = await this.client.records.list(this.models.issues, {
8524
+ filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
8525
+ limit: 100
8526
+ });
8527
+ const from = Date.parse(since2);
8528
+ return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
8529
+ }
8530
+ /**
8531
+ * The tickets behind a set of ticket numbers — the cleanup sweeps' input
8532
+ * (CREW-1593).
8533
+ *
8534
+ * The sweeps used to fetch "every closed ticket, newest `updated_at` first,
8535
+ * capped at 500" and hope the tickets they cared about were in the page. They
8536
+ * were not once the board held more than 500 closed tickets, and a bulk
8537
+ * write gave 1457 of them one identical `updated_at`, so which ones made the
8538
+ * page was arbitrary and a deployed ticket's worktree/branch was silently
8539
+ * never swept. The caller now names the numbers it actually has a worktree
8540
+ * or remote branch for, so the request is bounded by those and not by how
8541
+ * many tickets are closed.
8542
+ *
8543
+ * Matched on `issue_id`, in the raw, unpadded and 3-digit-padded spellings a
8544
+ * number has been stored under. `onTruncated` is told when a page came back
8545
+ * full, since anything past it would be missing without a trace.
8546
+ */
8547
+ async ticketsByNumbers(nums, onTruncated) {
8548
+ const c = this.contract;
8549
+ const keys = /* @__PURE__ */ new Set();
8550
+ for (const raw of nums) {
8551
+ const unpadded = String(Number.parseInt(raw, 10));
8552
+ for (const n of [raw, unpadded, unpadded.padStart(3, "0")]) keys.add(`ISSUE-${n}`);
8651
8553
  }
8652
- if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
8653
- if (this.#tokensByTicket.size > 0) {
8654
- if (!hint) return { ticket: this.winner(), by: "mentions" };
8655
- const winner = this.winner(hint);
8656
- if (winner === hint) return { ticket: hint, by: "mentions" };
8657
- let total = 0;
8658
- for (const n of this.#tokensByTicket.values()) total += n;
8659
- const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
8660
- return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
8554
+ const all = [...keys];
8555
+ const out = [];
8556
+ const limit = 500;
8557
+ for (let i = 0; i < all.length; i += 100) {
8558
+ const page = await this.client.records.list(this.models.issues, {
8559
+ filters: encodeFilters([{ columnName: c.columns.key, operator: "IN", value: all.slice(i, i + 100) }]),
8560
+ limit
8561
+ });
8562
+ if (page.length >= limit) onTruncated?.(limit);
8563
+ out.push(...page);
8661
8564
  }
8662
- return { ticket: hint, by: "hint" };
8565
+ return out;
8663
8566
  }
8664
8567
  /**
8665
- * The ticket key the run spent the most tokens on, or `fallback` (the
8666
- * pre-run poll hint) when the transcript never named one — an
8667
- * administrative run that touches the tracker without discussing a
8668
- * specific ticket in its visible text still has to cite something.
8568
+ * Tickets by id, for blockers that have already closed and so are absent
8569
+ * from the open set. Bounded to the ids actually referenced.
8669
8570
  */
8670
- winner(fallback) {
8671
- let best = fallback;
8672
- let bestTokens = -1;
8673
- for (const [ticket, tokens] of this.#tokensByTicket) {
8674
- if (tokens > bestTokens) {
8675
- best = ticket;
8676
- bestTokens = tokens;
8677
- }
8678
- }
8679
- return best;
8571
+ async ticketsByIds(ids) {
8572
+ if (ids.length === 0) return [];
8573
+ return this.client.records.list(this.models.issues, {
8574
+ filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
8575
+ limit: 500
8576
+ });
8680
8577
  }
8681
- };
8682
- function isClaimWrite(tool, input) {
8683
- if (!tool || !/records_controller_update$/.test(tool)) return false;
8684
- const body = input?.body;
8685
- return typeof body === "object" && body !== null && body.status === "in_progress";
8686
- }
8687
- var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
8688
- function classifyWrite(tool, input) {
8689
- if (!tool) return void 0;
8690
- const body = input?.body;
8691
- if (typeof body !== "object" || body === null) return void 0;
8692
- const b = body;
8693
- if (/records_controller_update$/.test(tool)) {
8694
- const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
8695
- if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
8696
- if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
8578
+ async ticket(id) {
8579
+ return this.client.records.get(this.models.issues, id);
8697
8580
  }
8698
- if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
8699
- return void 0;
8700
- }
8701
-
8702
- // src/handoff-transcript.ts
8703
- var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
8704
- function resultText(content) {
8705
- if (typeof content === "string") return content;
8706
- if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
8707
- return "";
8708
- }
8709
- function isTransportFailure(isError, text2) {
8710
- if (!isError) {
8581
+ async comments(limit = 500) {
8582
+ return this.client.records.list(this.models.comments, { limit });
8583
+ }
8584
+ async crewRows() {
8585
+ return this.client.records.list(this.models.crew, { limit: 200 });
8586
+ }
8587
+ /**
8588
+ * One row of the `Repos` table, for whatever it can tell the crew that
8589
+ * isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
8590
+ * particular. `undefined` on a workspace with no Repos table, or if the
8591
+ * row is gone; neither is fatal to the caller.
8592
+ */
8593
+ async repoRow(id) {
8594
+ const model = this.route.resolved?.reposModelId;
8595
+ if (!model) return void 0;
8711
8596
  try {
8712
- const doc = JSON.parse(text2);
8713
- if (doc && typeof doc === "object") {
8714
- const code = doc.statusCode;
8715
- return typeof code === "number" && code >= 500;
8716
- }
8597
+ return await this.client.records.get(model, id);
8717
8598
  } catch {
8599
+ return void 0;
8718
8600
  }
8719
8601
  }
8720
- return TRANSPORT_FAILURE.test(text2);
8721
- }
8722
- var FORBIDDEN_WRITE = /\b403\b[^]*?No WRITE access/i;
8723
- function isForbiddenWrite(isError, text2) {
8724
- if (FORBIDDEN_WRITE.test(text2)) return true;
8725
- if (!isError) {
8602
+ /**
8603
+ * One row of the `Projects` (area) table. `undefined` on a workspace
8604
+ * with no such table, or if the row is gone.
8605
+ */
8606
+ async projectRow(id) {
8607
+ const model = this.route.resolved?.areaModelId;
8608
+ if (!model) return void 0;
8726
8609
  try {
8727
- const doc = JSON.parse(text2);
8728
- return !!doc && typeof doc === "object" && doc.statusCode === 403;
8610
+ return await this.client.records.get(model, id);
8729
8611
  } catch {
8612
+ return void 0;
8730
8613
  }
8731
8614
  }
8732
- return false;
8733
- }
8734
- function forbiddenDetail403(text2) {
8735
- const m = /No WRITE access to (?:"[^"]+"|this model|[\w-]+)/i.exec(text2);
8736
- return m ? m[0] : "a hand-off write was refused";
8737
- }
8738
- var HandoffTranscriptTracker = class {
8739
- open = /* @__PURE__ */ new Map();
8740
- /** Per ticket record: the last hand-off status write's outcome. */
8741
- status = /* @__PURE__ */ new Map();
8742
- /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
8743
- comment = /* @__PURE__ */ new Map();
8744
- landed = /* @__PURE__ */ new Set();
8745
- /** Per ticket record: the last hand-off status write, when the tracker refused it with 403. */
8746
- forbiddenWrites = /* @__PURE__ */ new Map();
8747
- statusLanded = false;
8748
- lastText = "";
8749
- resultLine = "";
8750
- feed(parsed) {
8751
- const line = parsed;
8752
- if (!line || typeof line !== "object") return;
8753
- if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
8754
- const content = line.message?.content;
8755
- if (!Array.isArray(content)) return;
8756
- for (const b of content) {
8757
- if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
8758
- else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
8759
- const w = classifyWrite(b.name, b.input);
8760
- const body = b.input?.body;
8761
- if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
8762
- this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
8763
- }
8764
- } else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
8765
- const w = this.open.get(b.tool_use_id);
8766
- if (!w) continue;
8767
- this.open.delete(b.tool_use_id);
8768
- const text2 = resultText(b.content);
8769
- if (w.kind === "handoff") {
8770
- const refused = isForbiddenWrite(b.is_error === true, text2);
8771
- if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
8772
- else {
8773
- this.forbiddenWrites.delete(w.recordId);
8774
- if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
8775
- }
8776
- }
8777
- const failed = isTransportFailure(b.is_error === true, text2);
8778
- if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
8779
- else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
8780
- else if (!failed) {
8781
- this.comment.delete(w.recordId);
8782
- this.landed.add(w.recordId);
8783
- }
8784
- }
8615
+ /**
8616
+ * Every Projects (area) row's work mode (CREW-1445, CREW-1493). One extra
8617
+ * read per cycle. A workspace with no Projects table, no `work_mode` column
8618
+ * or an unreadable table yields the empty map, which the selection reads as
8619
+ * "no filtering"; a null value is `automatic`.
8620
+ */
8621
+ async projectWorkModes() {
8622
+ const model = this.route.resolved?.areaModelId;
8623
+ if (!model) return /* @__PURE__ */ new Map();
8624
+ try {
8625
+ const rows = await this.client.records.list(model, { limit: 200 });
8626
+ return projectWorkModes(rows, this.contract);
8627
+ } catch {
8628
+ return /* @__PURE__ */ new Map();
8785
8629
  }
8786
8630
  }
8787
- /** The newest hand-off write the tracker refused with 403 and no later success for that ticket, else null. */
8788
- forbidden() {
8789
- const last = [...this.forbiddenWrites].at(-1);
8790
- return last ? { ticketRecordId: last[0], detail: last[1] } : null;
8631
+ /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
8632
+ async projectRows() {
8633
+ const model = this.route.resolved?.areaModelId;
8634
+ if (!model) return [];
8635
+ return this.client.records.list(model, { limit: 200 });
8791
8636
  }
8792
- /** A hand-off status write went through, so the seat can write what a hand-off needs. */
8793
- handoffLanded() {
8794
- return this.statusLanded && this.forbiddenWrites.size === 0;
8637
+ /** Every Projects row with its raw `work_mode`, for `crew status`. */
8638
+ async projectRowsWithModes() {
8639
+ const rows = await this.projectRows();
8640
+ const modes = await this.projectWorkModes();
8641
+ return rows.map((r) => ({ ...r, mode: modes.get(r.id) ?? "automatic" }));
8795
8642
  }
8796
- /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
8797
- pending() {
8798
- const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
8799
- if (!failed) return null;
8800
- const [ticketRecordId, s] = failed;
8801
- return {
8802
- ticketRecordId,
8803
- patch: s.patch,
8804
- ...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
8805
- ...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
8806
- finalText: this.lastText || this.resultLine
8807
- };
8808
- }
8809
- };
8810
-
8811
- // src/pending-handoff.ts
8812
- import { existsSync as existsSync23, mkdirSync as mkdirSync16, readdirSync as readdirSync7, readFileSync as readFileSync23, rmSync as rmSync13, writeFileSync as writeFileSync17 } from "fs";
8813
- import { join as join25 } from "path";
8814
-
8815
- // src/ticket-branch.ts
8816
- import { basename as basename7 } from "path";
8817
-
8818
- // src/tracker.ts
8819
- import { hostname } from "os";
8820
- import { TablationClient } from "@tablation/client";
8821
-
8822
- // src/work-mode.ts
8823
- function projectWorkModes(rows, contract) {
8824
- const out = /* @__PURE__ */ new Map();
8825
- for (const r of rows) {
8826
- const v = r[contract.columns.workMode];
8827
- out.set(r.id, v === contract.manualWorkMode ? "manual" : v === contract.hybridWorkMode ? "hybrid" : "automatic");
8828
- }
8829
- return out;
8830
- }
8831
- function agentAssigneeIds(crewRows, isHoldRow) {
8832
- return new Set(crewRows.filter((r) => !!r.agent_id && !isHoldRow(r.id)).map((r) => r.id));
8833
- }
8834
- function modeOf(t, wm) {
8835
- if (!t.project_id) return "manual";
8836
- return wm.modes.get(t.project_id) ?? "automatic";
8837
- }
8838
- function withinWorkMode(tickets, wm) {
8839
- if (!wm || wm.modes.size === 0) return tickets;
8840
- return tickets.filter((t) => {
8841
- const mode = modeOf(t, wm);
8842
- if (mode === "automatic") return true;
8843
- if (mode === "manual") return false;
8844
- return !!t.assignee_id && wm.agentAssignees.has(t.assignee_id);
8845
- });
8846
- }
8847
- function isAutomatic(t, wm) {
8848
- if (!wm || wm.modes.size === 0) return true;
8849
- return modeOf(t, wm) === "automatic";
8850
- }
8851
- function skippedByMode(tickets, wm) {
8852
- const out = { manual: 0, hybrid: 0 };
8853
- if (!wm || wm.modes.size === 0) return out;
8854
- const kept = new Set(withinWorkMode(tickets, wm));
8855
- for (const t of tickets) {
8856
- if (kept.has(t)) continue;
8857
- out[modeOf(t, wm) === "manual" ? "manual" : "hybrid"]++;
8858
- }
8859
- return out;
8860
- }
8861
- function withoutManualVerified(tickets, verifiedStatus, wm) {
8862
- if (!wm || wm.modes.size === 0) return tickets;
8863
- return tickets.filter((t) => t.status !== verifiedStatus || modeOf(t, wm) !== "manual");
8864
- }
8865
- function projectModeLabels(rows) {
8866
- return rows.map((r) => {
8867
- const name = r.name ?? r.id;
8868
- return { id: r.id, name, mode: r.mode, label: `${name} (${r.mode})` };
8869
- });
8870
- }
8871
-
8872
- // src/board-lock.ts
8873
- import { StaleWriteError } from "@tablation/client";
8874
- function releaseLockScope(route2, repo) {
8875
- return `${route2}/${repo}`;
8876
- }
8877
- async function acquireBoardLock(client2, modelId, scope, holderLabel, ttlMs, log = {}) {
8878
- if (!modelId) return { ok: true, release: async () => {
8879
- }, exclusion: "off" };
8880
- const filters = JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
8881
- const rows = await client2.list(modelId, { filters, limit: 1 });
8882
- const row = rows[0];
8883
- if (!row) return { ok: false, reason: "missing" };
8884
- const age = Date.now() - new Date(row.updated_at).getTime();
8885
- const staleOwn = !!row.holder && row.holder !== holderLabel && isDeadOwnClaim(row.holder, holderLabel);
8886
- const free = !row.holder || row.holder === holderLabel || age > ttlMs || staleOwn;
8887
- if (staleOwn) log.warn?.(`reclaimed stale own claim ${row.holder} on ${scope} (process is gone)`);
8888
- if (!free) return { ok: false, heldBy: row.holder ?? void 0, reason: "held" };
8889
- let claimed;
8890
- try {
8891
- claimed = await client2.update(modelId, row.id, { holder: holderLabel }, row.updated_at);
8892
- } catch (e) {
8893
- if (e instanceof StaleWriteError) return { ok: false, reason: "contended" };
8894
- throw e;
8895
- }
8896
- return {
8897
- ok: true,
8898
- exclusion: "taken",
8899
- release: async () => {
8900
- try {
8901
- try {
8902
- await client2.update(modelId, row.id, { holder: "" }, claimed.updated_at);
8903
- } catch (e) {
8904
- if (!(e instanceof StaleWriteError)) throw e;
8905
- const [now] = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
8906
- if (now?.holder === holderLabel) await client2.update(modelId, row.id, { holder: "" }, now.updated_at);
8907
- }
8908
- log.debug?.(`lock released for ${scope}`);
8909
- } catch (e) {
8910
- log.warn?.(`lock release failed for ${scope}: ${e.message}`);
8911
- }
8912
- }
8913
- };
8914
- }
8915
- function isDeadOwnClaim(holder, mine) {
8916
- const split = (l) => {
8917
- const i = l.lastIndexOf(":");
8918
- return i < 0 ? null : { ship: l.slice(0, i), pid: Number(l.slice(i + 1)) };
8919
- };
8920
- const h = split(holder), m = split(mine);
8921
- if (!h || !m || h.ship !== m.ship || !Number.isInteger(h.pid) || h.pid <= 0) return false;
8922
- try {
8923
- process.kill(h.pid, 0);
8924
- return false;
8925
- } catch (e) {
8926
- return e.code === "ESRCH";
8927
- }
8928
- }
8929
- async function lockRowState(client2, modelId, scope) {
8930
- const rows = await client2.list(modelId, { filters: scopeFilter(scope), limit: 1 });
8931
- const row = rows[0];
8932
- if (!row) return { exists: false };
8933
- return { exists: true, ...row.holder ? { holder: row.holder, since: row.updated_at } : {} };
8934
- }
8935
- var scopeFilter = (scope) => JSON.stringify([{ columnName: "scope", operator: "EQ", value: scope }]);
8936
- async function ensureLockRows(client2, modelId, scopes) {
8937
- const created = [];
8938
- for (const scope of scopes) {
8939
- const filters = scopeFilter(scope);
8940
- if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
8941
- let mine;
8942
- try {
8943
- mine = await client2.create(modelId, { scope, holder: "" });
8944
- } catch (e) {
8945
- const status2 = e.status;
8946
- if (status2 === 409 || status2 === 400) {
8947
- if ((await client2.list(modelId, { filters, limit: 1 })).length > 0) continue;
8948
- }
8949
- throw e;
8950
- }
8951
- const all = await client2.list(modelId, { filters, limit: 20 });
8952
- const keeper = [...all].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? "") || a.id.localeCompare(b.id))[0];
8953
- if (keeper && keeper.id !== mine.id) {
8954
- await client2.remove(modelId, mine.id).catch(() => {
8955
- });
8956
- continue;
8957
- }
8958
- created.push(scope);
8959
- }
8960
- return created;
8961
- }
8962
-
8963
- // src/tracker.ts
8964
- function ticketBranchContext(t) {
8965
- const tag = t.issue_tag ?? void 0;
8966
- if (!tag) return {};
8967
- const prefix = t.project_issue_prefix ? t.project_issue_prefix.toLowerCase() : void 0;
8968
- return { tag, prefix };
8969
- }
8970
- function displayKey(t) {
8971
- return t.issue_tag ?? t.issue_id;
8972
- }
8973
- function referenceKeys(t) {
8974
- return t.issue_tag && t.issue_tag !== t.issue_id ? [t.issue_id, t.issue_tag] : [t.issue_id];
8975
- }
8976
- var RESOLVED_STATUSES = new Set(DEFAULT_CONTRACT.statuses.resolved);
8977
- var encodeFilters = (f) => JSON.stringify(f);
8978
- var Tracker = class {
8979
- // NB: explicit fields, not TypeScript parameter properties — node's
8980
- // --experimental-strip-types cannot transform those, and the test runner
8981
- // uses it. The same rule applies everywhere in src/.
8982
- route;
8983
- /** This workspace's rules. A ship holds several, one per route. */
8984
- contract;
8985
- client;
8986
- models;
8987
- userAgent;
8988
- shipName;
8989
- /**
8990
- * The once-per-process authorship probe (CREW-1371), memoised as a promise
8991
- * so concurrent first writes share one round of field-metadata reads.
8992
- */
8993
- authorship;
8994
- attachments;
8995
- stateDir;
8996
- /**
8997
- * `opts.apiKey` overrides the route's ship key (CREW-1603): a lane's own
8998
- * writes pass its seat's agent key so the event log and rule context see an
8999
- * agent, not the ship's human principal.
9000
- */
9001
- /** Told once per failed best-effort read (`events`); poll.ts points it at the pass's emitter. */
9002
- onReadFailure;
9003
- constructor(route2, ship, opts = {}) {
9004
- this.route = route2;
9005
- if (!route2.resolved) {
9006
- throw new ConfigError(
9007
- `route "${route2.route}" has no resolved ids \u2014 run \`crew connect\` to discover them from the project`
9008
- );
9009
- }
9010
- this.models = route2.resolved.models;
9011
- this.contract = resolveContract(route2.contract);
9012
- this.userAgent = ship.userAgent;
9013
- this.shipName = ship.name;
9014
- this.stateDir = ship.stateDir;
9015
- this.client = new TablationClient({
9016
- baseUrl: `${route2.baseUrl}/api`,
9017
- apiKey: opts.apiKey ?? resolveApiKey(route2),
9018
- // Cloudflare 403s (error 1010) default agents on this host.
9019
- headers: { "User-Agent": ship.userAgent }
9020
- });
9021
- }
9022
- /**
9023
- * Every non-terminal ticket for this route's area, in one call.
9024
- *
9025
- * The area filter is what keeps two routes on one tracker out of each
9026
- * other's queue: a ship working `synthesis` and `tablation-js` from the same
9027
- * Issues project must not have either seat pick up the other's tickets. A
9028
- * route with no area sees everything, which is right for a tracker
9029
- * nobody has sliced yet.
9030
- */
9031
- async openTickets() {
9032
- const c = this.contract;
9033
- const filters = [
9034
- { columnName: c.columns.status, operator: "IN", value: c.statuses.open }
9035
- ];
9036
- const areaId = this.route.resolved?.areaId;
9037
- if (areaId) filters.push({ columnName: c.columns.slice, operator: "EQ", value: areaId });
9038
- return this.client.records.list(this.models.issues, {
9039
- filters: encodeFilters(filters),
9040
- limit: 500
9041
- });
9042
- }
9043
- /**
9044
- * Tickets at the hand-off status whose row changed at or after `since`,
9045
- * other than `exceptId` (CREW-1759: a run's stray hand-offs).
9046
- */
9047
- async handedOffSince(since2, exceptId) {
9048
- const c = this.contract;
9049
- const rows = await this.client.records.list(this.models.issues, {
9050
- filters: encodeFilters([{ columnName: c.columns.status, operator: "EQ", value: c.statuses.handoff }]),
9051
- limit: 100
9052
- });
9053
- const from = Date.parse(since2);
9054
- return rows.filter((t) => t.id !== exceptId && Date.parse(t.updated_at) >= from);
9055
- }
9056
- /**
9057
- * The tickets behind a set of ticket numbers — the cleanup sweeps' input
9058
- * (CREW-1593).
9059
- *
9060
- * The sweeps used to fetch "every closed ticket, newest `updated_at` first,
9061
- * capped at 500" and hope the tickets they cared about were in the page. They
9062
- * were not once the board held more than 500 closed tickets, and a bulk
9063
- * write gave 1457 of them one identical `updated_at`, so which ones made the
9064
- * page was arbitrary and a deployed ticket's worktree/branch was silently
9065
- * never swept. The caller now names the numbers it actually has a worktree
9066
- * or remote branch for, so the request is bounded by those and not by how
9067
- * many tickets are closed.
9068
- *
9069
- * Matched on `issue_id`, in the raw, unpadded and 3-digit-padded spellings a
9070
- * number has been stored under. `onTruncated` is told when a page came back
9071
- * full, since anything past it would be missing without a trace.
9072
- */
9073
- async ticketsByNumbers(nums, onTruncated) {
9074
- const c = this.contract;
9075
- const keys = /* @__PURE__ */ new Set();
9076
- for (const raw of nums) {
9077
- const unpadded = String(Number.parseInt(raw, 10));
9078
- for (const n of [raw, unpadded, unpadded.padStart(3, "0")]) keys.add(`ISSUE-${n}`);
9079
- }
9080
- const all = [...keys];
9081
- const out = [];
9082
- const limit = 500;
9083
- for (let i = 0; i < all.length; i += 100) {
9084
- const page = await this.client.records.list(this.models.issues, {
9085
- filters: encodeFilters([{ columnName: c.columns.key, operator: "IN", value: all.slice(i, i + 100) }]),
9086
- limit
9087
- });
9088
- if (page.length >= limit) onTruncated?.(limit);
9089
- out.push(...page);
9090
- }
9091
- return out;
9092
- }
9093
- /**
9094
- * Tickets by id, for blockers that have already closed and so are absent
9095
- * from the open set. Bounded to the ids actually referenced.
9096
- */
9097
- async ticketsByIds(ids) {
9098
- if (ids.length === 0) return [];
9099
- return this.client.records.list(this.models.issues, {
9100
- filters: encodeFilters([{ columnName: "id", operator: "IN", value: ids }]),
9101
- limit: 500
9102
- });
9103
- }
9104
- async ticket(id) {
9105
- return this.client.records.get(this.models.issues, id);
9106
- }
9107
- async comments(limit = 500) {
9108
- return this.client.records.list(this.models.comments, { limit });
9109
- }
9110
- async crewRows() {
9111
- return this.client.records.list(this.models.crew, { limit: 200 });
9112
- }
9113
- /**
9114
- * One row of the `Repos` table, for whatever it can tell the crew that
9115
- * isn't in `.crew.yaml` — which `Projects` (area) row it belongs to, in
9116
- * particular. `undefined` on a workspace with no Repos table, or if the
9117
- * row is gone; neither is fatal to the caller.
9118
- */
9119
- async repoRow(id) {
9120
- const model = this.route.resolved?.reposModelId;
9121
- if (!model) return void 0;
9122
- try {
9123
- return await this.client.records.get(model, id);
9124
- } catch {
9125
- return void 0;
9126
- }
9127
- }
9128
- /**
9129
- * One row of the `Projects` (area) table. `undefined` on a workspace
9130
- * with no such table, or if the row is gone.
9131
- */
9132
- async projectRow(id) {
9133
- const model = this.route.resolved?.areaModelId;
9134
- if (!model) return void 0;
9135
- try {
9136
- return await this.client.records.get(model, id);
9137
- } catch {
9138
- return void 0;
9139
- }
9140
- }
9141
- /**
9142
- * Every Projects (area) row's work mode (CREW-1445, CREW-1493). One extra
9143
- * read per cycle. A workspace with no Projects table, no `work_mode` column
9144
- * or an unreadable table yields the empty map, which the selection reads as
9145
- * "no filtering"; a null value is `automatic`.
9146
- */
9147
- async projectWorkModes() {
9148
- const model = this.route.resolved?.areaModelId;
9149
- if (!model) return /* @__PURE__ */ new Map();
9150
- try {
9151
- const rows = await this.client.records.list(model, { limit: 200 });
9152
- return projectWorkModes(rows, this.contract);
9153
- } catch {
9154
- return /* @__PURE__ */ new Map();
9155
- }
9156
- }
9157
- /** Every `Projects` (area) row — what `crew repos add` offers when a new Repos row needs one. */
9158
- async projectRows() {
9159
- const model = this.route.resolved?.areaModelId;
9160
- if (!model) return [];
9161
- return this.client.records.list(model, { limit: 200 });
9162
- }
9163
- /** Every Projects row with its raw `work_mode`, for `crew status`. */
9164
- async projectRowsWithModes() {
9165
- const rows = await this.projectRows();
9166
- const modes = await this.projectWorkModes();
9167
- return rows.map((r) => ({ ...r, mode: modes.get(r.id) ?? "automatic" }));
9168
- }
9169
- /** Create a `Repos` row. Throws when the workspace has no Repos table. */
9170
- async createRepoRow(fields) {
9171
- const model = this.route.resolved?.reposModelId;
9172
- if (!model) throw new Error("this workspace has no Repos table");
9173
- return this.client.records.create(model, fields);
8643
+ /** Create a `Repos` row. Throws when the workspace has no Repos table. */
8644
+ async createRepoRow(fields) {
8645
+ const model = this.route.resolved?.reposModelId;
8646
+ if (!model) throw new Error("this workspace has no Repos table");
8647
+ return this.client.records.create(model, fields);
9174
8648
  }
9175
8649
  /** Every `Repos` row as `{ id, name }`, for finding one by name. */
9176
8650
  async repoRows() {
@@ -9872,6 +9346,96 @@ function materializeBranchForTicket(dir, cfg, t, role, onProblem, onReset) {
9872
9346
  return loc.branch;
9873
9347
  }
9874
9348
 
9349
+ // src/pending-handoff.ts
9350
+ import { existsSync as existsSync21, mkdirSync as mkdirSync15, readdirSync as readdirSync6, readFileSync as readFileSync22, rmSync as rmSync12, writeFileSync as writeFileSync16 } from "fs";
9351
+ import { join as join23 } from "path";
9352
+ var PENDING_MAX_AGE_MS = 48 * 36e5;
9353
+ var pendingDirFor = (stateDir) => join23(stateDir, "pending-handoffs");
9354
+ var stateDirOfStream = (streamPath) => join23(streamPath, "..", "..");
9355
+ function writePendingHandoff(stateDir, rec) {
9356
+ const dir = pendingDirFor(stateDir);
9357
+ mkdirSync15(dir, { recursive: true });
9358
+ writeFileSync16(join23(dir, `${rec.ticketRecordId}.json`), JSON.stringify(rec, null, 2));
9359
+ }
9360
+ function listPendingHandoffs(stateDir, route2) {
9361
+ const dir = pendingDirFor(stateDir);
9362
+ if (!existsSync21(dir)) return [];
9363
+ const out = [];
9364
+ for (const f of readdirSync6(dir).filter((n) => n.endsWith(".json")).sort()) {
9365
+ try {
9366
+ const rec = JSON.parse(readFileSync22(join23(dir, f), "utf8"));
9367
+ if (rec.version === 1 && rec.ticketRecordId && (!route2 || rec.route === route2)) out.push(rec);
9368
+ } catch {
9369
+ }
9370
+ }
9371
+ return out;
9372
+ }
9373
+ function removePendingHandoff(stateDir, ticketRecordId) {
9374
+ rmSync12(join23(pendingDirFor(stateDir), `${ticketRecordId}.json`), { force: true });
9375
+ }
9376
+ function pendingHandoffFor(stateDir, ticketRecordId) {
9377
+ return listPendingHandoffs(stateDir).find((r) => r.ticketRecordId === ticketRecordId);
9378
+ }
9379
+ var firstLine = (s) => s.trim().split("\n")[0] ?? "";
9380
+ async function replayPendingHandoffs(i) {
9381
+ const now = i.now ?? /* @__PURE__ */ new Date();
9382
+ const landed = [];
9383
+ for (const rec of listPendingHandoffs(i.stateDir, i.route).filter((r) => !i.role || r.role === i.role)) {
9384
+ const at = { ticket: rec.ticket, step: "handoff" };
9385
+ if (now.getTime() - Date.parse(rec.createdAt) > PENDING_MAX_AGE_MS) {
9386
+ i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: dropped a pending hand-off older than ${PENDING_MAX_AGE_MS / 36e5}h`, at);
9387
+ removePendingHandoff(i.stateDir, rec.ticketRecordId);
9388
+ continue;
9389
+ }
9390
+ if (now.toISOString() < rec.nextAttemptAt) continue;
9391
+ const seatId = i.seats[rec.role];
9392
+ try {
9393
+ const t = await i.writer.ticket(rec.ticketRecordId);
9394
+ const st = i.contract.statuses;
9395
+ if (!seatId || t.status !== st.building || rec.patch.status !== st.handoff) {
9396
+ i.log.emit(`${t.issue_id}: pending hand-off no longer applies (ticket is at ${t.status}) \u2014 dropped`, at);
9397
+ removePendingHandoff(i.stateDir, rec.ticketRecordId);
9398
+ continue;
9399
+ }
9400
+ const co = i.checkout(t);
9401
+ const v = co ? inspectHandoff(co.dir, co.cfg, t, rec.role) : null;
9402
+ if (!v || v.kind === "bounce") {
9403
+ i.log.warn(`${t.issue_id}: pending hand-off dropped \u2014 ${v ? v.reason : "this ship has no checkout of the ticket's repo"}`, at);
9404
+ removePendingHandoff(i.stateDir, rec.ticketRecordId);
9405
+ continue;
9406
+ }
9407
+ if (v.kind === "unverified") throw new Error(v.why);
9408
+ const text2 = rec.comment ?? (rec.commentLanded ? "" : rec.finalText);
9409
+ if (text2) {
9410
+ const head = firstLine(text2);
9411
+ const cc = i.contract.comments;
9412
+ const seen = (await i.writer.comments()).some((c) => {
9413
+ const row = c;
9414
+ return row[cc.parent] === t.id && row[cc.author] === seatId && firstLine(String(row[cc.body] ?? "")) === head;
9415
+ });
9416
+ if (!seen) await i.writer.postComment(t.id, text2, seatId);
9417
+ }
9418
+ await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), seatId, "pushed");
9419
+ await i.writer.updateTicket(t.id, rec.patch);
9420
+ removePendingHandoff(i.stateDir, rec.ticketRecordId);
9421
+ i.log.emit(`${t.issue_id}: replayed a pending hand-off at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, at);
9422
+ landed.push(t.id);
9423
+ } catch (e) {
9424
+ const attempts = rec.attempts + 1;
9425
+ writePendingHandoff(i.stateDir, {
9426
+ ...rec,
9427
+ attempts,
9428
+ warned: true,
9429
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
9430
+ });
9431
+ if (!rec.warned) {
9432
+ i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: a hand-off is pending and could not be replayed yet (${e.message}); will retry`, at);
9433
+ }
9434
+ }
9435
+ }
9436
+ return landed;
9437
+ }
9438
+
9875
9439
  // src/ship-affinity.ts
9876
9440
  var CYCLE_SECONDS = 120;
9877
9441
  var SHIP_DEAD_AFTER_CYCLES = 10;
@@ -10130,6 +9694,11 @@ function acceptanceLines(description) {
10130
9694
  }
10131
9695
  return out;
10132
9696
  }
9697
+ function acceptanceChecklistBlock(lines2) {
9698
+ if (lines2.length === 0) return "";
9699
+ return `## Acceptance checklist (copy into your last comment and fill each result)
9700
+ ${lines2.map((l) => `- ${l}: <test, command or manual check, and its result>`).join("\n")}`;
9701
+ }
10133
9702
  var flat = (s) => s.replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
10134
9703
  function unquotedAcceptance(lines2, comment) {
10135
9704
  const body = flat(comment);
@@ -10219,16 +9788,16 @@ async function missingAcceptance(i, t) {
10219
9788
  const from = i.since ? Date.parse(i.since) : NaN;
10220
9789
  const recent = Number.isNaN(from) ? [] : mine.filter((r) => Date.parse(String(r[cc.createdAt] ?? "")) >= from);
10221
9790
  const candidates2 = recent.length > 0 ? recent : mine.slice(-1);
10222
- if (candidates2.length === 0) return { missing: lines2, judged: null };
9791
+ if (candidates2.length === 0) return { missing: lines2, judged: null, all: lines2 };
10223
9792
  let best = null;
10224
9793
  for (const c of candidates2) {
10225
9794
  const missing = unquotedAcceptance(lines2, String(c[cc.body] ?? ""));
10226
9795
  if (missing.length === 0) return null;
10227
- if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null };
9796
+ if (!best || missing.length <= best.missing.length) best = { missing, judged: String(c[cc.createdAt] ?? "") || null, all: lines2 };
10228
9797
  }
10229
9798
  return best;
10230
9799
  }
10231
- function acceptanceBounceComment(missing, shipName2, judged) {
9800
+ function acceptanceBounceComment(missing, shipName2, judged, all = []) {
10232
9801
  return `${ACCEPTANCE_BOUNCED_MARKER}
10233
9802
 
10234
9803
  **Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
@@ -10240,7 +9809,13 @@ ${shipName2 ? `Ship: ${shipName2}
10240
9809
  ` : "This seat has no comment on the ticket.\n\n"}Not quoted verbatim:
10241
9810
  ${missing.map((l) => `- ${l}`).join("\n")}
10242
9811
 
10243
- Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.`;
9812
+ Set back to \`in_progress\`. Post a comment that lists every Acceptance line verbatim, each followed by the test, command or manual check that exercised it in this run and its result (a line you could not exercise is listed with the reason, never omitted), then set \`fixed\` again. The branch is already pushed.${all.length > 0 ? `
9813
+
9814
+ Copy this block into the comment and fill in each result:
9815
+
9816
+ \`\`\`
9817
+ ${acceptanceChecklistBlock(all)}
9818
+ \`\`\`` : ""}`;
10244
9819
  }
10245
9820
  async function enforceHandoff(i) {
10246
9821
  try {
@@ -10271,7 +9846,7 @@ async function enforceHandoff(i) {
10271
9846
  assignee_id: i.seatId,
10272
9847
  ...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
10273
9848
  });
10274
- await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged), i.seatId, "bounced").catch(() => {
9849
+ await i.writer.postEvent(t.id, acceptanceBounceComment(gap.missing, i.shipName, gap.judged, gap.all), i.seatId, "bounced").catch(() => {
10275
9850
  i.log.warn("bounced, but the note failed to post", log);
10276
9851
  });
10277
9852
  i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
@@ -10317,276 +9892,740 @@ async function enforceHandoff(i) {
10317
9892
  i.log.emit(`${t.issue_id}: pushed ${again.branch} to ${again.remote} on the seat's behalf at ${again.sha.slice(0, 7)} \u2014 handed on to QA`, log);
10318
9893
  return { kind: "pushed", sha: again.sha };
10319
9894
  }
10320
- pushError = `the push succeeded but ${again.kind === "bounce" ? again.reason : again.why}`;
10321
- } catch (e) {
10322
- pushError = e.message;
9895
+ pushError = `the push succeeded but ${again.kind === "bounce" ? again.reason : again.why}`;
9896
+ } catch (e) {
9897
+ pushError = e.message;
9898
+ }
9899
+ i.log.warn(`${t.issue_id}: the runner's own push failed: ${pushError}`, log);
9900
+ }
9901
+ await i.writer.updateTicket(t.id, {
9902
+ status: i.contract.statuses.building,
9903
+ assignee_id: i.seatId,
9904
+ ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
9905
+ });
9906
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
9907
+ i.log.warn("bounced, but the note failed to post", log);
9908
+ });
9909
+ i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
9910
+ return { kind: "bounced" };
9911
+ } catch (e) {
9912
+ i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
9913
+ return { kind: "failed", why: e.message };
9914
+ }
9915
+ }
9916
+ async function guardRunHandoff(route2, ship, tracker, role, workingId, log, since2) {
9917
+ const replayed = await replayRoutePending(route2, ship, tracker, log, role);
9918
+ if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
9919
+ const seatId = route2.resolved?.seats[role];
9920
+ if (!seatId) return null;
9921
+ try {
9922
+ const t = await tracker.ticket(workingId);
9923
+ const resolved = repoConfigFor(route2, t);
9924
+ if (!resolved) return null;
9925
+ const { target, cfg } = resolved;
9926
+ if (role === "qa") {
9927
+ await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
9928
+ return null;
9929
+ }
9930
+ const affinity = await tracker.claimAffinity();
9931
+ const outcome = replayed.includes(workingId) ? { kind: "pushed", sha: "" } : await enforceHandoff({
9932
+ writer: tracker,
9933
+ contract: tracker.contract,
9934
+ ticketId: workingId,
9935
+ role,
9936
+ dir: target.dir,
9937
+ cfg,
9938
+ seatId,
9939
+ shipName: ship.name,
9940
+ hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
9941
+ since: since2,
9942
+ log
9943
+ });
9944
+ if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
9945
+ else {
9946
+ const branch = existingBranchForTicket(target.dir, cfg, t, role);
9947
+ if (branch && dropEmptyRunBranch(target.dir, cfg, branch)) {
9948
+ log.emit(`${t.issue_id}: dropped ${branch} \u2014 the run made no commits beyond ${cfg.branch.base}`, { ticket: t.issue_id, step: "handoff" });
9949
+ }
9950
+ }
9951
+ if (since2) await guardRunStrays(route2, ship, tracker, role, workingId, since2, log);
9952
+ return outcome;
9953
+ } catch (e) {
9954
+ log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
9955
+ return null;
9956
+ }
9957
+ }
9958
+ async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
9959
+ try {
9960
+ const branch = existingBranchForTicket(dir, cfg, t, role);
9961
+ if (!branch) return;
9962
+ const o = await finishRunWorktree(dir, cfg, branch);
9963
+ const at = { ticket: t.issue_id, step: "handoff" };
9964
+ 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);
9965
+ else if (o.kind === "kept-handoff") {
9966
+ log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
9967
+ 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, "worktree_kept");
9968
+ } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
9969
+ else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
9970
+ } catch (e) {
9971
+ log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
9972
+ }
9973
+ }
9974
+ function repoConfigFor(route2, t) {
9975
+ const target = repoTargetFor(route2, t.repo_id);
9976
+ if (!target) return null;
9977
+ const o = route2.repoOverrides[target.name];
9978
+ const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
9979
+ hooks: { ...route2.hooks, ...o?.hooks },
9980
+ labels: { ...route2.labels, ...o?.labels },
9981
+ branch: { ...route2.branch, ...o?.branch },
9982
+ worktrees: { nameOverride: o?.worktreeName }
9983
+ }, target.dir);
9984
+ return { target, cfg };
9985
+ }
9986
+ async function replayRoutePending(route2, ship, tracker, log, role) {
9987
+ try {
9988
+ return await replayPendingHandoffs({
9989
+ writer: tracker,
9990
+ contract: tracker.contract,
9991
+ stateDir: ship.stateDir,
9992
+ route: routeSlug(route2.route),
9993
+ role,
9994
+ seats: route2.resolved?.seats ?? {},
9995
+ checkout: (t) => {
9996
+ const r = repoConfigFor(route2, t);
9997
+ return r ? { dir: r.target.dir, cfg: r.cfg } : null;
9998
+ },
9999
+ log
10000
+ });
10001
+ } catch (e) {
10002
+ log.warn(`could not replay pending hand-offs: ${e.message}`, { step: "handoff" });
10003
+ return [];
10004
+ }
10005
+ }
10006
+ var STRAY_HANDOFF_MARKER = calloutHeader("WARNING", "Stray Hand-off");
10007
+ async function guardStrayHandoffs(i) {
10008
+ const st = i.contract.statuses;
10009
+ if (i.role === "qa") return;
10010
+ for (const t of i.candidates) {
10011
+ const at = { ticket: t.issue_id, step: "handoff" };
10012
+ try {
10013
+ const holder = i.myShipId ? ticketHolderShipId(t, i.contract) : null;
10014
+ if (holder && holder !== i.myShipId) {
10015
+ await i.writer.updateTicket(t.id, { status: st.building });
10016
+ await i.writer.postEvent(
10017
+ t.id,
10018
+ `${STRAY_HANDOFF_MARKER}
10019
+
10020
+ **Hand-off bounced: this ticket is held by another ship.**
10021
+
10022
+ ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
10023
+ i.seatId,
10024
+ "bounced"
10025
+ ).catch(() => i.log.warn("bounced, but the note failed to post", at));
10026
+ i.log.warn(`${t.issue_id} was handed off by a run that did not claim it, while held by another ship \u2014 bounced to ${st.building}`, at);
10027
+ continue;
10028
+ }
10029
+ const co = i.checkout(t);
10030
+ if (!co || !existingBranchForTicket(co.dir, co.cfg, t, i.role)) {
10031
+ i.log.warn(`${t.issue_id} moved to ${st.handoff} during a run that did not claim it; no local branch to verify, left alone`, at);
10032
+ continue;
10033
+ }
10034
+ const v = inspectHandoff(co.dir, co.cfg, t, i.role);
10035
+ i.log.warn(`${t.issue_id} was handed off by a run that did not claim it`, at);
10036
+ if (v.kind === "pushed") {
10037
+ await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
10038
+ } else if (v.kind === "bounce") {
10039
+ if (v.origin && v.local && !isAncestor(co.dir, v.origin, v.local)) {
10040
+ i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
10041
+ continue;
10042
+ }
10043
+ await i.writer.updateTicket(t.id, { status: st.building, assignee_id: i.seatId });
10044
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
10045
+ });
10323
10046
  }
10324
- i.log.warn(`${t.issue_id}: the runner's own push failed: ${pushError}`, log);
10047
+ } catch (e) {
10048
+ i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
10325
10049
  }
10326
- await i.writer.updateTicket(t.id, {
10327
- status: i.contract.statuses.building,
10328
- assignee_id: i.seatId,
10329
- ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
10330
- });
10331
- await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
10332
- i.log.warn("bounced, but the note failed to post", log);
10333
- });
10334
- i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
10335
- return { kind: "bounced" };
10336
- } catch (e) {
10337
- i.log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
10338
- return { kind: "failed", why: e.message };
10339
10050
  }
10340
10051
  }
10341
- async function guardRunHandoff(route2, ship, tracker, role, workingId, log, since2) {
10342
- const replayed = await replayRoutePending(route2, ship, tracker, log, role);
10343
- if (!workingId || role !== "dev" && role !== "design" && role !== "qa") return null;
10052
+ async function guardRunStrays(route2, ship, tracker, role, workingId, since2, log) {
10344
10053
  const seatId = route2.resolved?.seats[role];
10345
- if (!seatId) return null;
10054
+ if (!seatId) return;
10346
10055
  try {
10347
- const t = await tracker.ticket(workingId);
10348
- const resolved = repoConfigFor(route2, t);
10349
- if (!resolved) return null;
10350
- const { target, cfg } = resolved;
10351
- if (role === "qa") {
10352
- await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
10353
- return null;
10354
- }
10355
10056
  const affinity = await tracker.claimAffinity();
10356
- const outcome = replayed.includes(workingId) ? { kind: "pushed", sha: "" } : await enforceHandoff({
10057
+ const candidates2 = await tracker.handedOffSince(since2, workingId);
10058
+ if (candidates2.length === 0) return;
10059
+ await guardStrayHandoffs({
10357
10060
  writer: tracker,
10358
10061
  contract: tracker.contract,
10359
- ticketId: workingId,
10360
- role,
10361
- dir: target.dir,
10362
- cfg,
10062
+ candidates: candidates2,
10363
10063
  seatId,
10064
+ role,
10065
+ myShipId: affinity?.shipId ?? null,
10364
10066
  shipName: ship.name,
10365
- hold: affinity ? { column: affinity.column, shipId: affinity.shipId } : void 0,
10366
- since: since2,
10067
+ checkout: (t) => {
10068
+ const r = repoConfigFor(route2, t);
10069
+ return r ? { dir: r.target.dir, cfg: r.cfg } : null;
10070
+ },
10367
10071
  log
10368
10072
  });
10369
- if (outcome.kind === "pushed") await retireRunWorktree(tracker, target.dir, cfg, t, role, seatId, ship.name, log);
10370
- else {
10371
- const branch = existingBranchForTicket(target.dir, cfg, t, role);
10372
- if (branch && dropEmptyRunBranch(target.dir, cfg, branch)) {
10373
- log.emit(`${t.issue_id}: dropped ${branch} \u2014 the run made no commits beyond ${cfg.branch.base}`, { ticket: t.issue_id, step: "handoff" });
10374
- }
10375
- }
10376
- if (since2) await guardRunStrays(route2, ship, tracker, role, workingId, since2, log);
10377
- return outcome;
10378
10073
  } catch (e) {
10379
- log.warn(`could not check the hand-off: ${e.message}`, { step: "handoff" });
10074
+ log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
10075
+ }
10076
+ }
10077
+
10078
+ // src/agent-log.ts
10079
+ var AgentLogUnreachableError = class extends Error {
10080
+ };
10081
+ var AgentLogHttpError = class extends Error {
10082
+ status;
10083
+ /** True when the refusal was of the Agent Log row itself (not a later cycle post): the tracker is not taking this seat's reports (CREW-2832). */
10084
+ row = false;
10085
+ constructor(message, status2) {
10086
+ super(message);
10087
+ this.status = status2;
10088
+ }
10089
+ };
10090
+ async function request(target, method, path, body) {
10091
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
10092
+ const res = await fetch(url, {
10093
+ method,
10094
+ headers: {
10095
+ Authorization: `Bearer ${target.apiKey}`,
10096
+ "User-Agent": target.userAgent,
10097
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
10098
+ },
10099
+ body: body === void 0 ? void 0 : JSON.stringify(body)
10100
+ });
10101
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
10102
+ return await res.json();
10103
+ }
10104
+ var issueColumns = /* @__PURE__ */ new Map();
10105
+ function columnsOf(target, modelId) {
10106
+ const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
10107
+ let p = issueColumns.get(key);
10108
+ if (!p) {
10109
+ p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
10110
+ p.catch(() => issueColumns.delete(key));
10111
+ issueColumns.set(key, p);
10112
+ }
10113
+ return p;
10114
+ }
10115
+ async function findTicketRecord(target, t, ref) {
10116
+ const filters = {
10117
+ combinator: "OR",
10118
+ conditions: [
10119
+ { columnName: t.keyColumn, operator: "EQ", value: ref },
10120
+ { columnName: t.tagColumn, operator: "EQ", value: ref }
10121
+ ]
10122
+ };
10123
+ const rows = await request(
10124
+ target,
10125
+ "GET",
10126
+ `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
10127
+ );
10128
+ return rows.length === 1 ? rows[0].id : void 0;
10129
+ }
10130
+ function counterIncrements(run3, cols, have) {
10131
+ const figures = [
10132
+ [cols.agentRuns, 1],
10133
+ [cols.tokensIn, run3.tokensIn],
10134
+ [cols.tokensOut, run3.tokensOut],
10135
+ [cols.cacheReadTokens, run3.cacheReadTokens],
10136
+ [cols.cacheWriteTokens, run3.cacheWriteTokens],
10137
+ [cols.costUsd, run3.costUsd]
10138
+ ];
10139
+ const out = {};
10140
+ for (const [column, value2] of figures) {
10141
+ if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
10142
+ }
10143
+ return out;
10144
+ }
10145
+ var BEFORE_EDIT_COLUMNS = {
10146
+ search: "calls_before_edit_search",
10147
+ symbol: "calls_before_edit_symbol",
10148
+ other: "calls_before_edit_other",
10149
+ seconds: "seconds_to_first_edit",
10150
+ edited: "edited"
10151
+ };
10152
+ function beforeFirstEditBody(b) {
10153
+ return {
10154
+ [BEFORE_EDIT_COLUMNS.search]: b.calls.search,
10155
+ [BEFORE_EDIT_COLUMNS.symbol]: b.calls.symbol,
10156
+ [BEFORE_EDIT_COLUMNS.other]: b.calls.other,
10157
+ [BEFORE_EDIT_COLUMNS.edited]: b.edited,
10158
+ ...b.secondsToFirstEdit !== void 0 ? { [BEFORE_EDIT_COLUMNS.seconds]: b.secondsToFirstEdit } : {}
10159
+ };
10160
+ }
10161
+ async function writeBeforeFirstEdit(target, rowId, b) {
10162
+ try {
10163
+ const model = await request(
10164
+ target,
10165
+ "GET",
10166
+ `/data-models/agent_log?workspaceId=${encodeURIComponent(target.workspaceId)}`
10167
+ );
10168
+ const have = await columnsOf(target, model.id);
10169
+ const body = Object.fromEntries(Object.entries(beforeFirstEditBody(b)).filter(([column]) => have.has(column)));
10170
+ if (Object.keys(body).length === 0) return;
10171
+ await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, body);
10172
+ } catch {
10173
+ }
10174
+ }
10175
+ var HANDOFF_BOUNCES_COLUMN = "handoff_bounces";
10176
+ async function countAcceptanceBounces(target, issuesModelId, ticketRecordId) {
10177
+ try {
10178
+ const filters = encodeURIComponent(JSON.stringify([{ columnName: "event_type", operator: "EQ", value: "bounced" }]));
10179
+ const rows = await request(
10180
+ target,
10181
+ "GET",
10182
+ `/data-models/${issuesModelId}/events?limit=500&filters=${filters}`
10183
+ );
10184
+ return rows.filter((e) => e.record_id === ticketRecordId && (e.message ?? "").includes(ACCEPTANCE_BOUNCED_MARKER)).length;
10185
+ } catch {
10380
10186
  return null;
10381
10187
  }
10382
10188
  }
10383
- async function retireRunWorktree(tracker, dir, cfg, t, role, seatId, shipName2, log) {
10189
+ async function writeHandoffBounces(target, rowId, count) {
10190
+ try {
10191
+ const model = await request(
10192
+ target,
10193
+ "GET",
10194
+ `/data-models/agent_log?workspaceId=${encodeURIComponent(target.workspaceId)}`
10195
+ );
10196
+ if (!(await columnsOf(target, model.id)).has(HANDOFF_BOUNCES_COLUMN)) return;
10197
+ await request(target, "PATCH", `/data-models/${model.id}/records/${rowId}`, { [HANDOFF_BOUNCES_COLUMN]: count });
10198
+ } catch {
10199
+ }
10200
+ }
10201
+ async function post(target, path, body) {
10202
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
10203
+ const res = await fetch(url, {
10204
+ method: "POST",
10205
+ headers: {
10206
+ Authorization: `Bearer ${target.apiKey}`,
10207
+ // Cloudflare 403s default agents on this host — same header connect.ts sends.
10208
+ "User-Agent": target.userAgent,
10209
+ "Content-Type": "application/json"
10210
+ },
10211
+ body: JSON.stringify(body)
10212
+ });
10213
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
10214
+ return await res.json();
10215
+ }
10216
+ async function reportAgentRun(target, run3) {
10217
+ let ticketRecordId;
10218
+ if (target.tickets && run3.ticketReference) {
10219
+ try {
10220
+ ticketRecordId = await findTicketRecord(target, target.tickets, run3.ticketReference);
10221
+ } catch {
10222
+ }
10223
+ }
10224
+ const body = {
10225
+ agentId: target.agentId,
10226
+ ticketReference: run3.ticketReference,
10227
+ outcome: run3.outcome,
10228
+ startedAt: run3.startedAt,
10229
+ finishedAt: run3.finishedAt,
10230
+ source: "client",
10231
+ client: "crew",
10232
+ model: run3.model,
10233
+ tokensIn: run3.tokensIn,
10234
+ tokensOut: run3.tokensOut,
10235
+ cacheReadTokens: run3.cacheReadTokens,
10236
+ cacheWriteTokens: run3.cacheWriteTokens,
10237
+ costUsd: run3.costUsd,
10238
+ numTurns: run3.numTurns,
10239
+ prefixTokens: run3.prefixTokens,
10240
+ promptVersion: run3.promptVersion,
10241
+ promptSha: run3.promptSha
10242
+ };
10243
+ const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
10244
+ let entry;
10384
10245
  try {
10385
- const branch = existingBranchForTicket(dir, cfg, t, role);
10386
- if (!branch) return;
10387
- const o = await finishRunWorktree(dir, cfg, branch);
10388
- const at = { ticket: t.issue_id, step: "handoff" };
10389
- 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);
10390
- else if (o.kind === "kept-handoff") {
10391
- log.emit(`${t.issue_id}: kept worktree ${o.path} \u2014 this repo's handoff hook serves from it`, at);
10392
- 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, "worktree_kept");
10393
- } else if (o.kind === "kept-dirty") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 it has uncommitted changes`, at);
10394
- else if (o.kind === "kept-unpushed") log.warn(`${t.issue_id}: left worktree ${o.path} \u2014 its branch has commits that are not on ${cfg.branch.remote}`, at);
10246
+ entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run3.harness, provider: run3.provider });
10395
10247
  } catch (e) {
10396
- log.warn(`could not retire the run's worktree: ${e.message}`, { step: "handoff" });
10248
+ if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
10249
+ if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
10250
+ if (e.status !== 400 || !run3.harness && !run3.provider && !ticketRecordId) {
10251
+ e.row = true;
10252
+ throw e;
10253
+ }
10254
+ try {
10255
+ entry = await post(target, "/agents/log", body);
10256
+ } catch (e2) {
10257
+ if (e2 instanceof AgentLogHttpError && e2.status < 500) e2.row = true;
10258
+ throw e2;
10259
+ }
10260
+ }
10261
+ for (const cycle of run3.cycles) {
10262
+ await post(target, `/agents/log/${entry.id}/cycles`, {
10263
+ cycleIndex: cycle.cycleIndex,
10264
+ occurredAt: cycle.occurredAt,
10265
+ thinking: cycle.thinking,
10266
+ ...cycle.role ? { role: cycle.role } : {}
10267
+ });
10268
+ }
10269
+ if (run3.beforeFirstEdit) await writeBeforeFirstEdit(target, entry.id, run3.beforeFirstEdit);
10270
+ if (target.tickets && ticketRecordId) {
10271
+ const bounces = await countAcceptanceBounces(target, target.tickets.issuesModelId, ticketRecordId);
10272
+ if (bounces !== null) await writeHandoffBounces(target, entry.id, bounces);
10397
10273
  }
10274
+ const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run3) : void 0;
10275
+ return {
10276
+ agentLogId: entry.id,
10277
+ ...counters?.warning ? { counterWarning: counters.warning } : {},
10278
+ ...counters?.retry ? { counterRetry: counters.retry } : {}
10279
+ };
10398
10280
  }
10399
- function repoConfigFor(route2, t) {
10400
- const target = repoTargetFor(route2, t.repo_id);
10401
- if (!target) return null;
10402
- const o = route2.repoOverrides[target.name];
10403
- const cfg = resolveRepoConfig(loadRepoConfig(target.dir), {
10404
- hooks: { ...route2.hooks, ...o?.hooks },
10405
- labels: { ...route2.labels, ...o?.labels },
10406
- branch: { ...route2.branch, ...o?.branch },
10407
- worktrees: { nameOverride: o?.worktreeName }
10408
- }, target.dir);
10409
- return { target, cfg };
10281
+ var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
10282
+ async function writeCounters(target, w) {
10283
+ const have = await columnsOf(target, w.issuesModelId);
10284
+ const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
10285
+ if (Object.keys(body).length === 0) return;
10286
+ await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
10410
10287
  }
10411
- async function replayRoutePending(route2, ship, tracker, log, role) {
10288
+ async function addRunToTicket(target, t, recordId, run3) {
10289
+ const write = {
10290
+ issuesModelId: t.issuesModelId,
10291
+ recordId,
10292
+ ticketReference: run3.ticketReference,
10293
+ counters: t.counters,
10294
+ body: counterIncrements(run3, t.counters, new Set(Object.values(t.counters)))
10295
+ };
10412
10296
  try {
10413
- return await replayPendingHandoffs({
10414
- writer: tracker,
10415
- contract: tracker.contract,
10416
- stateDir: ship.stateDir,
10417
- route: routeSlug(route2.route),
10418
- role,
10419
- seats: route2.resolved?.seats ?? {},
10420
- checkout: (t) => {
10421
- const r = repoConfigFor(route2, t);
10422
- return r ? { dir: r.target.dir, cfg: r.cfg } : null;
10423
- },
10424
- log
10425
- });
10297
+ await writeCounters(target, write);
10298
+ return {};
10426
10299
  } catch (e) {
10427
- log.warn(`could not replay pending hand-offs: ${e.message}`, { step: "handoff" });
10428
- return [];
10300
+ return {
10301
+ warning: `could not add run spend to ${run3.ticketReference}: ${e.message}`,
10302
+ ...isRetryable(e) ? { retry: write } : {}
10303
+ };
10429
10304
  }
10430
10305
  }
10431
- var STRAY_HANDOFF_MARKER = calloutHeader("WARNING", "Stray Hand-off");
10432
- async function guardStrayHandoffs(i) {
10433
- const st = i.contract.statuses;
10434
- if (i.role === "qa") return;
10435
- for (const t of i.candidates) {
10436
- const at = { ticket: t.issue_id, step: "handoff" };
10306
+ var queueDir = (stateDir) => join25(stateDir, "pending-agent-logs");
10307
+ function queueAgentRun(stateDir, name, q2) {
10308
+ mkdirSync16(queueDir(stateDir), { recursive: true });
10309
+ writeFileSync17(join25(queueDir(stateDir), `${name}.json`), JSON.stringify(q2));
10310
+ }
10311
+ async function replayQueuedAgentRuns(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
10312
+ const dir = queueDir(stateDir);
10313
+ if (!existsSync23(dir)) return 0;
10314
+ let sent = 0;
10315
+ for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
10316
+ const path = join25(dir, f);
10317
+ let q2;
10437
10318
  try {
10438
- const holder = i.myShipId ? ticketHolderShipId(t, i.contract) : null;
10439
- if (holder && holder !== i.myShipId) {
10440
- await i.writer.updateTicket(t.id, { status: st.building });
10441
- await i.writer.postEvent(
10442
- t.id,
10443
- `${STRAY_HANDOFF_MARKER}
10444
-
10445
- **Hand-off bounced: this ticket is held by another ship.**
10446
-
10447
- ${i.shipName ? `${i.shipName}'s ` : "A "}${i.role} run set it to \`${st.handoff}\` without having claimed it. Set back to \`${st.building}\`; the holding ship's own hand-off is the one that counts.`,
10448
- i.seatId,
10449
- "bounced"
10450
- ).catch(() => i.log.warn("bounced, but the note failed to post", at));
10451
- i.log.warn(`${t.issue_id} was handed off by a run that did not claim it, while held by another ship \u2014 bounced to ${st.building}`, at);
10319
+ q2 = JSON.parse(readFileSync23(path, "utf8"));
10320
+ } catch {
10321
+ continue;
10322
+ }
10323
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
10324
+ try {
10325
+ const apiKey = await keyFor(q2.role);
10326
+ if (!apiKey) continue;
10327
+ const sentRun = await reportAgentRun({ ...q2.target, apiKey }, q2.report);
10328
+ if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
10329
+ if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
10330
+ version: 1,
10331
+ route: q2.route,
10332
+ role: q2.role,
10333
+ target: q2.target,
10334
+ write: sentRun.counterRetry,
10335
+ attempts: 0,
10336
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
10337
+ });
10338
+ rmSync13(path, { force: true });
10339
+ sent += 1;
10340
+ log.emit(`sent a queued Agent Log report (${f})`);
10341
+ } catch (e) {
10342
+ if (!(e instanceof AgentLogUnreachableError)) {
10343
+ rmSync13(path, { force: true });
10344
+ log.warn(`dropped a queued Agent Log report (${f}): ${e.message}`);
10452
10345
  continue;
10453
10346
  }
10454
- const co = i.checkout(t);
10455
- if (!co || !existingBranchForTicket(co.dir, co.cfg, t, i.role)) {
10456
- i.log.warn(`${t.issue_id} moved to ${st.handoff} during a run that did not claim it; no local branch to verify, left alone`, at);
10347
+ const attempts = q2.attempts + 1;
10348
+ writeFileSync17(path, JSON.stringify({
10349
+ ...q2,
10350
+ attempts,
10351
+ warned: true,
10352
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
10353
+ }));
10354
+ if (!q2.warned) log.warn(`a queued Agent Log report could not be sent yet (${e.message}); will retry`);
10355
+ }
10356
+ }
10357
+ return sent;
10358
+ }
10359
+ var countersDir = (stateDir) => join25(stateDir, "pending-ticket-counters");
10360
+ function queueTicketCounters(stateDir, name, q2) {
10361
+ mkdirSync16(countersDir(stateDir), { recursive: true });
10362
+ writeFileSync17(join25(countersDir(stateDir), `${name}.json`), JSON.stringify(q2));
10363
+ }
10364
+ async function replayQueuedTicketCounters(stateDir, route2, keyFor, log, now = /* @__PURE__ */ new Date()) {
10365
+ const dir = countersDir(stateDir);
10366
+ if (!existsSync23(dir)) return 0;
10367
+ let sent = 0;
10368
+ for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
10369
+ const path = join25(dir, f);
10370
+ let q2;
10371
+ try {
10372
+ q2 = JSON.parse(readFileSync23(path, "utf8"));
10373
+ } catch {
10374
+ continue;
10375
+ }
10376
+ if (q2.version !== 1 || q2.route !== route2 || now.toISOString() < q2.nextAttemptAt) continue;
10377
+ try {
10378
+ const apiKey = await keyFor(q2.role);
10379
+ if (!apiKey) continue;
10380
+ await writeCounters({ ...q2.target, apiKey }, q2.write);
10381
+ rmSync13(path, { force: true });
10382
+ sent += 1;
10383
+ log.emit(`added a queued run's spend to ${q2.write.ticketReference ?? q2.write.recordId} (${f})`);
10384
+ } catch (e) {
10385
+ if (!isRetryable(e)) {
10386
+ rmSync13(path, { force: true });
10387
+ log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
10457
10388
  continue;
10458
10389
  }
10459
- const v = inspectHandoff(co.dir, co.cfg, t, i.role);
10460
- i.log.warn(`${t.issue_id} was handed off by a run that did not claim it`, at);
10461
- if (v.kind === "pushed") {
10462
- await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
10463
- } else if (v.kind === "bounce") {
10464
- if (v.origin && v.local && !isAncestor(co.dir, v.origin, v.local)) {
10465
- i.log.warn(`${t.issue_id}: \`${v.remote}/${v.branch}\` is at ${v.origin.slice(0, 7)}, not a tip this checkout built \u2014 left alone`, at);
10466
- continue;
10467
- }
10468
- await i.writer.updateTicket(t.id, { status: st.building, assignee_id: i.seatId });
10469
- await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
10470
- });
10390
+ const attempts = q2.attempts + 1;
10391
+ writeFileSync17(path, JSON.stringify({
10392
+ ...q2,
10393
+ attempts,
10394
+ warned: true,
10395
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
10396
+ }));
10397
+ if (!q2.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
10398
+ }
10399
+ }
10400
+ return sent;
10401
+ }
10402
+
10403
+ // src/ticket-attribution.ts
10404
+ var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
10405
+ function ticketMentions(text2) {
10406
+ return [...text2.matchAll(TICKET_KEY_RE)].map((m) => m[0]);
10407
+ }
10408
+ var TicketAttributionTracker = class {
10409
+ #tokensByTicket = /* @__PURE__ */ new Map();
10410
+ #turns = 0;
10411
+ #current;
10412
+ /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
10413
+ #keyByRecord = /* @__PURE__ */ new Map();
10414
+ #lastBy = {};
10415
+ /**
10416
+ * The ticket key most recently named in the transcript, or undefined before any.
10417
+ * Only the FIRST key in a turn counts: a closing summary names its own
10418
+ * ticket first and then lists others ("Other tickets", "Blocked by", "See
10419
+ * also"), and those trailing keys are not what the run worked (CREW-2598).
10420
+ */
10421
+ get current() {
10422
+ return this.#current;
10423
+ }
10424
+ /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
10425
+ note(text2) {
10426
+ const mentions = ticketMentions(text2);
10427
+ if (mentions.length > 0) this.#current = mentions[0];
10428
+ }
10429
+ /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
10430
+ add(text2, tokens) {
10431
+ const mentions = ticketMentions(text2);
10432
+ if (mentions.length > 0) this.#current = mentions[0];
10433
+ if (this.#current === void 0 || tokens <= 0) return;
10434
+ this.#turns++;
10435
+ this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
10436
+ }
10437
+ /**
10438
+ * Record a tool call if it is a ticket-targeted tracker write (claim,
10439
+ * hand-off or comment). The ticket key comes from a record uuid seen on an
10440
+ * earlier write when known, else from the key last named in the transcript
10441
+ * (a claim or hand-off announces its ticket). A comment on a record never
10442
+ * seen before is ignored: its uuid cannot be resolved to a key.
10443
+ */
10444
+ noteWrite(tool, input) {
10445
+ const w = classifyWrite(tool, input);
10446
+ if (!w) return;
10447
+ let key = w.recordId ? this.#keyByRecord.get(w.recordId) : void 0;
10448
+ if (w.kind !== "comment") key ??= this.#current;
10449
+ if (!key) return;
10450
+ if (w.kind !== "comment" && w.recordId) this.#keyByRecord.set(w.recordId, key);
10451
+ this.#lastBy[w.kind] = key;
10452
+ }
10453
+ /**
10454
+ * How the attribution is decided: a hand-off write beats a claim, a claim
10455
+ * beats a comment (so a follow-up ticket filed or commented on while
10456
+ * working never displaces the claimed one), and any write beats everything
10457
+ * below. Next, `hintMoved` — the post-run read-back showed the hinted
10458
+ * ticket changed status or assignee during the run — is a hand-off signal
10459
+ * for a run that wrote through MCP tools crew cannot see. Then the
10460
+ * mention-token heuristic, which with a hint may override it only when a
10461
+ * different ticket holds a strict majority of the credited tokens (a single
10462
+ * credited turn, like a lone end-of-run summary naming another ticket,
10463
+ * must not). A run with no hint
10464
+ * keeps the plain winner. The pre-run hint is last.
10465
+ */
10466
+ decide(hint, opts = {}) {
10467
+ for (const by of ["handoff", "claim", "comment"]) {
10468
+ const ticket = this.#lastBy[by];
10469
+ if (ticket) return { ticket, by };
10470
+ }
10471
+ if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
10472
+ if (this.#tokensByTicket.size > 0) {
10473
+ if (!hint) return { ticket: this.winner(), by: "mentions" };
10474
+ const winner = this.winner(hint);
10475
+ if (winner === hint) return { ticket: hint, by: "mentions" };
10476
+ let total = 0;
10477
+ for (const n of this.#tokensByTicket.values()) total += n;
10478
+ const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
10479
+ return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
10480
+ }
10481
+ return { ticket: hint, by: "hint" };
10482
+ }
10483
+ /**
10484
+ * The ticket key the run spent the most tokens on, or `fallback` (the
10485
+ * pre-run poll hint) when the transcript never named one — an
10486
+ * administrative run that touches the tracker without discussing a
10487
+ * specific ticket in its visible text still has to cite something.
10488
+ */
10489
+ winner(fallback) {
10490
+ let best = fallback;
10491
+ let bestTokens = -1;
10492
+ for (const [ticket, tokens] of this.#tokensByTicket) {
10493
+ if (tokens > bestTokens) {
10494
+ best = ticket;
10495
+ bestTokens = tokens;
10471
10496
  }
10472
- } catch (e) {
10473
- i.log.warn(`could not check the stray hand-off: ${e.message}`, at);
10474
10497
  }
10498
+ return best;
10475
10499
  }
10500
+ };
10501
+ function isClaimWrite(tool, input) {
10502
+ if (!tool || !/records_controller_update$/.test(tool)) return false;
10503
+ const body = input?.body;
10504
+ return typeof body === "object" && body !== null && body.status === "in_progress";
10476
10505
  }
10477
- async function guardRunStrays(route2, ship, tracker, role, workingId, since2, log) {
10478
- const seatId = route2.resolved?.seats[role];
10479
- if (!seatId) return;
10480
- try {
10481
- const affinity = await tracker.claimAffinity();
10482
- const candidates2 = await tracker.handedOffSince(since2, workingId);
10483
- if (candidates2.length === 0) return;
10484
- await guardStrayHandoffs({
10485
- writer: tracker,
10486
- contract: tracker.contract,
10487
- candidates: candidates2,
10488
- seatId,
10489
- role,
10490
- myShipId: affinity?.shipId ?? null,
10491
- shipName: ship.name,
10492
- checkout: (t) => {
10493
- const r = repoConfigFor(route2, t);
10494
- return r ? { dir: r.target.dir, cfg: r.cfg } : null;
10495
- },
10496
- log
10497
- });
10498
- } catch (e) {
10499
- log.warn(`could not check for stray hand-offs: ${e.message}`, { step: "handoff" });
10506
+ var HANDOFF_STATUSES = /* @__PURE__ */ new Set(["fixed", "verified", "needs_info", "accepted"]);
10507
+ function classifyWrite(tool, input) {
10508
+ if (!tool) return void 0;
10509
+ const body = input?.body;
10510
+ if (typeof body !== "object" || body === null) return void 0;
10511
+ const b = body;
10512
+ if (/records_controller_update$/.test(tool)) {
10513
+ const recordId = typeof input?.recordId === "string" ? input.recordId : void 0;
10514
+ if (b.status === "in_progress" || b.status === "qa") return { kind: "claim", recordId };
10515
+ if (typeof b.status === "string" && HANDOFF_STATUSES.has(b.status)) return { kind: "handoff", recordId };
10500
10516
  }
10517
+ if (/records_controller_create$/.test(tool) && typeof b.ticket_id === "string") return { kind: "comment", recordId: b.ticket_id };
10518
+ return void 0;
10501
10519
  }
10502
10520
 
10503
- // src/pending-handoff.ts
10504
- var PENDING_MAX_AGE_MS = 48 * 36e5;
10505
- var pendingDirFor = (stateDir) => join25(stateDir, "pending-handoffs");
10506
- var stateDirOfStream = (streamPath) => join25(streamPath, "..", "..");
10507
- function writePendingHandoff(stateDir, rec) {
10508
- const dir = pendingDirFor(stateDir);
10509
- mkdirSync16(dir, { recursive: true });
10510
- writeFileSync17(join25(dir, `${rec.ticketRecordId}.json`), JSON.stringify(rec, null, 2));
10521
+ // src/handoff-transcript.ts
10522
+ var TRANSPORT_FAILURE = /\b50[0-4]\b|<!doctype|<html|unexpected token '?<|fetch failed|econn(reset|refused)|etimedout|socket hang up|timed out|bad gateway|service unavailable/i;
10523
+ function resultText(content) {
10524
+ if (typeof content === "string") return content;
10525
+ if (Array.isArray(content)) return content.map((c) => c && typeof c.text === "string" ? c.text : "").join("\n");
10526
+ return "";
10511
10527
  }
10512
- function listPendingHandoffs(stateDir, route2) {
10513
- const dir = pendingDirFor(stateDir);
10514
- if (!existsSync23(dir)) return [];
10515
- const out = [];
10516
- for (const f of readdirSync7(dir).filter((n) => n.endsWith(".json")).sort()) {
10528
+ function isTransportFailure(isError, text2) {
10529
+ if (!isError) {
10517
10530
  try {
10518
- const rec = JSON.parse(readFileSync23(join25(dir, f), "utf8"));
10519
- if (rec.version === 1 && rec.ticketRecordId && (!route2 || rec.route === route2)) out.push(rec);
10531
+ const doc = JSON.parse(text2);
10532
+ if (doc && typeof doc === "object") {
10533
+ const code = doc.statusCode;
10534
+ return typeof code === "number" && code >= 500;
10535
+ }
10520
10536
  } catch {
10521
10537
  }
10522
10538
  }
10523
- return out;
10539
+ return TRANSPORT_FAILURE.test(text2);
10524
10540
  }
10525
- function removePendingHandoff(stateDir, ticketRecordId) {
10526
- rmSync13(join25(pendingDirFor(stateDir), `${ticketRecordId}.json`), { force: true });
10541
+ var FORBIDDEN_WRITE = /\b403\b[^]*?No WRITE access/i;
10542
+ function isForbiddenWrite(isError, text2) {
10543
+ if (FORBIDDEN_WRITE.test(text2)) return true;
10544
+ if (!isError) {
10545
+ try {
10546
+ const doc = JSON.parse(text2);
10547
+ return !!doc && typeof doc === "object" && doc.statusCode === 403;
10548
+ } catch {
10549
+ }
10550
+ }
10551
+ return false;
10527
10552
  }
10528
- function pendingHandoffFor(stateDir, ticketRecordId) {
10529
- return listPendingHandoffs(stateDir).find((r) => r.ticketRecordId === ticketRecordId);
10553
+ function forbiddenDetail403(text2) {
10554
+ const m = /No WRITE access to (?:"[^"]+"|this model|[\w-]+)/i.exec(text2);
10555
+ return m ? m[0] : "a hand-off write was refused";
10530
10556
  }
10531
- var firstLine = (s) => s.trim().split("\n")[0] ?? "";
10532
- async function replayPendingHandoffs(i) {
10533
- const now = i.now ?? /* @__PURE__ */ new Date();
10534
- const landed = [];
10535
- for (const rec of listPendingHandoffs(i.stateDir, i.route).filter((r) => !i.role || r.role === i.role)) {
10536
- const at = { ticket: rec.ticket, step: "handoff" };
10537
- if (now.getTime() - Date.parse(rec.createdAt) > PENDING_MAX_AGE_MS) {
10538
- i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: dropped a pending hand-off older than ${PENDING_MAX_AGE_MS / 36e5}h`, at);
10539
- removePendingHandoff(i.stateDir, rec.ticketRecordId);
10540
- continue;
10541
- }
10542
- if (now.toISOString() < rec.nextAttemptAt) continue;
10543
- const seatId = i.seats[rec.role];
10544
- try {
10545
- const t = await i.writer.ticket(rec.ticketRecordId);
10546
- const st = i.contract.statuses;
10547
- if (!seatId || t.status !== st.building || rec.patch.status !== st.handoff) {
10548
- i.log.emit(`${t.issue_id}: pending hand-off no longer applies (ticket is at ${t.status}) \u2014 dropped`, at);
10549
- removePendingHandoff(i.stateDir, rec.ticketRecordId);
10550
- continue;
10551
- }
10552
- const co = i.checkout(t);
10553
- const v = co ? inspectHandoff(co.dir, co.cfg, t, rec.role) : null;
10554
- if (!v || v.kind === "bounce") {
10555
- i.log.warn(`${t.issue_id}: pending hand-off dropped \u2014 ${v ? v.reason : "this ship has no checkout of the ticket's repo"}`, at);
10556
- removePendingHandoff(i.stateDir, rec.ticketRecordId);
10557
- continue;
10558
- }
10559
- if (v.kind === "unverified") throw new Error(v.why);
10560
- const text2 = rec.comment ?? (rec.commentLanded ? "" : rec.finalText);
10561
- if (text2) {
10562
- const head = firstLine(text2);
10563
- const cc = i.contract.comments;
10564
- const seen = (await i.writer.comments()).some((c) => {
10565
- const row = c;
10566
- return row[cc.parent] === t.id && row[cc.author] === seatId && firstLine(String(row[cc.body] ?? "")) === head;
10567
- });
10568
- if (!seen) await i.writer.postComment(t.id, text2, seatId);
10569
- }
10570
- await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), seatId, "pushed");
10571
- await i.writer.updateTicket(t.id, rec.patch);
10572
- removePendingHandoff(i.stateDir, rec.ticketRecordId);
10573
- i.log.emit(`${t.issue_id}: replayed a pending hand-off at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, at);
10574
- landed.push(t.id);
10575
- } catch (e) {
10576
- const attempts = rec.attempts + 1;
10577
- writePendingHandoff(i.stateDir, {
10578
- ...rec,
10579
- attempts,
10580
- warned: true,
10581
- nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
10582
- });
10583
- if (!rec.warned) {
10584
- i.log.warn(`${rec.ticket ?? rec.ticketRecordId}: a hand-off is pending and could not be replayed yet (${e.message}); will retry`, at);
10557
+ var HandoffTranscriptTracker = class {
10558
+ open = /* @__PURE__ */ new Map();
10559
+ /** Per ticket record: the last hand-off status write's outcome. */
10560
+ status = /* @__PURE__ */ new Map();
10561
+ /** Per ticket record: the last hand-off comment that failed after the status write's turn. */
10562
+ comment = /* @__PURE__ */ new Map();
10563
+ landed = /* @__PURE__ */ new Set();
10564
+ /** Per ticket record: the last hand-off status write, when the tracker refused it with 403. */
10565
+ forbiddenWrites = /* @__PURE__ */ new Map();
10566
+ statusLanded = false;
10567
+ lastText = "";
10568
+ resultLine = "";
10569
+ feed(parsed) {
10570
+ const line = parsed;
10571
+ if (!line || typeof line !== "object") return;
10572
+ if (line.type === "result" && typeof line.result === "string") this.resultLine = line.result;
10573
+ const content = line.message?.content;
10574
+ if (!Array.isArray(content)) return;
10575
+ for (const b of content) {
10576
+ if (line.type === "assistant" && b.type === "text" && b.text?.trim()) this.lastText = b.text.trim();
10577
+ else if (line.type === "assistant" && b.type === "tool_use" && b.id) {
10578
+ const w = classifyWrite(b.name, b.input);
10579
+ const body = b.input?.body;
10580
+ if (w?.recordId && (w.kind === "handoff" || w.kind === "comment") && body && typeof body === "object") {
10581
+ this.open.set(b.id, { kind: w.kind, recordId: w.recordId, body });
10582
+ }
10583
+ } else if (line.type === "user" && b.type === "tool_result" && b.tool_use_id) {
10584
+ const w = this.open.get(b.tool_use_id);
10585
+ if (!w) continue;
10586
+ this.open.delete(b.tool_use_id);
10587
+ const text2 = resultText(b.content);
10588
+ if (w.kind === "handoff") {
10589
+ const refused = isForbiddenWrite(b.is_error === true, text2);
10590
+ if (refused) this.forbiddenWrites.set(w.recordId, forbiddenDetail403(text2));
10591
+ else {
10592
+ this.forbiddenWrites.delete(w.recordId);
10593
+ if (!isTransportFailure(b.is_error === true, text2)) this.statusLanded = true;
10594
+ }
10595
+ }
10596
+ const failed = isTransportFailure(b.is_error === true, text2);
10597
+ if (w.kind === "handoff") this.status.set(w.recordId, { ok: !failed, patch: w.body });
10598
+ else if (failed && typeof w.body.body === "string") this.comment.set(w.recordId, w.body.body);
10599
+ else if (!failed) {
10600
+ this.comment.delete(w.recordId);
10601
+ this.landed.add(w.recordId);
10602
+ }
10585
10603
  }
10586
10604
  }
10587
10605
  }
10588
- return landed;
10589
- }
10606
+ /** The newest hand-off write the tracker refused with 403 and no later success for that ticket, else null. */
10607
+ forbidden() {
10608
+ const last = [...this.forbiddenWrites].at(-1);
10609
+ return last ? { ticketRecordId: last[0], detail: last[1] } : null;
10610
+ }
10611
+ /** A hand-off status write went through, so the seat can write what a hand-off needs. */
10612
+ handoffLanded() {
10613
+ return this.statusLanded && this.forbiddenWrites.size === 0;
10614
+ }
10615
+ /** The hand-off that did not land, or null when every hand-off write succeeded (or none was tried). */
10616
+ pending() {
10617
+ const failed = [...this.status].filter(([, s2]) => !s2.ok).at(-1);
10618
+ if (!failed) return null;
10619
+ const [ticketRecordId, s] = failed;
10620
+ return {
10621
+ ticketRecordId,
10622
+ patch: s.patch,
10623
+ ...this.comment.has(ticketRecordId) ? { comment: this.comment.get(ticketRecordId) } : {},
10624
+ ...this.landed.has(ticketRecordId) ? { commentLanded: true } : {},
10625
+ finalText: this.lastText || this.resultLine
10626
+ };
10627
+ }
10628
+ };
10590
10629
 
10591
10630
  // src/skill-include.ts
10592
10631
  var SkillIncludeError = class extends Error {
@@ -17037,7 +17076,13 @@ function otherRunsRemoved(tickets, own) {
17037
17076
  return tickets.filter((t) => t.id === own.ticket.id || !own.inFlight(t.id));
17038
17077
  }
17039
17078
  function ownRunBanner(own) {
17040
- return `**Your ticket for this run: ${own.ticket.issue_id}.** Work this one. Any other ticket a live run on this ship is already working has been left out of this digest: it belongs to that run.`;
17079
+ const banner = `**Your ticket for this run: ${own.ticket.issue_id}.** Work this one. Any other ticket a live run on this ship is already working has been left out of this digest: it belongs to that run.`;
17080
+ const block = acceptanceChecklistBlock(acceptanceLines(own.ticket.description));
17081
+ return block ? `${banner}
17082
+
17083
+ \`\`\`
17084
+ ${block}
17085
+ \`\`\`` : banner;
17041
17086
  }
17042
17087
  function writeDigest(o, d, role, stateDir, runId, own) {
17043
17088
  const me = o.route.resolved?.seats[role];
@@ -21001,6 +21046,10 @@ async function listLogEntries(route2, opts = {}) {
21001
21046
  ...filters.length > 0 ? { filters: JSON.stringify(filters) } : {}
21002
21047
  });
21003
21048
  const newestFirst = [...entries].sort((a, b) => String(b.started_at ?? "").localeCompare(String(a.started_at ?? "")));
21049
+ if (opts.bounces) {
21050
+ const n = (e) => e.handoff_bounces ?? -1;
21051
+ return newestFirst.sort((a, b) => n(b) - n(a));
21052
+ }
21004
21053
  if (!opts.beforeEdit) return newestFirst;
21005
21054
  const searches = (e) => e.calls_before_edit_search ?? -1;
21006
21055
  return newestFirst.sort((a, b) => searches(b) - searches(a));
@@ -25430,7 +25479,7 @@ function usage(problem, requested = false) {
25430
25479
  crew repos clone [ROUTE] NAME clone a Repos row's remote into the route directory and attach it (--dir PATH, --dry-run, --json)
25431
25480
  crew repos hooks [ROUTE] NAME suggest test/build hooks for a checkout (--agent, --write route|repo, --recipes, --json)
25432
25481
  crew repos list ROUTE the checkouts a route has, with the tracker's Repos row each matches (--json: every Repos row too)
25433
- crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket; --before-edit sorts by searches before the first edit
25482
+ crew logbook list [route] recent Agent Log entries, filterable by --role/--ticket; --before-edit sorts by searches before the first edit, --bounces by acceptance bounces
25434
25483
  crew logbook show [route] ID [--prompt] one entry; --prompt reconstructs and verifies its prompt
25435
25484
  crew passengers [route] sync Host Passengers containers/tunnels once; what the passengers unit invokes
25436
25485
  crew install write and load this platform's scheduler unit
@@ -28197,7 +28246,7 @@ ${cu.tail.trimEnd()}
28197
28246
  const sub = positional[1];
28198
28247
  if (sub !== "list" && sub !== "show") {
28199
28248
  process.stderr.write(
28200
- "crew logbook list [route] [--role R] [--ticket T] [--limit N] [--before-edit]\ncrew logbook show [route] <entryId> [--prompt]\n"
28249
+ "crew logbook list [route] [--role R] [--ticket T] [--limit N] [--before-edit] [--bounces]\ncrew logbook show [route] <entryId> [--prompt]\n"
28201
28250
  );
28202
28251
  process.exit(2);
28203
28252
  }
@@ -28225,7 +28274,8 @@ ${cu.tail.trimEnd()}
28225
28274
  role: value("role"),
28226
28275
  ticket: value("ticket"),
28227
28276
  limit: value("limit") ? Number(value("limit")) : void 0,
28228
- beforeEdit: flag("before-edit")
28277
+ beforeEdit: flag("before-edit"),
28278
+ bounces: flag("bounces")
28229
28279
  });
28230
28280
  if (entries.length === 0) {
28231
28281
  process.stdout.write("(no Agent Log entries match)\n");