mainflow 0.25.0 → 0.27.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-CBGKXsoi.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,65 @@ 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
+ /**
455
+ * Open tasks of different open mainlines whose `touches` overlap in the same repo and which have no depends-on
456
+ * between them in either direction, written on the mainline or on the task. Tasks without a repo are not compared,
457
+ * since their paths have no root to share.
458
+ */
459
+ function touchOverlaps(mainlines, edges) {
460
+ const open = mainlines.filter((m) => COMPARED_MAINLINE.includes(m.status)).flatMap((m) => m.tasks.filter((t) => !CLOSED_STATUS.task.includes(t.status) && t.repo && t.touches?.length).map((task) => ({
461
+ mainline: m.id,
462
+ task
463
+ })));
464
+ const overlaps = [];
465
+ open.forEach((a, i) => {
466
+ for (const b of open.slice(i + 1)) {
467
+ if (a.mainline === b.mainline || a.task.repo !== b.task.repo) continue;
468
+ const paths = a.task.touches.flatMap((x) => b.task.touches.filter((y) => touchesOverlap(x, y)).map((y) => [x, y]));
469
+ if (paths.length === 0 || dependsOn(edges, a, b) || dependsOn(edges, b, a)) continue;
470
+ overlaps.push({
471
+ tasks: [`${a.mainline}/${a.task.id}`, `${b.mainline}/${b.task.id}`],
472
+ repo: a.task.repo,
473
+ paths,
474
+ file: a.task.file
475
+ });
476
+ }
477
+ });
478
+ return overlaps;
479
+ }
480
+ //#endregion
422
481
  //#region ../core/dist/templates.js
423
482
  const body = (headings) => ["", ...headings.flatMap((h) => [`## ${h}`, ""])].join("\n");
424
483
  const MAINLINE_BODY = body(MAINLINE_SECTIONS);
@@ -705,6 +764,14 @@ function loadHub(root, options = {}) {
705
764
  message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
706
765
  });
707
766
  }
