mainflow 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,65 +1,813 @@
1
1
  #!/usr/bin/env node
2
- import { $ as appendOps, A as untilState, B as setSessionArchived, C as setCriterion, D as supersedeDecision, H as setSessionStatus, I as handoffSession, K as updateAction, L as loadSessions, M as sessionBriefing, O as undropChecklistItem, P as addAction, R as readSession, S as setChecklistItem, T as setMainlineStatus, W as startSession, _ as createMainline, _t as WriteError, at as setFindingArchived, b as initHub, ct as HubError, et as closeIssue, g as createDecision, h as addRepo, ht as TASK_FILE, it as reopenIssue, j as duplicateClaimLines, k as updateTask, l as migrateHub, lt as loadHub, m as addHub, mt as MAINLINE_FILE, nt as createIssue, ot as setFindingStatus, p as acceptDecision, rt as planIssue, st as setIssueArchived, tt as createFinding, u as retroReport, v as createTask, w as setCriterionText, x as setArchived, xt as findHubRoot, y as dropChecklistItem } from "./host-CeN3dvXg.js";
2
+ import { $ as nowMinute, G as loadHub, H as applyOperation, Q as requireSlug, S as readSession, U as checkHubName, W as HubError, X as MAINLINE_FILE, Z as TASK_FILE, _ as untilState, d as retroReportFromReceiver, f as receiverFromEnv, g as initHub, l as migrateHub, ot as DATA_DIR, p as RETRY_RULES, rt as findHubRoot, st as WriteError, tt as nodeStorage, u as retroReport, v as duplicateClaimLines, x as loadSessions, y as sessionBriefing } from "./host-BJnrv8xF.js";
3
3
  import { B as parseRef, H as refExample, V as parseUntil, k as UNTIL_FORMS } from "./dist-BbamrIPW.js";
4
4
  import { t as listProgress } from "./progress-1BfPg2zN.js";
5
+ import { C as ServerError, S as ServerApi, _ as silentHint, a as remoteMode, c as syncSettings, d as startDaemon, f as DaemonRunning, g as readDaemon, h as probeDaemon, i as queuedCount, l as hookAnswer, m as callDaemon, n as hubWriter, o as ENV, p as DaemonUnavailable, r as writtenLines, s as hubName, t as CONFIRM_MS, u as planFlush, v as SETTINGS_FILE, x as syncPaths } from "./operation-Bp4U2bx3.js";
5
6
  import { COMMAND_SPECS, OPTIONS } from "./facts.js";
6
7
  import { hookFallback, parsePayload, runHook } from "./hooks.js";
