mainflow 0.37.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.
package/dist/hooks.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as Task, a as Decision, c as Group, d as Mainline, f as OpsEntry, h as Session, i as ChecklistItem, n as ActionBlock, s as Finding, t as Action, u as Issue } from "./index-B32s8PHu.js";
1
+ import { _ as Task, a as Decision, c as Group, d as Mainline, f as OpsEntry, h as Session, i as ChecklistItem, n as ActionBlock, s as Finding, t as Action, u as Issue } from "./index-Dk4fCI7q.js";
2
2
  import { z } from "zod";
3
3
  //#region ../core/dist/ids/allocators.d.ts
4
4
  /** The letters ids start with. */
@@ -123,8 +123,6 @@ interface MainlineInput {
123
123
  title: string;
124
124
  /** A group of this hub the mainline belongs to */
125
125
  group?: string;
126
- /** plan or impl; without one the mainline is an impl one */
127
- role?: string;
128
126
  priority?: string;
129
127
  }
130
128
  declare function createMainline(hub: HubReader, input: MainlineInput, ctx: OpContext): OpResult<{
@@ -137,12 +135,11 @@ interface MainlinePatch {
137
135
  status?: string;
138
136
  /** A group of this hub, or `none` (null) to leave every group */
139
137
  group?: string | null;
140
- role?: string;
141
138
  priority?: string;
142
139
  expectedVersion?: string;
143
140
  }
144
141
  /**
145
- * Change a mainline's status, group, role or priority. Done is refused while criteria are open or decisions are
142
+ * Change a mainline's status, group or priority. Done is refused while criteria are open or decisions are
146
143
  * proposed; the other fields change without conditions, in the same write.
147
144
  */
148
145
  declare function setMainline(hub: HubReader, input: MainlinePatch, ctx: OpContext): OpResult<Mainline>;
@@ -157,7 +154,7 @@ declare function setMainlineArchived(hub: HubReader, input: {
157
154
  archived: boolean;
158
155
  expectedVersion?: string;
159
156
  }, ctx: OpContext): OpResult<Mainline>;
160
- /** Add an open criterion at the end of the mainline's list, numbered by the context's allocator. */
157
+ /** Add an open criterion at the end of the list of the mainline or group in `mainline`, numbered by the context's allocator. */
161
158
  declare function addCriterion(hub: HubReader, input: ListItemInput & {
162
159
  mainline: string;
163
160
  }, ctx: OpContext): OpResult<{
@@ -168,7 +165,7 @@ declare function setCriterion(hub: HubReader, input: {
168
165
  done: boolean;
169
166
  evidence?: string;
170
167
  expectedVersion?: string;
171
- }, ctx: OpContext): OpResult<Mainline>;
168
+ }, ctx: OpContext): OpResult<Mainline | Group>;
172
169
  /** What `criterion set` rewrites; at least one must be given. `done` stays as it is; use criterion check/uncheck for that. */
173
170
  interface CriterionTextPatch {
174
171
  text?: string;
@@ -179,7 +176,7 @@ declare function setCriterionText(hub: HubReader, input: {
179
176
  ref: string;
180
177
  patch: CriterionTextPatch;
181
178
  expectedVersion?: string;
182
- }): OpResult<Mainline>;
179
+ }): OpResult<Mainline | Group>;
183
180
  //#endregion
184
181
  //#region ../core/dist/ops/tasks.d.ts
