mainflow 0.26.0 → 0.28.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.
@@ -1,6 +1,6 @@
1
- import { $ as grantRepo, A as configSchema, B as parseUntil, C as SLUG, D as TASK_SECTIONS, E as TASK_REQUIRED_SECTIONS, F as mainlineSchema, G as waitingForAction, H as refMainline, I as opsEntrySchema, K as waitingForRef, L as parseActionRef, M as findingSchema, N as formatOpsEntry, P as issueSchema, Q as grantForm, R as parseOpsLine, S as SESSION_STATUS, T as SURVEY_SECTION, U as sessionSchema, V as refExample, W as taskSchema, X as describeRequest, Z as grantCovers, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, et as parseGrantTarget, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as decisionSchema, k as WORK_STATUS, l as FINDING_STATUS, n as CLOSED_STATUS, r as DECIDERS, s as DEFAULT_CATEGORIES, t as CHECKLIST_REF, w as SURVEY_LINES, x as SESSION_ID, y as REF_EXAMPLES, z as parseRef } from "./dist-DlmecRLf.js";
1
+ import { $ as grantForm, A as WORK_STATUS, B as parseRef, C as SESSION_STATUS, D as TASK_REQUIRED_SECTIONS, E as SURVEY_SECTION, F as issueSchema, G as taskSchema, H as refExample, I as mainlineSchema, K as waitingForAction, L as opsEntrySchema, M as decisionSchema, N as findingSchema, O as TASK_SECTIONS, P as formatOpsEntry, Q as grantCovers, R as parseActionRef, S as SESSION_ID, T as SURVEY_LINES, U as refMainline, V as parseUntil, W as sessionSchema, Z as describeRequest, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, et as grantRepo, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as configSchema, l as FINDING_STATUS, n as CLOSED_STATUS, q as waitingForRef, r as DECIDERS, s as DEFAULT_CATEGORIES, tt as parseGrantTarget, w as SLUG, x as SERVES_REF, y as REF_EXAMPLES, z as parseOpsLine } from "./dist-BbamrIPW.js";
2
2
  import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
3
- import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, isAbsolute, join, matchesGlob, parse, posix, relative, resolve, sep } from "node:path";
4
4
  import { execFileSync, spawn, spawnSync } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
6
  import { parse as parse$1, stringify } from "yaml";
@@ -419,6 +419,76 @@ function mainlineEdges(graph) {
419
419
  return [...out.values()];
420
420
  }
421
421
  //#endregion
422
+ //#region ../core/dist/touches.js
423
+ /** Mainline statuses whose tasks are compared: work planned or under way. */
424
+ const COMPARED_MAINLINE = ["planning", "active"];
425
+ /** Parent directories of a path, nearest first, without the path itself. */
426
+ function ancestors(path) {
427
+ const out = [];
428
+ for (let dir = posix.dirname(path); dir !== "." && dir !== "/" && dir !== path; dir = posix.dirname(dir)) out.push(dir);
429
+ return out;
430
+ }
431
+ const normal = (path) => posix.normalize(path.trim()).replace(/\/+$/, "");
432
+ /**
433
+ * Whether a `touches` entry covers a path: the same path, a path under it, or one the glob matches directly or
434
+ * through a parent directory. A directory or a glob that matches a directory stands for everything under it.
435
+ */
436
+ function covers(pattern, path) {
437
+ return [path, ...ancestors(path)].some((p) => p === pattern || matchesGlob(p, pattern));
438
+ }
439
+ /**
440
+ * Whether two `touches` entries overlap: equal paths, or either one covers the other as a path. Two globs meet only
441
+ * when one matches the other's text, so `a/*.ts` and `?/b.ts` are not seen to share `a/b.ts`.
442
+ */
443
+ function touchesOverlap(a, b) {
444
+ const x = normal(a);
445
+ const y = normal(b);
446
+ return x === y || covers(x, y) || covers(y, x);
447
+ }
448
+ /** Whether a dependency edge leaves `from` at the mainline or the task level and lands on `to` at either level. */
449
+ function dependsOn(edges, from, to) {
450
+ const fromRefs = [from.mainline, `${from.mainline}/${from.task.id}`];
451
+ const lands = (ref) => ref === to.mainline || ref.startsWith(`${to.mainline}#`) || ref === `${to.mainline}/${to.task.id}` || ref.startsWith(`${to.mainline}/${to.task.id}#`);
452
+ return edges.some((e) => fromRefs.includes(e.from) && lands(e.to));
453
+ }
454
+ /** Unfinished tasks of planning or active mainlines that list `touches`, each with the mainline it sits in. */
455
+ function openTouching(mainlines) {
456
+ return mainlines.filter((m) => COMPARED_MAINLINE.includes(m.status)).flatMap((m) => m.tasks.filter((t) => !CLOSED_STATUS.task.includes(t.status) && t.touches?.length).map((task) => ({
457
+ mainline: m.id,
458
+ task
459
+ })));
460
+ }
461
+ /** Unfinished tasks that list `touches` but no `repo`: their paths have no root, so touchOverlaps skips them. */
462
+ function touchesWithoutRepo(mainlines) {
463
+ return openTouching(mainlines).filter((p) => !p.task.repo).map((p) => ({
464
+ task: `${p.mainline}/${p.task.id}`,
465
+ file: p.task.file
466
+ }));
467
+ }
468
+ /**
469
+ * Open tasks of different open mainlines whose `touches` overlap in the same repo and which have no depends-on
470
+ * between them in either direction, written on the mainline or on the task. Tasks without a repo are not compared,
471
+ * since their paths have no root to share; touchesWithoutRepo names them.
472
+ */
473
+ function touchOverlaps(mainlines, edges) {
474
+ const open = openTouching(mainlines).filter((p) => p.task.repo);
475
+ const overlaps = [];
476
+ open.forEach((a, i) => {
477
+ for (const b of open.slice(i + 1)) {
478
+ if (a.mainline === b.mainline || a.task.repo !== b.task.repo) continue;
479
+ const paths = a.task.touches.flatMap((x) => b.task.touches.filter((y) => touchesOverlap(x, y)).map((y) => [x, y]));
480
+ if (paths.length === 0 || dependsOn(edges, a, b) || dependsOn(edges, b, a)) continue;
481
+ overlaps.push({
482
+ tasks: [`${a.mainline}/${a.task.id}`, `${b.mainline}/${b.task.id}`],
483
+ repo: a.task.repo,
484
+ paths,
485
+ file: a.task.file
486
+ });
487
+ }
488
+ });
489
+ return overlaps;
490
+ }
491
+ //#endregion
422
492
  //#region ../core/dist/templates.js
423
493
  const body = (headings) => ["", ...headings.flatMap((h) => [`## ${h}`, ""])].join("\n");
424
494
  const MAINLINE_BODY = body(MAINLINE_SECTIONS);
@@ -656,11 +726,18 @@ function loadHub(root, options = {}) {
656
726
  });
657
727
  const hubIssues = hubDocs(join(dataDir, "issues"), issueSchema);
658
728
  const taskRefs = new Set(mainlines.flatMap((m) => m.tasks.map((t) => `${m.id}/${t.id}`)));