7
- import { l as CLI_VERSION, r as launchUi, s as startUiInBackground } from "./launch-BKKoCyTj.js";
8
- import { readFileSync, realpathSync } from "node:fs";
9
- import { basename, join, relative } from "node:path";
8
+ import { c as watchHubGone, l as CLI_VERSION, r as launchUi, s as startUiInBackground } from "./launch-DefacJEL.js";
9
+ import { randomBytes } from "node:crypto";
10
+ import { existsSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
11
+ import { basename, join, relative, resolve } from "node:path";
10
12
  import { fileURLToPath } from "node:url";
11
13
  import { parseArgs } from "node:util";
14
+ //#region src/remote/sync.ts
15
+ /**
16
+ * Run the sync client for the hub at `root` until the process is told to stop, or the hub's directory goes. A write
17
+ * that found no sync client may have started this one in the background (src/remote/start.ts); a clean stop clears
18
+ * that record, so the next write does not take the stop for a failed start.
19
+ */
20
+ async function syncRun(root) {
21
+ const settings = syncSettings(root);
22
+ if (!settings) {
23
+ console.error(`✗ no sync server: set ${ENV.url} and ${ENV.token}, or url and token in .mainflow/${SETTINGS_FILE}`);
24
+ return 1;
25
+ }
26
+ const running = await probeDaemon(root);
27
+ if (running && running.state !== "gone") {
28
+ console.error(`✗ mainflow sync already runs for this hub (pid ${running.daemon.pid})${running.state === "silent" ? `; ${silentHint(running.daemon)}` : ""}`);
29
+ return 1;
30
+ }
31
+ try {
32
+ await new ServerApi(settings).snapshot();
33
+ } catch (error) {
34
+ if (error instanceof ServerError) {
35
+ console.error(`✗ ${error.message} (hub ${settings.hub} at ${settings.url})`);
36
+ return 1;
37
+ }
38
+ }
39
+ let daemon;
40
+ try {
41
+ daemon = await startDaemon({
42
+ root,
43
+ settings,
44
+ log: (line) => console.log(line)
45
+ });
46
+ } catch (error) {
47
+ if (!(error instanceof DaemonRunning)) throw error;
48
+ console.error(`✗ ${error.message}`);
49
+ return 1;
50
+ }
51
+ console.log(`syncing hub ${settings.hub} with ${settings.url} (${daemon.client.online ? "online" : "offline, retrying"})`);
52
+ await new Promise((resolve) => {
53
+ process.once("SIGINT", resolve);
54
+ process.once("SIGTERM", resolve);
55
+ watchHubGone(root, resolve);
56
+ });
57
+ await daemon.stop();
58
+ forgetStarted(root);
59
+ return 0;
60
+ }
61
+ /** Clear the record of a background start when it names this process. */
62
+ function forgetStarted(root) {
63
+ const file = syncPaths(root).started;
64
+ try {
65
+ if (JSON.parse(readFileSync(file, "utf8")).pid === process.pid) rmSync(file, { force: true });
66
+ } catch {}
67
+ }
68
+ function readPayload() {
69
+ try {
70
+ const parsed = JSON.parse(readFileSync(0, "utf8"));
71
+ return parsed && typeof parsed === "object" ? parsed : {};
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+ /**
77
+ * Send every change now and report what did not reach the hub as written. With `hook`, read the PostToolUse
78
+ * payload from stdin, skip tools that wrote nothing synced, and answer as the hook: a block with the problems as
79
+ * the reason, or nothing. A hook never fails the tool, so without a running sync client it answers nothing.
80
+ */
81
+ async function syncFlush(root, hook) {
82
+ const payload = hook ? readPayload() : {};
83
+ const daemon = readDaemon(root);
84
+ if (!daemon) {
85
+ if (hook) return 0;
86
+ console.error("✗ mainflow sync is not running for this hub");
87
+ return 1;
88
+ }
89
+ const plan = hook ? planFlush(root, payload) : { skip: false };
90
+ if (plan.skip) return 0;
91
+ let result;
92
+ try {
93
+ result = await callDaemon(daemon, "/flush", plan.edit ? { edit: plan.edit } : {}, 3e4);
94
+ } catch (error) {
95
+ if (hook && error instanceof DaemonUnavailable) return 0;
96
+ throw error;
97
+ }
98
+ if (hook) {
99
+ const answer = hookAnswer(result);
100
+ if (answer) console.log(answer);
101
+ return 0;
102
+ }
103
+ for (const problem of result.problems) console.log(`! ${problem.message}`);
104
+ console.log(`${result.problems.length} problems, ${result.queued} queued, ${result.online ? "online" : "offline"}`);
105
+ return result.problems.length > 0 ? 1 : 0;
106
+ }
107
+ async function syncStatus(root) {
108
+ const probe = await probeDaemon(root);
109
+ if (probe?.state === "silent") {
110
+ console.log(`mainflow sync runs for this hub (pid ${probe.daemon.pid}), but ${silentHint(probe.daemon)}`);
111
+ return 0;
112
+ }
113
+ if (probe?.state === "running") {
114
+ const { status, daemon } = probe;
115
+ if (!status) console.log(`mainflow sync runs for this hub (pid ${daemon.pid}); it did not give its status`);
116
+ else console.log(`mainflow sync runs (pid ${daemon.pid}): ${status.online ? status.caughtUp ? "online, caught up" : "online, catching up" : "offline"}, ${status.queued} queued, atomic exchange ${status.swapping ? "on" : "off"}, ${status.problems} problems so far`);
117
+ return 0;
118
+ }
119
+ const queue = queuedCount(root);
120
+ console.log(`mainflow sync is not running; ${remoteMode(root) ? `${queue} write(s) wait in the outbox until it runs` : "writes go to the local files"}`);
121
+ return 0;
122
+ }
123
+ //#endregion
12
124
  //#region src/retro.ts
13
- const int = (n) => n.toLocaleString("en-US");
125
+ const int = (n) => Math.round(n).toLocaleString("en-US");
14
126
  const usd = (n) => `$${n.toFixed(2)}`;
15
127
  const pct = (n) => n === null ? "–" : `${(n * 100).toFixed(1)}%`;
128
+ const sec = (ms) => `${(ms / 1e3).toFixed(1)} 秒`;
129
+ /** A local stamp of the report (2026-10-05T10:01:12) as the text shows it. */
130
+ const stampText = (s) => s.replace("T", " ");
16
131
  /** A transcript timestamp in local time, to the minute. */
17
132
  const local = (iso) => {
18
133
  const d = new Date(iso);
19
- return Number.isNaN(d.getTime()) ? iso : `${d.toLocaleDateString("sv-SE")} ${d.toTimeString().slice(0, 5)}`;
134
+ return Number.isNaN(d.getTime()) ? String(iso) : `${d.toLocaleDateString("sv-SE")} ${d.toTimeString().slice(0, 5)}`;
20
135
  };
21
136
  const tokens = (u) => `輸入 ${int(u.input)}、快取寫 5m ${int(u.cacheWrite5m)}、快取寫 1h ${int(u.cacheWrite1h)}、快取讀 ${int(u.cacheRead)}、輸出 ${int(u.output)}`;
22
137
  const costOf = (t) => `${usd(t.cost)}${t.unknownPriceModels.length ? `(另有未知單價:${t.unknownPriceModels.join("、")})` : ""}`;
23
138
  const byCount = (r) => Object.entries(r).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${v}`).join("、");
139
+ const joined = (parts) => parts.filter(Boolean).join(",");
24
140
  const COMMAND_SHOWN = 100;
25
- function retroText(report) {
26
- const { mainline, cost, precision } = report;
141
+ const LISTED = 15;
142
+ function calibration(s, undercount) {
143
+ const c = s.calibration;
144
+ if (!c) return "";
145
+ switch (c.method) {
146
+ case "otel": return `依 OTel api_request ${usd(c.calibrated)}`;
147
+ case "cost-state": {
148
+ const { total, after } = c.costState;
149
+ return after.calls > 0 ? `cost-state 合計 ${usd(total)},加合計沒有的呼叫 ${usd(after.calibrated)}(${int(after.calls)} 次呼叫,對話紀錄 ${usd(after.transcript)}),共 ${usd(c.calibrated)}` : `和 cost-state 一致 ${usd(c.calibrated)}`;
150
+ }
151
+ case "cost-state-split": {
152
+ const { total, covered, outside, after } = c.costState;
153
+ const later = after.calls > 0 ? `;合計沒有的呼叫 ${usd(after.calibrated)}(${int(after.calls)} 次呼叫,對話紀錄 ${usd(after.transcript)})` : "";
154
+ return `依 cost-state 分項校正 ${usd(c.calibrated)}(估算:cost-state ${usd(total)},涵蓋的對話紀錄 ${usd(covered)};範圍外 ${usd(outside)}${later})`;
155
+ }
156
+ case "none": return `沒有校正,${undercount ?? ""}`;
157
+ }
158
+ }
159
+ function stretches(s) {
160
+ if (!s.window.length) return "沒有計入的時段";
161
+ if (s.window.length === 1 && !s.window[0].from && !s.window[0].to) return "整份對話紀錄";
162
+ const spans = s.window.map((w) => `${w.from ? stampText(w.from) : "開頭"} 到 ${w.to ? stampText(w.to) : "結尾"}`);
163
+ return spans.length > 3 ? `${spans.length} 段:${spans.slice(0, 3).join("、")} 等` : spans.join("、");
164
+ }
165
+ /** Which part of the transcript counted, and by which rule. */
166
+ function window(s) {
167
+ const scope = s.scope;
168
+ if (!scope || scope.rule === "given") return stretches(s);
169
+ if (scope.rule === "time") return `${stretches(s)}(對話紀錄沒有 mainflow 的輪次標頭,依行動時段)`;
170
+ const notes = joined([
171
+ scope.inferred > 0 && `${scope.inferred} 輪開始時沒有進行中的行動,依這輪開始或接手的行動${scope.inferredCost !== void 0 ? `(對話紀錄 ${usd(scope.inferredCost)})` : ""}`,
172
+ scope.inherited > 0 && `${scope.inherited} 輪沒有標頭,沿用前一輪或派出子代理的那一輪`,
173
+ scope.shared > 0 && `${scope.shared} 輪的行動也服務別條主脈絡,只算給第一個參照`,
174
+ scope.switched > 0 && `${scope.switched} 輪中途開始了別條主脈絡的行動,整輪仍算在這裡`,
175
+ scope.beforeHeads > 0 && `標頭出現前的 ${scope.beforeHeads} 輪依行動時段`
176
+ ]);
177
+ return `${int(scope.counted)} / ${int(scope.turns)} 輪(依每輪的 mainflow 標頭${notes ? `;${notes}` : ""})`;
178
+ }
179
+ function nodeLine(n) {
180
+ const cost = joined([
181
+ n.subagents > 0 && `子代理 ${usd(n.subagentCost)} ×${n.subagents}`,
182
+ n.occupancy.cost > 0 && `主會話佔用 ${usd(n.occupancy.cost)}${n.repeatOccupancy.cost > 0 ? `(其中重複注入 ${usd(n.repeatOccupancy.cost)})` : ""}`,
183
+ n.skillTurns > 0 && `主會話 skill 輪次 ${usd(n.skillTurnCost)} ×${int(n.skillTurns)}`,
184
+ n.subagentOccupancy.cost > 0 && `子代理內佔用 ${usd(n.subagentOccupancy.cost)}(已含在子代理費用)`
185
+ ]);
186
+ const work = joined([
187
+ n.hookRuns > 0 && `hook 執行 ${int(n.hookRuns)} 次 ${sec(n.hookMs)}${n.cancelled ? `,取消 ${n.cancelled}` : ""}${n.blocked ? `,擋下 ${n.blocked}` : ""}`,
188
+ n.injections > 0 && `注入 ${int(n.injections)} 次 ${int(n.chars)} 字${n.repeats ? `(同內容重複 ${n.repeats})` : ""}`,
189
+ n.loads > 0 && `載入 ${int(n.loads)} 次`,
190
+ n.scriptRuns > 0 && `腳本執行 ${int(n.scriptRuns)} 次 ${sec(n.scriptMs)}`
191
+ ]);
192
+ const timing = n.measured && joined([n.measured.unfinished > 0 && `未結束 ${n.measured.unfinished}`, n.measured.killed > 0 && `被訊號中止 ${n.measured.killed}`]);
193
+ return `- ${n.node}(${n.kind}):歸因 ${usd(n.attributed)}${cost ? `;${cost}` : ""}${work ? `;${work}` : ""}${timing ? `(計時紀錄:${timing})` : ""}`;
194
+ }
195
+ function sourceLine(s, by) {
196
+ const head = `- [${s.source}] ${s.key}:${s.anomalies} 則`;
197
+ const reasons = byCount(s.reasons);
198
+ if (by === "ratio") return `${head},最大 ${s.maxRatio.toFixed(1)} 倍(${reasons})`;
199
+ return `${head},超出 ${sec(s.excessMs)},預期 ${s.expectedMs === null ? "–" : sec(s.expectedMs)},中位數 ${s.medianMs === null ? "–" : sec(s.medianMs)}(${reasons})`;
200
+ }
201
+ function costSection(report) {
202
+ const { cost } = report;
27
203
  return [
28
- `retro ${mainline.id} ${mainline.title}(${mainline.status})`,
29
- `Claude Code 版本:${report.versions.join("、") || "(對話紀錄沒有版本欄位)"}`,
30
- `費用為依牌價的估算:${report.pricing.url}(${report.pricing.date},${report.pricing.unit})`,
31
- `無法解析的列:${int(report.unreadable)}`,
32
- `對話紀錄:${cost.sessions.length} 場會話、${cost.subagents.length} 個子代理`,
33
- "",
34
204
  "== 費用 ==",
35
- `估算合計:${usd(cost.total)}${cost.unknownPriceModels.length ? `,不含未知單價的 ${cost.unknownPriceModels.join("、")}` : ""}`,
205
+ `估算合計:${usd(cost.total)}(校正後,對話紀錄本身 ${usd(cost.transcriptTotal)})${cost.unknownPriceModels.length ? `,不含未知單價的 ${cost.unknownPriceModels.join("、")}` : ""}`,
206
+ ...cost.undercount ? [`誤差:有會話沒有 OTel 也沒有 cost-state,${cost.undercount}`] : [],
36
207
  `快取命中率:${pct(cost.cacheHitRate)}(快取讀 ÷ 全部上下文)`,
37
208
  "各模型:",
38
- ...cost.models.map((m) => `- ${m.model}:呼叫 ${int(m.calls)},${tokens(m.usage)},${m.cost === null ? "未知單價" : usd(m.cost)}`),
209
+ ...cost.models.map((m) => `- ${m.model}:呼叫 ${int(m.calls)},${tokens(m.usage)},${m.cost === null ? "未知單價" : `對話紀錄 ${usd(m.cost)},校正後 ${usd(m.calibrated ?? m.cost)}`}`),
39
210
  "會話:",
40
- ...cost.sessions.map((s) => s.missing ? `- ${s.session}:沒有讀到對話紀錄(${s.missing})` : `- ${s.session}:開場上下文 ${s.openingContext === null ? "–" : int(s.openingContext)} tokens,呼叫 ${int(s.calls)},快取命中率 ${pct(s.cacheHitRate)},${costOf(s)}`),
211
+ ...cost.sessions.map((s) => s.missing ? `- ${s.session}:沒有讀到對話紀錄(${s.missing})` : `- ${s.session}:計入 ${window(s)},開場上下文 ${s.openingContext === null ? "–" : int(s.openingContext)} tokens,呼叫 ${int(s.calls)},快取命中率 ${pct(s.cacheHitRate)},${costOf(s)};連同子代理:${calibration(s, cost.undercount)}${s.calibration?.otelSkipped ? `(沒用 OTel:${s.calibration.otelSkipped})` : ""}`),
41
212
  "子代理:",
42
213
  ...cost.subagents.length === 0 ? ["- 無"] : cost.subagents.map((a) => {
43
214
  const who = [a.agentType, a.description].filter(Boolean).join(":");
44
215
  return `- ${basename(a.file, ".jsonl")}${who ? `(${who})` : ""},委派自 ${a.parent}:${a.models.join("、") || "沒有回覆"},呼叫 ${int(a.calls)},${tokens(a.usage)},開場上下文 ${a.openingContext === null ? "–" : int(a.openingContext)},${costOf(a)}`;
45
216
  }),
46
- "hook 注入:",
47
- ...cost.hooks.length === 0 ? ["- 無"] : cost.hooks.map((h) => `- ${h.event}:${int(h.injections)} 次,${int(h.chars)} 字${h.blocked ? `,擋下 ${h.blocked} 次` : ""}`),
48
- "",
217
+ cost.hooksNote ? `hook 事件(${cost.hooksNote}):` : "hook 事件:",
218
+ ...cost.hooks.length === 0 ? ["- 無"] : cost.hooks.map((h) => `- ${h.event}:${joined([
219
+ h.runs > 0 && `執行 ${int(h.runs)} 次 ${sec(h.durationMs)}${h.cancelled ? `(取消 ${h.cancelled})` : ""}`,
220
+ `注入 ${int(h.injections)} 次,${int(h.chars)} 字`,
221
+ h.blocked > 0 && `擋下 ${h.blocked} 次`
222
+ ])}`)
223
+ ];
224
+ }
225
+ function attributionSection(report) {
226
+ const a = report.attribution;
227
+ return [
228
+ "== 來源歸因 ==",
229
+ `promptex 紀錄:${a.roots.filter((r) => r.units.length).map((r) => `${r.root}(${r.units.join("、")})`).join("、") || "沒有找到,節點 id 依安裝路徑推得、未核對"}`,
230
+ `能對回節點:${usd(a.attributed)},占已歸到來源的費用 ${pct(a.coverage)}(分母不含主會話自己的對話與輸出 ${usd(a.conversation)}),占總費用 ${pct(a.coverageOfTotal)}。以對話紀錄的費用 ${usd(a.total)} 計,未校正`,
231
+ `節點(依歸因金額,前 ${LISTED}):`,
232
+ ...a.nodes.length === 0 ? ["- 無"] : a.nodes.slice(0, LISTED).map(nodeLine),
233
+ "對不回節點:",
234
+ ...a.unmapped.length === 0 ? ["- 無"] : a.unmapped.slice(0, 8).map((u) => `- ${u.reason}:${usd(u.cost)}(${int(u.count)} 筆)`),
235
+ ...measuredLines(a.measured)
236
+ ];
237
+ }
238
+ /** The hook timing log's part of the attribution, when the measurement receiver was read. */
239
+ function measuredLines(m) {
240
+ if (!m) return [];
241
+ const states = joined([
242
+ m.unfinished > 0 && `未結束 ${int(m.unfinished)}`,
243
+ m.killed > 0 && `被訊號中止 ${int(m.killed)}`,
244
+ m.endOnly > 0 && `只收到結束列 ${int(m.endOnly)}`
245
+ ]);
246
+ return [
247
+ `hook 計時紀錄:執行 ${int(m.runs)} 次 ${sec(m.ms)},對回節點 ${int(m.mapped)} 次${states ? `(${states})` : ""}。節點的 hook 執行次數與耗時取自它,再加上對話紀錄有、計時紀錄沒有對應的 ${int(m.transcriptOnly.runs)} 次 ${sec(m.transcriptOnly.ms)}`,
248
+ "計時紀錄對不回節點:",
249
+ ...m.unmapped.length === 0 ? ["- 無"] : m.unmapped.map((u) => `- ${u.reason}:${int(u.runs)} 次 ${sec(u.ms)}${u.scripts.length ? `(${u.scripts.join("、")})` : ""}`)
250
+ ];
251
+ }
252
+ /** What the measurement receiver gave and what it could not. */
253
+ function measurementSection(report) {
254
+ const m = report.measurements;
255
+ if (!m) return [];
256
+ const rows = Object.entries(m.rows).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${int(v)}`).join("、");
257
+ return [
258
+ "== 量測接收端 ==",
259
+ `來源:${m.origin || "(沒有讀取)"},${m.sessions} 場會話,列出物件 ${int(m.objects)} 個,下載 ${int(m.downloaded)} 個,缺 ${int(m.missing)} 個,請求 ${int(m.requests)} 次${report.timings.receiver !== void 0 ? `,耗時 ${int(report.timings.receiver)} ms` : ""}`,
260
+ `範圍內的列:${rows || "無"}`,
261
+ `api_request:範圍內 ${int(m.apiRequests.rows)} 列${m.apiRequests.withoutCost ? `,缺模型或費用 ${int(m.apiRequests.withoutCost)} 列` : ""}。涵蓋整個範圍的會話依它校正,其餘會話的校正方式寫在各會話那一行`,
262
+ "hook 批次(平台只記事件,不記腳本,所以對不回節點):",
263
+ ...m.hookBatches.length === 0 ? ["- 無"] : m.hookBatches.map((b) => `- ${b.event}:${joined([
264
+ `批次 ${int(b.batches)}`,
265
+ `hook ${int(b.hooks)} 支`,
266
+ sec(b.ms),
267
+ b.blocking > 0 && `擋下 ${int(b.blocking)}`,
268
+ b.cancelled > 0 && `取消 ${int(b.cancelled)}`,
269
+ b.nonBlockingErrors > 0 && `非阻擋錯誤 ${int(b.nonBlockingErrors)}`
270
+ ])}`),
271
+ "缺的部分:",
272
+ ...m.problems.length === 0 ? ["- 無"] : m.problems.map((p) => `- ${p}`)
273
+ ];
274
+ }
275
+ function anomalySection(report) {
276
+ const x = report.anomalies;
277
+ const { TIMEOUT_SHARE, ROBUST_Z, MIN_EXCESS_MS, MIN_BASELINE, RERUN_WINDOW } = x.rules;
278
+ const types = [
279
+ "hook",
280
+ "script",
281
+ "subagent",
282
+ "bash"
283
+ ].map((t) => `${t} ${x.anomalies.filter((a) => a.source === t).length}`).join("、");
284
+ const top = (list, by) => [
285
+ "hook",
286
+ "script",
287
+ "subagent",
288
+ "bash"
289
+ ].flatMap((t) => list.filter((s) => s.source === t).slice(0, 3)).map((s) => sourceLine(s, by));
290
+ return [
291
+ "== 異常 ==",
292
+ `規則:每個來源以自己的基線判斷(至少 ${MIN_BASELINE} 筆熱執行),robust z > ${ROBUST_Z} 且超出 ${sec(MIN_EXCESS_MS)},同組與整個來源都要超過;沒有基線的 hook 以宣告逾時的 ${TIMEOUT_SHARE * 100}% 當預期;逾時、取消、結束碼非 0、前 ${RERUN_WINDOW} 次內重跑同一指令、使用者中止一律列出`,
293
+ `共 ${x.anomalies.length} 則(${types}),計時的執行 ${int(x.runs.hook + x.runs.script + x.runs.subagent)} 筆,冷啟動 ${x.runs.cold} 筆不進基線`,
294
+ `擋下(把關的決定,不算異常):${int(blockTotal(x.blocks))} 次,${blockMatch(blockTotal(x.blocks), report.precision.blocked.total)}。有紀錄的依節點,PreToolUse 只留訊息,依訊息開頭`,
295
+ ...Object.entries(x.blocks).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, v]) => `- ${k}:${v} 次`),
296
+ "超出時間 × 次數(各類前 3):",
297
+ ...x.byExcess.length ? top(x.byExcess, "excess") : ["- 無"],
298
+ "實際 ÷ 預期(各類前 3):",
299
+ ...x.byRatio.length ? top(x.byRatio, "ratio") : ["- 無"]
300
+ ];
301
+ }
302
+ const blockTotal = (blocks) => Object.values(blocks).reduce((a, b) => a + b, 0);
303
+ /** The gate decisions against the precision section's blocks, which count the same runs. */
304
+ const blockMatch = (gates, blocked) => gates === blocked ? "和精準度候選的「被 hook 擋下」相同" : `比精準度候選的「被 hook 擋下」(${int(blocked)} 次)${gates > blocked ? "多" : "少"} ${int(Math.abs(gates - blocked))} 次`;
305
+ function reviewSection(report) {
306
+ const r = report.reviews;
307
+ const tally = (t) => t.total ? `發現 ${t.total} 則(嚴重度 ${byCount(t.bySev)};成因 ${byCount(t.byClass)})` : "發現 0 則";
308
+ return [
309
+ "== 委派與審查 ==",
310
+ `委派 ${r.delegations} 個,帶標籤 ${r.labeled} 個${r.otherMainline ? `,其中 ${r.otherMainline} 個屬於其他主脈絡` : ""},沒有標籤 ${r.unlabeled.length} 個`,
311
+ "各項目的輪數:",
312
+ ...r.items.length === 0 ? ["- 無"] : r.items.map((i) => {
313
+ const roles = Object.entries(i.roles).map(([role, v]) => `${role} ${v.delegations}(到 r${v.maxRound})`).join("、");
314
+ return `- ${i.item}:${roles}${i.roles.review ? `;${tally(i.findings)}` : ""}`;
315
+ }),
316
+ `審查發現合計:${tally(r.findings)}`,
317
+ `缺 review-findings 區塊的審查回覆:${r.missingBlock.length}`,
318
+ ...r.missingBlock.map((m) => `- ${m.agentId}(${m.item} review r${m.round})`),
319
+ `review-findings 區塊讀不懂的行:${r.badLines.length}`,
320
+ ...r.badLines.slice(0, 10).map((b) => `- ${b.agentId}(${b.item}):${b.why}:${b.line.slice(0, 80)}`),
321
+ `區塊之後還有文字的審查回覆:${r.trailingText.length}`,
322
+ ...r.trailingText.slice(0, 10).map((t) => `- ${t.agentId}(${t.item}):${t.text.replace(/\s+/g, " ").slice(0, 80)}`),
323
+ `無標籤的審查回覆(審查代理的委派沒寫標籤,發現不計入合計):${r.unlabeledReviews.length}`,
324
+ ...r.unlabeledReviews.map((u) => `- ${u.agentId}(${u.agentType}${u.description ? `:${u.description}` : ""}):${u.found ? tally(u.findings) : "缺 review-findings 區塊"}${u.badLines ? `,讀不懂的行 ${u.badLines}` : ""}`)
325
+ ];
326
+ }
327
+ const UNMATCHED = {
328
+ "hand-written": "手寫段",
329
+ "no-node": "標題對不到節點"
330
+ };
331
+ const STRENGTH = {
332
+ strong: "強",
333
+ weak: "弱",
334
+ stop: "Stop"
335
+ };
336
+ function retryLine(b) {
337
+ const between = b.between?.length ? `,中間呼叫 ${b.between.join("、")}` : "";
338
+ return `- [${STRENGTH[b.strength]}] ${b.blockedAt === void 0 ? "–" : local(b.blockedAt)} ${b.event} ${b.tool},${b.hook}:擋下 ${b.attempts} 次後通過${between}(${b.where}):${b.input}`;
339
+ }
340
+ function offlineSection(report) {
341
+ const { sections, sectionWarning, blockRetries } = report.offline;
342
+ const t = report.timings;
343
+ const strength = (k) => blockRetries.filter((b) => b.strength === k).length;
344
+ return [
345
+ "== 離線分析 ==",
346
+ `常駐指示與 rules 逐區段佔用(依會話載入的內容切 # 與 ## 標題,依佔用費用前 ${LISTED},共 ${sections.length} 段):`,
347
+ ...sectionWarning ? [`警告:${sectionWarning}`] : [],
348
+ ...sections.length === 0 ? ["- 無"] : sections.slice(0, LISTED).map((s) => `- ${s.file} ${s.heading}(${s.node ?? UNMATCHED[s.unmatched ?? "no-node"]}):${int(s.chars)} 字 ${int(s.bytes)} B,載入 ${s.loads} 次${s.carried ? `、範圍前帶入 ${s.carried} 次` : ""}${s.versions > 1 ? `(${s.versions} 種版本)` : ""},佔用 ${usd(s.occupancy.cost)}、${int(s.occupancy.tokenCalls)} token 次`),
349
+ `擋下誤判候選:強 ${strength("strong")}、弱 ${strength("weak")}、Stop ${strength("stop")}(強:${RETRY_RULES.strong};弱:${RETRY_RULES.weak};Stop:${RETRY_RULES.stop})`,
350
+ ...blockRetries.slice(0, 20).map(retryLine),
351
+ `耗時:讀取 ${int(t.read)} ms,${t.receiver !== void 0 ? `接收端 ${int(t.receiver)} ms,` : ""}來源歸因 ${int(t.attribution)} ms,異常 ${int(t.anomalies)} ms,委派與審查 ${int(t.reviews)} ms,逐區段佔用 ${int(t.sections)} ms,擋下誤判 ${int(t.blockRetries)} ms,合計 ${int(t.total)} ms`
352
+ ];
353
+ }
354
+ function precisionSection(report) {
355
+ const { mainline, precision } = report;
356
+ return [
49
357
  "== 精準度候選 ==",
50
358
  `工具錯誤:${precision.toolErrors.total}${precision.toolErrors.total ? `(${byCount(precision.toolErrors.byTool)})` : ""}`,
51
359
  `同一個 Bash 指令重跑:${precision.bashReruns.total} 次`,
52
360
  ...precision.bashReruns.commands.slice(0, 10).map((c) => `- ${c.runs} 次(${c.session}):${c.command.length > COMMAND_SHOWN ? `${c.command.slice(0, 99)}…` : c.command}`.replace(/\n/g, " ")),
53
- `誤觸候選(hook 注入後,下一則回覆沒有任何工具呼叫):${precision.misfireCandidates.total}${precision.misfireCandidates.total ? `(${byCount(precision.misfireCandidates.byEvent)})` : ""}`,
54
361
  `被 hook 擋下:${precision.blocked.total}${precision.blocked.total ? `(${byCount(precision.blocked.byEvent)})` : ""}`,
55
362
  `超過 5 分鐘的間隔:${precision.gaps.length}`,
56
363
  ...precision.gaps.map((g) => `- ${local(g.from)} → ${local(g.to)},${g.minutes} 分鐘,${g.waitingForUser ? "等使用者" : "工具或子代理執行中"}(${g.session})`),
57
364
  `主脈絡:解決條件 ${mainline.criteria} 條,任務 ${mainline.tasks} 個,完成檢查未過 ${precision.failedChecks.length} 行`,
58
365
  ...precision.failedChecks.map((f) => `- ${f.task}:${f.line}`)
366
+ ];
367
+ }
368
+ function retroText(report) {
369
+ const { mainline, cost } = report;
370
+ return [
371
+ `retro ${mainline.id} ${mainline.title}(${mainline.status})`,
372
+ `Claude Code 版本:${report.versions.join("、") || "(對話紀錄沒有版本欄位)"}`,
373
+ `費用為依牌價的估算:${report.pricing.url}(${report.pricing.date},${report.pricing.unit})`,
374
+ `無法解析的列:${int(report.unreadable)},重複 uuid 略過 ${int(report.records.duplicates)} 列,範圍外未計入 ${int(report.records.outsideWindow)} 列`,
375
+ `對話紀錄:${cost.sessions.length} 場會話、${cost.subagents.length} 個子代理`,
376
+ "",
377
+ ...report.measurements ? [...measurementSection(report), ""] : [],
378
+ ...costSection(report),
379
+ "",
380
+ ...attributionSection(report),
381
+ "",
382
+ ...anomalySection(report),
383
+ "",
384
+ ...reviewSection(report),
385
+ "",
386
+ ...offlineSection(report),
387
+ "",
388
+ ...precisionSection(report)
59
389
  ].join("\n");
60
390
  }
