mainflow 0.34.0 → 0.35.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 describeRequest, A as WORK_STATUS, B as parseRef, C as SESSION_STATUS, D as TASK_REQUIRED_SECTIONS, E as SURVEY_SECTION, F as issueSchema, G as taskSchema, H as refExample, I as mainlineSchema, J as OFFSET, K as waitingForAction, L as opsEntrySchema, M as decisionSchema, N as findingSchema, O as TASK_SECTIONS, P as formatOpsEntry, R as parseActionRef, S as SESSION_ID, T as SURVEY_LINES, U as refMainline, V as parseUntil, W as sessionSchema, Y as stampMs, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, et as grantCovers, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as configSchema, l as FINDING_STATUS, n as CLOSED_STATUS, nt as grantRepo, q as waitingForRef, r as DECIDERS, rt as parseGrantTarget, s as DEFAULT_CATEGORIES, tt as grantForm, w as SLUG, x as SERVES_REF, y as REF_EXAMPLES, z as parseOpsLine } from "./dist-IdbnCDqM.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, 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";
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";
@@ -10,7 +10,7 @@ import { performance } from "node:perf_hooks";
10
10
  import { gunzipSync, inflateRawSync } from "node:zlib";
11
11
  //#region ../core/dist/ops/document.js
12
12
  const DATA_DIR = ".mainflow";
13
- const ENTRY_DIR = /^([mtdif]\d{3})-(.+)$/;
13
+ const ENTRY_DIR = /^([mtdifw]\d{3})-(.+)$/;
14
14
  var WriteError = class extends Error {};
15
15
  /** The file changed after the caller read it; the caller must reload and retry. */
16
16
  var ConflictError = class extends WriteError {};
@@ -315,6 +315,7 @@ const MAINLINES_DIR = "mainlines";
315
315
  const CONFIG_FILE = "config.yaml";
316
316
  const ISSUES_DIR = "issues";
317
317
  const FINDINGS_DIR = "findings";
318
+ const GROUPS_DIR = "groups";
318
319
  const SESSIONS_DIR = "local/sessions";
319
320
  /** The document inside each mainline and task directory: front matter for attributes, body for prose. */
320
321
  const MAINLINE_FILE = "mainline.md";
@@ -667,6 +668,11 @@ function refIndex(hub) {
667
668
  title: f.title,
668
669
  status: f.status
669
670
  });
671
+ for (const g of hub.groups ?? []) index.set(g.id, {
672
+ kind: "group",
673
+ title: g.title,
674
+ status: g.status
675
+ });
670
676
  return index;
671
677
  }
672
678
  const isHubRoot = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
@@ -697,6 +703,7 @@ function linksInto(hub, hubName) {
697
703
  }
698
704
  for (const i of hub.issues) add(i.id, i.links);
699
705
  for (const f of hub.findings) add(f.id, f.links);
706
+ for (const g of hub.groups) add(g.id, g.links);
700
707
  return out;
701
708
  }
702
709
  //#endregion
@@ -742,7 +749,11 @@ function dependencyGraph(hub) {
742
749
  }));
743
750
  }
744
751
  }
