@tablation/crew 0.78.0 → 0.79.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
@@ -230,6 +230,13 @@ function extractTurnTokens(line) {
230
230
  if (!usage2) return void 0;
231
231
  return (usage2.input_tokens ?? 0) + (usage2.output_tokens ?? 0) + (usage2.cache_read_input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
232
232
  }
233
+ function extractPrefixTokens(line) {
234
+ const obj = line;
235
+ if (!obj || obj.type !== "assistant") return void 0;
236
+ const usage2 = obj.message?.usage;
237
+ if (!usage2) return void 0;
238
+ return (usage2.input_tokens ?? 0) + (usage2.cache_creation_input_tokens ?? 0);
239
+ }
233
240
  function extractResult(line) {
234
241
  const obj = line;
235
242
  if (!obj || obj.type !== "result") return void 0;
@@ -580,6 +587,7 @@ var claudeAdapter = {
580
587
  mapStreamLine,
581
588
  extractResult,
582
589
  extractTurnTokens,
590
+ extractPrefixTokens,
583
591
  inspectMcp(o) {
584
592
  const s = inspectClaudeMcp(o);
585
593
  return s.state === "no-claude" ? { state: "no-binary" } : s;
@@ -5114,6 +5122,8 @@ async function reportAgentRun(target, run2) {
5114
5122
  cacheReadTokens: run2.cacheReadTokens,
5115
5123
  cacheWriteTokens: run2.cacheWriteTokens,
5116
5124
  costUsd: run2.costUsd,
5125
+ numTurns: run2.numTurns,
5126
+ prefixTokens: run2.prefixTokens,
5117
5127
  promptVersion: run2.promptVersion,
5118
5128
  promptSha: run2.promptSha
5119
5129
  };
@@ -5271,24 +5281,31 @@ function ticketMentions(text) {
5271
5281
  }
5272
5282
  var TicketAttributionTracker = class {
5273
5283
  #tokensByTicket = /* @__PURE__ */ new Map();
5284
+ #turns = 0;
5274
5285
  #current;
5275
5286
  /** Tracker record uuid → ticket key, learned when a write names both (a claim resolves the key from the last mention). */
5276
5287
  #keyByRecord = /* @__PURE__ */ new Map();
5277
5288
  #lastBy = {};
5278
- /** The ticket key most recently named in the transcript, or undefined before any. */
5289
+ /**
5290
+ * The ticket key most recently named in the transcript, or undefined before any.
5291
+ * Only the FIRST key in a turn counts: a closing summary names its own
5292
+ * ticket first and then lists others ("Other tickets", "Blocked by", "See
5293
+ * also"), and those trailing keys are not what the run worked (CREW-2598).
5294
+ */
5279
5295
  get current() {
5280
5296
  return this.#current;
5281
5297
  }
5282
5298
  /** Record the mentions in `text` without crediting tokens (a claim needs "which ticket" before the turn's usage line arrives). */
5283
5299
  note(text) {
5284
5300
  const mentions = ticketMentions(text);
5285
- if (mentions.length > 0) this.#current = mentions[mentions.length - 1];
5301
+ if (mentions.length > 0) this.#current = mentions[0];
5286
5302
  }
5287
5303
  /** One assistant turn: its combined visible text (thinking + text blocks) and its own token cost. */
5288
5304
  add(text, tokens) {
5289
5305
  const mentions = ticketMentions(text);
5290
- if (mentions.length > 0) this.#current = mentions[mentions.length - 1];
5306
+ if (mentions.length > 0) this.#current = mentions[0];
5291
5307
  if (this.#current === void 0 || tokens <= 0) return;
5308
+ this.#turns++;
5292
5309
  this.#tokensByTicket.set(this.#current, (this.#tokensByTicket.get(this.#current) ?? 0) + tokens);
5293
5310
  }
5294
5311
  /**
@@ -5310,16 +5327,31 @@ var TicketAttributionTracker = class {
5310
5327
  /**
5311
5328
  * How the attribution is decided: a hand-off write beats a claim, a claim
5312
5329
  * beats a comment (so a follow-up ticket filed or commented on while
5313
- * working never displaces the claimed one), and any write beats the
5314
- * mention-token heuristic, which is only the fallback for runs that made
5315
- * no ticket-targeted write. The pre-run hint is last.
5330
+ * working never displaces the claimed one), and any write beats everything
5331
+ * below. Next, `hintMoved` — the post-run read-back showed the hinted
5332
+ * ticket changed status or assignee during the run — is a hand-off signal
5333
+ * for a run that wrote through MCP tools crew cannot see. Then the
5334
+ * mention-token heuristic, which with a hint may override it only when a
5335
+ * different ticket holds a strict majority of the credited tokens (a single
5336
+ * credited turn, like a lone end-of-run summary naming another ticket,
5337
+ * must not). A run with no hint
5338
+ * keeps the plain winner. The pre-run hint is last.
5316
5339
  */
5317
- decide(hint) {
5340
+ decide(hint, opts = {}) {
5318
5341
  for (const by of ["handoff", "claim", "comment"]) {
5319
5342
  const ticket = this.#lastBy[by];
5320
5343
  if (ticket) return { ticket, by };
5321
5344
  }
5322
- if (this.#tokensByTicket.size > 0) return { ticket: this.winner(hint), by: "mentions" };
5345
+ if (hint && opts.hintMoved) return { ticket: hint, by: "readback" };
5346
+ if (this.#tokensByTicket.size > 0) {
5347
+ if (!hint) return { ticket: this.winner(), by: "mentions" };
5348
+ const winner = this.winner(hint);
5349
+ if (winner === hint) return { ticket: hint, by: "mentions" };
5350
+ let total = 0;
5351
+ for (const n of this.#tokensByTicket.values()) total += n;
5352
+ const share = (this.#tokensByTicket.get(winner) ?? 0) / total;
5353
+ return share > 0.5 && this.#turns > 1 ? { ticket: winner, by: "mentions" } : { ticket: hint, by: "hint" };
5354
+ }
5323
5355
  return { ticket: hint, by: "hint" };
5324
5356
  }
5325
5357
  /**
@@ -7099,6 +7131,28 @@ async function finishRunWorktree(dir, cfg, branch) {
7099
7131
  // src/handoff-guard.ts
7100
7132
  var HANDOFF_BOUNCED_MARKER = calloutHeader("WARNING", "Hand-off Bounced");
7101
7133
  var pushedEventBody = (sha, remote, branch) => `pushed ${sha} to ${remote}/${branch}`;
7134
+ function acceptanceLines(description) {
7135
+ if (typeof description !== "string") return [];
7136
+ const out = [];
7137
+ let inside = false;
7138
+ for (const raw of description.split("\n")) {
7139
+ const heading = /^#{1,6}\s+(.*?)\s*$/.exec(raw);
7140
+ if (heading) {
7141
+ inside = /^acceptance\b/i.test(heading[1]);
7142
+ continue;
7143
+ }
7144
+ if (!inside) continue;
7145
+ const bullet = /^\s*(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+?)\s*$/.exec(raw);
7146
+ if (bullet) out.push(bullet[1]);
7147
+ }
7148
+ return out;
7149
+ }
7150
+ var flat = (s) => s.replace(/[`*_>]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
7151
+ function unquotedAcceptance(lines, comment) {
7152
+ const body = flat(comment);
7153
+ return lines.filter((l) => !body.includes(flat(l)));
7154
+ }
7155
+ var ACCEPTANCE_BOUNCED_MARKER = calloutHeader("WARNING", "Acceptance Checklist Missing");
7102
7156
  function remoteTip(dir, remote, branch) {
7103
7157
  const out = gitOk(dir, ["ls-remote", "--heads", remote, `refs/heads/${branch}`]);
7104
7158
  if (out === null) return null;
@@ -7174,6 +7228,27 @@ async function takenOverSince(i, ticketRow) {
7174
7228
  return !!by && by !== i.shipName;
7175
7229
  });
7176
7230
  }
7231
+ async function missingAcceptance(i, t) {
7232
+ const lines = acceptanceLines(t.description);
7233
+ if (lines.length === 0 || !i.writer.comments) return null;
7234
+ const cc = i.contract.comments;
7235
+ const mine = (await i.writer.comments()).map((c) => c).filter((r) => r[cc.parent] === t.id && r[cc.author] === i.seatId).sort((a, b) => String(a[cc.createdAt] ?? "").localeCompare(String(b[cc.createdAt] ?? "")));
7236
+ const last = mine.at(-1);
7237
+ const missing = unquotedAcceptance(lines, last ? String(last[cc.body] ?? "") : "");
7238
+ return missing.length > 0 ? missing : null;
7239
+ }
7240
+ function acceptanceBounceComment(missing, shipName2) {
7241
+ return `${ACCEPTANCE_BOUNCED_MARKER}
7242
+
7243
+ **Hand-off bounced: the hand-off comment does not quote every line of the ticket's Acceptance section.**
7244
+
7245
+ ${shipName2 ? `Ship: ${shipName2}
7246
+
7247
+ ` : ""}Not quoted verbatim:
7248
+ ${missing.map((l) => `- ${l}`).join("\n")}
7249
+
7250
+ 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.`;
7251
+ }
7177
7252
  async function enforceHandoff(i) {
7178
7253
  try {
7179
7254
  const t = await i.writer.ticket(i.ticketId);
@@ -7189,6 +7264,26 @@ async function enforceHandoff(i) {
7189
7264
  i.log.warn(`${t.issue_id} is at ${t.status} but the push was not checked: ${v.why}`, log);
7190
7265
  return { kind: "unverified", why: v.why };
7191
7266
  }
7267
+ if (v.kind === "pushed" && !claimed) {
7268
+ const missing = await missingAcceptance(i, t);
7269
+ if (missing) {
7270
+ const heldNow = i.hold ? t[i.hold.column] : void 0;
7271
+ if (i.hold && heldNow && heldNow !== i.hold.shipId || await takenOverSince(i, t.id)) {
7272
+ i.log.warn(`${t.issue_id} is held by another ship now \u2014 the acceptance check is stale, left alone`, log);
7273
+ return { kind: "unverified", why: "ticket is held by another ship" };
7274
+ }
7275
+ await i.writer.updateTicket(t.id, {
7276
+ status: i.contract.statuses.building,
7277
+ ...t.assignee_id ? {} : { assignee_id: i.seatId },
7278
+ ...i.hold && !heldNow ? { [i.hold.column]: i.hold.shipId } : {}
7279
+ });
7280
+ await i.writer.postEvent(t.id, acceptanceBounceComment(missing, i.shipName), i.seatId, "bounced").catch(() => {
7281
+ i.log.warn("bounced, but the note failed to post", log);
7282
+ });
7283
+ i.log.warn(`${t.issue_id} bounced back to ${i.contract.statuses.building}: hand-off comment lacks the acceptance checklist`, log);
7284
+ return { kind: "bounced" };
7285
+ }
7286
+ }
7192
7287
  if (v.kind === "pushed") {
7193
7288
  await i.writer.postEvent(t.id, pushedEventBody(v.sha, v.remote, v.branch), i.seatId, "pushed");
7194
7289
  i.log.emit(`${t.issue_id} pushed at ${v.sha.slice(0, 7)} \u2014 handed on to QA`, log);
@@ -8467,6 +8562,8 @@ var RunObserver = class {
8467
8562
  /** Hand-off writes and their outcomes, to tell a run whose hand-off never landed (CREW-2363). */
8468
8563
  handoff = new HandoffTranscriptTracker();
8469
8564
  result;
8565
+ /** Prefix tokens of the first assistant call that carried usage; kept once. */
8566
+ prefixTokens;
8470
8567
  /** Set once the stream carries the harness's final result (turn count or cost). */
8471
8568
  sawResult = false;
8472
8569
  muted = false;
@@ -8593,6 +8690,7 @@ var RunObserver = class {
8593
8690
  }
8594
8691
  }
8595
8692
  }
8693
+ if (this.prefixTokens === void 0 && parsed !== void 0) this.prefixTokens = this.adapter.extractPrefixTokens?.(parsed);
8596
8694
  const turnTokens = parsed === void 0 ? void 0 : this.adapter.extractTurnTokens(parsed);
8597
8695
  if (turnTokens !== void 0) this.attribution.add(turnText, turnTokens);
8598
8696
  }
@@ -8616,8 +8714,15 @@ function writeRunMeta(id, startedAt, liveTicket, extra) {
8616
8714
  } catch {
8617
8715
  }
8618
8716
  }
8619
- async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8620
- const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket);
8717
+ async function finishRun(id, obs, emit, startedAt, end, agentLogKey, hintMoved) {
8718
+ let moved = false;
8719
+ if (hintMoved) {
8720
+ try {
8721
+ moved = await hintMoved();
8722
+ } catch {
8723
+ }
8724
+ }
8725
+ const { ticket: attributedTicket, by: attributedBy } = obs.attribution.decide(obs.liveTicket, { hintMoved: moved });
8621
8726
  const identity = { toolchain: id.toolchain, harness: id.harness, provider: id.provider };
8622
8727
  const { result } = obs;
8623
8728
  const pending = end.interrupted ? null : obs.handoff.pending();
@@ -8649,6 +8754,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8649
8754
  ...pending ? { handoff: "pending" } : {},
8650
8755
  sessionId: result?.sessionId ?? null,
8651
8756
  numTurns: result?.numTurns ?? null,
8757
+ prefixTokens: obs.prefixTokens ?? null,
8652
8758
  totalCostUsd: result?.costUsd ?? null
8653
8759
  });
8654
8760
  } catch {
@@ -8661,6 +8767,8 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8661
8767
  ...pending ? { handoff: "pending" } : {},
8662
8768
  ...end.interrupted ? { interrupted: end.interrupted } : {},
8663
8769
  ...identity,
8770
+ attributedTicket: attributedTicket ?? null,
8771
+ attributedBy,
8664
8772
  ...result?.sessionId ? { sessionId: result.sessionId } : {},
8665
8773
  ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
8666
8774
  ...result?.costUsd !== void 0 ? { totalCostUsd: result.costUsd } : {}
@@ -8679,6 +8787,9 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8679
8787
  cacheReadTokens: result?.cacheReadTokens,
8680
8788
  cacheWriteTokens: result?.cacheWriteTokens,
8681
8789
  costUsd: result?.costUsd,
8790
+ // Both come from a finished run: without the result line (interrupted) neither is trustworthy.
8791
+ ...result?.numTurns !== void 0 ? { numTurns: result.numTurns } : {},
8792
+ ...result?.numTurns !== void 0 && obs.prefixTokens !== void 0 ? { prefixTokens: obs.prefixTokens } : {},
8682
8793
  harness: identity.harness,
8683
8794
  provider: identity.provider,
8684
8795
  promptVersion: id.promptVersion,
@@ -8723,7 +8834,7 @@ async function finishRun(id, obs, emit, startedAt, end, agentLogKey) {
8723
8834
  }
8724
8835
  return { ms: ms2, ticket: attributedTicket, handoffPending: !!pending };
8725
8836
  }
8726
- function spawnAgent(plan, emit) {
8837
+ function spawnAgent(plan, emit, hintMoved) {
8727
8838
  const adapter = adapterFor(plan.harness ?? LEGACY_TOOLCHAIN_HARNESS);
8728
8839
  const started = Date.now();
8729
8840
  const startedAt = new Date(started).toISOString();
@@ -8788,7 +8899,7 @@ function spawnAgent(plan, emit) {
8788
8899
  obs.end();
8789
8900
  await endSink(eventsSink);
8790
8901
  if (spawnFailed) return;
8791
- const { ms: ms2, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey);
8902
+ const { ms: ms2, handoffPending } = await finishRun(id, obs, emit, startedAt, { code: code ?? 1 }, plan.agentLog?.apiKey, hintMoved);
8792
8903
  resolve6({ code: code ?? 1, ms: ms2, ...handoffPending ? { handoffPending } : {} });
8793
8904
  });
8794
8905
  child.stdin?.on("error", (err) => {
@@ -16914,7 +17025,19 @@ async function runRoleAgent(role, decision, ctx) {
16914
17025
  }
16915
17026
  try {
16916
17027
  const runStart = (/* @__PURE__ */ new Date()).toISOString();
16917
- await spawnAgent(plan, emit);
17028
+ let hintMoved;
17029
+ if (workingId) {
17030
+ try {
17031
+ const before = await tracker.ticket(workingId);
17032
+ const col = tracker.contract.columns;
17033
+ hintMoved = async () => {
17034
+ const after = await tracker.ticket(workingId);
17035
+ return after[col.status] !== before[col.status] || after[col.assignee] !== before[col.assignee];
17036
+ };
17037
+ } catch {
17038
+ }
17039
+ }
17040
+ await spawnAgent(plan, emit, hintMoved);
16918
17041
  await guardRunHandoff(route, ship, tracker, role, workingId, emit, runStart);
16919
17042
  } finally {
16920
17043
  if (memberId) {
@@ -19383,7 +19506,7 @@ ${changed2.map((c) => ` - ${c}
19383
19506
  }
19384
19507
  for (const e of entries) {
19385
19508
  process.stdout.write(
19386
- `${e.id} ${e.started_at ?? "?"} ${e.outcome ?? "?"}${e.ticket_reference ? ` ${e.ticket_reference}` : ""}${e.model ? ` ${e.model}` : ""}${e.harness || e.provider ? ` (${[e.harness, e.provider].filter(Boolean).join(" \xB7 ")})` : ""}
19509
+ `${e.id} ${e.started_at ?? "?"} ${e.outcome ?? "?"}${e.ticket_reference ? ` ${e.ticket_reference}` : ""}${e.model ? ` ${e.model}` : ""}${e.harness || e.provider ? ` (${[e.harness, e.provider].filter(Boolean).join(" \xB7 ")})` : ""}${e.num_turns != null ? ` turns ${e.num_turns}` : ""}${e.prefix_tokens != null ? ` prefix ${e.prefix_tokens}` : ""}
19387
19510
  `
19388
19511
  );
19389
19512
  }
@@ -19401,7 +19524,9 @@ ticket: ${entry.ticket_reference ?? "(none)"}
19401
19524
  model: ${entry.model ?? "?"}
19402
19525
  harness: ${entry.harness ?? "?"}
19403
19526
  provider: ${entry.provider ?? "?"}
19404
- `
19527
+ ` + (entry.num_turns != null ? `turns: ${entry.num_turns}
19528
+ ` : "") + (entry.prefix_tokens != null ? `prefix: ${entry.prefix_tokens} tokens
19529
+ ` : "")
19405
19530
  );
19406
19531
  if (flag("prompt")) {
19407
19532
  process.stdout.write(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.78.0",
3
+ "version": "0.79.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",
@@ -94,6 +94,11 @@ pass.
94
94
  Judge by exit code and summary line, and quote the summary in your
95
95
  comment.
96
96
  7. **Check the hand-off itself.**
97
+ - **Acceptance checklist.** Start from the builder's checklist: their
98
+ comment quotes each Acceptance line with the check that exercised it.
99
+ Re-run the checks that matter, and when a line fails, a bounce cites that
100
+ line by its quoted text. A line the builder marked unexercised is yours
101
+ to exercise.
97
102
  - **Documentation.** If the repository's agent instructions (CLAUDE.md,
98
103
  AGENTS.md, GEMINI.md or equivalent) name a user-facing documentation
99
104
  location, a change to a user-facing surface must come with a matching
@@ -416,6 +416,14 @@ before going further, and never change a ticket's `report_type` yourself.
416
416
  used, and anything you could not test yourself. Anything not committed and
417
417
  pushed is gone.
418
418
 
419
+ **When the ticket has an Acceptance section, your last progress comment
420
+ quotes it.** List every line of that section verbatim, each followed by the
421
+ test, command or manual check that exercised it in this run and its result.
422
+ A line you could not exercise is listed with the reason, never omitted. The
423
+ runner checks your newest comment for every line and, if one is missing,
424
+ sets the ticket back to `in_progress` instead of passing it to QA. A ticket
425
+ with no Acceptance section is unaffected.
426
+
419
427
  ## Step 4 — you never merge, and you never deploy
420
428
 
421
429
  **Nothing you do touches the base branch.** The release phase takes every
@@ -93,6 +93,11 @@ pass.
93
93
  Judge by exit code and summary line, and quote the summary in your
94
94
  comment.
95
95
  7. **Check the hand-off itself.**
96
+ - **Acceptance checklist.** Start from the builder's checklist: their
97
+ comment quotes each Acceptance line with the check that exercised it.
98
+ Re-run the checks that matter, and when a line fails, a bounce cites that
99
+ line by its quoted text. A line the builder marked unexercised is yours
100
+ to exercise.
96
101
  - **Documentation.** If the repository's agent instructions (CLAUDE.md,
97
102
  AGENTS.md, GEMINI.md or equivalent) name a user-facing documentation
98
103
  location, a change to a user-facing surface must come with a matching
@@ -411,6 +411,14 @@ before going further, and never change a ticket's `report_type` yourself.
411
411
  used, and anything you could not test yourself. Anything not committed and
412
412
  pushed is gone.
413
413
 
414
+ **When the ticket has an Acceptance section, your last progress comment
415
+ quotes it.** List every line of that section verbatim, each followed by the
416
+ test, command or manual check that exercised it in this run and its result.
417
+ A line you could not exercise is listed with the reason, never omitted. The
418
+ runner checks your newest comment for every line and, if one is missing,
419
+ sets the ticket back to `in_progress` instead of passing it to QA. A ticket
420
+ with no Acceptance section is unaffected.
421
+
414
422
  ## Step 4 — you never merge, and you never deploy
415
423
 
416
424
  **Nothing you do touches the base branch.** The release phase takes every