61
391
  //#endregion
62
392
  //#region src/cli.ts
393
+ const write = (spec) => spec;
394
+ /** Where a document an operation created or changed is, as the commands print it: .mainflow/... */
395
+ const inData = (...parts) => join(DATA_DIR, ...parts);
396
+ const WRITES = {
397
+ "repo add": write({
398
+ op: "repo.add",
399
+ check: ({ root, args }) => {
400
+ requireSlug(args[0]);
401
+ const target = resolve(root, args[1]);
402
+ if (!existsSync(target) || !statSync(target).isDirectory()) throw new WriteError(`repo path ${args[1]} does not exist from the hub root (${target})`);
403
+ },
404
+ input: ({ args }) => ({
405
+ name: args[0],
406
+ path: args[1]
407
+ }),
408
+ print: ({ args }, name) => done(`registered ${name} ${args[1]}`)
409
+ }),
410
+ "hub add": write({
411
+ op: "hub.add",
412
+ check: ({ root, args }) => {
413
+ const [name, path] = [args[0], args[1]];
414
+ checkHubName(nodeStorage(root), name);
415
+ const target = resolve(root, path);
416
+ if (!existsSync(join(target, ".mainflow", "config.yaml"))) throw new WriteError(`no hub at ${path} from the hub root (${target})`);
417
+ const other = hubName(target);
418
+ if (other !== name) throw new WriteError(`${path} holds hub "${other}"; register it as "${other}"`);
419
+ },
420
+ input: ({ args }) => ({
421
+ name: args[0],
422
+ path: args[1]
423
+ }),
424
+ print: ({ args }, name) => done(`registered hub ${name} ${args[1]}`)
425
+ }),
426
+ "session start": write({
427
+ op: "session.start",
428
+ input: ({ opts }) => {
429
+ const agent = opts.agent ?? "manual";
430
+ return {
431
+ id: opts.id ?? `${agent}-${randomBytes(4).toString("hex")}`,
432
+ agent,
433
+ cwd: process.cwd(),
434
+ delegatedFrom: opts["delegated-from"],
435
+ handoffFrom: opts["handoff-from"]
436
+ };
437
+ },
438
+ print: (_, session) => done(`started ${session.id}${session.actions.length ? ` with ${session.actions.length} carried actions` : ""}`)
439
+ }),
440
+ "session handoff": write({
441
+ op: "session.handoff",
442
+ input: ({ args, opts }) => ({
443
+ id: sessionOf(opts),
444
+ from: args[0]
445
+ }),
446
+ print: ({ args }, session) => done(`${session.id} took over ${session.actions.filter((a) => a.status === "todo" || a.status === "doing").length} actions from ${args[0]}`)
447
+ }),
448
+ "session set": write({
449
+ op: "session.set",
450
+ input: ({ args, opts }) => ({
451
+ id: args[0],
452
+ status: required(opts, "status", "--status paused")
453
+ }),
454
+ print: ({ args }, session) => done(`updated ${args[0]} (${session.status})`)
455
+ }),
456
+ "session end": write({
457
+ op: "session.set",
458
+ input: ({ args, opts }) => ({
459
+ id: sessionOf(opts, args[0]),
460
+ status: "ended"
461
+ }),
462
+ print: (_, session) => done(`ended ${session.id}`)
463
+ }),
464
+ "session archive": write({
465
+ op: "session.archive",
466
+ input: ({ args }) => ({
467
+ id: args[0],
468
+ archived: true
469
+ }),
470
+ print: (_, session) => done(`archived ${session.id}`)
471
+ }),
472
+ "session unarchive": write({
473
+ op: "session.archive",
474
+ input: ({ args }) => ({
475
+ id: args[0],
476
+ archived: false
477
+ }),
478
+ print: (_, session) => done(`unarchived ${session.id}`)
479
+ }),
480
+ "action add": write({
481
+ op: "action.add",
482
+ input: ({ args, opts }) => ({
483
+ session: sessionOf(opts),
484
+ do: args[0],
485
+ serves: list(opts.serves),
486
+ before: opts.before
487
+ }),
488
+ print: ({ root }, ref) => {
489
+ done(`created ${ref}`);
490
+ return remindDuplicates(root, ref);
491
+ }
492
+ }),
493
+ "action set": write({
494
+ op: "action.set",
495
+ input: ({ args, opts }) => {
496
+ const blockedBy = opts.reason ? {
497
+ reason: opts.reason,
498
+ ...opts["waiting-for"] ? { waitingFor: opts["waiting-for"] } : {},
499
+ ...opts.until ? { until: opts.until } : {}
500
+ } : void 0;
501
+ if (!blockedBy && (opts["waiting-for"] || opts.until)) throw new WriteError("--waiting-for and --until need --reason, e.g. --status blocked --reason \"等 schema 定案\"");
502
+ return {
503
+ ref: actionRef(opts, args[0]),
504
+ patch: {
505
+ status: opts.status,
506
+ do: opts.text,
507
+ serves: opts.serves === void 0 ? void 0 : list(opts.serves),
508
+ before: opts.before,
509
+ note: opts.note,
510
+ blockedBy
511
+ }
512
+ };
513
+ },
514
+ print: ({ root, args, opts }, action) => {
515
+ const ref = actionRef(opts, args[0]);
516
+ done(`updated ${ref} (${action.status})`);
517
+ return opts.status === "doing" ? remindDuplicates(root, ref) : 0;
518
+ }
519
+ }),
520
+ "new mainline": write({
521
+ op: "mainline.new",
522
+ input: ({ args, opts }) => ({
523
+ slug: args[0],
524
+ title: required(opts, "title", "--title \"重建 mainline 任務管理\"")
525
+ }),
526
+ print: (_, { id, dir }) => done(`created ${id} ${inData(dir, MAINLINE_FILE)}`)
527
+ }),
528
+ "new task": write({
529
+ op: "task.new",
530
+ input: ({ args, opts }) => ({
531
+ mainline: args[0],
532
+ slug: args[1],
533
+ title: required(opts, "title", "--title \"定義資料格式 schema\""),
534
+ kind: opts.kind,
535
+ parent: opts.parent,
536
+ repo: opts.repo
537
+ }),
538
+ print: (_, { ref, dir }) => done(`created ${ref} ${inData(dir, TASK_FILE)}`)
539
+ }),
540
+ "new decision": write({
541
+ op: "decision.new",
542
+ input: ({ args, opts }) => ({
543
+ mainline: args[0],
544
+ slug: args[1],
545
+ title: required(opts, "title", "--title \"以文字檔為準\""),
546
+ status: opts.status,
547
+ by: opts.by,
548
+ basis: opts.basis,
549
+ affects: opts.affects ? list(opts.affects) : void 0
550
+ }),
551
+ print: (_, { ref, file }) => done(`created ${ref} ${inData(file)}`)
552
+ }),
553
+ "new issue": write({
554
+ op: "issue.new",
555
+ input: ({ args, opts }) => ({
556
+ slug: args[0],
557
+ title: required(opts, "title", "--title \"重試不穩定的 VM\""),
558
+ origin: opts.origin,
559
+ labels: opts.labels ? list(opts.labels) : void 0
560
+ }),
561
+ print: (_, { id, file }) => done(`created ${id} ${inData(file)}`)
562
+ }),
563
+ "new finding": write({
564
+ op: "finding.new",
565
+ input: ({ args, opts }) => ({
566
+ slug: args[0],
567
+ title: required(opts, "title", "--title \"先 await 再寫入 state\""),
568
+ origin: opts.origin,
569
+ labels: opts.labels ? list(opts.labels) : void 0
570
+ }),
571
+ print: (_, { id, file }) => done(`created ${id} ${inData(file)}`)
572
+ }),
573
+ "mainline set": write({
574
+ op: "mainline.set",
575
+ input: ({ args, opts }) => ({
576
+ id: args[0],
577
+ status: required(opts, "status", "--status active")
578
+ }),
579
+ print: (_, m) => done(`updated ${m.id} (${m.status})`)
580
+ }),
581
+ "mainline archive": write({
582
+ op: "mainline.archive",
583
+ input: ({ args }) => ({
584
+ id: args[0],
585
+ archived: true
586
+ }),
587
+ print: (_, m) => done(`archived ${m.id}`)
588
+ }),
589
+ "mainline unarchive": write({
590
+ op: "mainline.archive",
591
+ input: ({ args }) => ({
592
+ id: args[0],
593
+ archived: false
594
+ }),
595
+ print: (_, m) => done(`unarchived ${m.id}`)
596
+ }),
597
+ "criterion add": write({
598
+ op: "criterion.add",
599
+ input: ({ args, opts }) => ({
600
+ mainline: args[0],
601
+ text: args[1],
602
+ doneWhen: opts["done-when"] ?? ""
603
+ }),
604
+ print: (_, { ref }) => done(`created ${ref}`)
605
+ }),
606
+ "criterion check": write({
607
+ op: "criterion.check",
608
+ input: ({ args, opts }) => ({
609
+ ref: args[0],
610
+ done: true,
611
+ evidence: opts.evidence
612
+ }),
613
+ print: ({ args }) => done(`checked ${args[0]}`)
614
+ }),
615
+ "criterion uncheck": write({
616
+ op: "criterion.check",
617
+ input: ({ args }) => ({
618
+ ref: args[0],
619
+ done: false
620
+ }),
621
+ print: ({ args }) => done(`unchecked ${args[0]}`)
622
+ }),
623
+ "criterion set": write({
624
+ op: "criterion.set",
625
+ input: ({ args, opts }) => ({
626
+ ref: args[0],
627
+ patch: {
628
+ text: opts.text,
629
+ doneWhen: opts["done-when"]
630
+ }
631
+ }),
632
+ print: ({ args }) => done(`updated ${args[0]}`)
633
+ }),
634
+ "task set": write({
635
+ op: "task.set",
636
+ input: ({ args, opts }) => {
637
+ const blockedBy = opts.reason ? {
638
+ reason: opts.reason,
639
+ ...opts["waiting-for"] ? { waitingFor: opts["waiting-for"] } : {},
640
+ ...opts.signal ? { signal: opts.signal } : {}
641
+ } : void 0;
642
+ return {
643
+ ref: args[0],
644
+ patch: {
645
+ status: opts.status,
646
+ repo: opts.repo,
647
+ blockedBy,
648
+ branch: opts.branch,
649
+ worktree: opts.worktree,
650
+ base: opts.base,
651
+ merge: opts.merge
652
+ }
653
+ };
654
+ },
655
+ print: ({ args }, t) => done(`updated ${args[0]} (${t.status})`)
656
+ }),
657
+ "item add": write({
658
+ op: "item.add",
659
+ input: ({ args, opts }) => ({
660
+ task: args[0],
661
+ text: args[1],
662
+ doneWhen: opts["done-when"] ?? "",
663
+ serves: opts.serves === void 0 ? void 0 : list(opts.serves)
664
+ }),
665
+ print: (_, { ref }) => done(`created ${ref}`)
666
+ }),
667
+ "item check": write({
668
+ op: "item.check",
669
+ input: ({ args, opts }) => ({
670
+ ref: args[0],
671
+ done: true,
672
+ evidence: opts.evidence
673
+ }),
674
+ print: ({ args }) => done(`checked ${args[0]}`)
675
+ }),
676
+ "item uncheck": write({
677
+ op: "item.check",
678
+ input: ({ args }) => ({
679
+ ref: args[0],
680
+ done: false
681
+ }),
682
+ print: ({ args }) => done(`unchecked ${args[0]}`)
683
+ }),
684
+ "decision accept": write({
685
+ op: "decision.accept",
686
+ input: ({ args, opts }) => ({
687
+ ref: args[0],
688
+ by: opts.by,
689
+ basis: opts.basis
690
+ }),
691
+ print: ({ args }, d) => done(`accepted ${args[0]} (${d.decidedAt})`)
692
+ }),
693
+ "item drop": write({
694
+ op: "item.drop",
695
+ input: ({ args, opts }) => ({
696
+ ref: args[0],
697
+ supersededBy: opts.by,
698
+ note: opts.note
699
+ }),
700
+ print: ({ args }) => done(`dropped ${args[0]}`)
701
+ }),
702
+ "item undrop": write({
703
+ op: "item.undrop",
704
+ input: ({ args }) => ({ ref: args[0] }),
705
+ print: ({ args }) => done(`undropped ${args[0]}`)
706
+ }),
707
+ "issue plan": write({
708
+ op: "issue.plan",
709
+ input: ({ args }) => ({
710
+ id: args[0],
711
+ task: args[1]
712
+ }),
713
+ print: ({ args }, i) => done(`planned ${i.id} in ${args[1]}`)
714
+ }),
715
+ "issue close": write({
716
+ op: "issue.close",
717
+ input: ({ args, opts }) => ({
718
+ id: args[0],
719
+ resolution: opts.by ?? ""
720
+ }),
721
+ print: (_, i) => done(`closed ${i.id}`)
722
+ }),
723
+ "issue reopen": write({
724
+ op: "issue.reopen",
725
+ input: ({ args }) => ({ id: args[0] }),
726
+ print: (_, i) => done(`reopened ${i.id}`)
727
+ }),
728
+ "issue archive": write({
729
+ op: "issue.archive",
730
+ input: ({ args }) => ({
731
+ id: args[0],
732
+ archived: true
733
+ }),
734
+ print: (_, i) => done(`archived ${i.id}`)
735
+ }),
736
+ "issue unarchive": write({
737
+ op: "issue.archive",
738
+ input: ({ args }) => ({
739
+ id: args[0],
740
+ archived: false
741
+ }),
742
+ print: (_, i) => done(`unarchived ${i.id}`)
743
+ }),
744
+ "finding set": write({
745
+ op: "finding.set",
746
+ input: ({ args, opts }) => ({
747
+ id: args[0],
748
+ status: required(opts, "status", "--status obsolete")
749
+ }),
750
+ print: (_, f) => done(`updated ${f.id}`)
751
+ }),
752
+ "finding archive": write({
753
+ op: "finding.archive",
754
+ input: ({ args }) => ({
755
+ id: args[0],
756
+ archived: true
757
+ }),
758
+ print: (_, f) => done(`archived ${f.id}`)
759
+ }),
760
+ "finding unarchive": write({
761
+ op: "finding.archive",
762
+ input: ({ args }) => ({
763
+ id: args[0],
764
+ archived: false
765
+ }),
766
+ print: (_, f) => done(`unarchived ${f.id}`)
767
+ }),
768
+ "ops add": write({
769
+ op: "ops.add",
770
+ input: ({ opts }) => ({
771
+ target: required(opts, "target", "--target github.com/owner/repo"),
772
+ operation: required(opts, "op", "--op \"push tag v1.2.0\""),
773
+ result: required(opts, "result", "--result success"),
774
+ ...opts.ref ? { ref: opts.ref } : {}
775
+ }),
776
+ print: (_, entry) => done(`logged ${entry.at} ${entry.operation}`)
777
+ }),
778
+ "section append": write({
779
+ op: "section.append",
780
+ input: ({ args }) => ({
781
+ ref: args[0],
782
+ section: args[1],
783
+ text: args[2]
784
+ }),
785
+ print: ({ args }, { file }) => done(`appended to ${args[0]} ## ${args[1].trim()} ${inData(file)}`)
786
+ }),
787
+ "decision supersede": write({
788
+ op: "decision.supersede",
789
+ input: ({ args, opts }) => ({
790
+ ref: args[0],
791
+ by: required(opts, "by", "--by d002")
792
+ }),
793
+ print: ({ args }, d) => done(`superseded ${args[0]} by ${d.supersededBy}`)
794
+ })
795
+ };
796
+ /**
797
+ * Run a writing command. Locally, the operation runs on the hub's files under the write lock. In remote mode it is
798
+ * applied here and put in the hub's outbox, and the command waits for the sync server's answer, which may give a
799
+ * new entry another id; without an answer in time it ends with the write still in the outbox.
800
+ */
801
+ function runWrite(ctx, spec) {
802
+ spec.check?.(ctx);
803
+ const input = spec.input(ctx);
804
+ if (!remoteMode(ctx.root)) return spec.print(ctx, applyOperation(nodeStorage(ctx.root), spec.op, input, { now: nowMinute() }).value);
805
+ return hubWriter(ctx.root, process.env, { confirmMs: CONFIRM_MS })(spec.op, input).then((written) => {
806
+ const code = spec.print(ctx, written.value);
807
+ for (const line of writtenLines(written)) (line.startsWith("!") ? console.error : console.log)(line);
808
+ return code;
809
+ });
810
+ }
63
811
  const RUNS = {
64
812
  init: ({ root, args }) => done(`created ${relative(root, initHub(root, args[0]))}`),
65
813
  status: ({ root, opts }) => printStatus(loadHub(root), opts.all ?? false),
@@ -94,59 +842,12 @@ const RUNS = {
94
842
  });