745
- for (const d of [...hub.issues, ...hub.findings]) links(d.id, d.file, d.links);
752
+ for (const d of [
753
+ ...hub.issues,
754
+ ...hub.findings,
755
+ ...hub.groups ?? []
756
+ ]) links(d.id, d.file, d.links);
746
757
  for (const remote of hub.remotes ?? []) {
747
758
  if (!remote.hub) continue;
748
759
  for (const { to, link } of linksInto(remote.hub, hub.config.hub)) if (link.rel === "depends-on") edge(`${remote.name}:${link.from}`, to, "link", link.note ? { note: link.note } : {});
@@ -774,7 +785,7 @@ function findCycles(edges) {
774
785
  for (const node of [...next.keys()].sort()) if (!state.has(node)) visit(node);
775
786
  return [...cycles.values()];
776
787
  }
777
- /** The mainline a reference sits in, keeping another hub's prefix; undefined for issues and findings. */
788
+ /** The mainline a reference sits in, keeping another hub's prefix; undefined for issues, findings and groups. */
778
789
  function mainlineOf(ref) {
779
790
  const parsed = parseRef(ref);
780
791
  const m = parsed ? refMainline(parsed) : void 0;
@@ -885,6 +896,7 @@ const TASK_BODY = body(TASK_SECTIONS);
885
896
  const DECISION_BODY = body(DECISION_SECTIONS);
886
897
  const ISSUE_BODY = body(ISSUE_SECTIONS);
887
898
  const FINDING_BODY = body(FINDING_SECTIONS);
899
+ const GROUP_BODY = body(GROUP_SECTIONS);
888
900
  /** The text under `## {heading}` up to the next H2, trimmed; null when the heading is missing. */
889
901
  function sectionText(markdown, heading) {
890
902
  const lines = markdown.split("\n");
@@ -936,6 +948,33 @@ function withSection(markdown, heading, text) {
936
948
  }
937
949
  //#endregion
938
950
  //#region ../core/dist/load.js
951
+ /**
952
+ * Read config.yaml and check its format version; throws a HubError naming what is wrong when the hub cannot be
953
+ * used at all. The interface answers 503 for it before loading anything else (docs/ui.md "壞檔與讀取失敗").
954
+ */
955
+ function loadConfig(root) {
956
+ const file = join(root, DATA_DIR, "config.yaml");
957
+ const where = relative(root, file);
958
+ let data;
959
+ try {
960
+ data = parse$1(readText(file) ?? "");
961
+ } catch (error) {
962
+ throw new HubError("invalid config.yaml", [{
963
+ file: where,
964
+ field: "",
965
+ message: `cannot parse YAML: ${error.message}`
966
+ }]);
967
+ }
968
+ const result = configSchema.safeParse(data);
969
+ if (!result.success) throw new HubError("invalid config.yaml", result.error.issues.map((issue) => ({
970
+ file: where,
971
+ field: issue.path.join("."),
972
+ message: issue.message
973
+ })));
974
+ 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`, []);
976
+ return config;
977
+ }
939
978
  function loadHub(root, options = {}) {
940
979
  const problems = [];
941
980
  const warnings = [];
@@ -975,9 +1014,7 @@ function loadHub(root, options = {}) {
975
1014
  message: `directory name must be "${id}-{slug}" in kebab-case`
976
1015
  });
977
1016
  };
978
- const config = read(join(dataDir, "config.yaml"), configSchema);
979
- if (!config) throw new HubError("invalid config.yaml", problems);
980
- 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`, problems);
1017
+ const config = loadConfig(root);
981
1018
  problems.push(...repoPathProblems(root, config));
982
1019
  const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
983
1020
  /** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
@@ -1127,6 +1164,9 @@ function loadHub(root, options = {}) {
1127
1164
  });
1128
1165
  }
1129
1166
  const findings = hubDocs(join(dataDir, "findings"), findingSchema);
1167
+ const groups = hubDocs(join(dataDir, "groups"), groupSchema);
1168
+ problems.push(...groupProblems(groups, mainlines, hubIssues, unreadable));
1169
+ warnings.push(...groupWarnings(groups, mainlines, hubIssues));
1130
1170
  const ops = [];
1131
1171
  const opsPath = join(dataDir, "ops");
1132
1172
  for (const name of existsSync(opsPath) ? readdirSync(opsPath).filter((n) => n.endsWith(".log")).sort() : []) readFileSync(join(opsPath, name), "utf8").split("\n").forEach((line, index) => {
@@ -1150,6 +1190,7 @@ function loadHub(root, options = {}) {
1150
1190
  mainlines,
1151
1191
  issues: hubIssues,
1152
1192
  findings,
1193
+ groups,
1153
1194
  remotes,
1154
1195
  pending,
1155
1196
  unreadable,
@@ -1160,6 +1201,7 @@ function loadHub(root, options = {}) {
1160
1201
  mainlines,
1161
1202
  issues: hubIssues,
1162
1203
  findings,
1204
+ groups,
1163
1205
  remotes
1164
1206
  });
1165
1207
  for (const cycle of deps.cycles) {
@@ -1189,12 +1231,34 @@ function loadHub(root, options = {}) {
1189
1231
  mainlines,
1190
1232
  issues: hubIssues,
1191
1233
  findings,
1234
+ groups,
1192
1235
  ops,
1193
1236
  problems,
1194
1237
  warnings,
1195
1238
  remotes
1196
1239
  };
1197
1240
  }
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) {
1243
+ 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
+ }] : []);
1249
+ }
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) {
1252
+ return groups.flatMap((g) => {
1253
+ 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)];
1255
+ return open.length ? [{
1256
+ file: g.file,
1257
+ field: "status",
1258
+ message: `group ${g.id} is done but ${open.join(", ")} ${open.length === 1 ? "is" : "are"} still open; settle ${open.length === 1 ? "it" : "them"} or reopen the group`
1259
+ }] : [];
1260
+ });
1261
+ }
1198
1262
  /** Load every hub registered in config.yaml, read-only and without its own remotes. */
1199
1263
  function loadRemotes(root, config, problems, warnings) {
1200
1264
  const file = `${DATA_DIR}/config.yaml`;
@@ -1263,7 +1327,7 @@ function loadRemotes(root, config, problems, warnings) {
1263
1327
  * Links are for relations across mainlines; inside one mainline, parent, dependsOn and affects say it.
1264
1328
  */
1265
1329
  function checkReferences(input) {
1266
- const { config, mainlines, issues, findings, remotes, pending, unreadable, problems } = input;
1330
+ const { config, mainlines, issues, findings, groups, remotes, pending, unreadable, problems } = input;
1267
1331
  /** A target inside a document that failed to load: its schema problem is already reported. */
1268
1332
  const inUnreadable = (target) => [
1269
1333
  target,
@@ -1273,7 +1337,8 @@ function checkReferences(input) {
1273
1337
  const local = refIndex({
1274
1338
  mainlines,
1275
1339
  issues,
1276
- findings
1340
+ findings,
1341
+ groups
1277
1342
  });
1278
1343
  const remoteIndex = new Map(remotes.map((r) => [r.name, r.hub ? refIndex(r.hub) : null]));
1279
1344
  /** The document's own mainline, when it sits in one; links may not point back into it. */
@@ -1326,6 +1391,7 @@ function checkReferences(input) {
1326
1391
  }
1327
1392
  for (const i of issues) add(i.file, i, i.id);
1328
1393
  for (const f of findings) add(f.file, f, f.id);
1394
+ for (const g of groups) add(g.file, g, g.id);
1329
1395
  for (const p of pending) {
1330
1396
  const parsed = parseRef(p.ref);
1331
1397
  if (!parsed) continue;
@@ -1545,6 +1611,249 @@ function addHub(hub, input) {
1545
1611
  return draft.result(input.name);
1546
1612
  }
1547
1613
  //#endregion
1614
+ //#region ../core/dist/ops/hub-items.js
1615
+ function entryFile(hub, dir, id, what) {
1616
+ const file = entryFileIn(hub, dir, id);
1617
+ if (!file) throw new WriteError(`${what} ${id} not found`);
1618
+ return file;
1619
+ }
1620
+ /**
1621
+ * Any reference the schema accepts must also point at something that exists. A reference into another
1622
+ * hub only needs that hub registered here; check verifies the target when that hub is on this machine.
1623
+ */
1624
+ function requireExisting(hub, ref) {
1625
+ const parsed = parseRef(ref);
1626
+ if (!parsed) throw new WriteError(`"${ref}" is not a reference like ${REF_EXAMPLES}`);
1627
+ const config = readConfigIn(hub);
1628
+ if (parsed.hub !== void 0 && parsed.hub !== config.hub) {
1629
+ if (!config.hubs?.[parsed.hub]) throw new WriteError(`hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>" first`);
1630
+ return;
1631
+ }
1632
+ switch (parsed.kind) {
1633
+ case "issue":
1634
+ entryFile(hub, ISSUES_DIR, parsed.issue, "issue");
1635
+ return;
1636
+ case "finding":
1637
+ entryFile(hub, FINDINGS_DIR, parsed.finding, "finding");
1638
+ return;
1639
+ case "group":
1640
+ entryFile(hub, GROUPS_DIR, parsed.group, "group");
1641
+ return;
1642
+ default:
1643
+ mainlineDirIn(hub, parsed.mainline);
1644
+ if ("task" in parsed) taskDirIn(hub, parsed.mainline, parsed.task);
1645
+ if (parsed.kind === "decision") decisionFileIn(hub, parsed.mainline, parsed.decision);
1646
+ }
1647
+ }
1648
+ /** A priority as a command gives it, checked against P0 to P3. */
1649
+ const requirePriority = (value) => requireOneOf(value, PRIORITIES, "priority");
1650
+ /**
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.
1653
+ */
1654
+ function requireGroup(hub, value) {
1655
+ 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}"`);
1657
+ entryFile(hub, GROUPS_DIR, ref.group, "group");
1658
+ return ref.group;
1659
+ }
1660
+ /** Whether a patch clears the group: `none` from a command, or null. */
1661
+ const clearsGroup = (value) => value === null || value === "none";
1662
+ function createIssue(hub, input, ctx) {
1663
+ requireSlug(input.slug);
1664
+ if (input.origin) requireExisting(hub, input.origin);
1665
+ const group = input.group ? requireGroup(hub, input.group) : void 0;
1666
+ const priority = input.priority ? requirePriority(input.priority) : void 0;
1667
+ const { day } = stamps(ctx);
1668
+ const draft = new Draft(hub);
1669
+ const id = draft.number(ctx, {
1670
+ prefix: "i",
1671
+ taken: idsIn(draft, ISSUES_DIR),
1672
+ ref: (id) => id
1673
+ });
1674
+ const issue = validate(issueSchema, {
1675
+ id,
1676
+ title: input.title,
1677
+ status: "open",
1678
+ ...priority ? { priority } : {},
1679
+ created: day,
1680
+ ...input.origin ? { origin: input.origin } : {},
1681
+ ...input.labels?.length ? { labels: input.labels } : {},
1682
+ ...group ? { group } : {}
1683
+ }, "issue");
1684
+ const file = `${ISSUES_DIR}/${id}-${input.slug}.md`;
1685
+ draft.write(file, frontMatterText(issue, ISSUE_BODY));
1686
+ return draft.result({
1687
+ id,
1688
+ file
1689
+ });
1690
+ }
1691
+ function setIssue(hub, input) {
1692
+ const { patch } = input;
1693
+ if (patch.group === void 0 && patch.priority === void 0) throw new WriteError(`${input.id} needs --group or --priority, e.g. --group w001`);
1694
+ const draft = new Draft(hub);
1695
+ const group = patch.group === void 0 || clearsGroup(patch.group) ? void 0 : requireGroup(draft, patch.group);
1696
+ const priority = patch.priority === void 0 ? void 0 : requirePriority(patch.priority);
1697
+ return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1698
+ if (group) i.group = group;
1699
+ else if (clearsGroup(patch.group)) delete i.group;
1700
+ if (priority) i.priority = priority;
1701
+ }, input.expectedVersion));
1702
+ }
1703
+ /** Schedule an issue into the task that will carry it out. */
1704
+ function planIssue(hub, input) {
1705
+ const ref = requireRef(input.task, "task");
1706
+ const draft = new Draft(hub);
1707
+ taskDirIn(draft, ref.mainline, ref.task);
1708
+ return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1709
+ i.status = "planned";
1710
+ i.plannedIn = input.task;
1711
+ }, input.expectedVersion));
1712
+ }
1713
+ function closeIssue(hub, input, ctx) {
1714
+ if (!input.resolution) throw new WriteError(`closing ${input.id} needs what closed it, e.g. --by "mainflow@3fdd5ac" or --by "不再需要"`);
1715
+ const { day } = stamps(ctx);
1716
+ const draft = new Draft(hub);
1717
+ return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1718
+ i.status = "closed";
1719
+ i.resolution = input.resolution;
1720
+ i.closedAt ??= day;
1721
+ }, input.expectedVersion));
1722
+ }
1723
+ function reopenIssue(hub, input) {
1724
+ const draft = new Draft(hub);
1725
+ return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1726
+ i.status = "open";
1727
+ delete i.plannedIn;
1728
+ delete i.resolution;
1729
+ delete i.closedAt;
1730
+ }, input.expectedVersion));
1731
+ }
1732
+ /** Archive or unarchive an issue; the status stays as it is, so a closed issue is visible until archived. */
1733
+ function setIssueArchived(hub, input, ctx) {
1734
+ const { day } = stamps(ctx);
1735
+ const draft = new Draft(hub);
1736
+ return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1737
+ if (input.archived) i.archivedAt ??= day;
1738
+ else delete i.archivedAt;
1739
+ }, input.expectedVersion));
1740
+ }
1741
+ function createFinding(hub, input, ctx) {
1742
+ requireSlug(input.slug);
1743
+ if (input.origin) requireExisting(hub, input.origin);
1744
+ const { day } = stamps(ctx);
1745
+ const draft = new Draft(hub);
1746
+ const id = draft.number(ctx, {
1747
+ prefix: "f",
1748
+ taken: idsIn(draft, FINDINGS_DIR),
1749
+ ref: (id) => id
1750
+ });
1751
+ const finding = validate(findingSchema, {
1752
+ id,
1753
+ title: input.title,
1754
+ status: "valid",
1755
+ created: day,
1756
+ ...input.origin ? { origin: input.origin } : {},
1757
+ ...input.labels?.length ? { labels: input.labels } : {}
1758
+ }, "finding");
1759
+ const file = `${FINDINGS_DIR}/${id}-${input.slug}.md`;
1760
+ draft.write(file, frontMatterText(finding, FINDING_BODY));
1761
+ return draft.result({
1762
+ id,
1763
+ file
1764
+ });
1765
+ }
1766
+ function setFindingStatus(hub, input, ctx) {
1767
+ const value = requireOneOf(input.status, FINDING_STATUS, "finding status");
1768
+ const { day } = stamps(ctx);
1769
+ const draft = new Draft(hub);
1770
+ return draft.result(editFrontMatter(draft, entryFile(draft, FINDINGS_DIR, input.id, "finding"), findingSchema, (f) => {
1771
+ f.status = value;
1772
+ if (value === "obsolete") f.closedAt ??= day;
1773
+ else delete f.closedAt;
1774
+ }, input.expectedVersion));
1775
+ }
1776
+ /** Archive or unarchive a finding; the status stays as it is, so an obsolete finding is visible until archived. */
1777
+ function setFindingArchived(hub, input, ctx) {
1778
+ const { day } = stamps(ctx);
1779
+ const draft = new Draft(hub);
1780
+ return draft.result(editFrontMatter(draft, entryFile(draft, FINDINGS_DIR, input.id, "finding"), findingSchema, (f) => {
1781
+ if (input.archived) f.archivedAt ??= day;
1782
+ else delete f.archivedAt;
1783
+ }, input.expectedVersion));
1784
+ }
1785
+ /**
1786
+ * A group gathers mainlines and issues that are taken up together, apart from what depends on what. Its members name
1787
+ * it in their own `group`, so creating a group writes only the group's file.
1788
+ */
1789
+ function createGroup(hub, input, ctx) {
1790
+ requireSlug(input.slug);
1791
+ if (input.origin) requireExisting(hub, input.origin);
1792
+ const priority = input.priority ? requirePriority(input.priority) : void 0;
1793
+ const { day } = stamps(ctx);
1794
+ const draft = new Draft(hub);
1795
+ const id = draft.number(ctx, {
1796
+ prefix: "w",
1797
+ taken: idsIn(draft, GROUPS_DIR),
1798
+ ref: (id) => id
1799
+ });
1800
+ const group = validate(groupSchema, {
1801
+ id,
1802
+ title: input.title,
1803
+ status: "open",
1804
+ ...priority ? { priority } : {},
1805
+ created: day,
1806
+ ...input.origin ? { origin: input.origin } : {},
1807
+ ...input.labels?.length ? { labels: input.labels } : {},
1808
+ ...input.trigger?.trim() ? { trigger: input.trigger.trim() } : {}
1809
+ }, "group");
1810
+ const file = `${GROUPS_DIR}/${id}-${input.slug}.md`;
1811
+ draft.write(file, frontMatterText(group, GROUP_BODY));
1812
+ return draft.result({
1813
+ id,
1814
+ file
1815
+ });
1816
+ }
1817
+ function setGroup(hub, input, ctx) {
1818
+ const { patch } = input;
1819
+ requireRef(input.id, "group");
1820
+ if (Object.values(patch).every((v) => v === void 0)) throw new WriteError(`${input.id} needs --status, --priority, --trigger or --title, e.g. --status done`);
1821
+ const status = patch.status === void 0 ? void 0 : requireOneOf(patch.status, GROUP_STATUS, "group status");
1822
+ const priority = patch.priority === void 0 ? void 0 : requirePriority(patch.priority);
1823
+ const title = patch.title?.replace(/\s+/g, " ").trim();
1824
+ if (patch.title !== void 0 && !title) throw new WriteError(`${input.id} needs a title, e.g. --title "量測修正"`);
1825
+ const { day } = stamps(ctx);
1826
+ const draft = new Draft(hub);
1827
+ return draft.result(editFrontMatter(draft, entryFile(draft, GROUPS_DIR, input.id, "group"), groupSchema, (g) => {
1828
+ if (status) {
1829
+ g.status = status;
1830
+ stampClosed(g, closes("group", status), day);
1831
+ }
1832
+ if (priority) g.priority = priority;
1833
+ if (title) g.title = title;
1834
+ if (patch.trigger !== void 0) {
1835
+ if (patch.trigger.trim()) g.trigger = patch.trigger.trim();
1836
+ else delete g.trigger;
1837
+ }
1838
+ }, input.expectedVersion));
1839
+ }
1840
+ /** Append one external operation to ops/{YYYY-MM}.log; the log is never rewritten. `at` defaults to the context's time. */
1841
+ function appendOps(hub, input, ctx) {
1842
+ if (input.ref) requireExisting(hub, input.ref);
1843
+ const entry = validate(opsEntrySchema, {
1844
+ ...input,
1845
+ at: input.at ?? stamps(ctx).minute
1846
+ }, "ops entry");
1847
+ for (const field of [
1848
+ "target",
1849
+ "operation",
1850
+ "result"
1851
+ ]) if (entry[field].includes(" | ")) throw new WriteError(`${field} cannot contain " | ", the ops log's separator`);
1852
+ const draft = new Draft(hub);
1853
+ draft.append(`ops/${entry.at.slice(0, 7)}.log`, `${formatOpsEntry(entry)}\n`);
1854
+ return draft.result(entry);
1855
+ }
1856
+ //#endregion
1548
1857
  //#region ../core/dist/ops/mainlines.js
