mainflow 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as OFFSET, A as SURVEY_SECTION, B as issueSchema, C as REF_EXAMPLES, D as SESSION_STATUS, E as SESSION_ID, F as configSchema, G as parseRef, H as opsEntrySchema, I as decisionSchema, J as refMainline, K as parseUntil, L as findingSchema, M as TASK_SECTIONS, O as SLUG, P as WORK_STATUS, Q as waitingForRef, R as formatOpsEntry, S as PRIORITIES, T as SERVES_REF, U as parseActionRef, V as mainlineSchema, W as parseOpsLine, X as taskSchema, Y as sessionSchema, Z as waitingForAction, a as DECISION_SECTIONS, at as grantCovers, b as MAINLINE_STATUS, ct as parseGrantTarget, et as stampMs, f as GROUP_SECTIONS, h as ISSUE_SECTIONS, i as DECISION_BASES, it as describeRequest, j as TASK_REQUIRED_SECTIONS, k as SURVEY_LINES, l as FINDING_SECTIONS, n as CLOSED_STATUS, ot as grantForm, p as GROUP_STATUS, q as refExample, r as DECIDERS, s as DEFAULT_CATEGORIES, st as grantRepo, u as FINDING_STATUS, v as MAINLINE_ROLES, w as RESERVED_HUB_NAMES, x as MERGE_STATES, y as MAINLINE_SECTIONS, z as groupSchema } from "./dist-BdvWGTsb.js";
1
+ import { $ as OFFSET, A as SURVEY_SECTION, B as issueSchema, C as REF_EXAMPLES, D as SESSION_STATUS, E as SESSION_ID, F as configSchema, G as parseRef, H as opsEntrySchema, I as decisionSchema, J as refMainline, K as parseUntil, L as findingSchema, M as TASK_SECTIONS, N as UNTIL_FORMS, O as SLUG, P as WORK_STATUS, Q as waitingForRef, R as formatOpsEntry, S as PRIORITIES, T as SERVES_REF, U as parseActionRef, V as mainlineSchema, W as parseOpsLine, X as taskSchema, Y as sessionSchema, Z as waitingForAction, a as DECISION_SECTIONS, at as grantCovers, b as MAINLINE_STATUS, ct as parseGrantTarget, et as stampMs, f as GROUP_SECTIONS, h as ISSUE_SECTIONS, i as DECISION_BASES, it as describeRequest, j as TASK_REQUIRED_SECTIONS, k as SURVEY_LINES, l as FINDING_SECTIONS, n as CLOSED_STATUS, ot as grantForm, p as GROUP_STATUS, q as refExample, r as DECIDERS, s as DEFAULT_CATEGORIES, st as grantRepo, u as FINDING_STATUS, v as MAINLINE_ROLES, w as RESERVED_HUB_NAMES, x as MERGE_STATES, y as MAINLINE_SECTIONS, z as groupSchema } from "./dist-ud09dzZo.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
4
  import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
@@ -137,6 +137,91 @@ const offlineIds = {
137
137
  next: (request) => fresh(request, true)
138
138
  };
139
139
  //#endregion
140
+ //#region ../core/dist/ops/unknown.js
141
+ const isPlain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
142
+ const idOf = (value) => isPlain(value) && typeof value.id === "string" ? value.id : void 0;
143
+ /** Keys that would reach the prototype instead of the data when assigned. */
144
+ const UNSAFE_KEYS = /* @__PURE__ */ new Set([
145
+ "__proto__",
146
+ "constructor",
147
+ "prototype"
148
+ ]);
149
+ function diff(raw, parsed) {
150
+ if (isPlain(raw) && isPlain(parsed)) {
151
+ const fields = /* @__PURE__ */ new Map();
152
+ for (const [key, value] of Object.entries(raw)) if (!Object.hasOwn(parsed, key)) fields.set(key, {
153
+ kind: "value",
154
+ value
155
+ });
156
+ else {
157
+ const inner = diff(value, parsed[key]);
158
+ if (inner) fields.set(key, inner);
159
+ }
160
+ return fields.size > 0 ? {
161
+ kind: "object",
162
+ fields
163
+ } : void 0;
164
+ }
165
+ if (Array.isArray(raw) && Array.isArray(parsed)) {
166
+ const counts = /* @__PURE__ */ new Map();
167
+ for (const element of raw) {
168
+ const id = idOf(element);
169
+ if (id !== void 0) counts.set(id, (counts.get(id) ?? 0) + 1);
170
+ }
171
+ const byId = /* @__PURE__ */ new Map();
172
+ for (const element of raw) {
173
+ const id = idOf(element);
174
+ if (id === void 0 || counts.get(id) !== 1) continue;
175
+ const matches = parsed.filter((p) => idOf(p) === id);
176
+ if (matches.length !== 1) continue;
177
+ const inner = diff(element, matches[0]);
178
+ if (inner) byId.set(id, inner);
179
+ }
180
+ return byId.size > 0 ? {
181
+ kind: "array",
182
+ byId
183
+ } : void 0;
184
+ }
185
+ }
186
+ /** The parts of `raw`, as it was read, that validation left out of `parsed`; undefined when nothing was dropped. */
187
+ function strippedOf(raw, parsed) {
188
+ return diff(raw, parsed);
189
+ }
190
+ function put(target, stripped) {
191
+ if (stripped.kind === "object" && isPlain(target)) for (const [key, inner] of stripped.fields) {
192
+ if (UNSAFE_KEYS.has(key)) {
193
+ if (inner.kind === "value" && !Object.hasOwn(target, key)) Object.defineProperty(target, key, {
194
+ value: structuredClone(inner.value),
195
+ enumerable: true,
196
+ writable: true,
197
+ configurable: true
198
+ });
199
+ continue;
200
+ }
201
+ if (inner.kind === "value") {
202
+ if (!Object.hasOwn(target, key)) target[key] = structuredClone(inner.value);
203
+ } else if (Object.hasOwn(target, key)) put(target[key], inner);
204
+ }
205
+ else if (stripped.kind === "array" && Array.isArray(target)) {
206
+ const counts = /* @__PURE__ */ new Map();
207
+ for (const element of target) {
208
+ const id = idOf(element);
209
+ if (id !== void 0) counts.set(id, (counts.get(id) ?? 0) + 1);
210
+ }
211
+ for (const element of target) {
212
+ const id = idOf(element);
213
+ const inner = id === void 0 || counts.get(id) !== 1 ? void 0 : stripped.byId.get(id);
214
+ if (inner) put(element, inner);
215
+ }
216
+ }
217
+ }
218
+ /** A copy of `next` with the stripped parts put back where the data has no value of its own. */
219
+ function graft(next, stripped) {
220
+ const copy = structuredClone(next);
221
+ if (stripped) put(copy, stripped);
222
+ return copy;
223
+ }
224
+ //#endregion
140
225
  //#region ../core/dist/ops/draft.js
