mainflow 0.38.0 → 0.39.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 { A as TASK_SECTIONS, B as opsEntrySchema, C as SERVES_REF, D as SURVEY_LINES, E as SLUG, F as findingSchema, G as refExample, H as parseOpsLine, I as formatOpsEntry, J as taskSchema, K as refMainline, L as groupSchema, M as WORK_STATUS, N as configSchema, O as SURVEY_SECTION, P as decisionSchema, Q as stampMs, R as issueSchema, S as RESERVED_HUB_NAMES, T as SESSION_STATUS, U as parseRef, V as parseActionRef, W as parseUntil, X as waitingForRef, Y as waitingForAction, Z as OFFSET, _ as MAINLINE_SECTIONS, a as DECISION_SECTIONS, at as grantRepo, b as PRIORITIES, c as FINDING_SECTIONS, d as GROUP_SECTIONS, f as GROUP_STATUS, i as DECISION_BASES, it as grantForm, j as UNTIL_FORMS, k as TASK_REQUIRED_SECTIONS, l as FINDING_STATUS, m as ISSUE_SECTIONS, n as CLOSED_STATUS, nt as describeRequest, ot as parseGrantTarget, q as sessionSchema, r as DECIDERS, rt as grantCovers, s as DEFAULT_CATEGORIES, v as MAINLINE_STATUS, w as SESSION_ID, x as REF_EXAMPLES, y as MERGE_STATES, z as mainlineSchema } from "./dist-CZYL5PJ4.js";
1
+ import { $ as stampMs, A as TASK_REQUIRED_SECTIONS, B as mainlineSchema, C as RESERVED_HUB_NAMES, D as SLUG, E as SESSION_STATUS, F as decisionSchema, G as parseUntil, H as parseActionRef, I as findingSchema, J as sessionSchema, K as refExample, L as formatOpsEntry, M as UNTIL_FORMS, N as WORK_STATUS, O as SURVEY_LINES, P as configSchema, Q as OFFSET, R as groupSchema, S as REF_EXAMPLES, T as SESSION_ID, U as parseOpsLine, V as opsEntrySchema, W as parseRef, X as waitingForAction, Y as taskSchema, Z as waitingForRef, a as DECISION_SECTIONS, at as grantForm, b as MERGE_STATES, c as FINDING_SECTIONS, d as GROUP_SECTIONS, f as GROUP_STATUS, h as ISSUE_SECTIONS, i as DECISION_BASES, it as grantCovers, j as TASK_SECTIONS, k as SURVEY_SECTION, l as FINDING_STATUS, n as CLOSED_STATUS, ot as grantRepo, q as refMainline, r as DECIDERS, rt as describeRequest, s as DEFAULT_CATEGORIES, st as parseGrantTarget, v as MAINLINE_SECTIONS, w as SERVES_REF, x as PRIORITIES, y as MAINLINE_STATUS, z as issueSchema } from "./dist-zSabglPq.js";
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";
@@ -459,6 +459,11 @@ function decisionFileIn(hub, mainline, decision) {
459
459
  return file;
460
460
  }
461
461
  const sessionFileIn = (id) => `${SESSIONS_DIR}/${id}.yaml`;
462
+ function groupFileIn(hub, id) {
463
+ const file = entryFileIn(hub, GROUPS_DIR, id);
464
+ if (!file) throw new WriteError(`group ${id} not found`);
465
+ return file;
466
+ }
462
467
  //#endregion
463
468
  //#region ../core/dist/ops/format.js
464
469
  /** Why a hub whose data format is `found` cannot be written by this CLI; null when it can. */
@@ -751,6 +756,7 @@ const mainlineDir = (root, id) => onDisk(root, mainlineDirIn(nodeStorage(root),
751
756
  const taskDir = (root, mainline, task) => onDisk(root, taskDirIn(nodeStorage(root), mainline, task));
752
757
  const mainlineFile = (root, id) => join(mainlineDir(root, id), MAINLINE_FILE);
753
758
  const taskFile = (root, mainline, task) => join(taskDir(root, mainline, task), TASK_FILE);
759
+ const groupFile = (root, id) => onDisk(root, groupFileIn(nodeStorage(root), id));
754
760
  const readConfig = (root) => readConfigIn(nodeStorage(root));
755
761
  //#endregion
756
762
  //#region ../core/dist/refs.js
@@ -802,11 +808,18 @@ function refIndex(hub) {
802
808
  title: f.title,
803
809
  status: f.status
804
810
  });
805
- for (const g of hub.groups ?? []) index.set(g.id, {
806
- kind: "group",
807
- title: g.title,
808
- status: g.status
809
- });
811
+ for (const g of hub.groups ?? []) {
812
+ index.set(g.id, {
813
+ kind: "group",
814
+ title: g.title,
815
+ status: g.status
816
+ });
817
+ for (const r of g.criteria ?? []) index.set(`${g.id}#${r.id}`, {
818
+ kind: "groupCriterion",
819
+ title: r.text,
820
+ status: itemStatus(r)
821
+ });
822
+ }
810
823
  return index;
811
824
  }
