mainflow 0.27.0 → 0.29.0-alpha.1

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 createFinding, A as duplicateClaimLines, B as setSessionStatus, C as setCriterion, D as supersedeDecision, F as handoffSession, H as startSession, I as loadSessions, L as readSession, N as addAction, O as updateTask, Q as closeIssue, R as setSessionArchived, S as setChecklistItem, T as setMainlineStatus, W as updateAction, Z as appendOps, _ as createMainline, at as setIssueArchived, b as initHub, et as createIssue, ft as MAINLINE_FILE, g as createDecision, h as addRepo, ht as WriteError, it as setFindingStatus, j as sessionBriefing, k as untilState, l as migrateHub, m as addHub, nt as reopenIssue, ot as HubError, p as acceptDecision, pt as TASK_FILE, rt as setFindingArchived, st as loadHub, tt as planIssue, u as retroReport, v as createTask, w as setCriterionText, x as setArchived, y as dropChecklistItem, yt as findHubRoot } from "./host-sSWrUJeC.js";
3
- import { B as parseRef, H as refExample, V as parseUntil, k as UNTIL_FORMS } from "./dist-CBGKXsoi.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
+ 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-1i4OAsD6.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,99 +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
- });
211
- return done(`created ${id} ${relative(root, file)}`);
212
- },
213
- "new finding": ({ root, args, opts }) => {
214
- const { id, file } = createFinding(root, {
215
- slug: args[0],
216
- title: required(opts, "title", "--title \"先 await 再寫入 state\""),
217
- origin: opts.origin,
218
- labels: opts.labels ? list(opts.labels) : void 0
219
- });
220
- return done(`created ${id} ${relative(root, file)}`);
221
- },
222
- "mainline set": ({ root, args, opts }) => {
223
- const m = setMainlineStatus(root, args[0], required(opts, "status", "--status active"));
224
- return done(`updated ${m.id} (${m.status})`);
225
- },
226
- "mainline archive": ({ root, args }) => done(`archived ${setArchived(root, args[0], true).id}`),
227
- "mainline unarchive": ({ root, args }) => done(`unarchived ${setArchived(root, args[0], false).id}`),
228
- "criterion check": ({ root, args, opts }) => {
229
- setCriterion(root, args[0], true, opts.evidence);
230
- return done(`checked ${args[0]}`);
231
- },
232
- "criterion uncheck": ({ root, args }) => {
233
- setCriterion(root, args[0], false);
234
- return done(`unchecked ${args[0]}`);
235
- },
236
- "criterion set": ({ root, args, opts }) => {
237
- setCriterionText(root, args[0], {
238
- text: opts.text,
239
- doneWhen: opts["done-when"]
240
- });
241
- return done(`updated ${args[0]}`);
242
- },
243
- "task set": ({ root, args, opts }) => {
244
- const blockedBy = opts.reason ? {
245
- reason: opts.reason,
246
- ...opts["waiting-for"] ? { waitingFor: opts["waiting-for"] } : {},
247
- ...opts.signal ? { signal: opts.signal } : {}
248
- } : void 0;
249
- const t = updateTask(root, args[0], {
250
- status: opts.status,
251
- repo: opts.repo,
252
- blockedBy,
253
- branch: opts.branch,
254
- worktree: opts.worktree,
255
- base: opts.base,
256
- merge: opts.merge
257
- });
258
- return done(`updated ${args[0]} (${t.status})`);
259
878
  },
260
879
  "task show": ({ root, args }) => {
261
880
  const { mainline, task } = findTask(loadHub(root), args[0]);
@@ -269,75 +888,39 @@ const RUNS = {
269
888
  for (const link of task.links ?? []) console.log(` ${link.rel} → ${link.to}${link.note ? ` ${link.note}` : ""}`);
270
889
  return done(`${task.checklist.length} checklist items`);
271
890
  },
272
- "item check": ({ root, args, opts }) => {
273
- setChecklistItem(root, args[0], true, opts.evidence);
274
- return done(`checked ${args[0]}`);
275
- },
276
- "item uncheck": ({ root, args }) => {
277
- setChecklistItem(root, args[0], false);
278
- return done(`unchecked ${args[0]}`);
279
- },
280
- "decision accept": ({ root, args, opts }) => done(`accepted ${args[0]} (${acceptDecision(root, args[0], {
281
- by: opts.by,
282
- basis: opts.basis
283
- }).decidedAt})`),
284
- "item drop": ({ root, args, opts }) => {
285
- dropChecklistItem(root, args[0], {
286
- supersededBy: opts.by,
287
- note: opts.note
288
- });
289
- return done(`dropped ${args[0]}`);
290
- },
291
- "issue plan": ({ root, args }) => done(`planned ${planIssue(root, args[0], args[1]).id} in ${args[1]}`),
292
- "issue close": ({ root, args, opts }) => done(`closed ${closeIssue(root, args[0], opts.by ?? "").id}`),
293
- "issue reopen": ({ root, args }) => done(`reopened ${reopenIssue(root, args[0]).id}`),
294
- "issue archive": ({ root, args }) => done(`archived ${setIssueArchived(root, args[0], true).id}`),
295
- "issue unarchive": ({ root, args }) => done(`unarchived ${setIssueArchived(root, args[0], false).id}`),
296
891
  "issue list": ({ root, opts }) => {
297
892
  const shown = loadHub(root).issues.filter((i) => opts.all || i.status !== "closed");
298
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})` : ""}`);
299
894
  return done(`${shown.length} issues`);
