mainflow 0.42.0 → 0.43.0-alpha.2

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,14 +1,20 @@
1
- import { $ as stampMs, A as TASK_REQUIRED_SECTIONS, B as mainlineSchema, C as RESERVED_HUB_NAMES, D as SLUG, E as SESSION_STATUS, F as decisionSchema, G as parseUntil, H as parseActionRef, I as findingSchema, J as sessionSchema, K as refExample, L as formatOpsEntry, M as UNTIL_FORMS, N as WORK_STATUS, O as SURVEY_LINES, P as configSchema, Q as OFFSET, R as groupSchema, S as REF_EXAMPLES, T as SESSION_ID, U as parseOpsLine, V as opsEntrySchema, W as parseRef, X as waitingForAction, Y as taskSchema, Z as waitingForRef, a as DECISION_SECTIONS, at as grantForm, b as MERGE_STATES, c as FINDING_SECTIONS, d as GROUP_SECTIONS, f as GROUP_STATUS, h as ISSUE_SECTIONS, i as DECISION_BASES, it as grantCovers, j as TASK_SECTIONS, k as SURVEY_SECTION, l as FINDING_STATUS, n as CLOSED_STATUS, ot as grantRepo, q as refMainline, r as DECIDERS, rt as describeRequest, s as DEFAULT_CATEGORIES, st as parseGrantTarget, v as MAINLINE_SECTIONS, w as SERVES_REF, x as PRIORITIES, y as MAINLINE_STATUS, z as issueSchema } from "./dist-zSabglPq.js";
1
+ import { $ as removedUntil, A as SURVEY_LINES, B as groupSchema, C as PRIORITIES, D as SESSION_ID, E as SERVES_REF, F as WORK_STATUS, G as mainlineSchema, H as isMainflowRange, I as configSchema, J as parseOpsLine, K as opsEntrySchema, L as decisionSchema, M as TASK_REQUIRED_SECTIONS, N as TASK_SECTIONS, O as SESSION_STATUS, P as UNTIL_FORMS, Q as refMainline, R as findingSchema, S as MERGE_STATES, T as RESERVED_HUB_NAMES, U as issueSchema, V as hubRegistrySchema, W as listedRepos, X as parseUntil, Y as parseRef, Z as refExample, a as DECISION_SECTIONS, at as waitingForAction, b as MAINLINE_SECTIONS, c as FINDING_SECTIONS, ct as stampMs, d as GROUP_SECTIONS, et as repoPath, f as GROUP_STATUS, ft as describeRequest, g as ISSUE_SECTIONS, gt as parseGrantTarget, ht as grantRepo, i as DECISION_BASES, j as SURVEY_SECTION, k as SLUG, l as FINDING_STATUS, m as HUB_NAME, mt as grantForm, n as CLOSED_STATUS, nt as sessionSchema, ot as waitingForRef, pt as grantCovers, q as parseActionRef, r as DECIDERS, rt as taskSchema, s as DEFAULT_CATEGORIES, st as OFFSET, w as REF_EXAMPLES, x as MAINLINE_STATUS, y as MAINFLOW_RANGE, z as formatOpsEntry } from "./dist-D5rZ0MQH.js";
2
2
  import { a as LABEL_ROLES, i as FINDING_SEVERITIES, n as FINDING_CLASSES, r as FINDING_FIELDS, t as FINDINGS_FENCE } from "./retro-formats-ECy0gGOL.js";
3
3
  import { createHash } from "node:crypto";
