mainflow 0.11.2 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { A as mainlineSchema, C as TASK_SECTIONS, D as findingSchema, E as decisionSchema, F as refMainline, I as sessionSchema, L as taskSchema, M as parseOpsLine, N as parseRef, O as formatOpsEntry, P as refExample, S as TASK_REQUIRED_SECTIONS, T as configSchema, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as opsEntrySchema, k as issueSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as WORK_STATUS, x as SLUG, y as RESERVED_HUB_NAMES } from "./dist-D2NK-QQP.js";
1
+ import { A as mainlineSchema, C as TASK_SECTIONS, D as findingSchema, E as decisionSchema, F as refMainline, I as sessionSchema, L as taskSchema, M as parseOpsLine, N as parseRef, O as formatOpsEntry, P as refExample, R as waitingForRef, S as TASK_REQUIRED_SECTIONS, T as configSchema, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as opsEntrySchema, k as issueSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as WORK_STATUS, x as SLUG, y as RESERVED_HUB_NAMES } from "./dist-BQmXEfAf.js";
2
2
  import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
@@ -32,10 +32,11 @@ function currentBranch(cwd) {
32
32
  }
33
33
  /**
34
34
  * The hub for `cwd`: the first directory upwards holding `.mainflow/config.yaml`, except that a linked
35
- * worktree's checkout of a hub resolves to the same hub in the main working tree. A worktree is another
36
- * checkout of the same repository, so without this every worktree would write its own copy of the hub and
37
- * the copies would conflict on merge. A different hub nested inside a worktree (a scratch hub under tmp/,
38
- * say) has no counterpart in the main tree and stays as found.
35
+ * worktree's checkout of a hub at the repository root resolves to the same hub in the main working tree.
36
+ * A worktree is another checkout of the same repository, so without this every worktree would write its
37
+ * own copy of the hub and the copies would conflict on merge. A hub below the root (a sample hub under
38
+ * examples/, a scratch hub under tmp/) stays as found: it is data the worktree's own branch owns, and
39
+ * mapping it would let tests and migrations in the worktree write the main tree's copy.
39
40
  */
40
41
  function findHubRoot(cwd) {
41
42
  let found = null;
@@ -48,8 +49,8 @@ function findHubRoot(cwd) {
48
49
  }
49
50
  const trees = worktreesOf(cwd);
50
51
  if (!trees || trees.main === trees.current) return found;
51
- const counterpart = join(trees.main, relative(trees.current, found ?? trees.current));
52
- return isHub(counterpart) ? counterpart : found;
52
+ if (found && found !== trees.current) return found;
53
+ return isHub(trees.main) ? trees.main : found;
53
54
  }