95
843
  return 0;
96
844
  },
97
- "repo add": ({ root, args }) => done(`registered ${addRepo(root, args[0], args[1])} ${args[1]}`),
98
- "hub add": ({ root, args }) => done(`registered hub ${addHub(root, args[0], args[1])} ${args[1]}`),
99
- "session start": ({ root, opts }) => {
100
- const session = startSession(root, {
101
- id: opts.id,
102
- agent: opts.agent ?? "manual",
103
- cwd: process.cwd(),
104
- delegatedFrom: opts["delegated-from"],
105
- handoffFrom: opts["handoff-from"]
106
- });
107
- return done(`started ${session.id}${session.actions.length ? ` with ${session.actions.length} carried actions` : ""}`);
108
- },
109
- "session handoff": ({ root, args, opts }) => {
110
- const session = handoffSession(root, sessionOf(opts), args[0]);
111
- return done(`${session.id} took over ${session.actions.filter((a) => a.status === "todo" || a.status === "doing").length} actions from ${args[0]}`);
112
- },
113
845
  "session show": ({ root, args, opts }) => {
114
846
  const id = sessionOf(opts, args[0]);
115
847
  const session = readSession(root, id);
116
848
  if (!session) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
117
849
  return done(sessionBriefing(root, session, { cli: CLI }));
118
850
  },
