@xhulz/nina 0.28.21 → 0.29.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 (130) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/releases/0.29.0/core/GAPS.md +125 -0
  4. package/releases/0.29.0/core/answered.json +29 -0
  5. package/releases/0.29.0/core/defaults/tree/.claude/agents/architect.md +1 -0
  6. package/releases/0.29.0/core/defaults/tree/.claude/agents/dba.md +3 -0
  7. package/releases/0.29.0/core/defaults/tree/.claude/agents/devops.md +1 -0
  8. package/releases/0.29.0/core/defaults/tree/.claude/agents/implementer.md +1 -0
  9. package/releases/0.29.0/core/defaults/tree/.claude/agents/integration-tester.md +3 -0
  10. package/releases/0.29.0/core/defaults/tree/.claude/agents/qa.md +2 -0
  11. package/releases/0.29.0/core/defaults/tree/.claude/agents/reviewer.md +3 -0
  12. package/releases/0.29.0/core/defaults/tree/.claude/agents/secops.md +3 -0
  13. package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-auditor.md +3 -0
  14. package/releases/0.29.0/core/defaults/tree/.claude/agents/solidity-dev.md +3 -0
  15. package/releases/0.29.0/core/tree/.claude/agents/architect.md +125 -0
  16. package/releases/0.29.0/core/tree/.claude/agents/dba.md +107 -0
  17. package/releases/0.29.0/core/tree/.claude/agents/devops.md +113 -0
  18. package/releases/0.29.0/core/tree/.claude/agents/implementer.md +155 -0
  19. package/releases/0.29.0/core/tree/.claude/agents/integration-tester.md +160 -0
  20. package/releases/0.29.0/core/tree/.claude/agents/planner.md +132 -0
  21. package/releases/0.29.0/core/tree/.claude/agents/qa.md +146 -0
  22. package/releases/0.29.0/core/tree/.claude/agents/reviewer.md +180 -0
  23. package/releases/0.29.0/core/tree/.claude/agents/secops.md +121 -0
  24. package/releases/0.29.0/core/tree/.claude/agents/solidity-auditor.md +126 -0
  25. package/releases/0.29.0/core/tree/.claude/agents/solidity-dev.md +123 -0
  26. package/releases/0.29.0/core/tree/.claude/agents-overview.md +64 -0
  27. package/releases/0.29.0/core/tree/.claude/graph.md +73 -0
  28. package/releases/0.29.0/core/tree/.claude/patterns.md +212 -0
  29. package/releases/0.29.0/core/tree/.claude/pills/README.md +145 -0
  30. package/releases/0.29.0/core/tree/.claude/pipeline.md +102 -0
  31. package/releases/0.29.0/core/tree/.claude/retrieval.md +104 -0
  32. package/releases/0.29.0/core/tree/.claude/router.md +201 -0
  33. package/releases/0.29.0/core/tree/.claude/templates/integration.md +45 -0
  34. package/releases/0.29.0/core/tree/CLAUDE.md +155 -0
  35. package/releases/0.29.0/core/tree/scripts/edit-guard.mjs +16 -0
  36. package/releases/0.29.0/core/tree/scripts/harness-check.mjs +71 -0
  37. package/releases/0.29.0/core/tree/scripts/loop-gate.mjs +19 -0
  38. package/releases/0.29.0/core/vocabulary.json +12 -0
  39. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/architect.md +5 -0
  40. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/devops.md +5 -0
  41. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/reviewer.md +5 -0
  42. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-auditor.md +18 -0
  43. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents/solidity-dev.md +16 -0
  44. package/releases/0.29.0/surfaces/blockchain/tree/.claude/agents-overview.md +6 -0
  45. package/releases/0.29.0/surfaces/blockchain/tree/.claude/graph.md +11 -0
  46. package/releases/0.29.0/surfaces/blockchain/tree/.claude/router.md +9 -0
  47. package/releases/0.29.0/surfaces/blockchain/tree/CLAUDE.md +12 -0
  48. package/releases/0.29.0/surfaces/db/tree/.claude/agents/architect.md +8 -0
  49. package/releases/0.29.0/surfaces/db/tree/.claude/agents/dba.md +84 -0
  50. package/releases/0.29.0/surfaces/db/tree/.claude/agents/devops.md +14 -0
  51. package/releases/0.29.0/surfaces/db/tree/.claude/agents/implementer.md +5 -0
  52. package/releases/0.29.0/surfaces/db/tree/.claude/agents/integration-tester.md +13 -0
  53. package/releases/0.29.0/surfaces/db/tree/.claude/agents/planner.md +2 -0
  54. package/releases/0.29.0/surfaces/db/tree/.claude/agents/reviewer.md +25 -0
  55. package/releases/0.29.0/surfaces/db/tree/.claude/agents/secops.md +2 -0
  56. package/releases/0.29.0/surfaces/db/tree/.claude/agents-overview.md +5 -0
  57. package/releases/0.29.0/surfaces/db/tree/.claude/graph.md +8 -0
  58. package/releases/0.29.0/surfaces/db/tree/.claude/patterns.md +29 -0
  59. package/releases/0.29.0/surfaces/db/tree/.claude/pipeline.md +13 -0
  60. package/releases/0.29.0/surfaces/db/tree/.claude/retrieval.md +5 -0
  61. package/releases/0.29.0/surfaces/db/tree/.claude/router.md +10 -0
  62. package/releases/0.29.0/surfaces/db/tree/CLAUDE.md +23 -0
  63. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/architect.md +14 -0
  64. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/dba.md +2 -0
  65. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/devops.md +31 -0
  66. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/implementer.md +14 -0
  67. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/integration-tester.md +11 -0
  68. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/qa.md +3 -0
  69. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/reviewer.md +8 -0
  70. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/agents/secops.md +8 -0
  71. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/patterns.md +17 -0
  72. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/retrieval.md +8 -0
  73. package/releases/0.29.0/surfaces/edge-cf/tree/.claude/router.md +21 -0
  74. package/releases/0.29.0/surfaces/edge-cf/tree/CLAUDE.md +27 -0
  75. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/architect.md +20 -0
  76. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/devops.md +28 -0
  77. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/implementer.md +25 -0
  78. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/qa.md +17 -0
  79. package/releases/0.29.0/surfaces/frontend/tree/.claude/agents/reviewer.md +62 -0
  80. package/releases/0.29.0/surfaces/frontend/tree/.claude/patterns.md +15 -0
  81. package/releases/0.29.0/surfaces/frontend/tree/.claude/retrieval.md +5 -0
  82. package/releases/0.29.0/surfaces/frontend/tree/.claude/router.md +17 -0
  83. package/releases/0.29.0/surfaces/frontend/tree/CLAUDE.md +5 -0
  84. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/architect.md +16 -0
  85. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/implementer.md +12 -0
  86. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/integration-tester.md +20 -0
  87. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/planner.md +2 -0
  88. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/reviewer.md +21 -0
  89. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents/secops.md +2 -0
  90. package/releases/0.29.0/surfaces/integrations/tree/.claude/agents-overview.md +8 -0
  91. package/releases/0.29.0/surfaces/integrations/tree/.claude/graph.md +9 -0
  92. package/releases/0.29.0/surfaces/integrations/tree/.claude/patterns.md +38 -0
  93. package/releases/0.29.0/surfaces/integrations/tree/.claude/pipeline.md +16 -0
  94. package/releases/0.29.0/surfaces/integrations/tree/.claude/retrieval.md +16 -0
  95. package/releases/0.29.0/surfaces/integrations/tree/.claude/router.md +6 -0
  96. package/releases/0.29.0/surfaces/integrations/tree/CLAUDE.md +17 -0
  97. package/releases/0.29.0/surfaces/money/tree/.claude/agents/architect.md +12 -0
  98. package/releases/0.29.0/surfaces/money/tree/.claude/agents/dba.md +14 -0
  99. package/releases/0.29.0/surfaces/money/tree/.claude/agents/implementer.md +11 -0
  100. package/releases/0.29.0/surfaces/money/tree/.claude/agents/integration-tester.md +3 -0
  101. package/releases/0.29.0/surfaces/money/tree/.claude/agents/reviewer.md +23 -0
  102. package/releases/0.29.0/surfaces/money/tree/.claude/agents/secops.md +5 -0
  103. package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-auditor.md +4 -0
  104. package/releases/0.29.0/surfaces/money/tree/.claude/agents/solidity-dev.md +4 -0
  105. package/releases/0.29.0/surfaces/money/tree/.claude/graph.md +2 -0
  106. package/releases/0.29.0/surfaces/money/tree/.claude/patterns.md +49 -0
  107. package/releases/0.29.0/surfaces/money/tree/.claude/pipeline.md +2 -0
  108. package/releases/0.29.0/surfaces/money/tree/.claude/retrieval.md +2 -0
  109. package/releases/0.29.0/surfaces/money/tree/CLAUDE.md +11 -0
  110. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/dba.md +2 -0
  111. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/devops.md +8 -0
  112. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/reviewer.md +8 -0
  113. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/secops.md +2 -0
  114. package/releases/0.29.0/surfaces/pii/tree/.claude/agents/solidity-auditor.md +5 -0
  115. package/releases/0.29.0/surfaces/pii/tree/CLAUDE.md +5 -0
  116. package/src/agentrun.mjs +12 -4
  117. package/src/commands/compose.mjs +45 -1
  118. package/src/commands/export.mjs +15 -4
  119. package/src/commands/gate.mjs +91 -8
  120. package/src/commands/init.mjs +34 -15
  121. package/src/commands/learn.mjs +22 -9
  122. package/src/commands/pipeline.mjs +17 -42
  123. package/src/commands/snapshot.mjs +35 -4
  124. package/src/commands/stats.mjs +96 -53
  125. package/src/commands/upgrade.mjs +3 -0
  126. package/src/deep.mjs +4 -31
  127. package/src/detectors.mjs +33 -4
  128. package/src/gate.mjs +1 -1
  129. package/src/langfuse.mjs +2 -0
  130. package/src/transcripts.mjs +232 -81
