@tablation/crew 0.75.6 → 0.77.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.
Files changed (2) hide show
  1. package/dist/cli.js +386 -155
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3997,6 +3997,9 @@ function sweepStaleReleaseRoots(tmp, now = Date.now(), maxAgeMs = 60 * 60 * 1e3)
3997
3997
  function pushBranch(cwd, remote, branch, dst = branch) {
3998
3998
  git(cwd, ["push", remote, `${branch}:${dst}`]);
3999
3999
  }
4000
+ function pushBranchUpstream(cwd, remote, branch, dst = branch) {
4001
+ git(cwd, ["push", "--set-upstream", remote, `${branch}:${dst}`]);
4002
+ }
4000
4003
  function worktrees(cwd) {
4001
4004
  const out = gitOk(cwd, ["worktree", "list", "--porcelain"]) ?? "";
4002
4005
  const list = [];
@@ -4850,6 +4853,158 @@ function renderEnvironment(i) {
4850
4853
  return lines.join("\n");
4851
4854
  }
4852
4855
 
4856
+ // src/contract.ts
4857
+ var DEFAULT_CONTRACT = {
4858
+ columns: {
4859
+ key: "issue_id",
4860
+ tag: "issue_tag",
4861
+ title: "title",
4862
+ status: "status",
4863
+ assignee: "assignee_id",
4864
+ priority: "priority",
4865
+ severity: "severity",
4866
+ slice: "project_id",
4867
+ repo: "repo_id",
4868
+ parent: "parent_id",
4869
+ blockedBy: "blocked_by",
4870
+ updatedAt: "updated_at",
4871
+ needsDesign: "needs_design",
4872
+ attachments: "attachments",
4873
+ workMode: "work_mode",
4874
+ reportType: "report_type",
4875
+ epic: "epic_id",
4876
+ author: "filed_by_id",
4877
+ ship: "ship_id",
4878
+ heldBy: "held_by_ship_id",
4879
+ resolutionNote: "resolution_note",
4880
+ verifiedSha: "verified_sha",
4881
+ prRef: "pr_ref",
4882
+ externalId: "external_id",
4883
+ position: "position",
4884
+ agentRuns: "agent_runs",
4885
+ tokensIn: "tokens_in",
4886
+ tokensOut: "tokens_out",
4887
+ cacheReadTokens: "cache_read_tokens",
4888
+ cacheWriteTokens: "cache_write_tokens",
4889
+ costUsd: "cost_usd"
4890
+ },
4891
+ comments: {
4892
+ parent: "ticket_id",
4893
+ body: "body",
4894
+ author: "team_member_id",
4895
+ createdAt: "created_at",
4896
+ ship: "ship_id",
4897
+ publicReply: "is_public_reply",
4898
+ authorName: "author_name"
4899
+ },
4900
+ statuses: {
4901
+ open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
4902
+ resolved: [
4903
+ "verified",
4904
+ "closed_deployed",
4905
+ "closed_wont_fix",
4906
+ "closed_duplicate",
4907
+ "closed_completed",
4908
+ "closed_cant_reproduce"
4909
+ ],
4910
+ approved: "accepted",
4911
+ parked: "blocked",
4912
+ building: "in_progress",
4913
+ handoff: "fixed",
4914
+ verifying: "qa",
4915
+ verified: "verified",
4916
+ deployed: "closed_deployed",
4917
+ needsHuman: "needs_info",
4918
+ reviewing: null,
4919
+ wontFix: "closed_wont_fix",
4920
+ draft: "draft"
4921
+ },
4922
+ epics: {
4923
+ statusColumn: "status",
4924
+ keyColumn: "epic_id",
4925
+ statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
4926
+ },
4927
+ priorityOrder: ["p0", "p1", "p2", "p3"],
4928
+ severityOrder: ["s1", "s2", "s3", "s4"],
4929
+ unknownPriorityRank: 2,
4930
+ coordinatingValue: "coordinating",
4931
+ manualWorkMode: "manual",
4932
+ hybridWorkMode: "hybrid",
4933
+ repoExemptReportTypes: ["coordinating", "question", "investigation"]
4934
+ };
4935
+ function closedStatuses(c) {
4936
+ return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
4937
+ }
4938
+ function resolveContract(override) {
4939
+ if (!override) return DEFAULT_CONTRACT;
4940
+ return {
4941
+ columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
4942
+ comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
4943
+ statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
4944
+ epics: {
4945
+ ...DEFAULT_CONTRACT.epics,
4946
+ ...override.epics,
4947
+ statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
4948
+ },
4949
+ priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
4950
+ severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
4951
+ unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
4952
+ coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
4953
+ manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
4954
+ hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
4955
+ // A workspace overriding `coordinatingValue` alone (renaming the rollup
4956
+ // marker) still gets that renamed value exempted here by default,
4957
+ // without also having to repeat it — only an explicit
4958
+ // `repoExemptReportTypes` overrides this derived default.
4959
+ repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
4960
+ };
4961
+ }
4962
+ function validateContract(c) {
4963
+ const problems = [];
4964
+ const open = new Set(c.statuses.open);
4965
+ const resolved = new Set(c.statuses.resolved);
4966
+ for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
4967
+ const v = c.statuses[role];
4968
+ if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
4969
+ }
4970
+ if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
4971
+ problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
4972
+ }
4973
+ if (!resolved.has(c.statuses.deployed)) {
4974
+ problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
4975
+ }
4976
+ if (resolved.has(c.statuses.handoff)) {
4977
+ problems.push(
4978
+ `statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
4979
+ );
4980
+ }
4981
+ if (!resolved.has(c.statuses.wontFix)) {
4982
+ problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
4983
+ }
4984
+ if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
4985
+ problems.push(
4986
+ `statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
4987
+ );
4988
+ }
4989
+ if (resolved.has(c.statuses.approved)) {
4990
+ problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
4991
+ }
4992
+ if (c.statuses.parked === c.statuses.approved) {
4993
+ problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
4994
+ }
4995
+ const epicValues = Object.values(c.epics.statuses);
4996
+ if (new Set(epicValues).size !== epicValues.length) {
4997
+ problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
4998
+ }
4999
+ if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
5000
+ if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
5001
+ for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
5002
+ if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
5003
+ }
5004
+ return problems;
5005
+ }
5006
+ var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5007
+
4853
5008
  // src/agent-log.ts