767
+ for (const o of touchOverlaps(mainlines, deps.edges)) {
768
+ const pairs = o.paths.map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join(", ");
769
+ warnings.push({
770
+ file: o.file,
771
+ field: "touches",
772
+ 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`
773
+ });
774
+ }
708
775
  return {
709
776
  root,
710
777
  config,
@@ -1247,14 +1314,14 @@ function grantScope(root, session) {
1247
1314
  reason: `${session.id} 同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")}),分不出授權範圍`
1248
1315
  };
1249
1316
  const action = doing[0];
1250
- const mainlines = [...new Set(action.serves.flatMap((ref) => {
1317
+ const mainlineOf = (ref) => {
1251
1318
  const parsed = parseRef(ref);
1252
- const mainline = parsed ? refMainline(parsed) : void 0;
1253
- return mainline ? [mainline] : [];
1254
- }))];
1319
+ return parsed ? refMainline(parsed) : void 0;
1320
+ };
1321
+ const mainlines = [...new Set(action.serves.flatMap((ref) => mainlineOf(ref) ?? []))];
1255
1322
  if (mainlines.length === 0) return {
1256
1323
  ok: false,
1257
- reason: `${action.id} 沒有服務任何 checklist 項目`
1324
+ reason: `${action.id} 沒有服務任何 checklist 項目或解決條件`
1258
1325
  };
1259
1326
  const grants = [];
1260
1327
  const served = {};
@@ -1265,7 +1332,7 @@ function grantScope(root, session) {
1265
1332
  closed.push(`${id} 是 ${mainline.status}`);
1266
1333
  continue;
1267
1334
  }
1268
- served[id] = action.serves.find((ref) => ref.startsWith(`${id}/`));
1335
+ served[id] = action.serves.find((ref) => mainlineOf(ref) === id);
1269
1336
  for (const grant of mainline.grants ?? []) grants.push({
1270
1337
  mainline: id,
1271
1338
  grant,
@@ -1433,16 +1500,21 @@ function resolveAction(root, ref) {
1433
1500
  }
1434
1501
  }
1435
1502
  /**
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.
1503
+ * Every served checklist item or criterion must exist, so a session never points at work that is not in a
1504
+ * mainline. A criterion stands in while the mainline has no task for it yet, as when it is still being surveyed
1505
+ * and planned. Its mainline must still be open: follow-up work on an ended one either reopens it with a criterion
1506
+ * or task for the follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
1439
1507
  */
1440
1508
  function requireServes(root, serves) {
1441
1509
  for (const ref of serves) {
1442
1510
  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}"`);
1511
+ 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
1512
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1445
1513
  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"`);
1514
+ if (parsed.kind === "criterion") {
1515
+ if (!mainline.criteria.some((r) => r.id === parsed.criterion)) throw new WriteError(`criterion ${ref} not found`);
1516
+ continue;
1517
+ }
1446
1518
  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
1519
  }
1448
1520
  }
@@ -1695,9 +1767,18 @@ function updateAction(root, ref, patch) {
1695
1767
  //#region ../core/dist/briefing.js
1696
1768
  function describe(root, ref) {
1697
1769
  const parsed = parseRef(ref);
1698
- if (parsed?.kind !== "checklistItem") return null;
1770
+ if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") return null;
1699
1771
  try {
1700
1772
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1773
+ if (parsed.kind === "criterion") {
1774
+ const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
1775
+ return criterion ? {
1776
+ ref,
1777
+ text: criterion.text,
1778
+ checked: criterion.done,
1779
+ where: `${mainline.id} ${mainline.title} › 解決條件`
1780
+ } : null;
1781
+ }
1701
1782
  const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
1702
1783
  const item = task.checklist.find((c) => c.id === parsed.item);
1703
1784
  if (!item) return null;
@@ -1711,6 +1792,32 @@ function describe(root, ref) {
1711
1792
  return null;
1712
1793
  }
1713
1794
  }
1795
+ /** The criteria a served checklist item pushes forward, as m{NNN}#r{NNN}; empty for a criterion or an item that cannot be read. */
1796
+ function criteriaOf(root, ref) {
1797
+ const parsed = parseRef(ref);
1798
+ if (parsed?.kind !== "checklistItem") return [];
1799
+ try {
1800
+ return (readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
1801
+ } catch {
1802
+ return [];
1803
+ }
1804
+ }
1805
+ /**
1806
+ * Where two served lists meet: a reference both serve, or a criterion one serves that a checklist item the other
1807
+ * serves pushes forward, named as the item with its criterion. Two items serving the same criterion do not meet,
1808
+ * since splitting a criterion into items is how work on it runs in parallel.
1809
+ */
1810
+ function sharedServes(root, mine, theirs) {
1811
+ const shared = mine.filter((ref) => theirs.includes(ref));
1812
+ const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(root, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
1813
+ const mineCriteria = mine.filter((ref) => parseRef(ref)?.kind === "criterion");
1814
+ const theirCriteria = theirs.filter((ref) => parseRef(ref)?.kind === "criterion");
1815
+ return [.../* @__PURE__ */ new Set([
1816
+ ...shared,
1817
+ ...pushes(theirs, mineCriteria),
1818
+ ...pushes(mine, theirCriteria)
1819
+ ])];
1820
+ }
1714
1821
  /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
1715
1822
  function queueWarnings(root, session, cli) {
1716
1823
  const warnings = [];
@@ -1718,8 +1825,8 @@ function queueWarnings(root, session, cli) {
1718
1825
  if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
1719
1826
  for (const action of doing) {
1720
1827
  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。`);
1828
+ if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c 或 m#r> 改正。`);
1829
+ else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選或達成。做完了就執行 ${cli} action set ${action.id} --status done。`);
1723
1830
  }
1724
1831
  const next = session.actions.find((a) => a.status === "todo");
1725
1832
  if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
@@ -1794,8 +1901,8 @@ function waiterLines(root, session) {
1794
1901
  return lines;
1795
1902
  }
1796
1903
  /**
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
1904
+ * One reminder for each action in progress in another session that may still be running and serves what this
1905
+ * 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
1906
  * its parent handed it is not a second claim.
1800
1907
  */
1801
1908
  function duplicateClaimLines(root, sessionId, actionId) {
@@ -1807,7 +1914,7 @@ function duplicateClaimLines(root, sessionId, actionId) {
1807
1914
  for (const other of sessions) {
1808
1915
  if (related.has(other.id) || !mayBeRunning(root, other)) continue;
1809
1916
  for (const theirs of other.actions.filter((a) => a.status === "doing")) {
1810
- const shared = theirs.serves.filter((ref) => action.serves.includes(ref));
1917
+ const shared = sharedServes(root, action.serves, theirs.serves);
1811
1918
  if (shared.length > 0) lines.push(`⚠ ${other.id}#${theirs.id}(15 分鐘內有活動)也在做 ${shared.join("、")},先協調再開始`);
1812
1919
  }
1813
1920
  }
@@ -1824,8 +1931,8 @@ function handoffLines(root, session, cli) {
1824
1931
  }
1825
1932
  const servedLine = (root, action) => action.serves.map((ref) => {
1826
1933
  const s = describe(root, ref);
1827
- return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目)`;
1828
- }).join(";") || "沒有指定服務的 checklist 項目";
1934
+ return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目或條件)`;
1935
+ }).join(";") || "沒有指定服務的 checklist 項目或解決條件";
1829
1936
  /** Full briefing for the start of a session, after resuming, clearing or compacting. */
1830
1937
  function sessionBriefing(root, session, options) {
1831
1938
  const { cli } = options;
@@ -1853,7 +1960,7 @@ function sessionBriefing(root, session, options) {
1853
1960
  if (!session.actions.some(isOpen)) lines.push(...handoffLines(root, session, cli));
1854
1961
  const warnings = queueWarnings(root, session, cli);
1855
1962
  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,它依序帶過並告訴你資訊該寫在哪裡。");
1963
+ 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
1964
  return lines.join("\n");
1858
1965
  }
1859
1966
  /** One or two lines for every user turn; null when the session has no actions and the hub has no open mainline. */
@@ -2314,6 +2421,474 @@ function setArchivedMany(root, kind, ids, archived) {
2314
2421
  return result;
2315
2422
  }
2316
2423
  //#endregion
2424
+ //#region ../core/dist/retro.js
2425
+ /** Where the prices come from and when they were read; every figure is USD per million tokens. */
2426
+ const PRICING_SOURCE = {
2427
+ url: "https://platform.claude.com/docs/en/about-claude/pricing",
2428
+ date: "2026-09-30",
2429
+ unit: "USD/MTok"
2430
+ };
2431
+ const price = (input, cacheWrite5m, cacheWrite1h, cacheRead, output) => ({
2432
+ input,
2433
+ cacheWrite5m,
2434
+ cacheWrite1h,
2435
+ cacheRead,
2436
+ output
2437
+ });
2438
+ /**
2439
+ * List prices by model id prefix. A model matches a prefix when the rest of its id is empty, a date suffix
2440
+ * (-20251001) or a bracketed variant ([1m]); any other model has no known price, so its cost is not guessed.
2441
+ */
2442
+ const MODEL_PRICES = [
2443
+ {
2444
+ prefix: "claude-opus-5-5",
2445
+ price: price(4, 5, 8, .2, 20)
2446
+ },
2447
+ {
2448
+ prefix: "claude-opus-5",
2449
+ price: price(5, 6.25, 10, .5, 25)
2450
+ },
2451
+ {
2452
+ prefix: "claude-opus-4-8",
2453
+ price: price(5, 6.25, 10, .5, 25)
2454
+ },
2455
+ {
2456
+ prefix: "claude-sonnet-5-5",
2457
+ price: price(2, 2.5, 4, .2, 10)
2458
+ },
2459
+ {
2460
+ prefix: "claude-sonnet-5",
2461
+ price: price(2, 2.5, 4, .2, 10)
2462
+ },
2463
+ {
2464
+ prefix: "claude-sonnet-4-6",
2465
+ price: price(3, 3.75, 6, .3, 15)
2466
+ },
2467
+ {
2468
+ prefix: "claude-haiku-4-5",
2469
+ price: price(1, 1.25, 2, .1, 5)
2470
+ },
2471
+ {
2472
+ prefix: "claude-fable-5-1",
2473
+ price: price(10, 12.5, 20, .25, 50)
2474
+ }
2475
+ ];
2476
+ /**
2477
+ * Prices at speed "fast", keyed by the MODEL_PRICES prefix. The cache multipliers stack on the fast input
2478
+ * price (5m write 1.25x, 1h write 2x, read 0.05x for Opus 5.5 and 0.1x otherwise), as with standard speed.
2479
+ */
2480
+ const FAST_PRICES = {
2481
+ "claude-opus-5-5": price(8, 10, 16, .4, 40),
2482
+ "claude-opus-5": price(10, 12.5, 20, 1, 50),
2483
+ "claude-opus-4-8": price(10, 12.5, 20, 1, 50)
2484
+ };
2485
+ /** Inference kept in the US (inference_geo "us") is billed at 1.1 times the list price. */
2486
+ const US_GEO_MULTIPLIER = 1.1;
2487
+ const PRICE_SUFFIX = /^(?:-\d{8})?(?:\[[^\]]*\])?$/;
2488
+ /** The price entry a model id falls under: the longest prefix whose remainder is only a date or a variant. */
2489
+ function priceEntry(model) {
2490
+ 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];
2491
+ }
2492
+ const priceOf = (model) => priceEntry(model)?.price;
2493
+ const emptyUsage = () => ({
2494
+ input: 0,
2495
+ cacheWrite5m: 0,
2496
+ cacheWrite1h: 0,
2497
+ cacheRead: 0,
2498
+ output: 0
2499
+ });
2500
+ const addUsage = (to, from) => {
2501
+ for (const key of Object.keys(to)) to[key] += from[key];
2502
+ };
2503
+ /** Everything the model read before answering: fresh input, cache writes and cache reads. */
2504
+ const contextTokens = (u) => u.input + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
2505
+ /** Share of the context served from the cache; null when there was no context at all. */
2506
+ const cacheHitRate = (u) => contextTokens(u) > 0 ? u.cacheRead / contextTokens(u) : null;
2507
+ const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
2508
+ /** Read message.usage; cache writes without the 5m and 1h split count as 5m, the default cache lifetime. */
2509
+ function readUsage(raw) {
2510
+ const created = num(raw.cache_creation_input_tokens);
2511
+ const split = raw.cache_creation ?? {};
2512
+ const w1h = num(split.ephemeral_1h_input_tokens);
2513
+ const w5m = split.ephemeral_5m_input_tokens === void 0 ? created - w1h : num(split.ephemeral_5m_input_tokens);
2514
+ return {
2515
+ input: num(raw.input_tokens),
2516
+ cacheWrite5m: Math.max(0, w5m),
2517
+ cacheWrite1h: w1h,
2518
+ cacheRead: num(raw.cache_read_input_tokens),
2519
+ output: num(raw.output_tokens)
2520
+ };
2521
+ }
2522
+ /** The estimated cost of one message in USD, or null when the model has no known price. */
2523
+ function messageCost(model, usage, speed, geo) {
2524
+ const entry = priceEntry(model);
2525
+ if (!entry) return null;
2526
+ const p = (speed === "fast" ? FAST_PRICES[entry.prefix] : void 0) ?? entry.price;
2527
+ const usd = (usage.input * p.input + usage.cacheWrite5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h + usage.cacheRead * p.cacheRead + usage.output * p.output) / 1e6;
2528
+ return geo === "us" ? usd * US_GEO_MULTIPLIER : usd;
2529
+ }
2530
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2531
+ const blocks = (content) => Array.isArray(content) ? content.filter(isObject) : [];
2532
+ /** The text a hook attachment hands to the model, empty when it hands none. */
2533
+ function hookText(a) {
2534
+ const parts = [];
2535
+ if (typeof a.content === "string") parts.push(a.content);
2536
+ if (Array.isArray(a.content)) parts.push(...a.content.filter((c) => typeof c === "string"));
2537
+ const blocking = a.blockingError;
2538
+ if (typeof blocking === "string") parts.push(blocking);
2539
+ if (isObject(blocking) && typeof blocking.blockingError === "string") parts.push(blocking.blockingError);
2540
+ return parts.join("");
2541
+ }
2542
+ const hookEvent = (a) => typeof a.hookEvent === "string" && a.hookEvent ? a.hookEvent : typeof a.hookName === "string" && a.hookName ? a.hookName.split(":")[0] : "unknown";
2543
+ const emptyStats = (file, found) => ({
2544
+ file,
2545
+ found,
2546
+ versions: [],
2547
+ lines: 0,
2548
+ unreadable: 0,
2549
+ messages: [],
2550
+ toolUses: 0,
2551
+ toolErrors: {},
2552
+ bashRuns: {},
2553
+ hooks: [],
2554
+ misfires: {},
2555
+ gaps: []
2556
+ });
2557
+ function readTranscript(file) {
2558
+ const stats = emptyStats(file, existsSync(file));
2559
+ if (!stats.found) return stats;
2560
+ const versions = /* @__PURE__ */ new Set();
2561
+ const byId = /* @__PURE__ */ new Map();
2562
+ const toolNames = /* @__PURE__ */ new Map();
2563
+ const hooks = /* @__PURE__ */ new Map();
2564
+ /** Injections still waiting for the next assistant message, with the message they came after */
2565
+ let pending = [];
2566
+ const bound = [];
2567
+ let lastAssistant;
2568
+ let lastAt;
2569
+ let lastAtText = "";
2570
+ let anonymous = 0;
2571
+ for (const text of readFileSync(file, "utf8").split("\n")) {
2572
+ if (!text.trim()) continue;
2573
+ stats.lines += 1;
2574
+ let line;
2575
+ try {
2576
+ const parsed = JSON.parse(text);
2577
+ if (!isObject(parsed)) throw new Error("not an object");
2578
+ line = parsed;
2579
+ } catch {
2580
+ stats.unreadable += 1;
2581
+ continue;
2582
+ }
2583
+ if (typeof line.version === "string") versions.add(line.version);
2584
+ const at = typeof line.timestamp === "string" ? Date.parse(line.timestamp) : NaN;
2585
+ if (!Number.isNaN(at)) {
2586
+ if (lastAt !== void 0 && at - lastAt > 3e5) {
2587
+ const turnEnded = lastAssistant !== void 0 && byId.get(lastAssistant)?.toolUses === 0;
2588
+ stats.gaps.push({
2589
+ from: lastAtText,
2590
+ to: line.timestamp,
2591
+ minutes: Math.round((at - lastAt) / 6e4),
2592
+ waitingForUser: turnEnded
2593
+ });
2594
+ }
2595
+ if (lastAt === void 0 || at > lastAt) {
2596
+ lastAt = at;
2597
+ lastAtText = line.timestamp;
2598
+ }
2599
+ }
2600
+ if (line.type === "assistant") {
2601
+ const m = line.message;
2602
+ if (!isObject(m)) {
2603
+ stats.unreadable += 1;
2604
+ continue;
2605
+ }
2606
+ const id = typeof m.id === "string" && m.id ? m.id : `(no id ${++anonymous})`;
2607
+ let message = byId.get(id);
2608
+ if (!message) {
2609
+ message = {
2610
+ id,
2611
+ model: typeof m.model === "string" ? m.model : "unknown",
2612
+ usage: emptyUsage(),
2613
+ toolUses: 0,
2614
+ ...typeof line.timestamp === "string" ? { at: line.timestamp } : {}
2615
+ };
2616
+ byId.set(id, message);
2617
+ stats.messages.push(message);
2618
+ }
2619
+ if (isObject(m.usage)) {
2620
+ const usage = readUsage(m.usage);
2621
+ for (const key of Object.keys(usage)) message.usage[key] = Math.max(message.usage[key], usage[key]);
2622
+ if (typeof m.usage.speed === "string") message.speed = m.usage.speed;
2623
+ if (typeof m.usage.inference_geo === "string") message.geo = m.usage.inference_geo;
2624
+ }
2625
+ for (const block of blocks(m.content)) {
2626
+ if (block.type !== "tool_use") continue;
2627
+ message.toolUses += 1;
2628
+ stats.toolUses += 1;
2629
+ const name = typeof block.name === "string" ? block.name : "unknown";
2630
+ if (typeof block.id === "string") toolNames.set(block.id, name);
2631
+ const input = isObject(block.input) ? block.input : {};
2632
+ if (name === "Bash" && typeof input.command === "string") {
2633
+ const command = input.command.trim();
2634
+ stats.bashRuns[command] = (stats.bashRuns[command] ?? 0) + 1;
2635
+ }
2636
+ }
2637
+ if (id !== lastAssistant) {
2638
+ for (const p of pending.filter((x) => x.after !== id)) bound.push({
2639
+ event: p.event,
2640
+ message: id
2641
+ });
2642
+ pending = pending.filter((x) => x.after === id);
2643
+ }
2644
+ lastAssistant = id;
2645
+ continue;
2646
+ }
2647
+ if (line.type === "user" && line.message !== void 0) {
2648
+ for (const block of blocks(line.message?.content)) {
2649
+ if (block.type !== "tool_result" || block.is_error !== true) continue;
2650
+ const name = typeof block.tool_use_id === "string" && toolNames.get(block.tool_use_id) || "unknown";
2651
+ stats.toolErrors[name] = (stats.toolErrors[name] ?? 0) + 1;
2652
+ }
2653
+ continue;
2654
+ }
2655
+ if (line.type === "attachment") {
2656
+ const a = line.attachment;
2657
+ if (!isObject(a)) {
2658
+ stats.unreadable += 1;
2659
+ continue;
2660
+ }
2661
+ const kind = typeof a.type === "string" ? a.type : "";
2662
+ if (!kind.includes("hook")) continue;
2663
+ const event = hookEvent(a);
2664
+ const hook = hooks.get(event) ?? {
2665
+ event,
2666
+ injections: 0,
2667
+ chars: 0,
2668
+ blocked: 0
2669
+ };
2670
+ hooks.set(event, hook);
2671
+ if (kind.includes("blocking")) hook.blocked += 1;
2672
+ const injected = hookText(a);
2673
+ if (injected.length > 0) {
2674
+ hook.injections += 1;
2675
+ hook.chars += [...injected].length;
2676
+ pending.push({
2677
+ event,
2678
+ after: lastAssistant
2679
+ });
2680
+ }
2681
+ }
2682
+ }
2683
+ const seen = /* @__PURE__ */ new Set();
2684
+ for (const b of bound) {
2685
+ const key = `${b.event}\u0000${b.message}`;
2686
+ if (seen.has(key)) continue;
2687
+ seen.add(key);
2688
+ if (byId.get(b.message)?.toolUses === 0) stats.misfires[b.event] = (stats.misfires[b.event] ?? 0) + 1;
2689
+ }
2690
+ stats.versions = [...versions].sort();
2691
+ stats.hooks = [...hooks.values()];
2692
+ return stats;
2693
+ }
2694
+ /** Whether any action of the session serves the mainline, through a checklist item or a criterion. */
2695
+ const servesMainline = (session, mainline) => session.actions.some((a) => a.serves.some((ref) => {
2696
+ const parsed = parseRef(ref);
2697
+ return parsed !== null && parsed.hub === void 0 && refMainline(parsed) === mainline;
2698
+ }));
2699
+ /** Subagent transcripts Claude Code keeps beside a session's: <dir>/<session>/subagents/*.jsonl. */
2700
+ function subagentFiles(transcript) {
2701
+ const dir = join(transcript.replace(/\.jsonl$/, ""), "subagents");
2702
+ try {
2703
+ return readdirSync(dir).filter((n) => n.endsWith(".jsonl")).sort().map((n) => join(dir, n));
2704
+ } catch {
2705
+ return [];
2706
+ }
2707
+ }
2708
+ function readMeta(file) {
2709
+ try {
2710
+ const meta = JSON.parse(readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
2711
+ if (!isObject(meta)) return {};
2712
+ return {
2713
+ ...typeof meta.agentType === "string" ? { agentType: meta.agentType } : {},
2714
+ ...typeof meta.description === "string" ? { description: meta.description } : {}
2715
+ };
2716
+ } catch {
2717
+ return {};
2718
+ }
2719
+ }
2720
+ function summarize(stats) {
2721
+ const usage = emptyUsage();
2722
+ let cost = 0;
2723
+ const unknown = /* @__PURE__ */ new Set();
2724
+ const priced = stats.messages.filter((m) => m.model !== "<synthetic>");
2725
+ for (const m of priced) {
2726
+ addUsage(usage, m.usage);
2727
+ const c = messageCost(m.model, m.usage, m.speed, m.geo);
2728
+ if (c === null) unknown.add(m.model);
2729
+ else cost += c;
2730
+ }
2731
+ const first = priced.find((m) => contextTokens(m.usage) > 0);
2732
+ return {
2733
+ file: stats.file,
2734
+ found: stats.found,
2735
+ calls: priced.length,
2736
+ usage,
2737
+ cost,
2738
+ unknownPriceModels: [...unknown].sort(),
2739
+ models: [...new Set(priced.map((m) => m.model))].sort(),
2740
+ openingContext: first ? contextTokens(first.usage) : null,
2741
+ cacheHitRate: cacheHitRate(usage)
2742
+ };
2743
+ }
2744
+ /** Lines of a task's 紀錄 section that record a completion check that did not pass. */
2745
+ const FAILED_CHECK = (line) => line.includes("完成檢查") && line.includes("未過");
2746
+ /**
2747
+ * Read the transcripts of every session with an action serving the mainline, and their subagents', into cost
2748
+ * figures and candidates for where the work went wrong. Each transcript is read once, even when a subagent session
2749
+ * serves the mainline as well.
2750
+ */
2751
+ function retroReport(root, mainlineId) {
2752
+ const mainline = loadHub(root, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
2753
+ if (!mainline) throw new WriteError(`mainline ${mainlineId} not found; run "mainflow status --all" to list mainlines`);
2754
+ const serving = loadSessions(root).sessions.filter((s) => servesMainline(s, mainlineId));
2755
+ const read = /* @__PURE__ */ new Map();
2756
+ const stats = (file) => {
2757
+ let s = read.get(file);
2758
+ if (!s) read.set(file, s = readTranscript(file));
2759
+ return s;
2760
+ };
2761
+ const sessions = [];
2762
+ const subagents = [];
2763
+ const labels = /* @__PURE__ */ new Map();
2764
+ const subagentFilesSeen = /* @__PURE__ */ new Set();
2765
+ const addSubagent = (file, parent) => {
2766
+ if (subagentFilesSeen.has(file)) return;
2767
+ subagentFilesSeen.add(file);
2768
+ labels.set(file, `${parent} › ${basename(file, ".jsonl")}`);
2769
+ subagents.push({
2770
+ ...summarize(stats(file)),
2771
+ parent,
2772
+ ...readMeta(file)
2773
+ });
2774
+ };
2775
+ for (const s of [...serving].sort((a, b) => Number(Boolean(a.delegatedFrom)) - Number(Boolean(b.delegatedFrom)))) {
2776
+ const file = s.transcript ? transcriptPath(root, s.transcript) : void 0;
2777
+ if (s.delegatedFrom) {
2778
+ if (file) addSubagent(file, s.delegatedFrom);
2779
+ continue;
2780
+ }
2781
+ if (!file) {
2782
+ sessions.push({
2783
+ ...summarize(emptyStats("", false)),
2784
+ session: s.id,
2785
+ missing: "the session records no transcript"
2786
+ });
2787
+ continue;
2788
+ }
2789
+ labels.set(file, s.id);
2790
+ const summary = summarize(stats(file));
2791
+ sessions.push({
2792
+ ...summary,
2793
+ session: s.id,
2794
+ ...summary.found ? {} : { missing: `${file} does not exist` }
2795
+ });
2796
+ for (const sub of subagentFiles(file)) addSubagent(sub, s.id);
2797
+ }
2798
+ const all = [...read.values()];
2799
+ const models = /* @__PURE__ */ new Map();
2800
+ const total = emptyUsage();
2801
+ for (const m of all.flatMap((t) => t.messages).filter((m) => m.model !== "<synthetic>")) {
2802
+ const entry = models.get(m.model) ?? {
2803
+ model: m.model,
2804
+ calls: 0,
2805
+ usage: emptyUsage(),
2806
+ cost: priceOf(m.model) ? 0 : null
2807
+ };
2808
+ models.set(m.model, entry);
2809
+ entry.calls += 1;
2810
+ addUsage(entry.usage, m.usage);
2811
+ addUsage(total, m.usage);
2812
+ const c = messageCost(m.model, m.usage, m.speed, m.geo);
2813
+ if (c !== null && entry.cost !== null) entry.cost += c;
2814
+ }
2815
+ const hooks = /* @__PURE__ */ new Map();
2816
+ for (const h of all.flatMap((t) => t.hooks)) {
2817
+ const entry = hooks.get(h.event) ?? {
2818
+ event: h.event,
2819
+ injections: 0,
2820
+ chars: 0,
2821
+ blocked: 0
2822
+ };
2823
+ hooks.set(h.event, entry);
2824
+ entry.injections += h.injections;
2825
+ entry.chars += h.chars;
2826
+ entry.blocked += h.blocked;
2827
+ }
2828
+ const sum = (records) => {
2829
+ const out = {};
2830
+ for (const r of records) for (const [k, v] of Object.entries(r)) out[k] = (out[k] ?? 0) + v;
2831
+ return out;
2832
+ };
2833
+ const count = (r) => Object.values(r).reduce((a, b) => a + b, 0);
2834
+ const toolErrors = sum(all.map((t) => t.toolErrors));
2835
+ const misfires = sum(all.map((t) => t.misfires));
2836
+ const blocked = Object.fromEntries([...hooks.values()].filter((h) => h.blocked > 0).map((h) => [h.event, h.blocked]));
2837
+ const reruns = all.flatMap((t) => Object.entries(t.bashRuns).filter(([, runs]) => runs > 1).map(([command, runs]) => ({
2838
+ command,
2839
+ runs,
2840
+ session: labels.get(t.file) ?? t.file
2841
+ }))).sort((a, b) => b.runs - a.runs);
2842
+ const modelList = [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
2843
+ return {
2844
+ mainline: {
2845
+ id: mainline.id,
2846
+ title: mainline.title,
2847
+ status: mainline.status,
2848
+ criteria: mainline.criteria.length,
2849
+ tasks: mainline.tasks.length
2850
+ },
2851
+ versions: [...new Set(all.flatMap((t) => t.versions))].sort(),
2852
+ unreadable: all.reduce((n, t) => n + t.unreadable, 0),
2853
+ pricing: PRICING_SOURCE,
2854
+ cost: {
2855
+ total: modelList.reduce((n, m) => n + (m.cost ?? 0), 0),
2856
+ unknownPriceModels: modelList.filter((m) => m.cost === null).map((m) => m.model),
2857
+ cacheHitRate: cacheHitRate(total),
2858
+ models: modelList,
2859
+ sessions,
2860
+ subagents,
2861
+ hooks: [...hooks.values()].sort((a, b) => b.injections - a.injections || a.event.localeCompare(b.event))
2862
+ },
2863
+ precision: {
2864
+ toolErrors: {
2865
+ total: count(toolErrors),
2866
+ byTool: toolErrors
2867
+ },
2868
+ bashReruns: {
2869
+ total: reruns.reduce((n, r) => n + r.runs - 1, 0),
2870
+ commands: reruns
2871
+ },
2872
+ misfireCandidates: {
2873
+ total: count(misfires),
2874
+ byEvent: misfires
2875
+ },
2876
+ blocked: {
2877
+ total: count(blocked),
2878
+ byEvent: blocked
2879
+ },
2880
+ gaps: all.flatMap((t) => t.gaps.map((g) => ({
2881
+ ...g,
2882
+ session: labels.get(t.file) ?? t.file
2883
+ }))),
2884
+ failedChecks: mainline.tasks.flatMap((t) => (sectionText(t.body, "紀錄") ?? "").split("\n").filter(FAILED_CHECK).map((line) => ({
2885
+ task: `${mainline.id}/${t.id}`,
2886
+ line: line.trim()
2887
+ })))
2888
+ }
2889
+ };
2890
+ }
2891
+ //#endregion
2317
2892
  //#region ../core/dist/migrate.js
2318
2893
  const drop = (o, ...keys) => keys.forEach((k) => delete o[k]);
2319
2894
  const dropNull = (o, ...keys) => keys.forEach((k) => o[k] === null && delete o[k]);
@@ -2597,4 +3172,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
2597
3172
  return true;
2598
3173
  }
2599
3174
  //#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 };
3175
+ export { createFinding as $, duplicateClaimLines as A, setSessionStatus as B, setCriterion as C, supersedeDecision as D, setMainlineTitle as E, handoffSession as F, grantScope as G, startSession as H, loadSessions as I, endsWithMarker as J, matchGrants as K, readSession as L, turnReminder as M, addAction as N, updateTask as O, ensureSession as P, closeIssue as Q, setSessionArchived as R, setChecklistItem as S, setMainlineStatus as T, transcriptPath as U, settleSession as V, updateAction as W, mayDecorate as X, headerLine as Y, appendOps as Z, createMainline as _, DATA_DIR as _t, openInApp as a, setIssueArchived as at, initHub as b, worktreesOf as bt, syncHost as c, dependencyGraph as ct, ARCHIVE_KINDS as d, refIndex as dt, createIssue as et, setArchivedMany as f, MAINLINE_FILE as ft, createDecision as g, readYaml as gt, addRepo as h, WriteError as ht, hostName as i, setFindingStatus as it, sessionBriefing as j, untilState as k, migrateHub as l, mainlineEdges as lt, addHub as m, ConflictError as mt, DEFAULT_UI_PORT as n, reopenIssue as nt, removeHostLink as o, HubError as ot, acceptDecision as p, TASK_FILE as pt, decorateReply as q, caddySites as r, setFindingArchived as rt, siteBlock as s, loadHub as st, APP_BUNDLE_ID as t, planIssue as tt, retroReport as u, linksInto as ut, createTask as v, currentBranch as vt, setCriterionText as w, setArchived as x, dropChecklistItem as y, findHubRoot as yt, setSessionInfo as z };