@@ -270,6 +270,85 @@ const LOOP_BACK = new Set(['REJECTED', 'BLOCKED', 'FAIL', 'FAILED']);
270
270
  */
271
271
  export const isLoopBack = (verdict) => LOOP_BACK.has(verdict);
272
272
 
273
+ /**
274
+ * The run a record is a round of: the dispatch id of its first round. A run is one agent, from the Agent
275
+ * call that started it to the last report it handed back; a round is one pass of it that ends in a report —
276
+ * the dispatch itself, or a `SendMessage` that resumed the agent after it had reported. The first round is
277
+ * recorded under the dispatch's id, and each later one under that id with `#<round>` after it.
278
+ *
279
+ * A record with no id is a run of its own: read as one id they share, every such record would be one run.
280
+ *
281
+ * @param {{dispatch_id?: string}} record - A snapshot record.
282
+ * @returns {string}
283
+ */
284
+ export function runOf(record) {
285
+ if (record?.dispatch_id) return String(record.dispatch_id).split('#')[0];
286
+ if (!unnamed.has(record)) {
287
+ named += 1;
288
+ unnamed.set(record, `(unnamed ${named})`);
289
+ }
290
+ return unnamed.get(record);
291
+ }
292
+
293
+ /** An id for each record that has none, for as long as the record is held. */
294
+ const unnamed = new WeakMap();
295
+ let named = 0;
296
+
297
+ /**
298
+ * Whether a parsed row of a subagent's transcript is the run handing its report back.
299
+ *
300
+ * @param {object|null} row - A parsed transcript row.
301
+ * @returns {boolean}
302
+ */
303
+ const handsBack = (row) =>
304
+ row?.type === 'assistant' && Array.isArray(row.message?.content) && row.message.content.some((b) => b?.type === 'tool_use' && b.name === 'SubagentHandback');
305
+
306
+ /**
307
+ * Whether a parsed row of a subagent's transcript is something said to the agent, as opposed to a tool's
308
+ * result or a reminder: the prompt that starts a run, or the message that resumes it.
309
+ *
310
+ * @param {object|null} row - A parsed transcript row.
311
+ * @returns {boolean}
312
+ */
313
+ const saidToAgent = (row) => {
314
+ if (row?.type !== 'user') return false;
315
+ const content = row.message?.content;
316
+ if (Array.isArray(content) && content.some((b) => b?.type === 'tool_result')) return false;
317
+ return !textOf(content).trimStart().startsWith('<system-reminder>');
318
+ };
319
+
320
+ /**
321
+ * Tells, line by line, which round of its run each line of a subagent's transcript belongs to. A round
322
+ * ends when the run hands its report back, and the next opens with the next thing said to the agent: a
323
+ * resume is written into the agent's own transcript as a message, and everything after it up to its next
324
+ * handback is that round's. What the agent writes after it reports and before anyone speaks to it again
325
+ * stays in the round it closed; a message that reaches it before it reports is part of the round it is in.
326
+ *
327
+ * The snapshot, the context sent to Langfuse and `learn --deep` all read rounds through this, so the three
328
+ * cannot disagree about where one ends. Only a line that could open a round or close one is parsed.
329
+ *
330
+ * @returns {(line: string) => number} Called on each line in order; returns the round it belongs to.
331
+ */
332
+ export function roundsOf() {
333
+ let round = 1;
334
+ let reported = false;
335
+ const parse = (line) => {
336
+ try {
337
+ return JSON.parse(line);
338
+ } catch {
339
+ return null;
340
+ }
341
+ };
342
+ return (line) => {
343
+ if (reported && line.includes('"user"') && saidToAgent(parse(line))) {
344
+ round += 1;
345
+ reported = false;
346
+ }
347
+ if (line.includes('SubagentHandback') && handsBack(parse(line))) reported = true;
348
+ return round;
349
+ };
350
+ }
351
+
273
352
  /**
274
353
  * Every place a transcript line can carry text.
275
354
  *
@@ -331,8 +410,10 @@ async function completeLineOffset(file, size, lastLine) {
331
410
  * Scans one project's transcripts and returns a record per subagent dispatch.
332
411
  *
333
412
  * A dispatch may notify more than once (an agent resumed via SendMessage re-notifies
334
- * under the same id). The last notification wins and `resumes` counts the extras —
335
- * counting every notification as a separate run inflates the totals roughly fivefold.
413
+ * under the same id), and every copy of one notification is one result. Where the run
414
+ * handed its reports back, each resume after a report is a round of its own, read from
415
+ * the agent's transcript (`attachAgentDetail`); a run from before the handback keeps
416
+ * its last notification's verdict.
336
417
  *
337
418
  * Reading is incremental: a per-file byte cursor means a session that has already been
338
419
  * captured is not re-read, which matters because this runs from a Stop hook and the
@@ -411,7 +492,6 @@ export async function scanProject(projectDir, prior = {}) {
411
492
  result_ts: null,
412
493
  duration_s: null,
413
494
  result_chars: null,
414
- resumes: 0,
415
495
  status: null,
416
496
  agent_id: null,
417
497
  skills: [],
@@ -449,29 +529,29 @@ export async function scanProject(projectDir, prior = {}) {
449
529
  if (!record) continue;
450
530
 
451
531
  const result = /<result>([\s\S]*?)(?:<\/result>|$)/.exec(text)?.[1] ?? '';
452
- // The same notification read twice is not a second result. Taking it as one counted a resume
453
- // that never happened. It is read twice when bytes are re-walked after a cursor went back, and
454
- // since Claude Code 2.1.282 one notification is also written up to three times: queued, taken
455
- // off the queue or absorbed mid-turn, and delivered as a message, each at its own time. The
456
- // agent's usage block (its time so far) tells a copy from a resume, which has run for longer;
457
- // a notification without one is told by its time, as before.
532
+ // The same notification read twice is not a second result. It is read twice when bytes are
533
+ // re-walked after a cursor went back, and since Claude Code 2.1.282 one notification is also
534
+ // written up to three times: queued, taken off the queue or absorbed mid-turn, and delivered as a
535
+ // message, each at its own time. The agent's usage block (its time so far) tells a copy from a
536
+ // resume, which has run for longer; a notification without one is told by its time, as before.
458
537
  const spentMs = Number(/<duration_ms>(\d+)<\/duration_ms>/.exec(text)?.[1] ?? Number.NaN);
459
538
  const timed = Number.isFinite(spentMs);
460
539
  if (timed ? record.notified_ms === spentMs : record.result_ts && record.result_ts === (row.timestamp ?? null)) continue;
461
- if (timed ? record.notified_ms != null : record.result_ts) record.resumes += 1;
462
540
  if (timed) record.notified_ms = spentMs;
463
541
  record.status = /<status>(.*?)<\/status>/.exec(text)?.[1] ?? null;
464
- record.result_ts = row.timestamp ?? null;
465
542
  // A notification whose run handed its report back says only that: "delivered to you as a
466
543
  // message … it is not repeated here". Read for a verdict, it replaced the one the handback
467
544
  // declared with none, and since the subagent's transcript had not grown, nothing read it again.
468
- if (!String(record.verdict_source ?? '').startsWith('handback')) {
469
- const { verdict, source } = classifyVerdict(result, record.role);
470
- record.result_chars = result.length;
471
- record.verdict = verdict;
472
- record.verdict_source = source;
473
- record.issues = issueCount(result, verdict, source);
474
- }
545
+ // Nor does it time the round the handback closed: a resumed run notifies again under the
546
+ // dispatch's id, and taken as the first round's end, that notification stretched the round
547
+ // across every hour the agent sat waiting to be resumed.
548
+ if (String(record.verdict_source ?? '').startsWith('handback')) continue;
549
+ record.result_ts = row.timestamp ?? null;
550
+ const { verdict, source } = classifyVerdict(result, record.role);
551
+ record.result_chars = result.length;
552
+ record.verdict = verdict;
553
+ record.verdict_source = source;
554
+ record.issues = issueCount(result, verdict, source);
475
555
  if (record.ts && record.result_ts) {
476
556
  record.duration_s = Math.round((Date.parse(record.result_ts) - Date.parse(record.ts)) / 1000);
477
557
  }
@@ -487,8 +567,8 @@ export async function scanProject(projectDir, prior = {}) {
487
567
  }
488
568
 
489
569
  /**
490
- * Reads each subagent's own transcript for the two things only it knows: the report it
491
- * handed back, and the skills it invoked.
570
+ * Reads each subagent's own transcript for what only it knows: the reports it handed back, the skills
571
+ * it invoked, what it spent, and what it wrote — round by round.
492
572
  *
493
573
  * The report is the authoritative source for a verdict. Claude Code 2.1.276 moved the
494
574
  * hand-off to a `SubagentHandback` tool call, whose `message` is the report itself —
@@ -496,17 +576,25 @@ export async function scanProject(projectDir, prior = {}) {
496
576
  * live session that notification may not be written at all. Reading the subagent's own
497
577
  * file works across both, and is where the mandatory-skill rule can be checked.
498
578
  *
579
+ * A run the orchestrator resumed after it reported is recorded as a round per report (`roundsOf`). It
580
+ * used to be one record holding its last verdict, so a reviewer that rejected twice and then approved was
581
+ * one approval: over one project's first two days the gate's ledger held 34 loop-backs where the snapshot
582
+ * held 5, since the orchestrator resumed the same reviewer, architect and implementer through every fix.
583
+ * Each round keeps its own verdict, time, spend, files and skills, so every report reads rounds and none
584
+ * has to know that a run can have more than one.
585
+ *
499
586
  * These per-agent transcripts are pruned sooner than the session's own, so the snapshot
500
587
  * captures this while it exists.
501
588
  *
502
589
  * @param {string} projectDir - The project's transcript directory.
503
- * @param {Map<string, object>} dispatches - Records to enrich, keyed by dispatch id.
590
+ * @param {Map<string, object>} dispatches - Records to enrich, keyed by dispatch id; a run's later rounds
591
+ * are added to it.
504
592
  */