659
- for (const issue of hubIssues) if (issue.plannedIn && !taskRefs.has(issue.plannedIn)) problems.push({
660
- file: issue.file,
661
- field: "plannedIn",
662
- message: `task ${issue.plannedIn} does not exist`
663
- });
729
+ for (const issue of hubIssues) {
730
+ if (issue.plannedIn && !taskRefs.has(issue.plannedIn)) problems.push({
731
+ file: issue.file,
732
+ field: "plannedIn",
733
+ message: `task ${issue.plannedIn} does not exist`
734
+ });
735
+ if (issue.status !== "closed" && !issue.archivedAt && !issue.labels?.length) warnings.push({
736
+ file: issue.file,
737
+ field: "labels",
738
+ message: `issue ${issue.id} is ${issue.status} but has no labels; add labels so it can be found by topic`
739
+ });
740
+ }
664
741
  const findings = hubDocs(join(dataDir, "findings"), findingSchema);
665
742
  const ops = [];
666
743
  const opsPath = join(dataDir, "ops");
@@ -705,6 +782,19 @@ function loadHub(root, options = {}) {
705
782
  message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
706
783
  });
707
784
  }
785
+ for (const o of touchOverlaps(mainlines, deps.edges)) {
786
+ const pairs = o.paths.map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join(", ");
787
+ warnings.push({
788
+ file: o.file,
789
+ field: "touches",
790
+ message: `${o.tasks[0]} and ${o.tasks[1]} both touch ${pairs} in repo ${o.repo}, and neither depends on the other; coordinate before both run, or add a depends-on link`
791
+ });
792
+ }
793
+ for (const t of touchesWithoutRepo(mainlines)) warnings.push({
794
+ file: t.file,
795
+ field: "repo",
796
+ message: `${t.task} lists touches but no repo, so its paths are not compared with other tasks; add the repo they are relative to`
797
+ });
708
798
  return {
709
799
  root,
710
800
  config,
@@ -1026,7 +1116,8 @@ function createIssue(root, input) {
1026
1116
  title: input.title,
1027
1117
  status: "open",
1028
1118
  created: today(),
1029
- ...input.origin ? { origin: input.origin } : {}
1119
+ ...input.origin ? { origin: input.origin } : {},
1120
+ ...input.labels?.length ? { labels: input.labels } : {}
1030
1121
  }, "issue");
1031
1122
  const file = join(dir, `${id}-${input.slug}.md`);
1032
1123
  writeAtomic(file, frontMatterText(issue, ISSUE_BODY));
@@ -1247,14 +1338,14 @@ function grantScope(root, session) {
1247
1338
  reason: `${session.id} 同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")}),分不出授權範圍`
1248
1339
  };
1249
1340
  const action = doing[0];
1250
- const mainlines = [...new Set(action.serves.flatMap((ref) => {
1341
+ const mainlineOf = (ref) => {
1251
1342
  const parsed = parseRef(ref);
1252
- const mainline = parsed ? refMainline(parsed) : void 0;
1253
- return mainline ? [mainline] : [];
1254
- }))];
1343
+ return parsed ? refMainline(parsed) : void 0;
1344
+ };
1345
+ const mainlines = [...new Set(action.serves.flatMap((ref) => mainlineOf(ref) ?? []))];
1255
1346
  if (mainlines.length === 0) return {
1256
1347
  ok: false,
1257
- reason: `${action.id} 沒有服務任何 checklist 項目`
1348
+ reason: `${action.id} 沒有服務任何 checklist 項目或解決條件`
1258
1349
  };
1259
1350
  const grants = [];
1260
1351
  const served = {};
@@ -1265,7 +1356,7 @@ function grantScope(root, session) {
1265
1356
  closed.push(`${id} 是 ${mainline.status}`);
1266
1357
  continue;
1267
1358
  }
