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.
@@ -1,3268 +0,0 @@
1
- import { $ as grantForm, A as WORK_STATUS, B as parseRef, C as SESSION_STATUS, D as TASK_REQUIRED_SECTIONS, E as SURVEY_SECTION, F as issueSchema, G as taskSchema, H as refExample, I as mainlineSchema, K as waitingForAction, L as opsEntrySchema, M as decisionSchema, N as findingSchema, O as TASK_SECTIONS, P as formatOpsEntry, Q as grantCovers, R as parseActionRef, S as SESSION_ID, T as SURVEY_LINES, U as refMainline, V as parseUntil, W as sessionSchema, Z as describeRequest, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, et as grantRepo, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as configSchema, l as FINDING_STATUS, n as CLOSED_STATUS, q as waitingForRef, r as DECIDERS, s as DEFAULT_CATEGORIES, tt as parseGrantTarget, w as SLUG, x as SERVES_REF, y as REF_EXAMPLES, z as parseOpsLine } from "./dist-BbamrIPW.js";
2
- import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
3
- import { basename, dirname, isAbsolute, join, matchesGlob, parse, posix, relative, resolve, sep } from "node:path";
4
- import { execFileSync, spawn, spawnSync } from "node:child_process";
5
- import { createHash, randomBytes } from "node:crypto";
6
- import { parse as parse$1, stringify } from "yaml";
7
- //#region ../core/dist/paths.js
8
- const DATA_DIR = ".mainflow";
9
- const ENTRY_DIR = /^([mtdif]\d{3})-(.+)$/;
10
- const isHub = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
11
- const git = (cwd, ...args) => {
12
- const result = spawnSync("git", [
13
- "-C",
14
- cwd,
15
- ...args
16
- ], { encoding: "utf8" });
17
- return result.status === 0 ? result.stdout.trim() : "";
18
- };
19
- /** The main working tree and the current working tree of the repository containing `cwd`, or null outside git. */
20
- function worktreesOf(cwd) {
21
- const common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
22
- const current = git(cwd, "rev-parse", "--show-toplevel");
23
- if (!common.endsWith(`${sep}.git`) || !current) return null;
24
- return {
25
- main: dirname(common),
26
- current
27
- };
28
- }
29
- /** The branch checked out at `cwd`, or null outside git or on a detached HEAD. */
30
- function currentBranch(cwd) {
31
- return git(cwd, "branch", "--show-current") || null;
32
- }
33
- /**
34
- * The hub for `cwd`: the first directory upwards holding `.mainflow/config.yaml`, except that a linked
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.
40
- */
41
- function findHubRoot(cwd) {
42
- let found = null;
43
- for (let dir = cwd;; dir = dirname(dir)) {
44
- if (isHub(dir)) {
45
- found = dir;
46
- break;
47
- }
48
- if (dirname(dir) === dir) break;
49
- }
50
- const trees = worktreesOf(cwd);
51
- if (!trees || trees.main === trees.current) return found;
52
- if (found && found !== trees.current) return found;
53
- return isHub(trees.main) ? trees.main : found;
54
- }
55
- function subdirs(dir) {
56
- if (!existsSync(dir)) return [];
57
- return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(dir, entry.name)).sort();
58
- }
59
- /** Split a Markdown file into its YAML front matter and the body after it. */
60
- function splitFrontMatter(source) {
61
- const match = /^---\n([\s\S]*?)\n---\n?/.exec(source);
62
- if (!match) return {
63
- data: null,
64
- body: source
65
- };
66
- return {
67
- data: parse$1(match[1] ?? ""),
68
- body: source.slice(match[0].length)
69
- };
70
- }
71
- /** Find the entry directory whose name starts with `{id}-` inside `parent`. */
72
- function findEntryDir(parent, id) {
73
- return subdirs(parent).find((dir) => ENTRY_DIR.exec(dir.split("/").at(-1) ?? "")?.[1] === id) ?? null;
74
- }
75
- /** The next free id with the given prefix, e.g. `m003` after `m001` and `m002`. */
76
- function nextId(prefix, existing) {
77
- const max = existing.filter((id) => id.startsWith(prefix)).map((id) => Number(id.slice(prefix.length))).reduce((a, b) => Math.max(a, b), 0);
78
- if (max >= 999) throw new Error(`no ${prefix} ids left after ${prefix}999`);
79
- return `${prefix}${String(max + 1).padStart(3, "0")}`;
80
- }
81
- const contentHash = (text) => createHash("sha1").update(text).digest("hex");
82
- /** Read a UTF-8 file, or return null when it does not exist. */
83
- function readText(file) {
84
- return existsSync(file) ? readFileSync(file, "utf8") : null;
85
- }
86
- /** List every file below `dir`, sorted, skipping dot files. */
87
- function listFiles(dir) {
88
- if (!existsSync(dir)) return [];
89
- return readdirSync(dir, {
90
- recursive: true,
91
- withFileTypes: true
92
- }).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => join(entry.parentPath, entry.name)).sort();
93
- }
94
- //#endregion
95
- //#region ../core/dist/io.js
96
- var WriteError = class extends Error {};
97
- /** The file changed after the caller read it; the caller must reload and retry. */
98
- var ConflictError = class extends WriteError {};
99
- const LOCK_WAIT_MS = 5e3;
100
- const LOCK_STALE_MS = 3e4;
101
- const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
102
- /** Run `fn` while holding the hub-wide write lock in `.mainflow/local/`. */
103
- function withLock(root, fn) {
104
- const dir = join(root, DATA_DIR, "local");
105
- mkdirSync(dir, { recursive: true });
106
- const lock = join(dir, "write.lock");
107
- const deadline = Date.now() + LOCK_WAIT_MS;
108
- for (;;) try {
109
- const fd = openSync(lock, "wx");
110
- writeSync(fd, String(process.pid));
111
- closeSync(fd);
112
- break;
113
- } catch (error) {
114
- if (error.code !== "EEXIST") throw error;
115
- if (Date.now() - statSync(lock, { throwIfNoEntry: false }).mtimeMs > LOCK_STALE_MS) {
116
- rmSync(lock, { force: true });
117
- continue;
118
- }
119
- if (Date.now() > deadline) throw new WriteError(`hub is locked by another writer (${lock}); retry in a moment`);
120
- sleep(50);
121
- }
122
- try {
123
- return fn();
124
- } finally {
125
- rmSync(lock, { force: true });
126
- }
127
- }
128
- const today = () => (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
129
- /** Local time to the minute, the form sessions, actions and decisions use: 2026-09-24T15:30. */
130
- const nowMinute = () => {
131
- const d = /* @__PURE__ */ new Date();
132
- return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 5)}`;
133
- };
134
- function validate(schema, data, file) {
135
- const result = schema.safeParse(data);
136
- if (result.success) return result.data;
137
- throw new WriteError(`${file} would become invalid: ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
138
- }
139
- function writeAtomic(file, text) {
140
- const temp = `${file}.${process.pid}.tmp`;
141
- writeFileSync(temp, text);
142
- renameSync(temp, file);
143
- }
144
- const toYaml = (data) => stringify(data, { lineWidth: 0 });
145
- function readYaml(file, schema) {
146
- const text = readFileSync(file, "utf8");
147
- return {
148
- data: validate(schema, parse$1(text), file),
149
- version: contentHash(text)
150
- };
151
- }
152
- /** Read, mutate, validate and write back a YAML document under the hub lock. */
153
- function mutateYaml(root, file, schema, mutate, expectedVersion) {
154
- return withLock(root, () => {
155
- const text = readFileSync(file, "utf8");
156
- if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
157
- const data = validate(schema, parse$1(text), file);
158
- mutate(data);
159
- const next = validate(schema, data, file);
160
- writeAtomic(file, toYaml(next));
161
- return next;
162
- });
163
- }
164
- const frontMatterText = (data, body) => `---\n${toYaml(data)}---\n${body}`;
165
- /** Read and validate the front matter of a Markdown file, with its body and content hash. */
166
- function readFrontMatter(file, schema) {
167
- const text = readFileSync(file, "utf8");
168
- const { data, body } = splitFrontMatter(text);
169
- return {
170
- data: validate(schema, data, file),
171
- body,
172
- version: contentHash(text)
173
- };
174
- }
175
- /**
176
- * Read, mutate, validate and write back the front matter of a Markdown file. The body is kept unless
177
- * `mutate` returns a new one.
178
- */
179
- function mutateFrontMatter(root, file, schema, mutate, expectedVersion) {
180
- return withLock(root, () => {
181
- const text = readFileSync(file, "utf8");
182
- if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
183
- const { data, body } = splitFrontMatter(text);
184
- const current = validate(schema, data, file);
185
- const nextBody = mutate(current, body) ?? body;
186
- const next = validate(schema, current, file);
187
- writeAtomic(file, frontMatterText(next, nextBody));
188
- return next;
189
- });
190
- }
191
- //#endregion
192
- //#region ../core/dist/resolve.js
193
- const mainlinesDir = (root) => join(root, DATA_DIR, "mainlines");
194
- /** The document inside each mainline and task directory: front matter for attributes, body for prose. */
195
- const MAINLINE_FILE = "mainline.md";
196
- const TASK_FILE = "task.md";
197
- function requireRef(input, kind) {
198
- const ref = parseRef(input);
199
- if (ref?.kind !== kind) throw new WriteError(`expected a ${kind} reference like ${refExample(kind)}, got "${input}"`);
200
- return ref;
201
- }
202
- function mainlineDir(root, id) {
203
- const dir = findEntryDir(mainlinesDir(root), id);
204
- if (!dir) throw new WriteError(`mainline ${id} not found; run "mainflow status --all" to list mainlines`);
205
- return dir;
206
- }
207
- function taskDir(root, mainline, task) {
208
- const dir = findEntryDir(join(mainlineDir(root, mainline), "tasks"), task);
209
- if (!dir) throw new WriteError(`task ${mainline}/${task} not found`);
210
- return dir;
211
- }
212
- const mainlineFile = (root, id) => join(mainlineDir(root, id), MAINLINE_FILE);
213
- const taskFile = (root, mainline, task) => join(taskDir(root, mainline, task), TASK_FILE);
214
- function decisionFile(root, mainline, decision) {
215
- const dir = join(mainlineDir(root, mainline), "decisions");
216
- const name = existsSync(dir) ? readdirSync(dir).find((n) => n.startsWith(`${decision}-`) && n.endsWith(".md")) : void 0;
217
- if (!name) throw new WriteError(`decision ${mainline}/${decision} not found`);
218
- return join(dir, name);
219
- }
220
- function requireSlug(slug) {
221
- if (!SLUG.test(slug)) throw new WriteError(`slug "${slug}" must be kebab-case, e.g. mainflow-rebuild`);
222
- }
223
- function requireOneOf(value, allowed, what) {
224
- if (!allowed.includes(value)) throw new WriteError(`${what} "${value}" must be one of: ${allowed.join(", ")}`);
225
- return value;
226
- }
227
- function readConfig(root) {
228
- return readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
229
- }
230
- /** Entry ids (m001, t002, d003) found among the names in `dir`. */
231
- const idsIn = (dir) => (existsSync(dir) ? readdirSync(dir) : []).map((n) => ENTRY_DIR.exec(n)?.[1]).filter((id) => !!id);
232
- //#endregion
233
- //#region ../core/dist/refs.js
234
- const itemStatus = (item) => item.done ? "done" : item.dropped ? "dropped" : "open";
235
- /** Every reference a hub can resolve, keyed by the reference without a hub prefix. */
236
- function refIndex(hub) {
237
- const index = /* @__PURE__ */ new Map();
238
- for (const m of hub.mainlines) {
239
- index.set(m.id, {
240
- kind: "mainline",
241
- title: m.title,
242
- status: m.status,
243
- mainline: m.id
244
- });
245
- for (const r of m.criteria) index.set(`${m.id}#${r.id}`, {
246
- kind: "criterion",
247
- title: r.text,
248
- status: itemStatus(r),
249
- mainline: m.id
250
- });
251
- for (const d of m.decisions) index.set(`${m.id}/${d.id}`, {
252
- kind: "decision",
253
- title: d.title,
254
- status: d.status,
255
- mainline: m.id
256
- });
257
- for (const t of m.tasks) {
258
- index.set(`${m.id}/${t.id}`, {
259
- kind: "task",
260
- title: t.title,
261
- status: t.status,
262
- mainline: m.id
263
- });
264
- for (const c of t.checklist) index.set(`${m.id}/${t.id}#${c.id}`, {
265
- kind: "checklistItem",
266
- title: c.text,
267
- status: itemStatus(c),
268
- mainline: m.id
269
- });
270
- }
271
- }
272
- for (const i of hub.issues) index.set(i.id, {
273
- kind: "issue",
274
- title: i.title,
275
- status: i.status
276
- });
277
- for (const f of hub.findings) index.set(f.id, {
278
- kind: "finding",
279
- title: f.title,
280
- status: f.status
281
- });
282
- return index;
283
- }
284
- const isHubRoot = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
285
- /** Absolute root of a registered hub; `path` is relative to the registering hub's root. */
286
- const remoteRoot = (root, path) => resolve(root, path);
287
- /** Documents in `hub` whose links point at `hubName`'s documents; `hubName` undefined means links without a prefix. */
288
- function linksInto(hub, hubName) {
289
- const out = [];
290
- const add = (from, links) => {
291
- for (const l of links ?? []) {
292
- const colon = l.to.indexOf(":");
293
- const prefix = colon < 0 ? void 0 : l.to.slice(0, colon);
294
- const local = colon < 0 ? l.to : l.to.slice(colon + 1);
295
- if (prefix === hubName || prefix === hub.config.hub && hubName === void 0) out.push({
296
- to: local,
297
- link: {
298
- from,
299
- rel: l.rel,
300
- ...l.note ? { note: l.note } : {}
301
- }
302
- });
303
- }
304
- };
305
- for (const m of hub.mainlines) {
306
- add(m.id, m.links);
307
- for (const d of m.decisions) add(`${m.id}/${d.id}`, d.links);
308
- for (const t of m.tasks) add(`${m.id}/${t.id}`, t.links);
309
- }
310
- for (const i of hub.issues) add(i.id, i.links);
311
- for (const f of hub.findings) add(f.id, f.links);
312
- return out;
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
422
- //#region ../core/dist/touches.js
423
- /** Mainline statuses whose tasks are compared: work planned or under way. */
424
- const COMPARED_MAINLINE = ["planning", "active"];
425
- /** Parent directories of a path, nearest first, without the path itself. */
426
- function ancestors(path) {
427
- const out = [];
428
- for (let dir = posix.dirname(path); dir !== "." && dir !== "/" && dir !== path; dir = posix.dirname(dir)) out.push(dir);
429
- return out;
430
- }
431
- const normal = (path) => posix.normalize(path.trim()).replace(/\/+$/, "");
432
- /**
433
- * Whether a `touches` entry covers a path: the same path, a path under it, or one the glob matches directly or
434
- * through a parent directory. A directory or a glob that matches a directory stands for everything under it.
435
- */
436
- function covers(pattern, path) {
437
- return [path, ...ancestors(path)].some((p) => p === pattern || matchesGlob(p, pattern));
438
- }
439
- /**
440
- * Whether two `touches` entries overlap: equal paths, or either one covers the other as a path. Two globs meet only
441
- * when one matches the other's text, so `a/*.ts` and `?/b.ts` are not seen to share `a/b.ts`.
442
- */
443
- function touchesOverlap(a, b) {
444
- const x = normal(a);
445
- const y = normal(b);
446
- return x === y || covers(x, y) || covers(y, x);
447
- }
448
- /** Whether a dependency edge leaves `from` at the mainline or the task level and lands on `to` at either level. */
449
- function dependsOn(edges, from, to) {
450
- const fromRefs = [from.mainline, `${from.mainline}/${from.task.id}`];
451
- const lands = (ref) => ref === to.mainline || ref.startsWith(`${to.mainline}#`) || ref === `${to.mainline}/${to.task.id}` || ref.startsWith(`${to.mainline}/${to.task.id}#`);
452
- return edges.some((e) => fromRefs.includes(e.from) && lands(e.to));
453
- }
454
- /** Unfinished tasks of planning or active mainlines that list `touches`, each with the mainline it sits in. */
455
- function openTouching(mainlines) {
456
- return mainlines.filter((m) => COMPARED_MAINLINE.includes(m.status)).flatMap((m) => m.tasks.filter((t) => !CLOSED_STATUS.task.includes(t.status) && t.touches?.length).map((task) => ({
457
- mainline: m.id,
458
- task
459
- })));
460
- }
461
- /** Unfinished tasks that list `touches` but no `repo`: their paths have no root, so touchOverlaps skips them. */
462
- function touchesWithoutRepo(mainlines) {
463
- return openTouching(mainlines).filter((p) => !p.task.repo).map((p) => ({
464
- task: `${p.mainline}/${p.task.id}`,
465
- file: p.task.file
466
- }));
467
- }
468
- /**
469
- * Open tasks of different open mainlines whose `touches` overlap in the same repo and which have no depends-on
470
- * between them in either direction, written on the mainline or on the task. Tasks without a repo are not compared,
471
- * since their paths have no root to share; touchesWithoutRepo names them.
472
- */
473
- function touchOverlaps(mainlines, edges) {
474
- const open = openTouching(mainlines).filter((p) => p.task.repo);
475
- const overlaps = [];
476
- open.forEach((a, i) => {
477
- for (const b of open.slice(i + 1)) {
478
- if (a.mainline === b.mainline || a.task.repo !== b.task.repo) continue;
479
- const paths = a.task.touches.flatMap((x) => b.task.touches.filter((y) => touchesOverlap(x, y)).map((y) => [x, y]));
480
- if (paths.length === 0 || dependsOn(edges, a, b) || dependsOn(edges, b, a)) continue;
481
- overlaps.push({
482
- tasks: [`${a.mainline}/${a.task.id}`, `${b.mainline}/${b.task.id}`],
483
- repo: a.task.repo,
484
- paths,
485
- file: a.task.file
486
- });
487
- }
488
- });
489
- return overlaps;
490
- }
491
- //#endregion
492
- //#region ../core/dist/templates.js
493
- const body = (headings) => ["", ...headings.flatMap((h) => [`## ${h}`, ""])].join("\n");
494
- const MAINLINE_BODY = body(MAINLINE_SECTIONS);
495
- const TASK_BODY = body(TASK_SECTIONS);
496
- /** 建議 carries the recommendation while a decision is proposed; 決定 is filled once it is accepted. */
497
- const DECISION_BODY = body(DECISION_SECTIONS);
498
- const ISSUE_BODY = body(ISSUE_SECTIONS);
499
- const FINDING_BODY = body(FINDING_SECTIONS);
500
- /** The text under `## {heading}` up to the next H2, trimmed; null when the heading is missing. */
501
- function sectionText(markdown, heading) {
502
- const lines = markdown.split("\n");
503
- const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
504
- if (start === -1) return null;
505
- const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
506
- return lines.slice(start + 1, end === -1 ? void 0 : end).join("\n").trim();
507
- }
508
- const SKIP_WORDS = [...new Set(SURVEY_LINES.flatMap((l) => l.skip ? [l.skip] : []))];
509
- /** The survey lines as a writer fills them in, one `- {label}:{what it holds}` per source. */
510
- const SURVEY_TEMPLATE = SURVEY_LINES.map((l) => `- ${l.label}:{${l.holds}}${l.skip ? `,或「${l.skip}:{理由}」` : ""}`).join("\n");
511
- /**
512
- * What is missing from a mainline's 現況查核, one message per source; empty when every source has a line.
513
- * A line is `- {label}:{text}`. The text must not be blank, a source without `skip` must really be checked,
514
- * and a checked line must contain what the source `requires`.
515
- */
516
- function surveyGaps(mainlineBody) {
517
- const text = sectionText(mainlineBody, SURVEY_SECTION);
518
- if (text === null) return [`"## ${SURVEY_SECTION}" is missing`];
519
- const lines = text.split("\n").map((l) => l.trim());
520
- return SURVEY_LINES.flatMap((source) => {
521
- const prefix = `- ${source.label}:`;
522
- const content = lines.find((l) => l.startsWith(prefix))?.slice(prefix.length).trim();
523
- if (!content) return [`"- ${source.label}:" needs what was found`];
524
- const skipped = SKIP_WORDS.find((w) => content.startsWith(`${w}:`));
525
- if (skipped) {
526
- if (skipped !== source.skip) return [`"- ${source.label}:" cannot be ${skipped}; this source is always checked`];
527
- if (!content.slice(skipped.length + 1).trim()) return [`"- ${source.label}:${skipped}:" needs a reason`];
528
- return [];
529
- }
530
- if (source.requires && !content.includes(source.requires)) return [`"- ${source.label}:" needs ${source.requires}{…} for where it ran`];
531
- return [];
532
- });
533
- }
534
- /** Put `text` under `## {heading}`, replacing what was there; appends the section when it is missing. */
535
- function withSection(markdown, heading, text) {
536
- const lines = markdown.split("\n");
537
- const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
538
- if (start === -1) return `${markdown.trimEnd()}\n\n## ${heading}\n\n${text}\n`;
539
- const found = lines.findIndex((l, i) => i > start && l.startsWith("## "));
540
- const end = found === -1 ? lines.length : found;
541
- const rest = lines.slice(end);
542
- return [
543
- ...lines.slice(0, start + 1),
544
- "",
545
- text,
546
- ...rest.length ? ["", ...rest] : [""]
547
- ].join("\n");
548
- }
549
- //#endregion
550
- //#region ../core/dist/load.js
551
- function loadHub(root, options = {}) {
552
- const problems = [];
553
- const warnings = [];
554
- /** References checked once everything they may point at is loaded */
555
- const pending = [];
556
- /** Documents whose file is there but failed to load; references into them are not reported twice */
557
- const unreadable = /* @__PURE__ */ new Set();
558
- const idOf = (path) => ENTRY_DIR.exec(path.split("/").at(-1)?.replace(/\.md$/, "") ?? "")?.[1];
559
- const dataDir = join(root, DATA_DIR);
560
- const rel = (path) => relative(root, path);
561
- const read = (file, schema, raw) => {
562
- let data = raw;
563
- if (data === void 0) try {
564
- data = parse$1(readText(file) ?? "");
565
- } catch (error) {
566
- problems.push({
567
- file: rel(file),
568
- field: "",
569
- message: `cannot parse YAML: ${error.message}`
570
- });
571
- return null;
572
- }
573
- const result = schema.safeParse(data);
574
- if (result.success) return result.data;
575
- for (const issue of result.error.issues) problems.push({
576
- file: rel(file),
577
- field: issue.path.join("."),
578
- message: issue.message
579
- });
580
- return null;
581
- };
582
- const checkDirName = (dir, id) => {
583
- const match = ENTRY_DIR.exec(dir.split("/").at(-1) ?? "");
584
- if (!match || match[1] !== id || !SLUG.test(match[2] ?? "")) problems.push({
585
- file: rel(dir),
586
- field: "",
587
- message: `directory name must be "${id}-{slug}" in kebab-case`
588
- });
589
- };
590
- const config = read(join(dataDir, "config.yaml"), configSchema);
591
- if (!config) throw new HubError("invalid config.yaml", problems);
592
- if (config.formatVersion !== 2) throw new HubError(config.formatVersion < 2 ? `data format version ${config.formatVersion} is older than this CLI's (2); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${config.formatVersion} is newer than this CLI supports (2); install the matching mainflow version in this hub`, problems);
593
- problems.push(...repoPathProblems(root, config));
594
- const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
595
- /** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
596
- const readDocument = (file, schema) => {
597
- const text = readText(file);
598
- if (text === null) {
599
- problems.push({
600
- file: rel(file),
601
- field: "",
602
- message: "file is missing"
603
- });
604
- return null;
605
- }
606
- let split;
607
- try {
608
- split = splitFrontMatter(text);
609
- } catch (error) {
610
- problems.push({
611
- file: rel(file),
612
- field: "",
613
- message: `cannot parse front matter: ${error.message}`
614
- });
615
- return null;
616
- }
617
- const doc = read(file, schema, split.data);
618
- if (!doc) return null;
619
- if (doc.category && !categories.has(doc.category)) problems.push({
620
- file: rel(file),
621
- field: "category",
622
- message: `category "${doc.category}" is not one of ${[...categories].join(", ")}; add it under categories in config.yaml or pick one of these`
623
- });
624
- const updated = new Date(statSync(file).mtimeMs).toLocaleDateString("sv-SE");
625
- return {
626
- ...doc,
627
- file: rel(file),
628
- body: split.body,
629
- version: contentHash(text),
630
- updated
631
- };
632
- };
633
- const mainlines = [];
634
- for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
635
- const mainline = readDocument(join(mainlineDir, MAINLINE_FILE), mainlineSchema);
636
- if (!mainline) {
637
- const id = idOf(mainlineDir);
638
- if (id) unreadable.add(id);
639
- continue;
640
- }
641
- checkDirName(mainlineDir, mainline.id);
642
- problems.push(...grantProblems(mainline, config));
643
- const decisions = [];
644
- const decisionsDir = join(mainlineDir, "decisions");
645
- for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
646
- const file = join(decisionsDir, name);
647
- const decision = readDocument(file, decisionSchema);
648
- if (!decision) unreadable.add(`${mainline.id}/${idOf(file)}`);
649
- if (decision) {
650
- checkDirName(file.replace(/\.md$/, ""), decision.id);
651
- decisions.push(decision);
652
- }
653
- }
654
- const tasks = [];
655
- for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
656
- const task = readDocument(join(taskDir, TASK_FILE), taskSchema);
657
- if (!task) {
658
- unreadable.add(`${mainline.id}/${idOf(taskDir)}`);
659
- continue;
660
- }
661
- checkDirName(taskDir, task.id);
662
- if (task.repo && !(task.repo in config.repos)) problems.push({
663
- file: task.file,
664
- field: "repo",
665
- message: `repo "${task.repo}" is not listed in config.yaml`
666
- });
667
- tasks.push({
668
- ...task,
669
- dir: rel(taskDir),
670
- artifacts: listFiles(join(taskDir, "artifacts")).map(rel)
671
- });
672
- }
673
- problems.push(...contentGaps(mainline, tasks));
674
- const taskIds = new Set(tasks.map((t) => t.id));
675
- const criterionIds = new Set(mainline.criteria.map((c) => c.id));
676
- const itemRefs = new Set(tasks.flatMap((t) => t.checklist.map((c) => `${mainline.id}/${t.id}#${c.id}`)));
677
- for (const task of tasks) {
678
- if (task.parent && !taskIds.has(task.parent)) problems.push({
679
- file: task.file,
680
- field: "parent",
681
- message: `parent task ${task.parent} does not exist`
682
- });
683
- task.dependsOn?.forEach((dep, i) => {
684
- if (!taskIds.has(dep)) problems.push({
685
- file: task.file,
686
- field: `dependsOn.${i}`,
687
- message: `task ${dep} does not exist`
688
- });
689
- });
690
- task.checklist.forEach((item, i) => {
691
- item.serves?.forEach((r, j) => {
692
- if (!criterionIds.has(r)) problems.push({
693
- file: task.file,
694
- field: `checklist.${i}.serves.${j}`,
695
- message: `criterion ${mainline.id}#${r} does not exist`
696
- });
697
- });
698
- });
699
- }
700
- for (const decision of decisions) decision.affects?.forEach((ref, i) => {
701
- if (!(ref.includes("#") ? itemRefs.has(ref) : taskIds.has(ref.split("/")[1] ?? "") && ref.startsWith(`${mainline.id}/`))) problems.push({
702
- file: decision.file,
703
- field: `affects.${i}`,
704
- message: `${ref} is not a task or checklist item of ${mainline.id}`
705
- });
706
- });
707
- mainlines.push({
708
- ...mainline,
709
- dir: rel(mainlineDir),
710
- artifacts: listFiles(join(mainlineDir, "artifacts")).map(rel),
711
- views: listFiles(join(mainlineDir, "views")).map(rel),
712
- decisions,
713
- tasks
714
- });
715
- }
716
- const hubDocs = (dir, schema) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : []).flatMap((name) => {
717
- const file = join(dir, name);
718
- const doc = readDocument(file, schema);
719
- if (!doc) {
720
- const id = idOf(file);
721
- if (id) unreadable.add(id);
722
- return [];
723
- }
724
- checkDirName(file.replace(/\.md$/, ""), doc.id);
725
- return [doc];
726
- });
727
- const hubIssues = hubDocs(join(dataDir, "issues"), issueSchema);
728
- const taskRefs = new Set(mainlines.flatMap((m) => m.tasks.map((t) => `${m.id}/${t.id}`)));
729
- for (const issue of hubIssues) {
730
- if (issue.plannedIn && !taskRefs.has(issue.plannedIn)) problems.push({
731
- file: issue.file,
732
- field: "plannedIn",
733
- message: `task ${issue.plannedIn} does not exist`
734
- });
735
- if (issue.status !== "closed" && !issue.archivedAt && !issue.labels?.length) warnings.push({
736
- file: issue.file,
737
- field: "labels",
738
- message: `issue ${issue.id} is ${issue.status} but has no labels; add labels so it can be found by topic`
739
- });
740
- }
741
- const findings = hubDocs(join(dataDir, "findings"), findingSchema);
742
- const ops = [];
743
- const opsPath = join(dataDir, "ops");
744
- for (const name of existsSync(opsPath) ? readdirSync(opsPath).filter((n) => n.endsWith(".log")).sort() : []) readFileSync(join(opsPath, name), "utf8").split("\n").forEach((line, index) => {
745
- if (!line.trim()) return;
746
- const entry = parseOpsLine(line);
747
- if (entry?.ref) pending.push({
748
- ref: entry.ref,
749
- file: rel(join(opsPath, name)),
750
- field: `line ${index + 1}`
751
- });
752
- if (entry) ops.push(entry);
753
- else problems.push({
754
- file: rel(join(opsPath, name)),
755
- field: `line ${index + 1}`,
756
- message: "expected \"YYYY-MM-DDTHH:mm | target | operation | result [| ref]\""
757
- });
758
- });
759
- const remotes = options.remotes === false ? [] : loadRemotes(root, config, problems, warnings);
760
- checkReferences({
761
- config,
762
- mainlines,
763
- issues: hubIssues,
764
- findings,
765
- remotes,
766
- pending,
767
- unreadable,
768
- problems
769
- });
770
- const deps = dependencyGraph({
771
- config,
772
- mainlines,
773
- issues: hubIssues,
774
- findings,
775
- remotes
776
- });
777
- for (const cycle of deps.cycles) {
778
- const first = deps.edges.find((e) => e.from === cycle[0] && e.to === cycle[1]);
779
- problems.push({
780
- file: first.file,
781
- field: first.field,
782
- message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
783
- });
784
- }
785
- for (const o of touchOverlaps(mainlines, deps.edges)) {
786
- const pairs = o.paths.map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join(", ");
787
- warnings.push({
788
- file: o.file,
789
- field: "touches",
790
- message: `${o.tasks[0]} and ${o.tasks[1]} both touch ${pairs} in repo ${o.repo}, and neither depends on the other; coordinate before both run, or add a depends-on link`
791
- });
792
- }
793
- for (const t of touchesWithoutRepo(mainlines)) warnings.push({
794
- file: t.file,
795
- field: "repo",
796
- message: `${t.task} lists touches but no repo, so its paths are not compared with other tasks; add the repo they are relative to`
797
- });
798
- return {
799
- root,
800
- config,
801
- mainlines,
802
- issues: hubIssues,
803
- findings,
804
- ops,
805
- problems,
806
- warnings,
807
- remotes
808
- };
809
- }
810
- /** Load every hub registered in config.yaml, read-only and without its own remotes. */
811
- function loadRemotes(root, config, problems, warnings) {
812
- const file = `${DATA_DIR}/config.yaml`;
813
- return Object.entries(config.hubs ?? {}).map(([name, path]) => {
814
- const remote = {
815
- name,
816
- path,
817
- root: remoteRoot(root, path)
818
- };
819
- if (name === config.hub) {
820
- problems.push({
821
- file,
822
- field: `hubs.${name}`,
823
- message: `"${name}" is this hub's own name; references without a prefix already point here`
824
- });
825
- return {
826
- ...remote,
827
- unavailable: "this hub"
828
- };
829
- }
830
- if (!isHubRoot(remote.root)) {
831
- warnings.push({
832
- file,
833
- field: `hubs.${name}`,
834
- message: `hub "${name}" is not at ${path} on this machine; references into it are not checked`
835
- });
836
- return {
837
- ...remote,
838
- unavailable: `not found at ${path}`
839
- };
840
- }
841
- try {
842
- const hub = loadHub(remote.root, { remotes: false });
843
- if (hub.config.hub !== name) {
844
- problems.push({
845
- file,
846
- field: `hubs.${name}`,
847
- message: `${path} holds hub "${hub.config.hub}", not "${name}"; register it under its own name`
848
- });
849
- return {
850
- ...remote,
851
- unavailable: `holds hub "${hub.config.hub}"`
852
- };
853
- }
854
- return {
855
- ...remote,
856
- hub
857
- };
858
- } catch (error) {
859
- warnings.push({
860
- file,
861
- field: `hubs.${name}`,
862
- message: `hub "${name}" at ${path} cannot be loaded: ${error.message}`
863
- });
864
- return {
865
- ...remote,
866
- unavailable: error.message
867
- };
868
- }
869
- });
870
- }
871
- /**
872
- * Every reference in a document must point at something that exists: in this hub, or in a registered
873
- * hub that is on this machine. A reference into a registered hub that is missing here is left unchecked,
874
- * since another machine may not keep every hub; the missing hub itself is reported once as a warning.
875
- * Links are for relations across mainlines; inside one mainline, parent, dependsOn and affects say it.
876
- */
877
- function checkReferences(input) {
878
- const { config, mainlines, issues, findings, remotes, pending, unreadable, problems } = input;
879
- /** A target inside a document that failed to load: its schema problem is already reported. */
880
- const inUnreadable = (target) => [
881
- target,
882
- target.split(/[#/]/)[0],
883
- target.split("#")[0]
884
- ].some((t) => unreadable.has(t));
885
- const local = refIndex({
886
- mainlines,
887
- issues,
888
- findings
889
- });
890
- const remoteIndex = new Map(remotes.map((r) => [r.name, r.hub ? refIndex(r.hub) : null]));
891
- /** The document's own mainline, when it sits in one; links may not point back into it. */
892
- const add = (file, doc, self, mainline) => {
893
- if (doc.origin) pending.push({
894
- ref: doc.origin,
895
- file,
896
- field: "origin"
897
- });
898
- doc.links?.forEach((link, i) => {
899
- pending.push({
900
- ref: link.to,
901
- file,
902
- field: `links.${i}.to`
903
- });
904
- const parsed = parseRef(link.to);
905
- const here = parsed && (parsed.hub === void 0 || parsed.hub === config.hub);
906
- if (!parsed || !here) return;
907
- if (link.to.slice(link.to.indexOf(":") + 1) === self) problems.push({
908
- file,
909
- field: `links.${i}.to`,
910
- message: "a document cannot link to itself"
911
- });
912
- else if (mainline && refMainline(parsed) === mainline) problems.push({
913
- file,
914
- field: `links.${i}.to`,
915
- message: `${link.to} is inside ${mainline}; links are for other mainlines, so use parent, dependsOn or affects within one`
916
- });
917
- });
918
- };
919
- const items = (file, field, list) => list.forEach((item, i) => item.supersededBy && pending.push({
920
- ref: item.supersededBy,
921
- file,
922
- field: `${field}.${i}.supersededBy`
923
- }));
924
- for (const m of mainlines) {
925
- add(m.file, m, m.id, m.id);
926
- items(m.file, "criteria", m.criteria);
927
- for (const d of m.decisions) add(d.file, d, `${m.id}/${d.id}`, m.id);
928
- for (const t of m.tasks) {
929
- add(t.file, t, `${m.id}/${t.id}`, m.id);
930
- items(t.file, "checklist", t.checklist);
931
- const waiting = waitingForRef(t.blockedBy?.waitingFor);
932
- if (waiting) pending.push({
933
- ref: waiting,
934
- file: t.file,
935
- field: "blockedBy.waitingFor"
936
- });
937
- }
938
- }
939
- for (const i of issues) add(i.file, i, i.id);
940
- for (const f of findings) add(f.file, f, f.id);
941
- for (const p of pending) {
942
- const parsed = parseRef(p.ref);
943
- if (!parsed) continue;
944
- const target = p.ref.slice(p.ref.indexOf(":") + 1);
945
- if (parsed.hub === void 0 || parsed.hub === config.hub) {
946
- if (!local.has(target) && !inUnreadable(target)) problems.push({
947
- file: p.file,
948
- field: p.field,
949
- message: `${p.ref} does not exist in this hub`
950
- });
951
- continue;
952
- }
953
- if (!remoteIndex.has(parsed.hub)) {
954
- problems.push({
955
- file: p.file,
956
- field: p.field,
957
- message: `hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>"`
958
- });
959
- continue;
960
- }
961
- const index = remoteIndex.get(parsed.hub);
962
- if (index && !index.has(target)) problems.push({
963
- file: p.file,
964
- field: p.field,
965
- message: `${p.ref} does not exist in hub "${parsed.hub}"`
966
- });
967
- }
968
- }
969
- /**
970
- * A listed repository counts as part of the hub, so the PreToolUse hook lets grants act inside it. A path that
971
- * resolves to / or to a directory above the hub would put everything under it in scope. A path that does not
972
- * exist is fine: another machine may not have cloned that repository yet.
973
- */
974
- function repoPathProblems(root, config) {
975
- const real = (path) => {
976
- try {
977
- return realpathSync(path);
978
- } catch {
979
- return null;
980
- }
981
- };
982
- const hub = real(root) ?? root;
983
- return Object.entries(config.repos).flatMap(([name, path]) => {
984
- const dir = real(resolve(root, path));
985
- if (dir === null) return [];
986
- const problem = (message) => [{
987
- file: `${DATA_DIR}/config.yaml`,
988
- field: `repos.${name}`,
989
- message
990
- }];
991
- if (dir === parse(dir).root) return problem(`"${path}" resolves to the file system root; list the repository's own directory`);
992
- if (hub.startsWith(dir.endsWith(sep) ? dir : dir + sep)) return problem(`"${path}" resolves to ${dir}, above the hub; list the repository's own directory`);
993
- return [];
994
- });
995
- }
996
- /**
997
- * What the schema cannot see about a mainline's grants: ids must be unique, since the file is edited directly,
998
- * and a target that names a repository must name one listed in config.yaml.
999
- */
1000
- function grantProblems(mainline, config) {
1001
- const problems = [];
1002
- const seen = /* @__PURE__ */ new Set();
1003
- mainline.grants?.forEach((grant, i) => {
1004
- if (seen.has(grant.id)) problems.push({
1005
- file: mainline.file,
1006
- field: `grants.${i}.id`,
1007
- message: `${grant.id} is used twice; give it the next free id`
1008
- });
1009
- seen.add(grant.id);
1010
- const parsed = parseGrantTarget(grant.action, grant.target);
1011
- const repo = parsed.ok ? grantRepo(parsed.target) : void 0;
1012
- if (repo !== void 0 && !(repo in config.repos)) problems.push({
1013
- file: mainline.file,
1014
- field: `grants.${i}.target`,
1015
- message: `repo "${repo}" is not listed in config.yaml; use one of ${Object.keys(config.repos).join(", ") || "(none registered)"}`
1016
- });
1017
- });
1018
- return problems;
1019
- }
1020
- const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
1021
- /**
1022
- * What a reader without the conversation needs to pick the work up: an open mainline with tasks says what
1023
- * was checked before they were written, every open task says what it is for and when it is done, and
1024
- * every open criterion or item says what has to be true to check it. Closed
1025
- * entries are skipped, because their evidence already says what was done. Ids must also be unique within
1026
- * a list, since the files are edited directly and nothing else allocates them.
1027
- */
1028
- function contentGaps(mainline, tasks) {
1029
- const gaps = [];
1030
- const list = (file, field, items, open) => {
1031
- const seen = /* @__PURE__ */ new Set();
1032
- items.forEach((item, i) => {
1033
- if (seen.has(item.id)) gaps.push({
1034
- file,
1035
- field: `${field}.${i}.id`,
1036
- message: `${item.id} is used twice; give it the next free id`
1037
- });
1038
- seen.add(item.id);
1039
- if (open && !item.done && !item.dropped && !item.doneWhen?.trim()) gaps.push({
1040
- file,
1041
- field: `${field}.${i}.doneWhen`,
1042
- message: `${item.id} is open and needs doneWhen: what has to be true to check it`
1043
- });
1044
- });
1045
- };
1046
- const mainlineOpen = isOpen$2("mainline", mainline.status);
1047
- list(mainline.file, "criteria", mainline.criteria, mainlineOpen);
1048
- if (mainlineOpen && tasks.length > 0) for (const message of surveyGaps(mainline.body)) gaps.push({
1049
- file: mainline.file,
1050
- field: `## ${SURVEY_SECTION}`,
1051
- message
1052
- });
1053
- for (const task of tasks) {
1054
- const open = mainlineOpen && isOpen$2("task", task.status);
1055
- list(task.file, "checklist", task.checklist, open);
1056
- if (!open) continue;
1057
- for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(task.body, section)) gaps.push({
1058
- file: task.file,
1059
- field: `## ${section}`,
1060
- message: `the task is open and needs text under "## ${section}"`
1061
- });
1062
- }
1063
- return gaps;
1064
- }
1065
- var HubError = class extends Error {
1066
- problems;
1067
- constructor(message, problems) {
1068
- super(message);
1069
- this.problems = problems;
1070
- }
1071
- };
1072
- //#endregion
1073
- //#region ../core/dist/hub-items.js
1074
- const issuesDir = (root) => join(root, DATA_DIR, "issues");
1075
- const findingsDir = (root) => join(root, DATA_DIR, "findings");
1076
- const opsDir = (root) => join(root, DATA_DIR, "ops");
1077
- function entryFile(dir, id, what) {
1078
- const name = existsSync(dir) ? readdirSync(dir).find((n) => n.startsWith(`${id}-`) && n.endsWith(".md")) : void 0;
1079
- if (!name) throw new WriteError(`${what} ${id} not found`);
1080
- return join(dir, name);
1081
- }
1082
- /**
1083
- * Any reference the schema accepts must also point at something that exists. A reference into another
1084
- * hub only needs that hub registered here; check verifies the target when that hub is on this machine.
1085
- */
1086
- function requireExisting(root, ref) {
1087
- const parsed = parseRef(ref);
1088
- if (!parsed) throw new WriteError(`"${ref}" is not a reference like ${REF_EXAMPLES}`);
1089
- const config = readConfig(root);
1090
- if (parsed.hub !== void 0 && parsed.hub !== config.hub) {
1091
- if (!config.hubs?.[parsed.hub]) throw new WriteError(`hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>" first`);
1092
- return;
1093
- }
1094
- switch (parsed.kind) {
1095
- case "issue":
1096
- entryFile(issuesDir(root), parsed.issue, "issue");
1097
- return;
1098
- case "finding":
1099
- entryFile(findingsDir(root), parsed.finding, "finding");
1100
- return;
1101
- default:
1102
- mainlineDir(root, parsed.mainline);
1103
- if ("task" in parsed) taskDir(root, parsed.mainline, parsed.task);
1104
- if (parsed.kind === "decision") decisionFile(root, parsed.mainline, parsed.decision);
1105
- }
1106
- }
1107
- function createIssue(root, input) {
1108
- requireSlug(input.slug);
1109
- if (input.origin) requireExisting(root, input.origin);
1110
- return withLock(root, () => {
1111
- const dir = issuesDir(root);
1112
- mkdirSync(dir, { recursive: true });
1113
- const id = nextId("i", idsIn(dir));
1114
- const issue = validate(issueSchema, {
1115
- id,
1116
- title: input.title,
1117
- status: "open",
1118
- created: today(),
1119
- ...input.origin ? { origin: input.origin } : {},
1120
- ...input.labels?.length ? { labels: input.labels } : {}
1121
- }, "issue");
1122
- const file = join(dir, `${id}-${input.slug}.md`);
1123
- writeAtomic(file, frontMatterText(issue, ISSUE_BODY));
1124
- return {
1125
- id,
1126
- file
1127
- };
1128
- });
1129
- }
1130
- /** Schedule an issue into the task that will carry it out. */
1131
- function planIssue(root, id, taskRef, expectedVersion) {
1132
- const ref = requireRef(taskRef, "task");
1133
- taskDir(root, ref.mainline, ref.task);
1134
- return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
1135
- i.status = "planned";
1136
- i.plannedIn = taskRef;
1137
- }, expectedVersion);
1138
- }
1139
- function closeIssue(root, id, resolution, expectedVersion) {
1140
- if (!resolution) throw new WriteError(`closing ${id} needs what closed it, e.g. --by "mainflow@3fdd5ac" or --by "不再需要"`);
1141
- return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
1142
- i.status = "closed";
1143
- i.resolution = resolution;
1144
- i.closedAt ??= today();
1145
- }, expectedVersion);
1146
- }
1147
- function reopenIssue(root, id, expectedVersion) {
1148
- return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
1149
- i.status = "open";
1150
- delete i.plannedIn;
1151
- delete i.resolution;
1152
- delete i.closedAt;
1153
- }, expectedVersion);
1154
- }
1155
- /** Archive or unarchive an issue; the status stays as it is, so a closed issue is visible until archived. */
1156
- function setIssueArchived(root, id, archived, expectedVersion) {
1157
- return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
1158
- if (archived) i.archivedAt ??= today();
1159
- else delete i.archivedAt;
1160
- }, expectedVersion);
1161
- }
1162
- function createFinding(root, input) {
1163
- requireSlug(input.slug);
1164
- if (input.origin) requireExisting(root, input.origin);
1165
- return withLock(root, () => {
1166
- const dir = findingsDir(root);
1167
- mkdirSync(dir, { recursive: true });
1168
- const id = nextId("f", idsIn(dir));
1169
- const finding = validate(findingSchema, {
1170
- id,
1171
- title: input.title,
1172
- status: "valid",
1173
- created: today(),
1174
- ...input.origin ? { origin: input.origin } : {},
1175
- ...input.labels?.length ? { labels: input.labels } : {}
1176
- }, "finding");
1177
- const file = join(dir, `${id}-${input.slug}.md`);
1178
- writeAtomic(file, frontMatterText(finding, FINDING_BODY));
1179
- return {
1180
- id,
1181
- file
1182
- };
1183
- });
1184
- }
1185
- function setFindingStatus(root, id, status, expectedVersion) {
1186
- const value = requireOneOf(status, FINDING_STATUS, "finding status");
1187
- return mutateFrontMatter(root, entryFile(findingsDir(root), id, "finding"), findingSchema, (f) => {
1188
- f.status = value;
1189
- if (value === "obsolete") f.closedAt ??= today();
1190
- else delete f.closedAt;
1191
- }, expectedVersion);
1192
- }
1193
- /** Archive or unarchive a finding; the status stays as it is, so an obsolete finding is visible until archived. */
1194
- function setFindingArchived(root, id, archived, expectedVersion) {
1195
- return mutateFrontMatter(root, entryFile(findingsDir(root), id, "finding"), findingSchema, (f) => {
1196
- if (archived) f.archivedAt ??= today();
1197
- else delete f.archivedAt;
1198
- }, expectedVersion);
1199
- }
1200
- /** Append one external operation to ops/{YYYY-MM}.log; the log is never rewritten. */
1201
- function appendOps(root, input) {
1202
- if (input.ref) requireExisting(root, input.ref);
1203
- const entry = validate(opsEntrySchema, {
1204
- ...input,
1205
- at: input.at ?? nowMinute()
1206
- }, "ops entry");
1207
- for (const field of [
1208
- "target",
1209
- "operation",
1210
- "result"
1211
- ]) if (entry[field].includes(" | ")) throw new WriteError(`${field} cannot contain " | ", the ops log's separator`);
1212
- return withLock(root, () => {
1213
- mkdirSync(opsDir(root), { recursive: true });
1214
- appendFileSync(join(opsDir(root), `${entry.at.slice(0, 7)}.log`), `${formatOpsEntry(entry)}\n`);
1215
- return entry;
1216
- });
1217
- }
1218
- /** Where the hub's interface shows a reference; null for kinds it has no page or panel for. */
1219
- function pathOf(ref, target) {
1220
- const [m, child] = ref.split("/");
1221
- switch (target.kind) {
1222
- case "mainline": return `/mainlines/${m}`;
1223
- case "task": return `/mainlines/${m}/tasks/${child}`;
1224
- case "decision": return `/mainlines/${m}/decisions/${child}`;
1225
- case "issue": return `/issues?peek=${ref}`;
1226
- case "finding": return `/findings?peek=${ref}`;
1227
- default: return null;
1228
- }
1229
- }
1230
- const sessionPath = (id) => `/sessions/${id}`;
1231
- /** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
1232
- const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
1233
- /** A file under a mainline directory, or a reference to a mainline, task, decision, issue or finding. */
1234
- const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[if]\d{3})(?![\w\]])/g;
1235
- /** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
1236
- function linkMentions(text, context) {
1237
- const index = refIndex(context.hub);
1238
- return text.split(PROTECTED).map((part, i) => i % 2 === 1 ? part : part.replace(MENTION, (all, m, file, ref) => {
1239
- if (m && file) return index.has(m) ? `[${m}/${file}](${context.link(`/mainlines/${m}/files/${file}`)})` : all;
1240
- const target = ref ? index.get(ref) : void 0;
1241
- const path = ref && target ? pathOf(ref, target) : null;
1242
- return path ? `[${ref}](${context.link(path)})` : all;
1243
- })).join("");
1244
- }
1245
- /** `▸ m001 title › t001 title › a001 do` for the given ids, each linked; titles come from the hub and the session. */
1246
- function headerLineFor(ids, context) {
1247
- const index = refIndex(context.hub);
1248
- const parts = [];
1249
- if (ids.mainline) {
1250
- parts.push(`[${ids.mainline}](${context.link(`/mainlines/${ids.mainline}`)}) ${index.get(ids.mainline)?.title ?? ""}`.trimEnd());
1251
- if (ids.task) {
1252
- const ref = `${ids.mainline}/${ids.task}`;
1253
- parts.push(`[${ids.task}](${context.link(`/mainlines/${ids.mainline}/tasks/${ids.task}`)}) ${index.get(ref)?.title ?? ""}`.trimEnd());
1254
- }
1255
- }
1256
- const action = context.session?.actions.find((a) => a.id === ids.action);
1257
- parts.push(context.session ? `[${ids.action}](${context.link(sessionPath(context.session.id))}) ${action?.do ?? ""}`.trimEnd() : ids.action);
1258
- return `▸ ${parts.join(" › ")}`;
1259
- }
1260
- /** The ids of the session's action in progress; null when there is none. */
1261
- function currentIds(session) {
1262
- const action = session?.actions.find((a) => a.status === "doing");
1263
- if (!action) return null;
1264
- const served = action.serves.map((s) => parseRef(s)).find((r) => r !== null);
1265
- if (served && "mainline" in served) return {
1266
- mainline: served.mainline,
1267
- task: "task" in served ? served.task : void 0,
1268
- action: action.id
1269
- };
1270
- return { action: action.id };
1271
- }
1272
- /** The current action line for a bare marker; null when the session has no action in progress. */
1273
- function currentActionLine(context) {
1274
- const ids = currentIds(context.session);
1275
- return ids ? headerLineFor(ids, context) : null;
1276
- }
1277
- /** The header line the model writes at the end of a turn, e.g. `▸ m001/t001 · a001`; null without an action in progress. */
1278
- function headerLine(session) {
1279
- const ids = currentIds(session);
1280
- if (!ids) return null;
1281
- const where = ids.mainline ? ids.task ? `${ids.mainline}/${ids.task}` : ids.mainline : null;
1282
- return `▸ ${where ? `${where} · ` : ""}${ids.action}`;
1283
- }
1284
- /** The ids in a header line the model wrote, or null for a bare marker or a line that does not parse. */
1285
- const WRITTEN = new RegExp(`^\\s*▸\\s+(?:(m\\d{3})(?:\\/(t\\d{3}))?\\s*·\\s*)?(a\\d{3})\\s*$`);
1286
- function parseHeader(line) {
1287
- const m = WRITTEN.exec(line);
1288
- return m ? {
1289
- mainline: m[1],
1290
- task: m[2],
1291
- action: m[3]
1292
- } : null;
1293
- }
1294
- /** A line that is the header: the marker alone, or the marker followed by the ids the model copied. */
1295
- const HEADER = new RegExp(`^[ \\t]*▸(?:[ \\t].*)?$`);
1296
- /** The last non-empty line, if it is the header line. */
1297
- const lastHeader = (text) => {
1298
- const last = text.trimEnd().split("\n").at(-1) ?? "";
1299
- return HEADER.test(last) ? last : null;
1300
- };
1301
- /** Whether a message ends with the header line, as the turn's last message should. */
1302
- const endsWithMarker = (text) => lastHeader(text) !== null;
1303
- /**
1304
- * The text to display for a piece of a reply: mentions linked, and a trailing header line replaced with the
1305
- * mainline › task › action it names, titles and links included. A bare marker names the session's action in
1306
- * progress and is dropped without one. Returns the input unchanged when there is nothing to do.
1307
- */
1308
- function decorateReply(text, context) {
1309
- const header = lastHeader(text);
1310
- if (header === null) return linkMentions(text, context);
1311
- const body = linkMentions(text.trimEnd().slice(0, -header.length).trimEnd(), context);
1312
- const written = parseHeader(header);
1313
- const line = written ? headerLineFor(written, context) : header.trim() === "▸" ? currentActionLine(context) : linkMentions(header.trim(), context);
1314
- return line ? `${body}\n\n${line}\n` : `${body}\n`;
1315
- }
1316
- /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
1317
- const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
1318
- //#endregion
1319
- //#region ../core/dist/grants.js
1320
- /** Mainline statuses whose grants apply. */
1321
- const GRANTING_STATUS = ["active", "planning"];
1322
- /**
1323
- * The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
1324
- * must fail closed catches it and asks.
1325
- */
1326
- function grantScope(root, session) {
1327
- if (!session) return {
1328
- ok: false,
1329
- reason: "這場會話沒有 mainflow 紀錄"
1330
- };
1331
- const doing = session.actions.filter((a) => a.status === "doing");
1332
- if (doing.length === 0) return {
1333
- ok: false,
1334
- reason: `${session.id} 沒有進行中的行動`
1335
- };
1336
- if (doing.length > 1) return {
1337
- ok: false,
1338
- reason: `${session.id} 同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")}),分不出授權範圍`
1339
- };
1340
- const action = doing[0];
1341
- const mainlineOf = (ref) => {
1342
- const parsed = parseRef(ref);
1343
- return parsed ? refMainline(parsed) : void 0;
1344
- };
1345
- const mainlines = [...new Set(action.serves.flatMap((ref) => mainlineOf(ref) ?? []))];
1346
- if (mainlines.length === 0) return {
1347
- ok: false,
1348
- reason: `${action.id} 沒有服務任何 checklist 項目或解決條件`
1349
- };
1350
- const grants = [];
1351
- const served = {};
1352
- const closed = [];
1353
- for (const id of mainlines) {
1354
- const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
1355
- if (!GRANTING_STATUS.includes(mainline.status)) {
1356
- closed.push(`${id} 是 ${mainline.status}`);
1357
- continue;
1358
- }
1359
- served[id] = action.serves.find((ref) => mainlineOf(ref) === id);
1360
- for (const grant of mainline.grants ?? []) grants.push({
1361
- mainline: id,
1362
- grant,
1363
- ref: `${id}#${grant.id}`
1364
- });
1365
- }
1366
- if (Object.keys(served).length === 0) return {
1367
- ok: false,
1368
- reason: `${action.id} 服務的主脈絡已不在進行中(${closed.join("、")}),授權不再生效`
1369
- };
1370
- return {
1371
- ok: true,
1372
- scope: {
1373
- action,
1374
- grants,
1375
- served
1376
- }
1377
- };
1378
- }
1379
- /** Every request must be covered by some grant in scope; the first one that is not is named in the reason. */
1380
- function matchGrants(scope, requests) {
1381
- const matches = [];
1382
- for (const request of requests) {
1383
- const grant = scope.grants.find((g) => {
1384
- const parsed = parseGrantTarget(g.grant.action, g.grant.target);
1385
- return parsed.ok && grantCovers(parsed.target, request);
1386
- });
1387
- if (!grant) return {
1388
- ok: false,
1389
- reason: `${Object.keys(scope.served).join("、")} 沒有涵蓋 ${describeRequest(request)} 的授權`
1390
- };
1391
- matches.push({
1392
- request,
1393
- grant
1394
- });
1395
- }
1396
- return {
1397
- ok: true,
1398
- matches
1399
- };
1400
- }
1401
- /**
1402
- * One line per grant in effect for the session, for the briefing and the per-turn reminder, with the fixed form
1403
- * the hook allows for it; empty when the session has none or they cannot be read, since a briefing must not fail
1404
- * on them.
1405
- */
1406
- function grantLines(root, session) {
1407
- try {
1408
- const result = grantScope(root, session);
1409
- if (!result.ok) return [];
1410
- return result.scope.grants.map((g) => {
1411
- const parsed = parseGrantTarget(g.grant.action, g.grant.target);
1412
- const form = parsed.ok ? grantForm(parsed.target) : void 0;
1413
- return `- ${g.ref} ${g.grant.action} ${g.grant.target}${form ? `:${form}` : ""}`;
1414
- });
1415
- } catch {
1416
- return [];
1417
- }
1418
- }
1419
- //#endregion
1420
- //#region ../core/dist/sessions.js
1421
- const sessionsDir = (root) => join(root, DATA_DIR, "local", "sessions");
1422
- const sessionFile = (root, id) => join(sessionsDir(root), `${id}.yaml`);
1423
- /** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
1424
- function loadSessions(root) {
1425
- const dir = sessionsDir(root);
1426
- const sessions = [];
1427
- const problems = [];
1428
- let names = [];
1429
- try {
1430
- names = readdirSync(dir).filter((n) => n.endsWith(".yaml")).sort();
1431
- } catch {
1432
- return {
1433
- sessions,
1434
- problems
1435
- };
1436
- }
1437
- for (const name of names) {
1438
- const file = join(dir, name);
1439
- let data;
1440
- try {
1441
- data = parse$1(readText(file) ?? "");
1442
- } catch (error) {
1443
- problems.push({
1444
- file: relative(root, file),
1445
- field: "",
1446
- message: `cannot parse YAML: ${error.message}`
1447
- });
1448
- continue;
1449
- }
1450
- const result = sessionSchema.safeParse(data);
1451
- if (result.success) sessions.push(result.data);
1452
- else for (const i of result.error.issues) problems.push({
1453
- file: relative(root, file),
1454
- field: i.path.join("."),
1455
- message: i.message
1456
- });
1457
- }
1458
- return {
1459
- sessions,
1460
- problems
1461
- };
1462
- }
1463
- function readSession(root, id) {
1464
- const file = sessionFile(root, id);
1465
- return existsSync(file) ? readYaml(file, sessionSchema).data : null;
1466
- }
1467
- function requireSession(root, id) {
1468
- const file = sessionFile(root, id);
1469
- if (!existsSync(file)) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
1470
- return file;
1471
- }
1472
- /** Split `claude-code-1a2b3c4d#a003` into its session and action ids. */
1473
- function splitActionRef(ref) {
1474
- const hash = ref.lastIndexOf("#");
1475
- const action = ref.slice(hash + 1);
1476
- if (hash <= 0 || !/^a\d{3}$/.test(action)) throw new WriteError(`expected an action reference like claude-code-1a2b3c4d#a003, got "${ref}"`);
1477
- return {
1478
- session: ref.slice(0, hash),
1479
- action
1480
- };
1481
- }
1482
- /** The note a handoff leaves on the dropped action, naming where the work went. */
1483
- const handedOffNote = (to) => `handed off to ${to}`;
1484
- const HANDED_OFF = /(?:^|; )handed off to ([a-z0-9][a-z0-9-]*#a\d{3})$/;
1485
- /**
1486
- * Find the action a reference names. A handoff drops the action and names its new place in the note, so the
1487
- * reference is followed there: a wait on the old reference keeps working after the work moved to another session.
1488
- */
1489
- function resolveAction(root, ref) {
1490
- const seen = /* @__PURE__ */ new Set();
1491
- let current = ref;
1492
- for (;;) {
1493
- const parsed = parseActionRef(current);
1494
- if (!parsed) return {
1495
- ref: current,
1496
- missing: `"${current}" is not an action reference like claude-code-1a2b3c4d#a003`
1497
- };
1498
- let session;
1499
- try {
1500
- session = readSession(root, parsed.session);
1501
- } catch (error) {
1502
- return {
1503
- ref: current,
1504
- missing: error.message
1505
- };
1506
- }
1507
- if (!session) return {
1508
- ref: current,
1509
- missing: `session ${parsed.session} not found`
1510
- };
1511
- const action = session.actions.find((a) => a.id === parsed.action);
1512
- if (!action) return {
1513
- ref: current,
1514
- missing: `action ${current} not found`
1515
- };
1516
- const next = action.status === "dropped" ? HANDED_OFF.exec(action.note ?? "")?.[1] : void 0;
1517
- if (!next || seen.has(next)) return {
1518
- ref: current,
1519
- session,
1520
- action
1521
- };
1522
- seen.add(current);
1523
- current = next;
1524
- }
1525
- }
1526
- /**
1527
- * Every served checklist item or criterion must exist, so a session never points at work that is not in a
1528
- * mainline. A criterion stands in while the mainline has no task for it yet, as when it is still being surveyed
1529
- * and planned. Its mainline must still be open: follow-up work on an ended one either reopens it with a criterion
1530
- * or task for the follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
1531
- */
1532
- function requireServes(root, serves) {
1533
- for (const ref of serves) {
1534
- const parsed = parseRef(ref);
1535
- if (!SERVES_REF.test(ref) || parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") throw new WriteError(`--serves takes checklist items like m001/t002#c003 or criteria like m001#r001, got "${ref}"`);
1536
- const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1537
- if (CLOSED_STATUS.mainline.includes(mainline.status)) throw new WriteError(`${parsed.mainline} is ${mainline.status}; to continue it, run "mainline set ${parsed.mainline} --status active" and add a criterion or task for the follow-up, or start a new mainline with "new mainline"`);
1538
- if (parsed.kind === "criterion") {
1539
- if (!mainline.criteria.some((r) => r.id === parsed.criterion)) throw new WriteError(`criterion ${ref} not found`);
1540
- continue;
1541
- }
1542
- if (!readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.some((c) => c.id === parsed.item)) throw new WriteError(`checklist item ${ref} not found`);
1543
- }
1544
- }
1545
- const isOpen$1 = (a) => a.status === "todo" || a.status === "doing";
1546
- const isCarried$1 = (a) => isOpen$1(a) || a.status === "blocked";
1547
- /**
1548
- * The previous session's unfinished actions, numbered after `existing`, with the previous session marked
1549
- * handed-off. Blocked actions go along with their `blockedBy`, since the wait is still on and whoever is
1550
- * waited for keeps seeing it. Each carried action is dropped in the previous session with a note naming its new reference,
1551
- * so the work stays open in one place only and the old session still shows where it went. Runs inside the
1552
- * caller's lock.
1553
- */
1554
- function takeUnfinished(root, from, to, existing) {
1555
- const previousFile = requireSession(root, from);
1556
- const previous = readYaml(previousFile, sessionSchema).data;
1557
- const ids = [...existing];
1558
- const carried = [];
1559
- const at = nowMinute();
1560
- const left = previous.actions.map((a) => {
1561
- if (!isCarried$1(a)) return a;
1562
- const id = nextId("a", ids);
1563
- ids.push(id);
1564
- carried.push({
1565
- ...a,
1566
- id
1567
- });
1568
- const handedOff = handedOffNote(`${to}#${id}`);
1569
- const { blockedBy: _moved, ...rest } = a;
1570
- return {
1571
- ...rest,
1572
- status: "dropped",
1573
- note: a.note ? `${a.note}; ${handedOff}` : handedOff,
1574
- doneAt: at
1575
- };
1576
- });
1577
- writeAtomic(previousFile, toYaml(validate(sessionSchema, {
1578
- ...previous,
1579
- status: "handed-off",
1580
- actions: left
1581
- }, previousFile)));
1582
- return carried;
1583
- }
1584
- /**
1585
- * Start a session. Taking over from another session moves its unfinished actions into this one and marks
1586
- * the other session handed-off, so the queue continues where it stopped.
1587
- */
1588
- function startSession(root, input) {
1589
- const id = input.id ?? `${input.agent}-${randomBytes(4).toString("hex")}`;
1590
- if (!SESSION_ID.test(id)) throw new WriteError(`session id "${id}" must be lowercase letters, digits and hyphens`);
1591
- return withLock(root, () => {
1592
- const file = sessionFile(root, id);
1593
- if (existsSync(file)) throw new WriteError(`session ${id} already exists`);
1594
- if (input.delegatedFrom) requireSession(root, input.delegatedFrom);
1595
- const carried = input.handoffFrom ? takeUnfinished(root, input.handoffFrom, id, []) : [];
1596
- const session = validate(sessionSchema, {
1597
- id,
1598
- agent: input.agent,
1599
- status: "active",
1600
- started: nowMinute(),
1601
- delegatedFrom: input.delegatedFrom ?? null,
1602
- handoffFrom: input.handoffFrom ?? null,
1603
- cwd: input.cwd,
1604
- actions: carried
1605
- }, file);
1606
- mkdirSync(sessionsDir(root), { recursive: true });
1607
- writeAtomic(file, toYaml(session));
1608
- return session;
1609
- });
1610
- }
1611
- /**
1612
- * Start the session unless it already exists. An existing paused or ended session becomes active again, and
1613
- * an archived one comes back into view, since resumed work should not stay hidden.
1614
- */
1615
- function ensureSession(root, input) {
1616
- const existing = readSession(root, input.id);
1617
- if (!existing) return {
1618
- session: startSession(root, input),
1619
- created: true
1620
- };
1621
- if (existing.status === "active" && !existing.archivedAt) return {
1622
- session: existing,
1623
- created: false
1624
- };
1625
- return {
1626
- session: mutateYaml(root, requireSession(root, input.id), sessionSchema, (s) => {
1627
- s.status = "active";
1628
- delete s.archivedAt;
1629
- }),
1630
- created: false
1631
- };
1632
- }
1633
- /**
1634
- * Take over another session's unfinished actions into a session that already exists, such as one a host hook
1635
- * created when the conversation opened. The receiving session must have nothing unfinished of its own, so the
1636
- * carried queue is not mixed into work already under way; its finished actions stay and the carried ones are
1637
- * numbered after them.
1638
- */
1639
- function handoffSession(root, id, from) {
1640
- if (id === from) throw new WriteError(`session ${id} cannot take over from itself`);
1641
- return withLock(root, () => {
1642
- const file = requireSession(root, id);
1643
- const session = readYaml(file, sessionSchema).data;
1644
- if (session.handoffFrom && session.handoffFrom !== from) throw new WriteError(`session ${id} already took over from ${session.handoffFrom}`);
1645
- const open = session.actions.filter(isOpen$1);
1646
- if (open.length > 0) throw new WriteError(`session ${id} has unfinished actions (${open.map((a) => a.id).join(", ")}); finish or drop them before taking over from ${from}`);
1647
- if (!readYaml(requireSession(root, from), sessionSchema).data.actions.some(isCarried$1)) throw new WriteError(`session ${from} has no unfinished actions to take over`);
1648
- const carried = takeUnfinished(root, from, id, session.actions.map((a) => a.id));
1649
- const next = validate(sessionSchema, {
1650
- ...session,
1651
- status: "active",
1652
- handoffFrom: from,
1653
- actions: [...session.actions, ...carried]
1654
- }, file);
1655
- writeAtomic(file, toYaml(next));
1656
- return next;
1657
- });
1658
- }
1659
- /** Where a session's transcript is on disk: a platform records an absolute path, a hub may record one relative to its root. */
1660
- function transcriptPath(root, transcript) {
1661
- return isAbsolute(transcript) ? transcript : join(root, transcript);
1662
- }
1663
- function setSessionInfo(root, id, info) {
1664
- return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1665
- for (const [key, value] of Object.entries(info)) if (value !== void 0 && value !== null && value !== "") s[key] = value;
1666
- });
1667
- }
1668
- /** Remember the digest of what the last turn reminder listed in full; null forgets it, so the next reminder lists everything again. */
1669
- function setReminderDigest(root, id, digest) {
1670
- mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1671
- if (digest === null) delete s.reminderDigest;
1672
- else s.reminderDigest = digest;
1673
- });
1674
- }
1675
- function setSessionStatus(root, id, status) {
1676
- const value = requireOneOf(status, SESSION_STATUS, "session status");
1677
- return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1678
- s.status = value;
1679
- });
1680
- }
1681
- /**
1682
- * Settle a session when its turn ends. A session that tracked no action, and whose subagents tracked none, leaves
1683
- * no record, since nothing in it points at the hub's work. Then every active main session with no action left to
1684
- * do and no subagent still running ends, the others as well as this one: another session may have finished its
1685
- * last action, or it was closed before its own turn could settle it. The next prompt or action brings it back.
1686
- */
1687
- function settleSession(root, id) {
1688
- return withLock(root, () => {
1689
- const { sessions } = loadSessions(root);
1690
- const subagents = (parent) => sessions.filter((s) => s.delegatedFrom === parent).flatMap((s) => [s, ...subagents(s.id)]);
1691
- const family = sessions.some((s) => s.id === id) ? [sessions.find((s) => s.id === id), ...subagents(id)] : [];
1692
- const removed = family.length > 0 && family.every((s) => s.actions.length === 0);
1693
- if (removed) for (const s of family) rmSync(sessionFile(root, s.id), { force: true });
1694
- let result = removed ? "removed" : "kept";
1695
- for (const s of sessions) {
1696
- if (s.status !== "active" || s.delegatedFrom || removed && s.id === id) continue;
1697
- if (!s.actions.every((a) => a.status === "done" || a.status === "dropped") || subagents(s.id).some((x) => x.status === "active")) continue;
1698
- if (s.id === id && s.actions.length === 0) continue;
1699
- writeAtomic(sessionFile(root, s.id), toYaml(validate(sessionSchema, {
1700
- ...s,
1701
- status: "ended"
1702
- }, sessionFile(root, s.id))));
1703
- if (s.id === id) result = "ended";
1704
- }
1705
- return result;
1706
- });
1707
- }
1708
- /** Archive or unarchive a session; the status stays as it is, so an ended session is visible until archived. */
1709
- function setSessionArchived(root, id, archived) {
1710
- return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1711
- if (archived) s.archivedAt ??= today();
1712
- else delete s.archivedAt;
1713
- });
1714
- }
1715
- function place(actions, action, before) {
1716
- if (!before) {
1717
- actions.push(action);
1718
- return;
1719
- }
1720
- const index = actions.findIndex((a) => a.id === before);
1721
- if (index < 0) throw new WriteError(`--before ${before} is not an action in this session`);
1722
- actions.splice(index, 0, action);
1723
- }
1724
- function addAction(root, sessionId, input) {
1725
- requireServes(root, input.serves);
1726
- let created = "";
1727
- mutateYaml(root, requireSession(root, sessionId), sessionSchema, (s) => {
1728
- created = nextId("a", s.actions.map((a) => a.id));
1729
- place(s.actions, {
1730
- id: created,
1731
- do: input.do,
1732
- status: "todo",
1733
- serves: input.serves
1734
- }, input.before);
1735
- if (s.status === "ended") s.status = "active";
1736
- });
1737
- return `${sessionId}#${created}`;
1738
- }
1739
- /**
1740
- * What a blocked action waits for must be checkable: an action it names has to exist and, followed through
1741
- * handoffs, cannot be the action itself, and an `until` has to be one of the condition forms. An old reference
1742
- * to work that has since been handed off to this very action would otherwise wait forever.
1743
- */
1744
- function requireBlock(root, ref, block) {
1745
- const targets = [waitingForAction(block.waitingFor), block.until === void 0 ? void 0 : untilTarget(block.until)];
1746
- for (const target of targets) {
1747
- if (!target) continue;
1748
- const found = resolveAction(root, target);
1749
- if ("missing" in found) throw new WriteError(`${found.missing}; --waiting-for and --until action-done take an existing action like claude-code-1a2b3c4d#a003`);
1750
- if (found.ref === ref) throw new WriteError(`action ${ref} cannot wait for itself${target === ref ? "" : ` (${target} was handed off to it)`}`);
1751
- }
1752
- }
1753
- /** The action an `until` waits for; throws when the condition matches none of the forms. */
1754
- function untilTarget(until) {
1755
- const parsed = parseUntil(until);
1756
- if (!parsed) throw new WriteError(`--until takes a condition like commit:{branch}:{text}, action-done:{sessionId}#a{NNN} or file:{relative path}, got "${until}"`);
1757
- return parsed.kind === "action-done" ? `${parsed.session}#${parsed.action}` : void 0;
1758
- }
1759
- function updateAction(root, ref, patch) {
1760
- const { session, action } = splitActionRef(ref);
1761
- const status = patch.status === void 0 ? void 0 : requireOneOf(patch.status, WORK_STATUS, "action status");
1762
- if (patch.serves) requireServes(root, patch.serves);
1763
- if (patch.before === action) throw new WriteError(`action ${action} cannot be placed before itself`);
1764
- if (status === "blocked" && !patch.blockedBy?.reason) throw new WriteError(`blocking ${ref} needs a reason, e.g. --reason "等 schema 定案" --waiting-for claude-code-1a2b3c4d#a003 --until action-done:claude-code-1a2b3c4d#a003`);
1765
- if (patch.blockedBy) requireBlock(root, `${session}#${action}`, patch.blockedBy);
1766
- let updated;
1767
- mutateYaml(root, requireSession(root, session), sessionSchema, (s) => {
1768
- const index = s.actions.findIndex((a) => a.id === action);
1769
- if (index < 0) throw new WriteError(`action ${ref} not found`);
1770
- const [current] = s.actions.splice(index, 1);
1771
- const movedTo = HANDED_OFF.exec(current.note ?? "")?.[1];
1772
- if (movedTo && current.status === "dropped" && status !== void 0 && status !== "dropped") throw new WriteError(`${ref} was handed off to ${movedTo}, and references to ${ref} follow its note only while it stays dropped; change ${movedTo} instead`);
1773
- const handedTo = patch.note ? HANDED_OFF.exec(current.note ?? "")?.[1] : void 0;
1774
- if (handedTo) throw new WriteError(`${ref} was handed off to ${handedTo}, and references to ${ref} follow its note there, so the note stays as it is; add the note to ${handedTo} instead`);
1775
- const next = {
1776
- ...current,
1777
- ...status && { status },
1778
- ...patch.do && { do: patch.do },
1779
- ...patch.serves && { serves: patch.serves },
1780
- ...patch.note && { note: patch.note }
1781
- };
1782
- if (patch.blockedBy) {
1783
- if (next.status !== "blocked") throw new WriteError(`--reason, --waiting-for and --until go with --status blocked; ${ref} is ${next.status}`);
1784
- next.blockedBy = patch.blockedBy;
1785
- }
1786
- if (next.status !== "blocked") delete next.blockedBy;
1787
- if (status === "doing" && current.status !== "doing") {
1788
- next.startedAt = nowMinute();
1789
- delete next.doneAt;
1790
- }
1791
- if ((status === "done" || status === "dropped") && current.status !== status) next.doneAt = nowMinute();
1792
- if (status === "todo") {
1793
- delete next.startedAt;
1794
- delete next.doneAt;
1795
- }
1796
- updated = next;
1797
- if (patch.before) place(s.actions, next, patch.before);
1798
- else s.actions.splice(index, 0, next);
1799
- });
1800
- return updated;
1801
- }
1802
- //#endregion
1803
- //#region ../core/dist/briefing.js
1804
- function describe(root, ref) {
1805
- const parsed = parseRef(ref);
1806
- if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") return null;
1807
- try {
1808
- const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
1809
- if (parsed.kind === "criterion") {
1810
- const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
1811
- return criterion ? {
1812
- ref,
1813
- text: criterion.text,
1814
- checked: criterion.done,
1815
- where: `${mainline.id} ${mainline.title} › 解決條件`
1816
- } : null;
1817
- }
1818
- const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
1819
- const item = task.checklist.find((c) => c.id === parsed.item);
1820
- if (!item) return null;
1821
- return {
1822
- ref,
1823
- text: item.text,
1824
- checked: item.done,
1825
- where: `${mainline.id} ${mainline.title} › ${task.id} ${task.title}`
1826
- };
1827
- } catch {
1828
- return null;
1829
- }
1830
- }
1831
- /** The criteria a served checklist item pushes forward, as m{NNN}#r{NNN}; empty for a criterion or an item that cannot be read. */
1832
- function criteriaOf(root, ref) {
1833
- const parsed = parseRef(ref);
1834
- if (parsed?.kind !== "checklistItem") return [];
1835
- try {
1836
- return (readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
1837
- } catch {
1838
- return [];
1839
- }
1840
- }
1841
- /**
1842
- * Where two served lists meet: a reference both serve, or a criterion one serves that a checklist item the other
1843
- * serves pushes forward, named as the item with its criterion. Two items serving the same criterion do not meet,
1844
- * since splitting a criterion into items is how work on it runs in parallel.
1845
- */
1846
- function sharedServes(root, mine, theirs) {
1847
- const shared = mine.filter((ref) => theirs.includes(ref));
1848
- const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(root, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
1849
- const mineCriteria = mine.filter((ref) => parseRef(ref)?.kind === "criterion");
1850
- const theirCriteria = theirs.filter((ref) => parseRef(ref)?.kind === "criterion");
1851
- return [.../* @__PURE__ */ new Set([
1852
- ...shared,
1853
- ...pushes(theirs, mineCriteria),
1854
- ...pushes(mine, theirCriteria)
1855
- ])];
1856
- }
1857
- /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
1858
- function queueWarnings(root, session, cli) {
1859
- const warnings = [];
1860
- const doing = session.actions.filter((a) => a.status === "doing");
1861
- if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
1862
- for (const action of doing) {
1863
- const served = action.serves.map((ref) => describe(root, ref));
1864
- if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c 或 m#r> 改正。`);
1865
- else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選或達成。做完了就執行 ${cli} action set ${action.id} --status done。`);
1866
- }
1867
- const next = session.actions.find((a) => a.status === "todo");
1868
- if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
1869
- return warnings;
1870
- }
1871
- const current = (session) => session.actions.find((a) => a.status === "doing");
1872
- const isOpen = (a) => a.status === "todo" || a.status === "doing";
1873
- /** What a handoff takes over: open actions and blocked ones, which are still unfinished. */
1874
- const isCarried = (a) => isOpen(a) || a.status === "blocked";
1875
- /** A blocked action and what it waits for: the reason, the other party and the condition, as `action set --status blocked` recorded them. */
1876
- function waitingLine(a) {
1877
- const block = a.blockedBy;
1878
- const parts = block ? [
1879
- block.reason,
1880
- ...block.waitingFor ? [`等 ${block.waitingFor}`] : [],
1881
- ...block.until ? [`條件 ${block.until}`] : []
1882
- ] : [];
1883
- return `${a.id} ${a.do}${parts.length > 0 ? `(${parts.join(";")})` : ""}`;
1884
- }
1885
- /** Mainlines a session could be pushing forward. */
1886
- const openMainlines = (root) => loadHub(root).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
1887
- /** How recently a session's transcript must have been written for the session to count as possibly still running. */
1888
- const RUNNING_WINDOW_MS = 9e5;
1889
- /** Whether the session's transcript was written within the running window; false when no transcript is recorded or readable. */
1890
- function mayBeRunning(root, session) {
1891
- if (!session.transcript) return false;
1892
- try {
1893
- return Date.now() - statSync(transcriptPath(root, session.transcript)).mtimeMs < RUNNING_WINDOW_MS;
1894
- } catch {
1895
- return false;
1896
- }
1897
- }
1898
- /** The session and every session in its delegation tree: the sessions that delegated to it, and theirs, and all their subagents. */
1899
- function family(sessions, id) {
1900
- const byId = new Map(sessions.map((s) => [s.id, s]));
1901
- const chain = /* @__PURE__ */ new Set([id]);
1902
- let top = id;
1903
- for (let up = byId.get(id)?.delegatedFrom; up && byId.has(up) && !chain.has(up); up = byId.get(up)?.delegatedFrom) {
1904
- chain.add(up);
1905
- top = up;
1906
- }
1907
- const members = /* @__PURE__ */ new Set([top]);
1908
- for (let grew = true; grew;) {
1909
- grew = false;
1910
- for (const s of sessions) if (s.delegatedFrom && members.has(s.delegatedFrom) && !members.has(s.id)) {
1911
- members.add(s.id);
1912
- grew = true;
1913
- }
1914
- }
1915
- return members.add(id);
1916
- }
1917
- /** The actions in this session that a blocked action elsewhere waits for, named by `waitingFor` or by an `until` of kind action-done. */
1918
- function waitedFor(root, blocked) {
1919
- const until = blocked.blockedBy?.until ? parseUntil(blocked.blockedBy.until) : null;
1920
- const refs = [waitingForAction(blocked.blockedBy?.waitingFor), until?.kind === "action-done" ? `${until.session}#${until.action}` : void 0];
1921
- return [...new Set(refs.filter((r) => r !== void 0).map((r) => resolveAction(root, r).ref))];
1922
- }
1923
- /**
1924
- * One line for each blocked action in another session that waits for an action of this one, followed through
1925
- * handoffs, so the session being waited for sees it without the other side having to send a message. An action
1926
- * already done or dropped has nothing left to deliver, so a wait on it is not shown.
1927
- */
1928
- function waiterLines(root, session) {
1929
- const lines = [];
1930
- for (const other of loadSessions(root).sessions) {
1931
- if (other.id === session.id || other.archivedAt || other.status === "handed-off") continue;
1932
- for (const blocked of other.actions.filter((a) => a.status === "blocked")) for (const ref of waitedFor(root, blocked)) {
1933
- const mine = ref.startsWith(`${session.id}#`) ? session.actions.find((a) => `${session.id}#${a.id}` === ref) : void 0;
1934
- if (!mine || mine.status === "done" || mine.status === "dropped") continue;
1935
- const until = blocked.blockedBy?.until ? `。條件:${blocked.blockedBy.until}` : "";
1936
- lines.push(`有會話在等你:${other.id}#${blocked.id} 等 ${mine.id} ${mine.do}(${blocked.blockedBy.reason})${until}`);
1937
- }
1938
- }
1939
- return lines;
1940
- }
1941
- /**
1942
- * One reminder for each action in progress in another session that may still be running and serves what this
1943
- * action also serves: the same item or criterion, or a criterion and an item that pushes it forward. Sessions in the same delegation tree are left out: a subagent working on the item
1944
- * its parent handed it is not a second claim.
1945
- */
1946
- function duplicateClaimLines(root, sessionId, actionId) {
1947
- const { sessions } = loadSessions(root);
1948
- const action = sessions.find((s) => s.id === sessionId)?.actions.find((a) => a.id === actionId);
1949
- if (!action || action.serves.length === 0) return [];
1950
- const related = family(sessions, sessionId);
1951
- const lines = [];
1952
- for (const other of sessions) {
1953
- if (related.has(other.id) || !mayBeRunning(root, other)) continue;
1954
- for (const theirs of other.actions.filter((a) => a.status === "doing")) {
1955
- const shared = sharedServes(root, action.serves, theirs.serves);
1956
- if (shared.length > 0) lines.push(`⚠ ${other.id}#${theirs.id}(15 分鐘內有活動)也在做 ${shared.join("、")},先協調再開始`);
1957
- }
1958
- }
1959
- return lines;
1960
- }
1961
- /**
1962
- * Lines naming the other top-level sessions that still hold unfinished actions, with the command that takes them
1963
- * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
1964
- */
1965
- function handoffLines(root, session, cli) {
1966
- const others = loadSessions(root).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
1967
- if (others.length === 0) return [];
1968
- return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(root, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isCarried).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
1969
- }
1970
- const servedLine = (root, action) => action.serves.map((ref) => {
1971
- const s = describe(root, ref);
1972
- return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目或條件)`;
1973
- }).join(";") || "沒有指定服務的 checklist 項目或解決條件";
1974
- /**
1975
- * Lines for each pair of overlapping touches that involves a mainline an action in progress serves: the other
1976
- * mainline and task, and the paths they share. Empty when the session serves nothing that overlaps.
1977
- */
1978
- function overlapLines(root, session) {
1979
- const mine = new Set(session.actions.filter((a) => a.status === "doing").flatMap((a) => a.serves.map((ref) => parseRef(ref))).flatMap((parsed) => parsed && "mainline" in parsed ? [parsed.mainline] : []));
1980
- if (mine.size === 0) return [];
1981
- const hub = loadHub(root, { remotes: false });
1982
- const lines = [];
1983
- for (const o of touchOverlaps(hub.mainlines, dependencyGraph(hub).edges)) {
1984
- const [own, other] = mine.has(o.tasks[0].split("/")[0]) ? [0, 1] : mine.has(o.tasks[1].split("/")[0]) ? [1, 0] : [-1, -1];
1985
- if (own < 0) continue;
1986
- const paths = o.paths.map(([a, b]) => own === 0 ? [a, b] : [b, a]).map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join("、");
1987
- const title = hub.mainlines.find((m) => m.id === o.tasks[other].split("/")[0])?.title;
1988
- lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}),兩邊沒有 depends-on`);
1989
- }
1990
- return lines;
1991
- }
1992
- /** Full briefing for the start of a session, after resuming, clearing or compacting. */
1993
- function sessionBriefing(root, session, options) {
1994
- const { cli } = options;
1995
- const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
1996
- const doing = current(session);
1997
- const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
1998
- if (session.actions.length === 0) {
1999
- const active = openMainlines(root);
2000
- lines.push("這場會話還沒有行動。");
2001
- if (active.length > 0) {
2002
- lines.push("進行中的主脈絡:");
2003
- for (const m of active) lines.push(`- ${m.id} ${m.title}`);
2004
- lines.push(`要推進其中一個時,先用 ${cli} status 與它的任務 checklist 決定要做什麼,再列出行動。`);
2005
- }
2006
- } else {
2007
- lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
2008
- if (doing) lines.push(`服務:${servedLine(root, doing)}`);
2009
- const grants = grantLines(root, session);
2010
- if (grants.length > 0) lines.push("目前行動服務的主脈絡預先授權了這些動作:", ...grants);
2011
- if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
2012
- const blocked = session.actions.filter((a) => a.status === "blocked");
2013
- if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
2014
- }
2015
- lines.push(...waiterLines(root, session));
2016
- const overlaps = overlapLines(root, session);
2017
- if (overlaps.length > 0) lines.push("目前行動服務的主脈絡和其他進行中的主脈絡預計動到同樣的路徑,開始改之前先和對方協調,或加 depends-on:", ...overlaps);
2018
- if (!session.actions.some(isOpen)) lines.push(...handoffLines(root, session, cli));
2019
- const warnings = queueWarnings(root, session, cli);
2020
- if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
2021
- lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。主脈絡還沒拆任務時(查核與規劃階段),--serves 指向解決條件 <m#r>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
2022
- return lines.join("\n");
2023
- }
2024
- /**
2025
- * One or two lines for every user turn; null when the session has no actions and the hub has no open mainline.
2026
- * Records a digest of the grants, waits and warnings it lists in the session file, only when the digest changes.
2027
- */
2028
- function turnReminder(root, session, options) {
2029
- if (session.actions.length === 0) return openMainlines(root).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
2030
- const doing = current(session);
2031
- const open = session.actions.filter(isOpen);
2032
- const blocked = session.actions.filter((a) => a.status === "blocked");
2033
- const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length > 0 ? `[mainflow] ${session.id} 目前沒有進行中的行動。` : blocked.length > 0 ? `[mainflow] ${session.id} 的行動在等外部條件:${blocked.map(waitingLine).join(";")}。有條件(until)的可以在背景執行 ${options.cli} wait <a編號> 等它成立。條件出現後用 ${options.cli} action set <a編號> --status doing 繼續;要先做別的事,用 ${options.cli} action add 列出行動。` : `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。`;
2034
- const header = headerLine(session);
2035
- const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
2036
- const grants = grantLines(root, session);
2037
- const waiters = waiterLines(root, session);
2038
- const warnings = queueWarnings(root, session, options.cli);
2039
- const digest = contentHash(JSON.stringify([
2040
- doing?.id ?? null,
2041
- grants,
2042
- waiters,
2043
- warnings
2044
- ]));
2045
- const repeated = session.reminderDigest === digest;
2046
- if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(root, session.id)) try {
2047
- setReminderDigest(root, session.id, digest);
2048
- } catch {}
2049
- const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
2050
- const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
2051
- const waiting = once(waiters, `- 等待行同上一輪,共 ${waiters.length} 條。`, waiters.map((w) => `- ${w}`));
2052
- const warned = once(warnings, `- 佇列提醒同上一輪,共 ${warnings.length} 條。`, warnings.map((w) => `- ${w}`));
2053
- return [
2054
- head,
2055
- ...waiting,
2056
- ...granted,
2057
- ...marker,
2058
- ...warned
2059
- ].join("\n");
2060
- }
2061
- //#endregion
2062
- //#region ../core/dist/waiting.js
2063
- /** Separates commits in the git log output (%x1e); commit messages do not use this control character. */
2064
- const RECORD = "";
2065
- /** Whether git finds a repository at the hub root; false as well when git itself cannot run. */
2066
- function inGitRepository(root) {
2067
- try {
2068
- execFileSync("git", ["rev-parse", "--git-dir"], {
2069
- cwd: root,
2070
- stdio: "ignore"
2071
- });
2072
- return true;
2073
- } catch {
2074
- return false;
2075
- }
2076
- }
2077
- /** Whether a commit on the branch has the text in its message; git runs at the hub root, so the branch is the hub repository's. */
2078
- function commitState(root, branch, text) {
2079
- let log;
2080
- try {
2081
- log = execFileSync("git", [
2082
- "log",
2083
- "--format=%h %s%n%b%x1e",
2084
- branch,
2085
- "--"
2086
- ], {
2087
- cwd: root,
2088
- encoding: "utf8",
2089
- stdio: [
2090
- "ignore",
2091
- "pipe",
2092
- "pipe"
2093
- ],
2094
- maxBuffer: 268435456
2095
- });
2096
- } catch {
2097
- if (!inGitRepository(root)) return {
2098
- met: false,
2099
- unreachable: true,
2100
- detail: `the hub root ${root} is not in a git repository, so no commit can appear there`
2101
- };
2102
- return {
2103
- met: false,
2104
- detail: `branch ${branch} is not in the repository at the hub root yet`
2105
- };
2106
- }
2107
- for (const record of log.split(RECORD)) {
2108
- const entry = record.replace(/^\n/, "");
2109
- const space = entry.indexOf(" ");
2110
- const message = entry.slice(space + 1);
2111
- if (space > 0 && message.includes(text)) return {
2112
- met: true,
2113
- detail: `commit ${entry.slice(0, space)} on ${branch} contains "${text}": ${message.split("\n")[0]}`
2114
- };
2115
- }
2116
- return {
2117
- met: false,
2118
- detail: `no commit on ${branch} contains "${text}"`
2119
- };
2120
- }
2121
- function actionState(root, ref) {
2122
- const found = resolveAction(root, ref);
2123
- if ("missing" in found) return {
2124
- met: false,
2125
- detail: found.missing
2126
- };
2127
- const via = found.ref === ref ? "" : ` (handed off to ${found.ref})`;
2128
- if (found.action.status === "done") return {
2129
- met: true,
2130
- detail: `${ref}${via} is done`
2131
- };
2132
- if (found.action.status === "dropped") return {
2133
- met: false,
2134
- unreachable: true,
2135
- detail: `${ref}${via} was dropped without a handoff, so it will never be done`
2136
- };
2137
- return {
2138
- met: false,
2139
- detail: `${ref}${via} is ${found.action.status}`
2140
- };
2141
- }
2142
- /** Check a wait condition once. */
2143
- function untilState(root, until) {
2144
- switch (until.kind) {
2145
- case "commit": return commitState(root, until.branch, until.text);
2146
- case "action-done": return actionState(root, `${until.session}#${until.action}`);
2147
- case "file": return existsSync(resolve(root, until.path)) ? {
2148
- met: true,
2149
- detail: `${until.path} exists`
2150
- } : {
2151
- met: false,
2152
- detail: `${until.path} does not exist yet`
2153
- };
2154
- }
2155
- }
2156
- //#endregion
2157
- //#region ../core/dist/write.js
2158
- function checkTaskLinks(root, mainline, task) {
2159
- 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`);
2160
- if (task.parent) {
2161
- if (task.parent === task.id) throw new WriteError(`task ${task.id} cannot be its own parent`);
2162
- taskDir(root, mainline, task.parent);
2163
- }
2164
- }
2165
- function initHub(root, hub) {
2166
- const dataDir = join(root, DATA_DIR);
2167
- if (existsSync(join(dataDir, "config.yaml"))) throw new WriteError(`${dataDir} already holds a hub`);
2168
- const config = validate(configSchema, {
2169
- hub,
2170
- formatVersion: 2,
2171
- repos: {}
2172
- }, "config.yaml");
2173
- mkdirSync(join(dataDir, "mainlines"), { recursive: true });
2174
- writeFileSync(join(dataDir, "config.yaml"), toYaml(config));
2175
- writeFileSync(join(dataDir, ".gitignore"), "local/\n**/scratch/\n!ops/*.log\n");
2176
- return dataDir;
2177
- }
2178
- /** Register a repository the hub's tasks may point at; `path` is relative to the hub root. */
2179
- function addRepo(root, name, path) {
2180
- requireSlug(name);
2181
- const target = resolve(root, path);
2182
- if (!existsSync(target) || !statSync(target).isDirectory()) throw new WriteError(`repo path ${path} does not exist from the hub root (${target})`);
2183
- mutateYaml(root, join(root, DATA_DIR, "config.yaml"), configSchema, (c) => {
2184
- if (c.repos[name] !== void 0 && c.repos[name] !== path) throw new WriteError(`repo "${name}" is already registered as ${c.repos[name]}`);
2185
- c.repos[name] = path;
2186
- });
2187
- return name;
2188
- }
2189
- /**
2190
- * Register another hub whose documents this hub's references may point at, as `{name}:m001`. `path` is
2191
- * relative to this hub's root and must hold that hub, registered under its own name, so a reference reads
2192
- * the same in every hub that mentions it.
2193
- */
2194
- function addHub(root, name, path) {
2195
- requireSlug(name);
2196
- if (RESERVED_HUB_NAMES.includes(name)) throw new WriteError(`${RESERVED_HUB_NAMES.join(" and ")} are reserved and cannot name a hub`);
2197
- const target = resolve(root, path);
2198
- if (name === readConfig(root).hub) throw new WriteError(`"${name}" is this hub's own name; references without a prefix already point here`);
2199
- if (!existsSync(join(target, ".mainflow", "config.yaml"))) throw new WriteError(`no hub at ${path} from the hub root (${target})`);
2200
- const other = readConfig(target).hub;
2201
- if (other !== name) throw new WriteError(`${path} holds hub "${other}"; register it as "${other}"`);
2202
- mutateYaml(root, join(root, DATA_DIR, "config.yaml"), configSchema, (c) => {
2203
- const hubs = c.hubs ??= {};
2204
- if (hubs[name] !== void 0 && hubs[name] !== path) throw new WriteError(`hub "${name}" is already registered as ${hubs[name]}`);
2205
- hubs[name] = path;
2206
- });
2207
- return name;
2208
- }
2209
- /** Stamp closedAt when a status closes the entity and clear it when the entity reopens. */
2210
- function stampClosed(doc, closed, at) {
2211
- if (closed) doc.closedAt ??= at;
2212
- else delete doc.closedAt;
2213
- }
2214
- const closes = (kind, status) => CLOSED_STATUS[kind].includes(status);
2215
- function createMainline(root, input) {
2216
- requireSlug(input.slug);
2217
- return withLock(root, () => {
2218
- const id = nextId("m", idsIn(mainlinesDir(root)));
2219
- const dir = join(mainlinesDir(root), `${id}-${input.slug}`);
2220
- const mainline = validate(mainlineSchema, {
2221
- id,
2222
- title: input.title,
2223
- status: "planning",
2224
- created: today(),
2225
- criteria: []
2226
- }, MAINLINE_FILE);
2227
- mkdirSync(join(dir, "tasks"), { recursive: true });
2228
- writeAtomic(join(dir, MAINLINE_FILE), frontMatterText(mainline, MAINLINE_BODY));
2229
- return {
2230
- id,
2231
- dir
2232
- };
2233
- });
2234
- }
2235
- /** Decisions of a mainline still waiting for a ruling, by id. */
2236
- function proposedDecisions(root, id) {
2237
- return listFiles(join(mainlineDir(root, id), "decisions")).filter((file) => file.endsWith(".md")).map((file) => splitFrontMatter(readText(file) ?? "").data).filter((d) => d?.status === "proposed").map((d) => d.id ?? "?");
2238
- }
2239
- /**
2240
- * Marking a mainline done is a claim that its criteria hold and nothing is left to decide, so it is
2241
- * refused while a criterion is still open or a decision is still proposed. The message names each one and
2242
- * the ways to settle it; dropping the mainline instead is not checked.
2243
- */
2244
- function requireDone(root, id, m) {
2245
- const open = m.criteria.filter((c) => !c.done && !c.dropped).map((c) => `${id}#${c.id}`);
2246
- const proposed = proposedDecisions(root, id).map((d) => `${id}/${d}`);
2247
- if (!open.length && !proposed.length) return;
2248
- throw new WriteError(`${id} cannot be marked done: ${[open.length ? `unmet criteria ${open.join(", ")} (check each with evidence, e.g. criterion check ${open[0]} --evidence <commit>, or reword or drop it in ${MAINLINE_FILE})` : "", proposed.length ? `proposed decisions ${proposed.join(", ")} (decision accept or decision supersede first)` : ""].filter(Boolean).join("; ")}`);
2249
- }
2250
- /** Move a mainline to another status; done is refused while criteria are open or decisions are proposed. */
2251
- function setMainlineStatus(root, id, status, expectedVersion) {
2252
- requireRef(id, "mainline");
2253
- const next = requireOneOf(status, MAINLINE_STATUS, "mainline status");
2254
- return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
2255
- if (next === "done" && m.status !== "done") requireDone(root, id, m);
2256
- m.status = next;
2257
- stampClosed(m, closes("mainline", next), today());
2258
- }, expectedVersion);
2259
- }
2260
- /** Rename a mainline. References use its ID, so they keep resolving and show the new title. */
2261
- function setMainlineTitle(root, id, title, expectedVersion) {
2262
- requireRef(id, "mainline");
2263
- const next = title.replace(/\s+/g, " ").trim();
2264
- if (!next) throw new WriteError(`${id} needs a title, e.g. "mainflow 改發布到 npm 公開套件"`);
2265
- return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
2266
- m.title = next;
2267
- }, expectedVersion);
2268
- }
2269
- function setArchived(root, id, archived, expectedVersion) {
2270
- requireRef(id, "mainline");
2271
- return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
2272
- if (archived) m.archivedAt ??= today();
2273
- else delete m.archivedAt;
2274
- }, expectedVersion);
2275
- }
2276
- function setCriterion(root, refInput, done, evidence, expectedVersion) {
2277
- const ref = requireRef(refInput, "criterion");
2278
- if (done && !evidence) throw new WriteError(`marking ${refInput} as met needs evidence, e.g. --evidence mainflow@25e4165`);
2279
- return mutateFrontMatter(root, mainlineFile(root, ref.mainline), mainlineSchema, (m) => {
2280
- const criterion = m.criteria.find((c) => c.id === ref.criterion);
2281
- if (!criterion) throw new WriteError(`criterion ${refInput} not found`);
2282
- setListItem(criterion, done, evidence, `${refInput} is dropped; remove dropped and supersededBy in ${MAINLINE_FILE} to bring it back before checking it`);
2283
- }, expectedVersion);
2284
- }
2285
- /** Rewrite a criterion's text or doneWhen, leaving done and evidence as they are. */
2286
- function setCriterionText(root, refInput, patch, expectedVersion) {
2287
- const ref = requireRef(refInput, "criterion");
2288
- 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"`);
2289
- return mutateFrontMatter(root, mainlineFile(root, ref.mainline), mainlineSchema, (m) => {
2290
- const criterion = m.criteria.find((c) => c.id === ref.criterion);
2291
- if (!criterion) throw new WriteError(`criterion ${refInput} not found`);
2292
- if (patch.text !== void 0) criterion.text = patch.text;
2293
- if (patch.doneWhen !== void 0) criterion.doneWhen = patch.doneWhen;
2294
- }, expectedVersion);
2295
- }
2296
- /**
2297
- * Check or uncheck a criterion or checklist item. A dropped item cannot be checked, because a drop is a
2298
- * ruling that the item is no longer needed; `droppedHint` says how to bring it back. Neither direction
2299
- * touches dropped or supersededBy.
2300
- */
2301
- function setListItem(item, done, evidence, droppedHint) {
2302
- if (done && item.dropped) throw new WriteError(droppedHint);
2303
- item.done = done;
2304
- if (done) {
2305
- item.evidence = evidence;
2306
- item.doneAt = today();
2307
- } else {
2308
- delete item.evidence;
2309
- delete item.doneAt;
2310
- }
2311
- }
2312
- /** Tasks are written after what already exists has been checked, so a mainline without a full 現況查核 gets none. */
2313
- function requireSurvey(root, mainline) {
2314
- const gaps = surveyGaps(splitFrontMatter(readText(mainlineFile(root, mainline)) ?? "").body);
2315
- if (gaps.length === 0) return;
2316
- throw new WriteError([
2317
- `${mainline} needs a filled "## ${SURVEY_SECTION}" before its tasks are created:`,
2318
- ...gaps.map((g) => `- ${g}`),
2319
- "Write one line per source in mainline.md:",
2320
- SURVEY_TEMPLATE
2321
- ].join("\n"));
2322
- }
2323
- function createTask(root, mainline, input) {
2324
- requireRef(mainline, "mainline");
2325
- requireSlug(input.slug);
2326
- const kind = requireOneOf(input.kind ?? "phase", ["phase", "branch"], "task kind");
2327
- return withLock(root, () => {
2328
- requireSurvey(root, mainline);
2329
- const tasksDir = join(mainlineDir(root, mainline), "tasks");
2330
- const id = nextId("t", idsIn(tasksDir));
2331
- const task = validate(taskSchema, {
2332
- id,
2333
- title: input.title,
2334
- kind,
2335
- status: "todo",
2336
- created: today(),
2337
- checklist: [],
2338
- ...input.parent ? { parent: input.parent } : {},
2339
- ...input.repo ? { repo: input.repo } : {}
2340
- }, TASK_FILE);
2341
- checkTaskLinks(root, mainline, task);
2342
- const dir = join(tasksDir, `${id}-${input.slug}`);
2343
- mkdirSync(dir, { recursive: true });
2344
- writeAtomic(join(dir, TASK_FILE), frontMatterText(task, TASK_BODY));
2345
- return {
2346
- ref: `${mainline}/${id}`,
2347
- dir
2348
- };
2349
- });
2350
- }
2351
- function updateTask(root, refInput, patch, expectedVersion) {
2352
- const ref = requireRef(refInput, "task");
2353
- const status = patch.status === void 0 ? void 0 : requireOneOf(patch.status, WORK_STATUS, "task status");
2354
- const merge = patch.merge === void 0 ? void 0 : requireOneOf(patch.merge, MERGE_STATES, "merge state");
2355
- if (status === "blocked" && !patch.blockedBy?.reason) throw new WriteError(`blocking ${refInput} needs a reason, e.g. --reason "等使用者提供 API key" --waiting-for 使用者`);
2356
- return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2357
- if (status !== void 0) {
2358
- t.status = status;
2359
- if (status === "doing") t.startedAt ??= today();
2360
- stampClosed(t, closes("task", status), today());
2361
- if (status !== "blocked") delete t.blockedBy;
2362
- }
2363
- if (patch.repo !== void 0) {
2364
- if (patch.repo) t.repo = patch.repo;
2365
- else delete t.repo;
2366
- }
2367
- if (patch.blockedBy !== void 0) t.blockedBy = patch.blockedBy;
2368
- if (patch.branch !== void 0) t.branch = patch.branch;
2369
- if (patch.worktree !== void 0) t.worktree = patch.worktree;
2370
- if (patch.base !== void 0) t.base = patch.base;
2371
- if (merge !== void 0) t.merge = merge;
2372
- checkTaskLinks(root, ref.mainline, t);
2373
- }, expectedVersion);
2374
- }
2375
- function setChecklistItem(root, refInput, done, evidence, expectedVersion) {
2376
- const ref = requireRef(refInput, "checklistItem");
2377
- if (done && !evidence) throw new WriteError(`checking ${refInput} needs evidence, e.g. --evidence mainflow@25e4165`);
2378
- return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2379
- const item = t.checklist.find((c) => c.id === ref.item);
2380
- if (!item) throw new WriteError(`checklist item ${refInput} not found`);
2381
- setListItem(item, done, evidence, `${refInput} is dropped; run item undrop ${refInput} to bring it back before checking it`);
2382
- }, expectedVersion);
2383
- }
2384
- /** Mark an item as no longer needed, naming what replaced it when something did. */
2385
- function dropChecklistItem(root, refInput, input = {}, expectedVersion) {
2386
- const ref = requireRef(refInput, "checklistItem");
2387
- return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2388
- const item = t.checklist.find((c) => c.id === ref.item);
2389
- if (!item) throw new WriteError(`checklist item ${refInput} not found`);
2390
- if (item.done) throw new WriteError(`${refInput} is checked; uncheck it before dropping it`);
2391
- item.dropped = true;
2392
- if (input.supersededBy) item.supersededBy = input.supersededBy;
2393
- if (input.note) item.note = input.note;
2394
- }, expectedVersion);
2395
- }
2396
- /** Bring a dropped item back as an open one: clears dropped and supersededBy, keeps the note. */
2397
- function undropChecklistItem(root, refInput, expectedVersion) {
2398
- const ref = requireRef(refInput, "checklistItem");
2399
- return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
2400
- const item = t.checklist.find((c) => c.id === ref.item);
2401
- if (!item) throw new WriteError(`checklist item ${refInput} not found`);
2402
- if (!item.dropped) throw new WriteError(`${refInput} is not dropped`);
2403
- delete item.dropped;
2404
- delete item.supersededBy;
2405
- item.done = false;
2406
- delete item.evidence;
2407
- delete item.doneAt;
2408
- }, expectedVersion);
2409
- }
2410
- function createDecision(root, mainline, input) {
2411
- requireRef(mainline, "mainline");
2412
- requireSlug(input.slug);
2413
- const status = requireOneOf(input.status ?? "accepted", ["proposed", "accepted"], "new decision status");
2414
- const by = input.by === void 0 ? void 0 : requireOneOf(input.by, DECIDERS, "decided by");
2415
- const basis = input.basis === void 0 ? void 0 : requireOneOf(input.basis, DECISION_BASES, "decision basis");
2416
- for (const ref of input.affects ?? []) requireAffected(root, mainline, ref);
2417
- return withLock(root, () => {
2418
- const dir = join(mainlineDir(root, mainline), "decisions");
2419
- mkdirSync(dir, { recursive: true });
2420
- const id = nextId("d", idsIn(dir));
2421
- const at = nowMinute();
2422
- const decision = validate(decisionSchema, {
2423
- id,
2424
- title: input.title,
2425
- created: at,
2426
- status,
2427
- ...status === "accepted" ? { decidedAt: at } : {},
2428
- ...by ? { by } : {},
2429
- ...basis ? { basis } : {},
2430
- ...input.affects?.length ? { affects: input.affects } : {}
2431
- }, "decision");
2432
- const file = join(dir, `${id}-${input.slug}.md`);
2433
- writeAtomic(file, frontMatterText(decision, DECISION_BODY));
2434
- return {
2435
- ref: `${mainline}/${id}`,
2436
- file
2437
- };
2438
- });
2439
- }
2440
- /** A decision affects tasks or checklist items of its own mainline, and they must exist. */
2441
- function requireAffected(root, mainline, input) {
2442
- const ref = parseRefIn(input);
2443
- if (!ref || ref.mainline !== mainline) throw new WriteError(`--affects takes tasks or checklist items of ${mainline}, like ${mainline}/t001 or ${mainline}/t001#c002; got "${input}"`);
2444
- taskDir(root, ref.mainline, ref.task);
2445
- }
2446
- const parseRefIn = (input) => {
2447
- const task = /^(m\d{3})\/(t\d{3})(?:#c\d{3})?$/.exec(input);
2448
- return task ? {
2449
- mainline: task[1],
2450
- task: task[2]
2451
- } : null;
2452
- };
2453
- /** Settle a proposed decision: record who decided, how and when; the items it affects stop waiting for it. */
2454
- function acceptDecision(root, refInput, settlement = {}, expectedVersion) {
2455
- const ref = requireRef(refInput, "decision");
2456
- const decider = settlement.by === void 0 ? void 0 : requireOneOf(settlement.by, DECIDERS, "decided by");
2457
- const basis = settlement.basis === void 0 ? void 0 : requireOneOf(settlement.basis, DECISION_BASES, "decision basis");
2458
- return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
2459
- if (d.status !== "proposed") throw new WriteError(`${refInput} is ${d.status}; only a proposed decision can be accepted`);
2460
- d.status = "accepted";
2461
- d.decidedAt = nowMinute();
2462
- if (decider) d.by = decider;
2463
- if (basis) d.basis = basis;
2464
- }, expectedVersion);
2465
- }
2466
- function supersedeDecision(root, refInput, by, expectedVersion) {
2467
- const ref = requireRef(refInput, "decision");
2468
- const successor = requireRef(by.includes("/") ? by : `${ref.mainline}/${by}`, "decision");
2469
- if (successor.mainline !== ref.mainline) throw new WriteError(`a decision can only be superseded within its own mainline (${ref.mainline})`);
2470
- if (successor.decision === ref.decision) throw new WriteError(`decision ${refInput} cannot supersede itself`);
2471
- decisionFile(root, successor.mainline, successor.decision);
2472
- return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
2473
- d.status = "superseded";
2474
- d.supersededBy = successor.decision;
2475
- stampClosed(d, true, nowMinute());
2476
- }, expectedVersion);
2477
- }
2478
- //#endregion
2479
- //#region ../core/dist/archive.js
2480
- /** The entities that carry `archivedAt`; tasks and decisions follow their mainline instead. */
2481
- const ARCHIVE_KINDS = [
2482
- "mainline",
2483
- "issue",
2484
- "finding",
2485
- "session"
2486
- ];
2487
- const ARCHIVERS = {
2488
- mainline: setArchived,
2489
- issue: setIssueArchived,
2490
- finding: setFindingArchived,
2491
- session: setSessionArchived
2492
- };
2493
- /**
2494
- * Archive or unarchive several entities of one kind. Each file is written on its own: one that fails, such
2495
- * as an ID that no longer exists, is reported and the others still change, because a selection in the
2496
- * interface can go stale while it is open.
2497
- */
2498
- function setArchivedMany(root, kind, ids, archived) {
2499
- const archiver = ARCHIVERS[kind];
2500
- if (!archiver) throw new WriteError(`kind must be one of: ${ARCHIVE_KINDS.join(", ")}`);
2501
- const result = {
2502
- done: [],
2503
- failed: []
2504
- };
2505
- for (const id of new Set(ids)) try {
2506
- archiver(root, id, archived);
2507
- result.done.push(id);
2508
- } catch (error) {
2509
- result.failed.push({
2510
- id,
2511
- error: error instanceof Error ? error.message : String(error)
2512
- });
2513
- }
2514
- return result;
2515
- }
2516
- //#endregion
2517
- //#region ../core/dist/retro.js
2518
- /** Where the prices come from and when they were read; every figure is USD per million tokens. */
2519
- const PRICING_SOURCE = {
2520
- url: "https://platform.claude.com/docs/en/about-claude/pricing",
2521
- date: "2026-09-30",
2522
- unit: "USD/MTok"
2523
- };
2524
- const price = (input, cacheWrite5m, cacheWrite1h, cacheRead, output) => ({
2525
- input,
2526
- cacheWrite5m,
2527
- cacheWrite1h,
2528
- cacheRead,
2529
- output
2530
- });
2531
- /**
2532
- * List prices by model id prefix. A model matches a prefix when the rest of its id is empty, a date suffix
2533
- * (-20251001) or a bracketed variant ([1m]); any other model has no known price, so its cost is not guessed.
2534
- */
2535
- const MODEL_PRICES = [
2536
- {
2537
- prefix: "claude-opus-5-5",
2538
- price: price(4, 5, 8, .2, 20)
2539
- },
2540
- {
2541
- prefix: "claude-opus-5",
2542
- price: price(5, 6.25, 10, .5, 25)
2543
- },
2544
- {
2545
- prefix: "claude-opus-4-8",
2546
- price: price(5, 6.25, 10, .5, 25)
2547
- },
2548
- {
2549
- prefix: "claude-sonnet-5-5",
2550
- price: price(2, 2.5, 4, .2, 10)
2551
- },
2552
- {
2553
- prefix: "claude-sonnet-5",
2554
- price: price(2, 2.5, 4, .2, 10)
2555
- },
2556
- {
2557
- prefix: "claude-sonnet-4-6",
2558
- price: price(3, 3.75, 6, .3, 15)
2559
- },
2560
- {
2561
- prefix: "claude-haiku-4-5",
2562
- price: price(1, 1.25, 2, .1, 5)
2563
- },
2564
- {
2565
- prefix: "claude-fable-5-1",
2566
- price: price(10, 12.5, 20, .25, 50)
2567
- }
2568
- ];
2569
- /**
2570
- * Prices at speed "fast", keyed by the MODEL_PRICES prefix. The cache multipliers stack on the fast input
2571
- * price (5m write 1.25x, 1h write 2x, read 0.05x for Opus 5.5 and 0.1x otherwise), as with standard speed.
2572
- */
2573
- const FAST_PRICES = {
2574
- "claude-opus-5-5": price(8, 10, 16, .4, 40),
2575
- "claude-opus-5": price(10, 12.5, 20, 1, 50),
2576
- "claude-opus-4-8": price(10, 12.5, 20, 1, 50)
2577
- };
2578
- /** Inference kept in the US (inference_geo "us") is billed at 1.1 times the list price. */
2579
- const US_GEO_MULTIPLIER = 1.1;
2580
- const PRICE_SUFFIX = /^(?:-\d{8})?(?:\[[^\]]*\])?$/;
2581
- /** The price entry a model id falls under: the longest prefix whose remainder is only a date or a variant. */
2582
- function priceEntry(model) {
2583
- return MODEL_PRICES.filter((p) => model.startsWith(p.prefix) && PRICE_SUFFIX.test(model.slice(p.prefix.length))).sort((a, b) => b.prefix.length - a.prefix.length)[0];
2584
- }
2585
- const priceOf = (model) => priceEntry(model)?.price;
2586
- const emptyUsage = () => ({
2587
- input: 0,
2588
- cacheWrite5m: 0,
2589
- cacheWrite1h: 0,
2590
- cacheRead: 0,
2591
- output: 0
2592
- });
2593
- const addUsage = (to, from) => {
2594
- for (const key of Object.keys(to)) to[key] += from[key];
2595
- };
2596
- /** Everything the model read before answering: fresh input, cache writes and cache reads. */
2597
- const contextTokens = (u) => u.input + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
2598
- /** Share of the context served from the cache; null when there was no context at all. */
2599
- const cacheHitRate = (u) => contextTokens(u) > 0 ? u.cacheRead / contextTokens(u) : null;
2600
- const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0;
2601
- /** Read message.usage; cache writes without the 5m and 1h split count as 5m, the default cache lifetime. */
2602
- function readUsage(raw) {
2603
- const created = num(raw.cache_creation_input_tokens);
2604
- const split = raw.cache_creation ?? {};
2605
- const w1h = num(split.ephemeral_1h_input_tokens);
2606
- const w5m = split.ephemeral_5m_input_tokens === void 0 ? created - w1h : num(split.ephemeral_5m_input_tokens);
2607
- return {
2608
- input: num(raw.input_tokens),
2609
- cacheWrite5m: Math.max(0, w5m),
2610
- cacheWrite1h: w1h,
2611
- cacheRead: num(raw.cache_read_input_tokens),
2612
- output: num(raw.output_tokens)
2613
- };
2614
- }
2615
- /** The estimated cost of one message in USD, or null when the model has no known price. */
2616
- function messageCost(model, usage, speed, geo) {
2617
- const entry = priceEntry(model);
2618
- if (!entry) return null;
2619
- const p = (speed === "fast" ? FAST_PRICES[entry.prefix] : void 0) ?? entry.price;
2620
- const usd = (usage.input * p.input + usage.cacheWrite5m * p.cacheWrite5m + usage.cacheWrite1h * p.cacheWrite1h + usage.cacheRead * p.cacheRead + usage.output * p.output) / 1e6;
2621
- return geo === "us" ? usd * US_GEO_MULTIPLIER : usd;
2622
- }
2623
- const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2624
- const blocks = (content) => Array.isArray(content) ? content.filter(isObject) : [];
2625
- /** The text a hook attachment hands to the model, empty when it hands none. */
2626
- function hookText(a) {
2627
- const parts = [];
2628
- if (typeof a.content === "string") parts.push(a.content);
2629
- if (Array.isArray(a.content)) parts.push(...a.content.filter((c) => typeof c === "string"));
2630
- const blocking = a.blockingError;
2631
- if (typeof blocking === "string") parts.push(blocking);
2632
- if (isObject(blocking) && typeof blocking.blockingError === "string") parts.push(blocking.blockingError);
2633
- return parts.join("");
2634
- }
2635
- const hookEvent = (a) => typeof a.hookEvent === "string" && a.hookEvent ? a.hookEvent : typeof a.hookName === "string" && a.hookName ? a.hookName.split(":")[0] : "unknown";
2636
- const emptyStats = (file, found) => ({
2637
- file,
2638
- found,
2639
- versions: [],
2640
- lines: 0,
2641
- unreadable: 0,
2642
- messages: [],
2643
- toolUses: 0,
2644
- toolErrors: {},
2645
- bashRuns: {},
2646
- hooks: [],
2647
- misfires: {},
2648
- gaps: []
2649
- });
2650
- function readTranscript(file) {
2651
- const stats = emptyStats(file, existsSync(file));
2652
- if (!stats.found) return stats;
2653
- const versions = /* @__PURE__ */ new Set();
2654
- const byId = /* @__PURE__ */ new Map();
2655
- const toolNames = /* @__PURE__ */ new Map();
2656
- const hooks = /* @__PURE__ */ new Map();
2657
- /** Injections still waiting for the next assistant message, with the message they came after */
2658
- let pending = [];
2659
- const bound = [];
2660
- let lastAssistant;
2661
- let lastAt;
2662
- let lastAtText = "";
2663
- let anonymous = 0;
2664
- for (const text of readFileSync(file, "utf8").split("\n")) {
2665
- if (!text.trim()) continue;
2666
- stats.lines += 1;
2667
- let line;
2668
- try {
2669
- const parsed = JSON.parse(text);
2670
- if (!isObject(parsed)) throw new Error("not an object");
2671
- line = parsed;
2672
- } catch {
2673
- stats.unreadable += 1;
2674
- continue;
2675
- }
2676
- if (typeof line.version === "string") versions.add(line.version);
2677
- const at = typeof line.timestamp === "string" ? Date.parse(line.timestamp) : NaN;
2678
- if (!Number.isNaN(at)) {
2679
- if (lastAt !== void 0 && at - lastAt > 3e5) {
2680
- const turnEnded = lastAssistant !== void 0 && byId.get(lastAssistant)?.toolUses === 0;
2681
- stats.gaps.push({
2682
- from: lastAtText,
2683
- to: line.timestamp,
2684
- minutes: Math.round((at - lastAt) / 6e4),
2685
- waitingForUser: turnEnded
2686
- });
2687
- }
2688
- if (lastAt === void 0 || at > lastAt) {
2689
- lastAt = at;
2690
- lastAtText = line.timestamp;
2691
- }
2692
- }
2693
- if (line.type === "assistant") {
2694
- const m = line.message;
2695
- if (!isObject(m)) {
2696
- stats.unreadable += 1;
2697
- continue;
2698
- }
2699
- const id = typeof m.id === "string" && m.id ? m.id : `(no id ${++anonymous})`;
2700
- let message = byId.get(id);
2701
- if (!message) {
2702
- message = {
2703
- id,
2704
- model: typeof m.model === "string" ? m.model : "unknown",
2705
- usage: emptyUsage(),
2706
- toolUses: 0,
2707
- ...typeof line.timestamp === "string" ? { at: line.timestamp } : {}
2708
- };
2709
- byId.set(id, message);
2710
- stats.messages.push(message);
2711
- }
2712
- if (isObject(m.usage)) {
2713
- const usage = readUsage(m.usage);
2714
- for (const key of Object.keys(usage)) message.usage[key] = Math.max(message.usage[key], usage[key]);
2715
- if (typeof m.usage.speed === "string") message.speed = m.usage.speed;
2716
- if (typeof m.usage.inference_geo === "string") message.geo = m.usage.inference_geo;
2717
- }
2718
- for (const block of blocks(m.content)) {
2719
- if (block.type !== "tool_use") continue;
2720
- message.toolUses += 1;
2721
- stats.toolUses += 1;
2722
- const name = typeof block.name === "string" ? block.name : "unknown";
2723
- if (typeof block.id === "string") toolNames.set(block.id, name);
2724
- const input = isObject(block.input) ? block.input : {};
2725
- if (name === "Bash" && typeof input.command === "string") {
2726
- const command = input.command.trim();
2727
- stats.bashRuns[command] = (stats.bashRuns[command] ?? 0) + 1;
2728
- }
2729
- }
2730
- if (id !== lastAssistant) {
2731
- for (const p of pending.filter((x) => x.after !== id)) bound.push({
2732
- event: p.event,
2733
- message: id
2734
- });
2735
- pending = pending.filter((x) => x.after === id);
2736
- }
2737
- lastAssistant = id;
2738
- continue;
2739
- }
2740
- if (line.type === "user" && line.message !== void 0) {
2741
- for (const block of blocks(line.message?.content)) {
2742
- if (block.type !== "tool_result" || block.is_error !== true) continue;
2743
- const name = typeof block.tool_use_id === "string" && toolNames.get(block.tool_use_id) || "unknown";
2744
- stats.toolErrors[name] = (stats.toolErrors[name] ?? 0) + 1;
2745
- }
2746
- continue;
2747
- }
2748
- if (line.type === "attachment") {
2749
- const a = line.attachment;
2750
- if (!isObject(a)) {
2751
- stats.unreadable += 1;
2752
- continue;
2753
- }
2754
- const kind = typeof a.type === "string" ? a.type : "";
2755
- if (!kind.includes("hook")) continue;
2756
- const event = hookEvent(a);
2757
- const hook = hooks.get(event) ?? {
2758
- event,
2759
- injections: 0,
2760
- chars: 0,
2761
- blocked: 0
2762
- };
2763
- hooks.set(event, hook);
2764
- if (kind.includes("blocking")) hook.blocked += 1;
2765
- const injected = hookText(a);
2766
- if (injected.length > 0) {
2767
- hook.injections += 1;
2768
- hook.chars += [...injected].length;
2769
- pending.push({
2770
- event,
2771
- after: lastAssistant
2772
- });
2773
- }
2774
- }
2775
- }
2776
- const seen = /* @__PURE__ */ new Set();
2777
- for (const b of bound) {
2778
- const key = `${b.event}\u0000${b.message}`;
2779
- if (seen.has(key)) continue;
2780
- seen.add(key);
2781
- if (byId.get(b.message)?.toolUses === 0) stats.misfires[b.event] = (stats.misfires[b.event] ?? 0) + 1;
2782
- }
2783
- stats.versions = [...versions].sort();
2784
- stats.hooks = [...hooks.values()];
2785
- return stats;
2786
- }
2787
- /** Whether any action of the session serves the mainline, through a checklist item or a criterion. */
2788
- const servesMainline = (session, mainline) => session.actions.some((a) => a.serves.some((ref) => {
2789
- const parsed = parseRef(ref);
2790
- return parsed !== null && parsed.hub === void 0 && refMainline(parsed) === mainline;
2791
- }));
2792
- /** Subagent transcripts Claude Code keeps beside a session's: <dir>/<session>/subagents/*.jsonl. */
2793
- function subagentFiles(transcript) {
2794
- const dir = join(transcript.replace(/\.jsonl$/, ""), "subagents");
2795
- try {
2796
- return readdirSync(dir).filter((n) => n.endsWith(".jsonl")).sort().map((n) => join(dir, n));
2797
- } catch {
2798
- return [];
2799
- }
2800
- }
2801
- function readMeta(file) {
2802
- try {
2803
- const meta = JSON.parse(readFileSync(file.replace(/\.jsonl$/, ".meta.json"), "utf8"));
2804
- if (!isObject(meta)) return {};
2805
- return {
2806
- ...typeof meta.agentType === "string" ? { agentType: meta.agentType } : {},
2807
- ...typeof meta.description === "string" ? { description: meta.description } : {}
2808
- };
2809
- } catch {
2810
- return {};
2811
- }
2812
- }
2813
- function summarize(stats) {
2814
- const usage = emptyUsage();
2815
- let cost = 0;
2816
- const unknown = /* @__PURE__ */ new Set();
2817
- const priced = stats.messages.filter((m) => m.model !== "<synthetic>");
2818
- for (const m of priced) {
2819
- addUsage(usage, m.usage);
2820
- const c = messageCost(m.model, m.usage, m.speed, m.geo);
2821
- if (c === null) unknown.add(m.model);
2822
- else cost += c;
2823
- }
2824
- const first = priced.find((m) => contextTokens(m.usage) > 0);
2825
- return {
2826
- file: stats.file,
2827
- found: stats.found,
2828
- calls: priced.length,
2829
- usage,
2830
- cost,
2831
- unknownPriceModels: [...unknown].sort(),
2832
- models: [...new Set(priced.map((m) => m.model))].sort(),
2833
- openingContext: first ? contextTokens(first.usage) : null,
2834
- cacheHitRate: cacheHitRate(usage)
2835
- };
2836
- }
2837
- /** Lines of a task's 紀錄 section that record a completion check that did not pass. */
2838
- const FAILED_CHECK = (line) => line.includes("完成檢查") && line.includes("未過");
2839
- /**
2840
- * Read the transcripts of every session with an action serving the mainline, and their subagents', into cost
2841
- * figures and candidates for where the work went wrong. Each transcript is read once, even when a subagent session
2842
- * serves the mainline as well.
2843
- */
2844
- function retroReport(root, mainlineId) {
2845
- const mainline = loadHub(root, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
2846
- if (!mainline) throw new WriteError(`mainline ${mainlineId} not found; run "mainflow status --all" to list mainlines`);
2847
- const serving = loadSessions(root).sessions.filter((s) => servesMainline(s, mainlineId));
2848
- const read = /* @__PURE__ */ new Map();
2849
- const stats = (file) => {
2850
- let s = read.get(file);
2851
- if (!s) read.set(file, s = readTranscript(file));
2852
- return s;
2853
- };
2854
- const sessions = [];
2855
- const subagents = [];
2856
- const labels = /* @__PURE__ */ new Map();
2857
- const subagentFilesSeen = /* @__PURE__ */ new Set();
2858
- const addSubagent = (file, parent) => {
2859
- if (subagentFilesSeen.has(file)) return;
2860
- subagentFilesSeen.add(file);
2861
- labels.set(file, `${parent} › ${basename(file, ".jsonl")}`);
2862
- subagents.push({
2863
- ...summarize(stats(file)),
2864
- parent,
2865
- ...readMeta(file)
2866
- });
2867
- };
2868
- for (const s of [...serving].sort((a, b) => Number(Boolean(a.delegatedFrom)) - Number(Boolean(b.delegatedFrom)))) {
2869
- const file = s.transcript ? transcriptPath(root, s.transcript) : void 0;
2870
- if (s.delegatedFrom) {
2871
- if (file) addSubagent(file, s.delegatedFrom);
2872
- continue;
2873
- }
2874
- if (!file) {
2875
- sessions.push({
2876
- ...summarize(emptyStats("", false)),
2877
- session: s.id,
2878
- missing: "the session records no transcript"
2879
- });
2880
- continue;
2881
- }
2882
- labels.set(file, s.id);
2883
- const summary = summarize(stats(file));
2884
- sessions.push({
2885
- ...summary,
2886
- session: s.id,
2887
- ...summary.found ? {} : { missing: `${file} does not exist` }
2888
- });
2889
- for (const sub of subagentFiles(file)) addSubagent(sub, s.id);
2890
- }
2891
- const all = [...read.values()];
2892
- const models = /* @__PURE__ */ new Map();
2893
- const total = emptyUsage();
2894
- for (const m of all.flatMap((t) => t.messages).filter((m) => m.model !== "<synthetic>")) {
2895
- const entry = models.get(m.model) ?? {
2896
- model: m.model,
2897
- calls: 0,
2898
- usage: emptyUsage(),
2899
- cost: priceOf(m.model) ? 0 : null
2900
- };
2901
- models.set(m.model, entry);
2902
- entry.calls += 1;
2903
- addUsage(entry.usage, m.usage);
2904
- addUsage(total, m.usage);
2905
- const c = messageCost(m.model, m.usage, m.speed, m.geo);
2906
- if (c !== null && entry.cost !== null) entry.cost += c;
2907
- }
2908
- const hooks = /* @__PURE__ */ new Map();
2909
- for (const h of all.flatMap((t) => t.hooks)) {
2910
- const entry = hooks.get(h.event) ?? {
2911
- event: h.event,
2912
- injections: 0,
2913
- chars: 0,
2914
- blocked: 0
2915
- };
2916
- hooks.set(h.event, entry);
2917
- entry.injections += h.injections;
2918
- entry.chars += h.chars;
2919
- entry.blocked += h.blocked;
2920
- }
2921
- const sum = (records) => {
2922
- const out = {};
2923
- for (const r of records) for (const [k, v] of Object.entries(r)) out[k] = (out[k] ?? 0) + v;
2924
- return out;
2925
- };
2926
- const count = (r) => Object.values(r).reduce((a, b) => a + b, 0);
2927
- const toolErrors = sum(all.map((t) => t.toolErrors));
2928
- const misfires = sum(all.map((t) => t.misfires));
2929
- const blocked = Object.fromEntries([...hooks.values()].filter((h) => h.blocked > 0).map((h) => [h.event, h.blocked]));
2930
- const reruns = all.flatMap((t) => Object.entries(t.bashRuns).filter(([, runs]) => runs > 1).map(([command, runs]) => ({
2931
- command,
2932
- runs,
2933
- session: labels.get(t.file) ?? t.file
2934
- }))).sort((a, b) => b.runs - a.runs);
2935
- const modelList = [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
2936
- return {
2937
- mainline: {
2938
- id: mainline.id,
2939
- title: mainline.title,
2940
- status: mainline.status,
2941
- criteria: mainline.criteria.length,
2942
- tasks: mainline.tasks.length
2943
- },
2944
- versions: [...new Set(all.flatMap((t) => t.versions))].sort(),
2945
- unreadable: all.reduce((n, t) => n + t.unreadable, 0),
2946
- pricing: PRICING_SOURCE,
2947
- cost: {
2948
- total: modelList.reduce((n, m) => n + (m.cost ?? 0), 0),
2949
- unknownPriceModels: modelList.filter((m) => m.cost === null).map((m) => m.model),
2950
- cacheHitRate: cacheHitRate(total),
2951
- models: modelList,
2952
- sessions,
2953
- subagents,
2954
- hooks: [...hooks.values()].sort((a, b) => b.injections - a.injections || a.event.localeCompare(b.event))
2955
- },
2956
- precision: {
2957
- toolErrors: {
2958
- total: count(toolErrors),
2959
- byTool: toolErrors
2960
- },
2961
- bashReruns: {
2962
- total: reruns.reduce((n, r) => n + r.runs - 1, 0),
2963
- commands: reruns
2964
- },
2965
- misfireCandidates: {
2966
- total: count(misfires),
2967
- byEvent: misfires
2968
- },
2969
- blocked: {
2970
- total: count(blocked),
2971
- byEvent: blocked
2972
- },
2973
- gaps: all.flatMap((t) => t.gaps.map((g) => ({
2974
- ...g,
2975
- session: labels.get(t.file) ?? t.file
2976
- }))),
2977
- failedChecks: mainline.tasks.flatMap((t) => (sectionText(t.body, "紀錄") ?? "").split("\n").filter(FAILED_CHECK).map((line) => ({
2978
- task: `${mainline.id}/${t.id}`,
2979
- line: line.trim()
2980
- })))
2981
- }
2982
- };
2983
- }
2984
- //#endregion
2985
- //#region ../core/dist/migrate.js
2986
- const drop = (o, ...keys) => keys.forEach((k) => delete o[k]);
2987
- const dropNull = (o, ...keys) => keys.forEach((k) => o[k] === null && delete o[k]);
2988
- const rename = (o, from, to) => {
2989
- if (from in o) {
2990
- if (o[from] !== null && o[from] !== void 0) o[to] = o[from];
2991
- delete o[from];
2992
- }
2993
- };
2994
- const isClosed = (kind, status) => CLOSED_STATUS[kind].includes(status);
2995
- /** Drop the file's own H1, which format 2 replaces with the title in the front matter. */
2996
- const withoutTitle = (markdown) => markdown.replace(/^\s*# [^\n]*\n/, "").replace(/^\n+/, "");
2997
- /**
2998
- * Fold format 1's notes.md into the task body. Sections the task body also has keep their place; any
2999
- * other section, and text before the first section, moves under 紀錄 one heading level deeper.
3000
- */
3001
- function foldNotes(body, notes) {
3002
- const lines = withoutTitle(notes).split("\n");
3003
- const sections = [{
3004
- heading: null,
3005
- lines: []
3006
- }];
3007
- for (const line of lines) {
3008
- const h2 = /^## (.+)$/.exec(line);
3009
- if (h2) sections.push({
3010
- heading: h2[1].trim(),
3011
- lines: []
3012
- });
3013
- else sections.at(-1).lines.push(line);
3014
- }
3015
- const extra = [];
3016
- for (const section of sections) {
3017
- const text = section.lines.join("\n").trim();
3018
- if (section.heading && TASK_SECTIONS.includes(section.heading)) {
3019
- if (!text) continue;
3020
- const current = sectionText(body, section.heading) ?? "";
3021
- body = withSection(body, section.heading, current ? `${current}\n\n${text}` : text);
3022
- } else if (section.heading) extra.push(`### ${section.heading}`, "", text.replace(/^(#{3,5}) /gm, "#$1 "), "");
3023
- else if (text) extra.push(text, "");
3024
- }
3025
- if (extra.length) {
3026
- const current = sectionText(body, "紀錄") ?? "";
3027
- body = withSection(body, "紀錄", [current, extra.join("\n").trim()].filter(Boolean).join("\n\n"));
3028
- }
3029
- return body;
3030
- }
3031
- /** Criteria and checklist items: format 2 omits nulls and names a met criterion done. */
3032
- function migrateListItem(item) {
3033
- rename(item, "met", "done");
3034
- dropNull(item, "evidence", "doneAt");
3035
- return item;
3036
- }
3037
- const openItem = (item) => !item.done && !item.dropped;
3038
- /** Convert a format 1 hub to the current format, writing nothing when `dryRun` is set. */
3039
- function migrateHub(root, options = {}) {
3040
- const dataDir = join(root, DATA_DIR);
3041
- const configFile = join(dataDir, "config.yaml");
3042
- if (!existsSync(configFile)) throw new WriteError(`no hub at ${root}; run mainflow init first`);
3043
- const rel = (path) => relative(root, path);
3044
- return withLock(root, () => {
3045
- const config = parse$1(readFileSync(configFile, "utf8"));
3046
- const from = Number(config.formatVersion);
3047
- if (from === 2) throw new WriteError(`${rel(configFile)} is already format 2; nothing to migrate`);
3048
- if (from !== 1) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 to 2`);
3049
- const writes = /* @__PURE__ */ new Map();
3050
- const removes = [];
3051
- const missing = [];
3052
- const document = (file, schema, data, body) => {
3053
- writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
3054
- };
3055
- for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
3056
- const yamlFile = join(mainlineDir, "mainline.yaml");
3057
- if (!existsSync(yamlFile)) continue;
3058
- const m = parse$1(readFileSync(yamlFile, "utf8"));
3059
- dropNull(m, "archivedAt");
3060
- if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
3061
- const criteria = (m.criteria ?? []).map(migrateListItem);
3062
- m.criteria = criteria;
3063
- const id = String(m.id);
3064
- const open = !isClosed("mainline", m.status);
3065
- if (open) {
3066
- for (const c of criteria) if (openItem(c) && !c.doneWhen) missing.push(`${id}#${c.id} doneWhen`);
3067
- }
3068
- const contextFile = join(mainlineDir, "context.md");
3069
- const context = existsSync(contextFile) ? withoutTitle(readFileSync(contextFile, "utf8")) : "";
3070
- document(join(mainlineDir, MAINLINE_FILE), mainlineSchema, m, `\n${context.trimEnd()}\n`);
3071
- removes.push(yamlFile, ...existsSync(contextFile) ? [contextFile] : []);
3072
- for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
3073
- const taskYaml = join(taskDir, "task.yaml");
3074
- if (!existsSync(taskYaml)) continue;
3075
- const t = parse$1(readFileSync(taskYaml, "utf8"));
3076
- const content = typeof t.content === "string" ? t.content.trim() : "";
3077
- drop(t, "content");
3078
- dropNull(t, "parent", "repo");
3079
- t.created ??= m.created;
3080
- const checklist = (t.checklist ?? []).map(migrateListItem);
3081
- t.checklist = checklist;
3082
- const doneDays = checklist.map((c) => c.doneAt).filter((d) => typeof d === "string").sort();
3083
- if (isClosed("task", t.status) && doneDays.length) t.closedAt = doneDays.at(-1);
3084
- let body = content ? withSection(TASK_BODY, "目標", content) : TASK_BODY;
3085
- const notesFile = join(taskDir, "notes.md");
3086
- if (existsSync(notesFile)) {
3087
- body = foldNotes(body, readFileSync(notesFile, "utf8"));
3088
- removes.push(notesFile);
3089
- }
3090
- const taskRef = `${id}/${String(t.id)}`;
3091
- if (open && !isClosed("task", t.status)) {
3092
- for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(body, section)) missing.push(`${taskRef} ${section}`);
3093
- for (const c of checklist) if (openItem(c) && !c.doneWhen) missing.push(`${taskRef}#${c.id} doneWhen`);
3094
- }
3095
- document(join(taskDir, TASK_FILE), taskSchema, t, body);
3096
- removes.push(taskYaml);
3097
- }
3098
- const decisionsDir = join(mainlineDir, "decisions");
3099
- for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
3100
- const file = join(decisionsDir, name);
3101
- const { data, body } = splitFrontMatter(readFileSync(file, "utf8"));
3102
- const d = data;
3103
- rename(d, "date", "created");
3104
- if (d.status !== "proposed") d.decidedAt = d.created;
3105
- dropNull(d, "supersededBy");
3106
- rename(d, "blocks", "affects");
3107
- document(file, decisionSchema, d, body);
3108
- }
3109
- }
3110
- const hubDocs = (dir, migrate, schema) => {
3111
- for (const name of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : []) {
3112
- const file = join(dir, name);
3113
- const { data, body } = splitFrontMatter(readFileSync(file, "utf8"));
3114
- migrate(data);
3115
- document(file, schema, data, body);
3116
- }
3117
- };
3118
- hubDocs(join(dataDir, "issues"), (i) => rename(i, "closedBy", "resolution"), issueSchema);
3119
- hubDocs(join(dataDir, "findings"), (f) => {
3120
- rename(f, "date", "created");
3121
- rename(f, "tags", "labels");
3122
- }, findingSchema);
3123
- config.formatVersion = 2;
3124
- writes.set(configFile, toYaml(validate(configSchema, config, rel(configFile))));
3125
- if (!options.dryRun) {
3126
- for (const [file, text] of writes) writeAtomic(file, text);
3127
- for (const file of removes) rmSync(file);
3128
- }
3129
- return {
3130
- from,
3131
- to: 2,
3132
- written: [...writes.keys()].map(rel),
3133
- removed: removes.map(rel),
3134
- missingDefinitions: missing
3135
- };
3136
- });
3137
- }
3138
- //#endregion
3139
- //#region src/host.ts
3140
- const DEFAULT_UI_PORT = 4320;
3141
- const hostName = (hub) => `${hub}.mainflow.localhost`;
3142
- function siteBlock(hub, port) {
3143
- return [
3144
- `# mainflow hub ${hub}: written by \`mainflow ui\` to match the port it serves on; do not edit.`,
3145
- `${hostName(hub)} {`,
3146
- " tls internal",
3147
- `\treverse_proxy localhost:${port}`,
3148
- "}",
3149
- ""
3150
- ].join("\n");
3151
- }
3152
- /** Caddy's sites directory: MAINFLOW_CADDY_SITES, or the Homebrew layout {prefix}/etc/caddy/sites; null without either. */
3153
- function caddySites() {
3154
- if (process.env.MAINFLOW_CADDY_SITES) return process.env.MAINFLOW_CADDY_SITES;
3155
- const prefix = spawnSync("brew", ["--prefix"], { encoding: "utf8" });
3156
- if (prefix.error || prefix.status !== 0) return null;
3157
- return join(prefix.stdout.trim(), "etc/caddy/sites");
3158
- }
3159
- /** Reload Caddy from the Caddyfile two levels above the sites directory; the error text on failure. */
3160
- function reloadCaddy(sites) {
3161
- const caddyfile = join(sites, "..", "..", "Caddyfile");
3162
- const reload = spawnSync("caddy", [
3163
- "reload",
3164
- "--config",
3165
- caddyfile
3166
- ], { encoding: "utf8" });
3167
- if (reload.error) return reload.error.message;
3168
- return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
3169
- }
3170
- /**
3171
- * Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
3172
- * Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
3173
- * refused connection or a timeout means Caddy is down (observed 2026-09-25: checking for 200 reported a
3174
- * running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
3175
- */
3176
- async function caddyRunning() {
3177
- const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
3178
- try {
3179
- await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
3180
- return true;
3181
- } catch {
3182
- return false;
3183
- }
3184
- }
3185
- const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
3186
- /**
3187
- * Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
3188
- * port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
3189
- * different hub of the same name stops here: overwriting it would silently take that hub's host name.
3190
- */
3191
- async function syncHost(root, hub, port, options = {}) {
3192
- const sites = caddySites();
3193
- if (!sites || !existsSync(sites)) return {
3194
- url: null,
3195
- reason: `Caddy's sites directory was not found (${sites ?? "no Homebrew prefix"}); install Caddy and import that directory from the Caddyfile, or set MAINFLOW_CADDY_SITES`
3196
- };
3197
- const dir = join(root, DATA_DIR, "local", "caddy");
3198
- const file = join(dir, `${hub}.caddy`);
3199
- const link = join(sites, `mainflow-${hub}.caddy`);
3200
- const linked = lstatSync(link, { throwIfNoEntry: false });
3201
- if (linked) {
3202
- const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
3203
- if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
3204
- }
3205
- const block = siteBlock(hub, port);
3206
- if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
3207
- mkdirSync(dir, { recursive: true });
3208
- writeFileSync(file, block);
3209
- if (linked) rmSync(link);
3210
- symlinkSync(file, link);
3211
- const failure = (options.reload ?? reloadCaddy)(sites);
3212
- if (failure) return {
3213
- url: null,
3214
- reason: `caddy reload failed: ${failure}`
3215
- };
3216
- } else if (!await (options.running ?? caddyRunning)()) return {
3217
- url: null,
3218
- reason: "Caddy is not running; start it with `brew services start caddy`"
3219
- };
3220
- return { url: `https://${hostName(hub)}` };
3221
- }
3222
- /**
3223
- * Remove {hub}.mainflow.localhost when the hub rooted at `root` is gone, so a dangling link does not stop Caddy
3224
- * from loading every other site. Only a link that points into `root` is removed; the reload failure, if any,
3225
- * is returned for the caller to log.
3226
- */
3227
- function removeHostLink(root, hub, reload = reloadCaddy) {
3228
- const sites = caddySites();
3229
- if (!sites) return null;
3230
- const link = join(sites, `mainflow-${hub}.caddy`);
3231
- if (!lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) return null;
3232
- if (!resolve(dirname(link), readlinkSync(link)).startsWith(resolve(root) + "/")) return null;
3233
- rmSync(link);
3234
- return reload(sites);
3235
- }
3236
- /** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
3237
- const APP_BUNDLE_ID = "space.carlos.mainflow";
3238
- /** Close the tabs Safari and Chrome opened on this hub's /open route; each browser only when it is running. */
3239
- const closeOpenTabs = (hub) => {
3240
- const marker = `${hostName(hub)}/open/`;
3241
- return ["Safari", "Google Chrome"].map((app) => `if application "${app}" is running then
3242
- tell application "${app}"
3243
- repeat with w in (every window)
3244
- close (every tab of w whose URL contains "${marker}")
3245
- end repeat
3246
- end tell
3247
- end if`).join("\n");
3248
- };
3249
- /**
3250
- * Show an address of the hub's interface in the macOS app through its mainflow:// scheme (docs/app.md "連結").
3251
- * A link in a reply reaches here through the default browser, so once the app has the address the browser tab
3252
- * it left is closed and the app brought back to the front. Returns false when no app handles the scheme; the
3253
- * caller then leaves the page to the browser. Does nothing and returns false outside macOS.
3254
- */
3255
- function openInApp(hub, target, closeAfterMs = 150) {
3256
- if (process.platform !== "darwin") return false;
3257
- if (spawnSync("open", [`mainflow://${hub}${target}`]).status !== 0) return false;
3258
- setTimeout(() => {
3259
- spawnSync("osascript", ["-e", closeOpenTabs(hub)]);
3260
- spawn("open", ["-b", APP_BUNDLE_ID], {
3261
- detached: true,
3262
- stdio: "ignore"
3263
- }).on("error", () => {}).unref();
3264
- }, closeAfterMs);
3265
- return true;
3266
- }
3267
- //#endregion
3268
- export { appendOps as $, untilState as A, setSessionArchived as B, setCriterion as C, supersedeDecision as D, setMainlineTitle as E, ensureSession as F, transcriptPath as G, setSessionStatus as H, handoffSession as I, matchGrants as J, updateAction as K, loadSessions as L, sessionBriefing as M, turnReminder as N, undropChecklistItem as O, addAction as P, mayDecorate as Q, readSession as R, setChecklistItem as S, worktreesOf as St, setMainlineStatus as T, settleSession as U, setSessionInfo as V, startSession as W, endsWithMarker as X, decorateReply as Y, headerLine as Z, createMainline as _, WriteError as _t, openInApp as a, setFindingArchived as at, initHub as b, currentBranch as bt, syncHost as c, HubError as ct, ARCHIVE_KINDS as d, mainlineEdges as dt, closeIssue as et, setArchivedMany as f, linksInto as ft, createDecision as g, ConflictError as gt, addRepo as h, TASK_FILE as ht, hostName as i, reopenIssue as it, duplicateClaimLines as j, updateTask as k, migrateHub as l, loadHub as lt, addHub as m, MAINLINE_FILE as mt, DEFAULT_UI_PORT as n, createIssue as nt, removeHostLink as o, setFindingStatus as ot, acceptDecision as p, refIndex as pt, grantScope as q, caddySites as r, planIssue as rt, siteBlock as s, setIssueArchived as st, APP_BUNDLE_ID as t, createFinding as tt, retroReport as u, dependencyGraph as ut, createTask as v, readYaml as vt, setCriterionText as w, setArchived as x, findHubRoot as xt, dropChecklistItem as y, DATA_DIR as yt, setReminderDigest as z };