185
182
  interface TaskInput {
package/dist/hooks.js CHANGED
@@ -1,6 +1,6 @@
1
- import { F as configSchema, it as describeRequest } from "./dist-ud09dzZo.js";
2
- import { A as mayDecorate, C as readSession, D as decorateReply, E as matchGrants, H as HubError, O as endsWithMarker, S as loadSessions, St as DATA_DIR, T as grantScope, W as loadHub, b as sessionBriefing, bt as worktreesOf, et as readYaml, i as hostName, k as headerLine, mt as hubFormatVersion, pt as formatMismatch, tt as nodeStorage, vt as currentBranch, x as turnReminder, yt as findHubRoot } from "./host-lY8ytTbw.js";
3
- import { n as hubWriter, r as writtenLines } from "./operation-BZ7CHB6J.js";
1
+ import { P as configSchema, rt as describeRequest } from "./dist-zSabglPq.js";
2
+ import { A as headerLine, C as loadSessions, Ct as DATA_DIR, D as matchGrants, E as grantScope, G as loadHub, O as decorateReply, S as turnReminder, U as HubError, bt as findHubRoot, ht as hubFormatVersion, i as hostName, j as mayDecorate, k as endsWithMarker, mt as formatMismatch, nt as nodeStorage, tt as readYaml, w as readSession, x as sessionBriefing, xt as worktreesOf, yt as currentBranch } from "./host-CIOd2WSh.js";
3
+ import { n as hubWriter, r as writtenLines } from "./operation-DJARDCM0.js";
4
4
  import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
5
5
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
6
6
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -301,7 +301,7 @@ function grantableCall(payload) {
301
301
  }
302
302
  /** Outside a hub, or when deciding failed: ask for a grantable call, stay silent for any other. */
303
303
  function askWhenGrantable({ payload }, failure) {
304
- const reason = failure.kind === "outside-hub" ? "不在 mainflow hub 裡,沒有主脈絡授權可用" : failure.kind === "error" ? `判斷授權時出錯:${failure.message}` : "hook 讀不到 PreToolUse 的輸入";
304
+ const reason = failure.kind === "outside-hub" ? "不在 mainflow hub 裡,沒有授權可用" : failure.kind === "error" ? `判斷授權時出錯:${failure.message}` : "hook 讀不到 PreToolUse 的輸入";
305
305
  if (failure.kind === "unreadable-input") return askBecause(reason);
306
306
  try {
307
307
  return grantableCall(payload) ? askBecause(reason) : null;
@@ -318,7 +318,7 @@ async function logGranted(write, scope, call, request, grant, warn) {
318
318
  target: request.action === "publish" ? `npm ${request.name}` : `github release ${request.repos.join("/")}`,
319
319
  operation: opsText(call.text),
320
320
  result: `授權放行 ${grant.ref}(${describeRequest(request)})`,
321
- ref: scope.served[grant.mainline]
321
+ ref: scope.served[grant.owner]
322
322
  });
323
323
  } catch (error) {
324
324
  warn?.(`mainflow hook claude-code pre-tool-use: allowed by ${grant.ref} but could not write the ops log: ${error.message}`);
@@ -353,7 +353,7 @@ async function decideByGrants(root, { payload, warn, write }) {
353
353
  if (!match.ok) return askBecause(match.reason);
354
354
  const { grant } = match.matches[0];
355
355
  await logGranted(write, scoped.scope, scan.call, resolved.request, grant, warn);
356
- return permission("allow", `主脈絡授權 ${grant.ref} 涵蓋 ${describeRequest(resolved.request)}`);
356
+ return permission("allow", `授權 ${grant.ref} 涵蓋 ${describeRequest(resolved.request)}`);
357
357
  }
358
358
  /** How many problems outside the written file the reminder lists before it only gives the count. */
359
359
  const OTHER_PROBLEMS_SHOWN = 5;
@@ -1,4 +1,4 @@
1
- import { $ as OFFSET, A as SURVEY_SECTION, B as issueSchema, C as REF_EXAMPLES, D as SESSION_STATUS, E as SESSION_ID, F as configSchema, G as parseRef, H as opsEntrySchema, I as decisionSchema, J as refMainline, K as parseUntil, L as findingSchema, M as TASK_SECTIONS, N as UNTIL_FORMS, O as SLUG, P as WORK_STATUS, Q as waitingForRef, R as formatOpsEntry, S as PRIORITIES, T as SERVES_REF, U as parseActionRef, V as mainlineSchema, W as parseOpsLine, X as taskSchema, Y as sessionSchema, Z as waitingForAction, a as DECISION_SECTIONS, at as grantCovers, b as MAINLINE_STATUS, ct as parseGrantTarget, et as stampMs, f as GROUP_SECTIONS, h as ISSUE_SECTIONS, i as DECISION_BASES, it as describeRequest, j as TASK_REQUIRED_SECTIONS, k as SURVEY_LINES, l as FINDING_SECTIONS, n as CLOSED_STATUS, ot as grantForm, p as GROUP_STATUS, q as refExample, r as DECIDERS, s as DEFAULT_CATEGORIES, st as grantRepo, u as FINDING_STATUS, v as MAINLINE_ROLES, w as RESERVED_HUB_NAMES, x as MERGE_STATES, y as MAINLINE_SECTIONS, z as groupSchema } from "./dist-ud09dzZo.js";
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"));
@@ -880,7 +893,6 @@ function remoteMembers(remotes, hubName) {
880
893
  kind: "mainline",
881
894
  title: m.title,
882
895
  status: m.status,
883
- role: m.role ?? "impl",
884
896
  archived: Boolean(m.archivedAt),
885
897
  open: !CLOSED_STATUS.mainline.includes(m.status)
886
898
  });
@@ -1359,6 +1371,7 @@ function loadHub(root, options = {}) {
1359
1371
  const findings = hubDocs(join(dataDir, "findings"), findingSchema);
1360
1372
  const groups = hubDocs(join(dataDir, "groups"), groupSchema);
1361
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"));
1362
1375
  const ops = [];
1363
1376
  const opsPath = join(dataDir, "ops");
1364
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) => {
@@ -1601,7 +1614,10 @@ function checkReferences(input) {
1601
1614
  }
1602
1615
  for (const i of issues) add(i.file, i, i.id);
1603
1616
  for (const f of findings) add(f.file, f, f.id);
1604
- 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
+ }
1605
1621
  for (const p of pending) {
1606
1622
  const parsed = parseRef(p.ref);
1607
1623
  if (!parsed) continue;
@@ -1658,7 +1674,7 @@ function repoPathProblems(root, config) {
1658
1674
  });
1659
1675
  }
