mainflow 0.6.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.
@@ -0,0 +1,1615 @@
1
+ import { A as mainlineSchema, C as TASK_SECTIONS, D as findingSchema, E as decisionSchema, F as refMainline, I as sessionSchema, L as taskSchema, M as parseOpsLine, N as parseRef, O as formatOpsEntry, P as refExample, S as TASK_REQUIRED_SECTIONS, T as configSchema, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as opsEntrySchema, k as issueSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as WORK_STATUS, x as SLUG, y as RESERVED_HUB_NAMES } from "./dist-D2NK-QQP.js";
2
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { parse, 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 resolves to the same hub in the main working tree. A worktree is another
36
+ * checkout of the same repository, so without this every worktree would write its own copy of the hub and
37
+ * the copies would conflict on merge. A different hub nested inside a worktree (a scratch hub under tmp/,
38
+ * say) has no counterpart in the main tree and stays as found.
39
+ */
40
+ function findHubRoot(cwd) {
41
+ let found = null;
42
+ for (let dir = cwd;; dir = dirname(dir)) {
43
+ if (isHub(dir)) {
44
+ found = dir;
45
+ break;
46
+ }
47
+ if (dirname(dir) === dir) break;
48
+ }
49
+ const trees = worktreesOf(cwd);
50
+ if (!trees || trees.main === trees.current) return found;
51
+ const counterpart = join(trees.main, relative(trees.current, found ?? trees.current));
52
+ return isHub(counterpart) ? counterpart : found;
53
+ }
54
+ function subdirs(dir) {
55
+ if (!existsSync(dir)) return [];
56
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(dir, entry.name)).sort();
57
+ }
58
+ /** Split a Markdown file into its YAML front matter and the body after it. */
59
+ function splitFrontMatter(source) {
60
+ const match = /^---\n([\s\S]*?)\n---\n?/.exec(source);
61
+ if (!match) return {
62
+ data: null,
63
+ body: source
64
+ };
65
+ return {
66
+ data: parse(match[1] ?? ""),
67
+ body: source.slice(match[0].length)
68
+ };
69
+ }
70
+ /** Find the entry directory whose name starts with `{id}-` inside `parent`. */
71
+ function findEntryDir(parent, id) {
72
+ return subdirs(parent).find((dir) => ENTRY_DIR.exec(dir.split("/").at(-1) ?? "")?.[1] === id) ?? null;
73
+ }
74
+ /** The next free id with the given prefix, e.g. `m003` after `m001` and `m002`. */
75
+ function nextId(prefix, existing) {
76
+ const max = existing.filter((id) => id.startsWith(prefix)).map((id) => Number(id.slice(prefix.length))).reduce((a, b) => Math.max(a, b), 0);
77
+ if (max >= 999) throw new Error(`no ${prefix} ids left after ${prefix}999`);
78
+ return `${prefix}${String(max + 1).padStart(3, "0")}`;
79
+ }
80
+ const contentHash = (text) => createHash("sha1").update(text).digest("hex");
81
+ /** Read a UTF-8 file, or return null when it does not exist. */
82
+ function readText(file) {
83
+ return existsSync(file) ? readFileSync(file, "utf8") : null;
84
+ }
85
+ /** List every file below `dir`, sorted, skipping dot files. */
86
+ function listFiles(dir) {
87
+ if (!existsSync(dir)) return [];
88
+ return readdirSync(dir, {
89
+ recursive: true,
90
+ withFileTypes: true
91
+ }).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => join(entry.parentPath, entry.name)).sort();
92
+ }
93
+ //#endregion
94
+ //#region ../core/dist/io.js
95
+ var WriteError = class extends Error {};
96
+ /** The file changed after the caller read it; the caller must reload and retry. */
97
+ var ConflictError = class extends WriteError {};
98
+ const LOCK_WAIT_MS = 5e3;
99
+ const LOCK_STALE_MS = 3e4;
100
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
101
+ /** Run `fn` while holding the hub-wide write lock in `.mainflow/local/`. */
102
+ function withLock(root, fn) {
103
+ const dir = join(root, DATA_DIR, "local");
104
+ mkdirSync(dir, { recursive: true });
105
+ const lock = join(dir, "write.lock");
106
+ const deadline = Date.now() + LOCK_WAIT_MS;
107
+ for (;;) try {
108
+ const fd = openSync(lock, "wx");
109
+ writeSync(fd, String(process.pid));
110
+ closeSync(fd);
111
+ break;
112
+ } catch (error) {
113
+ if (error.code !== "EEXIST") throw error;
114
+ if (Date.now() - statSync(lock, { throwIfNoEntry: false }).mtimeMs > LOCK_STALE_MS) {
115
+ rmSync(lock, { force: true });
116
+ continue;
117
+ }
118
+ if (Date.now() > deadline) throw new WriteError(`hub is locked by another writer (${lock}); retry in a moment`);
119
+ sleep(50);
120
+ }
121
+ try {
122
+ return fn();
123
+ } finally {
124
+ rmSync(lock, { force: true });
125
+ }
126
+ }
127
+ const today = () => (/* @__PURE__ */ new Date()).toLocaleDateString("sv-SE");
128
+ /** Local time to the minute, the form sessions, actions and decisions use: 2026-09-24T15:30. */
129
+ const nowMinute = () => {
130
+ const d = /* @__PURE__ */ new Date();
131
+ return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 5)}`;
132
+ };
133
+ function validate(schema, data, file) {
134
+ const result = schema.safeParse(data);
135
+ if (result.success) return result.data;
136
+ throw new WriteError(`${file} would become invalid: ${result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ")}`);
137
+ }
138
+ function writeAtomic(file, text) {
139
+ const temp = `${file}.${process.pid}.tmp`;
140
+ writeFileSync(temp, text);
141
+ renameSync(temp, file);
142
+ }
143
+ const toYaml = (data) => stringify(data, { lineWidth: 0 });
144
+ function readYaml(file, schema) {
145
+ const text = readFileSync(file, "utf8");
146
+ return {
147
+ data: validate(schema, parse(text), file),
148
+ version: contentHash(text)
149
+ };
150
+ }
151
+ /** Read, mutate, validate and write back a YAML document under the hub lock. */
152
+ function mutateYaml(root, file, schema, mutate, expectedVersion) {
153
+ return withLock(root, () => {
154
+ const text = readFileSync(file, "utf8");
155
+ if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
156
+ const data = validate(schema, parse(text), file);
157
+ mutate(data);
158
+ const next = validate(schema, data, file);
159
+ writeAtomic(file, toYaml(next));
160
+ return next;
161
+ });
162
+ }
163
+ const frontMatterText = (data, body) => `---\n${toYaml(data)}---\n${body}`;
164
+ /** Read and validate the front matter of a Markdown file, with its body and content hash. */
165
+ function readFrontMatter(file, schema) {
166
+ const text = readFileSync(file, "utf8");
167
+ const { data, body } = splitFrontMatter(text);
168
+ return {
169
+ data: validate(schema, data, file),
170
+ body,
171
+ version: contentHash(text)
172
+ };
173
+ }
174
+ /**
175
+ * Read, mutate, validate and write back the front matter of a Markdown file. The body is kept unless
176
+ * `mutate` returns a new one.
177
+ */
178
+ function mutateFrontMatter(root, file, schema, mutate, expectedVersion) {
179
+ return withLock(root, () => {
180
+ const text = readFileSync(file, "utf8");
181
+ if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
182
+ const { data, body } = splitFrontMatter(text);
183
+ const current = validate(schema, data, file);
184
+ const nextBody = mutate(current, body) ?? body;
185
+ const next = validate(schema, current, file);
186
+ writeAtomic(file, frontMatterText(next, nextBody));
187
+ return next;
188
+ });
189
+ }
190
+ //#endregion
191
+ //#region ../core/dist/resolve.js
192
+ const mainlinesDir = (root) => join(root, DATA_DIR, "mainlines");
193
+ /** The document inside each mainline and task directory: front matter for attributes, body for prose. */
194
+ const MAINLINE_FILE = "mainline.md";
195
+ const TASK_FILE = "task.md";
196
+ function requireRef(input, kind) {
197
+ const ref = parseRef(input);
198
+ if (ref?.kind !== kind) throw new WriteError(`expected a ${kind} reference like ${refExample(kind)}, got "${input}"`);
199
+ return ref;
200
+ }
201
+ function mainlineDir(root, id) {
202
+ const dir = findEntryDir(mainlinesDir(root), id);
203
+ if (!dir) throw new WriteError(`mainline ${id} not found; run "mainflow status --all" to list mainlines`);
204
+ return dir;
205
+ }
206
+ function taskDir(root, mainline, task) {
207
+ const dir = findEntryDir(join(mainlineDir(root, mainline), "tasks"), task);
208
+ if (!dir) throw new WriteError(`task ${mainline}/${task} not found`);
209
+ return dir;
210
+ }
211
+ const mainlineFile = (root, id) => join(mainlineDir(root, id), MAINLINE_FILE);
212
+ const taskFile = (root, mainline, task) => join(taskDir(root, mainline, task), TASK_FILE);
213
+ function decisionFile(root, mainline, decision) {
214
+ const dir = join(mainlineDir(root, mainline), "decisions");
215
+ const name = existsSync(dir) ? readdirSync(dir).find((n) => n.startsWith(`${decision}-`) && n.endsWith(".md")) : void 0;
216
+ if (!name) throw new WriteError(`decision ${mainline}/${decision} not found`);
217
+ return join(dir, name);
218
+ }
219
+ function requireSlug(slug) {
220
+ if (!SLUG.test(slug)) throw new WriteError(`slug "${slug}" must be kebab-case, e.g. mainflow-rebuild`);
221
+ }
222
+ function requireOneOf(value, allowed, what) {
223
+ if (!allowed.includes(value)) throw new WriteError(`${what} "${value}" must be one of: ${allowed.join(", ")}`);
224
+ return value;
225
+ }
226
+ function readConfig(root) {
227
+ return readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
228
+ }
229
+ /** Entry ids (m001, t002, d003) found among the names in `dir`. */
230
+ const idsIn = (dir) => (existsSync(dir) ? readdirSync(dir) : []).map((n) => ENTRY_DIR.exec(n)?.[1]).filter((id) => !!id);
231
+ //#endregion
232
+ //#region ../core/dist/refs.js
233
+ const itemStatus = (item) => item.done ? "done" : item.dropped ? "dropped" : "open";
234
+ /** Every reference a hub can resolve, keyed by the reference without a hub prefix. */
235
+ function refIndex(hub) {
236
+ const index = /* @__PURE__ */ new Map();
237
+ for (const m of hub.mainlines) {
238
+ index.set(m.id, {
239
+ kind: "mainline",
240
+ title: m.title,
241
+ status: m.status,
242
+ mainline: m.id
243
+ });
244
+ for (const r of m.criteria) index.set(`${m.id}#${r.id}`, {
245
+ kind: "criterion",
246
+ title: r.text,
247
+ status: itemStatus(r),
248
+ mainline: m.id
249
+ });
250
+ for (const d of m.decisions) index.set(`${m.id}/${d.id}`, {
251
+ kind: "decision",
252
+ title: d.title,
253
+ status: d.status,
254
+ mainline: m.id
255
+ });
256
+ for (const t of m.tasks) {
257
+ index.set(`${m.id}/${t.id}`, {
258
+ kind: "task",
259
+ title: t.title,
260
+ status: t.status,
261
+ mainline: m.id
262
+ });
263
+ for (const c of t.checklist) index.set(`${m.id}/${t.id}#${c.id}`, {
264
+ kind: "checklistItem",
265
+ title: c.text,
266
+ status: itemStatus(c),
267
+ mainline: m.id
268
+ });
269
+ }
270
+ }
271
+ for (const i of hub.issues) index.set(i.id, {
272
+ kind: "issue",
273
+ title: i.title,
274
+ status: i.status
275
+ });
276
+ for (const f of hub.findings) index.set(f.id, {
277
+ kind: "finding",
278
+ title: f.title,
279
+ status: f.status
280
+ });
281
+ return index;
282
+ }
283
+ const isHubRoot = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
284
+ /** Absolute root of a registered hub; `path` is relative to the registering hub's root. */
285
+ const remoteRoot = (root, path) => resolve(root, path);
286
+ /** Documents in `hub` whose links point at `hubName`'s documents; `hubName` undefined means links without a prefix. */
287
+ function linksInto(hub, hubName) {
288
+ const out = [];
289
+ const add = (from, links) => {
290
+ for (const l of links ?? []) {
291
+ const colon = l.to.indexOf(":");
292
+ const prefix = colon < 0 ? void 0 : l.to.slice(0, colon);
293
+ const local = colon < 0 ? l.to : l.to.slice(colon + 1);
294
+ if (prefix === hubName || prefix === hub.config.hub && hubName === void 0) out.push({
295
+ to: local,
296
+ link: {
297
+ from,
298
+ rel: l.rel,
299
+ ...l.note ? { note: l.note } : {}
300
+ }
301
+ });
302
+ }
303
+ };
304
+ for (const m of hub.mainlines) {
305
+ add(m.id, m.links);
306
+ for (const d of m.decisions) add(`${m.id}/${d.id}`, d.links);
307
+ for (const t of m.tasks) add(`${m.id}/${t.id}`, t.links);
308
+ }
309
+ for (const i of hub.issues) add(i.id, i.links);
310
+ for (const f of hub.findings) add(f.id, f.links);
311
+ return out;
312
+ }
313
+ //#endregion
314
+ //#region ../core/dist/templates.js
315
+ const body = (headings) => ["", ...headings.flatMap((h) => [`## ${h}`, ""])].join("\n");
316
+ const MAINLINE_BODY = body(MAINLINE_SECTIONS);
317
+ const TASK_BODY = body(TASK_SECTIONS);
318
+ /** 建議 carries the recommendation while a decision is proposed; 決定 is filled once it is accepted. */
319
+ const DECISION_BODY = body(DECISION_SECTIONS);
320
+ const ISSUE_BODY = body(ISSUE_SECTIONS);
321
+ const FINDING_BODY = body(FINDING_SECTIONS);
322
+ /** The text under `## {heading}` up to the next H2, trimmed; null when the heading is missing. */
323
+ function sectionText(markdown, heading) {
324
+ const lines = markdown.split("\n");
325
+ const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
326
+ if (start === -1) return null;
327
+ const end = lines.findIndex((l, i) => i > start && l.startsWith("## "));
328
+ return lines.slice(start + 1, end === -1 ? void 0 : end).join("\n").trim();
329
+ }
330
+ /** Put `text` under `## {heading}`, replacing what was there; appends the section when it is missing. */
331
+ function withSection(markdown, heading, text) {
332
+ const lines = markdown.split("\n");
333
+ const start = lines.findIndex((l) => l.trim() === `## ${heading}`);
334
+ if (start === -1) return `${markdown.trimEnd()}\n\n## ${heading}\n\n${text}\n`;
335
+ const found = lines.findIndex((l, i) => i > start && l.startsWith("## "));
336
+ const end = found === -1 ? lines.length : found;
337
+ const rest = lines.slice(end);
338
+ return [
339
+ ...lines.slice(0, start + 1),
340
+ "",
341
+ text,
342
+ ...rest.length ? ["", ...rest] : [""]
343
+ ].join("\n");
344
+ }
345
+ //#endregion
346
+ //#region ../core/dist/load.js
347
+ function loadHub(root, options = {}) {
348
+ const problems = [];
349
+ const warnings = [];
350
+ /** References checked once everything they may point at is loaded */
351
+ const pending = [];
352
+ /** Documents whose file is there but failed to load; references into them are not reported twice */
353
+ const unreadable = /* @__PURE__ */ new Set();
354
+ const idOf = (path) => ENTRY_DIR.exec(path.split("/").at(-1)?.replace(/\.md$/, "") ?? "")?.[1];
355
+ const dataDir = join(root, DATA_DIR);
356
+ const rel = (path) => relative(root, path);
357
+ const read = (file, schema, raw) => {
358
+ let data = raw;
359
+ if (data === void 0) try {
360
+ data = parse(readText(file) ?? "");
361
+ } catch (error) {
362
+ problems.push({
363
+ file: rel(file),
364
+ field: "",
365
+ message: `cannot parse YAML: ${error.message}`
366
+ });
367
+ return null;
368
+ }
369
+ const result = schema.safeParse(data);
370
+ if (result.success) return result.data;
371
+ for (const issue of result.error.issues) problems.push({
372
+ file: rel(file),
373
+ field: issue.path.join("."),
374
+ message: issue.message
375
+ });
376
+ return null;
377
+ };
378
+ const checkDirName = (dir, id) => {
379
+ const match = ENTRY_DIR.exec(dir.split("/").at(-1) ?? "");
380
+ if (!match || match[1] !== id || !SLUG.test(match[2] ?? "")) problems.push({
381
+ file: rel(dir),
382
+ field: "",
383
+ message: `directory name must be "${id}-{slug}" in kebab-case`
384
+ });
385
+ };
386
+ const config = read(join(dataDir, "config.yaml"), configSchema);
387
+ if (!config) throw new HubError("invalid config.yaml", problems);
388
+ 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);
389
+ const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
390
+ /** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
391
+ const readDocument = (file, schema) => {
392
+ const text = readText(file);
393
+ if (text === null) {
394
+ problems.push({
395
+ file: rel(file),
396
+ field: "",
397
+ message: "file is missing"
398
+ });
399
+ return null;
400
+ }
401
+ let split;
402
+ try {
403
+ split = splitFrontMatter(text);
404
+ } catch (error) {
405
+ problems.push({
406
+ file: rel(file),
407
+ field: "",
408
+ message: `cannot parse front matter: ${error.message}`
409
+ });
410
+ return null;
411
+ }
412
+ const doc = read(file, schema, split.data);
413
+ if (!doc) return null;
414
+ if (doc.category && !categories.has(doc.category)) problems.push({
415
+ file: rel(file),
416
+ field: "category",
417
+ message: `category "${doc.category}" is not one of ${[...categories].join(", ")}; add it under categories in config.yaml or pick one of these`
418
+ });
419
+ const updated = new Date(statSync(file).mtimeMs).toLocaleDateString("sv-SE");
420
+ return {
421
+ ...doc,
422
+ file: rel(file),
423
+ body: split.body,
424
+ version: contentHash(text),
425
+ updated
426
+ };
427
+ };
428
+ const mainlines = [];
429
+ for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
430
+ const mainline = readDocument(join(mainlineDir, MAINLINE_FILE), mainlineSchema);
431
+ if (!mainline) {
432
+ const id = idOf(mainlineDir);
433
+ if (id) unreadable.add(id);
434
+ continue;
435
+ }
436
+ checkDirName(mainlineDir, mainline.id);
437
+ const decisions = [];
438
+ const decisionsDir = join(mainlineDir, "decisions");
439
+ for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
440
+ const file = join(decisionsDir, name);
441
+ const decision = readDocument(file, decisionSchema);
442
+ if (!decision) unreadable.add(`${mainline.id}/${idOf(file)}`);
443
+ if (decision) {
444
+ checkDirName(file.replace(/\.md$/, ""), decision.id);
445
+ decisions.push(decision);
446
+ }
447
+ }
448
+ const tasks = [];
449
+ for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
450
+ const task = readDocument(join(taskDir, TASK_FILE), taskSchema);
451
+ if (!task) {
452
+ unreadable.add(`${mainline.id}/${idOf(taskDir)}`);
453
+ continue;
454
+ }
455
+ checkDirName(taskDir, task.id);
456
+ if (task.repo && !(task.repo in config.repos)) problems.push({
457
+ file: task.file,
458
+ field: "repo",
459
+ message: `repo "${task.repo}" is not listed in config.yaml`
460
+ });
461
+ tasks.push({
462
+ ...task,
463
+ dir: rel(taskDir),
464
+ artifacts: listFiles(join(taskDir, "artifacts")).map(rel)
465
+ });
466
+ }
467
+ problems.push(...contentGaps(mainline, tasks));
468
+ const taskIds = new Set(tasks.map((t) => t.id));
469
+ const criterionIds = new Set(mainline.criteria.map((c) => c.id));
470
+ const itemRefs = new Set(tasks.flatMap((t) => t.checklist.map((c) => `${mainline.id}/${t.id}#${c.id}`)));
471
+ for (const task of tasks) {
472
+ if (task.parent && !taskIds.has(task.parent)) problems.push({
473
+ file: task.file,
474
+ field: "parent",
475
+ message: `parent task ${task.parent} does not exist`
476
+ });
477
+ task.dependsOn?.forEach((dep, i) => {
478
+ if (!taskIds.has(dep)) problems.push({
479
+ file: task.file,
480
+ field: `dependsOn.${i}`,
481
+ message: `task ${dep} does not exist`
482
+ });
483
+ });
484
+ task.checklist.forEach((item, i) => {
485
+ item.serves?.forEach((r, j) => {
486
+ if (!criterionIds.has(r)) problems.push({
487
+ file: task.file,
488
+ field: `checklist.${i}.serves.${j}`,
489
+ message: `criterion ${mainline.id}#${r} does not exist`
490
+ });
491
+ });
492
+ });
493
+ }
494
+ for (const decision of decisions) decision.affects?.forEach((ref, i) => {
495
+ if (!(ref.includes("#") ? itemRefs.has(ref) : taskIds.has(ref.split("/")[1] ?? "") && ref.startsWith(`${mainline.id}/`))) problems.push({
496
+ file: decision.file,
497
+ field: `affects.${i}`,
498
+ message: `${ref} is not a task or checklist item of ${mainline.id}`
499
+ });
500
+ });
501
+ mainlines.push({
502
+ ...mainline,
503
+ dir: rel(mainlineDir),
504
+ views: listFiles(join(mainlineDir, "views")).map(rel),
505
+ decisions,
506
+ tasks
507
+ });
508
+ }
509
+ const hubDocs = (dir, schema) => (existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : []).flatMap((name) => {
510
+ const file = join(dir, name);
511
+ const doc = readDocument(file, schema);
512
+ if (!doc) {
513
+ const id = idOf(file);
514
+ if (id) unreadable.add(id);
515
+ return [];
516
+ }
517
+ checkDirName(file.replace(/\.md$/, ""), doc.id);
518
+ return [doc];
519
+ });
520
+ const hubIssues = hubDocs(join(dataDir, "issues"), issueSchema);
521
+ const taskRefs = new Set(mainlines.flatMap((m) => m.tasks.map((t) => `${m.id}/${t.id}`)));
522
+ for (const issue of hubIssues) if (issue.plannedIn && !taskRefs.has(issue.plannedIn)) problems.push({
523
+ file: issue.file,
524
+ field: "plannedIn",
525
+ message: `task ${issue.plannedIn} does not exist`
526
+ });
527
+ const findings = hubDocs(join(dataDir, "findings"), findingSchema);
528
+ const ops = [];
529
+ const opsPath = join(dataDir, "ops");
530
+ for (const name of existsSync(opsPath) ? readdirSync(opsPath).filter((n) => n.endsWith(".log")).sort() : []) readFileSync(join(opsPath, name), "utf8").split("\n").forEach((line, index) => {
531
+ if (!line.trim()) return;
532
+ const entry = parseOpsLine(line);
533
+ if (entry?.ref) pending.push({
534
+ ref: entry.ref,
535
+ file: rel(join(opsPath, name)),
536
+ field: `line ${index + 1}`
537
+ });
538
+ if (entry) ops.push(entry);
539
+ else problems.push({
540
+ file: rel(join(opsPath, name)),
541
+ field: `line ${index + 1}`,
542
+ message: "expected \"YYYY-MM-DDTHH:mm | target | operation | result [| ref]\""
543
+ });
544
+ });
545
+ const remotes = options.remotes === false ? [] : loadRemotes(root, config, problems, warnings);
546
+ checkReferences({
547
+ config,
548
+ mainlines,
549
+ issues: hubIssues,
550
+ findings,
551
+ remotes,
552
+ pending,
553
+ unreadable,
554
+ problems
555
+ });
556
+ return {
557
+ root,
558
+ config,
559
+ mainlines,
560
+ issues: hubIssues,
561
+ findings,
562
+ ops,
563
+ problems,
564
+ warnings,
565
+ remotes
566
+ };
567
+ }
568
+ /** Load every hub registered in config.yaml, read-only and without its own remotes. */
569
+ function loadRemotes(root, config, problems, warnings) {
570
+ const file = `${DATA_DIR}/config.yaml`;
571
+ return Object.entries(config.hubs ?? {}).map(([name, path]) => {
572
+ const remote = {
573
+ name,
574
+ path,
575
+ root: remoteRoot(root, path)
576
+ };
577
+ if (name === config.hub) {
578
+ problems.push({
579
+ file,
580
+ field: `hubs.${name}`,
581
+ message: `"${name}" is this hub's own name; references without a prefix already point here`
582
+ });
583
+ return {
584
+ ...remote,
585
+ unavailable: "this hub"
586
+ };
587
+ }
588
+ if (!isHubRoot(remote.root)) {
589
+ warnings.push({
590
+ file,
591
+ field: `hubs.${name}`,
592
+ message: `hub "${name}" is not at ${path} on this machine; references into it are not checked`
593
+ });
594
+ return {
595
+ ...remote,
596
+ unavailable: `not found at ${path}`
597
+ };
598
+ }
599
+ try {
600
+ const hub = loadHub(remote.root, { remotes: false });
601
+ if (hub.config.hub !== name) {
602
+ problems.push({
603
+ file,
604
+ field: `hubs.${name}`,
605
+ message: `${path} holds hub "${hub.config.hub}", not "${name}"; register it under its own name`
606
+ });
607
+ return {
608
+ ...remote,
609
+ unavailable: `holds hub "${hub.config.hub}"`
610
+ };
611
+ }
612
+ return {
613
+ ...remote,
614
+ hub
615
+ };
616
+ } catch (error) {
617
+ warnings.push({
618
+ file,
619
+ field: `hubs.${name}`,
620
+ message: `hub "${name}" at ${path} cannot be loaded: ${error.message}`
621
+ });
622
+ return {
623
+ ...remote,
624
+ unavailable: error.message
625
+ };
626
+ }
627
+ });
628
+ }
629
+ /**
630
+ * Every reference in a document must point at something that exists: in this hub, or in a registered
631
+ * hub that is on this machine. A reference into a registered hub that is missing here is left unchecked,
632
+ * since another machine may not keep every hub; the missing hub itself is reported once as a warning.
633
+ * Links are for relations across mainlines; inside one mainline, parent, dependsOn and affects say it.
634
+ */
635
+ function checkReferences(input) {
636
+ const { config, mainlines, issues, findings, remotes, pending, unreadable, problems } = input;
637
+ /** A target inside a document that failed to load: its schema problem is already reported. */
638
+ const inUnreadable = (target) => [
639
+ target,
640
+ target.split(/[#/]/)[0],
641
+ target.split("#")[0]
642
+ ].some((t) => unreadable.has(t));
643
+ const local = refIndex({
644
+ mainlines,
645
+ issues,
646
+ findings
647
+ });
648
+ const remoteIndex = new Map(remotes.map((r) => [r.name, r.hub ? refIndex(r.hub) : null]));
649
+ /** The document's own mainline, when it sits in one; links may not point back into it. */
650
+ const add = (file, doc, self, mainline) => {
651
+ if (doc.origin) pending.push({
652
+ ref: doc.origin,
653
+ file,
654
+ field: "origin"
655
+ });
656
+ doc.links?.forEach((link, i) => {
657
+ pending.push({
658
+ ref: link.to,
659
+ file,
660
+ field: `links.${i}.to`
661
+ });
662
+ const parsed = parseRef(link.to);
663
+ const here = parsed && (parsed.hub === void 0 || parsed.hub === config.hub);
664
+ if (!parsed || !here) return;
665
+ if (link.to.slice(link.to.indexOf(":") + 1) === self) problems.push({
666
+ file,
667
+ field: `links.${i}.to`,
668
+ message: "a document cannot link to itself"
669
+ });
670
+ else if (mainline && refMainline(parsed) === mainline) problems.push({
671
+ file,
672
+ field: `links.${i}.to`,
673
+ message: `${link.to} is inside ${mainline}; links are for other mainlines, so use parent, dependsOn or affects within one`
674
+ });
675
+ });
676
+ };
677
+ const items = (file, field, list) => list.forEach((item, i) => item.supersededBy && pending.push({
678
+ ref: item.supersededBy,
679
+ file,
680
+ field: `${field}.${i}.supersededBy`
681
+ }));
682
+ for (const m of mainlines) {
683
+ add(m.file, m, m.id, m.id);
684
+ items(m.file, "criteria", m.criteria);
685
+ for (const d of m.decisions) add(d.file, d, `${m.id}/${d.id}`, m.id);
686
+ for (const t of m.tasks) {
687
+ add(t.file, t, `${m.id}/${t.id}`, m.id);
688
+ items(t.file, "checklist", t.checklist);
689
+ }
690
+ }
691
+ for (const i of issues) add(i.file, i, i.id);
692
+ for (const f of findings) add(f.file, f, f.id);
693
+ for (const p of pending) {
694
+ const parsed = parseRef(p.ref);
695
+ if (!parsed) continue;
696
+ const target = p.ref.slice(p.ref.indexOf(":") + 1);
697
+ if (parsed.hub === void 0 || parsed.hub === config.hub) {
698
+ if (!local.has(target) && !inUnreadable(target)) problems.push({
699
+ file: p.file,
700
+ field: p.field,
701
+ message: `${p.ref} does not exist in this hub`
702
+ });
703
+ continue;
704
+ }
705
+ if (!remoteIndex.has(parsed.hub)) {
706
+ problems.push({
707
+ file: p.file,
708
+ field: p.field,
709
+ message: `hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>"`
710
+ });
711
+ continue;
712
+ }
713
+ const index = remoteIndex.get(parsed.hub);
714
+ if (index && !index.has(target)) problems.push({
715
+ file: p.file,
716
+ field: p.field,
717
+ message: `${p.ref} does not exist in hub "${parsed.hub}"`
718
+ });
719
+ }
720
+ }
721
+ const isOpen = (kind, status) => !CLOSED_STATUS[kind].includes(status);
722
+ /**
723
+ * What a reader without the conversation needs to pick the work up: every open task says what it is for
724
+ * and when it is done, and every open criterion or item says what has to be true to check it. Closed
725
+ * entries are skipped, because their evidence already says what was done. Ids must also be unique within
726
+ * a list, since the files are edited directly and nothing else allocates them.
727
+ */
728
+ function contentGaps(mainline, tasks) {
729
+ const gaps = [];
730
+ const list = (file, field, items, open) => {
731
+ const seen = /* @__PURE__ */ new Set();
732
+ items.forEach((item, i) => {
733
+ if (seen.has(item.id)) gaps.push({
734
+ file,
735
+ field: `${field}.${i}.id`,
736
+ message: `${item.id} is used twice; give it the next free id`
737
+ });
738
+ seen.add(item.id);
739
+ if (open && !item.done && !item.dropped && !item.doneWhen?.trim()) gaps.push({
740
+ file,
741
+ field: `${field}.${i}.doneWhen`,
742
+ message: `${item.id} is open and needs doneWhen: what has to be true to check it`
743
+ });
744
+ });
745
+ };
746
+ const mainlineOpen = isOpen("mainline", mainline.status);
747
+ list(mainline.file, "criteria", mainline.criteria, mainlineOpen);
748
+ for (const task of tasks) {
749
+ const open = mainlineOpen && isOpen("task", task.status);
750
+ list(task.file, "checklist", task.checklist, open);
751
+ if (!open) continue;
752
+ for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(task.body, section)) gaps.push({
753
+ file: task.file,
754
+ field: `## ${section}`,
755
+ message: `the task is open and needs text under "## ${section}"`
756
+ });
757
+ }
758
+ return gaps;
759
+ }
760
+ var HubError = class extends Error {
761
+ problems;
762
+ constructor(message, problems) {
763
+ super(message);
764
+ this.problems = problems;
765
+ }
766
+ };
767
+ //#endregion
768
+ //#region ../core/dist/hub-items.js
769
+ const issuesDir = (root) => join(root, DATA_DIR, "issues");
770
+ const findingsDir = (root) => join(root, DATA_DIR, "findings");
771
+ const opsDir = (root) => join(root, DATA_DIR, "ops");
772
+ function entryFile(dir, id, what) {
773
+ const name = existsSync(dir) ? readdirSync(dir).find((n) => n.startsWith(`${id}-`) && n.endsWith(".md")) : void 0;
774
+ if (!name) throw new WriteError(`${what} ${id} not found`);
775
+ return join(dir, name);
776
+ }
777
+ /**
778
+ * Any reference the schema accepts must also point at something that exists. A reference into another
779
+ * hub only needs that hub registered here; check verifies the target when that hub is on this machine.
780
+ */
781
+ function requireExisting(root, ref) {
782
+ const parsed = parseRef(ref);
783
+ if (!parsed) throw new WriteError(`"${ref}" is not a reference like ${REF_EXAMPLES}`);
784
+ const config = readConfig(root);
785
+ if (parsed.hub !== void 0 && parsed.hub !== config.hub) {
786
+ if (!config.hubs?.[parsed.hub]) throw new WriteError(`hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>" first`);
787
+ return;
788
+ }
789
+ switch (parsed.kind) {
790
+ case "issue":
791
+ entryFile(issuesDir(root), parsed.issue, "issue");
792
+ return;
793
+ case "finding":
794
+ entryFile(findingsDir(root), parsed.finding, "finding");
795
+ return;
796
+ default:
797
+ mainlineDir(root, parsed.mainline);
798
+ if ("task" in parsed) taskDir(root, parsed.mainline, parsed.task);
799
+ if (parsed.kind === "decision") decisionFile(root, parsed.mainline, parsed.decision);
800
+ }
801
+ }
802
+ function createIssue(root, input) {
803
+ requireSlug(input.slug);
804
+ if (input.origin) requireExisting(root, input.origin);
805
+ return withLock(root, () => {
806
+ const dir = issuesDir(root);
807
+ mkdirSync(dir, { recursive: true });
808
+ const id = nextId("i", idsIn(dir));
809
+ const issue = validate(issueSchema, {
810
+ id,
811
+ title: input.title,
812
+ status: "open",
813
+ created: today(),
814
+ ...input.origin ? { origin: input.origin } : {}
815
+ }, "issue");
816
+ const file = join(dir, `${id}-${input.slug}.md`);
817
+ writeAtomic(file, frontMatterText(issue, ISSUE_BODY));
818
+ return {
819
+ id,
820
+ file
821
+ };
822
+ });
823
+ }
824
+ /** Schedule an issue into the task that will carry it out. */
825
+ function planIssue(root, id, taskRef, expectedVersion) {
826
+ const ref = requireRef(taskRef, "task");
827
+ taskDir(root, ref.mainline, ref.task);
828
+ return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
829
+ i.status = "planned";
830
+ i.plannedIn = taskRef;
831
+ }, expectedVersion);
832
+ }
833
+ function closeIssue(root, id, resolution, expectedVersion) {
834
+ if (!resolution) throw new WriteError(`closing ${id} needs what closed it, e.g. --by "mainflow@3fdd5ac" or --by "不再需要"`);
835
+ return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
836
+ i.status = "closed";
837
+ i.resolution = resolution;
838
+ i.closedAt ??= today();
839
+ }, expectedVersion);
840
+ }
841
+ function reopenIssue(root, id, expectedVersion) {
842
+ return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
843
+ i.status = "open";
844
+ delete i.plannedIn;
845
+ delete i.resolution;
846
+ delete i.closedAt;
847
+ }, expectedVersion);
848
+ }
849
+ /** Archive or unarchive an issue; the status stays as it is, so a closed issue is visible until archived. */
850
+ function setIssueArchived(root, id, archived, expectedVersion) {
851
+ return mutateFrontMatter(root, entryFile(issuesDir(root), id, "issue"), issueSchema, (i) => {
852
+ if (archived) i.archivedAt ??= today();
853
+ else delete i.archivedAt;
854
+ }, expectedVersion);
855
+ }
856
+ function createFinding(root, input) {
857
+ requireSlug(input.slug);
858
+ if (input.origin) requireExisting(root, input.origin);
859
+ return withLock(root, () => {
860
+ const dir = findingsDir(root);
861
+ mkdirSync(dir, { recursive: true });
862
+ const id = nextId("f", idsIn(dir));
863
+ const finding = validate(findingSchema, {
864
+ id,
865
+ title: input.title,
866
+ status: "valid",
867
+ created: today(),
868
+ ...input.origin ? { origin: input.origin } : {},
869
+ ...input.labels?.length ? { labels: input.labels } : {}
870
+ }, "finding");
871
+ const file = join(dir, `${id}-${input.slug}.md`);
872
+ writeAtomic(file, frontMatterText(finding, FINDING_BODY));
873
+ return {
874
+ id,
875
+ file
876
+ };
877
+ });
878
+ }
879
+ function setFindingStatus(root, id, status, expectedVersion) {
880
+ const value = requireOneOf(status, FINDING_STATUS, "finding status");
881
+ return mutateFrontMatter(root, entryFile(findingsDir(root), id, "finding"), findingSchema, (f) => {
882
+ f.status = value;
883
+ if (value === "obsolete") f.closedAt ??= today();
884
+ else delete f.closedAt;
885
+ }, expectedVersion);
886
+ }
887
+ /** Archive or unarchive a finding; the status stays as it is, so an obsolete finding is visible until archived. */
888
+ function setFindingArchived(root, id, archived, expectedVersion) {
889
+ return mutateFrontMatter(root, entryFile(findingsDir(root), id, "finding"), findingSchema, (f) => {
890
+ if (archived) f.archivedAt ??= today();
891
+ else delete f.archivedAt;
892
+ }, expectedVersion);
893
+ }
894
+ /** Append one external operation to ops/{YYYY-MM}.log; the log is never rewritten. */
895
+ function appendOps(root, input) {
896
+ if (input.ref) requireExisting(root, input.ref);
897
+ const entry = validate(opsEntrySchema, {
898
+ ...input,
899
+ at: input.at ?? nowMinute()
900
+ }, "ops entry");
901
+ for (const field of [
902
+ "target",
903
+ "operation",
904
+ "result"
905
+ ]) if (entry[field].includes(" | ")) throw new WriteError(`${field} cannot contain " | ", the ops log's separator`);
906
+ return withLock(root, () => {
907
+ mkdirSync(opsDir(root), { recursive: true });
908
+ appendFileSync(join(opsDir(root), `${entry.at.slice(0, 7)}.log`), `${formatOpsEntry(entry)}\n`);
909
+ return entry;
910
+ });
911
+ }
912
+ //#endregion
913
+ //#region ../core/dist/briefing.js
914
+ function describe(root, ref) {
915
+ const parsed = parseRef(ref);
916
+ if (parsed?.kind !== "checklistItem") return null;
917
+ try {
918
+ const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
919
+ const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
920
+ const item = task.checklist.find((c) => c.id === parsed.item);
921
+ if (!item) return null;
922
+ return {
923
+ ref,
924
+ text: item.text,
925
+ checked: item.done,
926
+ where: `${mainline.id} ${mainline.title} › ${task.id} ${task.title}`
927
+ };
928
+ } catch {
929
+ return null;
930
+ }
931
+ }
932
+ /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
933
+ function queueWarnings(root, session, cli) {
934
+ const warnings = [];
935
+ const doing = session.actions.filter((a) => a.status === "doing");
936
+ if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
937
+ for (const action of doing) {
938
+ const served = action.serves.map((ref) => describe(root, ref));
939
+ if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c> 改正。`);
940
+ else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選。做完了就執行 ${cli} action set ${action.id} --status done。`);
941
+ }
942
+ const next = session.actions.find((a) => a.status === "todo");
943
+ if (doing.length === 0 && next) warnings.push(`下一個行動是 ${next.id}。開始前先執行 ${cli} action set ${next.id} --status doing。`);
944
+ return warnings;
945
+ }
946
+ const current = (session) => session.actions.find((a) => a.status === "doing");
947
+ const servedLine = (root, action) => action.serves.map((ref) => {
948
+ const s = describe(root, ref);
949
+ return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目)`;
950
+ }).join(";") || "沒有指定服務的 checklist 項目";
951
+ /** Full briefing for the start of a session, after resuming, clearing or compacting. */
952
+ function sessionBriefing(root, session, options) {
953
+ const { cli } = options;
954
+ const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
955
+ const doing = current(session);
956
+ const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
957
+ if (session.actions.length === 0) {
958
+ const active = loadHub(root).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
959
+ lines.push("這場會話還沒有行動。");
960
+ if (active.length > 0) {
961
+ lines.push("進行中的主脈絡:");
962
+ for (const m of active) lines.push(`- ${m.id} ${m.title}`);
963
+ lines.push(`要推進其中一個時,先用 ${cli} status 與它的任務 checklist 決定要做什麼,再列出行動。`);
964
+ }
965
+ } else {
966
+ lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
967
+ if (doing) lines.push(`服務:${servedLine(root, doing)}`);
968
+ if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
969
+ }
970
+ const warnings = queueWarnings(root, session, cli);
971
+ if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
972
+ lines.push("工作方式:", `- 開始一件新工作前,先列進佇列:${cli} action add "<做法>" --serves <m/t#c>。`, `- 開始做標 doing,做完標 done:${cli} action set <a編號> --status doing。`, `- 勾選 checklist 要附證據:${cli} item check <m/t#c> --evidence <commit、檔案:行號或指令輸出>。`, `- 內容直接寫進檔案:意圖或需求寫進 mainline.md 的內文,任務的目標與完成定義寫進 task.md,選擇寫成決策(${cli} new decision <m> <slug> --title <標題> 建檔後再寫內文)。`, `- 改完 .mainflow/ 的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
973
+ return lines.join("\n");
974
+ }
975
+ /** One or two lines for every user turn; null when the session has no actions to anchor to. */
976
+ function turnReminder(root, session, options) {
977
+ if (session.actions.length === 0) return null;
978
+ const doing = current(session);
979
+ const open = session.actions.filter((a) => a.status === "todo" || a.status === "doing");
980
+ return [doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length === 0 ? `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。` : `[mainflow] ${session.id} 目前沒有進行中的行動。`, ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)].join("\n");
981
+ }
982
+ //#endregion
983
+ //#region ../core/dist/sessions.js
984
+ const sessionsDir = (root) => join(root, DATA_DIR, "local", "sessions");
985
+ const sessionFile = (root, id) => join(sessionsDir(root), `${id}.yaml`);
986
+ /** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
987
+ function loadSessions(root) {
988
+ const dir = sessionsDir(root);
989
+ const sessions = [];
990
+ const problems = [];
991
+ let names = [];
992
+ try {
993
+ names = readdirSync(dir).filter((n) => n.endsWith(".yaml")).sort();
994
+ } catch {
995
+ return {
996
+ sessions,
997
+ problems
998
+ };
999
+ }
1000
+ for (const name of names) {
1001
+ const file = join(dir, name);
1002
+ let data;
1003
+ try {
1004
+ data = parse(readText(file) ?? "");
1005
+ } catch (error) {
1006
+ problems.push({
1007
+ file: relative(root, file),
1008
+ field: "",
1009
+ message: `cannot parse YAML: ${error.message}`
1010
+ });
1011
+ continue;
1012
+ }
1013
+ const result = sessionSchema.safeParse(data);
1014
+ if (result.success) sessions.push(result.data);
1015
+ else for (const i of result.error.issues) problems.push({
1016
+ file: relative(root, file),
1017
+ field: i.path.join("."),
1018
+ message: i.message
1019
+ });
1020
+ }
1021
+ return {
1022
+ sessions,
1023
+ problems
1024
+ };
1025
+ }
1026
+ function readSession(root, id) {
1027
+ const file = sessionFile(root, id);
1028
+ return existsSync(file) ? readYaml(file, sessionSchema).data : null;
1029
+ }
1030
+ function requireSession(root, id) {
1031
+ const file = sessionFile(root, id);
1032
+ if (!existsSync(file)) throw new WriteError(`session ${id} not found; start one with "mainflow session start"`);
1033
+ return file;
1034
+ }
1035
+ /** Split `claude-code-1a2b3c4d#a003` into its session and action ids. */
1036
+ function splitActionRef(ref) {
1037
+ const hash = ref.lastIndexOf("#");
1038
+ const action = ref.slice(hash + 1);
1039
+ if (hash <= 0 || !/^a\d{3}$/.test(action)) throw new WriteError(`expected an action reference like claude-code-1a2b3c4d#a003, got "${ref}"`);
1040
+ return {
1041
+ session: ref.slice(0, hash),
1042
+ action
1043
+ };
1044
+ }
1045
+ /** Every served checklist item must exist, so a session never points at work that is not in a mainline. */
1046
+ function requireServes(root, serves) {
1047
+ for (const ref of serves) {
1048
+ const parsed = parseRef(ref);
1049
+ if (!CHECKLIST_REF.test(ref) || parsed?.kind !== "checklistItem") throw new WriteError(`--serves takes checklist references like m001/t002#c003, got "${ref}"`);
1050
+ 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`);
1051
+ }
1052
+ }
1053
+ /**
1054
+ * Start a session. Taking over from another session copies its unfinished actions into this one and marks
1055
+ * the other session handed-off, so the queue continues where it stopped.
1056
+ */
1057
+ function startSession(root, input) {
1058
+ const id = input.id ?? `${input.agent}-${randomBytes(4).toString("hex")}`;
1059
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) throw new WriteError(`session id "${id}" must be lowercase letters, digits and hyphens`);
1060
+ return withLock(root, () => {
1061
+ const file = sessionFile(root, id);
1062
+ if (existsSync(file)) throw new WriteError(`session ${id} already exists`);
1063
+ if (input.delegatedFrom) requireSession(root, input.delegatedFrom);
1064
+ let carried = [];
1065
+ if (input.handoffFrom) {
1066
+ const previousFile = requireSession(root, input.handoffFrom);
1067
+ const previous = readYaml(previousFile, sessionSchema).data;
1068
+ carried = previous.actions.filter((a) => a.status === "todo" || a.status === "doing").map((a, i) => ({
1069
+ ...a,
1070
+ id: `a${String(i + 1).padStart(3, "0")}`
1071
+ }));
1072
+ writeAtomic(previousFile, toYaml(validate(sessionSchema, {
1073
+ ...previous,
1074
+ status: "handed-off"
1075
+ }, previousFile)));
1076
+ }
1077
+ const session = validate(sessionSchema, {
1078
+ id,
1079
+ agent: input.agent,
1080
+ status: "active",
1081
+ started: nowMinute(),
1082
+ delegatedFrom: input.delegatedFrom ?? null,
1083
+ handoffFrom: input.handoffFrom ?? null,
1084
+ cwd: input.cwd,
1085
+ actions: carried
1086
+ }, file);
1087
+ mkdirSync(sessionsDir(root), { recursive: true });
1088
+ writeAtomic(file, toYaml(session));
1089
+ return session;
1090
+ });
1091
+ }
1092
+ /**
1093
+ * Start the session unless it already exists. An existing paused or ended session becomes active again, and
1094
+ * an archived one comes back into view, since resumed work should not stay hidden.
1095
+ */
1096
+ function ensureSession(root, input) {
1097
+ const existing = readSession(root, input.id);
1098
+ if (!existing) return {
1099
+ session: startSession(root, input),
1100
+ created: true
1101
+ };
1102
+ if (existing.status === "active" && !existing.archivedAt) return {
1103
+ session: existing,
1104
+ created: false
1105
+ };
1106
+ return {
1107
+ session: mutateYaml(root, requireSession(root, input.id), sessionSchema, (s) => {
1108
+ s.status = "active";
1109
+ delete s.archivedAt;
1110
+ }),
1111
+ created: false
1112
+ };
1113
+ }
1114
+ /** Where a session's transcript is on disk: a platform records an absolute path, a hub may record one relative to its root. */
1115
+ function transcriptPath(root, transcript) {
1116
+ return isAbsolute(transcript) ? transcript : join(root, transcript);
1117
+ }
1118
+ function setSessionInfo(root, id, info) {
1119
+ return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1120
+ for (const [key, value] of Object.entries(info)) if (value !== void 0 && value !== null && value !== "") s[key] = value;
1121
+ });
1122
+ }
1123
+ function setSessionStatus(root, id, status) {
1124
+ const value = requireOneOf(status, SESSION_STATUS, "session status");
1125
+ return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1126
+ s.status = value;
1127
+ });
1128
+ }
1129
+ /** Archive or unarchive a session; the status stays as it is, so an ended session is visible until archived. */
1130
+ function setSessionArchived(root, id, archived) {
1131
+ return mutateYaml(root, requireSession(root, id), sessionSchema, (s) => {
1132
+ if (archived) s.archivedAt ??= today();
1133
+ else delete s.archivedAt;
1134
+ });
1135
+ }
1136
+ function place(actions, action, before) {
1137
+ if (!before) {
1138
+ actions.push(action);
1139
+ return;
1140
+ }
1141
+ const index = actions.findIndex((a) => a.id === before);
1142
+ if (index < 0) throw new WriteError(`--before ${before} is not an action in this session`);
1143
+ actions.splice(index, 0, action);
1144
+ }
1145
+ function addAction(root, sessionId, input) {
1146
+ requireServes(root, input.serves);
1147
+ let created = "";
1148
+ mutateYaml(root, requireSession(root, sessionId), sessionSchema, (s) => {
1149
+ created = nextId("a", s.actions.map((a) => a.id));
1150
+ place(s.actions, {
1151
+ id: created,
1152
+ do: input.do,
1153
+ status: "todo",
1154
+ serves: input.serves
1155
+ }, input.before);
1156
+ });
1157
+ return `${sessionId}#${created}`;
1158
+ }
1159
+ function updateAction(root, ref, patch) {
1160
+ const { session, action } = splitActionRef(ref);
1161
+ const status = patch.status === void 0 ? void 0 : requireOneOf(patch.status, WORK_STATUS, "action status");
1162
+ if (patch.serves) requireServes(root, patch.serves);
1163
+ if (patch.before === action) throw new WriteError(`action ${action} cannot be placed before itself`);
1164
+ let updated;
1165
+ mutateYaml(root, requireSession(root, session), sessionSchema, (s) => {
1166
+ const index = s.actions.findIndex((a) => a.id === action);
1167
+ if (index < 0) throw new WriteError(`action ${ref} not found`);
1168
+ const [current] = s.actions.splice(index, 1);
1169
+ const next = {
1170
+ ...current,
1171
+ ...status && { status },
1172
+ ...patch.do && { do: patch.do },
1173
+ ...patch.serves && { serves: patch.serves },
1174
+ ...patch.note && { note: patch.note }
1175
+ };
1176
+ if (status === "doing" && current.status !== "doing") {
1177
+ next.startedAt = nowMinute();
1178
+ delete next.doneAt;
1179
+ }
1180
+ if ((status === "done" || status === "dropped") && current.status !== status) next.doneAt = nowMinute();
1181
+ if (status === "todo") {
1182
+ delete next.startedAt;
1183
+ delete next.doneAt;
1184
+ }
1185
+ updated = next;
1186
+ if (patch.before) place(s.actions, next, patch.before);
1187
+ else s.actions.splice(index, 0, next);
1188
+ });
1189
+ return updated;
1190
+ }
1191
+ //#endregion
1192
+ //#region ../core/dist/write.js
1193
+ function checkTaskLinks(root, mainline, task) {
1194
+ 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`);
1195
+ if (task.parent) {
1196
+ if (task.parent === task.id) throw new WriteError(`task ${task.id} cannot be its own parent`);
1197
+ taskDir(root, mainline, task.parent);
1198
+ }
1199
+ }
1200
+ function initHub(root, hub) {
1201
+ const dataDir = join(root, DATA_DIR);
1202
+ if (existsSync(join(dataDir, "config.yaml"))) throw new WriteError(`${dataDir} already holds a hub`);
1203
+ const config = validate(configSchema, {
1204
+ hub,
1205
+ formatVersion: 2,
1206
+ repos: {}
1207
+ }, "config.yaml");
1208
+ mkdirSync(join(dataDir, "mainlines"), { recursive: true });
1209
+ writeFileSync(join(dataDir, "config.yaml"), toYaml(config));
1210
+ writeFileSync(join(dataDir, ".gitignore"), "local/\n**/scratch/\n!ops/*.log\n");
1211
+ return dataDir;
1212
+ }
1213
+ /** Register a repository the hub's tasks may point at; `path` is relative to the hub root. */
1214
+ function addRepo(root, name, path) {
1215
+ requireSlug(name);
1216
+ const target = resolve(root, path);
1217
+ if (!existsSync(target) || !statSync(target).isDirectory()) throw new WriteError(`repo path ${path} does not exist from the hub root (${target})`);
1218
+ mutateYaml(root, join(root, DATA_DIR, "config.yaml"), configSchema, (c) => {
1219
+ if (c.repos[name] !== void 0 && c.repos[name] !== path) throw new WriteError(`repo "${name}" is already registered as ${c.repos[name]}`);
1220
+ c.repos[name] = path;
1221
+ });
1222
+ return name;
1223
+ }
1224
+ /**
1225
+ * Register another hub whose documents this hub's references may point at, as `{name}:m001`. `path` is
1226
+ * relative to this hub's root and must hold that hub, registered under its own name, so a reference reads
1227
+ * the same in every hub that mentions it.
1228
+ */
1229
+ function addHub(root, name, path) {
1230
+ requireSlug(name);
1231
+ if (RESERVED_HUB_NAMES.includes(name)) throw new WriteError(`${RESERVED_HUB_NAMES.join(" and ")} are reserved and cannot name a hub`);
1232
+ const target = resolve(root, path);
1233
+ if (name === readConfig(root).hub) throw new WriteError(`"${name}" is this hub's own name; references without a prefix already point here`);
1234
+ if (!existsSync(join(target, ".mainflow", "config.yaml"))) throw new WriteError(`no hub at ${path} from the hub root (${target})`);
1235
+ const other = readConfig(target).hub;
1236
+ if (other !== name) throw new WriteError(`${path} holds hub "${other}"; register it as "${other}"`);
1237
+ mutateYaml(root, join(root, DATA_DIR, "config.yaml"), configSchema, (c) => {
1238
+ const hubs = c.hubs ??= {};
1239
+ if (hubs[name] !== void 0 && hubs[name] !== path) throw new WriteError(`hub "${name}" is already registered as ${hubs[name]}`);
1240
+ hubs[name] = path;
1241
+ });
1242
+ return name;
1243
+ }
1244
+ /** Stamp closedAt when a status closes the entity and clear it when the entity reopens. */
1245
+ function stampClosed(doc, closed, at) {
1246
+ if (closed) doc.closedAt ??= at;
1247
+ else delete doc.closedAt;
1248
+ }
1249
+ const closes = (kind, status) => CLOSED_STATUS[kind].includes(status);
1250
+ function createMainline(root, input) {
1251
+ requireSlug(input.slug);
1252
+ return withLock(root, () => {
1253
+ const id = nextId("m", idsIn(mainlinesDir(root)));
1254
+ const dir = join(mainlinesDir(root), `${id}-${input.slug}`);
1255
+ const mainline = validate(mainlineSchema, {
1256
+ id,
1257
+ title: input.title,
1258
+ status: "planning",
1259
+ created: today(),
1260
+ criteria: []
1261
+ }, MAINLINE_FILE);
1262
+ mkdirSync(join(dir, "tasks"), { recursive: true });
1263
+ writeAtomic(join(dir, MAINLINE_FILE), frontMatterText(mainline, MAINLINE_BODY));
1264
+ return {
1265
+ id,
1266
+ dir
1267
+ };
1268
+ });
1269
+ }
1270
+ /** Decisions of a mainline still waiting for a ruling, by id. */
1271
+ function proposedDecisions(root, id) {
1272
+ 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 ?? "?");
1273
+ }
1274
+ /**
1275
+ * Marking a mainline done is a claim that its criteria hold and nothing is left to decide, so it is
1276
+ * refused while a criterion is still open or a decision is still proposed. The message names each one and
1277
+ * the ways to settle it; dropping the mainline instead is not checked.
1278
+ */
1279
+ function requireDone(root, id, m) {
1280
+ const open = m.criteria.filter((c) => !c.done && !c.dropped).map((c) => `${id}#${c.id}`);
1281
+ const proposed = proposedDecisions(root, id).map((d) => `${id}/${d}`);
1282
+ if (!open.length && !proposed.length) return;
1283
+ 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("; ")}`);
1284
+ }
1285
+ /** Move a mainline to another status; done is refused while criteria are open or decisions are proposed. */
1286
+ function setMainlineStatus(root, id, status, expectedVersion) {
1287
+ requireRef(id, "mainline");
1288
+ const next = requireOneOf(status, MAINLINE_STATUS, "mainline status");
1289
+ return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
1290
+ if (next === "done" && m.status !== "done") requireDone(root, id, m);
1291
+ m.status = next;
1292
+ stampClosed(m, closes("mainline", next), today());
1293
+ }, expectedVersion);
1294
+ }
1295
+ function setArchived(root, id, archived, expectedVersion) {
1296
+ requireRef(id, "mainline");
1297
+ return mutateFrontMatter(root, mainlineFile(root, id), mainlineSchema, (m) => {
1298
+ if (archived) m.archivedAt ??= today();
1299
+ else delete m.archivedAt;
1300
+ }, expectedVersion);
1301
+ }
1302
+ function setCriterion(root, refInput, done, evidence, expectedVersion) {
1303
+ const ref = requireRef(refInput, "criterion");
1304
+ if (done && !evidence) throw new WriteError(`marking ${refInput} as met needs evidence, e.g. --evidence mainflow@25e4165`);
1305
+ return mutateFrontMatter(root, mainlineFile(root, ref.mainline), mainlineSchema, (m) => {
1306
+ const criterion = m.criteria.find((c) => c.id === ref.criterion);
1307
+ if (!criterion) throw new WriteError(`criterion ${refInput} not found`);
1308
+ setListItem(criterion, done, evidence);
1309
+ }, expectedVersion);
1310
+ }
1311
+ /** Check or uncheck a criterion or checklist item; checking clears a drop. */
1312
+ function setListItem(item, done, evidence) {
1313
+ item.done = done;
1314
+ if (done) {
1315
+ item.evidence = evidence;
1316
+ item.doneAt = today();
1317
+ delete item.dropped;
1318
+ delete item.supersededBy;
1319
+ } else {
1320
+ delete item.evidence;
1321
+ delete item.doneAt;
1322
+ }
1323
+ }
1324
+ function createTask(root, mainline, input) {
1325
+ requireRef(mainline, "mainline");
1326
+ requireSlug(input.slug);
1327
+ const kind = requireOneOf(input.kind ?? "phase", ["phase", "branch"], "task kind");
1328
+ return withLock(root, () => {
1329
+ const tasksDir = join(mainlineDir(root, mainline), "tasks");
1330
+ const id = nextId("t", idsIn(tasksDir));
1331
+ const task = validate(taskSchema, {
1332
+ id,
1333
+ title: input.title,
1334
+ kind,
1335
+ status: "todo",
1336
+ created: today(),
1337
+ checklist: [],
1338
+ ...input.parent ? { parent: input.parent } : {},
1339
+ ...input.repo ? { repo: input.repo } : {}
1340
+ }, TASK_FILE);
1341
+ checkTaskLinks(root, mainline, task);
1342
+ const dir = join(tasksDir, `${id}-${input.slug}`);
1343
+ mkdirSync(dir, { recursive: true });
1344
+ writeAtomic(join(dir, TASK_FILE), frontMatterText(task, TASK_BODY));
1345
+ return {
1346
+ ref: `${mainline}/${id}`,
1347
+ dir
1348
+ };
1349
+ });
1350
+ }
1351
+ function updateTask(root, refInput, patch, expectedVersion) {
1352
+ const ref = requireRef(refInput, "task");
1353
+ const status = patch.status === void 0 ? void 0 : requireOneOf(patch.status, WORK_STATUS, "task status");
1354
+ const merge = patch.merge === void 0 ? void 0 : requireOneOf(patch.merge, MERGE_STATES, "merge state");
1355
+ if (status === "blocked" && !patch.blockedBy?.reason) throw new WriteError(`blocking ${refInput} needs a reason, e.g. --reason "等使用者提供 API key" --waiting-for 使用者`);
1356
+ return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
1357
+ if (status !== void 0) {
1358
+ t.status = status;
1359
+ if (status === "doing") t.startedAt ??= today();
1360
+ stampClosed(t, closes("task", status), today());
1361
+ if (status !== "blocked") delete t.blockedBy;
1362
+ }
1363
+ if (patch.repo !== void 0) {
1364
+ if (patch.repo) t.repo = patch.repo;
1365
+ else delete t.repo;
1366
+ }
1367
+ if (patch.blockedBy !== void 0) t.blockedBy = patch.blockedBy;
1368
+ if (patch.branch !== void 0) t.branch = patch.branch;
1369
+ if (patch.worktree !== void 0) t.worktree = patch.worktree;
1370
+ if (patch.base !== void 0) t.base = patch.base;
1371
+ if (merge !== void 0) t.merge = merge;
1372
+ checkTaskLinks(root, ref.mainline, t);
1373
+ }, expectedVersion);
1374
+ }
1375
+ function setChecklistItem(root, refInput, done, evidence, expectedVersion) {
1376
+ const ref = requireRef(refInput, "checklistItem");
1377
+ if (done && !evidence) throw new WriteError(`checking ${refInput} needs evidence, e.g. --evidence mainflow@25e4165`);
1378
+ return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
1379
+ const item = t.checklist.find((c) => c.id === ref.item);
1380
+ if (!item) throw new WriteError(`checklist item ${refInput} not found`);
1381
+ setListItem(item, done, evidence);
1382
+ }, expectedVersion);
1383
+ }
1384
+ /** Mark an item as no longer needed, naming what replaced it when something did. */
1385
+ function dropChecklistItem(root, refInput, input = {}, expectedVersion) {
1386
+ const ref = requireRef(refInput, "checklistItem");
1387
+ return mutateFrontMatter(root, taskFile(root, ref.mainline, ref.task), taskSchema, (t) => {
1388
+ const item = t.checklist.find((c) => c.id === ref.item);
1389
+ if (!item) throw new WriteError(`checklist item ${refInput} not found`);
1390
+ if (item.done) throw new WriteError(`${refInput} is checked; uncheck it before dropping it`);
1391
+ item.dropped = true;
1392
+ if (input.supersededBy) item.supersededBy = input.supersededBy;
1393
+ if (input.note) item.note = input.note;
1394
+ }, expectedVersion);
1395
+ }
1396
+ function createDecision(root, mainline, input) {
1397
+ requireRef(mainline, "mainline");
1398
+ requireSlug(input.slug);
1399
+ const status = requireOneOf(input.status ?? "accepted", ["proposed", "accepted"], "new decision status");
1400
+ const by = input.by === void 0 ? void 0 : requireOneOf(input.by, DECIDERS, "decided by");
1401
+ for (const ref of input.affects ?? []) requireAffected(root, mainline, ref);
1402
+ return withLock(root, () => {
1403
+ const dir = join(mainlineDir(root, mainline), "decisions");
1404
+ mkdirSync(dir, { recursive: true });
1405
+ const id = nextId("d", idsIn(dir));
1406
+ const at = nowMinute();
1407
+ const decision = validate(decisionSchema, {
1408
+ id,
1409
+ title: input.title,
1410
+ created: at,
1411
+ status,
1412
+ ...status === "accepted" ? { decidedAt: at } : {},
1413
+ ...by ? { by } : {},
1414
+ ...input.affects?.length ? { affects: input.affects } : {}
1415
+ }, "decision");
1416
+ const file = join(dir, `${id}-${input.slug}.md`);
1417
+ writeAtomic(file, frontMatterText(decision, DECISION_BODY));
1418
+ return {
1419
+ ref: `${mainline}/${id}`,
1420
+ file
1421
+ };
1422
+ });
1423
+ }
1424
+ /** A decision affects tasks or checklist items of its own mainline, and they must exist. */
1425
+ function requireAffected(root, mainline, input) {
1426
+ const ref = parseRefIn(input);
1427
+ 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}"`);
1428
+ taskDir(root, ref.mainline, ref.task);
1429
+ }
1430
+ const parseRefIn = (input) => {
1431
+ const task = /^(m\d{3})\/(t\d{3})(?:#c\d{3})?$/.exec(input);
1432
+ return task ? {
1433
+ mainline: task[1],
1434
+ task: task[2]
1435
+ } : null;
1436
+ };
1437
+ /** Settle a proposed decision: record who decided and when; the items it affects stop waiting for it. */
1438
+ function acceptDecision(root, refInput, by, expectedVersion) {
1439
+ const ref = requireRef(refInput, "decision");
1440
+ const decider = by === void 0 ? void 0 : requireOneOf(by, DECIDERS, "decided by");
1441
+ return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
1442
+ if (d.status !== "proposed") throw new WriteError(`${refInput} is ${d.status}; only a proposed decision can be accepted`);
1443
+ d.status = "accepted";
1444
+ d.decidedAt = nowMinute();
1445
+ if (decider) d.by = decider;
1446
+ }, expectedVersion);
1447
+ }
1448
+ function supersedeDecision(root, refInput, by, expectedVersion) {
1449
+ const ref = requireRef(refInput, "decision");
1450
+ const successor = requireRef(by.includes("/") ? by : `${ref.mainline}/${by}`, "decision");
1451
+ if (successor.mainline !== ref.mainline) throw new WriteError(`a decision can only be superseded within its own mainline (${ref.mainline})`);
1452
+ if (successor.decision === ref.decision) throw new WriteError(`decision ${refInput} cannot supersede itself`);
1453
+ decisionFile(root, successor.mainline, successor.decision);
1454
+ return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
1455
+ d.status = "superseded";
1456
+ d.supersededBy = successor.decision;
1457
+ stampClosed(d, true, nowMinute());
1458
+ }, expectedVersion);
1459
+ }
1460
+ //#endregion
1461
+ //#region ../core/dist/migrate.js
1462
+ const drop = (o, ...keys) => keys.forEach((k) => delete o[k]);
1463
+ const dropNull = (o, ...keys) => keys.forEach((k) => o[k] === null && delete o[k]);
1464
+ const rename = (o, from, to) => {
1465
+ if (from in o) {
1466
+ if (o[from] !== null && o[from] !== void 0) o[to] = o[from];
1467
+ delete o[from];
1468
+ }
1469
+ };
1470
+ const isClosed = (kind, status) => CLOSED_STATUS[kind].includes(status);
1471
+ /** Drop the file's own H1, which format 2 replaces with the title in the front matter. */
1472
+ const withoutTitle = (markdown) => markdown.replace(/^\s*# [^\n]*\n/, "").replace(/^\n+/, "");
1473
+ /**
1474
+ * Fold format 1's notes.md into the task body. Sections the task body also has keep their place; any
1475
+ * other section, and text before the first section, moves under 紀錄 one heading level deeper.
1476
+ */
1477
+ function foldNotes(body, notes) {
1478
+ const lines = withoutTitle(notes).split("\n");
1479
+ const sections = [{
1480
+ heading: null,
1481
+ lines: []
1482
+ }];
1483
+ for (const line of lines) {
1484
+ const h2 = /^## (.+)$/.exec(line);
1485
+ if (h2) sections.push({
1486
+ heading: h2[1].trim(),
1487
+ lines: []
1488
+ });
1489
+ else sections.at(-1).lines.push(line);
1490
+ }
1491
+ const extra = [];
1492
+ for (const section of sections) {
1493
+ const text = section.lines.join("\n").trim();
1494
+ if (section.heading && TASK_SECTIONS.includes(section.heading)) {
1495
+ if (!text) continue;
1496
+ const current = sectionText(body, section.heading) ?? "";
1497
+ body = withSection(body, section.heading, current ? `${current}\n\n${text}` : text);
1498
+ } else if (section.heading) extra.push(`### ${section.heading}`, "", text.replace(/^(#{3,5}) /gm, "#$1 "), "");
1499
+ else if (text) extra.push(text, "");
1500
+ }
1501
+ if (extra.length) {
1502
+ const current = sectionText(body, "紀錄") ?? "";
1503
+ body = withSection(body, "紀錄", [current, extra.join("\n").trim()].filter(Boolean).join("\n\n"));
1504
+ }
1505
+ return body;
1506
+ }
1507
+ /** Criteria and checklist items: format 2 omits nulls and names a met criterion done. */
1508
+ function migrateListItem(item) {
1509
+ rename(item, "met", "done");
1510
+ dropNull(item, "evidence", "doneAt");
1511
+ return item;
1512
+ }
1513
+ const openItem = (item) => !item.done && !item.dropped;
1514
+ /** Convert a format 1 hub to the current format, writing nothing when `dryRun` is set. */
1515
+ function migrateHub(root, options = {}) {
1516
+ const dataDir = join(root, DATA_DIR);
1517
+ const configFile = join(dataDir, "config.yaml");
1518
+ if (!existsSync(configFile)) throw new WriteError(`no hub at ${root}; run mainflow init first`);
1519
+ const rel = (path) => relative(root, path);
1520
+ return withLock(root, () => {
1521
+ const config = parse(readFileSync(configFile, "utf8"));
1522
+ const from = Number(config.formatVersion);
1523
+ if (from === 2) throw new WriteError(`${rel(configFile)} is already format 2; nothing to migrate`);
1524
+ if (from !== 1) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 to 2`);
1525
+ const writes = /* @__PURE__ */ new Map();
1526
+ const removes = [];
1527
+ const missing = [];
1528
+ const document = (file, schema, data, body) => {
1529
+ writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
1530
+ };
1531
+ for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
1532
+ const yamlFile = join(mainlineDir, "mainline.yaml");
1533
+ if (!existsSync(yamlFile)) continue;
1534
+ const m = parse(readFileSync(yamlFile, "utf8"));
1535
+ dropNull(m, "archivedAt");
1536
+ if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
1537
+ const criteria = (m.criteria ?? []).map(migrateListItem);
1538
+ m.criteria = criteria;
1539
+ const id = String(m.id);
1540
+ const open = !isClosed("mainline", m.status);
1541
+ if (open) {
1542
+ for (const c of criteria) if (openItem(c) && !c.doneWhen) missing.push(`${id}#${c.id} doneWhen`);
1543
+ }
1544
+ const contextFile = join(mainlineDir, "context.md");
1545
+ const context = existsSync(contextFile) ? withoutTitle(readFileSync(contextFile, "utf8")) : "";
1546
+ document(join(mainlineDir, MAINLINE_FILE), mainlineSchema, m, `\n${context.trimEnd()}\n`);
1547
+ removes.push(yamlFile, ...existsSync(contextFile) ? [contextFile] : []);
1548
+ for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
1549
+ const taskYaml = join(taskDir, "task.yaml");
1550
+ if (!existsSync(taskYaml)) continue;
1551
+ const t = parse(readFileSync(taskYaml, "utf8"));
1552
+ const content = typeof t.content === "string" ? t.content.trim() : "";
1553
+ drop(t, "content");
1554
+ dropNull(t, "parent", "repo");
1555
+ t.created ??= m.created;
1556
+ const checklist = (t.checklist ?? []).map(migrateListItem);
1557
+ t.checklist = checklist;
1558
+ const doneDays = checklist.map((c) => c.doneAt).filter((d) => typeof d === "string").sort();
1559
+ if (isClosed("task", t.status) && doneDays.length) t.closedAt = doneDays.at(-1);
1560
+ let body = content ? withSection(TASK_BODY, "目標", content) : TASK_BODY;
1561
+ const notesFile = join(taskDir, "notes.md");
1562
+ if (existsSync(notesFile)) {
1563
+ body = foldNotes(body, readFileSync(notesFile, "utf8"));
1564
+ removes.push(notesFile);
1565
+ }
1566
+ const taskRef = `${id}/${String(t.id)}`;
1567
+ if (open && !isClosed("task", t.status)) {
1568
+ for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(body, section)) missing.push(`${taskRef} ${section}`);
1569
+ for (const c of checklist) if (openItem(c) && !c.doneWhen) missing.push(`${taskRef}#${c.id} doneWhen`);
1570
+ }
1571
+ document(join(taskDir, TASK_FILE), taskSchema, t, body);
1572
+ removes.push(taskYaml);
1573
+ }
1574
+ const decisionsDir = join(mainlineDir, "decisions");
1575
+ for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
1576
+ const file = join(decisionsDir, name);
1577
+ const { data, body } = splitFrontMatter(readFileSync(file, "utf8"));
1578
+ const d = data;
1579
+ rename(d, "date", "created");
1580
+ if (d.status !== "proposed") d.decidedAt = d.created;
1581
+ dropNull(d, "supersededBy");
1582
+ rename(d, "blocks", "affects");
1583
+ document(file, decisionSchema, d, body);
1584
+ }
1585
+ }
1586
+ const hubDocs = (dir, migrate, schema) => {
1587
+ for (const name of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(".md")).sort() : []) {
1588
+ const file = join(dir, name);
1589
+ const { data, body } = splitFrontMatter(readFileSync(file, "utf8"));
1590
+ migrate(data);
1591
+ document(file, schema, data, body);
1592
+ }
1593
+ };
1594
+ hubDocs(join(dataDir, "issues"), (i) => rename(i, "closedBy", "resolution"), issueSchema);
1595
+ hubDocs(join(dataDir, "findings"), (f) => {
1596
+ rename(f, "date", "created");
1597
+ rename(f, "tags", "labels");
1598
+ }, findingSchema);
1599
+ config.formatVersion = 2;
1600
+ writes.set(configFile, toYaml(validate(configSchema, config, rel(configFile))));
1601
+ if (!options.dryRun) {
1602
+ for (const [file, text] of writes) writeAtomic(file, text);
1603
+ for (const file of removes) rmSync(file);
1604
+ }
1605
+ return {
1606
+ from,
1607
+ to: 2,
1608
+ written: [...writes.keys()].map(rel),
1609
+ removed: removes.map(rel),
1610
+ missingDefinitions: missing
1611
+ };
1612
+ });
1613
+ }
1614
+ //#endregion
1615
+ export { createFinding as A, refIndex as B, startSession as C, turnReminder as D, sessionBriefing as E, setFindingStatus as F, DATA_DIR as G, TASK_FILE as H, setIssueArchived as I, worktreesOf as J, currentBranch as K, HubError as L, planIssue as M, reopenIssue as N, appendOps as O, setFindingArchived as P, loadHub as R, setSessionStatus as S, updateAction as T, ConflictError as U, MAINLINE_FILE as V, WriteError as W, ensureSession as _, createDecision as a, setSessionArchived as b, dropChecklistItem as c, setChecklistItem as d, setCriterion as f, addAction as g, updateTask as h, addRepo as i, createIssue as j, closeIssue as k, initHub as l, supersedeDecision as m, acceptDecision as n, createMainline as o, setMainlineStatus as p, findHubRoot as q, addHub as r, createTask as s, migrateHub as t, setArchived as u, loadSessions as v, transcriptPath as w, setSessionInfo as x, readSession as y, linksInto as z };