4854
5009
  import { existsSync as existsSync11, mkdirSync as mkdirSync6, readdirSync as readdirSync4, readFileSync as readFileSync11, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "fs";
4855
5010
  import { join as join12 } from "path";
@@ -4867,6 +5022,61 @@ var AgentLogHttpError = class extends Error {
4867
5022
  this.status = status2;
4868
5023
  }
4869
5024
  };
5025
+ async function request(target, method, path, body) {
5026
+ const url = `${target.baseUrl.replace(/\/+$/, "")}/api${path}`;
5027
+ const res = await fetch(url, {
5028
+ method,
5029
+ headers: {
5030
+ Authorization: `Bearer ${target.apiKey}`,
5031
+ "User-Agent": target.userAgent,
5032
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
5033
+ },
5034
+ body: body === void 0 ? void 0 : JSON.stringify(body)
5035
+ });
5036
+ if (!res.ok) throw new AgentLogHttpError(`${path}: ${res.status} ${res.statusText}`, res.status);
5037
+ return await res.json();
5038
+ }
5039
+ var issueColumns = /* @__PURE__ */ new Map();
5040
+ function columnsOf(target, modelId) {
5041
+ const key = `${target.baseUrl}|${target.workspaceId}|${modelId}`;
5042
+ let p = issueColumns.get(key);
5043
+ if (!p) {
5044
+ p = request(target, "GET", `/data-models/${modelId}`).then((m) => new Set((m.fields ?? []).map((f) => f.columnName)));
5045
+ p.catch(() => issueColumns.delete(key));
5046
+ issueColumns.set(key, p);
5047
+ }
5048
+ return p;
5049
+ }
5050
+ async function findTicketRecord(target, t, ref) {
5051
+ const filters = {
5052
+ combinator: "OR",
5053
+ conditions: [
5054
+ { columnName: t.keyColumn, operator: "EQ", value: ref },
5055
+ { columnName: t.tagColumn, operator: "EQ", value: ref }
5056
+ ]
5057
+ };
5058
+ const rows = await request(
5059
+ target,
5060
+ "GET",
5061
+ `/data-models/${t.issuesModelId}/records?limit=2&filters=${encodeURIComponent(JSON.stringify(filters))}`
5062
+ );
5063
+ return rows.length === 1 ? rows[0].id : void 0;
5064
+ }
5065
+ function counterIncrements(run2, cols, have) {
5066
+ const figures = [
5067
+ [cols.agentRuns, 1],
5068
+ [cols.tokensIn, run2.tokensIn],
5069
+ [cols.tokensOut, run2.tokensOut],
5070
+ [cols.cacheReadTokens, run2.cacheReadTokens],
5071
+ [cols.cacheWriteTokens, run2.cacheWriteTokens],
5072
+ [cols.costUsd, run2.costUsd]
5073
+ ];
5074
+ const out = {};
5075
+ for (const [column, value2] of figures) {
5076
+ if (value2 !== void 0 && Number.isFinite(value2) && have.has(column)) out[column] = { $op: "increment", value: value2 };
5077
+ }
5078
+ return out;
5079
+ }
4870
5080
  async function post(target, path, body) {
4871
5081
  const url = `${target.baseUrl.replace(/\/+$/, "")}/api/workspaces/${target.workspaceId}${path}`;
4872
5082
  const res = await fetch(url, {
@@ -4883,6 +5093,13 @@ async function post(target, path, body) {
4883
5093
  return await res.json();
4884
5094
  }
4885
5095
  async function reportAgentRun(target, run2) {
5096
+ let ticketRecordId;
5097
+ if (target.tickets && run2.ticketReference) {
5098
+ try {
5099
+ ticketRecordId = await findTicketRecord(target, target.tickets, run2.ticketReference);
5100
+ } catch {
5101
+ }
5102
+ }
4886
5103
  const body = {
4887
5104
  agentId: target.agentId,
4888
5105
  ticketReference: run2.ticketReference,
@@ -4900,13 +5117,14 @@ async function reportAgentRun(target, run2) {
4900
5117
  promptVersion: run2.promptVersion,
4901
5118
  promptSha: run2.promptSha
4902
5119
  };
5120
+ const workItem = target.tickets && ticketRecordId ? { workItemTable: target.tickets.issuesModelId, workItemRecordId: ticketRecordId } : {};
4903
5121
  let entry;
4904
5122
  try {
4905
- entry = await post(target, "/agents/log", { ...body, harness: run2.harness, provider: run2.provider });
5123
+ entry = await post(target, "/agents/log", { ...body, ...workItem, harness: run2.harness, provider: run2.provider });
4906
5124
  } catch (e) {
4907
5125
  if (!(e instanceof AgentLogHttpError)) throw new AgentLogUnreachableError(e.message);
4908
5126
  if (e.status >= 500) throw new AgentLogUnreachableError(e.message);
4909
- if (!(e instanceof AgentLogHttpError) || e.status !== 400 || !run2.harness && !run2.provider) throw e;
5127
+ if (e.status !== 400 || !run2.harness && !run2.provider && !ticketRecordId) throw e;
4910
5128
  entry = await post(target, "/agents/log", body);
4911
5129
  }
4912
5130
  for (const cycle of run2.cycles) {
@@ -4917,7 +5135,37 @@ async function reportAgentRun(target, run2) {
4917
5135
  ...cycle.role ? { role: cycle.role } : {}
4918
5136
  });
4919
5137
  }
4920
- return { agentLogId: entry.id };
5138
+ const counters = target.tickets && ticketRecordId ? await addRunToTicket(target, target.tickets, ticketRecordId, run2) : void 0;
5139
+ return {
5140
+ agentLogId: entry.id,
5141
+ ...counters?.warning ? { counterWarning: counters.warning } : {},
5142
+ ...counters?.retry ? { counterRetry: counters.retry } : {}
5143
+ };
5144
+ }
5145
+ var isRetryable = (e) => !(e instanceof AgentLogHttpError) || e.status >= 500;
5146
+ async function writeCounters(target, w) {
5147
+ const have = await columnsOf(target, w.issuesModelId);
5148
+ const body = Object.fromEntries(Object.entries(w.body).filter(([column]) => have.has(column)));
5149
+ if (Object.keys(body).length === 0) return;
5150
+ await request(target, "PATCH", `/data-models/${w.issuesModelId}/records/${w.recordId}`, body);
5151
+ }
5152
+ async function addRunToTicket(target, t, recordId, run2) {
5153
+ const write = {
5154
+ issuesModelId: t.issuesModelId,
5155
+ recordId,
5156
+ ticketReference: run2.ticketReference,
5157
+ counters: t.counters,
5158
+ body: counterIncrements(run2, t.counters, new Set(Object.values(t.counters)))
5159
+ };
5160
+ try {
5161
+ await writeCounters(target, write);
5162
+ return {};
5163
+ } catch (e) {
5164
+ return {
5165
+ warning: `could not add run spend to ${run2.ticketReference}: ${e.message}`,
5166
+ ...isRetryable(e) ? { retry: write } : {}
5167
+ };
5168
+ }
4921
5169
  }
4922
5170
  var queueDir = (stateDir) => join12(stateDir, "pending-agent-logs");
4923
5171
  function queueAgentRun(stateDir, name, q) {
@@ -4940,7 +5188,17 @@ async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__P
4940
5188
  try {
4941
5189
  const apiKey = await keyFor(q.role);
4942
5190
  if (!apiKey) continue;
4943
- await reportAgentRun({ ...q.target, apiKey }, q.report);
5191
+ const sentRun = await reportAgentRun({ ...q.target, apiKey }, q.report);
5192
+ if (sentRun.counterWarning) log.warn(sentRun.counterWarning);
5193
+ if (sentRun.counterRetry) queueTicketCounters(stateDir, f.replace(/\.json$/, ""), {
5194
+ version: 1,
5195
+ route: q.route,
5196
+ role: q.role,
5197
+ target: q.target,
5198
+ write: sentRun.counterRetry,
5199
+ attempts: 0,
5200
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
5201
+ });
4944
5202
  rmSync6(path, { force: true });
4945
5203
  sent += 1;
4946
5204
  log.emit(`sent a queued Agent Log report (${f})`);
@@ -4962,6 +5220,49 @@ async function replayQueuedAgentRuns(stateDir, route, keyFor, log, now = /* @__P
4962
5220
  }
4963
5221
  return sent;
4964
5222
  }
5223
+ var countersDir = (stateDir) => join12(stateDir, "pending-ticket-counters");
5224
+ function queueTicketCounters(stateDir, name, q) {
5225
+ mkdirSync6(countersDir(stateDir), { recursive: true });
5226
+ writeFileSync7(join12(countersDir(stateDir), `${name}.json`), JSON.stringify(q));
5227
+ }
5228
+ async function replayQueuedTicketCounters(stateDir, route, keyFor, log, now = /* @__PURE__ */ new Date()) {
5229
+ const dir = countersDir(stateDir);
5230
+ if (!existsSync11(dir)) return 0;
5231
+ let sent = 0;
5232
+ for (const f of readdirSync4(dir).filter((n) => n.endsWith(".json")).sort()) {
5233
+ const path = join12(dir, f);
5234
+ let q;
5235
+ try {
5236
+ q = JSON.parse(readFileSync11(path, "utf8"));
5237
+ } catch {
5238
+ continue;
5239
+ }
5240
+ if (q.version !== 1 || q.route !== route || now.toISOString() < q.nextAttemptAt) continue;
5241
+ try {
5242
+ const apiKey = await keyFor(q.role);
5243
+ if (!apiKey) continue;
5244
+ await writeCounters({ ...q.target, apiKey }, q.write);
5245
+ rmSync6(path, { force: true });
5246
+ sent += 1;
5247
+ log.emit(`added a queued run's spend to ${q.write.ticketReference ?? q.write.recordId} (${f})`);
5248
+ } catch (e) {
5249
+ if (!isRetryable(e)) {
5250
+ rmSync6(path, { force: true });
5251
+ log.warn(`dropped a queued ticket counter update (${f}): ${e.message}`);
5252
+ continue;
5253
+ }
5254
+ const attempts = q.attempts + 1;
5255
+ writeFileSync7(path, JSON.stringify({
5256
+ ...q,
5257
+ attempts,
5258
+ warned: true,
5259
+ nextAttemptAt: new Date(now.getTime() + sleepBackoff(attempts)).toISOString()
5260
+ }));
5261
+ if (!q.warned) log.warn(`a queued ticket counter update could not be sent yet (${e.message}); will retry`);
5262
+ }
5263
+ }
5264
+ return sent;
5265
+ }
4965
5266
 
4966
5267
  // src/ticket-attribution.ts
4967
5268
  var TICKET_KEY_RE = /\b[A-Z][A-Z0-9]{1,9}-\d+\b/g;
@@ -5142,152 +5443,6 @@ import { basename as basename6 } from "path";
5142
5443
  import { hostname } from "os";
5143
5444
  import { TablationClient } from "@tablation/client";
5144
5445
 
5145
- // src/contract.ts
5146
- var DEFAULT_CONTRACT = {
5147
- columns: {
5148
- key: "issue_id",
5149
- tag: "issue_tag",
5150
- title: "title",
5151
- status: "status",
5152
- assignee: "assignee_id",
5153
- priority: "priority",
5154
- severity: "severity",
5155
- slice: "project_id",
5156
- repo: "repo_id",
5157
- parent: "parent_id",
5158
- blockedBy: "blocked_by",
5159
- updatedAt: "updated_at",
5160
- needsDesign: "needs_design",
5161
- attachments: "attachments",
5162
- workMode: "work_mode",
5163
- reportType: "report_type",
5164
- epic: "epic_id",
5165
- author: "filed_by_id",
5166
- ship: "ship_id",
5167
- heldBy: "held_by_ship_id",
5168
- resolutionNote: "resolution_note",
5169
- verifiedSha: "verified_sha",
5170
- prRef: "pr_ref",
5171
- externalId: "external_id",
5172
- position: "position"
5173
- },
5174
- comments: {
5175
- parent: "ticket_id",
5176
- body: "body",
5177
- author: "team_member_id",
5178
- createdAt: "created_at",
5179
- ship: "ship_id",
5180
- publicReply: "is_public_reply",
5181
- authorName: "author_name"
5182
- },
5183
- statuses: {
5184
- open: ["new", "accepted", "blocked", "in_progress", "needs_info", "fixed", "qa", "verified"],
5185
- resolved: [
5186
- "verified",
5187
- "closed_deployed",
5188
- "closed_wont_fix",
5189
- "closed_duplicate",
5190
- "closed_completed",
5191
- "closed_cant_reproduce"
5192
- ],
5193
- approved: "accepted",
5194
- parked: "blocked",
5195
- building: "in_progress",
5196
- handoff: "fixed",
5197
- verifying: "qa",
5198
- verified: "verified",
5199
- deployed: "closed_deployed",
5200
- needsHuman: "needs_info",
5201
- reviewing: null,
5202
- wontFix: "closed_wont_fix",
5203
- draft: "draft"
5204
- },
5205
- epics: {
5206
- statusColumn: "status",
5207
- keyColumn: "epic_id",
5208
- statuses: { planned: "planned", building: "in_progress", done: "done", cancelled: "cancelled" }
5209
- },
5210
- priorityOrder: ["p0", "p1", "p2", "p3"],
5211
- severityOrder: ["s1", "s2", "s3", "s4"],
5212
- unknownPriorityRank: 2,
5213
- coordinatingValue: "coordinating",
5214
- manualWorkMode: "manual",
5215
- hybridWorkMode: "hybrid",
5216
- repoExemptReportTypes: ["coordinating", "question", "investigation"]
5217
- };
5218
- function closedStatuses(c) {
5219
- return c.statuses.resolved.filter((s) => s !== c.statuses.verified);
5220
- }
5221
- function resolveContract(override) {
5222
- if (!override) return DEFAULT_CONTRACT;
5223
- return {
5224
- columns: { ...DEFAULT_CONTRACT.columns, ...override.columns },
5225
- comments: { ...DEFAULT_CONTRACT.comments, ...override.comments },
5226
- statuses: { ...DEFAULT_CONTRACT.statuses, ...override.statuses },
5227
- epics: {
5228
- ...DEFAULT_CONTRACT.epics,
5229
- ...override.epics,
5230
- statuses: { ...DEFAULT_CONTRACT.epics.statuses, ...override.epics?.statuses }
5231
- },
5232
- priorityOrder: override.priorityOrder ?? DEFAULT_CONTRACT.priorityOrder,
5233
- severityOrder: override.severityOrder ?? DEFAULT_CONTRACT.severityOrder,
5234
- unknownPriorityRank: override.unknownPriorityRank ?? DEFAULT_CONTRACT.unknownPriorityRank,
5235
- coordinatingValue: override.coordinatingValue ?? DEFAULT_CONTRACT.coordinatingValue,
5236
- manualWorkMode: override.manualWorkMode ?? DEFAULT_CONTRACT.manualWorkMode,
5237
- hybridWorkMode: override.hybridWorkMode ?? DEFAULT_CONTRACT.hybridWorkMode,
5238
- // A workspace overriding `coordinatingValue` alone (renaming the rollup
5239
- // marker) still gets that renamed value exempted here by default,
5240
- // without also having to repeat it — only an explicit
5241
- // `repoExemptReportTypes` overrides this derived default.
5242
- repoExemptReportTypes: override.repoExemptReportTypes ?? (override.coordinatingValue ? [override.coordinatingValue, "question", "investigation"] : DEFAULT_CONTRACT.repoExemptReportTypes)
5243
- };
5244
- }
5245
- function validateContract(c) {
5246
- const problems = [];
5247
- const open = new Set(c.statuses.open);
5248
- const resolved = new Set(c.statuses.resolved);
5249
- for (const role of ["approved", "parked", "building", "handoff", "verifying", "needsHuman"]) {
5250
- const v = c.statuses[role];
5251
- if (!open.has(v)) problems.push(`statuses.${role} ("${v}") is not listed in statuses.open`);
5252
- }
5253
- if (c.statuses.reviewing && !open.has(c.statuses.reviewing)) {
5254
- problems.push(`statuses.reviewing ("${c.statuses.reviewing}") is not listed in statuses.open`);
5255
- }
5256
- if (!resolved.has(c.statuses.deployed)) {
5257
- problems.push(`statuses.deployed ("${c.statuses.deployed}") is not listed in statuses.resolved`);
5258
- }
5259
- if (resolved.has(c.statuses.handoff)) {
5260
- problems.push(
5261
- `statuses.handoff ("${c.statuses.handoff}") is listed as resolved \u2014 it means built-but-unchecked, so a ticket blocked by it is still blocked; calling it resolved would build on unverified work`
5262
- );
5263
- }
5264
- if (!resolved.has(c.statuses.wontFix)) {
5265
- problems.push(`statuses.wontFix ("${c.statuses.wontFix}") is not listed in statuses.resolved`);
5266
- }
5267
- if (c.statuses.reviewing && resolved.has(c.statuses.reviewing)) {
5268
- problems.push(
5269
- `statuses.reviewing ("${c.statuses.reviewing}") is listed as resolved \u2014 it is unmerged work under review, so a ticket blocked by it is still blocked`
5270
- );
5271
- }
5272
- if (resolved.has(c.statuses.approved)) {
5273
- problems.push(`statuses.approved ("${c.statuses.approved}") is listed as resolved`);
5274
- }
5275
- if (c.statuses.parked === c.statuses.approved) {
5276
- problems.push("statuses.parked and statuses.approved are the same value \u2014 parking would be a no-op");
5277
- }
5278
- const epicValues = Object.values(c.epics.statuses);
5279
- if (new Set(epicValues).size !== epicValues.length) {
5280
- problems.push("epics.statuses names the same value twice \u2014 the epic sync could not tell those states apart");
5281
- }
5282
- if (c.priorityOrder.length === 0) problems.push("priorityOrder is empty");
5283
- if (c.severityOrder.length === 0) problems.push("severityOrder is empty");
5284
- for (const [name, order] of [["priorityOrder", c.priorityOrder], ["severityOrder", c.severityOrder]]) {
5285
- if (new Set(order).size !== order.length) problems.push(`${name} contains duplicates`);
5286
- }
5287
- return problems;
5288
- }
5289
- var EVENT_LOG_MISSING = "the Issues table has no event log \u2014 crew writes its audit trail there (CREW-1574). In the app, open the Issues table's Data & lifecycle tab and turn on Event log, then rerun.";
5290
-
5291
5446
  // src/state.ts
5292
5447
  import { readFileSync as readFileSync12, writeFileSync as writeFileSync8, existsSync as existsSync12, mkdirSync as mkdirSync7, rmSync as rmSync7, statSync as statSync4 } from "fs";
5293
5448
  import { join as join13 } from "path";
@@ -6989,7 +7144,15 @@ function inspectHandoff(dir, cfg, ticket, role) {
6989
7144
  }
6990
7145
  return { kind: "pushed", branch, sha: origin, remote };
6991
7146
  }
6992
- function bounceComment(v, shipName2) {
7147
+ function pushableBounce(dir, v) {
7148
+ if (!v.branch || !v.local) return false;
7149
+ return !v.origin || isAncestor(dir, v.origin, v.local);
7150
+ }
7151
+ function describeHandoffPush(dir, cfg, ticket, role) {
7152
+ const v = inspectHandoff(dir, cfg, ticket, role);
7153
+ return v.kind === "bounce" && pushableBounce(dir, v) ? `would push ${v.branch} to ${v.remote}` : null;
7154
+ }
7155
+ function bounceComment(v, shipName2, pushError) {
6993
7156
  return `${HANDOFF_BOUNCED_MARKER}
6994
7157
 
6995
7158
  **Hand-off bounced: the branch is not on \`${v.remote}\` at the tip this ticket was built to.**
@@ -6998,7 +7161,7 @@ ${shipName2 ? `Ship: ${shipName2}
6998
7161
  ` : ""}Local HEAD: ${v.local ? `\`${v.local}\`` : "(no local branch)"}
6999
7162
  \`${v.remote}\`: ${v.origin ? `\`${v.origin}\`` : v.branch ? "(branch missing)" : "(no branch to look for)"}
7000
7163
 
7001
- ${v.reason[0].toUpperCase()}${v.reason.slice(1)}. Set back to \`in_progress\`, still held by this ship; push with a plain \`git push --set-upstream ${v.remote} <branch>\` (never \`--force\`) and set \`fixed\` again. Your brief authorizes this push: it is the hand-off, not a release, so do it now rather than asking for permission.`;
7164
+ ${v.reason[0].toUpperCase()}${v.reason.slice(1)}. ${pushError ? ` The runner tried a plain push and git refused it: \`${pushError}\`.` : ""} Set back to \`in_progress\`, still held by this ship; push with a plain \`git push --set-upstream ${v.remote} <branch>\` (never \`--force\`) and set \`fixed\` again. Your brief authorizes this push: it is the hand-off, not a release, so do it now rather than asking for permission.`;
7002
7165
  }
7003
7166
  async function takenOverSince(i, ticketRow) {
7004
7167
  if (!i.since || !i.writer.events) return false;
@@ -7048,12 +7211,35 @@ async function enforceHandoff(i) {
7048
7211
  i.log.warn(`${t.issue_id} has no local branch on this ship, so it is not this ship's hand-off to bounce: ${v.reason}`, log);
7049
7212
  return { kind: "unverified", why: "no local branch on this ship" };
7050
7213
  }
7214
+ let pushError;
7215
+ if (pushableBounce(i.dir, v)) {
7216
+ const wt = worktrees(i.dir).find((w) => w.branch === v.branch);
7217
+ try {
7218
+ pushBranchUpstream(wt?.path ?? i.dir, v.remote, v.branch);
7219
+ const again = inspectHandoff(i.dir, i.cfg, t, i.role);
7220
+ if (again.kind === "pushed") {
7221
+ await i.writer.postEvent(t.id, pushedEventBody(again.sha, again.remote, again.branch), i.seatId, "pushed");
7222
+ await i.writer.postEvent(
7223
+ t.id,
7224
+ `The runner pushed \`${again.branch}\` to \`${again.remote}\` on ${i.shipName ? `${i.shipName}'s` : "this ship's"} behalf: the ${i.role} seat set \`${i.contract.statuses.handoff}\` without pushing. Plain fast-forward push.${i.cfg.hooks.ci_status ? " The repo's `ci_status` hook was NOT run by the hand-off guard, so CI was not checked by the builder." : ""}`,
7225
+ i.seatId,
7226
+ "pushed_by_runner"
7227
+ ).catch(() => i.log.warn("pushed, but the note failed to post", log));
7228
+ 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);
7229
+ return { kind: "pushed", sha: again.sha };
7230
+ }
7231
+ pushError = `the push succeeded but ${again.kind === "bounce" ? again.reason : again.why}`;
7232
+ } catch (e) {
7233
+ pushError = e.message;
7234
+ }
7235
+ i.log.warn(`${t.issue_id}: the runner's own push failed: ${pushError}`, log);
7236
+ }
7051
7237
  await i.writer.updateTicket(t.id, {
7052
7238
  status: i.contract.statuses.building,
7053
7239
  ...t.assignee_id ? {} : { assignee_id: i.seatId },
7054
7240
  ...i.hold && !heldBy ? { [i.hold.column]: i.hold.shipId } : {}
7055
7241
  });
7056
- await i.writer.postEvent(t.id, bounceComment(v, i.shipName), i.seatId, "bounced").catch(() => {
7242
+ await i.writer.postEvent(t.id, bounceComment(v, i.shipName, pushError), i.seatId, "bounced").catch(() => {
7057
7243
  i.log.warn("bounced, but the note failed to post", log);
7058
7244
  });
7059
7245
  i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: ${v.reason}`, log);
@@ -8110,12 +8296,26 @@ function planAgentRun(o) {
8110
8296
  // resolved yet has nothing to authenticate the MCP connection with either.
8111
8297
  mcp: o.apiKey ? { url: `${o.route.baseUrl}/api/mcp`, keyVar: API_KEY_VAR } : void 0
8112
8298
  });
8299
+ const columns = resolveContract(o.route.contract).columns;
8113
8300
  const agentLog = o.route.resolved && o.apiKey ? {
8114
8301
  baseUrl: o.route.baseUrl,
8115
8302
  workspaceId: o.route.resolved.workspaceId,
8116
8303
  apiKey: o.apiKey,
8117
8304
  userAgent: o.ship.userAgent,
8118
- agentId: o.resolvedAgentId
8305
+ agentId: o.resolvedAgentId,
8306
+ ...o.route.resolved.models?.issues ? { tickets: {
8307
+ issuesModelId: o.route.resolved.models.issues,
8308
+ keyColumn: columns.key,
8309
+ tagColumn: columns.tag,
8310
+ counters: {
8311
+ agentRuns: columns.agentRuns,
8312
+ tokensIn: columns.tokensIn,
8313
+ tokensOut: columns.tokensOut,
8314
+ cacheReadTokens: columns.cacheReadTokens,
8315
+ cacheWriteTokens: columns.cacheWriteTokens,
8316
+ costUsd: columns.costUsd
8317
+ }
8318
+ } } : {}
8119
8319
  } : void 0;
8120
8320
  return {
8121
8321
  role: o.role,
@@ -8485,7 +8685,22 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8485
8685
  promptSha: id.promptSha
8486
8686
  };
8487
8687
  try {
8488
- await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
8688
+ const sent = await reportAgentRun({ ...id.agentLog, apiKey: agentLogKey }, report);
8689
+ if (sent.counterWarning) emit.warn(sent.counterWarning, { step: "agent" });
8690
+ if (sent.counterRetry) {
8691
+ try {
8692
+ queueTicketCounters(stateDirOfStream(id.streamPath), basename7(id.streamPath, ".jsonl"), {
8693
+ version: 1,
8694
+ route: id.route,
8695
+ role: id.role,
8696
+ target: id.agentLog,
8697
+ write: sent.counterRetry,
8698
+ attempts: 0,
8699
+ nextAttemptAt: (/* @__PURE__ */ new Date()).toISOString()
8700
+ });
8701
+ } catch {
8702
+ }
8703
+ }
8489
8704
  } catch (e) {
8490
8705
  if (e instanceof AgentLogUnreachableError) {
8491
8706
  try {
@@ -16433,6 +16648,12 @@ async function replayPendingWrites(ctx) {
16433
16648
  async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
16434
16649
  emit
16435
16650
  );
16651
+ await replayQueuedTicketCounters(
16652
+ ship.stateDir,
16653
+ routeSlug(route.route),
16654
+ async (role) => (await resolveSeatKey(route, role, ctx.getSessionStore)).apiKey,
16655
+ emit
16656
+ );
16436
16657
  } catch (e) {
16437
16658
  emit.warn(`could not replay pending writes: ${e.message}`, { step: "agent" });
16438
16659
  }
@@ -18250,6 +18471,16 @@ ${changed2.map((c) => ` - ${c}
18250
18471
  if (dryRun) {
18251
18472
  process.stdout.write(`${describePlan(plan)}
18252
18473
  `);
18474
+ if (workingId && (current === "dev" || current === "design")) {
18475
+ try {
18476
+ const t = await tracker2.ticket(workingId);
18477
+ const r = repoConfigFor(route, t);
18478
+ const would = r && describeHandoffPush(r.target.dir, r.cfg, t, current);
18479
+ if (would) process.stdout.write(`${would}
18480
+ `);
18481
+ } catch {
18482
+ }
18483
+ }
18253
18484
  break;
18254
18485
  }
18255
18486
  const before = snapshot(await tracker2.openTickets());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.75.6",
3
+ "version": "0.77.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",