1549
1858
  /** Every file below `dir`, sorted, skipping dot files. */
1550
1859
  function listFilesIn(hub, dir) {
@@ -1552,6 +1861,9 @@ function listFilesIn(hub, dir) {
1552
1861
  }
1553
1862
  function createMainline(hub, input, ctx) {
1554
1863
  requireSlug(input.slug);
1864
+ const group = input.group ? requireGroup(hub, input.group) : void 0;
1865
+ const role = input.role ? requireOneOf(input.role, MAINLINE_ROLES, "mainline role") : void 0;
1866
+ const priority = input.priority ? requirePriority(input.priority) : void 0;
1555
1867
  const { day } = stamps(ctx);
1556
1868
  const draft = new Draft(hub);
1557
1869
  const id = draft.number(ctx, {
@@ -1564,7 +1876,10 @@ function createMainline(hub, input, ctx) {
1564
1876
  id,
1565
1877
  title: input.title,
1566
1878
  status: "planning",
1879
+ ...priority ? { priority } : {},
1567
1880
  created: day,
1881
+ ...group ? { group } : {},
1882
+ ...role ? { role } : {},
1568
1883
  criteria: []
1569
1884
  }, MAINLINE_FILE);
1570
1885
  draft.makeDir(`${dir}/tasks`);
@@ -1589,16 +1904,50 @@ function requireDone(hub, id, m) {
1589
1904
  if (!open.length && !proposed.length) return;
1590
1905
  throw new WriteError(`${id} cannot be marked done: ${[open.length ? `unmet criteria ${open.join(", ")} (check each with evidence, e.g. criterion check ${open[0]} --evidence <commit>, or reword or drop it in ${MAINLINE_FILE})` : "", proposed.length ? `proposed decisions ${proposed.join(", ")} (decision accept or decision supersede first)` : ""].filter(Boolean).join("; ")}`);
1591
1906
  }
1592
- /** Move a mainline to another status; done is refused while criteria are open or decisions are proposed. */
1593
- function setMainlineStatus(hub, input, ctx) {
1907
+ /**
1908
+ * Work starting on a mainline that is still planning moves it to active, in the same draft as the work's own
1909
+ * change. It only goes up: any other status is left as it is, and a mainline that cannot be found is skipped.
1910
+ */
1911
+ function activatePlanning(draft, id) {
1912
+ let path;
1913
+ try {
1914
+ path = mainlineFileIn(draft, id);
1915
+ } catch (error) {
1916
+ if (error instanceof WriteError) return;
1917
+ throw error;
1918
+ }
1919
+ const { data } = splitFrontMatter(draft.read(path) ?? "");
1920
+ if (data?.status !== "planning") return;
1921
+ editFrontMatter(draft, path, mainlineSchema, (m) => void (m.status = "active"));
1922
+ }
1923
+ /**
1924
+ * Change a mainline's status, group, role or priority. Done is refused while criteria are open or decisions are
1925
+ * proposed; the other fields change without conditions, in the same write.
1926
+ */
1927
+ function setMainline(hub, input, ctx) {
1594
1928
  requireRef(input.id, "mainline");
1595
- const next = requireOneOf(input.status, MAINLINE_STATUS, "mainline status");
1929
+ if ([
1930
+ input.status,
1931
+ input.group,
1932
+ input.role,
1933
+ input.priority
1934
+ ].every((v) => v === void 0)) throw new WriteError(`${input.id} needs --status, --group, --role or --priority, e.g. --status paused`);
1935
+ const next = input.status === void 0 ? void 0 : requireOneOf(input.status, MAINLINE_STATUS, "mainline status");
1936
+ const role = input.role === void 0 ? void 0 : requireOneOf(input.role, MAINLINE_ROLES, "mainline role");
1937
+ const priority = input.priority === void 0 ? void 0 : requirePriority(input.priority);
1596
1938
  const { day } = stamps(ctx);
1597
1939
  const draft = new Draft(hub);
1940
+ const group = input.group === void 0 || clearsGroup(input.group) ? void 0 : requireGroup(draft, input.group);
1598
1941
  return draft.result(editFrontMatter(draft, mainlineFileIn(draft, input.id), mainlineSchema, (m) => {
1599
- if (next === "done" && m.status !== "done") requireDone(draft, input.id, m);
1600
- m.status = next;
1601
- stampClosed(m, closes("mainline", next), day);
1942
+ if (next) {
1943
+ if (next === "done" && m.status !== "done") requireDone(draft, input.id, m);
1944
+ m.status = next;
1945
+ stampClosed(m, closes("mainline", next), day);
1946
+ }
1947
+ if (group) m.group = group;
1948
+ else if (clearsGroup(input.group)) delete m.group;
1949
+ if (role) m.role = role;
1950
+ if (priority) m.priority = priority;
1602
1951
  }, input.expectedVersion));
1603
1952
  }
1604
1953
  /** Rename a mainline. References use its ID, so they keep resolving and show the new title. */
@@ -1724,7 +2073,7 @@ function updateTask(hub, input, ctx) {
1724
2073
  if (status === "blocked" && !patch.blockedBy?.reason) throw new WriteError(`blocking ${input.ref} needs a reason, e.g. --reason "等使用者提供 API key" --waiting-for 使用者`);
1725
2074
  const { day } = stamps(ctx);
1726
2075
  const draft = new Draft(hub);
1727
- return draft.result(editFrontMatter(draft, taskFileIn(draft, ref.mainline, ref.task), taskSchema, (t) => {
2076
+ const task = editFrontMatter(draft, taskFileIn(draft, ref.mainline, ref.task), taskSchema, (t) => {
1728
2077
  if (status !== void 0) {
1729
2078
  t.status = status;
1730
2079
  if (status === "doing") t.startedAt ??= day;
@@ -1741,7 +2090,9 @@ function updateTask(hub, input, ctx) {
1741
2090
  if (patch.base !== void 0) t.base = patch.base;
1742
2091
  if (merge !== void 0) t.merge = merge;
1743
2092
  checkTaskLinks(draft, ref.mainline, t);
1744
- }, input.expectedVersion));
2093
+ }, input.expectedVersion);
2094
+ if (status === "doing") activatePlanning(draft, ref.mainline);
2095
+ return draft.result(task);
1745
2096
  }
1746
2097
  /** The criteria a new item serves, as stored: ids of its own mainline's criteria, given as r003 or m001#r003. */
1747
2098
  function servedCriteria(hub, mainline, serves) {
@@ -1786,11 +2137,13 @@ function setChecklistItem(hub, input, ctx) {
1786
2137
  if (input.done && !input.evidence) throw new WriteError(`checking ${input.ref} needs evidence, e.g. --evidence mainflow@25e4165`);
1787
2138
  const { day } = stamps(ctx);
1788
2139
  const draft = new Draft(hub);
1789
- return draft.result(editFrontMatter(draft, taskFileIn(draft, ref.mainline, ref.task), taskSchema, (t) => {
2140
+ const task = editFrontMatter(draft, taskFileIn(draft, ref.mainline, ref.task), taskSchema, (t) => {
1790
2141
  const item = t.checklist.find((c) => c.id === ref.item);
1791
2142
  if (!item) throw new WriteError(`checklist item ${input.ref} not found`);
1792
2143
  setListItem(item, input.done, input.evidence, `${input.ref} is dropped; run item undrop ${input.ref} to bring it back before checking it`, day);
1793
- }, input.expectedVersion));
2144
+ }, input.expectedVersion);
2145
+ if (input.done) activatePlanning(draft, ref.mainline);
2146
+ return draft.result(task);
1794
2147
  }
1795
2148
  /** Mark an item as no longer needed, naming what replaced it when something did. */
1796
2149
  function dropChecklistItem(hub, input) {
@@ -1898,161 +2251,6 @@ function supersedeDecision(hub, input, ctx) {
1898
2251
  }, input.expectedVersion));
1899
2252
  }
1900
2253
  //#endregion
1901
- //#region ../core/dist/ops/hub-items.js
1902
- function entryFile(hub, dir, id, what) {
1903
- const file = entryFileIn(hub, dir, id);
1904
- if (!file) throw new WriteError(`${what} ${id} not found`);
1905
- return file;
1906
- }
1907
- /**
1908
- * Any reference the schema accepts must also point at something that exists. A reference into another
1909
- * hub only needs that hub registered here; check verifies the target when that hub is on this machine.
1910
- */
1911
- function requireExisting(hub, ref) {
1912
- const parsed = parseRef(ref);
1913
- if (!parsed) throw new WriteError(`"${ref}" is not a reference like ${REF_EXAMPLES}`);
1914
- const config = readConfigIn(hub);
1915
- if (parsed.hub !== void 0 && parsed.hub !== config.hub) {
1916
- if (!config.hubs?.[parsed.hub]) throw new WriteError(`hub "${parsed.hub}" is not registered; run "mainflow hub add ${parsed.hub} <path>" first`);
1917
- return;
1918
- }
1919
- switch (parsed.kind) {
1920
- case "issue":
1921
- entryFile(hub, ISSUES_DIR, parsed.issue, "issue");
1922
- return;
1923
- case "finding":
1924
- entryFile(hub, FINDINGS_DIR, parsed.finding, "finding");
1925
- return;
1926
- default:
1927
- mainlineDirIn(hub, parsed.mainline);
1928
- if ("task" in parsed) taskDirIn(hub, parsed.mainline, parsed.task);
1929
- if (parsed.kind === "decision") decisionFileIn(hub, parsed.mainline, parsed.decision);
1930
- }
1931
- }
1932
- function createIssue(hub, input, ctx) {
1933
- requireSlug(input.slug);
1934
- if (input.origin) requireExisting(hub, input.origin);
1935
- const { day } = stamps(ctx);
1936
- const draft = new Draft(hub);
1937
- const id = draft.number(ctx, {
1938
- prefix: "i",
1939
- taken: idsIn(draft, ISSUES_DIR),
1940
- ref: (id) => id
1941
- });
1942
- const issue = validate(issueSchema, {
1943
- id,
1944
- title: input.title,
1945
- status: "open",
1946
- created: day,
1947
- ...input.origin ? { origin: input.origin } : {},
1948
- ...input.labels?.length ? { labels: input.labels } : {}
1949
- }, "issue");
1950
- const file = `${ISSUES_DIR}/${id}-${input.slug}.md`;
1951
- draft.write(file, frontMatterText(issue, ISSUE_BODY));
1952
- return draft.result({
1953
- id,
1954
- file
1955
- });
1956
- }
1957
- /** Schedule an issue into the task that will carry it out. */
1958
- function planIssue(hub, input) {
1959
- const ref = requireRef(input.task, "task");
1960
- const draft = new Draft(hub);
1961
- taskDirIn(draft, ref.mainline, ref.task);
1962
- return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1963
- i.status = "planned";
1964
- i.plannedIn = input.task;
1965
- }, input.expectedVersion));
1966
- }
1967
- function closeIssue(hub, input, ctx) {
1968
- if (!input.resolution) throw new WriteError(`closing ${input.id} needs what closed it, e.g. --by "mainflow@3fdd5ac" or --by "不再需要"`);
1969
- const { day } = stamps(ctx);
1970
- const draft = new Draft(hub);
1971
- return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1972
- i.status = "closed";
1973
- i.resolution = input.resolution;
1974
- i.closedAt ??= day;
1975
- }, input.expectedVersion));
1976
- }
1977
- function reopenIssue(hub, input) {
1978
- const draft = new Draft(hub);
1979
- return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1980
- i.status = "open";
1981
- delete i.plannedIn;
1982
- delete i.resolution;
1983
- delete i.closedAt;
1984
- }, input.expectedVersion));
1985
- }
1986
- /** Archive or unarchive an issue; the status stays as it is, so a closed issue is visible until archived. */
1987
- function setIssueArchived(hub, input, ctx) {
1988
- const { day } = stamps(ctx);
1989
- const draft = new Draft(hub);
1990
- return draft.result(editFrontMatter(draft, entryFile(draft, ISSUES_DIR, input.id, "issue"), issueSchema, (i) => {
1991
- if (input.archived) i.archivedAt ??= day;
1992
- else delete i.archivedAt;
1993
- }, input.expectedVersion));
1994
- }
1995
- function createFinding(hub, input, ctx) {
1996
- requireSlug(input.slug);
1997
- if (input.origin) requireExisting(hub, input.origin);
1998
- const { day } = stamps(ctx);
1999
- const draft = new Draft(hub);
2000
- const id = draft.number(ctx, {
2001
- prefix: "f",
2002
- taken: idsIn(draft, FINDINGS_DIR),
2003
- ref: (id) => id
2004
- });
2005
- const finding = validate(findingSchema, {
2006
- id,
2007
- title: input.title,
2008
- status: "valid",
2009
- created: day,
2010
- ...input.origin ? { origin: input.origin } : {},
2011
- ...input.labels?.length ? { labels: input.labels } : {}
2012
- }, "finding");
2013
- const file = `${FINDINGS_DIR}/${id}-${input.slug}.md`;
2014
- draft.write(file, frontMatterText(finding, FINDING_BODY));
2015
- return draft.result({
2016
- id,
2017
- file
2018
- });
2019
- }
2020
- function setFindingStatus(hub, input, ctx) {
2021
- const value = requireOneOf(input.status, FINDING_STATUS, "finding status");
2022
- const { day } = stamps(ctx);
2023
- const draft = new Draft(hub);
2024
- return draft.result(editFrontMatter(draft, entryFile(draft, FINDINGS_DIR, input.id, "finding"), findingSchema, (f) => {
2025
- f.status = value;
2026
- if (value === "obsolete") f.closedAt ??= day;
2027
- else delete f.closedAt;
2028
- }, input.expectedVersion));
2029
- }
2030
- /** Archive or unarchive a finding; the status stays as it is, so an obsolete finding is visible until archived. */
2031
- function setFindingArchived(hub, input, ctx) {
2032
- const { day } = stamps(ctx);
2033
- const draft = new Draft(hub);
2034
- return draft.result(editFrontMatter(draft, entryFile(draft, FINDINGS_DIR, input.id, "finding"), findingSchema, (f) => {
2035
- if (input.archived) f.archivedAt ??= day;
2036
- else delete f.archivedAt;
2037
- }, input.expectedVersion));
2038
- }
2039
- /** Append one external operation to ops/{YYYY-MM}.log; the log is never rewritten. `at` defaults to the context's time. */
2040
- function appendOps(hub, input, ctx) {
2041
- if (input.ref) requireExisting(hub, input.ref);
2042
- const entry = validate(opsEntrySchema, {
2043
- ...input,
2044
- at: input.at ?? stamps(ctx).minute
2045
- }, "ops entry");
2046
- for (const field of [
2047
- "target",
2048
- "operation",
2049
- "result"
2050
- ]) if (entry[field].includes(" | ")) throw new WriteError(`${field} cannot contain " | ", the ops log's separator`);
2051
- const draft = new Draft(hub);
2052
- draft.append(`ops/${entry.at.slice(0, 7)}.log`, `${formatOpsEntry(entry)}\n`);
2053
- return draft.result(entry);
2054
- }
2055
- //#endregion
2056
2254
  //#region ../core/dist/ops/sections.js