141
226
  const normalize$1 = (path) => path.split("/").filter(Boolean).join("/");
142
227
  const parentOf = (path) => normalize$1(path).split("/").slice(0, -1).join("/");
@@ -263,7 +348,11 @@ function commit(storage, changes) {
263
348
  case "remove": storage.remove(change.path);
264
349
  }
265
350
  }
266
- /** Run an operation on the hub as it is under the lock and apply its changes before the lock is released. */
351
+ /**
352
+ * Run an operation on the hub as it is under the lock and apply its changes before the lock is released. It does not
353
+ * check the hub's data format: migrate must write a hub of another format, and the sync server's text patches
354
+ * keep every field. The entries for operations (`runOp`, `applyOperation`) check it before they call this.
355
+ */
267
356
  function applyOp(storage, run) {
268
357
  return storage.lock(() => {
269
358
  const result = run(storage);
@@ -288,14 +377,20 @@ function readYamlIn(hub, path, schema) {
288
377
  function readFrontMatterIn(hub, path, schema) {
289
378
  return validate(schema, splitFrontMatter(requireText(hub, path)).data, hub.where(path));
290
379
  }
380
+ /** `next` with the fields the YAML document at `path` has beyond its schema put back; `current` is that document as validated. */
381
+ function yamlKeeping(hub, path, current, next) {
382
+ return graft(next, strippedOf(parse$1(requireText(hub, path)), current));
383
+ }
291
384
  /** Mutate, validate and stage a YAML document; `expectedVersion` refuses the change when the file moved on. */
292
385
  function editYaml(draft, path, schema, mutate, expectedVersion) {
293
386
  const text = requireText(draft, path);
294
387
  checkVersion(draft, path, text, expectedVersion);
295
- const data = validate(schema, parse$1(text), draft.where(path));
388
+ const raw = parse$1(text);
389
+ const data = validate(schema, raw, draft.where(path));
390
+ const stripped = strippedOf(raw, data);
296
391
  mutate(data);
297
392
  const next = validate(schema, data, draft.where(path));
298
- draft.write(path, toYaml(next));
393
+ draft.write(path, toYaml(graft(next, stripped)));
299
394
  return next;
300
395
  }
301
396
  /** Mutate, validate and stage the front matter of a Markdown document; the body is kept unless `mutate` returns a new one. */
@@ -304,9 +399,10 @@ function editFrontMatter(draft, path, schema, mutate, expectedVersion) {
304
399
  checkVersion(draft, path, text, expectedVersion);
305
400
  const { data, body } = splitFrontMatter(text);
306
401
  const current = validate(schema, data, draft.where(path));
402
+ const stripped = strippedOf(data, current);
307
403
  const nextBody = mutate(current, body) ?? body;
308
404
  const next = validate(schema, current, draft.where(path));
309
- draft.write(path, frontMatterText(next, nextBody));
405
+ draft.write(path, frontMatterText(graft(next, stripped), nextBody));
310
406
  return next;
311
407
  }
312
408
  //#endregion
@@ -364,6 +460,40 @@ function decisionFileIn(hub, mainline, decision) {
364
460
  }
365
461
  const sessionFileIn = (id) => `${SESSIONS_DIR}/${id}.yaml`;
366
462
  //#endregion
463
+ //#region ../core/dist/ops/format.js
464
+ /** Why a hub whose data format is `found` cannot be written by this CLI; null when it can. */
465
+ function formatMismatch(found) {
466
+ if (found === 3) return null;
467
+ 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`;
468
+ }
469
+ /**
470
+ * The data format config.yaml declares; null when the hub has no config.yaml or it names no number, which loading
471
+ * the config reports. A config.yaml that is not YAML is a WriteError, so the sync server refuses the request
472
+ * instead of failing on it.
473
+ */
474
+ function hubFormatVersion(hub) {
475
+ const text = hub.read(CONFIG_FILE);
476
+ if (text === null) return null;
477
+ let data;
478
+ try {
479
+ data = parse$1(text);
480
+ } catch (error) {
481
+ throw new WriteError(`refusing to write: ${hub.where(CONFIG_FILE)} cannot be parsed as YAML: ${error.message}`);
482
+ }
483
+ const found = data?.formatVersion;
484
+ return typeof found === "number" ? found : null;
485
+ }
486
+ /**
487
+ * Refuse a write to a hub whose data format is not the one this CLI writes: rewriting a document would drop what
488
+ * another format keeps there. A hub without config.yaml is not checked, since `init` writes it.
489
+ */
490
+ function requireWritableFormat(hub) {
491
+ const found = hubFormatVersion(hub);
492
+ if (found === null) return;
493
+ const reason = formatMismatch(found);
494
+ if (reason) throw new WriteError(`refusing to write: ${reason}`);
495
+ }
496
+ //#endregion
367
497
  //#region ../core/dist/ops/protocol.js
368
498
  /** `now` of a request: a local minute and its offset from UTC, `Z` for UTC itself. */
369
499
  const REQUEST_TIME = new RegExp(`^(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2})(${OFFSET})$`);
@@ -584,7 +714,10 @@ const localContext = () => ({ now: nowMinute() });
584
714
  /** Run an operation on the hub at `root` under its write lock, apply its changes and return what it reports. */
585
715
  function runOp(root, run) {
586
716
  const ctx = localContext();
587
- return applyOp(nodeStorage(root), (hub) => run(hub, ctx)).value;
717
+ return applyOp(nodeStorage(root), (hub) => {
718
+ requireWritableFormat(hub);
719
+ return run(hub, ctx);
720
+ }).value;
588
721
  }
589
722
  /** Run an operation that must not take the lock, such as one creating the hub the lock would live in. */
590
723
  function runUnlocked(root, run) {
@@ -618,6 +751,7 @@ const mainlineDir = (root, id) => onDisk(root, mainlineDirIn(nodeStorage(root),
618
751
  const taskDir = (root, mainline, task) => onDisk(root, taskDirIn(nodeStorage(root), mainline, task));
619
752
  const mainlineFile = (root, id) => join(mainlineDir(root, id), MAINLINE_FILE);
620
753
  const taskFile = (root, mainline, task) => join(taskDir(root, mainline, task), TASK_FILE);
754
+ const readConfig = (root) => readConfigIn(nodeStorage(root));
621
755
  //#endregion
622
756
  //#region ../core/dist/refs.js
623
757
  const itemStatus = (item) => item.done ? "done" : item.dropped ? "dropped" : "open";
@@ -706,6 +840,64 @@ function linksInto(hub, hubName) {
706
840
  for (const g of hub.groups) add(g.id, g.links);
707
841
  return out;
708
842
  }
843
+ /**
844
+ * Where a member's `group` value points: `id` is the group's id, and `hub` is the other hub's name, or undefined
845
+ * for a group of the member's own hub (no prefix, or the member's own hub name as prefix).
846
+ */
847
+ function splitGroup(value, ownHub) {
848
+ const colon = value.indexOf(":");
849
+ if (colon < 0) return { id: value };
850
+ const hub = value.slice(0, colon);
851
+ const id = value.slice(colon + 1);
852
+ return hub === ownHub ? { id } : {
853
+ hub,
854
+ id
855
+ };
856
+ }
857
+ /** Whether a member's `group` value names the group `groupId` of its own hub `ownHub`: `w001` or `{ownHub}:w001`. */
858
+ function memberOfGroup(group, groupId, ownHub) {
859
+ if (group === void 0) return false;
860
+ const target = splitGroup(group, ownHub);
861
+ return target.hub === void 0 && target.id === groupId;
862
+ }
863
+ /**
864
+ * The mainlines and issues of the registered hubs whose `group` names one of `hubName`'s groups, by group id. Only
865
+ * the hubs' own documents are read, one level, so a hub that is not on this machine contributes nothing.
866
+ */
867
+ function remoteMembers(remotes, hubName) {
868
+ const out = /* @__PURE__ */ new Map();
869
+ const add = (group, member) => {
870
+ if (!group) return;
871
+ const target = splitGroup(group, member.hub);
872
+ if (target.hub !== hubName) return;
873
+ out.set(target.id, [...out.get(target.id) ?? [], member]);
874
+ };
875
+ for (const r of remotes) {
876
+ if (!r.hub) continue;
877
+ for (const m of r.hub.mainlines) add(m.group, {
878
+ ref: `${r.name}:${m.id}`,
879
+ hub: r.name,
880
+ kind: "mainline",
881
+ title: m.title,
882
+ status: m.status,
883
+ role: m.role ?? "impl",
884
+ archived: Boolean(m.archivedAt),
885
+ open: !CLOSED_STATUS.mainline.includes(m.status)
886
+ });
887
+ for (const i of r.hub.issues) add(i.group, {
888
+ ref: `${r.name}:${i.id}`,
889
+ hub: r.name,
890
+ kind: "issue",
891
+ title: i.title,
892
+ status: i.status,
893
+ archived: Boolean(i.archivedAt),
894
+ open: i.status !== "closed"
895
+ });
896
+ }
897
+ return out;
898
+ }
899
+ /** The registered hubs that could not be loaded, for a reader that must say its member list may be incomplete. */
900
+ const unavailableHubs = (remotes) => remotes.filter((r) => r.unavailable).map((r) => r.name);
709
901
  //#endregion
710
902
  //#region ../core/dist/deps.js
711
903
  const MET = /* @__PURE__ */ new Set([
@@ -972,7 +1164,8 @@ function loadConfig(root) {
972
1164
  message: issue.message
973
1165
  })));
974
1166
  const config = result.data;
975
- if (config.formatVersion !== 3) throw new HubError(config.formatVersion < 3 ? `data format version ${config.formatVersion} is older than this CLI's (3); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${config.formatVersion} is newer than this CLI supports (3); install the matching mainflow version in this hub`, []);
1167
+ const mismatch = formatMismatch(config.formatVersion);
1168
+ if (mismatch) throw new HubError(mismatch, []);
976
1169
  return config;
977
1170
  }
978
1171
  function loadHub(root, options = {}) {
@@ -1165,8 +1358,7 @@ function loadHub(root, options = {}) {
1165
1358
  }
1166
1359
  const findings = hubDocs(join(dataDir, "findings"), findingSchema);
1167
1360
  const groups = hubDocs(join(dataDir, "groups"), groupSchema);
1168
- problems.push(...groupProblems(groups, mainlines, hubIssues, unreadable));
1169
- warnings.push(...groupWarnings(groups, mainlines, hubIssues));
1361
+ problems.push(...groupProblems(config.hub, groups, mainlines, hubIssues, unreadable));
1170
1362
  const ops = [];
1171
1363
  const opsPath = join(dataDir, "ops");
1172
1364
  for (const name of existsSync(opsPath) ? readdirSync(opsPath).filter((n) => n.endsWith(".log")).sort() : []) readFileSync(join(opsPath, name), "utf8").split("\n").forEach((line, index) => {
@@ -1185,6 +1377,12 @@ function loadHub(root, options = {}) {
1185
1377
  });
1186
1378
  });
1187
1379
  const remotes = options.remotes === false ? [] : loadRemotes(root, config, problems, warnings);
1380
+ warnings.push(...groupWarnings(config.hub, groups, mainlines, hubIssues, remotes));
1381
+ for (const doc of [...mainlines, ...hubIssues]) if (doc.group && splitGroup(doc.group, config.hub).hub !== void 0) pending.push({
1382
+ ref: doc.group,
1383
+ file: doc.file,
1384
+ field: "group"
1385
+ });
1188
1386
  checkReferences({
1189
1387
  config,
1190
1388
  mainlines,
@@ -1238,20 +1436,32 @@ function loadHub(root, options = {}) {
1238
1436
  remotes
1239
1437
  };
1240
1438
  }
1241
- /** Members name their group; one that names a group this hub does not have is reported on the member. */
1242
- function groupProblems(groups, mainlines, issues, unreadable) {
1439
+ /** Members name their group; one that names a group this hub does not have is reported on the member. A group of another hub is checked with the other references. */
1440
+ function groupProblems(hubName, groups, mainlines, issues, unreadable) {
1243
1441
  const known = new Set(groups.map((g) => g.id));
1244
- return [...mainlines, ...issues].flatMap((doc) => doc.group && !known.has(doc.group) && !unreadable.has(doc.group) ? [{
1245
- file: doc.file,
1246
- field: "group",
1247
- message: `group ${doc.group} does not exist; create it with "mainflow new group <slug> --title <title>" or name an existing one`
1248
- }] : []);
1442
+ return [...mainlines, ...issues].flatMap((doc) => {
1443
+ if (!doc.group) return [];
1444
+ const { hub, id } = splitGroup(doc.group, hubName);
1445
+ return hub === void 0 && !known.has(id) && !unreadable.has(id) ? [{
1446
+ file: doc.file,
1447
+ field: "group",
1448
+ message: `group ${doc.group} does not exist; create it with "mainflow new group <slug> --title <title>" or name an existing one`
1449
+ }] : [];
1450
+ });
1249
1451
  }
1250
- /** A group marked done while some of its members are still open: a plan to settle, not a broken file. */
1251
- function groupWarnings(groups, mainlines, issues) {
1452
+ /**
1453
+ * A group marked done while some of its members are still open: a plan to settle, not a broken file. Members in
1454
+ * other hubs count too, when those hubs are on this machine.
1455
+ */
1456
+ function groupWarnings(hubName, groups, mainlines, issues, remotes) {
1457
+ const remote = remoteMembers(remotes, hubName);
1252
1458
  return groups.flatMap((g) => {
1253
1459
  if (g.status !== "done") return [];
1254
- const open = [...mainlines.filter((m) => m.group === g.id && isOpen$2("mainline", m.status)).map((m) => m.id), ...issues.filter((i) => i.group === g.id && i.status !== "closed").map((i) => i.id)];
1460
+ const open = [
1461
+ ...mainlines.filter((m) => memberOfGroup(m.group, g.id, hubName) && isOpen$2("mainline", m.status)).map((m) => m.id),
1462
+ ...issues.filter((i) => memberOfGroup(i.group, g.id, hubName) && i.status !== "closed").map((i) => i.id),
1463
+ ...(remote.get(g.id) ?? []).filter((m) => m.open).map((m) => m.ref)
1464
+ ];
1255
1465
  return open.length ? [{
1256
1466
  file: g.file,
1257
1467
  field: "status",
@@ -1648,12 +1858,18 @@ function requireExisting(hub, ref) {
1648
1858
  /** A priority as a command gives it, checked against P0 to P3. */
1649
1859
  const requirePriority = (value) => requireOneOf(value, PRIORITIES, "priority");
1650
1860
  /**
1651
- * The group a member names, which must exist in this hub. `none` clears the membership; `null` is how a patch says
1652
- * the same, so a command can pass the word and an interface the value.
1861
+ * The group a member names: one of this hub, which must exist, or one of another hub (`other-hub:w001`), which
1862
+ * only needs that hub registered here; the commands check the group itself when that hub is on this machine.
1863
+ * The hub's own name as prefix gives the group without it. `none` clears the membership; `null` is how a patch
1864
+ * says the same, so a command can pass the word and an interface the value.
1653
1865
  */
1654
1866
  function requireGroup(hub, value) {
1655
1867
  const ref = requireRef(value, "group");
1656
- if (ref.hub !== void 0) throw new WriteError(`a group is one of this hub's, like w001; got "${value}"`);
1868
+ const config = readConfigIn(hub);
1869
+ if (ref.hub !== void 0 && ref.hub !== config.hub) {
1870
+ if (!config.hubs?.[ref.hub]) throw new WriteError(`hub "${ref.hub}" is not registered; run "mainflow hub add ${ref.hub} <path>" first`);
1871
+ return `${ref.hub}:${ref.group}`;
1872
+ }
1657
1873
  entryFile(hub, GROUPS_DIR, ref.group, "group");
1658
1874
  return ref.group;
1659
1875
  }
@@ -2509,11 +2725,11 @@ function takeUnfinished(draft, ctx, from, to, existing, at) {
2509
2725
  doneAt: at
2510
2726
  };
2511
2727
  });
2512
- draft.write(previousFile, toYaml(validate(sessionSchema, {
2728
+ draft.write(previousFile, toYaml(yamlKeeping(draft, previousFile, previous, validate(sessionSchema, {
2513
2729
  ...previous,
2514
2730
  status: "handed-off",
2515
2731
  actions: left
2516
- }, draft.where(previousFile))));
2732
+ }, draft.where(previousFile)))));
2517
2733
  return carried;
2518
2734
  }
2519
2735
  /**
@@ -2596,7 +2812,7 @@ function handoffSession(hub, input, ctx) {
2596
2812
  handoffFrom: from,
2597
2813
  actions: [...session.actions, ...carried]
2598
2814
  }, draft.where(file));
2599
- draft.write(file, toYaml(next));
2815
+ draft.write(file, toYaml(yamlKeeping(draft, file, session, next)));
2600
2816
  return draft.result(next);
2601
2817
  }
2602
2818
  function setSessionInfo(hub, input) {
@@ -2639,10 +2855,10 @@ function settleSession(hub, input) {
2639
2855
  if (s.status !== "active" || s.delegatedFrom || removed && s.id === id) continue;
2640
2856
  if (!s.actions.every((a) => a.status === "done" || a.status === "dropped") || subagents(s.id).some((x) => x.status === "active")) continue;
2641
2857
  if (s.id === id && s.actions.length === 0) continue;
2642
- draft.write(sessionFileIn(s.id), toYaml(validate(sessionSchema, {
2858
+ draft.write(sessionFileIn(s.id), toYaml(yamlKeeping(draft, sessionFileIn(s.id), s, validate(sessionSchema, {
2643
2859
  ...s,
2644
2860
  status: "ended"
2645
- }, draft.where(sessionFileIn(s.id)))));
2861
+ }, draft.where(sessionFileIn(s.id))))));
2646
2862
  if (s.id === id) result = "ended";
2647
2863
  }
2648
2864
  return draft.result(result);
@@ -2692,6 +2908,7 @@ function addAction(hub, input, ctx) {
2692
2908
  * to work that has since been handed off to this very action would otherwise wait forever.
2693
2909
  */
2694
2910
  function requireBlock(hub, ref, block) {
2911
+ if (block.until !== void 0) requireMainlineTarget(hub, block.until);
2695
2912
  const targets = [waitingForAction(block.waitingFor), block.until === void 0 ? void 0 : untilTarget(block.until)];
2696
2913
  for (const target of targets) {
2697
2914
  if (!target) continue;
@@ -2700,10 +2917,23 @@ function requireBlock(hub, ref, block) {
2700
2917
  if (found.ref === ref) throw new WriteError(`action ${ref} cannot wait for itself${target === ref ? "" : ` (${target} was handed off to it)`}`);
2701
2918
  }
2702
2919
  }
2920
+ /** A `mainline-done` condition has to name a mainline in this hub, or a hub registered here. */
2921
+ function requireMainlineTarget(hub, until) {
2922
+ const parsed = parseUntil(until);
2923
+ if (parsed?.kind !== "mainline-done") return;
2924
+ const ref = parseRef(parsed.mainline);
2925
+ if (ref?.kind !== "mainline") return;
2926
+ const config = readConfigIn(hub);
2927
+ if (ref.hub !== void 0 && ref.hub !== config.hub) {
2928
+ if (!config.hubs?.[ref.hub]) throw new WriteError(`hub "${ref.hub}" is not registered; run "mainflow hub add ${ref.hub} <path>" first`);
2929
+ return;
2930
+ }
2931
+ mainlineDirIn(hub, ref.mainline);
2932
+ }
2703
2933
  /** The action an `until` waits for; throws when the condition matches none of the forms. */
2704
2934
  function untilTarget(until) {
2705
2935
  const parsed = parseUntil(until);
2706
- if (!parsed) throw new WriteError(`--until takes a condition like commit:{branch}:{text}, action-done:{sessionId}#a{NNN} or file:{relative path}, got "${until}"`);
2936
+ if (!parsed) throw new WriteError(`--until takes a condition like ${UNTIL_FORMS.map((f) => f.format).join(", ")}, got "${until}"`);
2707
2937
  return parsed.kind === "action-done" ? `${parsed.session}#${parsed.action}` : void 0;
2708
2938
  }
2709
2939
  function updateAction(hub, input, ctx) {
@@ -2980,7 +3210,10 @@ const OPERATIONS = {
2980
3210
  function applyOperation(storage, name, input, ctx) {
2981
3211
  const operation = OPERATIONS[name];
2982
3212
  if (!operation) throw new Error(`unknown operation "${String(name)}"`);
2983
- return applyOp(storage, (hub) => operation(hub, input, ctx));
3213
+ return applyOp(storage, (hub) => {
3214
+ requireWritableFormat(hub);
3215
+ return operation(hub, input, ctx);
3216
+ });
2984
3217
  }
2985
3218
  //#endregion
2986
3219
  //#region ../core/dist/ids/renumber.js
@@ -3917,11 +4150,78 @@ function actionState(root, ref) {
3917
4150
  detail: `${ref}${via} is ${found.action.status}`
3918
4151
  };
3919
4152
  }
4153
+ /**
4154
+ * Whether a mainline is done. It can no longer come true when the mainline was dropped, does not exist, or its
4155
+ * hub is not registered here. A registered hub that is missing on this machine or fails to load may return, so
4156
+ * that case keeps waiting. A prefix equal to this hub's own name reads the local hub.
4157
+ */
4158
+ function mainlineState(root, text) {
4159
+ const ref = parseRef(text);
4160
+ if (ref?.kind !== "mainline") return {
4161
+ met: false,
4162
+ unreachable: true,
4163
+ detail: `"${text}" is not a mainline reference like m001 or other-hub:m001`
4164
+ };
4165
+ let target = root;
4166
+ try {
4167
+ const config = readConfig(root);
4168
+ if (ref.hub !== void 0 && ref.hub !== config.hub) {
4169
+ const path = config.hubs?.[ref.hub];
4170
+ if (path === void 0) return {
4171
+ met: false,
4172
+ unreachable: true,
4173
+ detail: `hub "${ref.hub}" is not registered, so ${text} can never be read; run "mainflow hub add ${ref.hub} <path>"`
4174
+ };
4175
+ target = remoteRoot(root, path);
4176
+ if (!isHubRoot(target)) return {
4177
+ met: false,
4178
+ detail: `hub "${ref.hub}" is not at ${path} on this machine, so ${text} cannot be read yet`
4179
+ };
4180
+ }
4181
+ } catch (error) {
4182
+ return {
4183
+ met: false,
4184
+ detail: `the hub config cannot be read, so ${text} cannot be checked: ${error.message}`
4185
+ };
4186
+ }
4187
+ try {
4188
+ mainlineDir(target, ref.mainline);
4189
+ } catch {
4190
+ return {
4191
+ met: false,
4192
+ unreachable: true,
4193
+ detail: `mainline ${text} does not exist, so it will never be done`
4194
+ };
4195
+ }
4196
+ let status;
4197
+ try {
4198
+ status = readFrontMatter(mainlineFile(target, ref.mainline), mainlineSchema).data.status;
4199
+ } catch (error) {
4200
+ return {
4201
+ met: false,
4202
+ detail: `mainline ${text} cannot be read yet: ${error.message}`
4203
+ };
4204
+ }
4205
+ if (status === "done") return {
4206
+ met: true,
4207
+ detail: `mainline ${text} is done`
4208
+ };
4209
+ if (status === "dropped") return {
4210
+ met: false,
4211
+ unreachable: true,
4212
+ detail: `mainline ${text} was dropped, so it will never be done`
4213
+ };
4214
+ return {
4215
+ met: false,
4216
+ detail: `mainline ${text} is ${status}`
4217
+ };
4218
+ }
3920
4219
  /** Check a wait condition once. */
3921
4220
  function untilState(root, until) {
3922
4221
  switch (until.kind) {
3923
4222
  case "commit": return commitState(root, until.branch, until.text);
3924
4223
  case "action-done": return actionState(root, `${until.session}#${until.action}`);
4224
+ case "mainline-done": return mainlineState(root, until.mainline);
3925
4225
  case "file": return existsSync(resolve(root, until.path)) ? {
3926
4226
  met: true,
3927
4227
  detail: `${until.path} exists`
@@ -7939,4 +8239,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
7939
8239
  return true;
7940
8240
  }
7941
8241
  //#endregion
7942
- export { ACCESS_HEADERS as $, mayDecorate as A, applyOperation as B, readSession as C, decorateReply as D, matchGrants as E, renumberRequest as F, sectionText as G, HubError as H, renumberFiles as I, linksInto as J, dependencyGraph as K, renumberPath as L, traceTranscript as M, transcriptTitle as N, endsWithMarker as O, applyOffline as P, nodeStorage as Q, renumberText as R, loadSessions as S, grantScope as T, loadConfig as U, checkHubName as V, loadHub as W, nowMinute as X, refIndex as Y, readYaml as Z, initHub as _, DATA_DIR as _t, openInApp as a, comparePaths as at, sessionBriefing as b, splitFrontMatter as bt, siteBlock as c, requestTime as ct, retroReport as d, TASK_FILE as dt, BLOB_HASH as et, retroReportFromReceiver as f, requireSlug as ft, setArchivedMany as g, ConflictError as gt, ARCHIVE_KINDS as h, worktreesOf as ht, hostName as i, SOCKET_MAX_AGE_CLOSE as it, attributeTrace as j, headerLine as k, syncHost as l, textBytes as lt, RETRY_RULES as m, findHubRoot as mt, DEFAULT_UI_PORT as n, MAX_FILE_BYTES as nt, removeDanglingLinks as o, exceedsFileLimit as ot, receiverFromEnv as p, currentBranch as pt, mainlineEdges as q, caddySites as r, ROUTES as rt, removeHostLink as s, readsAsText as st, APP_BUNDLE_ID as t, MAX_BLOB_BYTES as tt, migrateHub as u, MAINLINE_FILE as ut, untilState as v, WriteError as vt, transcriptPath as w, turnReminder as x, duplicateClaimLines as y, contentHash as yt, scopeOfPath as z };
8242
+ export { nowMinute as $, mayDecorate as A, applyOperation as B, readSession as C, WriteError as Ct, decorateReply as D, matchGrants as E, renumberRequest as F, sectionText as G, HubError as H, renumberFiles as I, linksInto as J, dependencyGraph as K, renumberPath as L, traceTranscript as M, transcriptTitle as N, endsWithMarker as O, applyOffline as P, unavailableHubs as Q, renumberText as R, loadSessions as S, DATA_DIR as St, grantScope as T, splitFrontMatter as Tt, loadConfig as U, checkHubName as V, loadHub as W, refIndex as X, memberOfGroup as Y, remoteMembers as Z, initHub as _, requireSlug as _t, openInApp as a, MAX_FILE_BYTES as at, sessionBriefing as b, worktreesOf as bt, siteBlock as c, comparePaths as ct, retroReport as d, requestTime as dt, readYaml as et, retroReportFromReceiver as f, textBytes as ft, setArchivedMany as g, TASK_FILE as gt, ARCHIVE_KINDS as h, MAINLINE_FILE as ht, hostName as i, MAX_BLOB_BYTES as it, attributeTrace as j, headerLine as k, syncHost as l, exceedsFileLimit as lt, RETRY_RULES as m, hubFormatVersion as mt, DEFAULT_UI_PORT as n, ACCESS_HEADERS as nt, removeDanglingLinks as o, ROUTES as ot, receiverFromEnv as p, formatMismatch as pt, mainlineEdges as q, caddySites as r, BLOB_HASH as rt, removeHostLink as s, SOCKET_MAX_AGE_CLOSE as st, APP_BUNDLE_ID as t, nodeStorage as tt, migrateHub as u, readsAsText as ut, untilState as v, currentBranch as vt, transcriptPath as w, contentHash as wt, turnReminder as x, ConflictError as xt, duplicateClaimLines as y, findHubRoot as yt, scopeOfPath as z };
package/dist/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as openInApp, c as siteBlock, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, o as removeDanglingLinks, r as caddySites, s as removeHostLink, t as APP_BUNDLE_ID } from "./host-bnHYIwuY.js";
1
+ import { a as openInApp, c as siteBlock, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, o as removeDanglingLinks, r as caddySites, s as removeHostLink, t as APP_BUNDLE_ID } from "./host-lY8ytTbw.js";
2
2
  export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeDanglingLinks, removeHostLink, siteBlock, syncHost };
@@ -250,7 +250,8 @@ type Task = z.infer<typeof taskSchema>;
250
250
  /**
251
251
  * A condition a program can check, written `{kind}:{argument}`. `commit` holds once a commit message on the
252
252
  * branch contains the text, `action-done` once the action is done, `file` once the path exists under the hub
253
- * root. Git branch names cannot contain a colon, so the text after the branch may.
253
+ * root, `mainline-done` once the mainline (`m{NNN}` or `{hub}:m{NNN}`) is done. Git branch names cannot contain a
254
+ * colon, so the text after the branch may.
254
255
  */
255
256
  type Until = {
256
257
  kind: 'commit';
@@ -263,6 +264,9 @@ type Until = {
263
264
  } | {
264
265
  kind: 'file';
265
266
  path: string;
267
+ } | {
268
+ kind: 'mainline-done';
269
+ mainline: string;
266
270
  };
267
271
  /**
268
272
  * Why a blocked action cannot move, what it waits for, and the condition a program checks to end the wait.
@@ -1,4 +1,4 @@
1
- import { _t as DATA_DIR, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink, vt as WriteError } from "./host-bnHYIwuY.js";
1
+ import { Ct as WriteError, St as DATA_DIR, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink } from "./host-lY8ytTbw.js";
2
2
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
@@ -276,7 +276,7 @@ async function launchUi(root, hub, configured, options) {
276
276
  }
277
277
  let server;
278
278
  if (!reuse) {
279
- const { serveUi } = await import("./ui-C4h4iLDu.js");
279
+ const { serveUi } = await import("./ui-AhBsqtZe.js");
280
280
  server = await serveUi(root, choice.port);
281
281
  writeUiRecord(root, {
282
282
  port: choice.port,
package/dist/launch.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as networkProbe, c as realRoot, d as withUiLock, i as launchUi, l as startUiInBackground, n as choosePort, o as portCandidates, r as exitCodeFor, s as readUiRecord, t as PORT_SPAN, u as watchHubGone } from "./launch-D80CYPcm.js";
1
+ import { a as networkProbe, c as realRoot, d as withUiLock, i as launchUi, l as startUiInBackground, n as choosePort, o as portCandidates, r as exitCodeFor, s as readUiRecord, t as PORT_SPAN, u as watchHubGone } from "./launch-3HLHjQF0.js";
2
2
  export { PORT_SPAN, choosePort, exitCodeFor, launchUi, networkProbe, portCandidates, readUiRecord, realRoot, startUiInBackground, watchHubGone, withUiLock };
@@ -1,4 +1,4 @@
1
- import { $ as ACCESS_HEADERS, B as applyOperation, F as renumberRequest, I as renumberFiles, L as renumberPath, P as applyOffline, Q as nodeStorage, R as renumberText, X as nowMinute, _t as DATA_DIR, at as comparePaths, bt as splitFrontMatter, ct as requestTime, et as BLOB_HASH, gt as ConflictError, it as SOCKET_MAX_AGE_CLOSE, lt as textBytes, nt as MAX_FILE_BYTES, ot as exceedsFileLimit, rt as ROUTES, st as readsAsText, tt as MAX_BLOB_BYTES, vt as WriteError, yt as contentHash, z as scopeOfPath } from "./host-bnHYIwuY.js";
1
+ import { $ as nowMinute, B as applyOperation, Ct as WriteError, F as renumberRequest, I as renumberFiles, L as renumberPath, P as applyOffline, R as renumberText, St as DATA_DIR, Tt as splitFrontMatter, at as MAX_FILE_BYTES, ct as comparePaths, dt as requestTime, ft as textBytes, it as MAX_BLOB_BYTES, lt as exceedsFileLimit, nt as ACCESS_HEADERS, ot as ROUTES, rt as BLOB_HASH, st as SOCKET_MAX_AGE_CLOSE, tt as nodeStorage, ut as readsAsText, wt as contentHash, xt as ConflictError, z as scopeOfPath } from "./host-lY8ytTbw.js";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, createReadStream, createWriteStream, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -1,8 +1,8 @@
1
- import { F as configSchema, f as GROUP_SECTIONS } from "./dist-BdvWGTsb.js";
2
- import { C as readSession, G as sectionText, H as HubError, J as linksInto, K as dependencyGraph, M as traceTranscript, N as transcriptTitle, S as loadSessions, U as loadConfig, W as loadHub, Y as refIndex, Z as readYaml, _t as DATA_DIR, a as openInApp, g as setArchivedMany, gt as ConflictError, h as ARCHIVE_KINDS, j as attributeTrace, q as mainlineEdges, vt as WriteError, w as transcriptPath } from "./host-bnHYIwuY.js";
1
+ import { F as configSchema, f as GROUP_SECTIONS } from "./dist-ud09dzZo.js";
2
+ import { C as readSession, Ct as WriteError, G as sectionText, H as HubError, J as linksInto, K as dependencyGraph, M as traceTranscript, N as transcriptTitle, S as loadSessions, St as DATA_DIR, U as loadConfig, W as loadHub, X as refIndex, Z as remoteMembers, a as openInApp, et as readYaml, g as setArchivedMany, h as ARCHIVE_KINDS, j as attributeTrace, q as mainlineEdges, w as transcriptPath, xt as ConflictError } from "./host-lY8ytTbw.js";
3
3
  import { t as listProgress } from "./progress-1BfPg2zN.js";
4
- import { A as isSynced, _ as readDaemon, a as remoteMode, b as readRemote, h as callDaemon, j as syncPaths, k as SYNC_DIR, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as watchTree } from "./operation-DVcJgKM9.js";
5
- import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-D80CYPcm.js";
4
+ import { A as isSynced, _ as readDaemon, a as remoteMode, b as readRemote, h as callDaemon, j as syncPaths, k as SYNC_DIR, m as DaemonUnavailable, n as hubWriter, r as writtenLines, y as watchTree } from "./operation-BZ7CHB6J.js";
5
+ import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-3HLHjQF0.js";
6
6
  import { createRequire } from "node:module";
7
7
  import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
8
8
  import { basename, dirname, extname, join, normalize, relative, resolve, sep } from "node:path";
@@ -23,7 +23,7 @@ const WRITE_HEADER = "x-mainflow-write";
23
23
  /** Every reference with a hub prefix in the documents of `hub`: links, origins and replaced items. */
24
24
  function prefixedRefs(hub) {
25
25
  const refs = [];
26
- const doc = (d) => refs.push(d.origin, ...(d.links ?? []).map((l) => l.to));
26
+ const doc = (d) => refs.push(d.origin, d.group, ...(d.links ?? []).map((l) => l.to));
27
27
  for (const m of hub.mainlines) {
28
28
  doc(m);
29
29
  refs.push(...m.criteria.map((c) => c.supersededBy));
@@ -46,7 +46,13 @@ function relationsView(hub) {
46
46
  const targets = {};
47
47
  const graph = dependencyGraph(hub);
48
48
  const depRefs = graph.edges.flatMap((e) => [e.from, e.to]);
49
- for (const ref of /* @__PURE__ */ new Set([...prefixedRefs(hub), ...depRefs.filter((r) => r.includes(":"))])) {
49
+ const groupMembers = remoteMembers(hub.remotes, hub.config.hub);
50
+ const memberRefs = [...groupMembers.values()].flatMap((list) => list.map((m) => m.ref));
51
+ for (const ref of /* @__PURE__ */ new Set([
52
+ ...prefixedRefs(hub),
53
+ ...depRefs.filter((r) => r.includes(":")),
54
+ ...memberRefs
55
+ ])) {
50
56
  const colon = ref.indexOf(":");
51
57
  const name = ref.slice(0, colon);
52
58
  const found = name === hub.config.hub ? own.get(ref.slice(colon + 1)) : indexes.get(name)?.get(ref.slice(colon + 1));
@@ -77,6 +83,7 @@ function relationsView(hub) {
77
83
  targets,
78
84
  backlinks,
79
85
  deps: graph.edges,
86
+ groupMembers: Object.fromEntries(groupMembers),
80
87
  mainlineDeps: mainlineEdges(graph),
81
88
  cycles: graph.cycles
82
89
  };