54
55
  function subdirs(dir) {
55
56
  if (!existsSync(dir)) return [];
@@ -311,6 +312,113 @@ function linksInto(hub, hubName) {
311
312
  return out;
312
313
  }
313
314
  //#endregion
315
+ //#region ../core/dist/deps.js
316
+ const MET = /* @__PURE__ */ new Set([
317
+ "done",
318
+ "closed",
319
+ "accepted",
320
+ "superseded",
321
+ "obsolete"
322
+ ]);
323
+ const depState = (target) => !target ? "unknown" : MET.has(target.status) ? "met" : target.status === "dropped" ? "dropped" : "pending";
324
+ function dependencyGraph(hub) {
325
+ const own = refIndex(hub);
326
+ const others = new Map((hub.remotes ?? []).flatMap((r) => r.hub ? [[r.name, refIndex(r.hub)]] : []));
327
+ /** A reference as this hub writes it: this hub's own prefix is dropped, another hub's is kept. */
328
+ const normal = (ref) => parseRef(ref)?.hub === hub.config.hub ? ref.slice(ref.indexOf(":") + 1) : ref;
329
+ const target = (ref) => {
330
+ const hubName = parseRef(ref)?.hub;
331
+ return hubName === void 0 ? own.get(ref) : others.get(hubName)?.get(ref.slice(ref.indexOf(":") + 1));
332
+ };
333
+ const edges = [];
334
+ const edge = (from, to, source, extra) => edges.push({
335
+ from,
336
+ to,
337
+ source,
338
+ state: depState(target(to)),
339
+ ...extra
340
+ });
341
+ const links = (from, file, list) => list?.forEach((l, i) => l.rel === "depends-on" && edge(from, normal(l.to), "link", {
342
+ file,
343
+ field: `links.${i}.to`,
344
+ ...l.note ? { note: l.note } : {}
345
+ }));
346
+ for (const m of hub.mainlines) {
347
+ links(m.id, m.file, m.links);
348
+ for (const d of m.decisions) links(`${m.id}/${d.id}`, d.file, d.links);
349
+ for (const t of m.tasks) {
350
+ links(`${m.id}/${t.id}`, t.file, t.links);
351
+ t.dependsOn?.forEach((dep, i) => edge(`${m.id}/${t.id}`, `${m.id}/${dep}`, "task", {
352
+ file: t.file,
353
+ field: `dependsOn.${i}`
354
+ }));
355
+ }
356
+ }
357
+ for (const d of [...hub.issues, ...hub.findings]) links(d.id, d.file, d.links);
358
+ for (const remote of hub.remotes ?? []) {
359
+ if (!remote.hub) continue;
360
+ for (const { to, link } of linksInto(remote.hub, hub.config.hub)) if (link.rel === "depends-on") edge(`${remote.name}:${link.from}`, to, "link", link.note ? { note: link.note } : {});
361
+ }
362
+ return {
363
+ edges,
364
+ cycles: findCycles(edges)
365
+ };
366
+ }
367
+ /** Cycles among this hub's own documents, each once, starting from its smallest reference. */
368
+ function findCycles(edges) {
369
+ const next = /* @__PURE__ */ new Map();
370
+ for (const e of edges) if (!e.from.includes(":") && !e.to.includes(":")) next.set(e.from, [...next.get(e.from) ?? [], e.to]);
371
+ const state = /* @__PURE__ */ new Map();
372
+ const stack = [];
373
+ const cycles = /* @__PURE__ */ new Map();
374
+ const visit = (node) => {
375
+ state.set(node, "open");
376
+ stack.push(node);
377
+ for (const to of next.get(node) ?? []) if (state.get(to) === "open") {
378
+ const loop = stack.slice(stack.indexOf(to));
379
+ const start = loop.indexOf([...loop].sort()[0]);
380
+ const ordered = [...loop.slice(start), ...loop.slice(0, start)];
381
+ cycles.set(ordered.join(" "), [...ordered, ordered[0]]);
382
+ } else if (!state.has(to)) visit(to);
383
+ stack.pop();
384
+ state.set(node, "closed");
385
+ };
386
+ for (const node of [...next.keys()].sort()) if (!state.has(node)) visit(node);
387
+ return [...cycles.values()];
388
+ }
389
+ /** The mainline a reference sits in, keeping another hub's prefix; undefined for issues and findings. */
390
+ function mainlineOf(ref) {
391
+ const parsed = parseRef(ref);
392
+ const m = parsed ? refMainline(parsed) : void 0;
393
+ return m && (parsed.hub ? `${parsed.hub}:${m}` : m);
394
+ }
395
+ /** Dependencies lifted to mainlines, for a map of how mainlines wait for one another. */
396
+ function mainlineEdges(graph) {
397
+ const out = /* @__PURE__ */ new Map();
398
+ for (const e of graph.edges) {
399
+ const from = mainlineOf(e.from);
400
+ const to = mainlineOf(e.to);
401
+ if (!from || !to || from === to) continue;
402
+ const key = `${from} ${to}`;
403
+ const entry = out.get(key) ?? {
404
+ from,
405
+ to,
406
+ state: e.state,
407
+ edges: []
408
+ };
409
+ entry.edges.push(e);
410
+ const states = entry.edges.map((x) => x.state);
411
+ entry.state = [
412
+ "pending",
413
+ "unknown",
414
+ "dropped",
415
+ "met"
416
+ ].find((s) => states.includes(s));
417
+ out.set(key, entry);
418
+ }
419
+ return [...out.values()];
420
+ }
421
+ //#endregion
314
422
  //#region ../core/dist/templates.js
315
423
  const body = (headings) => ["", ...headings.flatMap((h) => [`## ${h}`, ""])].join("\n");
316
424
  const MAINLINE_BODY = body(MAINLINE_SECTIONS);
@@ -553,6 +661,21 @@ function loadHub(root, options = {}) {
553
661
  unreadable,
554
662
  problems
555
663
  });