1660
1676
  /**
1661
- * 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,
1662
1678
  * and a target that names a repository must name one listed in config.yaml.
1663
1679
  */
1664
1680
  function grantProblems(mainline, config) {
@@ -1689,24 +1705,28 @@ const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
1689
1705
  * entries are skipped, because their evidence already says what was done. Ids must also be unique within
1690
1706
  * a list, since the files are edited directly and nothing else allocates them.
1691
1707
  */
1692
- 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) {
1693
1710
  const gaps = [];
1694
- const list = (file, field, items, open) => {
1695
- const seen = /* @__PURE__ */ new Set();
1696
- items.forEach((item, i) => {
1697
- if (seen.has(item.id)) gaps.push({
1698
- file,
1699
- field: `${field}.${i}.id`,
1700
- message: `${item.id} is used twice; give it the next free id`
1701
- });
1702
- seen.add(item.id);
1703
- if (open && !item.done && !item.dropped && !item.doneWhen?.trim()) gaps.push({
1704
- file,
1705
- field: `${field}.${i}.doneWhen`,
1706
- message: `${item.id} is open and needs doneWhen: what has to be true to check it`
1707
- });
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`
1708
1717
  });
1709
- };
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));
1710
1730
  const mainlineOpen = isOpen$2("mainline", mainline.status);
1711
1731
  list(mainline.file, "criteria", mainline.criteria, mainlineOpen);
1712
1732
  if (mainlineOpen && tasks.length > 0) for (const message of surveyGaps(mainline.body)) gaps.push({
@@ -1847,6 +1867,7 @@ function requireExisting(hub, ref) {
1847
1867
  entryFile(hub, FINDINGS_DIR, parsed.finding, "finding");
1848
1868
  return;
1849
1869
  case "group":
1870
+ case "groupCriterion":
1850
1871
  entryFile(hub, GROUPS_DIR, parsed.group, "group");
1851
1872
  return;
1852
1873
  default:
@@ -2030,6 +2051,26 @@ function createGroup(hub, input, ctx) {
2030
2051
  file
2031
2052
  });
2032
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
+ }
2033
2074
  function setGroup(hub, input, ctx) {
2034
2075
  const { patch } = input;
2035
2076
  requireRef(input.id, "group");
@@ -2042,6 +2083,7 @@ function setGroup(hub, input, ctx) {
2042
2083
  const draft = new Draft(hub);
2043
2084
  return draft.result(editFrontMatter(draft, entryFile(draft, GROUPS_DIR, input.id, "group"), groupSchema, (g) => {
2044
2085
  if (status) {
2086
+ if (status === "done" && g.status !== "done") requireGroupDone(draft, input.id, g);
2045
2087
  g.status = status;
2046
2088
  stampClosed(g, closes("group", status), day);
2047
2089
  }
@@ -2078,7 +2120,6 @@ function listFilesIn(hub, dir) {
2078
2120
  function createMainline(hub, input, ctx) {
2079
2121
  requireSlug(input.slug);
2080
2122
  const group = input.group ? requireGroup(hub, input.group) : void 0;
2081
- const role = input.role ? requireOneOf(input.role, MAINLINE_ROLES, "mainline role") : void 0;
2082
2123
  const priority = input.priority ? requirePriority(input.priority) : void 0;
2083
2124
  const { day } = stamps(ctx);
2084
2125
  const draft = new Draft(hub);
@@ -2095,7 +2136,6 @@ function createMainline(hub, input, ctx) {
2095
2136
  ...priority ? { priority } : {},
2096
2137
  created: day,
2097
2138
  ...group ? { group } : {},
2098
- ...role ? { role } : {},
2099
2139
  criteria: []
2100
2140
  }, MAINLINE_FILE);
2101
2141
  draft.makeDir(`${dir}/tasks`);
@@ -2137,7 +2177,7 @@ function activatePlanning(draft, id) {
2137
2177
  editFrontMatter(draft, path, mainlineSchema, (m) => void (m.status = "active"));
2138
2178
  }
2139
2179
  /**
2140
- * Change a mainline's status, group, role or priority. Done is refused while criteria are open or decisions are
2180
+ * Change a mainline's status, group or priority. Done is refused while criteria are open or decisions are
2141
2181
  * proposed; the other fields change without conditions, in the same write.
2142
2182
  */
2143
2183
  function setMainline(hub, input, ctx) {
@@ -2145,11 +2185,9 @@ function setMainline(hub, input, ctx) {
2145
2185
  if ([
2146
2186
  input.status,
2147
2187
  input.group,
2148
- input.role,
2149
2188
  input.priority
2150
- ].every((v) => v === void 0)) throw new WriteError(`${input.id} needs --status, --group, --role or --priority, e.g. --status paused`);
2189
+ ].every((v) => v === void 0)) throw new WriteError(`${input.id} needs --status, --group or --priority, e.g. --status paused`);
2151
2190
  const next = input.status === void 0 ? void 0 : requireOneOf(input.status, MAINLINE_STATUS, "mainline status");
2152
- const role = input.role === void 0 ? void 0 : requireOneOf(input.role, MAINLINE_ROLES, "mainline role");
2153
2191
  const priority = input.priority === void 0 ? void 0 : requirePriority(input.priority);
2154
2192
  const { day } = stamps(ctx);
2155
2193
  const draft = new Draft(hub);
@@ -2162,7 +2200,6 @@ function setMainline(hub, input, ctx) {
2162
2200
  }
2163
2201
  if (group) m.group = group;
2164
2202
  else if (clearsGroup(input.group)) delete m.group;
2165
- if (role) m.role = role;
2166
2203
  if (priority) m.priority = priority;
2167
2204
  }, input.expectedVersion));
2168
2205
  }
@@ -2185,45 +2222,69 @@ function setMainlineArchived(hub, input, ctx) {
2185
2222
  else delete m.archivedAt;
2186
2223
  }, input.expectedVersion));
2187
2224
  }
2188
- /** 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. */
2189
2250
  function addCriterion(hub, input, ctx) {
2190
- requireRef(input.mainline, "mainline");
2191
- const fields = newListItem(input, `criterion add ${input.mainline}`);
2251
+ const owner = requireOwner(input.mainline);
2252
+ const fields = newListItem(input, `criterion add ${owner}`);
2192
2253
  const draft = new Draft(hub);
2193
2254
  let ref = "";
2194
- editFrontMatter(draft, mainlineFileIn(draft, input.mainline), mainlineSchema, (m) => {
2255
+ editCriteria(draft, owner, (criteria) => {
2195
2256
  const id = draft.number(ctx, {
2196
2257
  prefix: "r",
2197
- taken: m.criteria.map((c) => c.id),
2198
- ref: (id) => `${input.mainline}#${id}`
2258
+ taken: criteria.map((c) => c.id),
2259
+ ref: (id) => `${owner}#${id}`
2199
2260
  });
2200
- m.criteria.push({
2261
+ criteria.push({
2201
2262
  id,
2202
2263
  ...fields
2203
2264
  });
2204
- ref = `${input.mainline}#${id}`;
2265
+ ref = `${owner}#${id}`;
2205
2266
  });
2206
2267
  return draft.result({ ref });
2207
2268
  }
2208
2269
  function setCriterion(hub, input, ctx) {
2209
- const ref = requireRef(input.ref, "criterion");
2270
+ const { owner, criterion: id } = requireCriterionRef(input.ref);
2210
2271
  if (input.done && !input.evidence) throw new WriteError(`marking ${input.ref} as met needs evidence, e.g. --evidence mainflow@25e4165`);
2211
2272
  const { day } = stamps(ctx);
2212
2273
  const draft = new Draft(hub);
2213
- return draft.result(editFrontMatter(draft, mainlineFileIn(draft, ref.mainline), mainlineSchema, (m) => {
2214
- 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);
2215
2276
  if (!criterion) throw new WriteError(`criterion ${input.ref} not found`);
2216
- 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);
2217
2278
  }, input.expectedVersion));