2057
2255
  /** The documents that have sections, with the sections each kind is created with. */
2058
2256
  const DOCUMENTS = {
@@ -2060,11 +2258,12 @@ const DOCUMENTS = {
2060
2258
  task: TASK_SECTIONS,
2061
2259
  decision: DECISION_SECTIONS,
2062
2260
  issue: ISSUE_SECTIONS,
2063
- finding: FINDING_SECTIONS
2261
+ finding: FINDING_SECTIONS,
2262
+ group: GROUP_SECTIONS
2064
2263
  };
2065
2264
  function documentFile(hub, input) {
2066
2265
  const ref = parseRef(input);
2067
- if (!ref || ref.hub !== void 0 || !(ref.kind in DOCUMENTS)) throw new WriteError(`section append takes a mainline, task, decision, issue or finding of this hub, like m001, m001/t002, m001/d001, i001 or f001; got "${input}"`);
2266
+ if (!ref || ref.hub !== void 0 || !(ref.kind in DOCUMENTS)) throw new WriteError(`section append takes a mainline, task, decision, issue, finding or group of this hub, like m001, m001/t002, m001/d001, i001, f001 or w001; got "${input}"`);
2068
2267
  const sections = DOCUMENTS[ref.kind];
2069
2268
  const entry = (dir, id, what) => {
2070
2269
  const file = entryFileIn(hub, dir, id);
@@ -2088,6 +2287,10 @@ function documentFile(hub, input) {
2088
2287
  file: entry(ISSUES_DIR, ref.issue, "issue"),
2089
2288
  sections
2090
2289
  };
2290
+ case "group": return {
2291
+ file: entry(GROUPS_DIR, ref.group, "group"),
2292
+ sections
2293
+ };
2091
2294
  default: return {
2092
2295
  file: entry(FINDINGS_DIR, ref.finding, "finding"),
2093
2296
  sections
@@ -2547,6 +2750,7 @@ function updateAction(hub, input, ctx) {
2547
2750
  if (patch.before) place(s.actions, next, patch.before);
2548
2751
  else s.actions.splice(index, 0, next);
2549
2752
  });
2753
+ if (status === "doing") for (const m of new Set(updated.serves.map((r) => /^m\d{3}/.exec(r)?.[0]).filter((m) => !!m))) activatePlanning(draft, m);
2550
2754
  return draft.result(updated);
2551
2755
  }
2552
2756
  //#endregion
@@ -2732,7 +2936,7 @@ const OPERATIONS = {
2732
2936
  "repo.add": addRepo,
2733
2937
  "hub.add": addHub,
2734
2938
  "mainline.new": createMainline,
2735
- "mainline.set": setMainlineStatus,
2939
+ "mainline.set": setMainline,
2736
2940
  "mainline.title": setMainlineTitle,
2737
2941
  "mainline.archive": setMainlineArchived,
2738
2942
  "criterion.add": addCriterion,
@@ -2748,6 +2952,7 @@ const OPERATIONS = {
2748
2952
  "decision.accept": acceptDecision,
2749
2953
  "decision.supersede": supersedeDecision,
2750
2954
  "issue.new": createIssue,
2955
+ "issue.set": setIssue,
2751
2956
  "issue.plan": planIssue,
2752
2957
  "issue.close": closeIssue,
2753
2958
  "issue.reopen": reopenIssue,
@@ -2755,6 +2960,8 @@ const OPERATIONS = {
2755
2960
  "finding.new": createFinding,
2756
2961
  "finding.set": setFindingStatus,
2757
2962
  "finding.archive": setFindingArchived,
2963
+ "group.new": createGroup,
2964
+ "group.set": setGroup,
2758
2965
  "ops.add": appendOps,
2759
2966
  "section.append": appendSection,
2760
2967
  "session.start": startSession,
@@ -2821,7 +3028,7 @@ function renumberText(text, renumbered, scope) {
2821
3028
  const parts = [full.size ? `(?<![A-Za-z0-9_:#-])(${alternation([...full.keys()])})${after}` : "(?!)", short.size ? `(?<![A-Za-z0-9_:#/-])(${alternation([...short.keys()])})${after}` : "(?!)"];
2822
3029
  return text.replace(new RegExp(parts.join("|"), "g"), (match, whole, id) => whole ? full.get(whole) : id ? short.get(id) : match);
2823
3030
  }
2824
- const ENTRY = /^([mtdif]\d{3})(-.+)$/;
3031
+ const ENTRY = /^([mtdifw]\d{3})(-.+)$/;
2825
3032
  /** The scope of a file in the data directory, from its path: the mainline and task directories above it, or its session. */
2826
3033
  function scopeOfPath(path) {
2827
3034
  const [top, second, third, fourth] = path.split("/");
@@ -2836,7 +3043,7 @@ function scopeOfPath(path) {
2836
3043
  }
2837
3044
  /**
2838
3045
  * The path of a file after renumbering: the directory or file named after a renumbered mainline, task, decision,
2839
- * issue or finding takes the new id and keeps its slug, e.g. issues/i004-flaky.md for i003 → i004.
3046
+ * issue, finding or group takes the new id and keeps its slug, e.g. issues/i004-flaky.md for i003 → i004.
2840
3047
  */
2841
3048
  function renumberPath(path, renumbered) {
2842
3049
  const to = new Map(renumbered.map((r) => [r.from, r.to]));
@@ -2846,7 +3053,7 @@ function renumberPath(path, renumbered) {
2846
3053
  if (!entry) return segment;
2847
3054
  const id = entry[1];
2848
3055
  const mainline = ENTRY.exec(segments[1] ?? "")?.[1];
2849
- const ref = segments[0] === "mainlines" && i === 1 ? id : segments[0] === "mainlines" && i === 3 && (segments[2] === "tasks" || segments[2] === "decisions") && mainline ? `${mainline}/${id}` : (segments[0] === "issues" || segments[0] === "findings") && i === 1 ? id : void 0;
3056
+ const ref = segments[0] === "mainlines" && i === 1 ? id : segments[0] === "mainlines" && i === 3 && (segments[2] === "tasks" || segments[2] === "decisions") && mainline ? `${mainline}/${id}` : (segments[0] === "issues" || segments[0] === "findings" || segments[0] === "groups") && i === 1 ? id : void 0;
2850
3057
  const next = ref === void 0 ? void 0 : to.get(ref);
2851
3058
  return next === void 0 ? segment : `${lastId(next)}${entry[2]}`;
2852
3059
  }).join("/");
@@ -3148,14 +3355,15 @@ function pathOf(ref, target) {
3148
3355
  case "decision": return `/mainlines/${m}/decisions/${child}`;
3149
3356
  case "issue": return `/issues?peek=${ref}`;
3150
3357
  case "finding": return `/findings?peek=${ref}`;
3358
+ case "group": return `/mainlines?peek=${ref}`;
3151
3359
  default: return null;
3152
3360
  }
3153
3361
  }
3154
3362
  const sessionPath = (id) => `/sessions/${id}`;
3155
3363
  /** Fenced blocks, inline code, markdown links and bare URLs: text inside them is left as written. */
3156
3364
  const PROTECTED = /(```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|https?:\/\/\S+)/;
3157
- /** A file under a mainline directory, or a reference to a mainline, task, decision, issue or finding. */
3158
- const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[if]\d{3})(?![\w\]])/g;
3365
+ /** A file under a mainline directory, or a reference to a mainline, task, decision, issue, finding or group. */
3366
+ const MENTION = /\.mainflow\/mainlines\/(m\d{3})-[\w-]+\/([^\s`'")\]]+\.\w+)|(?<![\w/#.[-])(m\d{3}(?:\/[td]\d{3})?|[ifw]\d{3})(?![\w\]])/g;
3159
3367
  /** Links for the references and mainline files the text mentions; references the hub does not have stay as text. */
3160
3368
  function linkMentions(text, context) {
3161
3369
  const index = refIndex(context.hub);
@@ -3238,7 +3446,7 @@ function decorateReply(text, context) {
3238
3446
  return line ? `${body}\n\n${line}\n` : `${body}\n`;
3239
3447
  }
3240
3448
  /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
3241
- const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
3449
+ const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
3242
3450
  //#endregion
3243
3451
  //#region ../core/dist/grants.js
3244
3452
  /** Mainline statuses whose grants apply. */
@@ -7564,6 +7772,7 @@ function reloadCaddy(sites) {
7564
7772
  if (reload.error) return reload.error.message;
7565
7773
  return reload.status === 0 ? null : (reload.stderr || reload.stdout).trim().slice(-300);
7566
7774
  }
7775
+ const adminAddress = () => process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
7567
7776
  /**
7568
7777
  * Whether Caddy is running, judged by its admin API answering at all. The API refuses requests like
7569
7778
  * Node's fetch with 403 ("client is not allowed to access from origin"), so any response counts; only a
@@ -7571,14 +7780,63 @@ function reloadCaddy(sites) {
7571
7780
  * running Caddy as stopped). MAINFLOW_CADDY_ADMIN overrides the address.
7572
7781
  */
7573
7782
  async function caddyRunning() {
7574
- const admin = process.env.MAINFLOW_CADDY_ADMIN ?? "http://127.0.0.1:2019";
7575
7783
  try {
7576
- await fetch(`${admin}/config/`, { signal: AbortSignal.timeout(500) });
7784
+ await fetch(`${adminAddress()}/config/`, { signal: AbortSignal.timeout(500) });
7577
7785
  return true;
7578
7786
  } catch {
7579
7787
  return false;
7580
7788
  }
7581
7789
  }
7790
+ /** Every `dial` address under `node`. */
7791
+ function collectDials(node, out) {
7792
+ if (Array.isArray(node)) for (const item of node) collectDials(item, out);
7793
+ else if (node && typeof node === "object") for (const [key, value] of Object.entries(node)) if (key === "dial" && typeof value === "string") out.push(value);
7794
+ else collectDials(value, out);
7795
+ }
7796
+ /** Every `dial` address of the routes under `node` whose match names `host`. */
7797
+ function dialsOf(node, host, out = []) {
7798
+ if (Array.isArray(node)) for (const item of node) dialsOf(item, host, out);
7799
+ else if (node && typeof node === "object") {
7800
+ const route = node;
7801
+ if (Array.isArray(route.match) && route.match.some((m) => m.host?.includes(host))) collectDials(route.handle, out);
7802
+ else for (const value of Object.values(route)) dialsOf(value, host, out);
7803
+ }
7804
+ return out;
7805
+ }
7806
+ /**
7807
+ * The upstream addresses the running Caddy has for `host`, read from its admin API (an empty list when it has
7808
+ * no such site), or undefined when the API cannot be read. The request carries an Origin header because the
7809
+ * API answers 403 without one.
7810
+ */
7811
+ async function runningUpstreams(host) {
7812
+ const admin = adminAddress();
7813
+ try {
7814
+ const response = await fetch(`${admin}/config/`, {
7815
+ headers: { Origin: admin },
7816
+ signal: AbortSignal.timeout(1500)
7817
+ });
7818
+ if (!response.ok) return void 0;
7819
+ return dialsOf(await response.json(), host);
7820
+ } catch {
7821
+ return;
7822
+ }
7823
+ }
7824
+ /**
7825
+ * Remove the mainflow-* links in `sites` whose target is gone. One such link makes every Caddy reload fail,
7826
+ * because the Caddyfile imports the whole directory; hubs deleted without being asked to stop (test hubs)
7827
+ * leave them behind. Other names and links with a target are not touched. Returns the names removed.
7828
+ */
7829
+ function removeDanglingLinks(sites) {
7830
+ const removed = [];
7831
+ for (const name of readdirSync(sites)) {
7832
+ if (!name.startsWith("mainflow-")) continue;
7833
+ const link = join(sites, name);
7834
+ if (!lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink() || existsSync(link)) continue;
7835
+ rmSync(link, { force: true });
7836
+ removed.push(name);
7837
+ }
7838
+ return removed;
7839
+ }
7582
7840
  const realOf = (path) => existsSync(path) ? realpathSync(path) : resolve(path);
7583
7841
  /**
7584
7842
  * Point {hub}.mainflow.localhost at `port`. The site block is rewritten and Caddy reloaded only when the
@@ -7594,23 +7852,36 @@ async function syncHost(root, hub, port, options = {}) {
7594
7852
  const dir = join(root, DATA_DIR, "local", "caddy");
7595
7853
  const file = join(dir, `${hub}.caddy`);
7596
7854
  const link = join(sites, `mainflow-${hub}.caddy`);
7855
+ removeDanglingLinks(sites);
7597
7856
  const linked = lstatSync(link, { throwIfNoEntry: false });
7598
7857
  if (linked) {
7599
7858
  const target = linked.isSymbolicLink() ? resolve(dirname(link), readlinkSync(link)) : link;
7600
7859
  if (existsSync(target) && realOf(target) !== realOf(file)) throw new WriteError(`host name ${hostName(hub)} is already used by another hub (${link} → ${target}); give one of the hubs a different name in its .mainflow/config.yaml`);
7601
7860
  }
7602
7861
  const block = siteBlock(hub, port);
7603
- if ((linked && existsSync(file) ? readFileSync(file, "utf8") : null) !== block) {
7862
+ const current = linked && existsSync(file) ? readFileSync(file, "utf8") : null;
7863
+ const pending = join(dir, `${hub}.reload-pending`);
7864
+ const upstreams = await (options.upstreams ?? runningUpstreams)(hostName(hub));
7865
+ const stale = upstreams ? upstreams.length !== 1 || upstreams[0] !== `localhost:${port}` : existsSync(pending);
7866
+ if (current !== block || stale) {
7604
7867
  mkdirSync(dir, { recursive: true });
7605
- writeFileSync(file, block);
7606
- if (linked) rmSync(link);
7607
- symlinkSync(file, link);
7868
+ if (current !== block) {
7869
+ writeFileSync(file, block);
7870
+ if (linked) rmSync(link);
7871
+ symlinkSync(file, link);
7872
+ }
7608
7873
  const failure = (options.reload ?? reloadCaddy)(sites);
7609
- if (failure) return {
7610
- url: null,
7611
- reason: `caddy reload failed: ${failure}`
7612
- };
7613
- } else if (!await (options.running ?? caddyRunning)()) return {
7874
+ if (failure) {
7875
+ writeFileSync(pending, `${failure}\n`);
7876
+ return {
7877
+ url: null,
7878
+ reason: `caddy reload failed: ${failure}`,
7879
+ reloadFailed: true
7880
+ };
7881
+ }
7882
+ rmSync(pending, { force: true });
7883
+ } else if (upstreams) rmSync(pending, { force: true });
7884
+ else if (!await (options.running ?? caddyRunning)()) return {
7614
7885
  url: null,
7615
7886
  reason: "Caddy is not running; start it with `brew services start caddy`"
7616
7887
  };
@@ -7625,10 +7896,16 @@ function removeHostLink(root, hub, reload = reloadCaddy) {
7625
7896
  const sites = caddySites();
7626
7897
  if (!sites) return null;
7627
7898
  const link = join(sites, `mainflow-${hub}.caddy`);
7628
- if (!lstatSync(link, { throwIfNoEntry: false })?.isSymbolicLink()) return null;
7629
- if (!resolve(dirname(link), readlinkSync(link)).startsWith(resolve(root) + "/")) return null;
7630
- rmSync(link);
7631
- return reload(sites);
7899
+ const linked = lstatSync(link, { throwIfNoEntry: false });
7900
+ let removed = false;
7901
+ if (linked?.isSymbolicLink()) {
7902
+ if (resolve(dirname(link), readlinkSync(link)).startsWith(resolve(root) + "/")) {
7903
+ rmSync(link);
7904
+ removed = true;
7905
+ }
7906
+ }
7907
+ if (removeDanglingLinks(sites).length > 0) removed = true;
7908
+ return removed ? reload(sites) : null;
7632
7909
  }
7633
7910
  /** The macOS app's bundle identifier, from apps/macos/scripts/package.sh. */
7634
7911
  const APP_BUNDLE_ID = "space.carlos.mainflow";
@@ -7662,4 +7939,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
7662
7939
  return true;
7663
7940
  }
7664
7941
  //#endregion
7665
- export { MAX_FILE_BYTES as $, attributeTrace as A, checkHubName as B, transcriptPath as C, endsWithMarker as D, decorateReply as E, renumberFiles as F, linksInto as G, loadHub as H, renumberPath as I, readYaml as J, refIndex as K, renumberText as L, transcriptTitle as M, applyOffline as N, headerLine as O, renumberRequest as P, MAX_BLOB_BYTES as Q, scopeOfPath as R, readSession as S, matchGrants as T, dependencyGraph as U, HubError as V, mainlineEdges as W, ACCESS_HEADERS as X, nodeStorage as Y, BLOB_HASH as Z, untilState as _, splitFrontMatter as _t, openInApp as a, requestTime as at, turnReminder as b, syncHost as c, TASK_FILE as ct, retroReportFromReceiver as d, findHubRoot as dt, ROUTES as et, receiverFromEnv as f, worktreesOf as ft, initHub as g, contentHash as gt, setArchivedMany as h, WriteError as ht, hostName as i, readsAsText as it, traceTranscript as j, mayDecorate as k, migrateHub as l, requireSlug as lt, ARCHIVE_KINDS as m, DATA_DIR as mt, DEFAULT_UI_PORT as n, comparePaths as nt, removeHostLink as o, textBytes as ot, RETRY_RULES as p, ConflictError as pt, nowMinute as q, caddySites as r, exceedsFileLimit as rt, siteBlock as s, MAINLINE_FILE as st, APP_BUNDLE_ID as t, SOCKET_MAX_AGE_CLOSE as tt, retroReport as u, currentBranch as ut, duplicateClaimLines as v, grantScope as w, loadSessions as x, sessionBriefing as y, applyOperation as z };
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 };