505
593
  async function attachAgentDetail(projectDir, dispatches) {
506
- /** @type {Map<string, object[]>} agent id → the dispatches that ran under it */
594
+ /** @type {Map<string, object[]>} agent id → the dispatches that ran under it, first rounds only */
507
595
  const byAgent = new Map();
508
596
  for (const record of dispatches.values()) {
509
- if (!record.agent_id) continue;
597
+ if (!record.agent_id || (record.round ?? 1) !== 1) continue;
510
598
  if (!byAgent.has(record.agent_id)) byAgent.set(record.agent_id, []);
511
599
  byAgent.get(record.agent_id).push(record);
512
600
  }
@@ -522,45 +610,78 @@ async function attachAgentDetail(projectDir, dispatches) {
522
610
  const agentId = /^agent-([a-f0-9]+)\.jsonl$/.exec(file)?.[1];
523
611
  const records = agentId && byAgent.get(agentId);
524
612
  if (!records) continue;
613
+ const run = records[0];
614
+ const later = [];
615
+ for (let k = 2; dispatches.has(`${run.dispatch_id}#${k}`); k += 1) later.push(dispatches.get(`${run.dispatch_id}#${k}`));
525
616
  // A run read in full whose transcript has not grown since has nothing new to say. It was assumed
526
617
  // a finished run's transcript never changes, and 14 in 1,389 did: resumed after reporting, with a
527
618
  // median 42% of their tokens — and their final verdict — written after the first handback, so the
528
- // first read froze a partial bill. A run still in flight has no handback yet and is read again;
619
+ // first read froze a partial bill. A run still in flight is read again when its transcript grows;
529
620
  // a record from before a field existed is read once more; and one whose transcript is gone is
530
621
  // never reached here, so it keeps what it had rather than being dropped, which is what a
531
- // --rebuild would do to every run older than Claude Code's transcript retention. A run read in
532
- // full has a verdict from its handback; one whose verdict says otherwise lost it to a notification
533
- // read after it, and is read once more to get it back.
622
+ // --rebuild would do to every run older than Claude Code's transcript retention. A round that
623
+ // handed its report back has its verdict from the handback; one whose verdict says otherwise lost
624
+ // it to a notification read after it, and is read once more to get it back. A run from before the
625
+ // handback existed is read once, not on every snapshot: skipping only runs with a handback re-read
626
+ // 744 of one project's 826 in full on every turn, two seconds before the person saw an answer.
534
627
  const size = (await stat(join(dir, file)).catch(() => null))?.size ?? null;
535
- const whole = (r) => r.agent_read && String(r.verdict_source ?? '').startsWith('handback') && r.agent_read_bytes === size;
536
- if (records.every((r) => whole(r) && 'lessons_read' in r && 'tokens' in r && 'files_touched' in r && 'effort' in r)) continue;
537
-
538
- const skills = new Set();
539
- // Lessons this run read, and whether it only listed the directory. Every spec tells its role
540
- // to read its pills before acting, and until this was counted nothing could say whether one
541
- // ever had. Only the count is kept, never the path or the content — this record is metadata.
542
- const lessonsRead = new Set();
543
- let lessonsListed = false;
544
- let handback = null;
545
- let handbackTs = null;
546
- /**
547
- * Each API message's final usage, by message id. A streamed message is written once per content
548
- * block under the same id, and its output count grows until the last copy — summing the rows
549
- * counted the same input three or four times over.
550
- */
551
- const usage = new Map();
552
- /** The files the run wrote through the edit tools. Only their number is kept. */
553
- const written = new Set();
628
+ const settled = (r) => r.handed_back === false || String(r.verdict_source ?? '').startsWith('handback');
629
+ if (
630
+ run.agent_read_bytes === size &&
631
+ [...records, ...later].every((r) => settled(r) && 'round' in r && 'lessons_read' in r && 'tokens' in r && 'files_touched' in r && 'effort' in r)
632
+ ) {
633
+ continue;
634
+ }
635
+ // A run captured before rounds were, holding every round's spend and its last verdict. Its later
636
+ // rounds are marked, so an export that already sent the whole run does not send them again.
637
+ const legacy = !('round' in run) && 'tokens' in run;
638
+
639
+ /** What one round did, read into as the transcript goes. */
640
+ const fresh = () => ({
641
+ start: null,
642
+ branch: null,
643
+ skills: new Set(),
644
+ // Lessons this round read, and whether it only listed the directory. Every spec tells its role
645
+ // to read its pills before acting, and until this was counted nothing could say whether one
646
+ // ever had. Only the count is kept, never the path or the content — this record is metadata.
647
+ lessonsRead: new Set(),
648
+ lessonsListed: false,
649
+ handback: null,
650
+ handbackTs: null,
651
+ /**
652
+ * Each API message's final usage, by message id. A streamed message is written once per content
653
+ * block under the same id, and its output count grows until the last copy — summing the rows
654
+ * counted the same input three or four times over.
655
+ */
656
+ usage: new Map(),
657
+ /** The files the round wrote through the edit tools. Only their number is kept. */
658
+ written: new Set(),
659
+ });
660
+ const rounds = [fresh()];
661
+ const roundOf = roundsOf();
554
662
 
555
663
  const rl = createInterface({ input: createReadStream(join(dir, file)), crlfDelay: Infinity });
556
664
  for await (const line of rl) {
665
+ const n = roundOf(line);
666
+ if (n > rounds.length) {
667
+ // The line that opened the round: the message that resumed the run, and when.
668
+ rounds.push(fresh());
669
+ try {
670
+ const opened = JSON.parse(line);
671
+ rounds[n - 1].start = opened.timestamp ?? null;
672
+ rounds[n - 1].branch = opened.gitBranch ?? null;
673
+ } catch {
674
+ // Unreachable: a line that opens a round was parsed to tell that it does.
675
+ }
676
+ }
677
+ const round = rounds[n - 1];
557
678
  const hasSkill = line.includes('"Skill"');
558
679
  const hasHandback = line.includes('SubagentHandback');
559
680
  if (/"name":"(Edit|Write|MultiEdit|NotebookEdit)"/.test(line)) {
560
681
  try {
561
682
  for (const block of JSON.parse(line)?.message?.content ?? []) {
562
683
  const path = block?.type === 'tool_use' ? block.input?.[WRITES[block.name]] : null;
563
- if (typeof path === 'string') written.add(path);
684
+ if (typeof path === 'string') round.written.add(path);
564
685
  }
565
686
  } catch {
566
687
  // A torn line loses one edit, not the run's.
@@ -571,18 +692,18 @@ async function attachAgentDetail(projectDir, dispatches) {
571
692
  const row = JSON.parse(line);
572
693
  const message = row?.message;
573
694
  const effort = typeof row?.effort === 'string' ? row.effort : null;
574
- if (message?.id && message.usage) usage.set(message.id, { usage: message.usage, model: message.model ?? null, effort });
695
+ if (message?.id && message.usage) round.usage.set(message.id, { usage: message.usage, model: message.model ?? null, effort });
575
696
  } catch {
576
697
  // A torn line loses one message's count, not the run's.
577
698
  }
578
699
  }
579
700
  const touched = pillReads(line);
580
- for (const lesson of touched.read) lessonsRead.add(lesson);
581
- if (touched.listed) lessonsListed = true;
701
+ for (const lesson of touched.read) round.lessonsRead.add(lesson);
702
+ if (touched.listed) round.lessonsListed = true;
582
703
  if (!hasSkill && !hasHandback) continue;
583
704
 
584
705
  if (hasSkill) {
585
- for (const m of line.matchAll(/"skill"\s*:\s*"([^"]+)"/g)) skills.add(m[1]);
706
+ for (const m of line.matchAll(/"skill"\s*:\s*"([^"]+)"/g)) round.skills.add(m[1]);
586
707
  }
587
708
  if (!hasHandback) continue;
588
709
 
@@ -598,43 +719,73 @@ async function attachAgentDetail(projectDir, dispatches) {
598
719
  if (block?.type !== 'tool_use' || block?.name !== 'SubagentHandback') continue;
599
720
  const message = block?.input?.message;
600
721
  if (typeof message !== 'string') continue;
601
- handback = message;
602
- handbackTs = row.timestamp ?? null;
722
+ round.handback = message;
723
+ round.handbackTs = row.timestamp ?? null;
603
724
  }
604
725
  }
605
726
 
606
- const spent = tokensOf(usage);
607
- // One transcript, one bill: were two records ever to share an agent, the second would count it again.
608
- records.forEach((record, i) => {
609
- record.tokens = i === 0 ? spent.tokens : null;
610
- record.usage_model = spent.model;
611
- record.effort = spent.effort;
612
- record.files_touched = written.size;
613
- });
614
- for (const record of records) {
615
- record.skills = [...skills];
616
- record.lessons_read = lessonsRead.size;
617
- record.lessons_listed = lessonsListed;
618
- // The first definition, which counted a listing or a citation as a read. Dropped rather
619
- // than kept beside the new one, so no report can pick up the inflated figure by name.
620
- delete record.pills_read;
621
- delete record.pills_looked;
622
- if (handback) {
623
- const { verdict, source } = classifyVerdict(handback, record.role);
727
+ rounds.forEach((round, i) => {
728
+ // A round nothing happened in — something said to the agent after it reported that it never
729
+ // answered — is not a round.
730
+ if (i > 0 && !round.handback && round.usage.size === 0) return;
731
+ const id = `${run.dispatch_id}#${i + 1}`;
732
+ let targets = records;
733
+ if (i > 0) {
734
+ if (!dispatches.has(id)) {
735
+ dispatches.set(id, {
736
+ dispatch_id: id,
737
+ ts: round.start,
738
+ role: run.role,
739
+ desc: run.desc,
740
+ model: run.model,
741
+ branch: round.branch ?? run.branch,
742
+ session: run.session,
743
+ verdict: null,
744
+ verdict_source: null,
745
+ issues: null,
746
+ result_ts: null,
747
+ duration_s: null,
748
+ result_chars: null,
749
+ status: null,
750
+ agent_id: run.agent_id,
751
+ skills: [],
752
+ ...(legacy ? { backfilled: true } : {}),
753
+ });
754
+ }
755
+ targets = [dispatches.get(id)];
756
+ }
757
+ const spent = tokensOf(round.usage);
758
+ targets.forEach((record, j) => {
759
+ // One transcript, one bill: were two records ever to share an agent, the second would count it again.
760
+ record.tokens = j === 0 ? spent.tokens : null;
761
+ record.usage_model = spent.model;
762
+ record.effort = spent.effort;
763
+ record.files_touched = round.written.size;
764
+ record.skills = [...round.skills];
765
+ record.lessons_read = round.lessonsRead.size;
766
+ record.lessons_listed = round.lessonsListed;
767
+ record.round = i + 1;
768
+ record.handed_back = Boolean(round.handback);
769
+ record.agent_read = true;
770
+ // Fields a record no longer carries: the first definition of a lesson read, which counted a
771
+ // listing or a citation as one, and the count of resumes, which rounds replaced.
772
+ delete record.pills_read;
773
+ delete record.pills_looked;
774
+ delete record.resumes;
775
+ if (!round.handback) return;
776
+ const { verdict, source } = classifyVerdict(round.handback, record.role);
624
777
  record.verdict = verdict;
625
778
  record.verdict_source = source === 'declared' ? 'handback' : `handback:${source}`;
626
- record.issues = issueCount(handback, verdict, source);
627
- record.result_chars = handback.length;
628
- record.result_ts = record.result_ts ?? handbackTs;
779
+ record.issues = issueCount(round.handback, verdict, source);
780
+ record.result_chars = round.handback.length;
781
+ record.status = record.status ?? 'completed';
782
+ record.result_ts = round.handbackTs ?? record.result_ts;
629
783
  if (record.ts && record.result_ts) {
630
- record.duration_s = Math.round(
631
- (Date.parse(record.result_ts) - Date.parse(record.ts)) / 1000,
632
- );
784
+ record.duration_s = Math.round((Date.parse(record.result_ts) - Date.parse(record.ts)) / 1000);
633
785
  }
634
- record.agent_read = true;
635
- record.agent_read_bytes = size;
636
- }
637
- }
786
+ });
787
+ });
788
+ run.agent_read_bytes = size;
638
789
  }
639
790
  }
640
791
  }