2218
2279
  }
2219
2280
  /** Rewrite a criterion's text or doneWhen, leaving done and evidence as they are. */
2220
2281
  function setCriterionText(hub, input) {
2221
- const ref = requireRef(input.ref, "criterion");
2282
+ const { owner, criterion: id } = requireCriterionRef(input.ref);
2222
2283
  const { patch } = input;
2223
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"`);
2224
2285
  const draft = new Draft(hub);
2225
- return draft.result(editFrontMatter(draft, mainlineFileIn(draft, ref.mainline), mainlineSchema, (m) => {
2226
- 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);
2227
2288
  if (!criterion) throw new WriteError(`criterion ${input.ref} not found`);
2228
2289
  if (patch.text !== void 0) criterion.text = patch.text;
2229
2290
  if (patch.doneWhen !== void 0) criterion.doneWhen = patch.doneWhen;
@@ -2674,14 +2735,21 @@ function resolveActionIn(hub, ref) {
2674
2735
  }
2675
2736
  /**
2676
2737
  * Every served checklist item or criterion must exist, so a session never points at work that is not in a
2677
- * mainline. A criterion stands in while the mainline has no task for it yet, as when it is still being surveyed
2678
- * 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
2679
2741
  * or task for the follow-up, or starts a new mainline, so the follow-up is tracked instead of hanging off finished work.
2680
2742
  */
2681
2743
  function requireServes(hub, serves) {
2682
2744
  for (const ref of serves) {
2683
2745
  const parsed = parseRef(ref);
2684
- 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
+ }
2685
2753
  const mainline = readFrontMatterIn(hub, mainlineFileIn(hub, parsed.mainline), mainlineSchema);
2686
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"`);
2687
2755
  if (parsed.kind === "criterion") {
@@ -3237,6 +3305,7 @@ const rewriteOf = ({ from, to }) => {
3237
3305
  case "task":
3238
3306
  case "decision":
3239
3307
  case "criterion": return short((s) => s.mainline === ref.mainline);
3308
+ case "groupCriterion": return short((s) => s.group === ref.group);
3240
3309
  case "checklistItem": return short((s) => s.mainline === ref.mainline && s.task === ref.task);
3241
3310
  default: return {
3242
3311
  full: from,
@@ -3266,6 +3335,10 @@ const ENTRY = /^([mtdifw]\d{3})(-.+)$/;
3266
3335
  function scopeOfPath(path) {
3267
3336
  const [top, second, third, fourth] = path.split("/");
3268
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
+ }
3269
3342
  if (top !== "mainlines" || !second) return {};
3270
3343
  const mainline = ENTRY.exec(second)?.[1];
3271
3344
  const task = third === "tasks" && fourth ? ENTRY.exec(fourth)?.[1] : void 0;
@@ -3684,6 +3757,8 @@ const mayDecorate = (text) => /m\d{3}|[ifw]\d{3}|\.mainflow\/mainlines/.test(tex
3684
3757
  //#region ../core/dist/grants.js
3685
3758
  /** Mainline statuses whose grants apply. */
3686
3759
  const GRANTING_STATUS = ["active", "planning"];
3760
+ /** Group statuses whose grants apply. */
3761
+ const GRANTING_GROUP_STATUS = ["open"];
3687
3762
  /**
3688
3763
  * The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
3689
3764
  * must fail closed catches it and asks.
@@ -3708,13 +3783,21 @@ function grantScope(root, session) {
3708
3783
  return parsed ? refMainline(parsed) : void 0;
3709
3784
  };
3710
3785
  const mainlines = [...new Set(action.serves.flatMap((ref) => mainlineOf(ref) ?? []))];
3711
- 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 {
3712
3792
  ok: false,
3713
3793
  reason: `${action.id} 沒有服務任何 checklist 項目或解決條件`
3714
3794
  };
3715
3795
  const grants = [];
3716
3796
  const served = {};
3717
3797
  const closed = [];
3798
+ const hubName = readConfig(root).hub;
3799
+ const groups = new Map(direct);
3800
+ const reachedByMainline = /* @__PURE__ */ new Set();
3718
3801
  for (const id of mainlines) {
3719
3802
  const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
3720
3803
  if (!GRANTING_STATUS.includes(mainline.status)) {
@@ -3723,15 +3806,42 @@ function grantScope(root, session) {
3723
3806
  }
3724
3807
  served[id] = action.serves.find((ref) => mainlineOf(ref) === id);
3725
3808
  for (const grant of mainline.grants ?? []) grants.push({
3726
- mainline: id,
3809
+ owner: id,
3727
3810
  grant,
3728
3811
  ref: `${id}#${grant.id}`
3729
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
+ };
3730
3844
  }
3731
- if (Object.keys(served).length === 0) return {
3732
- ok: false,
3733
- reason: `${action.id} 服務的主脈絡已不在進行中(${closed.join("、")}),授權不再生效`
3734
- };
3735
3845
  return {
3736
3846
  ok: true,
3737
3847
  scope: {
@@ -3812,8 +3922,18 @@ function setReminderDigest(root, id, digest) {
3812
3922
  //#region ../core/dist/briefing.js
3813
3923
  function describe(root, ref) {
3814
3924
  const parsed = parseRef(ref);
3815
- if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion") return null;
3925
+ if (parsed?.kind !== "checklistItem" && parsed?.kind !== "criterion" && parsed?.kind !== "groupCriterion") return null;
3816
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
+ }
3817
3937
  const mainline = readFrontMatter(mainlineFile(root, parsed.mainline), mainlineSchema).data;
3818
3938
  if (parsed.kind === "criterion") {
3819
3939
  const criterion = mainline.criteria.find((r) => r.id === parsed.criterion);
@@ -4016,7 +4136,7 @@ function sessionBriefing(root, session, options) {
4016
4136
  lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
4017
4137
  if (doing) lines.push(`服務:${servedLine(root, doing)}`);
4018
4138
  const grants = grantLines(root, session);
4019
- if (grants.length > 0) lines.push("目前行動服務的主脈絡預先授權了這些動作:", ...grants);
4139
+ if (grants.length > 0) lines.push("目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants);
4020
4140
  if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
4021
4141
  const blocked = session.actions.filter((a) => a.status === "blocked");
4022
4142
  if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
@@ -4058,7 +4178,7 @@ function turnReminder(root, session, options) {
4058
4178
  else setReminderDigest(root, session.id, digest);
4059
4179
  } catch {}
4060
4180
  const once = (lines, summary, full) => lines.length === 0 ? [] : repeated ? [summary] : full;
4061
- const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
4181
+ const granted = once(grants, `- 預先授權同上一輪,共 ${grants.length} 條。`, ["- 目前行動服務的主脈絡與所屬的組預先授權了這些動作:", ...grants.map((g) => ` ${g}`)]);
4062
4182
  const waiting = once(waiters, `- 等待行同上一輪,共 ${waiters.length} 條。`, waiters.map((w) => `- ${w}`));
4063
4183
  const warned = once(warnings, `- 佇列提醒同上一輪,共 ${warnings.length} 條。`, warnings.map((w) => `- ${w}`));
4064
4184
  return [
@@ -8041,6 +8161,77 @@ function migrateHub(root, options = {}) {
8041
8161
  }).value;
8042
8162
  }
8043
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
8044
8235
  //#region src/host.ts
8045
8236
  const DEFAULT_UI_PORT = 4320;
8046
8237
  const hostName = (hub) => `${hub}.mainflow.localhost`;
@@ -8239,4 +8430,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
8239
8430
  return true;
8240
8431
  }
8241
8432
  //#endregion
8242
- export { nowMinute as $, mayDecorate as A, applyOperation as B, readSession as C, WriteError as Ct, decorateReply as D, matchGrants as E, renumberRequest as F, sectionText as G, HubError as H, renumberFiles as I, linksInto as J, dependencyGraph as K, renumberPath as L, traceTranscript as M, transcriptTitle as N, endsWithMarker as O, applyOffline as P, unavailableHubs as Q, renumberText as R, loadSessions as S, DATA_DIR as St, grantScope as T, splitFrontMatter as Tt, loadConfig as U, checkHubName as V, loadHub as W, refIndex as X, memberOfGroup as Y, remoteMembers as Z, initHub as _, requireSlug as _t, openInApp as a, MAX_FILE_BYTES as at, sessionBriefing as b, worktreesOf as bt, siteBlock as c, comparePaths as ct, retroReport as d, requestTime as dt, readYaml as et, retroReportFromReceiver as f, textBytes as ft, setArchivedMany as g, TASK_FILE as gt, ARCHIVE_KINDS as h, MAINLINE_FILE as ht, hostName as i, MAX_BLOB_BYTES as it, attributeTrace as j, headerLine as k, syncHost as l, exceedsFileLimit as lt, RETRY_RULES as m, hubFormatVersion as mt, DEFAULT_UI_PORT as n, ACCESS_HEADERS as nt, removeDanglingLinks as o, ROUTES as ot, receiverFromEnv as p, formatMismatch as pt, mainlineEdges as q, caddySites as r, BLOB_HASH as rt, removeHostLink as s, SOCKET_MAX_AGE_CLOSE as st, APP_BUNDLE_ID as t, nodeStorage as tt, migrateHub as u, readsAsText as ut, untilState as v, currentBranch as vt, transcriptPath as w, contentHash as wt, turnReminder as x, ConflictError as xt, duplicateClaimLines as y, findHubRoot as yt, scopeOfPath as z };
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-lY8ytTbw.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 };