1268
- served[id] = action.serves.find((ref) => ref.startsWith(`${id}/`));
1359
+ served[id] = action.serves.find((ref) => mainlineOf(ref) === id);
1269
1360
  for (const grant of mainline.grants ?? []) grants.push({
1270
1361
  mainline: id,
1271
1362
  grant,
@@ -1433,23 +1524,30 @@ function resolveAction(root, ref) {
1433
1524
  }
1434
1525
  }
1435
1526
  /**
1436
- * Every served checklist item must exist, so a session never points at work that is not in a mainline. Its
1437
- * mainline must still be open: follow-up work on an ended one either reopens it with a criterion or task for the
1438
- * follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
1527
+ * Every served checklist item or criterion must exist, so a session never points at work that is not in a
1528
+ * mainline. A criterion stands in while the mainline has no task for it yet, as when it is still being surveyed
1529
+ * and planned. Its mainline must still be open: follow-up work on an ended one either reopens it with a criterion
1530
+ * or task for the follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
1439
1531
  */
1440
1532
  function requireServes(root, serves) {
1441
1533
  for (const ref of serves) {
1442
1534
  const parsed = parseRef(ref);
1443
- if (!CHECKLIST_REF.test(ref) || parsed?.kind !== "checklistItem") throw new WriteError(`--serves takes checklist references like m001/t002#c003, got "${ref}"`);
1535
+ if (!SERVES_REF.test(ref) || parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") throw new WriteError(`--serves takes checklist items like m001/t002#c003 or criteria like m001#r001, got "${ref}"`);
1444
1536
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1445
1537
  if (CLOSED_STATUS.mainline.includes(mainline.status)) throw new WriteError(`${parsed.mainline} is ${mainline.status}; to continue it, run "mainline set ${parsed.mainline} --status active" and add a criterion or task for the follow-up, or start a new mainline with "new mainline"`);
1538
+ if (parsed.kind === "criterion") {
1539
+ if (!mainline.criteria.some((r) => r.id === parsed.criterion)) throw new WriteError(`criterion ${ref} not found`);
1540
+ continue;
1541
+ }
1446
1542
  if (!readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.some((c) => c.id === parsed.item)) throw new WriteError(`checklist item ${ref} not found`);
1447
1543
  }
1448
1544
  }
1449
1545
  const isOpen$1 = (a) => a.status === "todo" || a.status === "doing";
1546
+ const isCarried$1 = (a) => isOpen$1(a) || a.status === "blocked";
1450
1547
  /**
1451
1548
  * The previous session's unfinished actions, numbered after `existing`, with the previous session marked
1452
- * handed-off. Each carried action is dropped in the previous session with a note naming its new reference,
1549
+ * handed-off. Blocked actions go along with their `blockedBy`, since the wait is still on and whoever is
1550
+ * waited for keeps seeing it. Each carried action is dropped in the previous session with a note naming its new reference,
1453
1551
  * so the work stays open in one place only and the old session still shows where it went. Runs inside the
1454
1552
  * caller's lock.
1455
1553
  */
@@ -1460,7 +1558,7 @@ function takeUnfinished(root, from, to, existing) {
1460
1558
  const carried = [];
1461
1559
  const at = nowMinute();
1462
1560
  const left = previous.actions.map((a) => {
1463
- if (!isOpen$1(a)) return a;
1561
+ if (!isCarried$1(a)) return a;
1464
1562
  const id = nextId("a", ids);
1465
1563
  ids.push(id);
1466
1564
  carried.push({
@@ -1468,8 +1566,9 @@ function takeUnfinished(root, from, to, existing) {
1468
1566
  id
1469
1567
  });
1470
1568
  const handedOff = handedOffNote(`${to}#${id}`);
1569
+ const { blockedBy: _moved, ...rest } = a;
1471
1570
  return {
1472
- ...a,
1571
+ ...rest,
1473
1572
  status: "dropped",
1474
1573
  note: a.note ? `${a.note}; ${handedOff}` : handedOff,
1475
1574
  doneAt: at
@@ -1545,7 +1644,7 @@ function handoffSession(root, id, from) {
1545
1644
  if (session.handoffFrom && session.handoffFrom !== from) throw new WriteError(`session ${id} already took over from ${session.handoffFrom}`);
1546
1645
  const open = session.actions.filter(isOpen$1);
1547
1646
  if (open.length > 0) throw new WriteError(`session ${id} has unfinished actions (${open.map((a) => a.id).join(", ")}); finish or drop them before taking over from ${from}`);
1548
- if (!readYaml(requireSession(root, from), sessionSchema).data.actions.some(isOpen$1)) throw new WriteError(`session ${from} has no unfinished actions to take over`);
1647
+ if (!readYaml(requireSession(root, from), sessionSchema).data.actions.some(isCarried$1)) throw new WriteError(`session ${from} has no unfinished actions to take over`);
1549
1648
  const carried = takeUnfinished(root, from, id, session.actions.map((a) => a.id));
1550
1649
  const next = validate(sessionSchema, {
1551
1650
  ...session,
@@ -1566,6 +1665,13 @@ function setSessionInfo(root, id, info) {
1566
1665
  for (const [key, value] of Object.entries(info)) if (value !== void 0 && value !== null && value !== "") s[key] = value;
1567
1666
  });
1568
1667
  }
1668
+ /** Remember the digest of what the last turn reminder listed in full; null forgets it, so the next reminder lists everything again. */
1669
+ function setReminderDigest(root, id, digest) {
1670
+ mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1671
+ if (digest === null) delete s.reminderDigest;
1672
+ else s.reminderDigest = digest;
1673
+ });
1674
+ }
1569
1675
  function setSessionStatus(root, id, status) {
1570
1676
  const value = requireOneOf(status, SESSION_STATUS, "session status");
1571
1677
  return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
@@ -1662,6 +1768,8 @@ function updateAction(root, ref, patch) {
1662
1768
  const index = s.actions.findIndex((a) => a.id === action);
1663
1769
  if (index < 0) throw new WriteError(`action ${ref} not found`);
1664
1770
  const [current] = s.actions.splice(index, 1);
1771
+ const movedTo = HANDED_OFF.exec(current.note ?? "")?.[1];
1772
+ if (movedTo && current.status === "dropped" && status !== void 0 && status !== "dropped") throw new WriteError(`${ref} was handed off to ${movedTo}, and references to ${ref} follow its note only while it stays dropped; change ${movedTo} instead`);
1665
1773
  const handedTo = patch.note ? HANDED_OFF.exec(current.note ?? "")?.[1] : void 0;
1666
1774
  if (handedTo) throw new WriteError(`${ref} was handed off to ${handedTo}, and references to ${ref} follow its note there, so the note stays as it is; add the note to ${handedTo} instead`);
1667
1775
  const next = {
@@ -1695,9 +1803,18 @@ function updateAction(root, ref, patch) {
1695
1803
  //#region ../core/dist/briefing.js
1696
1804
  function describe(root, ref) {
1697
1805
  const parsed = parseRef(ref);
1698
- if (parsed?.kind !== "checklistItem") return null;
1806
+ if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") return null;
1699
1807
  try {
1700
1808
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1809
+ if (parsed.kind === "criterion") {
1810
+ const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
1811
+ return criterion ? {
1812
+ ref,
1813
+ text: criterion.text,
1814
+ checked: criterion.done,
1815
+ where: `${mainline.id} ${mainline.title} › 解決條件`
1816
+ } : null;
1817
+ }
1701
1818
  const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
1702
1819
  const item = task.checklist.find((c) => c.id === parsed.item);
1703
1820
  if (!item) return null;
@@ -1711,6 +1828,32 @@ function describe(root, ref) {
1711
1828
  return null;
1712
1829
  }
1713
1830
  }
1831
+ /** The criteria a served checklist item pushes forward, as m{NNN}#r{NNN}; empty for a criterion or an item that cannot be read. */
1832
+ function criteriaOf(root, ref) {
1833
+ const parsed = parseRef(ref);
1834
+ if (parsed?.kind !== "checklistItem") return [];
1835
+ try {
1836
+ return (readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
1837
+ } catch {
1838
+ return [];
1839
+ }
1840
+ }
1841
+ /**
1842
+ * Where two served lists meet: a reference both serve, or a criterion one serves that a checklist item the other
1843
+ * serves pushes forward, named as the item with its criterion. Two items serving the same criterion do not meet,
1844
+ * since splitting a criterion into items is how work on it runs in parallel.
1845
+ */
1846
+ function sharedServes(root, mine, theirs) {
1847
+ const shared = mine.filter((ref) => theirs.includes(ref));
1848
+ const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(root, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
1849
+ const mineCriteria = mine.filter((ref) => parseRef(ref)?.kind === "criterion");
1850
+ const theirCriteria = theirs.filter((ref) => parseRef(ref)?.kind === "criterion");
1851
+ return [.../* @__PURE__ */ new Set([
1852
+ ...shared,
1853
+ ...pushes(theirs, mineCriteria),
1854
+ ...pushes(mine, theirCriteria)
1855
+ ])];
1856
+ }
1714
1857
  /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
1715
1858
  function queueWarnings(root, session, cli) {
1716
1859
  const warnings = [];
@@ -1718,8 +1861,8 @@ function queueWarnings(root, session, cli) {
1718
1861
  if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
1719
1862
  for (const action of doing) {
1720
1863
  const served = action.serves.map((ref) => describe(root, ref));
1721
- if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c> 改正。`);
1722
- else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選。做完了就執行 ${cli} action set ${action.id} --status done。`);
1864
+ if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c 或 m#r> 改正。`);
1865
+ else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選或達成。做完了就執行 ${cli} action set ${action.id} --status done。`);
1723
1866
  }
1724
1867
  const next = session.actions.find((a) => a.status === "todo");
1725
1868
  if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
@@ -1727,6 +1870,8 @@ function queueWarnings(root, session, cli) {
1727
1870
  }
1728
1871
  const current = (session) => session.actions.find((a) => a.status === "doing");
1729
1872
  const isOpen = (a) => a.status === "todo" || a.status === "doing";
1873
+ /** What a handoff takes over: open actions and blocked ones, which are still unfinished. */
1874
+ const isCarried = (a) => isOpen(a) || a.status === "blocked";
1730
1875
  /** A blocked action and what it waits for: the reason, the other party and the condition, as `action set --status blocked` recorded them. */
1731
1876
  function waitingLine(a) {
1732
1877
  const block = a.blockedBy;
@@ -1794,8 +1939,8 @@ function waiterLines(root, session) {
1794
1939
  return lines;
1795
1940
  }
1796
1941
  /**
1797
- * One reminder for each action in progress in another session that may still be running and serves an item
1798
- * this action also serves. Sessions in the same delegation tree are left out: a subagent working on the item
1942
+ * One reminder for each action in progress in another session that may still be running and serves what this
1943
+ * action also serves: the same item or criterion, or a criterion and an item that pushes it forward. Sessions in the same delegation tree are left out: a subagent working on the item
1799
1944
  * its parent handed it is not a second claim.
1800
1945
  */
1801
1946
  function duplicateClaimLines(root, sessionId, actionId) {
@@ -1807,7 +1952,7 @@ function duplicateClaimLines(root, sessionId, actionId) {
1807
1952
  for (const other of sessions) {
1808
1953
  if (related.has(other.id) || !mayBeRunning(root, other)) continue;
1809
1954
  for (const theirs of other.actions.filter((a) => a.status === "doing")) {
1810
- const shared = theirs.serves.filter((ref) => action.serves.includes(ref));
1955
+ const shared = sharedServes(root, action.serves, theirs.serves);
1811
1956
  if (shared.length > 0) lines.push(`⚠ ${other.id}#${theirs.id}(15 分鐘內有活動)也在做 ${shared.join("、")},先協調再開始`);
1812
1957
  }
1813
1958
  }
@@ -1818,14 +1963,32 @@ function duplicateClaimLines(root, sessionId, actionId) {
1818
1963
  * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
1819
1964
  */
1820
1965
  function handoffLines(root, session, cli) {
1821
- const others = loadSessions(root).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isOpen));
1966
+ const others = loadSessions(root).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
1822
1967
  if (others.length === 0) return [];
1823
- return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(root, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isOpen).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
1968
+ return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(root, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isCarried).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
1824
1969
  }
1825
1970
  const servedLine = (root, action) => action.serves.map((ref) => {
1826
1971
  const s = describe(root, ref);
1827
- return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目)`;
1828
- }).join(";") || "沒有指定服務的 checklist 項目";
1972
+ return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目或條件)`;
1973
+ }).join(";") || "沒有指定服務的 checklist 項目或解決條件";
1974
+ /**
1975
+ * Lines for each pair of overlapping touches that involves a mainline an action in progress serves: the other
1976
+ * mainline and task, and the paths they share. Empty when the session serves nothing that overlaps.
1977
+ */
1978
+ function overlapLines(root, session) {
1979
+ const mine = new Set(session.actions.filter((a) => a.status === "doing").flatMap((a) => a.serves.map((ref) => parseRef(ref))).flatMap((parsed) => parsed && "mainline" in parsed ? [parsed.mainline] : []));
1980
+ if (mine.size === 0) return [];
1981
+ const hub = loadHub(root, { remotes: false });
1982
+ const lines = [];
1983
+ for (const o of touchOverlaps(hub.mainlines, dependencyGraph(hub).edges)) {
1984
+ const [own, other] = mine.has(o.tasks[0].split("/")[0]) ? [0, 1] : mine.has(o.tasks[1].split("/")[0]) ? [1, 0] : [-1, -1];
1985
+ if (own < 0) continue;
1986
+ const paths = o.paths.map(([a, b]) => own === 0 ? [a, b] : [b, a]).map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join("、");
1987
+ const title = hub.mainlines.find((m) => m.id === o.tasks[other].split("/")[0])?.title;
1988
+ lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}),兩邊沒有 depends-on`);
1989
+ }
1990
+ return lines;
1991
+ }
1829
1992
  /** Full briefing for the start of a session, after resuming, clearing or compacting. */
1830
1993
  function sessionBriefing(root, session, options) {
1831
1994
  const { cli } = options;
@@ -1850,13 +2013,18 @@ function sessionBriefing(root, session, options) {
1850
2013
  if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
1851
2014
  }
1852
2015
  lines.push(...waiterLines(root, session));
2016
+ const overlaps = overlapLines(root, session);
2017
+ if (overlaps.length > 0) lines.push("目前行動服務的主脈絡和其他進行中的主脈絡預計動到同樣的路徑,開始改之前先和對方協調,或加 depends-on:", ...overlaps);
1853
2018
  if (!session.actions.some(isOpen)) lines.push(...handoffLines(root, session, cli));
1854
2019
  const warnings = queueWarnings(root, session, cli);
1855
2020
  if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
1856
- lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
2021
+ lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。主脈絡還沒拆任務時(查核與規劃階段),--serves 指向解決條件 <m#r>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
1857
2022
  return lines.join("\n");
1858
2023
  }
1859
- /** One or two lines for every user turn; null when the session has no actions and the hub has no open mainline. */
2024
+ /**
2025
+ * One or two lines for every user turn; null when the session has no actions and the hub has no open mainline.
2026
+ * Records a digest of the grants, waits and warnings it lists in the session file, only when the digest changes.
2027
+ */
1860
2028
  function turnReminder(root, session, options) {
1861
2029
  if (session.actions.length === 0) return openMainlines(root).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
1862
2030
  const doing = current(session);
@@ -1866,13 +2034,28 @@ function turnReminder(root, session, options) {
1866
2034
  const header = headerLine(session);
1867
2035
  const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
1868
2036
  const grants = grantLines(root, session);
1869
- const granted = grants.length > 0 ? ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)] : [];
2037
+ const waiters = waiterLines(root, session);
2038
+ const warnings = queueWarnings(root, session, options.cli);
2039
+ const digest = contentHash(JSON.stringify([
2040
+ doing?.id ?? null,
2041
+ grants,
2042
+ waiters,
2043
+ warnings
2044
+ ]));
2045
+ const repeated = session.reminderDigest === digest;
2046
+ if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(root, session.id)) try {
2047
+ setReminderDigest(root, session.id, digest);
2048
+ } catch {}
2049
+ const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
2050
+ const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
2051
+ const waiting = once(waiters, `- 等待行同上一輪,共 ${waiters.length} 條。`, waiters.map((w) => `- ${w}`));
2052
+ const warned = once(warnings, `- 佇列提醒同上一輪,共 ${warnings.length} 條。`, warnings.map((w) => `- ${w}`));
1870
2053
  return [
1871
2054
  head,
1872
- ...waiterLines(root, session).map((w) => `- ${w}`),
2055
+ ...waiting,
1873
2056
  ...granted,
1874
2057
  ...marker,
1875
- ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
2058
+ ...warned
1876
2059
  ].join("\n");
1877
2060
  }
1878
2061
  //#endregion
@@ -2096,7 +2279,7 @@ function setCriterion(root, refInput, done, evidence, expectedVersion) {
2096
2279
  return mutateFrontMatter(root, mainlineFile(root, ref.mainline), mainlineSchema, (m) => {
2097
2280
  const criterion = m.criteria.find((c) => c.id === ref.criterion);
2098
2281
  if (!criterion) throw new WriteError(`criterion ${refInput} not found`);
2099
- setListItem(criterion, done, evidence);
2282
+ setListItem(criterion, done, evidence, `${refInput} is dropped; remove dropped and supersededBy in ${MAINLINE_FILE} to bring it back before checking it`);
2100
2283
  }, expectedVersion);
2101
2284
  }
2102
2285
  /** Rewrite a criterion's text or doneWhen, leaving done and evidence as they are. */
@@ -2110,14 +2293,17 @@ function setCriterionText(root, refInput, patch, expectedVersion) {
2110
2293
  if (patch.doneWhen !== void 0) criterion.doneWhen = patch.doneWhen;
2111
2294
  }, expectedVersion);
2112
2295
  }
2113
- /** Check or uncheck a criterion or checklist item; checking clears a drop. */
2114
- function setListItem(item, done, evidence) {
2296
+ /**
2297
+ * Check or uncheck a criterion or checklist item. A dropped item cannot be checked, because a drop is a
2298
+ * ruling that the item is no longer needed; `droppedHint` says how to bring it back. Neither direction
2299
+ * touches dropped or supersededBy.
2300
+ */
2301
+ function setListItem(item, done, evidence, droppedHint) {
2302
+ if (done && item.dropped) throw new WriteError(droppedHint);
2115
2303
  item.done = done;
2116
2304
  if (done) {
2117
2305
  item.evidence = evidence;
2118
2306
  item.doneAt = today();
2119
- delete item.dropped;
2120
- delete item.supersededBy;
2121
2307
  } else {
2122
2308
  delete item.evidence;
2123
2309
  delete item.doneAt;
@@ -2192,7 +2378,7 @@ function setChecklistItem(root, refInput, done, evidence, expectedVersion) {
2192
2378
  return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2193
2379
  const item = t.checklist.find((c) => c.id === ref.item);
2194
2380
  if (!item) throw new WriteError(`checklist item ${refInput} not found`);
2195
- setListItem(item, done, evidence);
2381
+ setListItem(item, done, evidence, `${refInput} is dropped; run item undrop ${refInput} to bring it back before checking it`);
2196
2382
  }, expectedVersion);
2197
2383
  }
2198
2384
  /** Mark an item as no longer needed, naming what replaced it when something did. */
@@ -2207,6 +2393,20 @@ function dropChecklistItem(root, refInput, input = {}, expectedVersion) {
2207
2393
  if (input.note) item.note = input.note;
2208
2394
  }, expectedVersion);
2209
2395
  }
2396
+ /** Bring a dropped item back as an open one: clears dropped and supersededBy, keeps the note. */
2397
+ function undropChecklistItem(root, refInput, expectedVersion) {
2398
+ const ref = requireRef(refInput, "checklistItem");
2399
+ return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2400
+ const item = t.checklist.find((c) => c.id === ref.item);
2401
+ if (!item) throw new WriteError(`checklist item ${refInput} not found`);
2402
+ if (!item.dropped) throw new WriteError(`${refInput} is not dropped`);
2403
+ delete item.dropped;
2404
+ delete item.supersededBy;
2405
+ item.done = false;
2406
+ delete item.evidence;
2407
+ delete item.doneAt;
2408
+ }, expectedVersion);
2409
+ }
2210
2410
  function createDecision(root, mainline, input) {
2211
2411
  requireRef(mainline, "mainline");
2212
2412
  requireSlug(input.slug);
@@ -2314,6 +2514,474 @@ function setArchivedMany(root, kind, ids, archived) {
2314
2514
  return result;
2315
2515
  }
2316
2516
  //#endregion
2517
+ //#region ../core/dist/retro.js
2518
+ /** Where the prices come from and when they were read; every figure is USD per million tokens. */
2519
+ const PRICING_SOURCE = {
2520
+ url: "https://platform.claude.com/docs/en/about-claude/pricing",
2521
+ date: "2026-09-30",
2522
+ unit: "USD/MTok"
2523
+ };
2524
+ const price = (input, cacheWrite5m, cacheWrite1h, cacheRead, output) => ({
2525
+ input,
2526
+ cacheWrite5m,
2527
+ cacheWrite1h,
2528
+ cacheRead,
2529
+ output
2530
+ });
2531
+ /**
2532
+ * List prices by model id prefix. A model matches a prefix when the rest of its id is empty, a date suffix
2533
+ * (-20251001) or a bracketed variant ([1m]); any other model has no known price, so its cost is not guessed.
2534
+ */
2535
+ const MODEL_PRICES = [
2536
+ {
2537
+ prefix: "claude-opus-5-5",
2538
+ price: price(4, 5, 8, .2, 20)
2539
+ },
2540
+ {
2541
+ prefix: "claude-opus-5",
2542
+ price: price(5, 6.25, 10, .5, 25)
2543
+ },
2544
+ {
2545
+ prefix: "claude-opus-4-8",
2546
+ price: price(5, 6.25, 10, .5, 25)
2547
+ },
2548
+ {
2549
+ prefix: "claude-sonnet-5-5",
2550
+ price: price(2, 2.5, 4, .2, 10)
2551
+ },
2552
+ {
2553
+ prefix: "claude-sonnet-5",
2554
+ price: price(2, 2.5, 4, .2, 10)
2555
+ },
2556
+ {
2557
+ prefix: "claude-sonnet-4-6",
2558
+ price: price(3, 3.75, 6, .3, 15)
2559
+ },
2560
+ {
2561
+ prefix: "claude-haiku-4-5",
2562
+ price: price(1, 1.25, 2, .1, 5)
2563
+ },
2564
+ {
2565
+ prefix: "claude-fable-5-1",
2566
+ price: price(10, 12.5, 20, .25, 50)
2567
+ }
2568
+ ];
2569
+ /**
2570
+ * Prices at speed "fast", keyed by the MODEL_PRICES prefix. The cache multipliers stack on the fast input
2571
+ * price (5m write 1.25x, 1h write 2x, read 0.05x for Opus 5.5 and 0.1x otherwise), as with standard speed.
2572
+ */
2573
+ const FAST_PRICES = {
2574
+ "claude-opus-5-5": price(8, 10, 16, .4, 40),
2575
+ "claude-opus-5": price(10, 12.5, 20, 1, 50),
2576
+ "claude-opus-4-8": price(10, 12.5, 20, 1, 50)
2577
+ };
2578
+ /** Inference kept in the US (inference_geo "us") is billed at 1.1 times the list price. */
2579
+ const US_GEO_MULTIPLIER = 1.1;
2580
+ const PRICE_SUFFIX = /^(?:-\d{8})?(?:\[[^\]]*\])?$/;
2581
+ /** The price entry a model id falls under: the longest prefix whose remainder is only a date or a variant. */
2582
+ function priceEntry(model) {
2583
+ return MODEL_PRICES.filter((p) => model.startsWith(p.prefix) && PRICE_SUFFIX.test(model.slice(p.prefix.length))).sort((a, b) => b.prefix.length - a.prefix.length)[0];
2584
+ }
2585
+ const priceOf = (model) => priceEntry(model)?.price;
2586
+ const emptyUsage = () => ({
2587
+ input: 0,
2588
+ cacheWrite5m: 0,
2589
+ cacheWrite1h: 0,
2590
+ cacheRead: 0,
2591
+ output: 0
2592
+ });
2593
+ const addUsage = (to, from) => {
2594
+ for (const key of Object.keys(to)) to[key] += from[key];
2595
+ };
2596
+ /** Everything the model read before answering: fresh input, cache writes and cache reads. */
2597
+ const contextTokens = (u) => u.input + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
2598
+ /** Share of the context served from the cache; null when there was no context at all. */
2599
+ const cacheHitRate = (u) => contextTokens(u) > 0 ? u.cacheRead / contextTokens(u) : null;
2600
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
2601
+ /** Read message.usage; cache writes without the 5m and 1h split count as 5m, the default cache lifetime. */
2602
+ function readUsage(raw) {
2603
+ const created = num(raw.cache_creation_input_tokens);
2604
+ const split = raw.cache_creation ?? {};
2605
+ const w1h = num(split.ephemeral_1h_input_tokens);
2606
+ const w5m = split.ephemeral_5m_input_tokens === void 0 ? created - w1h : num(split.ephemeral_5m_input_tokens);
2607
+ return {
2608
+ input: num(raw.input_tokens),
2609
+ cacheWrite5m: Math.max(0, w5m),
2610
+ cacheWrite1h: w1h,
2611
+ cacheRead: num(raw.cache_read_input_tokens),
2612
+ output: num(raw.output_tokens)
2613
+ };
2614
+ }
2615
+ /** The estimated cost of one message in USD, or null when the model has no known price. */
2616
+ function messageCost(model, usage, speed, geo) {
2617
+ const entry = priceEntry(model);
2618
+ if (!entry) return null;
2619
+ const p = (speed === "fast" ? FAST_PRICES[entry.prefix] : void 0) ?? entry.price;
2620
+ const usd = (usage.input * p.input + usage.cacheWrite5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h + usage.cacheRead * p.cacheRead + usage.output * p.output) / 1e6;
2621
+ return geo === "us" ? usd * US_GEO_MULTIPLIER : usd;
2622
+ }
2623
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2624
+ const blocks = (content) => Array.isArray(content) ? content.filter(isObject) : [];
2625
+ /** The text a hook attachment hands to the model, empty when it hands none. */
2626
+ function hookText(a) {
2627
+ const parts = [];
2628
+ if (typeof a.content === "string") parts.push(a.content);
2629
+ if (Array.isArray(a.content)) parts.push(...a.content.filter((c) => typeof c === "string"));
2630
+ const blocking = a.blockingError;
2631
+ if (typeof blocking === "string") parts.push(blocking);
2632
+ if (isObject(blocking) && typeof blocking.blockingError === "string") parts.push(blocking.blockingError);
2633
+ return parts.join("");
2634
+ }
2635
+ const hookEvent = (a) => typeof a.hookEvent === "string" && a.hookEvent ? a.hookEvent : typeof a.hookName === "string" && a.hookName ? a.hookName.split(":")[0] : "unknown";
2636
+ const emptyStats = (file, found) => ({
2637
+ file,
2638
+ found,
2639
+ versions: [],
2640
+ lines: 0,
2641
+ unreadable: 0,
2642
+ messages: [],
2643
+ toolUses: 0,
2644
+ toolErrors: {},
2645
+ bashRuns: {},
2646
+ hooks: [],
2647
+ misfires: {},
2648
+ gaps: []
2649
+ });
2650
+ function readTranscript(file) {
2651
+ const stats = emptyStats(file, existsSync(file));
2652
+ if (!stats.found) return stats;
2653
+ const versions = /* @__PURE__ */ new Set();
2654
+ const byId = /* @__PURE__ */ new Map();
2655
+ const toolNames = /* @__PURE__ */ new Map();
2656
+ const hooks = /* @__PURE__ */ new Map();
2657
+ /** Injections still waiting for the next assistant message, with the message they came after */
2658
+ let pending = [];
2659
+ const bound = [];
2660
+ let lastAssistant;
2661
+ let lastAt;
2662
+ let lastAtText = "";
2663
+ let anonymous = 0;
2664
+ for (const text of readFileSync(file, "utf8").split("\n")) {
2665
+ if (!text.trim()) continue;
2666
+ stats.lines += 1;
2667
+ let line;
2668
+ try {
2669
+ const parsed = JSON.parse(text);
2670
+ if (!isObject(parsed)) throw new Error("not an object");
2671
+ line = parsed;
2672
+ } catch {
2673
+ stats.unreadable += 1;
2674
+ continue;
2675
+ }
2676
+ if (typeof line.version === "string") versions.add(line.version);
2677
+ const at = typeof line.timestamp === "string" ? Date.parse(line.timestamp) : NaN;
2678
+ if (!Number.isNaN(at)) {
2679
+ if (lastAt !== void 0 && at - lastAt > 3e5) {
2680
+ const turnEnded = lastAssistant !== void 0 && byId.get(lastAssistant)?.toolUses === 0;
2681
+ stats.gaps.push({
2682
+ from: lastAtText,
2683
+ to: line.timestamp,
2684
+ minutes: Math.round((at - lastAt) / 6e4),
2685
+ waitingForUser: turnEnded
2686
+ });
2687
+ }
2688
+ if (lastAt === void 0 || at > lastAt) {
2689
+ lastAt = at;
2690
+ lastAtText = line.timestamp;
2691
+ }
2692
+ }
2693
+ if (line.type === "assistant") {
2694
+ const m = line.message;
2695
+ if (!isObject(m)) {
2696
+ stats.unreadable += 1;
2697
+ continue;
2698
+ }
2699
+ const id = typeof m.id === "string" && m.id ? m.id : `(no id ${++anonymous})`;
2700
+ let message = byId.get(id);
2701
+ if (!message) {
2702
+ message = {
2703
+ id,
2704
+ model: typeof m.model === "string" ? m.model : "unknown",
2705
+ usage: emptyUsage(),
2706
+ toolUses: 0,
2707
+ ...typeof line.timestamp === "string" ? { at: line.timestamp } : {}
2708
+ };
2709
+ byId.set(id, message);
2710
+ stats.messages.push(message);
2711
+ }
2712
+ if (isObject(m.usage)) {
2713
+ const usage = readUsage(m.usage);
2714
+ for (const key of Object.keys(usage)) message.usage[key] = Math.max(message.usage[key], usage[key]);
2715
+ if (typeof m.usage.speed === "string") message.speed = m.usage.speed;
2716
+ if (typeof m.usage.inference_geo === "string") message.geo = m.usage.inference_geo;
2717
+ }
2718
+ for (const block of blocks(m.content)) {
2719
+ if (block.type !== "tool_use") continue;
2720
+ message.toolUses += 1;
2721
+ stats.toolUses += 1;
2722
+ const name = typeof block.name === "string" ? block.name : "unknown";
2723
+ if (typeof block.id === "string") toolNames.set(block.id, name);
2724
+ const input = isObject(block.input) ? block.input : {};
2725
+ if (name === "Bash" && typeof input.command === "string") {
2726
+ const command = input.command.trim();
2727
+ stats.bashRuns[command] = (stats.bashRuns[command] ?? 0) + 1;
2728
+ }
2729
+ }
2730
+ if (id !== lastAssistant) {
2731
+ for (const p of pending.filter((x) => x.after !== id)) bound.push({
2732
+ event: p.event,
2733
+ message: id
2734
+ });
2735
+ pending = pending.filter((x) => x.after === id);
2736
+ }
2737
+ lastAssistant = id;
2738
+ continue;
2739
+ }
2740
+ if (line.type === "user" && line.message !== void 0) {
2741
+ for (const block of blocks(line.message?.content)) {
2742
+ if (block.type !== "tool_result" || block.is_error !== true) continue;
2743
+ const name = typeof block.tool_use_id === "string" && toolNames.get(block.tool_use_id) || "unknown";
2744
+ stats.toolErrors[name] = (stats.toolErrors[name] ?? 0) + 1;
2745
+ }
2746
+ continue;
2747
+ }
2748
+ if (line.type === "attachment") {
2749
+ const a = line.attachment;
2750
+ if (!isObject(a)) {
2751
+ stats.unreadable += 1;
2752
+ continue;
2753
+ }
2754
+ const kind = typeof a.type === "string" ? a.type : "";
2755
+ if (!kind.includes("hook")) continue;
2756
+ const event = hookEvent(a);
2757
+ const hook = hooks.get(event) ?? {
2758
+ event,
2759
+ injections: 0,
2760
+ chars: 0,
2761
+ blocked: 0
2762
+ };
2763
+ hooks.set(event, hook);
2764
+ if (kind.includes("blocking")) hook.blocked += 1;
2765
+ const injected = hookText(a);
2766
+ if (injected.length > 0) {
2767
+ hook.injections += 1;
2768
+ hook.chars += [...injected].length;
2769
+ pending.push({
2770
+ event,
2771
+ after: lastAssistant
2772
+ });
2773
+ }
2774
+ }
2775
+ }
2776
+ const seen = /* @__PURE__ */ new Set();
2777
+ for (const b of bound) {
2778
+ const key = `${b.event}\u0000${b.message}`;
2779
+ if (seen.has(key)) continue;
2780
+ seen.add(key);
2781
+ if (byId.get(b.message)?.toolUses === 0) stats.misfires[b.event] = (stats.misfires[b.event] ?? 0) + 1;
2782
+ }
2783
+ stats.versions = [...versions].sort();
2784
+ stats.hooks = [...hooks.values()];
2785
+ return stats;
2786
+ }
2787
+ /** Whether any action of the session serves the mainline, through a checklist item or a criterion. */
2788
+ const servesMainline = (session, mainline) => session.actions.some((a) => a.serves.some((ref) => {
2789
+ const parsed = parseRef(ref);
2790
+ return parsed !== null && parsed.hub === void 0 && refMainline(parsed) === mainline;
2791
+ }));
2792
+ /** Subagent transcripts Claude Code keeps beside a session's: <dir>/<session>/subagents/*.jsonl. */
2793
+ function subagentFiles(transcript) {
2794
+ const dir = join(transcript.replace(/\.jsonl$/, ""), "subagents");
2795
+ try {
2796
+ return readdirSync(dir).filter((n) => n.endsWith(".jsonl")).sort().map((n) => join(dir, n));
2797
+ } catch {
2798
+ return [];
2799
+ }
2800
+ }
2801
+ function readMeta(file) {
2802
+ try {
2803
+ const meta = JSON.parse(readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
2804
+ if (!isObject(meta)) return {};
2805
+ return {
2806
+ ...typeof meta.agentType === "string" ? { agentType: meta.agentType } : {},
2807
+ ...typeof meta.description === "string" ? { description: meta.description } : {}
2808
+ };
2809
+ } catch {
2810
+ return {};
2811
+ }
2812
+ }
2813
+ function summarize(stats) {
2814
+ const usage = emptyUsage();
2815
+ let cost = 0;
2816
+ const unknown = /* @__PURE__ */ new Set();
2817
+ const priced = stats.messages.filter((m) => m.model !== "<synthetic>");
2818
+ for (const m of priced) {
2819
+ addUsage(usage, m.usage);
2820
+ const c = messageCost(m.model, m.usage, m.speed, m.geo);
2821
+ if (c === null) unknown.add(m.model);
2822
+ else cost += c;
2823
+ }
2824
+ const first = priced.find((m) => contextTokens(m.usage) > 0);
2825
+ return {
2826
+ file: stats.file,
2827
+ found: stats.found,
2828
+ calls: priced.length,
2829
+ usage,
2830
+ cost,
2831
+ unknownPriceModels: [...unknown].sort(),
2832
+ models: [...new Set(priced.map((m) => m.model))].sort(),
2833
+ openingContext: first ? contextTokens(first.usage) : null,
2834
+ cacheHitRate: cacheHitRate(usage)
2835
+ };
2836
+ }
2837
+ /** Lines of a task's 紀錄 section that record a completion check that did not pass. */
2838
+ const FAILED_CHECK = (line) => line.includes("完成檢查") && line.includes("未過");
2839
+ /**
2840
+ * Read the transcripts of every session with an action serving the mainline, and their subagents', into cost
2841
+ * figures and candidates for where the work went wrong. Each transcript is read once, even when a subagent session
2842
+ * serves the mainline as well.
2843
+ */
2844
+ function retroReport(root, mainlineId) {
2845
+ const mainline = loadHub(root, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
2846
+ if (!mainline) throw new WriteError(`mainline ${mainlineId} not found; run "mainflow status --all" to list mainlines`);
2847
+ const serving = loadSessions(root).sessions.filter((s) => servesMainline(s, mainlineId));
2848
+ const read = /* @__PURE__ */ new Map();
2849
+ const stats = (file) => {
2850
+ let s = read.get(file);
2851
+ if (!s) read.set(file, s = readTranscript(file));
2852
+ return s;
2853
+ };
2854
+ const sessions = [];
2855
+ const subagents = [];
2856
+ const labels = /* @__PURE__ */ new Map();
2857
+ const subagentFilesSeen = /* @__PURE__ */ new Set();
2858
+ const addSubagent = (file, parent) => {
2859
+ if (subagentFilesSeen.has(file)) return;
2860
+ subagentFilesSeen.add(file);
2861
+ labels.set(file, `${parent} › ${basename(file, ".jsonl")}`);
2862
+ subagents.push({
2863
+ ...summarize(stats(file)),
2864
+ parent,
2865
+ ...readMeta(file)
2866
+ });
2867
+ };
2868
+ for (const s of [...serving].sort((a, b) => Number(Boolean(a.delegatedFrom)) - Number(Boolean(b.delegatedFrom)))) {
2869
+ const file = s.transcript ? transcriptPath(root, s.transcript) : void 0;
2870
+ if (s.delegatedFrom) {
2871
+ if (file) addSubagent(file, s.delegatedFrom);
2872
+ continue;
2873
+ }
2874
+ if (!file) {
2875
+ sessions.push({
2876
+ ...summarize(emptyStats("", false)),
2877
+ session: s.id,
2878
+ missing: "the session records no transcript"
2879
+ });
2880
+ continue;
2881
+ }
2882
+ labels.set(file, s.id);
2883
+ const summary = summarize(stats(file));
2884
+ sessions.push({
2885
+ ...summary,
2886
+ session: s.id,
2887
+ ...summary.found ? {} : { missing: `${file} does not exist` }
2888
+ });
2889
+ for (const sub of subagentFiles(file)) addSubagent(sub, s.id);
2890
+ }
2891
+ const all = [...read.values()];
2892
+ const models = /* @__PURE__ */ new Map();
2893
+ const total = emptyUsage();
2894
+ for (const m of all.flatMap((t) => t.messages).filter((m) => m.model !== "<synthetic>")) {
2895
+ const entry = models.get(m.model) ?? {
2896
+ model: m.model,
2897
+ calls: 0,
2898
+ usage: emptyUsage(),
2899
+ cost: priceOf(m.model) ? 0 : null
2900
+ };
2901
+ models.set(m.model, entry);
2902
+ entry.calls += 1;
2903
+ addUsage(entry.usage, m.usage);
2904
+ addUsage(total, m.usage);
2905
+ const c = messageCost(m.model, m.usage, m.speed, m.geo);
2906
+ if (c !== null && entry.cost !== null) entry.cost += c;
2907
+ }
2908
+ const hooks = /* @__PURE__ */ new Map();
2909
+ for (const h of all.flatMap((t) => t.hooks)) {
2910
+ const entry = hooks.get(h.event) ?? {
2911
+ event: h.event,
2912
+ injections: 0,
2913
+ chars: 0,
2914
+ blocked: 0
2915
+ };
2916
+ hooks.set(h.event, entry);
2917
+ entry.injections += h.injections;
2918
+ entry.chars += h.chars;
2919
+ entry.blocked += h.blocked;
2920
+ }
2921
+ const sum = (records) => {
2922
+ const out = {};
2923
+ for (const r of records) for (const [k, v] of Object.entries(r)) out[k] = (out[k] ?? 0) + v;
2924
+ return out;
2925
+ };
2926
+ const count = (r) => Object.values(r).reduce((a, b) => a + b, 0);
2927
+ const toolErrors = sum(all.map((t) => t.toolErrors));
2928
+ const misfires = sum(all.map((t) => t.misfires));
2929
+ const blocked = Object.fromEntries([...hooks.values()].filter((h) => h.blocked > 0).map((h) => [h.event, h.blocked]));
2930
+ const reruns = all.flatMap((t) => Object.entries(t.bashRuns).filter(([, runs]) => runs > 1).map(([command, runs]) => ({
2931
+ command,
2932
+ runs,
2933
+ session: labels.get(t.file) ?? t.file
2934
+ }))).sort((a, b) => b.runs - a.runs);
2935
+ const modelList = [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
2936
+ return {
2937
+ mainline: {
2938
+ id: mainline.id,
2939
+ title: mainline.title,
2940
+ status: mainline.status,
2941
+ criteria: mainline.criteria.length,
2942
+ tasks: mainline.tasks.length
2943
+ },
2944
+ versions: [...new Set(all.flatMap((t) => t.versions))].sort(),
2945
+ unreadable: all.reduce((n, t) => n + t.unreadable, 0),
2946
+ pricing: PRICING_SOURCE,
2947
+ cost: {
2948
+ total: modelList.reduce((n, m) => n + (m.cost ?? 0), 0),
2949
+ unknownPriceModels: modelList.filter((m) => m.cost === null).map((m) => m.model),
2950
+ cacheHitRate: cacheHitRate(total),
2951
+ models: modelList,
2952
+ sessions,
2953
+ subagents,
2954
+ hooks: [...hooks.values()].sort((a, b) => b.injections - a.injections || a.event.localeCompare(b.event))
2955
+ },
2956
+ precision: {
2957
+ toolErrors: {
2958
+ total: count(toolErrors),
2959
+ byTool: toolErrors
2960
+ },
2961
+ bashReruns: {
2962
+ total: reruns.reduce((n, r) => n + r.runs - 1, 0),
2963
+ commands: reruns
2964
+ },
2965
+ misfireCandidates: {
2966
+ total: count(misfires),
2967
+ byEvent: misfires
2968
+ },
2969
+ blocked: {
2970
+ total: count(blocked),
2971
+ byEvent: blocked
2972
+ },
2973
+ gaps: all.flatMap((t) => t.gaps.map((g) => ({
2974
+ ...g,
2975
+ session: labels.get(t.file) ?? t.file
2976
+ }))),
2977
+ failedChecks: mainline.tasks.flatMap((t) => (sectionText(t.body, "紀錄") ?? "").split("\n").filter(FAILED_CHECK).map((line) => ({
2978
+ task: `${mainline.id}/${t.id}`,
2979
+ line: line.trim()
2980
+ })))
2981
+ }
2982
+ };
2983
+ }
2984
+ //#endregion
2317
2985
  //#region ../core/dist/migrate.js
2318
2986
  const drop = (o, ...keys) => keys.forEach((k) => delete o[k]);
2319
2987
  const dropNull = (o, ...keys) => keys.forEach((k) => o[k] === null && delete o[k]);
@@ -2597,4 +3265,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
2597
3265
  return true;
2598
3266
  }
2599
3267
  //#endregion
2600
- export { createIssue as $, sessionBriefing as A, settleSession as B, setCriterionText as C, updateTask as D, supersedeDecision as E, loadSessions as F, matchGrants as G, transcriptPath as H, readSession as I, headerLine as J, decorateReply as K, setSessionArchived as L, addAction as M, ensureSession as N, untilState as O, handoffSession as P, createFinding as Q, setSessionInfo as R, setCriterion as S, setMainlineTitle as T, updateAction as U, startSession as V, grantScope as W, appendOps as X, mayDecorate as Y, closeIssue as Z, createTask as _, currentBranch as _t, openInApp as a, HubError as at, setArchived as b, syncHost as c, mainlineEdges as ct, setArchivedMany as d, MAINLINE_FILE as dt, planIssue as et, acceptDecision as f, TASK_FILE as ft, createMainline as g, DATA_DIR as gt, createDecision as h, readYaml as ht, hostName as i, setIssueArchived as it, turnReminder as j, duplicateClaimLines as k, migrateHub as l, linksInto as lt, addRepo as m, WriteError as mt, DEFAULT_UI_PORT as n, setFindingArchived as nt, removeHostLink as o, loadHub as ot, addHub as p, ConflictError as pt, endsWithMarker as q, caddySites as r, setFindingStatus as rt, siteBlock as s, dependencyGraph as st, APP_BUNDLE_ID as t, reopenIssue as tt, ARCHIVE_KINDS as u, refIndex as ut, dropChecklistItem as v, findHubRoot as vt, setMainlineStatus as w, setChecklistItem as x, initHub as y, worktreesOf as yt, setSessionStatus as z };
3268
+ export { appendOps as $, untilState as A, setSessionArchived as B, setCriterion as C, supersedeDecision as D, setMainlineTitle as E, ensureSession as F, transcriptPath as G, setSessionStatus as H, handoffSession as I, matchGrants as J, updateAction as K, loadSessions as L, sessionBriefing as M, turnReminder as N, undropChecklistItem as O, addAction as P, mayDecorate as Q, readSession as R, setChecklistItem as S, worktreesOf as St, setMainlineStatus as T, settleSession as U, setSessionInfo as V, startSession as W, endsWithMarker as X, decorateReply as Y, headerLine as Z, createMainline as _, WriteError as _t, openInApp as a, setFindingArchived as at, initHub as b, currentBranch as bt, syncHost as c, HubError as ct, ARCHIVE_KINDS as d, mainlineEdges as dt, closeIssue as et, setArchivedMany as f, linksInto as ft, createDecision as g, ConflictError as gt, addRepo as h, TASK_FILE as ht, hostName as i, reopenIssue as it, duplicateClaimLines as j, updateTask as k, migrateHub as l, loadHub as lt, addHub as m, MAINLINE_FILE as mt, DEFAULT_UI_PORT as n, createIssue as nt, removeHostLink as o, setFindingStatus as ot, acceptDecision as p, refIndex as pt, grantScope as q, caddySites as r, planIssue as rt, siteBlock as s, setIssueArchived as st, APP_BUNDLE_ID as t, createFinding as tt, retroReport as u, dependencyGraph as ut, createTask as v, readYaml as vt, setCriterionText as w, setArchived as x, findHubRoot as xt, dropChecklistItem as y, DATA_DIR as yt, setReminderDigest as z };