300
895
  },
301
- "finding set": ({ root, args, opts }) => done(`updated ${setFindingStatus(root, args[0], required(opts, "status", "--status obsolete")).id}`),
302
- "finding archive": ({ root, args }) => done(`archived ${setFindingArchived(root, args[0], true).id}`),
303
- "finding unarchive": ({ root, args }) => done(`unarchived ${setFindingArchived(root, args[0], false).id}`),
304
896
  "finding list": ({ root, opts }) => {
305
897
  const shown = loadHub(root).findings.filter((f) => opts.all || f.status === "valid");
306
898
  for (const f of shown) console.log(` ${f.id} ${f.created} ${f.title}${f.labels?.length ? ` [${f.labels.join(", ")}]` : ""}`);
307
899
  return done(`${shown.length} findings`);
308
900
  },
309
- "ops add": ({ root, opts }) => {
310
- const entry = appendOps(root, {
311
- target: required(opts, "target", "--target github.com/owner/repo"),
312
- operation: required(opts, "op", "--op \"push tag v1.2.0\""),
313
- result: required(opts, "result", "--result success"),
314
- ...opts.ref ? { ref: opts.ref } : {}
315
- });
316
- return done(`logged ${entry.at} ${entry.operation}`);
317
- },
318
901
  "ops list": ({ root, opts }) => {
319
902
  const covers = (ref) => !opts.ref || ref !== void 0 && (ref === opts.ref || ref.startsWith(`${opts.ref}/`) || ref.startsWith(`${opts.ref}#`));
320
903
  const shown = loadHub(root).ops.filter((o) => (!opts.month || o.at.startsWith(opts.month)) && covers(o.ref));
321
904
  for (const o of shown) console.log(` ${o.at} ${o.target} ${o.operation} ${o.result}${o.ref ? ` ${o.ref}` : ""}`);
322
905
  return done(`${shown.length} operations`);
323
906
  },
324
- "decision supersede": ({ root, args, opts }) => {
325
- const d = supersedeDecision(root, args[0], required(opts, "by", "--by d002"));
326
- return done(`superseded ${args[0]} by ${d.supersededBy}`);
327
- },
328
907
  retro: ({ root, args, opts }) => {
329
- const report = retroReport(root, args[0]);
330
- 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]));
331
911
  },
332
912
  "decision list": ({ root, args }) => {
333
913
  const mainline = findMainline(loadHub(root), args[0]);
334
914
  for (const d of mainline.decisions) console.log(` ${mainline.id}/${d.id} ${d.status.padEnd(10)} ${d.title}`);
335
915
  return done(`${mainline.decisions.length} decisions`);
336
- }
916
+ },
917
+ sync: ({ root }) => syncRun(root),
918
+ "sync flush": ({ root, opts }) => syncFlush(root, opts.hook ?? false),
919
+ "sync status": ({ root }) => syncStatus(root)
337
920
  };
338
921
  const COMMANDS = Object.fromEntries(Object.keys(COMMAND_SPECS).map((name) => [name, {
339
922
  ...COMMAND_SPECS[name],
340
- run: RUNS[name]
923
+ run: name in WRITES ? (ctx) => runWrite(ctx, WRITES[name]) : RUNS[name]
341
924
  }]));
342
925
  const USAGE = [
343
926
  "Usage: mainflow <command>",
@@ -349,11 +932,28 @@ const USAGE = [
349
932
  " --version Print the version",
350
933
  " --help Print this message"
351
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
+ }
352
952
  function run(argv, cwd) {
353
953
  let parsed;
354
954
  try {
355
955
  parsed = parseArgs({
356
- args: argv,
956
+ args: literalLast(argv),
357
957
  allowPositionals: true,
358
958
  options: OPTIONS
359
959
  });
@@ -379,20 +979,25 @@ function run(argv, cwd) {
379
979
  root = found;
380
980
  }
381
981
  try {
382
- return command.run({
982
+ const exit = command.run({
383
983
  root,
384
984
  args,
385
985
  opts
386
986
  });
987
+ return typeof exit === "number" ? exit : exit.catch(reportError);
387
988
  } catch (error) {
388
- if (error instanceof HubError) {
389
- printProblems(error.problems);
390
- return fail(error.message);
391
- }
392
- if (error instanceof WriteError) return fail(`✗ ${error.message}`);
393
- throw error;
989
+ return reportError(error);
394
990
  }
395
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
+ }
396
1001
  function done(line) {
397
1002
  console.log(line);
398
1003
  return 0;
@@ -541,6 +1146,10 @@ function printProblems(problems, mark = "✗") {
541
1146
  for (const problem of problems) console.error(`${mark} ${problem.file}${problem.field ? ` › ${problem.field}` : ""}: ${problem.message}`);
542
1147
  }
543
1148
  const entry = process.argv[1];
544
- 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
+ }
545
1154
  //#endregion
546
1155
  export { run };