119
- "session set": ({ root, args, opts }) => done(`updated ${args[0]} (${setSessionStatus(root, args[0], required(opts, "status", "--status paused")).status})`),
120
- "session end": ({ root, args, opts }) => done(`ended ${setSessionStatus(root, sessionOf(opts, args[0]), "ended").id}`),
121
- "session archive": ({ root, args }) => done(`archived ${setSessionArchived(root, args[0], true).id}`),
122
- "session unarchive": ({ root, args }) => done(`unarchived ${setSessionArchived(root, args[0], false).id}`),
123
- "action add": ({ root, args, opts }) => {
124
- const ref = addAction(root, sessionOf(opts), {
125
- do: args[0],
126
- serves: list(opts.serves),
127
- before: opts.before
128
- });
129
- done(`created ${ref}`);
130
- return remindDuplicates(root, ref);
131
- },
132
- "action set": ({ root, args, opts }) => {
133
- const ref = actionRef(opts, args[0]);
134
- const blockedBy = opts.reason ? {
135
- reason: opts.reason,
136
- ...opts["waiting-for"] ? { waitingFor: opts["waiting-for"] } : {},
137
- ...opts.until ? { until: opts.until } : {}
138
- } : void 0;
139
- if (!blockedBy && (opts["waiting-for"] || opts.until)) throw new WriteError("--waiting-for and --until need --reason, e.g. --status blocked --reason \"等 schema 定案\"");
140
- done(`updated ${ref} (${updateAction(root, ref, {
141
- status: opts.status,
142
- do: opts.text,
143
- serves: opts.serves === void 0 ? void 0 : list(opts.serves),
144
- before: opts.before,
145
- note: opts.note,
146
- blockedBy
147
- }).status})`);
148
- return opts.status === "doing" ? remindDuplicates(root, ref) : 0;
149
- },
150
851
  wait: ({ root, args, opts }) => waitUntil(root, args[0], opts),