664
+ const deps = dependencyGraph({
665
+ config,
666
+ mainlines,
667
+ issues: hubIssues,
668
+ findings,
669
+ remotes
670
+ });
671
+ for (const cycle of deps.cycles) {
672
+ const first = deps.edges.find((e) => e.from === cycle[0] && e.to === cycle[1]);
673
+ problems.push({
674
+ file: first.file,
675
+ field: first.field,
676
+ message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
677
+ });
678
+ }
556
679
  return {
557
680
  root,
558
681
  config,
@@ -686,6 +809,12 @@ function checkReferences(input) {
686
809
  for (const t of m.tasks) {
687
810
  add(t.file, t, `${m.id}/${t.id}`, m.id);
688
811
  items(t.file, "checklist", t.checklist);
812
+ const waiting = waitingForRef(t.blockedBy?.waitingFor);
813
+ if (waiting) pending.push({
814
+ ref: waiting,
815
+ file: t.file,
816
+ field: "blockedBy.waitingFor"
817
+ });
689
818
  }
690
819
  }
691
820
  for (const i of issues) add(i.file, i, i.id);
@@ -718,7 +847,7 @@ function checkReferences(input) {
718
847
  });
719
848
  }
720
849
  }
721
- const isOpen = (kind, status) => !CLOSED_STATUS[kind].includes(status);
850
+ const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
722
851
  /**
723
852
  * What a reader without the conversation needs to pick the work up: every open task says what it is for
724
853
  * and when it is done, and every open criterion or item says what has to be true to check it. Closed
@@ -743,10 +872,10 @@ function contentGaps(mainline, tasks) {
743
872
  });
744
873
  });
745
874
  };
746
- const mainlineOpen = isOpen("mainline", mainline.status);
875
+ const mainlineOpen = isOpen$2("mainline", mainline.status);
747
876
  list(mainline.file, "criteria", mainline.criteria, mainlineOpen);
748
877
  for (const task of tasks) {
749
- const open = mainlineOpen && isOpen("task", task.status);
878
+ const open = mainlineOpen && isOpen$2("task", task.status);
750
879
  list(task.file, "checklist", task.checklist, open);
751
880
  if (!open) continue;
752
881
  for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(task.body, section)) gaps.push({
@@ -1010,82 +1139,6 @@ function decorateReply(text, context) {
1010
1139
  /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
1011
1140
  const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
1012
1141
  //#endregion
1013
- //#region ../core/dist/briefing.js
1014
- function describe(root, ref) {
1015
- const parsed = parseRef(ref);
1016
- if (parsed?.kind !== "checklistItem") return null;
1017
- try {
1018
- const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1019
- const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
1020
- const item = task.checklist.find((c) => c.id === parsed.item);
1021
- if (!item) return null;
1022
- return {
1023
- ref,
1024
- text: item.text,
1025
- checked: item.done,
1026
- where: `${mainline.id} ${mainline.title} › ${task.id} ${task.title}`
1027
- };
1028
- } catch {
1029
- return null;
1030
- }
1031
- }
1032
- /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
1033
- function queueWarnings(root, session, cli) {
1034
- const warnings = [];
1035
- const doing = session.actions.filter((a) => a.status === "doing");
1036
- if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
1037
- for (const action of doing) {
1038
- const served = action.serves.map((ref) => describe(root, ref));
1039
- if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c> 改正。`);
1040
- else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選。做完了就執行 ${cli} action set ${action.id} --status done。`);
1041
- }
1042
- const next = session.actions.find((a) => a.status === "todo");
1043
- if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
1044
- return warnings;
1045
- }
1046
- const current = (session) => session.actions.find((a) => a.status === "doing");
1047
- const servedLine = (root, action) => action.serves.map((ref) => {
1048
- const s = describe(root, ref);
1049
- return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目)`;
1050
- }).join(";") || "沒有指定服務的 checklist 項目";
1051
- /** Full briefing for the start of a session, after resuming, clearing or compacting. */
1052
- function sessionBriefing(root, session, options) {
1053
- const { cli } = options;
1054
- const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
1055
- const doing = current(session);
1056
- const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
1057
- if (session.actions.length === 0) {
1058
- const active = loadHub(root).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
1059
- lines.push("這場會話還沒有行動。");
1060
- if (active.length > 0) {
1061
- lines.push("進行中的主脈絡:");
1062
- for (const m of active) lines.push(`- ${m.id} ${m.title}`);
1063
- lines.push(`要推進其中一個時,先用 ${cli} status 與它的任務 checklist 決定要做什麼,再列出行動。`);
1064
- }
1065
- } else {
1066
- lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
1067
- if (doing) lines.push(`服務:${servedLine(root, doing)}`);
1068
- if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
1069
- }
1070
- const warnings = queueWarnings(root, session, cli);
1071
- if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
1072
- lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
1073
- return lines.join("\n");
1074
- }
1075
- /** One or two lines for every user turn; null when the session has no actions to anchor to. */
1076
- function turnReminder(root, session, options) {
1077
- if (session.actions.length === 0) return null;
1078
- const doing = current(session);
1079
- const open = session.actions.filter((a) => a.status === "todo" || a.status === "doing");
1080
- const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`;
1081
- const header = headerLine(session);
1082
- return [
1083
- head,
1084
- ...header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [],
1085
- ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
1086
- ].join("\n");
1087
- }
1088
- //#endregion
1089
1142
  //#region ../core/dist/sessions.js
1090
1143
  const sessionsDir = (root) => join(root, DATA_DIR, "local", "sessions");
1091
1144
  const sessionFile = (root, id) => join(sessionsDir(root), `${id}.yaml`);
@@ -1162,6 +1215,29 @@ function requireServes(root, serves) {
1162
1215
  if (!readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.some((c) => c.id === parsed.item)) throw new WriteError(`checklist item ${ref} not found`);
1163
1216
  }
1164
1217
  }
1218
+ const isOpen$1 = (a) => a.status === "todo" || a.status === "doing";
1219
+ /**
1220
+ * The previous session's unfinished actions, numbered after `existing`, with the previous session marked
1221
+ * handed-off. Runs inside the caller's lock.
1222
+ */
1223
+ function takeUnfinished(root, from, existing) {
1224
+ const previousFile = requireSession(root, from);
1225
+ const previous = readYaml(previousFile, sessionSchema).data;
1226
+ const ids = [...existing];
1227
+ const carried = previous.actions.filter(isOpen$1).map((a) => {
1228
+ const id = nextId("a", ids);
1229
+ ids.push(id);
1230
+ return {
1231
+ ...a,
1232
+ id
1233
+ };
1234
+ });
1235
+ writeAtomic(previousFile, toYaml(validate(sessionSchema, {
1236
+ ...previous,
1237
+ status: "handed-off"
1238
+ }, previousFile)));
1239
+ return carried;
1240
+ }
1165
1241
  /**
1166
1242
  * Start a session. Taking over from another session copies its unfinished actions into this one and marks
1167
1243
  * the other session handed-off, so the queue continues where it stopped.
@@ -1173,19 +1249,7 @@ function startSession(root, input) {
1173
1249
  const file = sessionFile(root, id);
1174
1250
  if (existsSync(file)) throw new WriteError(`session ${id} already exists`);
1175
1251
  if (input.delegatedFrom) requireSession(root, input.delegatedFrom);
1176
- let carried = [];
1177
- if (input.handoffFrom) {
1178
- const previousFile = requireSession(root, input.handoffFrom);
1179
- const previous = readYaml(previousFile, sessionSchema).data;
1180
- carried = previous.actions.filter((a) => a.status === "todo" || a.status === "doing").map((a, i) => ({
1181
- ...a,
1182
- id: `a${String(i + 1).padStart(3, "0")}`
1183
- }));
1184
- writeAtomic(previousFile, toYaml(validate(sessionSchema, {
1185
- ...previous,
1186
- status: "handed-off"
1187
- }, previousFile)));
1188
- }
1252
+ const carried = input.handoffFrom ? takeUnfinished(root, input.handoffFrom, []) : [];
1189
1253
  const session = validate(sessionSchema, {
1190
1254
  id,
1191
1255
  agent: input.agent,
@@ -1223,6 +1287,32 @@ function ensureSession(root, input) {
1223
1287
  created: false
1224
1288
  };
1225
1289
  }
1290
+ /**
1291
+ * Take over another session's unfinished actions into a session that already exists, such as one a host hook
1292
+ * created when the conversation opened. The receiving session must have nothing unfinished of its own, so the
1293
+ * carried queue is not mixed into work already under way; its finished actions stay and the carried ones are
1294
+ * numbered after them.
1295
+ */
1296
+ function handoffSession(root, id, from) {
1297
+ if (id === from) throw new WriteError(`session ${id} cannot take over from itself`);
1298
+ return withLock(root, () => {
1299
+ const file = requireSession(root, id);
1300
+ const session = readYaml(file, sessionSchema).data;
1301
+ if (session.handoffFrom && session.handoffFrom !== from) throw new WriteError(`session ${id} already took over from ${session.handoffFrom}`);
1302
+ const open = session.actions.filter(isOpen$1);
1303
+ if (open.length > 0) throw new WriteError(`session ${id} has unfinished actions (${open.map((a) => a.id).join(", ")}); finish or drop them before taking over from ${from}`);
1304
+ if (!readYaml(requireSession(root, from), sessionSchema).data.actions.some(isOpen$1)) throw new WriteError(`session ${from} has no unfinished actions to take over`);
1305
+ const carried = takeUnfinished(root, from, session.actions.map((a) => a.id));
1306
+ const next = validate(sessionSchema, {
1307
+ ...session,
1308
+ status: "active",
1309
+ handoffFrom: from,
1310
+ actions: [...session.actions, ...carried]
1311
+ }, file);
1312
+ writeAtomic(file, toYaml(next));
1313
+ return next;
1314
+ });
1315
+ }
1226
1316
  /** Where a session's transcript is on disk: a platform records an absolute path, a hub may record one relative to its root. */
1227
1317
  function transcriptPath(root, transcript) {
1228
1318
  return isAbsolute(transcript) ? transcript : join(root, transcript);
@@ -1327,6 +1417,106 @@ function updateAction(root, ref, patch) {
1327
1417
  return updated;
1328
1418
  }
1329
1419
  //#endregion
1420
+ //#region ../core/dist/briefing.js
1421
+ function describe(root, ref) {
1422
+ const parsed = parseRef(ref);
1423
+ if (parsed?.kind !== "checklistItem") return null;
1424
+ try {
1425
+ const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1426
+ const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
1427
+ const item = task.checklist.find((c) => c.id === parsed.item);
1428
+ if (!item) return null;
1429
+ return {
1430
+ ref,
1431
+ text: item.text,
1432
+ checked: item.done,
1433
+ where: `${mainline.id} ${mainline.title} › ${task.id} ${task.title}`
1434
+ };
1435
+ } catch {
1436
+ return null;
1437
+ }
1438
+ }
1439
+ /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
1440
+ function queueWarnings(root, session, cli) {
1441
+ const warnings = [];
1442
+ const doing = session.actions.filter((a) => a.status === "doing");
1443
+ if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
1444
+ for (const action of doing) {
1445
+ const served = action.serves.map((ref) => describe(root, ref));
1446
+ if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c> 改正。`);
1447
+ else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選。做完了就執行 ${cli} action set ${action.id} --status done。`);
1448
+ }
1449
+ const next = session.actions.find((a) => a.status === "todo");
1450
+ if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
1451
+ return warnings;
1452
+ }
1453
+ const current = (session) => session.actions.find((a) => a.status === "doing");
1454
+ const isOpen = (a) => a.status === "todo" || a.status === "doing";
1455
+ /** Mainlines a session could be pushing forward. */
1456
+ const openMainlines = (root) => loadHub(root).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
1457
+ /** How recently a session's transcript must have been written for the session to count as possibly still running. */
1458
+ const RUNNING_WINDOW_MS = 9e5;
1459
+ /** Whether the session's transcript was written within the running window; false when no transcript is recorded or readable. */
1460
+ function mayBeRunning(root, session) {
1461
+ if (!session.transcript) return false;
1462
+ try {
1463
+ return Date.now() - statSync(transcriptPath(root, session.transcript)).mtimeMs < RUNNING_WINDOW_MS;
1464
+ } catch {
1465
+ return false;
1466
+ }
1467
+ }
1468
+ /**
1469
+ * Lines naming the other top-level sessions that still hold unfinished actions, with the command that takes them
1470
+ * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
1471
+ */
1472
+ function handoffLines(root, session, cli) {
1473
+ const others = loadSessions(root).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isOpen));
1474
+ if (others.length === 0) return [];
1475
+ return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(root, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isOpen).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
1476
+ }
1477
+ const servedLine = (root, action) => action.serves.map((ref) => {
1478
+ const s = describe(root, ref);
1479
+ return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目)`;
1480
+ }).join(";") || "沒有指定服務的 checklist 項目";
1481
+ /** Full briefing for the start of a session, after resuming, clearing or compacting. */
1482
+ function sessionBriefing(root, session, options) {
1483
+ const { cli } = options;
1484
+ const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
1485
+ const doing = current(session);
1486
+ const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
1487
+ if (session.actions.length === 0) {
1488
+ const active = openMainlines(root);
1489
+ lines.push("這場會話還沒有行動。");
1490
+ if (active.length > 0) {
1491
+ lines.push("進行中的主脈絡:");
1492
+ for (const m of active) lines.push(`- ${m.id} ${m.title}`);
1493
+ lines.push(`要推進其中一個時,先用 ${cli} status 與它的任務 checklist 決定要做什麼,再列出行動。`);
1494
+ }
1495
+ } else {
1496
+ lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
1497
+ if (doing) lines.push(`服務:${servedLine(root, doing)}`);
1498
+ if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
1499
+ }
1500
+ if (!session.actions.some(isOpen)) lines.push(...handoffLines(root, session, cli));
1501
+ const warnings = queueWarnings(root, session, cli);
1502
+ if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
1503
+ lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
1504
+ return lines.join("\n");
1505
+ }
1506
+ /** One or two lines for every user turn; null when the session has no actions and the hub has no open mainline. */
1507
+ function turnReminder(root, session, options) {
1508
+ if (session.actions.length === 0) return openMainlines(root).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
1509
+ const doing = current(session);
1510
+ const open = session.actions.filter((a) => a.status === "todo" || a.status === "doing");
1511
+ const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`;
1512
+ const header = headerLine(session);
1513
+ return [
1514
+ head,
1515
+ ...header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [],
1516
+ ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
1517
+ ].join("\n");
1518
+ }
1519
+ //#endregion
1330
1520
  //#region ../core/dist/write.js
1331
1521
  function checkTaskLinks(root, mainline, task) {
1332
1522
  if (task.repo && !(task.repo in readConfig(root).repos)) throw new WriteError(`repo "${task.repo}" is not listed in .mainflow/config.yaml; add it under repos first`);
@@ -1455,6 +1645,17 @@ function setCriterion(root, refInput, done, evidence, expectedVersion) {
1455
1645
  setListItem(criterion, done, evidence);
1456
1646
  }, expectedVersion);
1457
1647
  }
1648
+ /** Rewrite a criterion's text or doneWhen, leaving done and evidence as they are. */
1649
+ function setCriterionText(root, refInput, patch, expectedVersion) {
1650
+ const ref = requireRef(refInput, "criterion");
1651
+ if (patch.text === void 0 && patch.doneWhen === void 0) throw new WriteError(`${refInput} needs --text or --done-when, e.g. --text "CLI can create mainlines"`);
1652
+ return mutateFrontMatter(root, mainlineFile(root, ref.mainline), mainlineSchema, (m) => {
1653
+ const criterion = m.criteria.find((c) => c.id === ref.criterion);
1654
+ if (!criterion) throw new WriteError(`criterion ${refInput} not found`);
1655
+ if (patch.text !== void 0) criterion.text = patch.text;
1656
+ if (patch.doneWhen !== void 0) criterion.doneWhen = patch.doneWhen;
1657
+ }, expectedVersion);
1658
+ }
1458
1659
  /** Check or uncheck a criterion or checklist item; checking clears a drop. */
1459
1660
  function setListItem(item, done, evidence) {
1460
1661
  item.done = done;
@@ -1605,6 +1806,44 @@ function supersedeDecision(root, refInput, by, expectedVersion) {
1605
1806
  }, expectedVersion);
1606
1807
  }
1607
1808
  //#endregion
1809
+ //#region ../core/dist/archive.js
1810
+ /** The entities that carry `archivedAt`; tasks and decisions follow their mainline instead. */
1811
+ const ARCHIVE_KINDS = [
1812
+ "mainline",
1813
+ "issue",
1814
+ "finding",
1815
+ "session"
1816
+ ];
1817
+ const ARCHIVERS = {
1818
+ mainline: setArchived,
1819
+ issue: setIssueArchived,
1820
+ finding: setFindingArchived,
1821
+ session: setSessionArchived
1822
+ };
1823
+ /**
1824
+ * Archive or unarchive several entities of one kind. Each file is written on its own: one that fails, such
1825
+ * as an ID that no longer exists, is reported and the others still change, because a selection in the
1826
+ * interface can go stale while it is open.
1827
+ */
1828
+ function setArchivedMany(root, kind, ids, archived) {
1829
+ const archiver = ARCHIVERS[kind];
1830
+ if (!archiver) throw new WriteError(`kind must be one of: ${ARCHIVE_KINDS.join(", ")}`);
1831
+ const result = {
1832
+ done: [],
1833
+ failed: []
1834
+ };
1835
+ for (const id of new Set(ids)) try {
1836
+ archiver(root, id, archived);
1837
+ result.done.push(id);
1838
+ } catch (error) {
1839
+ result.failed.push({
1840
+ id,
1841
+ error: error instanceof Error ? error.message : String(error)
1842
+ });
1843
+ }
1844
+ return result;
1845
+ }
1846
+ //#endregion
1608
1847
  //#region ../core/dist/migrate.js
1609
1848
  const drop = (o, ...keys) => keys.forEach((k) => delete o[k]);
1610
1849
  const dropNull = (o, ...keys) => keys.forEach((k) => o[k] === null && delete o[k]);
@@ -1888,4 +2127,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
1888
2127
  return true;
1889
2128
  }
1890
2129
  //#endregion
1891
- export { linksInto as $, setSessionInfo as A, headerLine as B, supersedeDecision as C, loadSessions as D, ensureSession as E, updateAction as F, createIssue as G, appendOps as H, sessionBriefing as I, setFindingArchived as J, planIssue as K, turnReminder as L, settleSession as M, startSession as N, readSession as O, transcriptPath as P, loadHub as Q, decorateReply as R, setMainlineTitle as S, addAction as T, closeIssue as U, mayDecorate as V, createFinding as W, setIssueArchived as X, setFindingStatus as Y, HubError as Z, initHub as _, openInApp as a, DATA_DIR as at, setCriterion as b, syncHost as c, worktreesOf as ct, addHub as d, refIndex as et, addRepo as f, dropChecklistItem as g, createTask as h, hostName as i, WriteError as it, setSessionStatus as j, setSessionArchived as k, migrateHub as l, createMainline as m, DEFAULT_UI_PORT as n, TASK_FILE as nt, removeHostLink as o, currentBranch as ot, createDecision as p, reopenIssue as q, caddySites as r, ConflictError as rt, siteBlock as s, findHubRoot as st, APP_BUNDLE_ID as t, MAINLINE_FILE as tt, acceptDecision as u, setArchived as v, updateTask as w, setMainlineStatus as x, setChecklistItem as y, endsWithMarker as z };
2130
+ export { setFindingStatus as $, addAction as A, transcriptPath as B, setCriterionText as C, updateTask as D, supersedeDecision as E, setSessionArchived as F, mayDecorate as G, decorateReply as H, setSessionInfo as I, createFinding as J, appendOps as K, setSessionStatus as L, handoffSession as M, loadSessions as N, sessionBriefing as O, readSession as P, setFindingArchived as Q, settleSession as R, setCriterion as S, setMainlineTitle as T, endsWithMarker as U, updateAction as V, headerLine as W, planIssue as X, createIssue as Y, reopenIssue as Z, createTask as _, openInApp as a, linksInto as at, setArchived as b, syncHost as c, TASK_FILE as ct, setArchivedMany as d, DATA_DIR as dt, setIssueArchived as et, acceptDecision as f, currentBranch as ft, createMainline as g, createDecision as h, hostName as i, mainlineEdges as it, ensureSession as j, turnReminder as k, migrateHub as l, ConflictError as lt, addRepo as m, worktreesOf as mt, DEFAULT_UI_PORT as n, loadHub as nt, removeHostLink as o, refIndex as ot, addHub as p, findHubRoot as pt, closeIssue as q, caddySites as r, dependencyGraph as rt, siteBlock as s, MAINLINE_FILE as st, APP_BUNDLE_ID as t, HubError as tt, ARCHIVE_KINDS as u, WriteError as ut, dropChecklistItem as v, setMainlineStatus as w, setChecklistItem as x, initHub as y, startSession as z };
package/dist/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-C3MyDGB_.js";
1
+ import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-daQGe7no.js";
2
2
  export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeHostLink, siteBlock, syncHost };
@@ -1,4 +1,4 @@
1
- import { at as DATA_DIR, c as syncHost, i as hostName, it as WriteError, n as DEFAULT_UI_PORT, o as removeHostLink } from "./host-C3MyDGB_.js";
1
+ import { c as syncHost, dt as DATA_DIR, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, ut as WriteError } from "./host-daQGe7no.js";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
@@ -176,7 +176,7 @@ async function launchUi(root, hub, configured, options) {
176
176
  }
177
177
  let server;
178
178
  if (!reuse) {
179
- const { serveUi } = await import("./ui-BTLe6I--.js");
179
+ const { serveUi } = await import("./ui-BoBBdpZH.js");
180
180
  server = await serveUi(root, choice.port);
181
181
  writeUiRecord(root, {
182
182
  port: choice.port,
package/dist/launch.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-T0sXbTgT.js";
1
+ import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-JK8l8BYu.js";
2
2
  export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord, startUiInBackground, watchHubGone };
@@ -1,6 +1,6 @@
1
- import { $ as linksInto, D as loadSessions, J as setFindingArchived, O as readSession, P as transcriptPath, Q as loadHub, S as setMainlineTitle, X as setIssueArchived, a as openInApp, at as DATA_DIR, b as setCriterion, et as refIndex, it as WriteError, k as setSessionArchived, rt as ConflictError, v as setArchived, w as updateTask, x as setMainlineStatus, y as setChecklistItem } from "./host-C3MyDGB_.js";
1
+ import { B as transcriptPath, D as updateTask, F as setSessionArchived, N as loadSessions, P as readSession, Q as setFindingArchived, S as setCriterion, T as setMainlineTitle, a as openInApp, at as linksInto, b as setArchived, d as setArchivedMany, dt as DATA_DIR, et as setIssueArchived, it as mainlineEdges, lt as ConflictError, nt as loadHub, ot as refIndex, rt as dependencyGraph, u as ARCHIVE_KINDS, ut as WriteError, w as setMainlineStatus, x as setChecklistItem } from "./host-daQGe7no.js";
2
2
  import { t as listProgress } from "./progress-1BfPg2zN.js";
3
- import { l as CLI_VERSION } from "./launch-T0sXbTgT.js";
3
+ import { l as CLI_VERSION } from "./launch-JK8l8BYu.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watch } from "node:fs";
6
6
  import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
@@ -226,7 +226,9 @@ function relationsView(hub) {
226
226
  const own = refIndex(hub);
227
227
  const indexes = new Map(hub.remotes.flatMap((r) => r.hub ? [[r.name, refIndex(r.hub)]] : []));
228
228
  const targets = {};
229
- for (const ref of prefixedRefs(hub)) {
229
+ const graph = dependencyGraph(hub);
230
+ const depRefs = graph.edges.flatMap((e) => [e.from, e.to]);
231
+ for (const ref of /* @__PURE__ */ new Set([...prefixedRefs(hub), ...depRefs.filter((r) => r.includes(":"))])) {
230
232
  const colon = ref.indexOf(":");
231
233
  const name = ref.slice(0, colon);
232
234
  const found = name === hub.config.hub ? own.get(ref.slice(colon + 1)) : indexes.get(name)?.get(ref.slice(colon + 1));
@@ -255,7 +257,10 @@ function relationsView(hub) {
255
257
  ...r.unavailable ? { reason: r.unavailable } : {}
256
258
  })),
257
259
  targets,
258
- backlinks
260
+ backlinks,
261
+ deps: graph.edges,
262
+ mainlineDeps: mainlineEdges(graph),
263
+ cycles: graph.cycles
259
264
  };
260
265
  }
261
266
  const pick = (t) => t ? {
@@ -369,6 +374,11 @@ function createApp(root) {
369
374
  problems
370
375
  });
371
376
  });
377
+ app.post("/api/archive", async (c) => {
378
+ const { kind, ids, archived } = await c.req.json();
379
+ if (!ARCHIVE_KINDS.includes(kind) || !Array.isArray(ids)) return c.json({ error: `kind must be one of ${ARCHIVE_KINDS.join(", ")} and ids a list` }, 400);
380
+ return c.json(setArchivedMany(root, kind, ids, archived));
381
+ });
372
382
  app.post("/api/sessions/:id/archive", async (c) => {
373
383
  const { archived } = await c.req.json();
374
384
  return c.json(setSessionArchived(root, c.req.param("id"), archived));