4
- import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
4
+ import { appendFileSync, closeSync, cpSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
5
5
  import { basename, dirname, isAbsolute, join, matchesGlob, parse, posix, relative, resolve, sep } from "node:path";
6
6
  import { execFileSync, spawn, spawnSync } from "node:child_process";
7
7
  import { parse as parse$1, stringify } from "yaml";
8
8
  import { homedir } from "node:os";
9
+ import satisfies from "semver/functions/satisfies.js";
9
10
  import { performance } from "node:perf_hooks";
10
11
  import { gunzipSync, inflateRawSync } from "node:zlib";
11
12
  //#region ../core/dist/ops/document.js
13
+ /**
14
+ * How messages and the interface name the hub's data directory, whichever directory holds it on a machine: a file of
15
+ * the hub is `.mainflow/{path}`, its path in the data directory. Also the name of the data directory inside a hub
16
+ * repository before the data moved to the user's mainflow directory (`mainflow hub migrate-data`).
17
+ */
12
18
  const DATA_DIR = ".mainflow";
13
19
  const ENTRY_DIR = /^([mtdifw]\d{3})-(.+)$/;
14
20
  var WriteError = class extends Error {};
@@ -45,57 +51,92 @@ function stamps(ctx) {
45
51
  }
46
52
  //#endregion
47
53
  //#region ../core/dist/paths.js
48
- const isHub = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
49
- const git = (cwd, ...args) => {
50
- const result = spawnSync("git", [
51
- "-C",
52
- cwd,
53
- ...args
54
- ], { encoding: "utf8" });
55
- return result.status === 0 ? result.stdout.trim() : "";
56
- };
57
- /** The main working tree and the current working tree of the repository containing `cwd`, or null outside git. */
58
- function worktreesOf(cwd) {
59
- const common = git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir");
60
- const current = git(cwd, "rev-parse", "--show-toplevel");
61
- if (!common.endsWith(`${sep}.git`) || !current) return null;
62
- return {
63
- main: dirname(common),
64
- current
65
- };
54
+ /**
55
+ * The absolute path of a file of the hub that messages and the interface name by its path in the data directory,
56
+ * written `.mainflow/{path}` (hubPath). Any other path is taken as relative to the hub repository.
57
+ */
58
+ function dataFile(dirs, named) {
59
+ if (named === ".mainflow") return dirs.data;
60
+ if (named.startsWith(`.mainflow/`)) return join(dirs.data, named.slice(10));
61
+ return resolve(dirs.root, named);
66
62
  }
67
- /** The branch checked out at `cwd`, or null outside git or on a detached HEAD. */
68
- function currentBranch(cwd) {
69
- return git(cwd, "branch", "--show-current") || null;
63
+ /**
64
+ * How messages and the interface name `file`, an absolute path in the hub's data directory: `.mainflow/{path}`, with
65
+ * `/` between names whatever the platform. The name is the same in every environment of the hub, wherever its data
66
+ * directory is on each machine. A file outside the data directory keeps its absolute path.
67
+ */
68
+ function hubPath(dirs, file) {
69
+ const path = relative(dirs.data, file);
70
+ if (path === "") return DATA_DIR;
71
+ if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) return file;
72
+ return `${DATA_DIR}/${path.split(sep).join("/")}`;
70
73
  }
71
74
  /**
72
- * The hub for `cwd`: the first directory upwards holding `.mainflow/config.yaml`, except that a linked
73
- * worktree's checkout of a hub at the repository root resolves to the same hub in the main working tree.
74
- * A worktree is another checkout of the same repository, so without this every worktree would write its
75
- * own copy of the hub and the copies would conflict on merge. A hub below the root (a sample hub under
76
- * examples/, a scratch hub under tmp/) stays as found: it is data the worktree's own branch owns, and
77
- * mapping it would let tests and migrations in the worktree write the main tree's copy.
75
+ * `path` as the file system spells it, symbolic links resolved, or null when it does not exist. The native call also
76
+ * takes each name's case from the disk, so on a file system that ignores case (macOS and Windows by default) two
77
+ * spellings of one directory come out the same, and on one that keeps case apart (Linux) they stay two directories.
78
78
  */
79
- function findHubRoot(cwd) {
80
- let found = null;
81
- for (let dir = cwd;; dir = dirname(dir)) {
82
- if (isHub(dir)) {
83
- found = dir;
84
- break;
79
+ function realPath(path) {
80
+ try {
81
+ return realpathSync.native(path);
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * The git checkout holding `dir`, read from the `.git` entries on disk without running git. `current` is the
88
+ * checkout's root. `main` is the main working tree of the repository: the checkout itself, or, for a linked
89
+ * worktree, the directory holding the `.git` directory its `gitdir` points into (through `commondir`); null
90
+ * for a worktree of a bare repository, which has no main working tree. A submodule is a checkout of its own.
91
+ */
92
+ function gitCheckout(dir) {
93
+ for (let at = resolve(dir);; at = dirname(at)) {
94
+ const dotGit = join(at, ".git");
95
+ const entry = statSync(dotGit, { throwIfNoEntry: false });
96
+ if (entry?.isDirectory()) return {
97
+ current: at,
98
+ main: at
99
+ };
100
+ if (entry?.isFile()) {
101
+ const gitdir = /^gitdir:\s*(.+?)\s*$/m.exec(readText$1(dotGit) ?? "")?.[1];
102
+ const commondir = gitdir ? readText$1(join(resolve(at, gitdir), "commondir"))?.trim() : void 0;
103
+ if (!gitdir || !commondir) return {
104
+ current: at,
105
+ main: at
106
+ };
107
+ const common = realPath(resolve(at, gitdir, commondir)) ?? resolve(at, gitdir, commondir);
108
+ return {
109
+ current: at,
110
+ main: basename(common) === ".git" ? dirname(common) : null
111
+ };
85
112
  }
86
- if (dirname(dir) === dir) break;
113
+ if (dirname(at) === at) return null;
87
114
  }
88
- const trees = worktreesOf(cwd);
89
- if (!trees || trees.main === trees.current) return found;
90
- if (found && found !== trees.current) return found;
91
- return isHub(trees.main) ? trees.main : found;
92
115
  }
93
- function subdirs(dir) {
116
+ /** The main working tree and the current working tree of the repository containing `cwd`, or null outside git or in a worktree of a bare repository. */
117
+ function worktreesOf(cwd) {
118
+ const checkout = gitCheckout(realPath(cwd) ?? cwd);
119
+ return checkout?.main ? {
120
+ main: checkout.main,
121
+ current: checkout.current
122
+ } : null;
123
+ }
124
+ /** The branch checked out at `cwd`, or null outside git or on a detached HEAD. */
125
+ function currentBranch(cwd) {
126
+ const result = spawnSync("git", [
127
+ "-C",
128
+ cwd,
129
+ "branch",
130
+ "--show-current"
131
+ ], { encoding: "utf8" });
132
+ return (result.status === 0 ? result.stdout.trim() : "") || null;
133
+ }
134
+ function subdirs$1(dir) {
94
135
  if (!existsSync(dir)) return [];
95
136
  return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(dir, entry.name)).sort();
96
137
  }
97
138
  /** Read a UTF-8 file, or return null when it does not exist. */
98
- function readText(file) {
139
+ function readText$1(file) {
99
140
  return existsSync(file) ? readFileSync(file, "utf8") : null;
100
141
  }
101
142
  /** List every file below `dir`, sorted, skipping dot files. */
@@ -107,6 +148,586 @@ function listFiles(dir) {
107
148
  }).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => join(entry.parentPath, entry.name)).sort();
108
149
  }
109
150
  //#endregion
151
+ //#region ../core/dist/storage/node.js
152
+ const LOCK_WAIT_MS = 5e3;
153
+ const LOCK_STALE_MS = 3e4;
154
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
155
+ /**
156
+ * Run `fn` while holding the hub-wide write lock in `local/` of the data directory `data`. When the lock had to create
157
+ * that directory and `fn` refuses the write, the directory goes again, so a refused write leaves the hub as it was.
158
+ */
159
+ function withLock(data, fn) {
160
+ const dir = join(data, "local");
161
+ const created = lockDir(data);
162
+ try {
163
+ return holdLock(join(dir, "write.lock"), fn);
164
+ } catch (error) {
165
+ if (created) removeIfEmpty(dir);
166
+ throw error;
167
+ }
168
+ }
169
+ /** withLock for an `fn` that waits on something, such as a request: the lock is held until its promise settles. */
170
+ async function withLockAsync(data, fn) {
171
+ const dir = join(data, "local");
172
+ const lock = join(dir, "write.lock");
173
+ const created = lockDir(data);
174
+ try {
175
+ acquireLock(lock);
176
+ } catch (error) {
177
+ if (created) removeIfEmpty(dir);
178
+ throw error;
179
+ }
180
+ try {
181
+ return await fn();
182
+ } catch (error) {
183
+ rmSync(lock, { force: true });
184
+ if (created) removeIfEmpty(dir);
185
+ throw error;
186
+ } finally {
187
+ rmSync(lock, { force: true });
188
+ }
189
+ }
190
+ /**
191
+ * Make `local/` of the data directory `data` for its lock, and say whether it had to. The data directory itself is never
192
+ * made: a writer still holding a path the hub's data moved away from (mainflow hub migrate-data) fails here instead
193
+ * of making an empty data directory there.
194
+ */
195
+ function lockDir(data) {
196
+ try {
197
+ mkdirSync(join(data, "local"));
198
+ return true;
199
+ } catch (error) {
200
+ if (error.code === "EEXIST") return false;
201
+ if (error.code === "ENOENT") throw missingData(data);
202
+ throw error;
203
+ }
204
+ }
205
+ const missingData = (data) => new WriteError(`the hub's data directory ${data} does not exist; when the hub's data moved, run the command again, which reads where it is now from the hub registry`);
206
+ function removeIfEmpty(dir) {
207
+ try {
208
+ rmdirSync(dir);
209
+ } catch {}
210
+ }
211
+ /**
212
+ * Run `fn` while holding the lock file `lock`, created exclusively and removed afterwards. A lock older than
213
+ * LOCK_STALE_MS belongs to a writer that died and is taken over; past `waitMs` (LOCK_WAIT_MS by default) the wait
214
+ * ends with a WriteError saying `what` is locked. With `waitMs` 0 the lock is tried once and a held one fails at once.
215
+ */
216
+ function holdLock(lock, fn, options = {}) {
217
+ acquireLock(lock, options);
218
+ try {
219
+ return fn();
220
+ } finally {
221
+ rmSync(lock, { force: true });
222
+ }
223
+ }
224
+ /**
225
+ * Take the lock file `lock` as holdLock does; the caller removes it. When the lock's directory is gone, it is made
226
+ * again, but not its parent: a lock whose whole directory moved away, as a hub's data directory does, fails instead.
227
+ */
228
+ function acquireLock(lock, { what = "hub", waitMs = LOCK_WAIT_MS } = {}) {
229
+ const dir = dirname(lock);
230
+ const deadline = Date.now() + waitMs;
231
+ let vanished = 0;
232
+ for (;;) try {
233
+ const fd = openSync(lock, "wx");
234
+ writeSync(fd, String(process.pid));
235
+ closeSync(fd);
236
+ return;
237
+ } catch (error) {
238
+ if (error.code === "ENOENT") {
239
+ try {
240
+ mkdirSync(dir);
241
+ } catch (made) {
242
+ if (made.code === "ENOENT") throw what === "hub" ? missingData(dirname(dir)) : new WriteError(`${dirname(dir)}, which holds the lock of ${what}, does not exist`);
243
+ if (made.code !== "EEXIST") throw made;
244
+ }
245
+ continue;
246
+ }
247
+ if (error.code !== "EEXIST") throw error;
248
+ const held = statSync(lock, { throwIfNoEntry: false });
249
+ if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
250
+ rmSync(lock, { force: true });
251
+ continue;
252
+ }
253
+ if (Date.now() >= deadline) throw new WriteError(`${what} is locked by another writer (${lock}); retry in a moment`);
254
+ sleep(held ? 50 : Math.min(2 ** vanished++, 50));
255
+ }
256
+ }
257
+ /** Write through a temporary file and a rename, so a reader never sees half a file. */
258
+ function writeAtomic(file, text) {
259
+ const temp = `${file}.${process.pid}.tmp`;
260
+ writeFileSync(temp, text);
261
+ renameSync(temp, file);
262
+ }
263
+ /** The hub at `dirs`, its data directory read and written on the local file system. */
264
+ function nodeStorage(dirs) {
265
+ const file = (path) => join(dirs.data, path);
266
+ return {
267
+ read: (path) => {
268
+ const target = file(path);
269
+ return existsSync(target) ? readFileSync(target, "utf8") : null;
270
+ },
271
+ list: (dir) => {
272
+ const target = file(dir);
273
+ if (!existsSync(target)) return [];
274
+ return readdirSync(target, { withFileTypes: true }).map((entry) => ({
275
+ name: entry.name,
276
+ dir: entry.isDirectory()
277
+ })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
278
+ },
279
+ where: file,
280
+ write: (path, text) => {
281
+ mkdirSync(dirname(file(path)), { recursive: true });
282
+ writeAtomic(file(path), text);
283
+ },
284
+ append: (path, text) => {
285
+ mkdirSync(dirname(file(path)), { recursive: true });
286
+ appendFileSync(file(path), text);
287
+ },
288
+ makeDir: (path) => {
289
+ mkdirSync(file(path), { recursive: true });
290
+ },
291
+ remove: (path) => {
292
+ rmSync(file(path), { force: true });
293
+ },
294
+ lock: (fn) => withLock(dirs.data, fn)
295
+ };
296
+ }
297
+ //#endregion
298
+ //#region ../core/dist/registry.js
299
+ /** The registry's file name inside MAINFLOW_HOME. */
300
+ const REGISTRY_FILE = "hubs.json";
301
+ /** How a hub is registered, for the messages that ask for it. */
302
+ const REGISTER_USAGE = "mainflow hub register <name> <path>";
303
+ /** The user-level mainflow directory: MAINFLOW_HOME when set, ~/.mainflow otherwise. */
304
+ const mainflowHome = (env = process.env) => resolve(env.MAINFLOW_HOME || join(homedir(), ".mainflow"));
305
+ /** The registry file of the user-level mainflow directory. */
306
+ const registryFile = (env = process.env) => join(mainflowHome(env), REGISTRY_FILE);
307
+ /** The data directory of hub `name` in the user-level mainflow directory: $MAINFLOW_HOME/<name>. */
308
+ const userDataDir = (name, env = process.env) => join(mainflowHome(env), name);
309
+ /**
310
+ * The data directory a hub kept inside its repository before its data moved to MAINFLOW_HOME, `<root>/.mainflow`, when
311
+ * it holds a hub: what `mainflow hub migrate-data` moves.
312
+ */
313
+ function inRepositoryData(root) {
314
+ const data = join(root, DATA_DIR);
315
+ const name = /^hub:\s*["']?([a-z0-9-]+)/m.exec(readText(join(data, "config.yaml")) ?? "")?.[1];
316
+ return name ? {
317
+ name,
318
+ data
319
+ } : null;
320
+ }
321
+ const readText = (file) => {
322
+ try {
323
+ return readFileSync(file, "utf8");
324
+ } catch {
325
+ return null;
326
+ }
327
+ };
328
+ const empty = () => ({
329
+ version: 1,
330
+ hubs: {}
331
+ });
332
+ /** The registry as the file holds it; empty when there is no file yet. */
333
+ function readRegistry(env = process.env) {
334
+ const file = registryFile(env);
335
+ const text = readText(file);
336
+ if (text === null) return empty();
337
+ const redo = `fix it, or delete it and run ${REGISTER_USAGE} for each hub again`;
338
+ let data;
339
+ try {
340
+ data = JSON.parse(text);
341
+ } catch (error) {
342
+ throw new WriteError(`${file} is not JSON (${error.message}); ${redo}`);
343
+ }
344
+ const version = data?.version;
345
+ if (typeof version === "number" && version > 1) throw new WriteError(`${file} is format ${version}, written by a newer mainflow; this one reads format 1, so install the newer mainflow`);
346
+ const parsed = hubRegistrySchema.safeParse(data);
347
+ if (!parsed.success) throw new WriteError(`${file} is not a hub registry: ${parsed.error.issues.map((i) => `${i.path.join(".") || "(top)"}: ${i.message}`).join("; ")}; ${redo}`);
348
+ return parsed.data;
349
+ }
350
+ /**
351
+ * Change the registry under its lock: read it, let `change` edit it, and write it back when it changed. The lock is
352
+ * waited for as holdLock does, or with `wait` false tried once, so a hook never waits on another writer.
353
+ */
354
+ function updateRegistry(env, change, wait = true) {
355
+ const file = registryFile(env);
356
+ mkdirSync(mainflowHome(env), { recursive: true });
357
+ return holdLock(`${file}.lock`, () => {
358
+ const registry = readRegistry(env);
359
+ const before = JSON.stringify(registry);
360
+ const result = change(registry);
361
+ const after = JSON.stringify(registry, null, 2);
362
+ if (JSON.stringify(registry) !== before) writeAtomic(file, `${after}\n`);
363
+ return result;
364
+ }, {
365
+ what: "the hub registry",
366
+ ...wait ? {} : { waitMs: 0 }
367
+ });
368
+ }
369
+ /** The repositories a hub is the primary hub of, with the names config.yaml gives them; the hub repository first. */
370
+ const primaries = (hub) => [["(the hub repository)", hub.root], ...Object.entries(hub.repos).filter(([, path]) => path !== hub.root)];
371
+ /** Why the hub repository `root` cannot be registered with the data directory `data`, which holds no config.yaml. */
372
+ function noDataMessage(root, data, env) {
373
+ const inRepository = inRepositoryData(root);
374
+ if (inRepository) return `hub "${inRepository.name}" still keeps its data inside its repository (${inRepository.data}); move it to ${mainflowHome(env)} and register the hub with: mainflow hub migrate-data ${root}`;
375
+ return `no hub data at ${data}: ${join(data, "config.yaml")} does not exist`;
376
+ }
377
+ /**
378
+ * Whether the hub registered from `root` with the data directory `data` is a new environment: neither holds a
379
+ * config.yaml. A repository that still keeps a hub's data inside it is not, since that data moves with
380
+ * `mainflow hub migrate-data`.
381
+ */
382
+ function awaitsData(root, data) {
383
+ const real = realPath(root);
384
+ if (!real) return false;
385
+ const dir = realPath(data) ?? resolve(data);
386
+ return readText(join(dir, "config.yaml")) === null && inRepositoryData(real) === null;
387
+ }
388
+ /** The entry of hub `name` whose files are not on this machine yet. */
389
+ function pendingEntry(name, root, data) {
390
+ if (!HUB_NAME.test(name) || RESERVED_HUB_NAMES.includes(name)) throw new WriteError(`"${name}" is not a hub name: use lowercase letters, digits and hyphens, and not ${RESERVED_HUB_NAMES.join(" or ")}`);
391
+ const real = realPath(root);
392
+ if (!real) throw new WriteError(`${root} does not exist`);
393
+ mkdirSync(data, { recursive: true });
394
+ return {
395
+ name,
396
+ entry: {
397
+ root: real,
398
+ data: realPath(data) ?? resolve(data),
399
+ config: "",
400
+ repos: {},
401
+ references: {}
402
+ },
403
+ missing: []
404
+ };
405
+ }
406
+ /** The registry entry of the hub whose repository is `root` and whose data directory is `data`, derived from its config.yaml. */
407
+ function derive(root, data, env) {
408
+ const real = realPath(root);
409
+ if (!real) throw new WriteError(`${root} does not exist`);
410
+ const dir = realPath(data) ?? resolve(data);
411
+ const file = join(dir, "config.yaml");
412
+ const text = readText(file);
413
+ if (text === null) throw new WriteError(noDataMessage(real, dir, env));
414
+ let raw;
415
+ try {
416
+ raw = parse$1(text);
417
+ } catch (error) {
418
+ throw new WriteError(`${file} is not valid YAML: ${error.message}`);
419
+ }
420
+ const parsed = configSchema.pick({
421
+ hub: true,
422
+ repos: true
423
+ }).safeParse(raw);
424
+ if (!parsed.success) throw new WriteError(`${file} is invalid: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
425
+ const entry = {
426
+ root: real,
427
+ data: dir,
428
+ config: contentHash(text),
429
+ repos: {},
430
+ references: {}
431
+ };
432
+ const missing = [];
433
+ for (const repo of listedRepos(parsed.data.repos)) {
434
+ const listed = resolve(real, repo.path);
435
+ const path = realPath(listed);
436
+ if (!path) missing.push(`${repo.name} (${repo.path})`);
437
+ (repo.role === "primary" ? entry.repos : entry.references)[repo.name] = path ?? listed;
438
+ }
439
+ return {
440
+ name: parsed.data.hub,
441
+ entry,
442
+ missing
443
+ };
444
+ }
445
+ /** Each primary repository of `entry`, to be hub `name`, that another registered hub already has, one line each. */
446
+ function claimConflicts(registry, name, entry) {
447
+ const out = [];
448
+ for (const [other, hub] of Object.entries(registry.hubs)) {
449
+ if (other === name || hub.root === entry.root) continue;
450
+ const theirs = new Set(primaries(hub).map(([, path]) => path));
451
+ for (const [repo, path] of primaries(entry)) {
452
+ if (!theirs.has(path)) continue;
453
+ out.push(`${repo === "(the hub repository)" ? "the hub repository" : `repo "${repo}"`} ${path} is already the primary repository of hub "${other}" (registered from ${hub.root})`);
454
+ }
455
+ }
456
+ return out;
457
+ }
458
+ /** The message for the conflicts claimConflicts found, opening with `lead`, and what to do about them. */
459
+ const conflictMessage = (lead, conflicts) => [
460
+ lead,
461
+ ...conflicts.map((c) => ` ${c}`),
462
+ "A repository has one primary hub. In the config.yaml of the hub that should only reference it, write it under repos as { path: ..., role: reference }, then run mainflow hub register again."
463
+ ].join("\n");
464
+ /**
465
+ * The data directory of hub `name` registered from the repository `root`: the one `options` names, else the one the
466
+ * hub is registered with already, under this name or, when the hub was renamed, from this repository; else its
467
+ * directory in MAINFLOW_HOME.
468
+ */
469
+ function dataDirFor(registry, name, root, env) {
470
+ const real = realPath(root);
471
+ return registry.hubs[name]?.data ?? Object.values(registry.hubs).find((hub) => hub.root === real)?.data ?? userDataDir(name, env);
472
+ }
473
+ /**
474
+ * Register the hub whose repository is `path`, or bring its entry up to date. `name` must be the name its config.yaml
475
+ * gives it. Its data directory is `options.data` when given, as when its data has just moved there; otherwise the one
476
+ * it is registered with already, or $MAINFLOW_HOME/<name> for a hub not registered yet. Refused when that directory
477
+ * holds no hub, or when a repository the hub is the primary hub of is already another hub's primary repository. A new
478
+ * environment (no entry yet, or one registered without data), where neither the repository nor that directory holds any hub data, is registered with only the name,
479
+ * the repository and the data directory (`pending`); its repositories are matched, and checked against the other
480
+ * hubs, once `mainflow sync` has downloaded config.yaml.
481
+ */
482
+ function registerHub(name, path, env = process.env, options = {}) {
483
+ const data = options.data ?? dataDirFor(readRegistry(env), name, path, env);
484
+ let derived;
485
+ if (awaitsData(path, data)) {
486
+ const registry = readRegistry(env);
487
+ const real = realPath(path);
488
+ const [knownName, known] = registry.hubs[name] ? [name, registry.hubs[name]] : Object.entries(registry.hubs).find(([, hub]) => hub.root === real) ?? [];
489
+ if (known && known.config !== "") throw new WriteError(`${join(realPath(data) ?? resolve(data), "config.yaml")} does not exist, but hub "${knownName}" is registered with its data in ${known.data}; move the data back there, or run mainflow hub unregister ${knownName} and register it again`);
490
+ derived = pendingEntry(name, path, data);
491
+ } else derived = derivedAs(name, path, data, env);
492
+ return updateRegistry(env, (registry) => {
493
+ const registration = planRegistration(registry, name, derived);
494
+ if (registration.renamedFrom) delete registry.hubs[registration.renamedFrom];
495
+ registry.hubs[name] = derived.entry;
496
+ return registration;
497
+ });
498
+ }
499
+ /**
500
+ * What registerHub would do with the same arguments, without writing the registry: it throws what registerHub would
501
+ * refuse. `options.data` is required here, since the hub's data may not be in its directory yet.
502
+ */
503
+ function previewRegistration(name, path, env, options) {
504
+ return planRegistration(readRegistry(env), name, derivedAs(name, path, options.data, env));
505
+ }
506
+ /** The entry of the hub at `path` with its data in `data`, which must be hub `name`. */
507
+ function derivedAs(name, path, data, env) {
508
+ const derived = derive(path, data, env);
509
+ if (derived.name !== name) throw new WriteError(`${derived.entry.data} holds hub "${derived.name}"; register it as "${derived.name}"`);
510
+ return derived;
511
+ }
512
+ /** What registering `derived` as hub `name` changes in `registry`; refused when another hub has its repositories. */
513
+ function planRegistration(registry, name, derived) {
514
+ const conflicts = claimConflicts(registry, name, derived.entry);
515
+ if (conflicts.length) throw new WriteError(conflictMessage(`hub "${name}" is not registered, since another hub already has its repositories:`, conflicts));
516
+ const previous = registry.hubs[name] ?? null;
517
+ const renamedFrom = Object.keys(registry.hubs).find((other) => other !== name && registry.hubs[other].root === derived.entry.root) ?? null;
518
+ return {
519
+ name,
520
+ entry: derived.entry,
521
+ previous,
522
+ renamedFrom,
523
+ missing: derived.missing,
524
+ pending: derived.entry.config === ""
525
+ };
526
+ }
527
+ /** Remove a hub from the registry; its files stay as they are. */
528
+ function unregisterHub(name, env = process.env) {
529
+ return updateRegistry(env, (registry) => {
530
+ const hub = registry.hubs[name];
531
+ if (!hub) throw new WriteError(`hub "${name}" is not registered; registered: ${Object.keys(registry.hubs).join(", ") || "(none)"}`);
532
+ delete registry.hubs[name];
533
+ return hub;
534
+ });
535
+ }
536
+ /** Whether an entry may no longer say what its config.yaml says: the file changed, or a path it holds now resolves differently, as when a listed repository was cloned. */
537
+ function stale(hub) {
538
+ const text = readText(join(hub.data, "config.yaml"));
539
+ if (text === null && hub.config === "") return false;
540
+ if (text === null || contentHash(text) !== hub.config) return true;
541
+ return [
542
+ hub.root,
543
+ hub.data,
544
+ ...Object.values(hub.repos),
545
+ ...Object.values(hub.references)
546
+ ].some((path) => realPath(path) !== path);
547
+ }
548
+ /** Derive the entries of `names` again where stale; what cannot be updated keeps its entry and says why. */
549
+ function refresh(registry, names, env) {
550
+ const warnings = [];
551
+ for (const name of names) {
552
+ const hub = registry.hubs[name];
553
+ if (!hub || !stale(hub)) continue;
554
+ let derived;
555
+ try {
556
+ derived = derive(hub.root, hub.data, env);
557
+ } catch (error) {
558
+ warnings.push(`hub "${name}" is registered from ${hub.root}, which cannot be read again (${error.message}); register it where it is now, or run mainflow hub unregister ${name}`);
559
+ continue;
560
+ }
561
+ if (derived.name !== name) {
562
+ warnings.push(`${hub.data} now holds hub "${derived.name}", registered as "${name}"; run mainflow hub register ${derived.name} ${hub.root}`);
563
+ continue;
564
+ }
565
+ const conflicts = claimConflicts(registry, name, derived.entry);
566
+ if (conflicts.length) {
567
+ warnings.push(conflictMessage(`the changed config.yaml of hub "${name}" is not applied, since another hub already has its repositories:`, conflicts));
568
+ continue;
569
+ }
570
+ registry.hubs[name] = derived.entry;
571
+ }
572
+ return warnings;
573
+ }
574
+ /**
575
+ * The registry with the entries of `names` derived again where stale. The file is rewritten only when something
576
+ * changed; when it cannot be, as when another writer holds the lock and `wait` is false, the updated entries are still
577
+ * used for this run.
578
+ */
579
+ function refreshed(env, names, warn, wait = true) {
580
+ const registry = readRegistry(env);
581
+ const probe = structuredClone(registry);
582
+ const warnings = refresh(probe, names, env);
583
+ if (JSON.stringify(probe) === JSON.stringify(registry)) {
584
+ warnings.forEach(warn);
585
+ return registry;
586
+ }
587
+ try {
588
+ return updateRegistry(env, (locked) => {
589
+ refresh(locked, names, env);
590
+ return locked;
591
+ }, wait);
592
+ } catch (error) {
593
+ warn(`cannot update ${registryFile(env)}: ${error.message}`);
594
+ return probe;
595
+ } finally {
596
+ warnings.forEach(warn);
597
+ }
598
+ }
599
+ /** Every registered hub, each entry brought up to date with its config.yaml first. */
600
+ function listHubs(options = {}) {
601
+ const env = options.env ?? process.env;
602
+ return refreshed(env, Object.keys(readRegistry(env).hubs), options.warn ?? (() => {}));
603
+ }
604
+ const within = (path, dir) => path === dir || path.startsWith(dir.endsWith(sep) ? dir : dir + sep);
605
+ /**
606
+ * The hub whose primary repository or data directory holds `place` most closely, so a hub registered inside another
607
+ * hub's repository wins there, and a command run in a hub's data directory runs on that hub. Paths are compared exactly
608
+ * as given: they come from realPath, which spells them as the file system does.
609
+ */
610
+ function hubClaiming(registry, place) {
611
+ let best = null;
612
+ for (const [name, hub] of Object.entries(registry.hubs)) for (const path of [...primaries(hub).map(([, path]) => path), hub.data]) if (within(place, path) && (!best || path.length > best.length)) best = {
613
+ name,
614
+ length: path.length
615
+ };
616
+ return best?.name ?? null;
617
+ }
618
+ /**
619
+ * The places looked up for `cwd`, in order: its real path, matched against the registered paths that equal or hold it,
620
+ * and for a linked worktree the root of the repository's main working tree, so every worktree of a repository belongs
621
+ * to the hub of its main checkout. The git repository's root is not looked up on its own: when it is registered, the
622
+ * real path of any directory in it already matches. The path inside the worktree is not carried over: a hub below the root of the main
623
+ * checkout (a sample hub under examples/) is that checkout's data, not the worktree's.
624
+ */
625
+ function places(cwd) {
626
+ const here = realPath(cwd) ?? resolve(cwd);
627
+ const checkout = gitCheckout(here);
628
+ return checkout?.main && checkout.main !== checkout.current ? [here, checkout.main] : [here];
629
+ }
630
+ const lookUp = (registry, spots) => {
631
+ for (const spot of spots) {
632
+ const name = hubClaiming(registry, spot);
633
+ if (name) return name;
634
+ }
635
+ return null;
636
+ };
637
+ /**
638
+ * The hub `cwd` belongs to on this machine, from the registry alone, or null when no registered hub claims it. The
639
+ * entry found is brought up to date with its config.yaml first, and the lookup repeated, since the change may have
640
+ * moved the repository to another hub; when nothing claims `cwd`, every stale entry is, in case a change elsewhere
641
+ * claims it now. `warn` receives what kept the entry found from being brought up to date. With `waitForLock` false, as
642
+ * a hook asks, the registry's lock is tried once: when another writer holds it, the entries derived again are used
643
+ * for this lookup without being written, so a hook never waits.
644
+ */
645
+ function resolveHub(cwd, options = {}) {
646
+ const env = options.env ?? process.env;
647
+ const warn = options.warn ?? (() => {});
648
+ const wait = options.waitForLock ?? true;
649
+ const spots = places(cwd);
650
+ let registry = readRegistry(env);
651
+ let name = lookUp(registry, spots);
652
+ if (name) {
653
+ registry = refreshed(env, [name], warn, wait);
654
+ name = lookUp(registry, spots);
655
+ }
656
+ if (!name) {
657
+ registry = refreshed(env, Object.keys(registry.hubs), () => {}, wait);
658
+ name = lookUp(registry, spots);
659
+ }
660
+ const hub = name ? registry.hubs[name] : void 0;
661
+ return name && hub ? {
662
+ name,
663
+ root: hub.root,
664
+ data: hub.data
665
+ } : null;
666
+ }
667
+ /**
668
+ * The registered hub whose data directory holds `file`, or null: a written file belongs to the hub whose data it is,
669
+ * whichever directory the writer runs in. The innermost data directory wins.
670
+ */
671
+ function hubHoldingFile(file, env = process.env) {
672
+ const dir = realPath(dirname(file));
673
+ const path = realPath(file) ?? (dir ? join(dir, basename(file)) : resolve(file));
674
+ let best = null;
675
+ for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (within(path, hub.data) && (!best || hub.data.length > best.data.length)) best = {
676
+ name,
677
+ root: hub.root,
678
+ data: hub.data
679
+ };
680
+ return best;
681
+ }
682
+ /** The name `dirs` is registered under, or null: how a hub whose config.yaml is not downloaded yet is told apart. */
683
+ function registeredName(dirs, env = process.env) {
684
+ for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (hub.data === dirs.data && hub.root === dirs.root) return name;
685
+ return null;
686
+ }
687
+ /**
688
+ * The registered hub whose repository is `root`, or null: how a hub reaches another hub its config.yaml lists under
689
+ * `hubs` by the path of that hub's repository.
690
+ */
691
+ function hubAtRoot(root, env = process.env) {
692
+ const real = realPath(root);
693
+ if (!real) return null;
694
+ for (const [name, hub] of Object.entries(readRegistry(env).hubs)) if (hub.root === real) return {
695
+ name,
696
+ root: hub.root,
697
+ data: hub.data
698
+ };
699
+ return null;
700
+ }
701
+ /**
702
+ * A hub that still keeps its data inside the repository of `cwd` (`.mainflow/` at its main working tree's root or its
703
+ * own, or in `cwd` itself, as in a directory outside git), with the repository; null when there is none. Only the
704
+ * messages for an unregistered directory use it, to give the command that moves the data and registers the hub.
705
+ */
706
+ function inRepositoryHubAt(cwd) {
707
+ const here = realPath(cwd) ?? resolve(cwd);
708
+ const checkout = gitCheckout(here);
709
+ for (const root of [
710
+ checkout?.main,
711
+ checkout?.current,
712
+ here
713
+ ]) {
714
+ if (!root) continue;
715
+ const found = inRepositoryData(root);
716
+ if (found) return {
717
+ name: found.name,
718
+ root
719
+ };
720
+ }
721
+ return null;
722
+ }
723
+ /** What to tell the user when no registered hub claims `cwd`: how to register one, and the exact command when the repository still holds a hub's data. */
724
+ function notRegisteredMessage(cwd) {
725
+ const found = inRepositoryHubAt(cwd);
726
+ const lines = [`No registered hub claims ${realPath(cwd) ?? cwd}. Register the hub it belongs to with: ${REGISTER_USAGE} (the path is the hub repository; the hub's data is in ${userDataDir("<name>")}).`];
727
+ if (found) lines.push(`This repository still keeps the data of hub "${found.name}" in ${join(found.root, DATA_DIR)}; move it and register the hub with: mainflow hub migrate-data ${found.root}`);
728
+ return lines.join("\n");
729
+ }
730
+ //#endregion
110
731
  //#region ../core/dist/ids/allocators.js
111
732
  /** The next id after the highest one taken, e.g. `m003` after `m001` and `m002`. */
112
733
  function nextId(prefix, existing) {
@@ -471,22 +1092,34 @@ function formatMismatch(found) {
471
1092
  if (found === 3) return null;
472
1093
  return found < 3 ? `data format version ${found} is older than this CLI's (3); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${found} is newer than this CLI supports (3); install the matching mainflow version in this hub`;
473
1094
  }
1095
+ /** What the text of a config.yaml asks of a writer, read in one parse; a text that is not YAML throws the parser's error. */
1096
+ function configRequirements(text) {
1097
+ const data = parse$1(text);
1098
+ const found = data?.formatVersion;
1099
+ const range = data?.mainflow;
1100
+ const valid = typeof range === "string" && isMainflowRange(range);
1101
+ return {
1102
+ formatVersion: typeof found === "number" ? found : null,
1103
+ mainflow: valid ? range : null,
1104
+ mainflowInvalid: range !== void 0 && range !== null && !valid
1105
+ };
1106
+ }
474
1107
  /**
475
- * The data format config.yaml declares; null when the hub has no config.yaml or it names no number, which loading
476
- * the config reports. A config.yaml that is not YAML is a WriteError, so the sync server refuses the request
477
- * instead of failing on it.
1108
+ * What the hub's config.yaml asks of a writer; null when the hub has none. A config.yaml that is not YAML is a
1109
+ * WriteError, so the sync server refuses the request instead of failing on it.
478
1110
  */
479
- function hubFormatVersion(hub) {
1111
+ function hubRequirements(hub) {
480
1112
  const text = hub.read(CONFIG_FILE);
481
1113
  if (text === null) return null;
482
- let data;
483
1114
  try {
484
- data = parse$1(text);
1115
+ return configRequirements(text);
485
1116
  } catch (error) {
486
1117
  throw new WriteError(`refusing to write: ${hub.where(CONFIG_FILE)} cannot be parsed as YAML: ${error.message}`);
487
1118
  }
488
- const found = data?.formatVersion;
489
- return typeof found === "number" ? found : null;
1119
+ }
1120
+ /** The data format config.yaml declares; null when the hub has no config.yaml or it names no number (hubRequirements). */
1121
+ function hubFormatVersion(hub) {
1122
+ return hubRequirements(hub)?.formatVersion ?? null;
490
1123
  }
491
1124
  /**
492
1125
  * Refuse a write to a hub whose data format is not the one this CLI writes: rewriting a document would drop what
@@ -498,6 +1131,22 @@ function requireWritableFormat(hub) {
498
1131
  const reason = formatMismatch(found);
499
1132
  if (reason) throw new WriteError(`refusing to write: ${reason}`);
500
1133
  }
1134
+ /** The package the mainflow CLI is published as. */
1135
+ const CLI_PACKAGE = "mainflow";
1136
+ /**
1137
+ * The command that installs a mainflow CLI of the versions `range` names: pnpm installs the newest of them. Quoted for
1138
+ * the shell, since a range holds `>` and spaces.
1139
+ */
1140
+ const upgradeCommand = (range) => `pnpm add -g '${CLI_PACKAGE}@${range}'`;
1141
+ /**
1142
+ * Why a CLI of `version`, null for one that did not say, may not write to a hub whose config.yaml asks for the mainflow
1143
+ * versions `required` (HubRequirements.mainflow), with the command that installs one it takes; null when it may.
1144
+ * `after` ends the message: what the sender does next, for a write it sent, so a caller that only warns gives its own.
1145
+ */
1146
+ function versionMismatch(required, version, after = ", then send the write again") {
1147
+ if (version !== null && satisfies(version, required, MAINFLOW_RANGE)) return null;
1148
+ return `this hub takes writes from mainflow ${required} (mainflow in .mainflow/config.yaml), and the request came from ${version === null ? "a mainflow that does not say its version" : `mainflow ${version}`}; install one with ${upgradeCommand(required)}${after}`;
1149
+ }
501
1150
  //#endregion
502
1151
  //#region ../core/dist/ops/protocol.js
503
1152
  /** `now` of a request: a local minute and its offset from UTC, `Z` for UTC itself. */
@@ -534,9 +1183,23 @@ const ROUTES = {
534
1183
  * refused then: the server holds the right content already. GET: download them. Neither carries the protocol version:
535
1184
  * the content is named by its own hash.
536
1185
  */
537
- blob: (hub, hash) => `/hub/${hub}/blob/${hash}`
1186
+ blob: (hub, hash) => `/hub/${hub}/blob/${hash}`,
1187
+ /** POST: a page of the hub's change log (ChangeLogRequest, answered with ChangeLogPage) */
1188
+ changes: (hub) => `/hub/${hub}/changes`,
1189
+ /** POST: put the hub, or a path of it, back to what it held after a seq, as a new change (RestoreRequest, answered with RestoreResponse) */
1190
+ restore: (hub) => `/hub/${hub}/restore`,
1191
+ /** POST: convert the hub to the data format of the server's mainflow (MigrateRequest, answered with MigrateResponse) */
1192
+ migrate: (hub) => `/hub/${hub}/migrate`,
1193
+ /** POST: the migration or restore the server runs in batches, or ran last (RewriteRequest, answered with RewriteStatus) */
1194
+ rewrite: (hub) => `/hub/${hub}/rewrite`
538
1195
  };
539
1196
  /**
1197
+ * The header with the version of the mainflow CLI a request comes from, like 0.43.0, which every request and the
1198
+ * WebSocket's upgrade carry. A hub whose config.yaml names the mainflow versions that may write to it (`mainflow`)
1199
+ * refuses a write without it, or from a version outside them (`client-outdated`).
1200
+ */
1201
+ const VERSION_HEADER = "x-mainflow-version";
1202
+ /**
540
1203
  * The service token of Cloudflare Access, which stands in front of the sync server and lets through only the requests
541
1204
  * that carry a valid one: every request, the WebSocket's upgrade included, sends both headers. The sync server itself
542
1205
  * checks no credentials, so one deployed without Access in front serves anyone who knows its address.
@@ -617,116 +1280,25 @@ const SOCKET_MAX_AGE_CLOSE = {
617
1280
  code: 4e3,
618
1281
  reason: "the connection reached its maximum age; reconnect"
619
1282
  };
620
- //#endregion
621
- //#region ../core/dist/storage/node.js
622
- const LOCK_WAIT_MS = 5e3;
623
- const LOCK_STALE_MS = 3e4;
624
- const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
625
- /**
626
- * Run `fn` while holding the hub-wide write lock in `.mainflow/local/`. When the lock had to create that
627
- * directory and `fn` refuses the write, the directory goes again, so a refused write leaves the hub as it was.
628
- */
629
- function withLock(root, fn) {
630
- const dir = join(root, DATA_DIR, "local");
631
- const created = mkdirSync(dir, { recursive: true }) !== void 0;
632
- try {
633
- return holdLock(dir, fn);
634
- } catch (error) {
635
- if (created) removeIfEmpty(dir);
636
- throw error;
637
- }
638
- }
639
- function removeIfEmpty(dir) {
640
- try {
641
- rmdirSync(dir);
642
- } catch {}
643
- }
644
- function holdLock(dir, fn) {
645
- const lock = join(dir, "write.lock");
646
- const deadline = Date.now() + LOCK_WAIT_MS;
647
- let vanished = 0;
648
- for (;;) try {
649
- const fd = openSync(lock, "wx");
650
- writeSync(fd, String(process.pid));
651
- closeSync(fd);
652
- break;
653
- } catch (error) {
654
- if (error.code === "ENOENT") {
655
- mkdirSync(dir, { recursive: true });
656
- continue;
657
- }
658
- if (error.code !== "EEXIST") throw error;
659
- const held = statSync(lock, { throwIfNoEntry: false });
660
- if (held && Date.now() - held.mtimeMs > LOCK_STALE_MS) {
661
- rmSync(lock, { force: true });
662
- continue;
663
- }
664
- if (Date.now() > deadline) throw new WriteError(`hub is locked by another writer (${lock}); retry in a moment`);
665
- sleep(held ? 50 : Math.min(2 ** vanished++, 50));
666
- }
667
- try {
668
- return fn();
669
- } finally {
670
- rmSync(lock, { force: true });
671
- }
672
- }
673
- /** Write through a temporary file and a rename, so a reader never sees half a file. */
674
- function writeAtomic(file, text) {
675
- const temp = `${file}.${process.pid}.tmp`;
676
- writeFileSync(temp, text);
677
- renameSync(temp, file);
678
- }
679
- /** The hub whose data directory is `{root}/.mainflow/`, read and written on the local file system. */
680
- function nodeStorage(root) {
681
- const file = (path) => join(root, DATA_DIR, path);
682
- return {
683
- read: (path) => {
684
- const target = file(path);
685
- return existsSync(target) ? readFileSync(target, "utf8") : null;
686
- },
687
- list: (dir) => {
688
- const target = file(dir);
689
- if (!existsSync(target)) return [];
690
- return readdirSync(target, { withFileTypes: true }).map((entry) => ({
691
- name: entry.name,
692
- dir: entry.isDirectory()
693
- })).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
694
- },
695
- where: file,
696
- write: (path, text) => {
697
- mkdirSync(dirname(file(path)), { recursive: true });
698
- writeAtomic(file(path), text);
699
- },
700
- append: (path, text) => {
701
- mkdirSync(dirname(file(path)), { recursive: true });
702
- appendFileSync(file(path), text);
703
- },
704
- makeDir: (path) => {
705
- mkdirSync(file(path), { recursive: true });
706
- },
707
- remove: (path) => {
708
- rmSync(file(path), { force: true });
709
- },
710
- lock: (fn) => withLock(root, fn)
711
- };
712
- }
1283
+ /** The most changes a page of the change log carries. */
1284
+ const MAX_CHANGE_PAGE = 1e3;
713
1285
  //#endregion
714
1286
  //#region ../core/dist/io.js
715
1287
  /** The time now to the minute with this machine's offset from UTC, the form sessions, actions and decisions use: 2026-09-24T15:30+08:00, or 2026-09-24T07:30Z in UTC. */
716
1288
  const nowMinute = () => requestTime();
717
1289
  /** The context an operation run from this machine gets: the local time, which is the user's. */
718
1290
  const localContext = () => ({ now: nowMinute() });
719
- /** Run an operation on the hub at `root` under its write lock, apply its changes and return what it reports. */
720
- function runOp(root, run) {
1291
+ /** Run an operation on the hub at `dirs` under its write lock, apply its changes and return what it reports. */
1292
+ function runOp(dirs, run) {
721
1293
  const ctx = localContext();
722
- return applyOp(nodeStorage(root), (hub) => {
1294
+ return applyOp(nodeStorage(dirs), (hub) => {
723
1295
  requireWritableFormat(hub);
724
1296
  return run(hub, ctx);
725
1297
  }).value;
726
1298
  }
727
1299
  /** Run an operation that must not take the lock, such as one creating the hub the lock would live in. */
728
- function runUnlocked(root, run) {
729
- const storage = nodeStorage(root);
1300
+ function runUnlocked(dirs, run) {
1301
+ const storage = nodeStorage(dirs);
730
1302
  const result = run(storage, localContext());
731
1303
  commit(storage, result.changes);
732
1304
  return result.value;
@@ -751,13 +1323,13 @@ function readFrontMatter(file, schema) {
751
1323
  //#endregion
752
1324
  //#region ../core/dist/resolve.js
753
1325
  /** The absolute path of a path inside the hub's data directory. */
754
- const onDisk = (root, path) => join(root, DATA_DIR, path);
755
- const mainlineDir = (root, id) => onDisk(root, mainlineDirIn(nodeStorage(root), id));
756
- const taskDir = (root, mainline, task) => onDisk(root, taskDirIn(nodeStorage(root), mainline, task));
757
- const mainlineFile = (root, id) => join(mainlineDir(root, id), MAINLINE_FILE);
758
- const taskFile = (root, mainline, task) => join(taskDir(root, mainline, task), TASK_FILE);
759
- const groupFile = (root, id) => onDisk(root, groupFileIn(nodeStorage(root), id));
760
- const readConfig = (root) => readConfigIn(nodeStorage(root));
1326
+ const onDisk = (dirs, path) => join(dirs.data, path);
1327
+ const mainlineDir = (dirs, id) => onDisk(dirs, mainlineDirIn(nodeStorage(dirs), id));
1328
+ const taskDir = (dirs, mainline, task) => onDisk(dirs, taskDirIn(nodeStorage(dirs), mainline, task));
1329
+ const mainlineFile = (dirs, id) => join(mainlineDir(dirs, id), MAINLINE_FILE);
1330
+ const taskFile = (dirs, mainline, task) => join(taskDir(dirs, mainline, task), TASK_FILE);
1331
+ const groupFile = (dirs, id) => onDisk(dirs, groupFileIn(nodeStorage(dirs), id));
1332
+ const readConfig = (dirs) => readConfigIn(nodeStorage(dirs));
761
1333
  //#endregion
762
1334
  //#region ../core/dist/refs.js
763
1335
  const itemStatus = (item) => item.done ? "done" : item.dropped ? "dropped" : "open";
@@ -822,8 +1394,7 @@ function refIndex(hub) {
822
1394
  }
823
1395
  return index;
824
1396
  }
825
- const isHubRoot = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
826
- /** Absolute root of a registered hub; `path` is relative to the registering hub's root. */
1397
+ /** Absolute path of the repository of a hub config.yaml lists; `path` is relative to the listing hub's repository. */
827
1398
  const remoteRoot = (root, path) => resolve(root, path);
828
1399
  /** Documents in `hub` whose links point at `hubName`'s documents; `hubName` undefined means links without a prefix. */
829
1400
  function linksInto(hub, hubName) {
@@ -1070,7 +1641,9 @@ function touchesWithoutRepo(mainlines) {
1070
1641
  }
1071
1642
  /**
1072
1643
  * Open tasks of different open mainlines whose `touches` overlap in the same repo and which have no depends-on
1073
- * between them in either direction, written on the mainline or on the task. Tasks without a repo are not compared,
1644
+ * between them in either direction, written on the mainline or on the task. The opening briefing lists them for the
1645
+ * session serving the mainline; check does not warn, and a commit that runs into the overlap is coordinated between
1646
+ * the sessions rather than by adding a depends-on. Tasks without a repo are not compared,
1074
1647
  * since their paths have no root to share; touchesWithoutRepo names them.
1075
1648
  */
1076
1649
  function touchOverlaps(mainlines, edges) {
@@ -1156,12 +1729,12 @@ function withSection(markdown, heading, text) {
1156
1729
  * Read config.yaml and check its format version; throws a HubError naming what is wrong when the hub cannot be
1157
1730
  * used at all. The interface answers 503 for it before loading anything else (docs/ui.md "壞檔與讀取失敗").
1158
1731
  */
1159
- function loadConfig(root) {
1160
- const file = join(root, DATA_DIR, "config.yaml");
1161
- const where = relative(root, file);
1732
+ function loadConfig(dirs) {
1733
+ const file = join(dirs.data, "config.yaml");
1734
+ const where = hubPath(dirs, file);
1162
1735
  let data;
1163
1736
  try {
1164
- data = parse$1(readText(file) ?? "");
1737
+ data = parse$1(readText$1(file) ?? "");
1165
1738
  } catch (error) {
1166
1739
  throw new HubError("invalid config.yaml", [{
1167
1740
  file: where,
@@ -1180,7 +1753,7 @@ function loadConfig(root) {
1180
1753
  if (mismatch) throw new HubError(mismatch, []);
1181
1754
  return config;
1182
1755
  }
1183
- function loadHub(root, options = {}) {
1756
+ function loadHub(dirs, options = {}) {
1184
1757
  const problems = [];
1185
1758
  const warnings = [];
1186
1759
  /** References checked once everything they may point at is loaded */
@@ -1188,12 +1761,12 @@ function loadHub(root, options = {}) {
1188
1761
  /** Documents whose file is there but failed to load; references into them are not reported twice */
1189
1762
  const unreadable = /* @__PURE__ */ new Set();
1190
1763
  const idOf = (path) => ENTRY_DIR.exec(path.split("/").at(-1)?.replace(/\.md$/, "") ?? "")?.[1];
1191
- const dataDir = join(root, DATA_DIR);
1192
- const rel = (path) => relative(root, path);
1764
+ const dataDir = dirs.data;
1765
+ const rel = (path) => hubPath(dirs, path);
1193
1766
  const read = (file, schema, raw) => {
1194
1767
  let data = raw;
1195
1768
  if (data === void 0) try {
1196
- data = parse$1(readText(file) ?? "");
1769
+ data = parse$1(readText$1(file) ?? "");
1197
1770
  } catch (error) {
1198
1771
  problems.push({
1199
1772
  file: rel(file),
@@ -1219,12 +1792,12 @@ function loadHub(root, options = {}) {
1219
1792
  message: `directory name must be "${id}-{slug}" in kebab-case`
1220
1793
  });
1221
1794
  };
1222
- const config = loadConfig(root);
1223
- problems.push(...repoPathProblems(root, config));
1795
+ const config = loadConfig(dirs);
1796
+ problems.push(...repoPathProblems(dirs.root, config));
1224
1797
  const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
1225
1798
  /** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
1226
1799
  const readDocument = (file, schema) => {
1227
- const text = readText(file);
1800
+ const text = readText$1(file);
1228
1801
  if (text === null) {
1229
1802
  problems.push({
1230
1803
  file: rel(file),
@@ -1261,7 +1834,7 @@ function loadHub(root, options = {}) {
1261
1834
  };
1262
1835
  };
1263
1836
  const mainlines = [];
1264
- for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
1837
+ for (const mainlineDir of subdirs$1(join(dataDir, "mainlines"))) {
1265
1838
  const mainline = readDocument(join(mainlineDir, MAINLINE_FILE), mainlineSchema);
1266
1839
  if (!mainline) {
1267
1840
  const id = idOf(mainlineDir);
@@ -1282,7 +1855,7 @@ function loadHub(root, options = {}) {
1282
1855
  }
1283
1856
  }
1284
1857
  const tasks = [];
1285
- for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
1858
+ for (const taskDir of subdirs$1(join(mainlineDir, "tasks"))) {
1286
1859
  const task = readDocument(join(taskDir, TASK_FILE), taskSchema);
1287
1860
  if (!task) {
1288
1861
  unreadable.add(`${mainline.id}/${idOf(taskDir)}`);
@@ -1389,7 +1962,7 @@ function loadHub(root, options = {}) {
1389
1962
  message: "expected \"YYYY-MM-DDTHH:mm | target | operation | result [| ref]\""
1390
1963
  });
1391
1964
  });
1392
- const remotes = options.remotes === false ? [] : loadRemotes(root, config, problems, warnings);
1965
+ const remotes = options.remotes === false ? [] : loadRemotes(dirs.root, config, problems, warnings);
1393
1966
  warnings.push(...groupWarnings(config.hub, groups, mainlines, hubIssues, remotes));
1394
1967
  for (const doc of [...mainlines, ...hubIssues]) if (doc.group && splitGroup(doc.group, config.hub).hub !== void 0) pending.push({
1395
1968
  ref: doc.group,
@@ -1423,21 +1996,14 @@ function loadHub(root, options = {}) {
1423
1996
  message: `dependency cycle ${cycle.join(" → ")}; drop one of these dependencies`
1424
1997
  });
1425
1998
  }
1426
- for (const o of touchOverlaps(mainlines, deps.edges)) {
1427
- const pairs = o.paths.map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join(", ");
1428
- warnings.push({
1429
- file: o.file,
1430
- field: "touches",
1431
- 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`
1432
- });
1433
- }
1434
1999
  for (const t of touchesWithoutRepo(mainlines)) warnings.push({
1435
2000
  file: t.file,
1436
2001
  field: "repo",
1437
2002
  message: `${t.task} lists touches but no repo, so its paths are not compared with other tasks; add the repo they are relative to`
1438
2003
  });
1439
2004
  return {
1440
- root,
2005
+ root: dirs.root,
2006
+ data: dirs.data,
1441
2007
  config,
1442
2008
  mainlines,
1443
2009
  issues: hubIssues,
@@ -1482,7 +2048,10 @@ function groupWarnings(hubName, groups, mainlines, issues, remotes) {
1482
2048
  }] : [];
1483
2049
  });
1484
2050
  }
1485
- /** Load every hub registered in config.yaml, read-only and without its own remotes. */
2051
+ /**
2052
+ * Load every hub registered in config.yaml, read-only and without its own remotes. Each is found by its repository,
2053
+ * `root` joined with the path config.yaml gives it, in this machine's hub registry, which names its data directory.
2054
+ */
1486
2055
  function loadRemotes(root, config, problems, warnings) {
1487
2056
  const file = `${DATA_DIR}/config.yaml`;
1488
2057
  return Object.entries(config.hubs ?? {}).map(([name, path]) => {
@@ -1502,7 +2071,8 @@ function loadRemotes(root, config, problems, warnings) {
1502
2071
  unavailable: "this hub"
1503
2072
  };
1504
2073
  }
1505
- if (!isHubRoot(remote.root)) {
2074
+ const registered = hubAtRoot(remote.root);
2075
+ if (!registered) {
1506
2076
  warnings.push({
1507
2077
  file,
1508
2078
  field: `hubs.${name}`,
@@ -1514,7 +2084,7 @@ function loadRemotes(root, config, problems, warnings) {
1514
2084
  };
1515
2085
  }
1516
2086
  try {
1517
- const hub = loadHub(remote.root, { remotes: false });
2087
+ const hub = loadHub(registered, { remotes: false });
1518
2088
  if (hub.config.hub !== name) {
1519
2089
  problems.push({
1520
2090
  file,
@@ -1649,7 +2219,8 @@ function checkReferences(input) {
1649
2219
  /**
1650
2220
  * A listed repository counts as part of the hub, so the PreToolUse hook lets grants act inside it. A path that
1651
2221
  * resolves to / or to a directory above the hub would put everything under it in scope. A path that does not
1652
- * exist is fine: another machine may not have cloned that repository yet.
2222
+ * exist is fine: another machine may not have cloned that repository yet. The hub repository is always a primary
2223
+ * repository of its hub, so it cannot be listed as a reference.
1653
2224
  */
1654
2225
  function repoPathProblems(root, config) {
1655
2226
  const real = (path) => {
@@ -1660,7 +2231,7 @@ function repoPathProblems(root, config) {
1660
2231
  }
1661
2232
  };
1662
2233
  const hub = real(root) ?? root;
1663
- return Object.entries(config.repos).flatMap(([name, path]) => {
2234
+ return listedRepos(config.repos).flatMap(({ name, path, role }) => {
1664
2235
  const dir = real(resolve(root, path));
1665
2236
  if (dir === null) return [];
1666
2237
  const problem = (message) => [{
@@ -1670,6 +2241,7 @@ function repoPathProblems(root, config) {
1670
2241
  }];
1671
2242
  if (dir === parse(dir).root) return problem(`"${path}" resolves to the file system root; list the repository's own directory`);
1672
2243
  if (hub.startsWith(dir.endsWith(sep) ? dir : dir + sep)) return problem(`"${path}" resolves to ${dir}, above the hub; list the repository's own directory`);
2244
+ if (role === "reference" && dir === hub) return problem(`"${path}" is the hub repository, which always belongs to this hub; list it without role: reference`);
1673
2245
  return [];
1674
2246
  });
1675
2247
  }
@@ -1815,8 +2387,9 @@ function addRepo(hub, input) {
1815
2387
  requireSlug(input.name);
1816
2388
  const draft = new Draft(hub);
1817
2389
  editYaml(draft, CONFIG_FILE, configSchema, (c) => {
1818
- if (c.repos[input.name] !== void 0 && c.repos[input.name] !== input.path) throw new WriteError(`repo "${input.name}" is already registered as ${c.repos[input.name]}`);
1819
- c.repos[input.name] = input.path;
2390
+ const listed = c.repos[input.name];
2391
+ if (listed !== void 0 && repoPath(listed) !== input.path) throw new WriteError(`repo "${input.name}" is already registered as ${repoPath(listed)}`);
2392
+ c.repos[input.name] ??= input.path;
1820
2393
  });
1821
2394
  return draft.result(input.name);
1822
2395
  }
@@ -3001,7 +3574,7 @@ function requireMainlineTarget(hub, until) {
3001
3574
  /** The action an `until` waits for; throws when the condition matches none of the forms. */
3002
3575
  function untilTarget(until) {
3003
3576
  const parsed = parseUntil(until);
3004
- if (!parsed) throw new WriteError(`--until takes a condition like ${UNTIL_FORMS.map((f) => f.format).join(", ")}, got "${until}"`);
3577
+ if (!parsed) throw new WriteError(removedUntil(until) ?? `--until takes a condition like ${UNTIL_FORMS.map((f) => f.format).join(", ")}, got "${until}"`);
3005
3578
  return parsed.kind === "action-done" ? `${parsed.session}#${parsed.action}` : void 0;
3006
3579
  }
3007
3580
  function updateAction(hub, input, ctx) {
@@ -3062,6 +3635,9 @@ const rename = (o, from, to) => {
3062
3635
  }
3063
3636
  };
3064
3637
  const isClosed = (kind, status) => CLOSED_STATUS[kind].includes(status);
3638
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3639
+ /** How messages and the report name a file: from the hub root, the way the CLI user sees them. */
3640
+ const rel = (path) => `${DATA_DIR}/${path}`;
3065
3641
  /** Drop the file's own H1, which format 2 replaces with the title in the front matter. */
3066
3642
  const withoutTitle = (markdown) => markdown.replace(/^\s*# [^\n]*\n/, "").replace(/^\n+/, "");
3067
3643
  /**
@@ -3105,122 +3681,198 @@ function migrateListItem(item) {
3105
3681
  return item;
3106
3682
  }
3107
3683
  const openItem = (item) => !item.done && !item.dropped;
3684
+ /** The text of `path`; a WriteError when the hub holds no file there. */
3685
+ function readFile(hub, path) {
3686
+ const text = hub.read(path);
3687
+ if (text === null) throw new WriteError(`${hub.where(path)} does not exist`);
3688
+ return text;
3689
+ }
3690
+ /** `text` read as YAML, which must be a mapping: a WriteError naming `path` otherwise. */
3691
+ function yamlRecord(text, path) {
3692
+ let data;
3693
+ try {
3694
+ data = parse$1(text);
3695
+ } catch (error) {
3696
+ throw new WriteError(`${rel(path)} is not valid YAML: ${error.message}`);
3697
+ }
3698
+ if (!isRecord(data)) throw new WriteError(`${rel(path)} is not a YAML mapping of attributes, so it cannot be migrated; fix it and migrate again`);
3699
+ return data;
3700
+ }
3701
+ /** A Markdown document's front matter, which must be a YAML mapping, and its body: a WriteError naming `path` otherwise. */
3702
+ function frontMatterOf(text, path) {
3703
+ let split;
3704
+ try {
3705
+ split = splitFrontMatter(text);
3706
+ } catch (error) {
3707
+ throw new WriteError(`${rel(path)} has front matter that is not valid YAML: ${error.message}`);
3708
+ }
3709
+ if (!isRecord(split.data)) throw new WriteError(`${rel(path)} has no front matter, the YAML mapping of its attributes between --- lines at its top, so it cannot be migrated; add it, or move the file out of the hub, and migrate again`);
3710
+ return {
3711
+ data: split.data,
3712
+ body: split.body
3713
+ };
3714
+ }
3715
+ /** The list at `field` of a format 1 document, every item a mapping; none for a field it lacks. */
3716
+ function itemsOf(data, field, path) {
3717
+ const value = data[field] ?? [];
3718
+ if (!Array.isArray(value) || !value.every(isRecord)) throw new WriteError(`${rel(path)}: ${field} is not a list of items, so it cannot be migrated; fix it and migrate again`);
3719
+ return value;
3720
+ }
3721
+ const subdirs = (hub, dir) => hub.list(dir).filter((e) => e.dir).map((e) => `${dir}/${e.name}`);
3722
+ const markdownIn = (hub, dir) => hub.list(dir).map((e) => e.name).filter((n) => n.endsWith(".md"));
3108
3723
  /**
3109
- * Convert a format 1 or 2 hub to the current format. Times already written are never changed. The report lists what the changes write and remove; a dry
3110
- * run reports them without applying them.
3724
+ * The data format config.yaml names, to migrate from: 1 or 2. A WriteError for a hub already of this CLI's format, of
3725
+ * a format this CLI does not convert, or without a readable config.yaml.
3111
3726
  */
3112
- function migrateHub$1(hub) {
3113
- const rel = (path) => `${DATA_DIR}/${path}`;
3114
- const read = (path) => {
3115
- const text = hub.read(path);
3116
- if (text === null) throw new WriteError(`${hub.where(path)} does not exist`);
3117
- return text;
3118
- };
3119
- const subdirs = (dir) => hub.list(dir).filter((e) => e.dir).map((e) => `${dir}/${e.name}`);
3120
- const markdownIn = (dir) => hub.list(dir).map((e) => e.name).filter((n) => n.endsWith(".md"));
3121
- const config = parse$1(read(CONFIG_FILE));
3727
+ function migrationSource(hub) {
3728
+ const config = yamlRecord(readFile(hub, CONFIG_FILE), CONFIG_FILE);
3122
3729
  const from = Number(config.formatVersion);
3123
3730
  if (from === 3) throw new WriteError(`${rel(CONFIG_FILE)} is already format 3; nothing to migrate`);
3124
3731
  if (from !== 1 && from !== 2) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 or 2 to 3`);
3125
- const writes = /* @__PURE__ */ new Map();
3126
- const removes = [];
3127
- const missing = [];
3732
+ return from;
3733
+ }
3734
+ /** A mainline's directory: mainline.yaml and context.md into mainline.md, each task.yaml and notes.md into task.md, each decision in its new shape. */
3735
+ function convertMainline(hub, mainlineDir, part) {
3128
3736
  const document = (file, schema, data, body) => {
3129
- writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
3130
- };
3131
- /** Format 1 to 2: documents are rewritten in their new shape and the old files removed. */
3132
- const convertFormat1 = () => {
3133
- for (const mainlineDir of subdirs(MAINLINES_DIR)) {
3134
- const yamlFile = `${mainlineDir}/mainline.yaml`;
3135
- const yamlText = hub.read(yamlFile);
3136
- if (yamlText === null) continue;
3137
- const m = parse$1(yamlText);
3138
- dropNull(m, "archivedAt");
3139
- if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
3140
- const criteria = (m.criteria ?? []).map(migrateListItem);
3141
- m.criteria = criteria;
3142
- const id = String(m.id);
3143
- const open = !isClosed("mainline", m.status);
3144
- if (open) {
3145
- for (const c of criteria) if (openItem(c) && !c.doneWhen) missing.push(`${id}#${c.id} doneWhen`);
3146
- }
3147
- const contextFile = `${mainlineDir}/context.md`;
3148
- const contextText = hub.read(contextFile);
3149
- const context = contextText === null ? "" : withoutTitle(contextText);
3150
- document(`${mainlineDir}/${MAINLINE_FILE}`, mainlineSchema, m, `\n${context.trimEnd()}\n`);
3151
- removes.push(yamlFile, ...contextText === null ? [] : [contextFile]);
3152
- for (const taskDir of subdirs(`${mainlineDir}/tasks`)) {
3153
- const taskYaml = `${taskDir}/task.yaml`;
3154
- const taskText = hub.read(taskYaml);
3155
- if (taskText === null) continue;
3156
- const t = parse$1(taskText);
3157
- const content = typeof t.content === "string" ? t.content.trim() : "";
3158
- drop(t, "content");
3159
- dropNull(t, "parent", "repo");
3160
- t.created ??= m.created;
3161
- const checklist = (t.checklist ?? []).map(migrateListItem);
3162
- t.checklist = checklist;
3163
- const doneDays = checklist.map((c) => c.doneAt).filter((d) => typeof d === "string").sort();
3164
- if (isClosed("task", t.status) && doneDays.length) t.closedAt = doneDays.at(-1);
3165
- let body = content ? withSection(TASK_BODY, "目標", content) : TASK_BODY;
3166
- const notesFile = `${taskDir}/notes.md`;
3167
- const notes = hub.read(notesFile);
3168
- if (notes !== null) {
3169
- body = foldNotes(body, notes);
3170
- removes.push(notesFile);
3171
- }
3172
- const taskRef = `${id}/${String(t.id)}`;
3173
- if (open && !isClosed("task", t.status)) {
3174
- for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(body, section)) missing.push(`${taskRef} ${section}`);
3175
- for (const c of checklist) if (openItem(c) && !c.doneWhen) missing.push(`${taskRef}#${c.id} doneWhen`);
3176
- }
3177
- document(`${taskDir}/${TASK_FILE}`, taskSchema, t, body);
3178
- removes.push(taskYaml);
3179
- }
3180
- const decisionsDir = `${mainlineDir}/decisions`;
3181
- for (const name of markdownIn(decisionsDir)) {
3182
- const file = `${decisionsDir}/${name}`;
3183
- const { data, body } = splitFrontMatter(read(file));
3184
- const d = data;
3185
- rename(d, "date", "created");
3186
- if (d.status !== "proposed") d.decidedAt = d.created;
3187
- dropNull(d, "supersededBy");
3188
- rename(d, "blocks", "affects");
3189
- document(file, decisionSchema, d, body);
3190
- }
3191
- }
3192
- const hubDocs = (dir, migrate, schema) => {
3193
- for (const name of markdownIn(dir)) {
3194
- const file = `${dir}/${name}`;
3195
- const { data, body } = splitFrontMatter(read(file));
3196
- migrate(data);
3197
- document(file, schema, data, body);
3198
- }
3199
- };
3200
- hubDocs(ISSUES_DIR, (i) => rename(i, "closedBy", "resolution"), issueSchema);
3201
- hubDocs(FINDINGS_DIR, (f) => {
3202
- rename(f, "date", "created");
3203
- rename(f, "tags", "labels");
3204
- }, findingSchema);
3205
- };
3206
- if (from === 1) convertFormat1();
3207
- const configText = read(CONFIG_FILE);
3737
+ part.writes.set(file, frontMatterText(validate(schema, data, rel(file)), body));
3738
+ };
3739
+ const yamlFile = `${mainlineDir}/mainline.yaml`;
3740
+ const yamlText = hub.read(yamlFile);
3741
+ if (yamlText === null) return;
3742
+ const m = yamlRecord(yamlText, yamlFile);
3743
+ dropNull(m, "archivedAt");
3744
+ if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
3745
+ const criteria = itemsOf(m, "criteria", yamlFile).map(migrateListItem);
3746
+ m.criteria = criteria;
3747
+ const id = String(m.id);
3748
+ const open = !isClosed("mainline", m.status);
3749
+ if (open) {
3750
+ for (const c of criteria) if (openItem(c) && !c.doneWhen) part.missing.push(`${id}#${c.id} doneWhen`);
3751
+ }
3752
+ const contextFile = `${mainlineDir}/context.md`;
3753
+ const contextText = hub.read(contextFile);
3754
+ const context = contextText === null ? "" : withoutTitle(contextText);
3755
+ document(`${mainlineDir}/${MAINLINE_FILE}`, mainlineSchema, m, `\n${context.trimEnd()}\n`);
3756
+ part.removes.push(yamlFile, ...contextText === null ? [] : [contextFile]);
3757
+ for (const taskDir of subdirs(hub, `${mainlineDir}/tasks`)) {
3758
+ const taskYaml = `${taskDir}/task.yaml`;
3759
+ const taskText = hub.read(taskYaml);
3760
+ if (taskText === null) continue;
3761
+ const t = yamlRecord(taskText, taskYaml);
3762
+ const content = typeof t.content === "string" ? t.content.trim() : "";
3763
+ drop(t, "content");
3764
+ dropNull(t, "parent", "repo");
3765
+ t.created ??= m.created;
3766
+ const checklist = itemsOf(t, "checklist", taskYaml).map(migrateListItem);
3767
+ t.checklist = checklist;
3768
+ const doneDays = checklist.map((c) => c.doneAt).filter((d) => typeof d === "string").sort();
3769
+ if (isClosed("task", t.status) && doneDays.length) t.closedAt = doneDays.at(-1);
3770
+ let body = content ? withSection(TASK_BODY, "目標", content) : TASK_BODY;
3771
+ const notesFile = `${taskDir}/notes.md`;
3772
+ const notes = hub.read(notesFile);
3773
+ if (notes !== null) {
3774
+ body = foldNotes(body, notes);
3775
+ part.removes.push(notesFile);
3776
+ }
3777
+ const taskRef = `${id}/${String(t.id)}`;
3778
+ if (open && !isClosed("task", t.status)) {
3779
+ for (const section of TASK_REQUIRED_SECTIONS) if (!sectionText(body, section)) part.missing.push(`${taskRef} ${section}`);
3780
+ for (const c of checklist) if (openItem(c) && !c.doneWhen) part.missing.push(`${taskRef}#${c.id} doneWhen`);
3781
+ }
3782
+ document(`${taskDir}/${TASK_FILE}`, taskSchema, t, body);
3783
+ part.removes.push(taskYaml);
3784
+ }
3785
+ const decisionsDir = `${mainlineDir}/decisions`;
3786
+ for (const name of markdownIn(hub, decisionsDir)) {
3787
+ const file = `${decisionsDir}/${name}`;
3788
+ const { data: d, body } = frontMatterOf(readFile(hub, file), file);
3789
+ rename(d, "date", "created");
3790
+ if (d.status !== "proposed") d.decidedAt = d.created;
3791
+ dropNull(d, "supersededBy");
3792
+ rename(d, "blocks", "affects");
3793
+ document(file, decisionSchema, d, body);
3794
+ }
3795
+ }
3796
+ /**
3797
+ * Convert one unit of a format 1 hub (migrationUnit) as it is now: what it writes and removes, which only its own files.
3798
+ * A WriteError, naming the file, for a document it cannot read or one that would be invalid in the current format.
3799
+ */
3800
+ function convertUnit(hub, unit) {
3801
+ const part = {
3802
+ writes: /* @__PURE__ */ new Map(),
3803
+ removes: [],
3804
+ missing: []
3805
+ };
3806
+ if (unit.kind === "mainline") {
3807
+ convertMainline(hub, unit.path, part);
3808
+ return part;
3809
+ }
3810
+ const { data, body } = frontMatterOf(readFile(hub, unit.path), unit.path);
3811
+ if (unit.kind === "issue") rename(data, "closedBy", "resolution");
3812
+ else {
3813
+ rename(data, "date", "created");
3814
+ rename(data, "tags", "labels");
3815
+ }
3816
+ part.writes.set(unit.path, frontMatterText(validate(unit.kind === "issue" ? issueSchema : findingSchema, data, rel(unit.path)), body));
3817
+ return part;
3818
+ }
3819
+ /**
3820
+ * config.yaml in the current format, from the format `from` it names (migrationSource): its path and text. Format 2
3821
+ * to 3 changes how new times are written, not the times already there, so only the version changes, and the line is
3822
+ * replaced in place to keep the rest of config.yaml byte for byte.
3823
+ */
3824
+ function convertConfig(hub, from) {
3825
+ const configText = readFile(hub, CONFIG_FILE);
3826
+ const config = yamlRecord(configText, CONFIG_FILE);
3208
3827
  const versionLine = /^formatVersion:[ \t]*2[ \t]*$/m;
3209
3828
  config.formatVersion = 3;
3210
3829
  if (from === 2 && versionLine.test(configText)) {
3211
3830
  validate(configSchema, config, rel(CONFIG_FILE));
3212
- writes.set(CONFIG_FILE, configText.replace(versionLine, `formatVersion: 3`));
3213
- } else writes.set(CONFIG_FILE, toYaml(validate(configSchema, config, rel(CONFIG_FILE))));
3214
- const draft = new Draft(hub);
3215
- for (const [file, text] of writes) draft.write(file, text);
3216
- for (const file of removes) draft.remove(file);
3217
- return draft.result({
3831
+ return [CONFIG_FILE, configText.replace(versionLine, `formatVersion: 3`)];
3832
+ }
3833
+ return [CONFIG_FILE, toYaml(validate(configSchema, config, rel(CONFIG_FILE)))];
3834
+ }
3835
+ /** The report of the migration from format `from` that `parts` make up, in their order: each file by its path from the hub root. */
3836
+ function migrationReport(from, parts) {
3837
+ return {
3218
3838
  from,
3219
3839
  to: 3,
3220
- written: [...writes.keys()].map(rel),
3221
- removed: removes.map(rel),
3222
- missingDefinitions: missing
3840
+ written: parts.flatMap((part) => [...part.writes.keys()].map(rel)),
3841
+ removed: parts.flatMap((part) => part.removes.map(rel)),
3842
+ missingDefinitions: parts.flatMap((part) => part.missing)
3843
+ };
3844
+ }
3845
+ /**
3846
+ * Convert a format 1 or 2 hub to the current format. Times already written are never changed. The report lists what the changes write and remove; a dry
3847
+ * run reports them without applying them.
3848
+ */
3849
+ function migrateHub$1(hub) {
3850
+ const from = migrationSource(hub);
3851
+ const parts = [];
3852
+ if (from === 1) {
3853
+ for (const path of subdirs(hub, MAINLINES_DIR)) parts.push(convertUnit(hub, {
3854
+ kind: "mainline",
3855
+ path
3856
+ }));
3857
+ for (const name of markdownIn(hub, ISSUES_DIR)) parts.push(convertUnit(hub, {
3858
+ kind: "issue",
3859
+ path: `${ISSUES_DIR}/${name}`
3860
+ }));
3861
+ for (const name of markdownIn(hub, FINDINGS_DIR)) parts.push(convertUnit(hub, {
3862
+ kind: "finding",
3863
+ path: `${FINDINGS_DIR}/${name}`
3864
+ }));
3865
+ }
3866
+ const [configFile, configText] = convertConfig(hub, from);
3867
+ parts.push({
3868
+ writes: /* @__PURE__ */ new Map([[configFile, configText]]),
3869
+ removes: [],
3870
+ missing: []
3223
3871
  });
3872
+ const draft = new Draft(hub);
3873
+ for (const part of parts) for (const [file, text] of part.writes) draft.write(file, text);
3874
+ for (const part of parts) for (const file of part.removes) draft.remove(file);
3875
+ return draft.result(migrationReport(from, parts));
3224
3876
  }
3225
3877
  //#endregion
3226
3878
  //#region ../core/dist/ops/operations.js
@@ -3284,6 +3936,68 @@ function applyOperation(storage, name, input, ctx) {
3284
3936
  });
3285
3937
  }
3286
3938
  //#endregion
3939
+ //#region ../core/dist/ops/scope.js
3940
+ /** Names programs leave behind while writing: editor swap and backup files, temporary files, Finder's. */
3941
+ const TRANSIENT = [
3942
+ /\.tmp$/,
3943
+ /\.sw[a-p]$/,
3944
+ /\.swx$/,
3945
+ /~$/,
3946
+ /^\.#/,
3947
+ /^4913$/,
3948
+ /\.crswap$/,
3949
+ /^\.DS_Store$/
3950
+ ];
3951
+ /** Directories never synced, nor anything in them, at any depth. */
3952
+ const OWN_DIRS = /* @__PURE__ */ new Set(["scratch", "node_modules"]);
3953
+ /**
3954
+ * The parts of `path`, or null when it is no plain relative path, as no file of the hub has, or when a directory in
3955
+ * it, `path` itself too when `dir`, is one never synced (OWN_DIRS). `.git` is never synced, as a directory or as the
3956
+ * file a git worktree has.
3957
+ */
3958
+ function partsOf(path, dir) {
3959
+ const parts = path.split("/");
3960
+ if (parts.some((part) => part === "" || part === "." || part === ".." || part === ".git")) return null;
3961
+ return (dir ? parts : parts.slice(0, -1)).some((part) => OWN_DIRS.has(part)) ? null : parts;
3962
+ }
3963
+ /** Whether the file at `path` is synced: not in a directory never synced, not in local/ but local/sessions/, and not a transient name. */
3964
+ function syncedPath(path) {
3965
+ const parts = partsOf(path, false);
3966
+ if (parts === null) return false;
3967
+ if (parts[0] === "local" && !(parts[1] === "sessions" && parts.length === 3)) return false;
3968
+ return !TRANSIENT.some((pattern) => pattern.test(parts.at(-1)));
3969
+ }
3970
+ /** Whether the directory at `path` may hold synced files, so a scan walks into it: local/ itself and local/sessions/ but nothing else in local/. */
3971
+ function mayHoldSynced(path) {
3972
+ if (path === "") return true;
3973
+ if (partsOf(path, true) === null) return false;
3974
+ return path === "local" || !path.startsWith("local/") || path === "local/sessions";
3975
+ }
3976
+ /** Whether the directory at `path` is synced: one that may hold synced files, apart from .mainflow/ and local/, which every machine has of its own. */
3977
+ const syncedDir = (path) => path !== "" && path !== "local" && mayHoldSynced(path);
3978
+ /** Whether the file or directory (`dir`) at `path` is of the sync scope: syncedPath for a file, syncedDir for a directory. */
3979
+ const syncedEntry = (path, dir) => dir ? syncedDir(path) : syncedPath(path);
3980
+ //#endregion
3981
+ //#region ../core/dist/ops/digest.js
3982
+ const DIGEST_DIGITS = 40;
3983
+ /** Whether the entry at `path`, a directory's when `dir`, is in the digest: one of the sync scope. */
3984
+ const digestCovers = syncedEntry;
3985
+ /** The term of one entry in the digest: a file by its path and content hash, a directory (`hash` null) by its path. */
3986
+ function digestTerm(path, hash) {
3987
+ return BigInt(`0x${contentHash(hash === null ? `d\0${path}` : `f\0${path}\0${hash}`)}`);
3988
+ }
3989
+ /** A digest as it is stored and sent: 40 hex digits. */
3990
+ function digestText(value) {
3991
+ return value.toString(16).padStart(DIGEST_DIGITS, "0");
3992
+ }
3993
+ /** The digest of the files (path to content hash) and directories given, those it covers (digestCovers). */
3994
+ function hubDigest(files, dirs) {
3995
+ let value = 0n;
3996
+ for (const [path, hash] of files) if (digestCovers(path, false)) value ^= digestTerm(path, hash);
3997
+ for (const dir of dirs) if (digestCovers(dir, true)) value ^= digestTerm(dir, null);
3998
+ return digestText(value);
3999
+ }
4000
+ //#endregion
3287
4001
  //#region ../core/dist/ids/renumber.js
3288
4002
  /** The id at the end of a reference: c003 in m001/t002#c003, a002 in claude-code-1a2b3c4d#a002. */
3289
4003
  const lastId = (ref) => /([a-z]\d{3})$/.exec(ref)?.[1] ?? "";
@@ -3669,7 +4383,7 @@ const sessionPath = (id) => `/sessions/${id}`;
3669
4383
  /** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
3670
4384
  const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
3671
4385
  /** A file under a mainline directory, or a reference to a mainline, task, decision, issue, finding or group. */
3672
- const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[ifw]\d{3})(?![\w\]])/g;
4386
+ const MENTION = /\.mainflow\/(?:[a-z0-9-]+\/)?mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[ifw]\d{3})(?![\w\]])/g;
3673
4387
  /** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
3674
4388
  function linkMentions(text, context) {
3675
4389
  const index = refIndex(context.hub);
@@ -3752,7 +4466,7 @@ function decorateReply(text, context) {
3752
4466
  return line ? `${body}\n\n${line}\n` : `${body}\n`;
3753
4467
  }
3754
4468
  /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
3755
- const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
4469
+ const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/(?:[a-z0-9-]+\/)?mainlines/.test(text) || endsWithMarker(text);
3756
4470
  //#endregion
3757
4471
  //#region ../core/dist/grants.js
3758
4472
  /** Mainline statuses whose grants apply. */
@@ -3763,7 +4477,7 @@ const GRANTING_GROUP_STATUS = ["open"];
3763
4477
  * The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
3764
4478
  * must fail closed catches it and asks.
3765
4479
  */
3766
- function grantScope(root, session) {
4480
+ function grantScope(dirs, session) {
3767
4481
  if (!session) return {
3768
4482
  ok: false,
3769
4483
  reason: "這場會話沒有 mainflow 紀錄"
@@ -3795,11 +4509,11 @@ function grantScope(root, session) {
3795
4509
  const grants = [];
3796
4510
  const served = {};
3797
4511
  const closed = [];
3798
- const hubName = readConfig(root).hub;
4512
+ const hubName = readConfig(dirs).hub;
3799
4513
  const groups = new Map(direct);
3800
4514
  const reachedByMainline = /* @__PURE__ */ new Set();
3801
4515
  for (const id of mainlines) {
3802
- const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
4516
+ const mainline = readFrontMatter(mainlineFile(dirs, id), mainlineSchema).data;
3803
4517
  if (!GRANTING_STATUS.includes(mainline.status)) {
3804
4518
  closed.push(`${id} 是 ${mainline.status}`);
3805
4519
  continue;
@@ -3819,7 +4533,7 @@ function grantScope(root, session) {
3819
4533
  for (const [id, via] of groups) {
3820
4534
  let group;
3821
4535
  try {
3822
- group = readFrontMatter(groupFile(root, id), groupSchema).data;
4536
+ group = readFrontMatter(groupFile(dirs, id), groupSchema).data;
3823
4537
  } catch (error) {
3824
4538
  if (reachedByMainline.has(id)) continue;
3825
4539
  throw error;
@@ -3878,9 +4592,9 @@ function matchGrants(scope, requests) {
3878
4592
  * the hook allows for it; empty when the session has none or they cannot be read, since a briefing must not fail
3879
4593
  * on them.
3880
4594
  */
3881
- function grantLines(root, session) {
4595
+ function grantLines(dirs, session) {
3882
4596
  try {
3883
- const result = grantScope(root, session);
4597
+ const result = grantScope(dirs, session);
3884
4598
  if (!result.ok) return [];
3885
4599
  return result.scope.grants.map((g) => {
3886
4600
  const parsed = parseGrantTarget(g.grant.action, g.grant.target);
@@ -3894,26 +4608,26 @@ function grantLines(root, session) {
3894
4608
  //#endregion
3895
4609
  //#region ../core/dist/sessions.js
3896
4610
  /** Read every session under `.mainflow/local/sessions/`; sessions are local and never version-controlled. */
3897
- function loadSessions(root) {
3898
- return readSessionsIn(nodeStorage(root));
4611
+ function loadSessions(dirs) {
4612
+ return readSessionsIn(nodeStorage(dirs));
3899
4613
  }
3900
- function readSession(root, id) {
3901
- return readSessionIn(nodeStorage(root), id);
4614
+ function readSession(dirs, id) {
4615
+ return readSessionIn(nodeStorage(dirs), id);
3902
4616
  }
3903
4617
  /**
3904
4618
  * Find the action a reference names. A handoff drops the action and names its new place in the note, so the
3905
4619
  * reference is followed there: a wait on the old reference keeps working after the work moved to another session.
3906
4620
  */
3907
- function resolveAction(root, ref) {
3908
- return resolveActionIn(nodeStorage(root), ref);
4621
+ function resolveAction(dirs, ref) {
4622
+ return resolveActionIn(nodeStorage(dirs), ref);
3909
4623
  }
3910
4624
  /** Where a session's transcript is on disk: a platform records an absolute path, a hub may record one relative to its root. */
3911
- function transcriptPath(root, transcript) {
3912
- return isAbsolute(transcript) ? transcript : join(root, transcript);
4625
+ function transcriptPath(dirs, transcript) {
4626
+ return isAbsolute(transcript) ? transcript : join(dirs.root, transcript);
3913
4627
  }
3914
4628
  /** Remember the digest of what the last turn reminder listed in full; null forgets it, so the next reminder lists everything again. */
3915
- function setReminderDigest(root, id, digest) {
3916
- runOp(root, (hub) => setReminderDigest$1(hub, {
4629
+ function setReminderDigest(dirs, id, digest) {
4630
+ runOp(dirs, (hub) => setReminderDigest$1(hub, {
3917
4631
  id,
3918
4632
  digest
3919
4633
  }));
@@ -3992,14 +4706,16 @@ function readSyncState(data) {
3992
4706
  }
3993
4707
  /**
3994
4708
  * Kinds telling that a file did not reach the server. Once the file is the confirmed version it did, whenever it last
3995
- * changed: a binary file an older server skipped is uploaded later as it is, without being written again.
4709
+ * changed: a binary file an older server skipped is uploaded later as it is, without being written again, and so is a
4710
+ * write the hub refused for the CLI's version, once a mainflow it takes runs here.
3996
4711
  */
3997
4712
  const UNSENT_KINDS = /* @__PURE__ */ new Set([
3998
4713
  "skipped-binary",
3999
4714
  "skipped-large",
4000
4715
  "binary-text-file",
4001
4716
  "upload-failing",
4002
- "download-failed"
4717
+ "download-failed",
4718
+ "client-outdated"
4003
4719
  ]);
4004
4720
  /**
4005
4721
  * Whether the files show a notice settled. With a saved text, once the saved text is removed. Without one, when the
@@ -4024,18 +4740,18 @@ function settled(data, notice, state) {
4024
4740
  return false;
4025
4741
  }
4026
4742
  }
4027
- const handledFile = (root) => join(root, DATA_DIR, HANDLED_FILE);
4743
+ const handledFile = (dirs) => join(dirs.data, HANDLED_FILE);
4028
4744
  /** The keys marked handled by hand. */
4029
- function readHandledKeys(root) {
4030
- const keys = readJson$1(handledFile(root), {}).keys;
4745
+ function readHandledKeys(dirs) {
4746
+ const keys = readJson$1(handledFile(dirs), {}).keys;
4031
4747
  return keys && typeof keys === "object" && !Array.isArray(keys) ? keys : {};
4032
4748
  }
4033
4749
  /** Every warn-level line of notices.jsonl, newest first, each with how it was handled if it was; unreadable lines are skipped. */
4034
- function readNotices(root) {
4035
- const data = join(root, DATA_DIR);
4750
+ function readNotices(dirs) {
4751
+ const data = dirs.data;
4036
4752
  const file = join(data, NOTICES_FILE);
4037
4753
  if (!existsSync(file)) return [];
4038
- const manual = readHandledKeys(root);
4754
+ const manual = readHandledKeys(dirs);
4039
4755
  const state = readSyncState(data);
4040
4756
  const notices = [];
4041
4757
  for (const line of readFileSync(file, "utf8").split("\n")) {
@@ -4050,19 +4766,19 @@ function readNotices(root) {
4050
4766
  return notices.reverse();
4051
4767
  }
4052
4768
  /** The notices nobody has handled yet, newest first. */
4053
- const unhandledNotices = (root) => readNotices(root).filter((n) => n.handled === void 0);
4769
+ const unhandledNotices = (dirs) => readNotices(dirs).filter((n) => n.handled === void 0);
4054
4770
  /**
4055
4771
  * Mark notices handled by hand, writing handled.json by a rename so a reader never sees half of it. `keys` names the
4056
4772
  * notices (noticeKey); without it every notice unhandled now is marked. Returns the number marked.
4057
4773
  */
4058
- function markNoticesHandled(root, keys) {
4059
- const unhandled = new Set(unhandledNotices(root).map(noticeKey));
4774
+ function markNoticesHandled(dirs, keys) {
4775
+ const unhandled = new Set(unhandledNotices(dirs).map(noticeKey));
4060
4776
  const wanted = (keys ?? [...unhandled]).filter((key) => unhandled.has(key));
4061
4777
  if (wanted.length === 0) return 0;
4062
- const marked = readHandledKeys(root);
4778
+ const marked = readHandledKeys(dirs);
4063
4779
  const now = (/* @__PURE__ */ new Date()).toISOString();
4064
4780
  for (const key of wanted) marked[key] = now;
4065
- const file = handledFile(root);
4781
+ const file = handledFile(dirs);
4066
4782
  mkdirSync(dirname(file), { recursive: true });
4067
4783
  const temp = `${file}.${process.pid}.tmp`;
4068
4784
  writeFileSync(temp, JSON.stringify({ keys: marked }, null, 1));
@@ -4071,12 +4787,12 @@ function markNoticesHandled(root, keys) {
4071
4787
  }
4072
4788
  //#endregion
4073
4789
  //#region ../core/dist/briefing.js
4074
- function describe(root, ref) {
4790
+ function describe(dirs, ref) {
4075
4791
  const parsed = parseRef(ref);
4076
4792
  if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion" && parsed?.kind !== "groupCriterion") return null;
4077
4793
  try {
4078
4794
  if (parsed.kind === "groupCriterion") {
4079
- const group = readFrontMatter(groupFile(root, parsed.group), groupSchema).data;
4795
+ const group = readFrontMatter(groupFile(dirs, parsed.group), groupSchema).data;
4080
4796
  const criterion = group.criteria?.find((r) => r.id === parsed.criterion);
4081
4797
  return criterion ? {
4082
4798
  ref,
@@ -4085,7 +4801,7 @@ function describe(root, ref) {
4085
4801
  where: `${group.id} ${group.title} › 解決條件`
4086
4802
  } : null;
4087
4803
  }
4088
- const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
4804
+ const mainline = readFrontMatter(mainlineFile(dirs, parsed.mainline), mainlineSchema).data;
4089
4805
  if (parsed.kind === "criterion") {
4090
4806
  const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
4091
4807
  return criterion ? {
@@ -4095,7 +4811,7 @@ function describe(root, ref) {
4095
4811
  where: `${mainline.id} ${mainline.title} › 解決條件`
4096
4812
  } : null;
4097
4813
  }
4098
- const task = readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data;
4814
+ const task = readFrontMatter(taskFile(dirs, parsed.mainline, parsed.task), taskSchema).data;
4099
4815
  const item = task.checklist.find((c) => c.id === parsed.item);
4100
4816
  if (!item) return null;
4101
4817
  return {
@@ -4109,11 +4825,11 @@ function describe(root, ref) {
4109
4825
  }
4110
4826
  }
4111
4827
  /** The criteria a served checklist item pushes forward, as m{NNN}#r{NNN}; empty for a criterion or an item that cannot be read. */
4112
- function criteriaOf(root, ref) {
4828
+ function criteriaOf(dirs, ref) {
4113
4829
  const parsed = parseRef(ref);
4114
4830
  if (parsed?.kind !== "checklistItem") return [];
4115
4831
  try {
4116
- return (readFrontMatter(taskFile(root, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
4832
+ return (readFrontMatter(taskFile(dirs, parsed.mainline, parsed.task), taskSchema).data.checklist.find((c) => c.id === parsed.item)?.serves ?? []).map((r) => `${parsed.mainline}#${r}`);
4117
4833
  } catch {
4118
4834
  return [];
4119
4835
  }
@@ -4123,9 +4839,9 @@ function criteriaOf(root, ref) {
4123
4839
  * serves pushes forward, named as the item with its criterion. Two items serving the same criterion do not meet,
4124
4840
  * since splitting a criterion into items is how work on it runs in parallel.
4125
4841
  */
4126
- function sharedServes(root, mine, theirs) {
4842
+ function sharedServes(dirs, mine, theirs) {
4127
4843
  const shared = mine.filter((ref) => theirs.includes(ref));
4128
- const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(root, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
4844
+ const pushes = (items, criteria) => items.flatMap((item) => criteriaOf(dirs, item).filter((r) => criteria.includes(r)).map((r) => `${item}(推進 ${r})`));
4129
4845
  const mineCriteria = mine.filter((ref) => parseRef(ref)?.kind === "criterion");
4130
4846
  const theirCriteria = theirs.filter((ref) => parseRef(ref)?.kind === "criterion");
4131
4847
  return [.../* @__PURE__ */ new Set([
@@ -4135,12 +4851,12 @@ function sharedServes(root, mine, theirs) {
4135
4851
  ])];
4136
4852
  }
4137
4853
  /** Problems in a session's queue that the executor should fix before going on, each with the command that fixes it. */
4138
- function queueWarnings(root, session, cli) {
4854
+ function queueWarnings(dirs, session, cli) {
4139
4855
  const warnings = [];
4140
4856
  const doing = session.actions.filter((a) => a.status === "doing");
4141
4857
  if (doing.length > 1) warnings.push(`同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")})。一次只推進一個,其餘改回 todo。`);
4142
4858
  for (const action of doing) {
4143
- const served = action.serves.map((ref) => describe(root, ref));
4859
+ const served = action.serves.map((ref) => describe(dirs, ref));
4144
4860
  if (served.some((s) => s === null)) warnings.push(`${action.id} 服務的項目有的已不存在,用 ${cli} action set ${action.id} --serves <m/t#c 或 m#r> 改正。`);
4145
4861
  else if (served.length > 0 && served.every((s) => s.checked)) warnings.push(`${action.id} 服務的項目都已勾選或達成。做完了就執行 ${cli} action set ${action.id} --status done。`);
4146
4862
  }
@@ -4163,14 +4879,14 @@ function waitingLine(a) {
4163
4879
  return `${a.id} ${a.do}${parts.length > 0 ? `(${parts.join(";")})` : ""}`;
4164
4880
  }
4165
4881
  /** Mainlines a session could be pushing forward. */
4166
- const openMainlines = (root) => loadHub(root).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
4882
+ const openMainlines = (dirs) => loadHub(dirs).mainlines.filter((m) => !m.archivedAt && (m.status === "active" || m.status === "planning"));
4167
4883
  /** How recently a session's transcript must have been written for the session to count as possibly still running. */
4168
4884
  const RUNNING_WINDOW_MS = 9e5;
4169
4885
  /** Whether the session's transcript was written within the running window; false when no transcript is recorded or readable. */
4170
- function mayBeRunning(root, session) {
4886
+ function mayBeRunning(dirs, session) {
4171
4887
  if (!session.transcript) return false;
4172
4888
  try {
4173
- return Date.now() - statSync(transcriptPath(root, session.transcript)).mtimeMs < RUNNING_WINDOW_MS;
4889
+ return Date.now() - statSync(transcriptPath(dirs, session.transcript)).mtimeMs < RUNNING_WINDOW_MS;
4174
4890
  } catch {
4175
4891
  return false;
4176
4892
  }
@@ -4195,21 +4911,21 @@ function family(sessions, id) {
4195
4911
  return members.add(id);
4196
4912
  }
4197
4913
  /** The actions in this session that a blocked action elsewhere waits for, named by `waitingFor` or by an `until` of kind action-done. */
4198
- function waitedFor(root, blocked) {
4914
+ function waitedFor(dirs, blocked) {
4199
4915
  const until = blocked.blockedBy?.until ? parseUntil(blocked.blockedBy.until) : null;
4200
4916
  const refs = [waitingForAction(blocked.blockedBy?.waitingFor), until?.kind === "action-done" ? `${until.session}#${until.action}` : void 0];
4201
- return [...new Set(refs.filter((r) => r !== void 0).map((r) => resolveAction(root, r).ref))];
4917
+ return [...new Set(refs.filter((r) => r !== void 0).map((r) => resolveAction(dirs, r).ref))];
4202
4918
  }
4203
4919
  /**
4204
4920
  * One line for each blocked action in another session that waits for an action of this one, followed through
4205
4921
  * handoffs, so the session being waited for sees it without the other side having to send a message. An action
4206
4922
  * already done or dropped has nothing left to deliver, so a wait on it is not shown.
4207
4923
  */
4208
- function waiterLines(root, session) {
4924
+ function waiterLines(dirs, session) {
4209
4925
  const lines = [];
4210
- for (const other of loadSessions(root).sessions) {
4926
+ for (const other of loadSessions(dirs).sessions) {
4211
4927
  if (other.id === session.id || other.archivedAt || other.status === "handed-off") continue;
4212
- for (const blocked of other.actions.filter((a) => a.status === "blocked")) for (const ref of waitedFor(root, blocked)) {
4928
+ for (const blocked of other.actions.filter((a) => a.status === "blocked")) for (const ref of waitedFor(dirs, blocked)) {
4213
4929
  const mine = ref.startsWith(`${session.id}#`) ? session.actions.find((a) => `${session.id}#${a.id}` === ref) : void 0;
4214
4930
  if (!mine || mine.status === "done" || mine.status === "dropped") continue;
4215
4931
  const until = blocked.blockedBy?.until ? `。條件:${blocked.blockedBy.until}` : "";
@@ -4223,16 +4939,16 @@ function waiterLines(root, session) {
4223
4939
  * 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
4224
4940
  * its parent handed it is not a second claim.
4225
4941
  */
4226
- function duplicateClaimLines(root, sessionId, actionId) {
4227
- const { sessions } = loadSessions(root);
4942
+ function duplicateClaimLines(dirs, sessionId, actionId) {
4943
+ const { sessions } = loadSessions(dirs);
4228
4944
  const action = sessions.find((s) => s.id === sessionId)?.actions.find((a) => a.id === actionId);
4229
4945
  if (!action || action.serves.length === 0) return [];
4230
4946
  const related = family(sessions, sessionId);
4231
4947
  const lines = [];
4232
4948
  for (const other of sessions) {
4233
- if (related.has(other.id) || !mayBeRunning(root, other)) continue;
4949
+ if (related.has(other.id) || !mayBeRunning(dirs, other)) continue;
4234
4950
  for (const theirs of other.actions.filter((a) => a.status === "doing")) {
4235
- const shared = sharedServes(root, action.serves, theirs.serves);
4951
+ const shared = sharedServes(dirs, action.serves, theirs.serves);
4236
4952
  if (shared.length > 0) lines.push(`⚠ ${other.id}#${theirs.id}(15 分鐘內有活動)也在做 ${shared.join("、")},先協調再開始`);
4237
4953
  }
4238
4954
  }
@@ -4242,30 +4958,30 @@ function duplicateClaimLines(root, sessionId, actionId) {
4242
4958
  * Lines naming the other top-level sessions that still hold unfinished actions, with the command that takes them
4243
4959
  * over into this one. Subagent sessions are left out: their work returns to the session that delegated it.
4244
4960
  */
4245
- function handoffLines(root, session, cli) {
4246
- 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));
4961
+ function handoffLines(dirs, session, cli) {
4962
+ const others = loadSessions(dirs).sessions.filter((s) => s.id !== session.id && !s.delegatedFrom && !s.archivedAt && (s.status === "active" || s.status === "paused") && s.actions.some(isCarried));
4247
4963
  if (others.length === 0) return [];
4248
- 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(";")}`)];
4964
+ return [`其他會話還有沒做完的行動。要接續的是其中一場已經停下的工作時,先用 ${cli} session handoff <會話> 把它的行動接進這場會話,再開始做:`, ...others.map((s) => `- ${s.id}${mayBeRunning(dirs, s) ? "(15 分鐘內還有寫入對話紀錄,可能還在執行,不要接手)" : ""}:${s.actions.filter(isCarried).map((a) => `${a.id} ${a.do}(${a.status},服務 ${a.serves.join("、") || "未指定"})`).join(";")}`)];
4249
4965
  }
4250
- const servedLine = (root, action) => action.serves.map((ref) => {
4251
- const s = describe(root, ref);
4966
+ const servedLine = (dirs, action) => action.serves.map((ref) => {
4967
+ const s = describe(dirs, ref);
4252
4968
  return s ? `${ref} ${s.text}(${s.where})` : `${ref}(找不到這個項目或條件)`;
4253
4969
  }).join(";") || "沒有指定服務的 checklist 項目或解決條件";
4254
4970
  /**
4255
4971
  * Lines for each pair of overlapping touches that involves a mainline an action in progress serves: the other
4256
4972
  * mainline and task, and the paths they share. Empty when the session serves nothing that overlaps.
4257
4973
  */
4258
- function overlapLines(root, session) {
4974
+ function overlapLines(dirs, session) {
4259
4975
  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] : []));
4260
4976
  if (mine.size === 0) return [];
4261
- const hub = loadHub(root, { remotes: false });
4977
+ const hub = loadHub(dirs, { remotes: false });
4262
4978
  const lines = [];
4263
4979
  for (const o of touchOverlaps(hub.mainlines, dependencyGraph(hub).edges)) {
4264
4980
  const [own, other] = mine.has(o.tasks[0].split("/")[0]) ? [0, 1] : mine.has(o.tasks[1].split("/")[0]) ? [1, 0] : [-1, -1];
4265
4981
  if (own < 0) continue;
4266
4982
  const paths = o.paths.map(([a, b]) => own === 0 ? [a, b] : [b, a]).map(([a, b]) => a === b ? a : `${a} ~ ${b}`).join("、");
4267
4983
  const title = hub.mainlines.find((m) => m.id === o.tasks[other].split("/")[0])?.title;
4268
- lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}),兩邊沒有 depends-on`);
4984
+ lines.push(`- ${o.tasks[own]} 和 ${o.tasks[other]}${title ? `(${title})` : ""} 都會動到 ${paths}(倉庫 ${o.repo}),提交時撞上就和對方的會話協調先後,不必記 depends-on`);
4269
4985
  }
4270
4986
  return lines;
4271
4987
  }
@@ -4274,8 +4990,8 @@ const BRIEFING_NOTICES = 5;
4274
4990
  /** Longest message summary of a notice in the briefing, in characters. */
4275
4991
  const NOTICE_SUMMARY = 80;
4276
4992
  /** The unhandled sync notices for the briefing: up to BRIEFING_NOTICES, the total, and where the page lists them; empty when none. */
4277
- function syncNoticeLines(root) {
4278
- const notices = unhandledNotices(root);
4993
+ function syncNoticeLines(dirs) {
4994
+ const notices = unhandledNotices(dirs);
4279
4995
  if (notices.length === 0) return [];
4280
4996
  const summary = (message) => message.length > NOTICE_SUMMARY ? `${message.slice(0, NOTICE_SUMMARY)}…` : message;
4281
4997
  return [
@@ -4285,13 +5001,13 @@ function syncNoticeLines(root) {
4285
5001
  ];
4286
5002
  }
4287
5003
  /** Full briefing for the start of a session, after resuming, clearing or compacting. */
4288
- function sessionBriefing(root, session, options) {
5004
+ function sessionBriefing(dirs, session, options) {
4289
5005
  const { cli } = options;
4290
5006
  const lines = [`[mainflow] 這場會話的 id 是 ${session.id}${options.envNote ? `(${options.envNote})` : ""}。`];
4291
5007
  const doing = current(session);
4292
5008
  const upcoming = session.actions.filter((a) => a.status === "todo").slice(0, 3);
4293
5009
  if (session.actions.length === 0) {
4294
- const active = openMainlines(root);
5010
+ const active = openMainlines(dirs);
4295
5011
  lines.push("這場會話還沒有行動。");
4296
5012
  if (active.length > 0) {
4297
5013
  lines.push("進行中的主脈絡:");
@@ -4300,21 +5016,21 @@ function sessionBriefing(root, session, options) {
4300
5016
  }
4301
5017
  } else {
4302
5018
  lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
4303
- if (doing) lines.push(`服務:${servedLine(root, doing)}`);
4304
- const grants = grantLines(root, session);
5019
+ if (doing) lines.push(`服務:${servedLine(dirs, doing)}`);
5020
+ const grants = grantLines(dirs, session);
4305
5021
  if (grants.length > 0) lines.push("目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants);
4306
5022
  if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
4307
5023
  const blocked = session.actions.filter((a) => a.status === "blocked");
4308
5024
  if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
4309
5025
  }
4310
- lines.push(...waiterLines(root, session));
4311
- const overlaps = overlapLines(root, session);
5026
+ lines.push(...waiterLines(dirs, session));
5027
+ const overlaps = overlapLines(dirs, session);
4312
5028
  if (overlaps.length > 0) lines.push("目前行動服務的主脈絡和其他進行中的主脈絡預計動到同樣的路徑,開始改之前先和對方協調,或加 depends-on:", ...overlaps);
4313
- if (!session.actions.some(isOpen)) lines.push(...handoffLines(root, session, cli));
4314
- const warnings = queueWarnings(root, session, cli);
5029
+ if (!session.actions.some(isOpen)) lines.push(...handoffLines(dirs, session, cli));
5030
+ const warnings = queueWarnings(dirs, session, cli);
4315
5031
  if (warnings.length > 0) lines.push("需要處理:", ...warnings.map((w) => `- ${w}`));
4316
- lines.push(...syncNoticeLines(root));
4317
- 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,它依序帶過並告訴你資訊該寫在哪裡。");
5032
+ lines.push(...syncNoticeLines(dirs));
5033
+ 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 <標題> 建檔後再寫內文)。這些檔案在 hub 的資料目錄 ${dirs.data},訊息裡的 .mainflow/ 指的就是這個目錄。`, `- 改完資料目錄裡的檔案跑 ${cli} check,把它列出的問題修到沒有。`, "- 用完其他 skill 或子流程後,回到上面的目前行動再繼續。", "- 要開主脈絡、拆任務、記決策、延後或收尾時,用 mainflow skill,它依序帶過並告訴你資訊該寫在哪裡。");
4318
5034
  return lines.join("\n");
4319
5035
  }
4320
5036
  /**
@@ -4322,17 +5038,17 @@ function sessionBriefing(root, session, options) {
4322
5038
  * Records a digest of the grants, waits and warnings it lists in the session file, only when the digest changes;
4323
5039
  * `saveDigest` records it instead of writing the file here, as a host hook in remote mode sends it to the sync server.
4324
5040
  */
4325
- function turnReminder(root, session, options) {
4326
- if (session.actions.length === 0) return openMainlines(root).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
5041
+ function turnReminder(dirs, session, options) {
5042
+ if (session.actions.length === 0) return openMainlines(dirs).length > 0 ? `[mainflow] ${session.id} 還沒有行動。這一輪要推進主脈絡時,先用 ${options.cli} action add 列出行動並標為 doing,或用 ${options.cli} session handoff <會話> 接手上一場沒做完的行動,再開始做。回覆結尾的標頭只看這場會話進行中的行動。` : null;
4327
5043
  const doing = current(session);
4328
5044
  const open = session.actions.filter(isOpen);
4329
5045
  const blocked = session.actions.filter((a) => a.status === "blocked");
4330
5046
  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 列出下一個行動。`;
4331
5047
  const header = headerLine(session);
4332
5048
  const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
4333
- const grants = grantLines(root, session);
4334
- const waiters = waiterLines(root, session);
4335
- const warnings = queueWarnings(root, session, options.cli);
5049
+ const grants = grantLines(dirs, session);
5050
+ const waiters = waiterLines(dirs, session);
5051
+ const warnings = queueWarnings(dirs, session, options.cli);
4336
5052
  const digest = contentHash(JSON.stringify([
4337
5053
  doing?.id ?? null,
4338
5054
  grants,
@@ -4340,9 +5056,9 @@ function turnReminder(root, session, options) {
4340
5056
  warnings
4341
5057
  ]));
4342
5058
  const repeated = session.reminderDigest === digest;
4343
- if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(root, session.id)) try {
5059
+ if (!repeated && (session.reminderDigest !== void 0 || grants.length + waiters.length + warnings.length > 0) && readSession(dirs, session.id)) try {
4344
5060
  if (options.saveDigest) options.saveDigest(digest);
4345
- else setReminderDigest(root, session.id, digest);
5061
+ else setReminderDigest(dirs, session.id, digest);
4346
5062
  } catch {}
4347
5063
  const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
4348
5064
  const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
@@ -4358,66 +5074,8 @@ function turnReminder(root, session, options) {
4358
5074
  }
4359
5075
  //#endregion
4360
5076
  //#region ../core/dist/waiting.js
4361
- /** Separates commits in the git log output (%x1e); commit messages do not use this control character. */
4362
- const RECORD = "";
4363
- /** Whether git finds a repository at the hub root; false as well when git itself cannot run. */
4364
- function inGitRepository(root) {
4365
- try {
4366
- execFileSync("git", ["rev-parse", "--git-dir"], {
4367
- cwd: root,
4368
- stdio: "ignore"
4369
- });
4370
- return true;
4371
- } catch {
4372
- return false;
4373
- }
4374
- }
4375
- /** 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. */
4376
- function commitState(root, branch, text) {
4377
- let log;
4378
- try {
4379
- log = execFileSync("git", [
4380
- "log",
4381
- "--format=%h %s%n%b%x1e",
4382
- branch,
4383
- "--"
4384
- ], {
4385
- cwd: root,
4386
- encoding: "utf8",
4387
- stdio: [
4388
- "ignore",
4389
- "pipe",
4390
- "pipe"
4391
- ],
4392
- maxBuffer: 268435456
4393
- });
4394
- } catch {
4395
- if (!inGitRepository(root)) return {
4396
- met: false,
4397
- unreachable: true,
4398
- detail: `the hub root ${root} is not in a git repository, so no commit can appear there`
4399
- };
4400
- return {
4401
- met: false,
4402
- detail: `branch ${branch} is not in the repository at the hub root yet`
4403
- };
4404
- }
4405
- for (const record of log.split(RECORD)) {
4406
- const entry = record.replace(/^\n/, "");
4407
- const space = entry.indexOf(" ");
4408
- const message = entry.slice(space + 1);
4409
- if (space > 0 && message.includes(text)) return {
4410
- met: true,
4411
- detail: `commit ${entry.slice(0, space)} on ${branch} contains "${text}": ${message.split("\n")[0]}`
4412
- };
4413
- }
4414
- return {
4415
- met: false,
4416
- detail: `no commit on ${branch} contains "${text}"`
4417
- };
4418
- }
4419
- function actionState(root, ref) {
4420
- const found = resolveAction(root, ref);
5077
+ function actionState(dirs, ref) {
5078
+ const found = resolveAction(dirs, ref);
4421
5079
  if ("missing" in found) return {
4422
5080
  met: false,
4423
5081
  detail: found.missing
@@ -4442,16 +5100,16 @@ function actionState(root, ref) {
4442
5100
  * hub is not registered here. A registered hub that is missing on this machine or fails to load may return, so
4443
5101
  * that case keeps waiting. A prefix equal to this hub's own name reads the local hub.
4444
5102
  */
4445
- function mainlineState(root, text) {
5103
+ function mainlineState(dirs, text) {
4446
5104
  const ref = parseRef(text);
4447
5105
  if (ref?.kind !== "mainline") return {
4448
5106
  met: false,
4449
5107
  unreachable: true,
4450
5108
  detail: `"${text}" is not a mainline reference like m001 or other-hub:m001`
4451
5109
  };
4452
- let target = root;
5110
+ let target = dirs;
4453
5111
  try {
4454
- const config = readConfig(root);
5112
+ const config = readConfig(dirs);
4455
5113
  if (ref.hub !== void 0 && ref.hub !== config.hub) {
4456
5114
  const path = config.hubs?.[ref.hub];
4457
5115
  if (path === void 0) return {
@@ -4459,11 +5117,12 @@ function mainlineState(root, text) {
4459
5117
  unreachable: true,
4460
5118
  detail: `hub "${ref.hub}" is not registered, so ${text} can never be read; run "mainflow hub add ${ref.hub} <path>"`
4461
5119
  };
4462
- target = remoteRoot(root, path);
4463
- if (!isHubRoot(target)) return {
5120
+ const registered = hubAtRoot(remoteRoot(dirs.root, path));
5121
+ if (!registered) return {
4464
5122
  met: false,
4465
5123
  detail: `hub "${ref.hub}" is not at ${path} on this machine, so ${text} cannot be read yet`
4466
5124
  };
5125
+ target = registered;
4467
5126
  }
4468
5127
  } catch (error) {
4469
5128
  return {
@@ -4503,13 +5162,15 @@ function mainlineState(root, text) {
4503
5162
  detail: `mainline ${text} is ${status}`
4504
5163
  };
4505
5164
  }
4506
- /** Check a wait condition once. */
4507
- function untilState(root, until) {
5165
+ /**
5166
+ * Check a wait condition once. A file is relative to the hub repository,
5167
+ * or in the data directory when named `.mainflow/{path}`.
5168
+ */
5169
+ function untilState(dirs, until) {
4508
5170
  switch (until.kind) {
4509
- case "commit": return commitState(root, until.branch, until.text);
4510
- case "action-done": return actionState(root, `${until.session}#${until.action}`);
4511
- case "mainline-done": return mainlineState(root, until.mainline);
4512
- case "file": return existsSync(resolve(root, until.path)) ? {
5171
+ case "action-done": return actionState(dirs, `${until.session}#${until.action}`);
5172
+ case "mainline-done": return mainlineState(dirs, until.mainline);
5173
+ case "file": return existsSync(dataFile(dirs, until.path)) ? {
4513
5174
  met: true,
4514
5175
  detail: `${until.path} exists`
4515
5176
  } : {
@@ -4520,9 +5181,9 @@ function untilState(root, until) {
4520
5181
  }
4521
5182
  //#endregion
4522
5183
  //#region ../core/dist/write.js
4523
- function initHub(root, hub) {
4524
- runUnlocked(root, (h) => initHub$1(h, { name: hub }));
4525
- return join(root, DATA_DIR);
5184
+ function initHub(dirs, hub) {
5185
+ runUnlocked(dirs, (h) => initHub$1(h, { name: hub }));
5186
+ return dirs.data;
4526
5187
  }
4527
5188
  //#endregion
4528
5189
  //#region ../core/dist/archive.js
@@ -7356,17 +8017,70 @@ async function fetchTranscripts(source, wanted) {
7356
8017
  }
7357
8018
  //#endregion
7358
8019
  //#region ../core/dist/retro/receiver.js
7359
- /** The environment variables a command reads the receiver from. None set: the retro does not read the receiver. */
8020
+ /**
8021
+ * Where a command reads the receiver from: the same names the measurement upload hooks use, so one setting serves both.
8022
+ * The address is the origin of the transcript upload URL; the Access service token is read from the environment first,
8023
+ * then from the macOS keychain.
8024
+ */
7360
8025
  const RECEIVER_ENV = {
7361
- url: "MAINFLOW_TELEMETRY_URL",
7362
- accessClientId: "MAINFLOW_TELEMETRY_ACCESS_CLIENT_ID",
7363
- accessClientSecret: "MAINFLOW_TELEMETRY_ACCESS_CLIENT_SECRET"
8026
+ url: "TRANSCRIPT_UPLOAD_URL",
8027
+ accessClientId: "TELEMETRY_ACCESS_CLIENT_ID",
8028
+ accessClientSecret: "TELEMETRY_ACCESS_CLIENT_SECRET"
7364
8029
  };
7365
- /** The receiver named by the environment, or null when none of the three variables is set. A partial set is passed on, so the report says which is missing. */
7366
- function receiverFromEnv(env) {
7367
- const url = env[RECEIVER_ENV.url]?.trim() ?? "";
7368
- const accessClientId = env[RECEIVER_ENV.accessClientId]?.trim() ?? "";
7369
- const accessClientSecret = env[RECEIVER_ENV.accessClientSecret]?.trim() ?? "";
8030
+ /** The macOS keychain items (generic passwords) holding the same service token. */
8031
+ const RECEIVER_KEYCHAIN = {
8032
+ accessClientId: "telemetry-access-client-id",
8033
+ accessClientSecret: "telemetry-access-client-secret"
8034
+ };
8035
+ function defaultReceiverSources(env) {
8036
+ return {
8037
+ settingsEnv: () => {
8038
+ try {
8039
+ const dir = env.CLAUDE_CONFIG_DIR?.trim() || join(env.HOME?.trim() || homedir(), ".claude");
8040
+ const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8"));
8041
+ return isObject(settings) && isObject(settings.env) ? settings.env : {};
8042
+ } catch {
8043
+ return {};
8044
+ }
8045
+ },
8046
+ keychain: (service) => {
8047
+ if (process.platform !== "darwin") return "";
8048
+ try {
8049
+ return execFileSync("security", [
8050
+ "find-generic-password",
8051
+ "-s",
8052
+ service,
8053
+ "-w"
8054
+ ], {
8055
+ encoding: "utf8",
8056
+ stdio: [
8057
+ "ignore",
8058
+ "pipe",
8059
+ "ignore"
8060
+ ],
8061
+ timeout: 5e3
8062
+ }).trim();
8063
+ } catch {
8064
+ return "";
8065
+ }
8066
+ }
8067
+ };
8068
+ }
8069
+ /**
8070
+ * The receiver the environment, the Claude Code user settings and the keychain name, or null when none of the three
8071
+ * values is found. A partial set is passed on, so the report says which is missing. The address is the origin of
8072
+ * TRANSCRIPT_UPLOAD_URL (the upload URL itself when it is no URL); each Access value is the environment variable, else
8073
+ * the keychain item. The keychain is asked only for what the environment lacks.
8074
+ */
8075
+ function receiverFromEnv(env, sources = defaultReceiverSources(env)) {
8076
+ const text = (v) => typeof v === "string" ? v.trim() : "";
8077
+ const upload = text(env[RECEIVER_ENV.url]) || text(sources.settingsEnv()[RECEIVER_ENV.url]);
8078
+ let url = upload;
8079
+ try {
8080
+ url = new URL(upload).origin;
8081
+ } catch {}
8082
+ const accessClientId = text(env[RECEIVER_ENV.accessClientId]) || sources.keychain(RECEIVER_KEYCHAIN.accessClientId).trim();
8083
+ const accessClientSecret = text(env[RECEIVER_ENV.accessClientSecret]) || sources.keychain(RECEIVER_KEYCHAIN.accessClientSecret).trim();
7370
8084
  return url || accessClientId || accessClientSecret ? {
7371
8085
  url,
7372
8086
  accessClientId,
@@ -7431,9 +8145,9 @@ async function readReceiver(config, scopes) {
7431
8145
  problems
7432
8146
  });
7433
8147
  const absent = [
7434
- ...config.url ? [] : [RECEIVER_ENV.url],
7435
- ...config.accessClientId ? [] : [RECEIVER_ENV.accessClientId],
7436
- ...config.accessClientSecret ? [] : [RECEIVER_ENV.accessClientSecret]
8148
+ ...config.url ? [] : [`${RECEIVER_ENV.url}(環境變數或 Claude Code 使用者設定的 env)`],
8149
+ ...config.accessClientId ? [] : [`${RECEIVER_ENV.accessClientId}(環境變數或鑰匙圈 ${RECEIVER_KEYCHAIN.accessClientId})`],
8150
+ ...config.accessClientSecret ? [] : [`${RECEIVER_ENV.accessClientSecret}(環境變數或鑰匙圈 ${RECEIVER_KEYCHAIN.accessClientSecret})`]
7437
8151
  ];
7438
8152
  if (absent.length) {
7439
8153
  problems.push(`${absent.join("、")} 沒有設定,沒有讀取接收端`);
@@ -7793,24 +8507,24 @@ const secondText = (ms) => {
7793
8507
  return `${d.toLocaleDateString("sv-SE")}T${d.toTimeString().slice(0, 8)}`;
7794
8508
  };
7795
8509
  /** Where transcripts fetched from the receiver are kept: in the hub's local directory, which the hub ignores. */
7796
- const transcriptCache = (root) => join(root, ".mainflow/local/transcripts");
8510
+ const transcriptCache = (dirs) => join(dirs.data, "local", "transcripts");
7797
8511
  /** The transcript to read: the recorded path when the file is there, else the copy fetched from the receiver, else the recorded path. */
7798
- function transcriptOf(root, transcript) {
7799
- const recorded = transcriptPath(root, transcript);
8512
+ function transcriptOf(dirs, transcript) {
8513
+ const recorded = transcriptPath(dirs, transcript);
7800
8514
  if (existsSync(recorded)) return recorded;
7801
- const cached = join(transcriptCache(root), basename(recorded));
8515
+ const cached = join(transcriptCache(dirs), basename(recorded));
7802
8516
  return existsSync(cached) ? cached : recorded;
7803
8517
  }
7804
8518
  /** Sessions serving the mainline whose recorded transcript is not on this machine, as the receiver knows them. */
7805
- function missingTranscripts(root, mainlineId) {
8519
+ function missingTranscripts(dirs, mainlineId) {
7806
8520
  const wanted = /* @__PURE__ */ new Map();
7807
- for (const s of loadSessions(root).sessions) {
8521
+ for (const s of loadSessions(dirs).sessions) {
7808
8522
  if (!s.transcript || !s.actions.some((a) => servesMainline(a, mainlineId))) continue;
7809
- if (existsSync(transcriptPath(root, s.transcript))) continue;
8523
+ if (existsSync(transcriptPath(dirs, s.transcript))) continue;
7810
8524
  const sid = basename(s.transcript, ".jsonl");
7811
8525
  wanted.set(sid, {
7812
8526
  sid,
7813
- dir: transcriptCache(root)
8527
+ dir: transcriptCache(dirs)
7814
8528
  });
7815
8529
  }
7816
8530
  return [...wanted.values()];
@@ -7922,8 +8636,8 @@ const timed = (f) => {
7922
8636
  * counts inside its window; a subagent counts whole when the Agent call that started it falls inside its parent's
7923
8637
  * window, and not at all otherwise. Without the call, the subagent's first record stands in for it.
7924
8638
  */
7925
- function readGroups(root, mainlineId, windowOf) {
7926
- const serving = loadSessions(root).sessions.filter((s) => s.actions.some((a) => servesMainline(a, mainlineId)));
8639
+ function readGroups(dirs, mainlineId, windowOf) {
8640
+ const serving = loadSessions(dirs).sessions.filter((s) => s.actions.some((a) => servesMainline(a, mainlineId)));
7927
8641
  const groups = [];
7928
8642
  const bare = [];
7929
8643
  const seen = /* @__PURE__ */ new Set();
@@ -7947,7 +8661,7 @@ function readGroups(root, mainlineId, windowOf) {
7947
8661
  };
7948
8662
  const ordered = [...serving].sort((a, b) => Number(Boolean(a.delegatedFrom)) - Number(Boolean(b.delegatedFrom)));
7949
8663
  for (const s of ordered) {
7950
- const file = s.transcript ? transcriptOf(root, s.transcript) : void 0;
8664
+ const file = s.transcript ? transcriptOf(dirs, s.transcript) : void 0;
7951
8665
  if (s.delegatedFrom) {
7952
8666
  if (!file || seen.has(file)) continue;
7953
8667
  const parent = groups.find((g) => g.session === s.delegatedFrom);
@@ -7998,8 +8712,8 @@ function readGroups(root, mainlineId, windowOf) {
7998
8712
  * Read the transcripts of every session with an action serving the mainline, and their subagents', into cost,
7999
8713
  * attribution to promptex nodes, anomalies, review rounds and the offline analyses.
8000
8714
  */
8001
- function retroReport(root, mainlineId, options = {}) {
8002
- return buildReport(root, readRetro(root, mainlineId, options), options);
8715
+ function retroReport(dirs, mainlineId, options = {}) {
8716
+ return buildReport(dirs, readRetro(dirs, mainlineId, options), options);
8003
8717
  }
8004
8718
  /**
8005
8719
  * retroReport with the measurement receiver's export: the transcripts are read first, since their windows decide
@@ -8007,9 +8721,9 @@ function retroReport(root, mainlineId, options = {}) {
8007
8721
  * in part, leaves the report as retroReport would make it from what was read, with what is missing listed in
8008
8722
  * `measurements.problems`.
8009
8723
  */
8010
- async function retroReportFromReceiver(root, mainlineId, receiver, options = {}) {
8724
+ async function retroReportFromReceiver(dirs, mainlineId, receiver, options = {}) {
8011
8725
  if (receiver.url && receiver.accessClientId && receiver.accessClientSecret) {
8012
- const wanted = missingTranscripts(root, mainlineId);
8726
+ const wanted = missingTranscripts(dirs, mainlineId);
8013
8727
  if (wanted.length) {
8014
8728
  const { warnings } = await fetchTranscripts({
8015
8729
  url: receiver.url,
@@ -8020,19 +8734,19 @@ async function retroReportFromReceiver(root, mainlineId, receiver, options = {})
8020
8734
  for (const line of warnings) (receiver.warn ?? ((l) => process.stderr.write(`${l}\n`)))(line);
8021
8735
  }
8022
8736
  }
8023
- const read = readRetro(root, mainlineId, options);
8737
+ const read = readRetro(dirs, mainlineId, options);
8024
8738
  const t = performance.now();
8025
8739
  const exported = await readReceiver(receiver, receiverScopes(read.groups));
8026
- return buildReport(root, read, {
8740
+ return buildReport(dirs, read, {
8027
8741
  ...options,
8028
8742
  receiver: exported
8029
8743
  }, performance.now() - t);
8030
8744
  }
8031
- function readRetro(root, mainlineId, options) {
8745
+ function readRetro(dirs, mainlineId, options) {
8032
8746
  const started = performance.now();
8033
- const mainline = loadHub(root, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
8747
+ const mainline = loadHub(dirs, { remotes: false }).mainlines.find((m) => m.id === mainlineId);
8034
8748
  if (!mainline) throw new WriteError(`mainline ${mainlineId} not found; run "mainflow status --all" to list mainlines`);
8035
- const [{ groups, bare }, readMs] = timed(() => readGroups(root, mainlineId, options.windowOf));
8749
+ const [{ groups, bare }, readMs] = timed(() => readGroups(dirs, mainlineId, options.windowOf));
8036
8750
  return {
8037
8751
  started,
8038
8752
  mainline,
@@ -8042,7 +8756,7 @@ function readRetro(root, mainlineId, options) {
8042
8756
  readMs
8043
8757
  };
8044
8758
  }
8045
- function buildReport(root, read, options, receiverMs) {
8759
+ function buildReport(dirs, read, options, receiverMs) {
8046
8760
  const { started, mainline, mainlineId, groups, bare, readMs } = read;
8047
8761
  const receiver = options.receiver;
8048
8762
  const measuredRuns = [];
@@ -8189,7 +8903,7 @@ function buildReport(root, read, options, receiverMs) {
8189
8903
  }))).sort((a, b) => b.runs - a.runs);
8190
8904
  const modelList = [...models.values()].sort((a, b) => b.calls - a.calls || a.model.localeCompare(b.model));
8191
8905
  const transcriptTotal = modelList.reduce((n, m) => n + (m.cost ?? 0), 0);
8192
- const nodes = loadNodeRegistry(options.promptexRoots ?? [homedir(), root]);
8906
+ const nodes = loadNodeRegistry(options.promptexRoots ?? [homedir(), dirs.root]);
8193
8907
  const metas = groups.flatMap((g) => g.subagents.map((a) => ({
8194
8908
  agentId: basename(a.file, ".jsonl").replace(/^agent-/, ""),
8195
8909
  ...a.meta
@@ -8316,9 +9030,9 @@ function buildReport(root, read, options, receiverMs) {
8316
9030
  //#endregion
8317
9031
  //#region ../core/dist/migrate.js
8318
9032
  /** Convert a format 1 hub on disk to the current format, writing nothing when `dryRun` is set. */
8319
- function migrateHub(root, options = {}) {
8320
- const storage = nodeStorage(root);
8321
- if (storage.read("config.yaml") === null) throw new WriteError(`no hub at ${root}; run mainflow init first`);
9033
+ function migrateHub(dirs, options = {}) {
9034
+ const storage = nodeStorage(dirs);
9035
+ if (storage.read("config.yaml") === null) throw new WriteError(`no hub at ${dirs.root}; run mainflow init first`);
8322
9036
  return applyOp(storage, (hub) => {
8323
9037
  const result = migrateHub$1(hub);
8324
9038
  return options.dryRun ? {
@@ -8328,6 +9042,177 @@ function migrateHub(root, options = {}) {
8328
9042
  }).value;
8329
9043
  }
8330
9044
  //#endregion
9045
+ //#region ../core/dist/migrate-data.js
9046
+ /** What the original data directory is renamed to, inside the hub repository, once the hub reads from its new place. */
9047
+ const MIGRATED_NAME = `${DATA_DIR}.migrated`;
9048
+ /** Paths of the data directory, relative to it with `/`, that the copy leaves out. */
9049
+ const LEFT_OUT = /* @__PURE__ */ new Set([".git", "local/write.lock"]);
9050
+ /**
9051
+ * Every entry under `dir` but the ones left out, by its path relative to `dir` with `/`; symbolic links are not followed.
9052
+ * Refused when an entry is none of a file, a directory or a symbolic link, such as a named pipe, which the copy cannot take.
9053
+ */
9054
+ function inventory(dir) {
9055
+ const items = /* @__PURE__ */ new Map();
9056
+ const walk = (at) => {
9057
+ for (const name of readdirSync(join(dir, at)).sort()) {
9058
+ const path = at ? `${at}/${name}` : name;
9059
+ if (LEFT_OUT.has(path)) continue;
9060
+ const full = join(dir, path);
9061
+ const info = lstatSync(full);
9062
+ if (info.isSymbolicLink()) items.set(path, {
9063
+ kind: "link",
9064
+ target: readlinkSync(full)
9065
+ });
9066
+ else if (info.isDirectory()) {
9067
+ items.set(path, { kind: "dir" });
9068
+ walk(path);
9069
+ } else if (info.isFile()) items.set(path, {
9070
+ kind: "file",
9071
+ hash: createHash("sha256").update(readFileSync(full)).digest("hex"),
9072
+ size: info.size
9073
+ });
9074
+ else throw new WriteError(`${full} is neither a file, a directory nor a symbolic link, so it cannot be copied; move it out of ${dir} and run this again`);
9075
+ }
9076
+ };
9077
+ walk("");
9078
+ return items;
9079
+ }
9080
+ /** The first difference between the original's entries and the copy's, or null when the copy holds the same. */
9081
+ function difference(original, copy) {
9082
+ for (const [path, item] of original) {
9083
+ const copied = copy.get(path);
9084
+ if (!copied) return `${path} is missing from the copy`;
9085
+ if (JSON.stringify(copied) !== JSON.stringify(item)) return `${path} differs in the copy`;
9086
+ }
9087
+ for (const path of copy.keys()) if (!original.has(path)) return `${path} is in the copy only`;
9088
+ return null;
9089
+ }
9090
+ /** `<root>/.mainflow.migrated`, or the first of `.mainflow.migrated.2`, `.3` … that is free. */
9091
+ function keptName(root) {
9092
+ for (let n = 1;; n++) {
9093
+ const kept = join(root, n === 1 ? MIGRATED_NAME : `${MIGRATED_NAME}.${n}`);
9094
+ if (!existsSync(kept) && !isLink(kept)) return kept;
9095
+ }
9096
+ }
9097
+ const isLink = (path) => {
9098
+ try {
9099
+ return lstatSync(path).isSymbolicLink();
9100
+ } catch {
9101
+ return false;
9102
+ }
9103
+ };
9104
+ /** Whether `dir` is missing or an empty directory, so the copy can take its place. */
9105
+ const vacant = (dir) => !existsSync(dir) || statSync(dir).isDirectory() && readdirSync(dir).length === 0;
9106
+ /**
9107
+ * Move the data of the hub whose repository is `path` from `<path>/.mainflow` to `$MAINFLOW_HOME/<name>`, and register
9108
+ * the hub with its data there. With `dryRun` nothing changes: the move is checked and described. Refused when the
9109
+ * repository holds no hub data, when the hub already moved its data, when the new place is taken, when the hub could
9110
+ * not be registered from there, or when `busy` names something that still reads and writes the old place, such as a
9111
+ * running sync client; `busy` is asked again once the move holds the write lock, so nothing starts between the check
9112
+ * and the move.
9113
+ */
9114
+ async function migrateHubData(path, options = {}) {
9115
+ const env = options.env ?? process.env;
9116
+ const dryRun = options.dryRun ?? false;
9117
+ const root = realPath(path);
9118
+ if (!root) throw new WriteError(`${path} does not exist`);
9119
+ const found = inRepositoryData(root);
9120
+ if (!found) {
9121
+ const registered = hubAtRoot(root, env);
9122
+ if (registered) throw new WriteError(`hub "${registered.name}" already keeps its data in ${registered.data}; ${join(root, DATA_DIR)} holds no hub data to move`);
9123
+ throw new WriteError(`no hub data to move at ${root}: ${join(root, DATA_DIR, "config.yaml")} does not exist`);
9124
+ }
9125
+ const { name } = found;
9126
+ const source = realPath(found.data);
9127
+ const to = join(realPath(mainflowHome(env)) ?? mainflowHome(env), name);
9128
+ const previous = readRegistry(env).hubs[name] ?? null;
9129
+ if (previous && previous.root !== root) throw new WriteError(`hub "${name}" is registered from ${previous.root}, not ${root}; move its data from there, or run mainflow hub unregister ${name} first`);
9130
+ if (previous && previous.data === (realPath(to) ?? to)) throw new WriteError(`hub "${name}" already moved its data to ${to} and reads it from there; ${found.data} is the original an earlier move could not rename. Check that the hub works from ${to}, then rename or delete ${found.data} yourself.`);
9131
+ if (!vacant(to)) throw new WriteError(`${to} already exists, so the data of hub "${name}" cannot move there. When it holds this hub from an earlier move, check it and register it with: mainflow hub register ${name} ${root}. Otherwise move it away and run this again.`);
9132
+ previewRegistration(name, root, env, { data: source });
9133
+ const worktree = statSync(join(source, ".git"), { throwIfNoEntry: false })?.isFile() ?? false;
9134
+ const files = [...inventory(source).values()].filter((item) => item.kind !== "dir");
9135
+ const bytes = files.reduce((sum, item) => sum + (item.kind === "file" ? item.size : 0), 0);
9136
+ const move = {
9137
+ name,
9138
+ root,
9139
+ from: found.data,
9140
+ to,
9141
+ files: files.length,
9142
+ bytes,
9143
+ worktree,
9144
+ kept: keptName(root),
9145
+ previous,
9146
+ dryRun
9147
+ };
9148
+ const busy = async () => {
9149
+ const reason = options.busy ? await options.busy({
9150
+ root,
9151
+ data: found.data
9152
+ }, name) : null;
9153
+ if (reason) throw new WriteError(reason);
9154
+ };
9155
+ if (dryRun) {
9156
+ await busy();
9157
+ return move;
9158
+ }
9159
+ const staging = join(mainflowHome(env), `.${name}.migrating`);
9160
+ const created = !existsSync(to);
9161
+ mkdirSync(mainflowHome(env), { recursive: true });
9162
+ return withLockAsync(source, async () => {
9163
+ await busy();
9164
+ rmSync(staging, {
9165
+ recursive: true,
9166
+ force: true
9167
+ });
9168
+ let placed = false;
9169
+ try {
9170
+ const copied = inventory(copyData(source, staging));
9171
+ const differs = difference(inventory(source), copied);
9172
+ if (differs) throw new WriteError(`the copy of ${source} in ${staging} is not the same (${differs})`);
9173
+ renameSync(staging, to);
9174
+ placed = true;
9175
+ registerHub(name, root, env, { data: to });
9176
+ } catch (error) {
9177
+ rmSync(staging, {
9178
+ recursive: true,
9179
+ force: true
9180
+ });
9181
+ if (placed) {
9182
+ rmSync(to, {
9183
+ recursive: true,
9184
+ force: true
9185
+ });
9186
+ if (!created) mkdirSync(to);
9187
+ }
9188
+ throw error instanceof WriteError ? new WriteError(`${error.message}; nothing moved, and ${found.data} is as it was`) : new WriteError(`could not move the data of hub "${name}" to ${to}: ${error.message}; nothing moved, and ${found.data} is as it was`);
9189
+ }
9190
+ try {
9191
+ renameSync(found.data, move.kept);
9192
+ } catch (error) {
9193
+ return {
9194
+ ...move,
9195
+ kept: found.data,
9196
+ notRenamed: error.message
9197
+ };
9198
+ }
9199
+ rmSync(join(move.kept, "local", "write.lock"), { force: true });
9200
+ return move;
9201
+ });
9202
+ }
9203
+ /** Copy the data directory `source` to `target`, leaving out LEFT_OUT, with each file's times kept; returns `target`. */
9204
+ function copyData(source, target) {
9205
+ cpSync(source, target, {
9206
+ recursive: true,
9207
+ errorOnExist: true,
9208
+ force: false,
9209
+ preserveTimestamps: true,
9210
+ verbatimSymlinks: true,
9211
+ filter: (from) => !LEFT_OUT.has(relative(source, from).split(sep).join("/"))
9212
+ });
9213
+ return target;
9214
+ }
9215
+ //#endregion
8331
9216
  //#region ../core/dist/group-plan.js
8332
9217
  /** Sessions that can still hold work: not archived, and active or paused. */
8333
9218
  const live = (s) => !s.archivedAt && (s.status === "active" || s.status === "paused");
@@ -8501,13 +9386,13 @@ const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
8501
9386
  * port differs from what Caddy already has, so an ordinary start leaves Caddy alone. A link owned by a
8502
9387
  * different hub of the same name stops here: overwriting it would silently take that hub's host name.
8503
9388
  */
8504
- async function syncHost(root, hub, port, options = {}) {
9389
+ async function syncHost(dirs, hub, port, options = {}) {
8505
9390
  const sites = caddySites();
8506
9391
  if (!sites || !existsSync(sites)) return {
8507
9392
  url: null,
8508
9393
  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`
8509
9394
  };
8510
- const dir = join(root, DATA_DIR, "local", "caddy");
9395
+ const dir = join(dirs.data, "local", "caddy");
8511
9396
  const file = join(dir, `${hub}.caddy`);
8512
9397
  const link = join(sites, `mainflow-${hub}.caddy`);
8513
9398
  removeDanglingLinks(sites);
@@ -8550,14 +9435,14 @@ async function syncHost(root, hub, port, options = {}) {
8550
9435
  * from loading every other site. Only a link that points into `root` is removed; the reload failure, if any,
8551
9436
  * is returned for the caller to log.
8552
9437
  */
8553
- function removeHostLink(root, hub, reload = reloadCaddy) {
9438
+ function removeHostLink(dirs, hub, reload = reloadCaddy) {
8554
9439
  const sites = caddySites();
8555
9440
  if (!sites) return null;
8556
9441
  const link = join(sites, `mainflow-${hub}.caddy`);
8557
9442
  const linked = lstatSync(link, { throwIfNoEntry: false });
8558
9443
  let removed = false;
8559
9444
  if (linked?.isSymbolicLink()) {
8560
- if (resolve(dirname(link), readlinkSync(link)).startsWith(resolve(root) + "/")) {
9445
+ if (resolve(dirname(link), readlinkSync(link)).startsWith(resolve(dirs.data) + "/")) {
8561
9446
  rmSync(link);
8562
9447
  removed = true;
8563
9448
  }
@@ -8597,4 +9482,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
8597
9482
  return true;
8598
9483
  }
8599
9484
  //#endregion
8600
- export { linksInto as $, grantScope as A, WriteError as At, renumberRequest as B, HANDLED_FILE as C, TASK_FILE as Ct, loadSessions as D, worktreesOf as Dt, readNotices as E, findHubRoot as Et, mayDecorate as F, applyOperation as G, renumberPath as H, attributeTrace as I, loadConfig as J, checkHubName as K, traceTranscript as L, decorateReply as M, splitFrontMatter as Mt, endsWithMarker as N, readSession as O, ConflictError as Ot, headerLine as P, mainlineEdges as Q, transcriptTitle as R, turnReminder as S, MAINLINE_FILE as St, parseWarn as T, currentBranch as Tt, renumberText as U, renumberFiles as V, scopeOfPath as W, sectionText as X, loadHub as Y, dependencyGraph as Z, setArchivedMany as _, readsAsText as _t, openInApp as a, nowMinute as at, duplicateClaimLines as b, formatMismatch as bt, siteBlock as c, nodeStorage as ct, migrateHub as d, MAX_BLOB_BYTES as dt, memberOfGroup as et, retroReport as f, MAX_FILE_BYTES as ft, ARCHIVE_KINDS as g, exceedsFileLimit as gt, RETRY_RULES as h, comparePaths as ht, hostName as i, taskFile as it, matchGrants as j, contentHash as jt, transcriptPath as k, DATA_DIR as kt, syncHost as l, ACCESS_HEADERS as lt, receiverFromEnv as m, SOCKET_MAX_AGE_CLOSE as mt, DEFAULT_UI_PORT as n, remoteMembers as nt, removeDanglingLinks as o, readFrontMatter as ot, retroReportFromReceiver as p, ROUTES as pt, HubError as q, caddySites as r, unavailableHubs as rt, removeHostLink as s, readYaml as st, APP_BUNDLE_ID as t, refIndex as tt, groupPlan as u, BLOB_HASH as ut, initHub as v, requestTime as vt, markNoticesHandled as w, requireSlug as wt, sessionBriefing as x, hubFormatVersion as xt, untilState as y, textBytes as yt, applyOffline as z };
9485
+ export { loadConfig as $, WriteError as $t, transcriptPath as A, MAINLINE_FILE as At, applyOffline as B, registeredName as Bt, turnReminder as C, readsAsText as Ct, readNotices as D, hubFormatVersion as Dt, parseWarn as E, formatMismatch as Et, headerLine as F, inRepositoryHubAt as Ft, scopeOfPath as G, nodeStorage as Gt, renumberFiles as H, resolveHub as Ht, mayDecorate as I, listHubs as It, syncedDir as J, hubPath as Jt, hubDigest as K, currentBranch as Kt, attributeTrace as L, notRegisteredMessage as Lt, matchGrants as M, requireSlug as Mt, decorateReply as N, hubAtRoot as Nt, loadSessions as O, hubRequirements as Ot, endsWithMarker as P, hubHoldingFile as Pt, HubError as Q, DATA_DIR as Qt, traceTranscript as R, readRegistry as Rt, sessionBriefing as S, exceedsFileLimit as St, markNoticesHandled as T, textBytes as Tt, renumberPath as U, unregisterHub as Ut, renumberRequest as V, registryFile as Vt, renumberText as W, userDataDir as Wt, applyOperation as X, worktreesOf as Xt, syncedPath as Y, realPath as Yt, checkHubName as Z, ConflictError as Zt, ARCHIVE_KINDS as _, MAX_FILE_BYTES as _t, openInApp as a, memberOfGroup as at, untilState as b, VERSION_HEADER as bt, siteBlock as c, unavailableHubs as ct, migrateHubData as d, readFrontMatter as dt, contentHash as en, loadHub as et, migrateHub as f, readYaml as ft, RETRY_RULES as g, MAX_CHANGE_PAGE as gt, receiverFromEnv as h, MAX_BLOB_BYTES as ht, hostName as i, linksInto as it, grantScope as j, TASK_FILE as jt, readSession as k, versionMismatch as kt, syncHost as l, taskFile as lt, retroReportFromReceiver as m, BLOB_HASH as mt, DEFAULT_UI_PORT as n, dependencyGraph as nt, removeDanglingLinks as o, refIndex as ot, retroReport as p, ACCESS_HEADERS as pt, mayHoldSynced as q, dataFile as qt, caddySites as r, mainlineEdges as rt, removeHostLink as s, remoteMembers as st, APP_BUNDLE_ID as t, splitFrontMatter as tn, sectionText as tt, groupPlan as u, nowMinute as ut, setArchivedMany as v, ROUTES as vt, HANDLED_FILE as w, requestTime as wt, duplicateClaimLines as x, comparePaths as xt, initHub as y, SOCKET_MAX_AGE_CLOSE as yt, transcriptTitle as z, registerHub as zt };