151
852
  hook: ({ args }) => {
152
853
  let input = "";
@@ -163,100 +864,17 @@ const RUNS = {
163
864
  if (fallback) console.log(fallback);
164
865
  return 0;
165
866
  }
166
- const { stdout, error } = runHook(args[0], args[1], {
867
+ return runHook(args[0], args[1], {
167
868
  payload,
168
869
  envFile: process.env.CLAUDE_ENV_FILE,
169
870
  projectDir: process.env.CLAUDE_PROJECT_DIR,
170
871
  cli: CLI,
171
872
  startUi: startUiInBackground
873
+ }).then(({ stdout, error }) => {
874
+ if (error) console.error(error);
875
+ if (stdout) console.log(stdout);
876
+ return 0;
172
877
  });
173
- if (error) console.error(error);
174
- if (stdout) console.log(stdout);
175
- return 0;
176
- },
177
- "new mainline": ({ root, args, opts }) => {
178
- const { id, dir } = createMainline(root, {
179
- slug: args[0],
180
- title: required(opts, "title", "--title \"重建 mainline 任務管理\"")
181
- });
182
- return done(`created ${id} ${relative(root, join(dir, MAINLINE_FILE))}`);
183
- },
184
- "new task": ({ root, args, opts }) => {
185
- const { ref, dir } = createTask(root, args[0], {
186
- slug: args[1],
187
- title: required(opts, "title", "--title \"定義資料格式 schema\""),
188
- kind: opts.kind,
189
- parent: opts.parent,
190
- repo: opts.repo
191
- });
192
- return done(`created ${ref} ${relative(root, join(dir, TASK_FILE))}`);
193
- },
194
- "new decision": ({ root, args, opts }) => {
195
- const { ref, file } = createDecision(root, args[0], {
196
- slug: args[1],
197
- title: required(opts, "title", "--title \"以文字檔為準\""),
198
- status: opts.status,
199
- by: opts.by,
200
- basis: opts.basis,
201
- affects: opts.affects ? list(opts.affects) : void 0
202
- });
203
- return done(`created ${ref} ${relative(root, file)}`);
204
- },
205
- "new issue": ({ root, args, opts }) => {
206
- const { id, file } = createIssue(root, {
207
- slug: args[0],
208
- title: required(opts, "title", "--title \"重試不穩定的 VM\""),
209
- origin: opts.origin,
210
- labels: opts.labels ? list(opts.labels) : void 0
211
- });
212
- return done(`created ${id} ${relative(root, file)}`);
213
- },
214
- "new finding": ({ root, args, opts }) => {
215
- const { id, file } = createFinding(root, {
216
- slug: args[0],
217
- title: required(opts, "title", "--title \"先 await 再寫入 state\""),
218
- origin: opts.origin,
219
- labels: opts.labels ? list(opts.labels) : void 0
220
- });
221
- return done(`created ${id} ${relative(root, file)}`);
222
- },
223
- "mainline set": ({ root, args, opts }) => {
224
- const m = setMainlineStatus(root, args[0], required(opts, "status", "--status active"));
225
- return done(`updated ${m.id} (${m.status})`);
226
- },
227
- "mainline archive": ({ root, args }) => done(`archived ${setArchived(root, args[0], true).id}`),
228
- "mainline unarchive": ({ root, args }) => done(`unarchived ${setArchived(root, args[0], false).id}`),
229
- "criterion check": ({ root, args, opts }) => {
230
- setCriterion(root, args[0], true, opts.evidence);
231
- return done(`checked ${args[0]}`);
232
- },
233
- "criterion uncheck": ({ root, args }) => {
234
- setCriterion(root, args[0], false);
235
- return done(`unchecked ${args[0]}`);
236
- },
237
- "criterion set": ({ root, args, opts }) => {
238
- setCriterionText(root, args[0], {
239
- text: opts.text,
240
- doneWhen: opts["done-when"]
241
- });
242
- return done(`updated ${args[0]}`);
243
- },
244
- "task set": ({ root, args, opts }) => {
245
- const blockedBy = opts.reason ? {
246
- reason: opts.reason,
247
- ...opts["waiting-for"] ? { waitingFor: opts["waiting-for"] } : {},
248
- ...opts.signal ? { signal: opts.signal } : {}
249
- } : void 0;
250
- const t = updateTask(root, args[0], {
251
- status: opts.status,
252
- repo: opts.repo,
253
- blockedBy,
254
- branch: opts.branch,
255
- worktree: opts.worktree,
256
- base: opts.base,
257
- merge: opts.merge
258
- });
259
- return done(`updated ${args[0]} (${t.status})`);
260
878
  },
261
879
  "task show": ({ root, args }) => {
262
880
  const { mainline, task } = findTask(loadHub(root), args[0]);
@@ -270,79 +888,39 @@ const RUNS = {
270
888
  for (const link of task.links ?? []) console.log(` ${link.rel} → ${link.to}${link.note ? ` ${link.note}` : ""}`);
271
889
  return done(`${task.checklist.length} checklist items`);
272
890
  },
273
- "item check": ({ root, args, opts }) => {
274
- setChecklistItem(root, args[0], true, opts.evidence);
275
- return done(`checked ${args[0]}`);
276
- },
277
- "item uncheck": ({ root, args }) => {
278
- setChecklistItem(root, args[0], false);
279
- return done(`unchecked ${args[0]}`);
280
- },
281
- "decision accept": ({ root, args, opts }) => done(`accepted ${args[0]} (${acceptDecision(root, args[0], {
282
- by: opts.by,
283
- basis: opts.basis
284
- }).decidedAt})`),
285
- "item drop": ({ root, args, opts }) => {
286
- dropChecklistItem(root, args[0], {
287
- supersededBy: opts.by,
288
- note: opts.note
289
- });
290
- return done(`dropped ${args[0]}`);
291
- },
292
- "item undrop": ({ root, args }) => {
293
- undropChecklistItem(root, args[0]);
294
- return done(`undropped ${args[0]}`);
295
- },
296
- "issue plan": ({ root, args }) => done(`planned ${planIssue(root, args[0], args[1]).id} in ${args[1]}`),
297
- "issue close": ({ root, args, opts }) => done(`closed ${closeIssue(root, args[0], opts.by ?? "").id}`),
298
- "issue reopen": ({ root, args }) => done(`reopened ${reopenIssue(root, args[0]).id}`),
299
- "issue archive": ({ root, args }) => done(`archived ${setIssueArchived(root, args[0], true).id}`),
300
- "issue unarchive": ({ root, args }) => done(`unarchived ${setIssueArchived(root, args[0], false).id}`),
301
891
  "issue list": ({ root, opts }) => {
302
892
  const shown = loadHub(root).issues.filter((i) => opts.all || i.status !== "closed");
303
893
  for (const i of shown) console.log(` ${i.id} ${i.status.padEnd(7)} ${i.title}${i.plannedIn ? ` → ${i.plannedIn}` : ""}${i.origin ? ` (from ${i.origin})` : ""}`);
304
894
  return done(`${shown.length} issues`);
305
895
  },
306
- "finding set": ({ root, args, opts }) => done(`updated ${setFindingStatus(root, args[0], required(opts, "status", "--status obsolete")).id}`),
307
- "finding archive": ({ root, args }) => done(`archived ${setFindingArchived(root, args[0], true).id}`),
308
- "finding unarchive": ({ root, args }) => done(`unarchived ${setFindingArchived(root, args[0], false).id}`),
309
896
  "finding list": ({ root, opts }) => {
310
897
  const shown = loadHub(root).findings.filter((f) => opts.all || f.status === "valid");
311
898
  for (const f of shown) console.log(` ${f.id} ${f.created} ${f.title}${f.labels?.length ? ` [${f.labels.join(", ")}]` : ""}`);
312
899
  return done(`${shown.length} findings`);
313
900
  },
314
- "ops add": ({ root, opts }) => {
315
- const entry = appendOps(root, {
316
- target: required(opts, "target", "--target github.com/owner/repo"),
317
- operation: required(opts, "op", "--op \"push tag v1.2.0\""),
318
- result: required(opts, "result", "--result success"),
319
- ...opts.ref ? { ref: opts.ref } : {}
320
- });
321
- return done(`logged ${entry.at} ${entry.operation}`);
322
- },
323
901
  "ops list": ({ root, opts }) => {
324
902
  const covers = (ref) => !opts.ref || ref !== void 0 && (ref === opts.ref || ref.startsWith(`${opts.ref}/`) || ref.startsWith(`${opts.ref}#`));
325
903
  const shown = loadHub(root).ops.filter((o) => (!opts.month || o.at.startsWith(opts.month)) && covers(o.ref));
326
904
  for (const o of shown) console.log(` ${o.at} ${o.target} ${o.operation} ${o.result}${o.ref ? ` ${o.ref}` : ""}`);
327
905
  return done(`${shown.length} operations`);
328
906
  },
329
- "decision supersede": ({ root, args, opts }) => {
330
- const d = supersedeDecision(root, args[0], required(opts, "by", "--by d002"));
331
- return done(`superseded ${args[0]} by ${d.supersededBy}`);
332
- },
333
907
  retro: ({ root, args, opts }) => {
334
- const report = retroReport(root, args[0]);
335
- return done(opts.json ? JSON.stringify(report, null, 2) : retroText(report));
908
+ const print = (report) => done(opts.json ? JSON.stringify(report, null, 2) : retroText(report));
909
+ const receiver = receiverFromEnv(process.env);
910
+ return receiver ? retroReportFromReceiver(root, args[0], receiver).then(print) : print(retroReport(root, args[0]));
336
911
  },
337
912
  "decision list": ({ root, args }) => {
338
913
  const mainline = findMainline(loadHub(root), args[0]);
339
914
  for (const d of mainline.decisions) console.log(` ${mainline.id}/${d.id} ${d.status.padEnd(10)} ${d.title}`);
340
915
  return done(`${mainline.decisions.length} decisions`);
341
- }
916
+ },
917
+ sync: ({ root }) => syncRun(root),
918
+ "sync flush": ({ root, opts }) => syncFlush(root, opts.hook ?? false),
919
+ "sync status": ({ root }) => syncStatus(root)
342
920
  };
343
921
  const COMMANDS = Object.fromEntries(Object.keys(COMMAND_SPECS).map((name) => [name, {
344
922
  ...COMMAND_SPECS[name],
345
- run: RUNS[name]
923
+ run: name in WRITES ? (ctx) => runWrite(ctx, WRITES[name]) : RUNS[name]
346
924
  }]));
347
925
  const USAGE = [
348
926
  "Usage: mainflow <command>",
@@ -354,11 +932,28 @@ const USAGE = [
354
932
  " --version Print the version",
355
933
  " --help Print this message"
356
934
  ].join("\n");
935
+ /**
936
+ * `argv` with `--` put before the last argument of a command whose last argument is literal text, unless the
937
+ * caller already put it there, so the parser takes text such as "- 完成" as an argument and not as options.
938
+ */
939
+ function literalLast(argv) {
940
+ for (const [name, spec] of Object.entries(COMMAND_SPECS)) {
941
+ if (!spec.literalLast) continue;
942
+ const words = name.split(" ");
943
+ const at = words.length + spec.args - 1;
944
+ if (words.every((word, i) => argv[i] === word) && argv.length > at && !argv.slice(0, at + 1).includes("--")) return [
945
+ ...argv.slice(0, at),
946
+ "--",
947
+ ...argv.slice(at)
948
+ ];
949
+ }
950
+ return argv;
951
+ }
357
952
  function run(argv, cwd) {
358
953
  let parsed;
359
954
  try {
360
955
  parsed = parseArgs({
361
- args: argv,
956
+ args: literalLast(argv),
362
957
  allowPositionals: true,
363
958
  options: OPTIONS
364
959
  });
@@ -384,20 +979,25 @@ function run(argv, cwd) {
384
979
  root = found;
385
980
  }
386
981
  try {
387
- return command.run({
982
+ const exit = command.run({
388
983
  root,
389
984
  args,
390
985
  opts
391
986
  });
987
+ return typeof exit === "number" ? exit : exit.catch(reportError);
392
988
  } catch (error) {
393
- if (error instanceof HubError) {
394
- printProblems(error.problems);
395
- return fail(error.message);
396
- }
397
- if (error instanceof WriteError) return fail(`✗ ${error.message}`);
398
- throw error;
989
+ return reportError(error);
399
990
  }
400
991
  }
992
+ /** A refused write or an unreadable hub ends the command with its message; anything else is a bug and is thrown. */
993
+ function reportError(error) {
994
+ if (error instanceof HubError) {
995
+ printProblems(error.problems);
996
+ return fail(error.message);
997
+ }
998
+ if (error instanceof WriteError) return fail(`✗ ${error.message}`);
999
+ throw error;
1000
+ }
401
1001
  function done(line) {
402
1002
  console.log(line);
403
1003
  return 0;
@@ -546,6 +1146,10 @@ function printProblems(problems, mark = "✗") {
546
1146
  for (const problem of problems) console.error(`${mark} ${problem.file}${problem.field ? ` › ${problem.field}` : ""}: ${problem.message}`);
547
1147
  }
548
1148
  const entry = process.argv[1];
549
- if (entry && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) process.exitCode = run(process.argv.slice(2), process.cwd());
1149
+ if (entry && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url))) {
1150
+ const exit = run(process.argv.slice(2), process.cwd());
1151
+ if (typeof exit === "number") process.exitCode = exit;
1152
+ else exit.then((code) => process.exitCode = code);
1153
+ }
550
1154
  //#endregion
551
1155
  export { run };