812
825
  const isHubRoot = (dir) => existsSync(join(dir, DATA_DIR, "config.yaml"));
@@ -1358,6 +1371,7 @@ function loadHub(root, options = {}) {
1358
1371
  const findings = hubDocs(join(dataDir, "findings"), findingSchema);
1359
1372
  const groups = hubDocs(join(dataDir, "groups"), groupSchema);
1360
1373
  problems.push(...groupProblems(config.hub, groups, mainlines, hubIssues, unreadable));
1374
+ for (const g of groups) problems.push(...grantProblems(g, config), ...listGaps(g.file, "criteria", g.criteria ?? [], g.status === "open"));
1361
1375
  const ops = [];
1362
1376
  const opsPath = join(dataDir, "ops");
1363
1377
  for (const name of existsSync(opsPath) ? readdirSync(opsPath).filter((n) => n.endsWith(".log")).sort() : []) readFileSync(join(opsPath, name), "utf8").split("\n").forEach((line, index) => {
@@ -1600,7 +1614,10 @@ function checkReferences(input) {
1600
1614
  }
1601
1615
  for (const i of issues) add(i.file, i, i.id);
1602
1616
  for (const f of findings) add(f.file, f, f.id);
1603
- for (const g of groups) add(g.file, g, g.id);
1617
+ for (const g of groups) {
1618
+ add(g.file, g, g.id);
1619
+ items(g.file, "criteria", g.criteria ?? []);
1620
+ }
1604
1621
  for (const p of pending) {
1605
1622
  const parsed = parseRef(p.ref);
1606
1623
  if (!parsed) continue;
@@ -1657,7 +1674,7 @@ function repoPathProblems(root, config) {
1657
1674
  });
1658
1675
  }
1659
1676
  /**
1660
- * What the schema cannot see about a mainline's grants: ids must be unique, since the file is edited directly,
1677
+ * What the schema cannot see about a mainline's or a group's grants: ids must be unique, since the file is edited directly,
1661
1678
  * and a target that names a repository must name one listed in config.yaml.
1662
1679
  */
1663
1680
  function grantProblems(mainline, config) {
@@ -1688,24 +1705,28 @@ const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
1688
1705
  * entries are skipped, because their evidence already says what was done. Ids must also be unique within
1689
1706
  * a list, since the files are edited directly and nothing else allocates them.
1690
1707
  */
1691
- function contentGaps(mainline, tasks) {
1708
+ /** Ids unique within a list, and every open item saying what has to be true to check it. */
1709
+ function listGaps(file, field, items, open) {
1692
1710
  const gaps = [];
1693
- const list = (file, field, items, open) => {
1694
- const seen = /* @__PURE__ */ new Set();
1695
- items.forEach((item, i) => {
1696
- if (seen.has(item.id)) gaps.push({
1697
- file,
1698
- field: `${field}.${i}.id`,
1699
- message: `${item.id} is used twice; give it the next free id`
1700
- });
1701
- seen.add(item.id);
1702
- if (open && !item.done && !item.dropped && !item.doneWhen?.trim()) gaps.push({
1703
- file,
1704
- field: `${field}.${i}.doneWhen`,
1705
- message: `${item.id} is open and needs doneWhen: what has to be true to check it`
1706
- });
1711
+ const seen = /* @__PURE__ */ new Set();
1712
+ items.forEach((item, i) => {
1713
+ if (seen.has(item.id)) gaps.push({
1714
+ file,
1715
+ field: `${field}.${i}.id`,
1716
+ message: `${item.id} is used twice; give it the next free id`
1707
1717
  });
1708
- };
1718
+ seen.add(item.id);
1719
+ if (open && !item.done && !item.dropped && !item.doneWhen?.trim()) gaps.push({
1720
+ file,
1721
+ field: `${field}.${i}.doneWhen`,
1722
+ message: `${item.id} is open and needs doneWhen: what has to be true to check it`
1723
+ });
1724
+ });
1725
+ return gaps;
1726
+ }
1727
+ function contentGaps(mainline, tasks) {
1728
+ const gaps = [];
1729
+ const list = (file, field, items, open) => gaps.push(...listGaps(file, field, items, open));
1709
1730
  const mainlineOpen = isOpen$2("mainline", mainline.status);
1710
1731
  list(mainline.file, "criteria", mainline.criteria, mainlineOpen);
1711
1732
  if (mainlineOpen && tasks.length > 0) for (const message of surveyGaps(mainline.body)) gaps.push({
@@ -1846,6 +1867,7 @@ function requireExisting(hub, ref) {
1846
1867
  entryFile(hub, FINDINGS_DIR, parsed.finding, "finding");
1847
1868
  return;
1848
1869
  case "group":
1870
+ case "groupCriterion":
1849
1871
  entryFile(hub, GROUPS_DIR, parsed.group, "group");
1850
1872
  return;
1851
1873
  default:
@@ -2029,6 +2051,26 @@ function createGroup(hub, input, ctx) {
2029
2051
  file
2030
2052
  });
2031
2053
  }
2054
+ /** The mainlines and issues of this hub that are members of group `id` and not yet settled, by id. */
2055
+ function openMembers(hub, id) {
2056
+ const own = readConfigIn(hub).hub;
2057
+ const isMember = (value) => value === id || value === `${own}:${id}`;
2058
+ const read = (path) => splitFrontMatter(hub.read(path) ?? "").data ?? {};
2059
+ const mainlines = hub.list(MAINLINES_DIR).filter((e) => e.dir).map((e) => read(`${MAINLINES_DIR}/${e.name}/${MAINLINE_FILE}`));
2060
+ const issues = hub.list(ISSUES_DIR).filter((e) => !e.dir && e.name.endsWith(".md")).map((e) => read(`${ISSUES_DIR}/${e.name}`));
2061
+ return [...mainlines.filter((m) => isMember(m.group) && !closes("mainline", m.status ?? "")), ...issues.filter((i) => isMember(i.group) && i.status !== "closed")].map((d) => d.id ?? "?");
2062
+ }
2063
+ /**
2064
+ * Marking a group done is a claim that its goal is reached and its work is settled, so it is refused while a
2065
+ * criterion is still open or a mainline or issue of this hub in the group is not finished. The message names each one.
2066
+ * Members in other hubs are not read here, since the writer only sees this hub.
2067
+ */
2068
+ function requireGroupDone(hub, id, g) {
2069
+ const open = (g.criteria ?? []).filter((c) => !c.done && !c.dropped).map((c) => `${id}#${c.id}`);
2070
+ const members = openMembers(hub, id);
2071
+ if (!open.length && !members.length) return;
2072
+ 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 the group file)` : "", members.length ? `unfinished members ${members.join(", ")} (finish them, or move them out with mainline set or issue set --group none)` : ""].filter(Boolean).join("; ")}`);
2073
+ }
2032
2074
  function setGroup(hub, input, ctx) {
2033
2075
  const { patch } = input;
2034
2076
  requireRef(input.id, "group");
@@ -2041,6 +2083,7 @@ function setGroup(hub, input, ctx) {
2041
2083
  const draft = new Draft(hub);
2042
2084
  return draft.result(editFrontMatter(draft, entryFile(draft, GROUPS_DIR, input.id, "group"), groupSchema, (g) => {
2043
2085
  if (status) {
2086
+ if (status === "done" && g.status !== "done") requireGroupDone(draft, input.id, g);
2044
2087
  g.status = status;
2045
2088
  stampClosed(g, closes("group", status), day);
2046
2089
  }
@@ -2179,45 +2222,69 @@ function setMainlineArchived(hub, input, ctx) {
2179
2222
  else delete m.archivedAt;
2180
2223
  }, input.expectedVersion));
2181
2224
  }
2182
- /** Add an open criterion at the end of the mainline's list, numbered by the context's allocator. */
2225
+ /** The mainline or group a criterion list belongs to, from `m001` or `w001`. */
2226
+ function requireOwner(input) {
2227
+ const ref = parseRef(input);
2228
+ if (ref?.kind !== "mainline" && ref?.kind !== "group") throw new WriteError(`expected a mainline or group reference like ${refExample("mainline")} or ${refExample("group")}, got "${input}"`);
2229
+ return input;
2230
+ }
2231
+ /** The owner and criterion ids of `m001#r001` or `w001#r001`. */
2232
+ function requireCriterionRef(input) {
2233
+ const ref = parseRef(input);
2234
+ if (ref?.kind === "criterion") return {
2235
+ owner: ref.mainline,
2236
+ criterion: ref.criterion
2237
+ };
2238
+ if (ref?.kind === "groupCriterion") return {
2239
+ owner: ref.group,
2240
+ criterion: ref.criterion
2241
+ };
2242
+ throw new WriteError(`expected a criterion reference like ${refExample("criterion")} or ${refExample("groupCriterion")}, got "${input}"`);
2243
+ }
2244
+ /** Edit the criteria of the mainline or group `owner`; a group written before criteria existed gets an empty list first. */
2245
+ function editCriteria(draft, owner, mutate, expectedVersion) {
2246
+ if (parseRef(owner)?.kind === "group") return editFrontMatter(draft, groupFileIn(draft, owner), groupSchema, (g) => mutate(g.criteria ??= []), expectedVersion);
2247
+ return editFrontMatter(draft, mainlineFileIn(draft, owner), mainlineSchema, (m) => mutate(m.criteria), expectedVersion);
2248
+ }
2249
+ /** Add an open criterion at the end of the list of the mainline or group in `mainline`, numbered by the context's allocator. */
2183
2250
  function addCriterion(hub, input, ctx) {
2184
- requireRef(input.mainline, "mainline");
2185
- const fields = newListItem(input, `criterion add ${input.mainline}`);
2251
+ const owner = requireOwner(input.mainline);
2252
+ const fields = newListItem(input, `criterion add ${owner}`);
2186
2253
  const draft = new Draft(hub);
2187
2254
  let ref = "";
2188
- editFrontMatter(draft, mainlineFileIn(draft, input.mainline), mainlineSchema, (m) => {
2255
+ editCriteria(draft, owner, (criteria) => {
2189
2256
  const id = draft.number(ctx, {
2190
2257
  prefix: "r",
2191
- taken: m.criteria.map((c) => c.id),
2192
- ref: (id) => `${input.mainline}#${id}`
2258
+ taken: criteria.map((c) => c.id),
2259
+ ref: (id) => `${owner}#${id}`
2193
2260
  });
2194
- m.criteria.push({
2261
+ criteria.push({
2195
2262
  id,
2196
2263
  ...fields
2197
2264
  });
2198
- ref = `${input.mainline}#${id}`;
2265
+ ref = `${owner}#${id}`;
2199
2266
  });
2200
2267
  return draft.result({ ref });
2201
2268
  }
2202
2269
  function setCriterion(hub, input, ctx) {
2203
- const ref = requireRef(input.ref, "criterion");
2270
+ const { owner, criterion: id } = requireCriterionRef(input.ref);
2204
2271
  if (input.done && !input.evidence) throw new WriteError(`marking ${input.ref} as met needs evidence, e.g. --evidence mainflow@25e4165`);
2205
2272
  const { day } = stamps(ctx);
2206
2273
  const draft = new Draft(hub);
2207
- return draft.result(editFrontMatter(draft, mainlineFileIn(draft, ref.mainline), mainlineSchema, (m) => {
2208
- const criterion = m.criteria.find((c) => c.id === ref.criterion);
2274
+ return draft.result(editCriteria(draft, owner, (criteria) => {
2275
+ const criterion = criteria.find((c) => c.id === id);
2209
2276
  if (!criterion) throw new WriteError(`criterion ${input.ref} not found`);
2210
- setListItem(criterion, input.done, input.evidence, `${input.ref} is dropped; remove dropped and supersededBy in ${MAINLINE_FILE} to bring it back before checking it`, day);
2277
+ setListItem(criterion, input.done, input.evidence, `${input.ref} is dropped; remove dropped and supersededBy in ${parseRef(owner)?.kind === "group" ? "the group file" : MAINLINE_FILE} to bring it back before checking it`, day);
2211
2278
  }, input.expectedVersion));
2212
2279
  }
2213
2280
  /** Rewrite a criterion's text or doneWhen, leaving done and evidence as they are. */
2214
2281
  function setCriterionText(hub, input) {
2215
- const ref = requireRef(input.ref, "criterion");
2282
+ const { owner, criterion: id } = requireCriterionRef(input.ref);
2216
2283
  const { patch } = input;
2217
2284
  if (patch.text === void 0 && patch.doneWhen === void 0) throw new WriteError(`${input.ref} needs --text or --done-when, e.g. --text "CLI can create mainlines"`);
2218
2285
  const draft = new Draft(hub);
2219
- return draft.result(editFrontMatter(draft, mainlineFileIn(draft, ref.mainline), mainlineSchema, (m) => {
2220
- const criterion = m.criteria.find((c) => c.id === ref.criterion);
2286
+ return draft.result(editCriteria(draft, owner, (criteria) => {
2287
+ const criterion = criteria.find((c) => c.id === id);
2221
2288
  if (!criterion) throw new WriteError(`criterion ${input.ref} not found`);
2222
2289
  if (patch.text !== void 0) criterion.text = patch.text;
2223
2290
  if (patch.doneWhen !== void 0) criterion.doneWhen = patch.doneWhen;
@@ -2668,14 +2735,21 @@ function resolveActionIn(hub, ref) {
2668
2735
  }
2669
2736
  /**
2670
2737
  * Every served checklist item or criterion must exist, so a session never points at work that is not in a
2671
- * mainline. A criterion stands in while the mainline has no task for it yet, as when it is still being surveyed
2672
- * and planned. Its mainline must still be open: follow-up work on an ended one either reopens it with a criterion
2738
+ * mainline or a group. A criterion stands in while the mainline has no task for it yet, as when it is still being
2739
+ * surveyed and planned; a group's criterion (w001#r001) stands for work that belongs to the group as a whole. Its
2740
+ * mainline or group must still be open: follow-up work on an ended one either reopens it with a criterion
2673
2741
  * or task for the follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
2674
2742
  */
2675
2743
  function requireServes(hub, serves) {
2676
2744
  for (const ref of serves) {
2677
2745
  const parsed = parseRef(ref);
2678
- if (!SERVES_REF.test(ref) || parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") throw new WriteError(`--serves takes checklist items like m001/t002#c003 or criteria like m001#r001, got "${ref}"`);
2746
+ if (!SERVES_REF.test(ref) || parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion" && parsed?.kind !== "groupCriterion") throw new WriteError(`--serves takes checklist items like m001/t002#c003 or criteria like m001#r001 or w001#r001, got "${ref}"`);
2747
+ if (parsed.kind === "groupCriterion") {
2748
+ const group = readFrontMatterIn(hub, groupFileIn(hub, parsed.group), groupSchema);
2749
+ if (group.status !== "open") throw new WriteError(`${parsed.group} is ${group.status}; to continue it, run "group set ${parsed.group} --status open" and add a criterion for the follow-up`);
2750
+ if (!(group.criteria ?? []).some((r) => r.id === parsed.criterion)) throw new WriteError(`criterion ${ref} not found`);
2751
+ continue;
2752
+ }
2679
2753
  const mainline = readFrontMatterIn(hub, mainlineFileIn(hub, parsed.mainline), mainlineSchema);
2680
2754
  if (CLOSED_STATUS.mainline.includes(mainline.status)) throw new WriteError(`${parsed.mainline} is ${mainline.status}; to continue it, run "mainline set ${parsed.mainline} --status active" and add a criterion or task for the follow-up, or start a new mainline with "new mainline"`);
2681
2755
  if (parsed.kind === "criterion") {
@@ -3231,6 +3305,7 @@ const rewriteOf = ({ from, to }) => {
3231
3305
  case "task":
3232
3306
  case "decision":
3233
3307
  case "criterion": return short((s) => s.mainline === ref.mainline);
3308
+ case "groupCriterion": return short((s) => s.group === ref.group);
3234
3309
  case "checklistItem": return short((s) => s.mainline === ref.mainline && s.task === ref.task);
3235
3310
  default: return {
3236
3311
  full: from,
@@ -3260,6 +3335,10 @@ const ENTRY = /^([mtdifw]\d{3})(-.+)$/;
3260
3335
  function scopeOfPath(path) {
3261
3336
  const [top, second, third, fourth] = path.split("/");
3262
3337
  if (top === "local" && second === "sessions" && third?.endsWith(".yaml") && fourth === void 0) return { session: third.slice(0, -5) };
3338
+ if (top === "groups" && second && third === void 0) {
3339
+ const group = ENTRY.exec(second)?.[1];
3340
+ return group ? { group } : {};
3341
+ }
3263
3342
  if (top !== "mainlines" || !second) return {};
3264
3343
  const mainline = ENTRY.exec(second)?.[1];
3265
3344
  const task = third === "tasks" && fourth ? ENTRY.exec(fourth)?.[1] : void 0;
@@ -3678,6 +3757,8 @@ const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/mainlines/.test(tex
3678
3757
  //#region ../core/dist/grants.js
3679
3758
  /** Mainline statuses whose grants apply. */
3680
3759
  const GRANTING_STATUS = ["active", "planning"];
3760
+ /** Group statuses whose grants apply. */
3761
+ const GRANTING_GROUP_STATUS = ["open"];
3681
3762
  /**
3682
3763
  * The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
3683
3764
  * must fail closed catches it and asks.
@@ -3702,13 +3783,21 @@ function grantScope(root, session) {
3702
3783
  return parsed ? refMainline(parsed) : void 0;
3703
3784
  };
3704
3785
  const mainlines = [...new Set(action.serves.flatMap((ref) => mainlineOf(ref) ?? []))];
3705
- if (mainlines.length === 0) return {
3786
+ const direct = /* @__PURE__ */ new Map();
3787
+ for (const ref of action.serves) {
3788
+ const parsed = parseRef(ref);
3789
+ if (parsed?.kind === "groupCriterion" && !direct.has(parsed.group)) direct.set(parsed.group, ref);
3790
+ }
3791
+ if (mainlines.length === 0 && direct.size === 0) return {
3706
3792
  ok: false,
3707
3793
  reason: `${action.id} 沒有服務任何 checklist 項目或解決條件`
3708
3794
  };
3709
3795
  const grants = [];
3710
3796
  const served = {};
3711
3797
  const closed = [];
3798
+ const hubName = readConfig(root).hub;
3799
+ const groups = new Map(direct);
3800
+ const reachedByMainline = /* @__PURE__ */ new Set();
3712
3801
  for (const id of mainlines) {
3713
3802
  const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
3714
3803
  if (!GRANTING_STATUS.includes(mainline.status)) {
@@ -3717,15 +3806,42 @@ function grantScope(root, session) {
3717
3806
  }
3718
3807
  served[id] = action.serves.find((ref) => mainlineOf(ref) === id);
3719
3808
  for (const grant of mainline.grants ?? []) grants.push({
3720
- mainline: id,
3809
+ owner: id,
3721
3810
  grant,
3722
3811
  ref: `${id}#${grant.id}`
3723
3812
  });
3813
+ const own = mainline.group ? splitGroup(mainline.group, hubName) : void 0;
3814
+ if (own && own.hub === void 0 && !groups.has(own.id)) {
3815
+ groups.set(own.id, served[id]);
3816
+ reachedByMainline.add(own.id);
3817
+ }
3818
+ }
3819
+ for (const [id, via] of groups) {
3820
+ let group;
3821
+ try {
3822
+ group = readFrontMatter(groupFile(root, id), groupSchema).data;
3823
+ } catch (error) {
3824
+ if (reachedByMainline.has(id)) continue;
3825
+ throw error;
3826
+ }
3827
+ if (!GRANTING_GROUP_STATUS.includes(group.status)) {
3828
+ closed.push(`${id} 是 ${group.status}`);
3829
+ continue;
3830
+ }
3831
+ served[id] = via;
3832
+ for (const grant of group.grants ?? []) grants.push({
3833
+ owner: id,
3834
+ grant,
3835
+ ref: `${id}#${grant.id}`
3836
+ });
3837
+ }
3838
+ if (Object.keys(served).length === 0) {
3839
+ const what = [closed.some((c) => c.startsWith("m")) ? "主脈絡" : "", closed.some((c) => c.startsWith("w")) ? "組" : ""].filter(Boolean).join("或");
3840
+ return {
3841
+ ok: false,
3842
+ reason: `${action.id} 服務的${what}已不在進行中(${closed.join("、")}),授權不再生效`
3843
+ };
3724
3844
  }
3725
- if (Object.keys(served).length === 0) return {
3726
- ok: false,
3727
- reason: `${action.id} 服務的主脈絡已不在進行中(${closed.join("、")}),授權不再生效`
3728
- };
3729
3845
  return {
3730
3846
  ok: true,
3731
3847
  scope: {
@@ -3806,8 +3922,18 @@ function setReminderDigest(root, id, digest) {
3806
3922
  //#region ../core/dist/briefing.js
3807
3923
  function describe(root, ref) {
3808
3924
  const parsed = parseRef(ref);
3809
- if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") return null;
3925
+ if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion" && parsed?.kind !== "groupCriterion") return null;
3810
3926
  try {
3927
+ if (parsed.kind === "groupCriterion") {
3928
+ const group = readFrontMatter(groupFile(root, parsed.group), groupSchema).data;
3929
+ const criterion = group.criteria?.find((r) => r.id === parsed.criterion);
3930
+ return criterion ? {
3931
+ ref,
3932
+ text: criterion.text,
3933
+ checked: criterion.done,
3934
+ where: `${group.id} ${group.title} › 解決條件`
3935
+ } : null;
3936
+ }
3811
3937
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
3812
3938
  if (parsed.kind === "criterion") {
3813
3939
  const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
@@ -4010,7 +4136,7 @@ function sessionBriefing(root, session, options) {
4010
4136
  lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
4011
4137
  if (doing) lines.push(`服務:${servedLine(root, doing)}`);
4012
4138
  const grants = grantLines(root, session);
4013
- if (grants.length > 0) lines.push("目前行動服務的主脈絡預先授權了這些動作:", ...grants);
4139
+ if (grants.length > 0) lines.push("目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants);
4014
4140
  if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
4015
4141
  const blocked = session.actions.filter((a) => a.status === "blocked");
4016
4142
  if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
@@ -4052,7 +4178,7 @@ function turnReminder(root, session, options) {
4052
4178
  else setReminderDigest(root, session.id, digest);
4053
4179
  } catch {}
4054
4180
  const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
4055
- const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
4181
+ const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
4056
4182
  const waiting = once(waiters, `- 等待行同上一輪,共 ${waiters.length} 條。`, waiters.map((w) => `- ${w}`));
4057
4183
  const warned = once(warnings, `- 佇列提醒同上一輪,共 ${warnings.length} 條。`, warnings.map((w) => `- ${w}`));
4058
4184
  return [
@@ -8035,6 +8161,77 @@ function migrateHub(root, options = {}) {
8035
8161
  }).value;
8036
8162
  }
8037
8163
  //#endregion
8164
+ //#region ../core/dist/group-plan.js
8165
+ /** Sessions that can still hold work: not archived, and active or paused. */
8166
+ const live = (s) => !s.archivedAt && (s.status === "active" || s.status === "paused");
8167
+ /** Order `refs` so each comes after the ones it depends on; members in a dependency loop keep their given order at the end. */
8168
+ function ordered(refs, dependsOn) {
8169
+ const done = /* @__PURE__ */ new Set();
8170
+ const out = [];
8171
+ for (let progressed = true; progressed && out.length < refs.length;) {
8172
+ progressed = false;
8173
+ for (const ref of refs) {
8174
+ if (done.has(ref) || !(dependsOn.get(ref) ?? []).every((d) => done.has(d))) continue;
8175
+ done.add(ref);
8176
+ out.push(ref);
8177
+ progressed = true;
8178
+ break;
8179
+ }
8180
+ }
8181
+ return [...out, ...refs.filter((r) => !done.has(r))];
8182
+ }
8183
+ function groupPlan(hub, sessions, groupId) {
8184
+ const own = hub.config.hub;
8185
+ const mainlines = hub.mainlines.filter((m) => memberOfGroup(m.group, groupId, own));
8186
+ const issues = hub.issues.filter((i) => memberOfGroup(i.group, groupId, own));
8187
+ const docs = [...mainlines.map((m) => ({
8188
+ ref: m.id,
8189
+ kind: "mainline",
8190
+ title: m.title,
8191
+ status: m.status,
8192
+ open: !CLOSED_STATUS.mainline.includes(m.status),
8193
+ links: m.links
8194
+ })), ...issues.map((i) => ({
8195
+ ref: i.id,
8196
+ kind: "issue",
8197
+ title: i.title,
8198
+ status: i.status,
8199
+ open: i.status !== "closed",
8200
+ links: i.links
8201
+ }))];
8202
+ const refs = docs.map((d) => d.ref);
8203
+ const dependsOn = new Map(docs.map((d) => [d.ref, (d.links ?? []).filter((l) => l.rel === "depends-on").map((l) => parseRef(l.to)?.hub === own ? l.to.slice(l.to.indexOf(":") + 1) : l.to).filter((to) => refs.includes(to) && to !== d.ref)]));
8204
+ const active = (matches) => sessions.filter(live).flatMap((s) => s.actions.flatMap((a) => (a.status === "doing" || a.status === "blocked") && a.serves.some(matches) ? [{
8205
+ session: s.id,
8206
+ action: a.id,
8207
+ status: a.status,
8208
+ do: a.do
8209
+ }] : []));
8210
+ const mainlineOf = (served) => {
8211
+ const parsed = parseRef(served);
8212
+ return parsed ? refMainline(parsed) : void 0;
8213
+ };
8214
+ const byRef = new Map(docs.map((d) => [d.ref, d]));
8215
+ return {
8216
+ members: ordered(refs, dependsOn).map((ref) => {
8217
+ const d = byRef.get(ref);
8218
+ return {
8219
+ ref,
8220
+ kind: d.kind,
8221
+ title: d.title,
8222
+ status: d.status,
8223
+ open: d.open,
8224
+ dependsOn: dependsOn.get(ref) ?? [],
8225
+ active: d.kind === "mainline" ? active((s) => mainlineOf(s) === ref) : []
8226
+ };
8227
+ }),
8228
+ active: active((s) => {
8229
+ const parsed = parseRef(s);
8230
+ return parsed?.kind === "groupCriterion" && parsed.group === groupId;
8231
+ })
8232
+ };
8233
+ }
8234
+ //#endregion
8038
8235
  //#region src/host.ts
8039
8236
  const DEFAULT_UI_PORT = 4320;
8040
8237
  const hostName = (hub) => `${hub}.mainflow.localhost`;
@@ -8233,4 +8430,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
8233
8430
  return true;
8234
8431
  }
8235
8432
  //#endregion
8236
- 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 };
8433
+ export { unavailableHubs as $, headerLine as A, scopeOfPath as B, loadSessions as C, DATA_DIR as Ct, matchGrants as D, grantScope as E, splitFrontMatter as Et, applyOffline as F, loadHub as G, checkHubName as H, renumberRequest as I, mainlineEdges as J, sectionText as K, renumberFiles as L, attributeTrace as M, traceTranscript as N, decorateReply as O, transcriptTitle as P, remoteMembers as Q, renumberPath as R, turnReminder as S, ConflictError as St, transcriptPath as T, contentHash as Tt, HubError as U, applyOperation as V, loadConfig as W, memberOfGroup as X, linksInto as Y, refIndex as Z, setArchivedMany as _, TASK_FILE as _t, openInApp as a, MAX_BLOB_BYTES as at, duplicateClaimLines as b, findHubRoot as bt, siteBlock as c, SOCKET_MAX_AGE_CLOSE as ct, migrateHub as d, readsAsText as dt, nowMinute as et, retroReport as f, requestTime as ft, ARCHIVE_KINDS as g, MAINLINE_FILE as gt, RETRY_RULES as h, hubFormatVersion as ht, hostName as i, BLOB_HASH as it, mayDecorate as j, endsWithMarker as k, syncHost as l, comparePaths as lt, receiverFromEnv as m, formatMismatch as mt, DEFAULT_UI_PORT as n, nodeStorage as nt, removeDanglingLinks as o, MAX_FILE_BYTES as ot, retroReportFromReceiver as p, textBytes as pt, dependencyGraph as q, caddySites as r, ACCESS_HEADERS as rt, removeHostLink as s, ROUTES as st, APP_BUNDLE_ID as t, readYaml as tt, groupPlan as u, exceedsFileLimit as ut, initHub as v, requireSlug as vt, readSession as w, WriteError as wt, sessionBriefing as x, worktreesOf as xt, untilState as y, currentBranch as yt, renumberText 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-aYx3DYrv.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-CIOd2WSh.js";
2
2
  export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeDanglingLinks, removeHostLink, siteBlock, syncHost };
@@ -15,7 +15,10 @@ declare const ID_PATTERNS: {
15
15
  readonly grant: RegExp;
16
16
  };
17
17
  declare const CHECKLIST_REF: RegExp;
18
- /** What an action may serve: a checklist item, or a mainline's criterion while the mainline has no task for it yet. */
18
+ /**
19
+ * What an action may serve: a checklist item, or a mainline's criterion while the mainline has no task for it yet,
20
+ * or a group's criterion (`w017#r001`).
21
+ */
19
22
  declare const SERVES_REF: RegExp;
20
23
  declare const SLUG: RegExp;
21
24
  /** One source the survey checks: the line's label, what it holds, and how it may be left unchecked. */
@@ -459,6 +462,31 @@ declare const groupSchema: z.ZodObject<{
459
462
  P3: "P3";
460
463
  }>>;
461
464
  trigger: z.ZodOptional<z.ZodString>;
465
+ criteria: z.ZodOptional<z.ZodArray<z.ZodObject<{
466
+ text: z.ZodString;
467
+ doneWhen: z.ZodOptional<z.ZodString>;
468
+ done: z.ZodBoolean;
469
+ evidence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
470
+ doneAt: z.ZodOptional<z.ZodNullable<z.ZodString>>;
471
+ dropped: z.ZodOptional<z.ZodBoolean>;
472
+ supersededBy: z.ZodOptional<z.ZodString>;
473
+ note: z.ZodOptional<z.ZodString>;
474
+ id: z.ZodString;
475
+ }, z.core.$strip>>>;
476
+ grants: z.ZodOptional<z.ZodArray<z.ZodObject<{
477
+ id: z.ZodString;
478
+ action: z.ZodEnum<{
479
+ commit: "commit";
480
+ "install-prompts": "install-prompts";
481
+ "merge-pr": "merge-pr";
482
+ publish: "publish";
483
+ push: "push";
484
+ release: "release";
485
+ "sync-hubs": "sync-hubs";
486
+ }>;
487
+ target: z.ZodString;
488
+ note: z.ZodOptional<z.ZodString>;
489
+ }, z.core.$strip>>>;
462
490
  }, z.core.$strip>;
463
491
  type Group = z.infer<typeof groupSchema>;
464
492
  /** One line of ops/{YYYY-MM}.log: an external operation, append-only. */
@@ -1,4 +1,4 @@
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-aYx3DYrv.js";
1
+ import { Ct as DATA_DIR, i as hostName, l as syncHost, n as DEFAULT_UI_PORT, s as removeHostLink, wt as WriteError } from "./host-CIOd2WSh.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-BwAp_sgf.js");
279
+ const { serveUi } = await import("./ui-BhwCZPJR.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-D38sNgXM.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-DJ944-bI.js";
2
2
  export { PORT_SPAN, choosePort, exitCodeFor, launchUi, networkProbe, portCandidates, readUiRecord, realRoot, startUiInBackground, watchHubGone, withUiLock };
@@ -1,4 +1,4 @@
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-aYx3DYrv.js";
1
+ import { B as scopeOfPath, Ct as DATA_DIR, Et as splitFrontMatter, F as applyOffline, I as renumberRequest, L as renumberFiles, R as renumberPath, St as ConflictError, Tt as contentHash, V as applyOperation, at as MAX_BLOB_BYTES, ct as SOCKET_MAX_AGE_CLOSE, dt as readsAsText, et as nowMinute, ft as requestTime, it as BLOB_HASH, lt as comparePaths, nt as nodeStorage, ot as MAX_FILE_BYTES, pt as textBytes, rt as ACCESS_HEADERS, st as ROUTES, ut as exceedsFileLimit, wt as WriteError, z as renumberText } from "./host-CIOd2WSh.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 { N as configSchema, d as GROUP_SECTIONS } from "./dist-CZYL5PJ4.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-aYx3DYrv.js";
1
+ import { P as configSchema } from "./dist-zSabglPq.js";
2
+ import { C as loadSessions, Ct as DATA_DIR, G as loadHub, J as mainlineEdges, K as sectionText, M as attributeTrace, N as traceTranscript, P as transcriptTitle, Q as remoteMembers, St as ConflictError, T as transcriptPath, U as HubError, W as loadConfig, Y as linksInto, Z as refIndex, _ as setArchivedMany, a as openInApp, g as ARCHIVE_KINDS, q as dependencyGraph, tt as readYaml, u as groupPlan, w as readSession, wt as WriteError } from "./host-CIOd2WSh.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-1oQ8lA9C.js";
5
- import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-D38sNgXM.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-DJARDCM0.js";
5
+ import { c as realRoot, f as CLI_VERSION, s as readUiRecord } from "./launch-DJ944-bI.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";
@@ -133,11 +133,12 @@ function summarize(m) {
133
133
  }
134
134
  /** The first paragraph of a group's 說明 on one line, for the heading of its group in a list. */
135
135
  function groupSummary(body) {
136
- return ((sectionText(body, GROUP_SECTIONS[0]) ?? "").split(/\n\s*\n/)[0] ?? "").replace(/\s+/g, " ").trim();
136
+ return ((sectionText(body, "說明") ?? "").split(/\n\s*\n/)[0] ?? "").replace(/\s+/g, " ").trim();
137
137
  }
138
- const groupView = (g) => ({
138
+ const groupView = (hub, g) => ({
139
139
  ...g,
140
- summary: groupSummary(g.body)
140
+ summary: groupSummary(g.body),
141
+ order: groupPlan(hub, [], g.id).members.map((m) => m.ref)
141
142
  });
142
143
  /** The 503 body for a config.yaml that cannot be used, with the hub's root left out of paths. */
143
144
  function configErrorView(root, error) {
@@ -241,7 +242,7 @@ function createApp(root, options = {}) {
241
242
  version: CLI_VERSION,
242
243
  repos: hub.config.repos,
243
244
  mainlines: hub.mainlines.map(summarize),
244
- groups: hub.groups.map(groupView),
245
+ groups: hub.groups.map((g) => groupView(hub, g)),
245
246
  openIssues: hub.issues.filter((i) => i.status !== "closed").length,
246
247
  validFindings: hub.findings.filter((f) => f.status === "valid").length,
247
248
  problems: hub.problems