mainflow 0.21.0 → 0.23.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.js CHANGED
@@ -1,4 +1,6 @@
1
- import { G as mayDecorate, H as decorateReply, I as setSessionInfo, L as setSessionStatus, O as sessionBriefing, P as readSession, R as settleSession, U as endsWithMarker, W as headerLine, dt as DATA_DIR, ft as currentBranch, i as hostName, j as ensureSession, k as turnReminder, mt as worktreesOf, nt as loadHub, pt as findHubRoot, tt as HubError } from "./host-BsLqiI0_.js";
1
+ import { G as endsWithMarker, H as grantScope, I as setSessionInfo, J as appendOps, K as headerLine, L as setSessionStatus, O as sessionBriefing, P as readSession, R as settleSession, U as matchGrants, W as decorateReply, _t as worktreesOf, gt as findHubRoot, ht as currentBranch, i as hostName, it as loadHub, j as ensureSession, k as turnReminder, mt as DATA_DIR, pt as readYaml, q as mayDecorate, rt as HubError } from "./host-DkaCYnnc.js";
2
+ import { G as describeRequest, O as configSchema } from "./dist-Dy45myuN.js";
3
+ import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
2
4
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
3
5
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
6
  //#region src/hooks.ts
@@ -20,6 +22,46 @@ const brief = (text) => {
20
22
  const flat = (text ?? "").replace(/\s+/g, " ").trim();
21
23
  return flat.length > SUMMARY_LIMIT ? `${flat.slice(0, 279)}…` : flat;
22
24
  };
25
+ /**
26
+ * Read a hook's stdin: one JSON object, as Claude Code sends it. Anything else is null, including arrays,
27
+ * scalars, empty input, and an object with a key written twice, since which value the host acted on is unknown.
28
+ */
29
+ function parsePayload(text) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(text);
33
+ } catch {
34
+ return null;
35
+ }
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || hasDuplicateKeys(text)) return null;
37
+ return parsed;
38
+ }
39
+ /** Whether any object in valid JSON text names a key twice; JSON.parse keeps only the last one. */
40
+ function hasDuplicateKeys(text) {
41
+ const stack = [];
42
+ for (let i = 0; i < text.length; i++) {
43
+ const c = text[i];
44
+ const top = stack.at(-1);
45
+ if (c === "{") stack.push({
46
+ keys: /* @__PURE__ */ new Set(),
47
+ expectKey: true
48
+ });
49
+ else if (c === "[") stack.push(null);
50
+ else if (c === "}" || c === "]") stack.pop();
51
+ else if (c === "," && top) top.expectKey = true;
52
+ else if (c === "\"") {
53
+ const start = i;
54
+ for (i++; text[i] !== "\""; i++) if (text[i] === "\\") i++;
55
+ if (top?.expectKey) {
56
+ const key = JSON.parse(text.slice(start, i + 1));
57
+ if (top.keys.has(key)) return true;
58
+ top.keys.add(key);
59
+ top.expectKey = false;
60
+ }
61
+ }
62
+ }
63
+ return false;
64
+ }
23
65
  const shortId = (raw) => raw.replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8);
24
66
  const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
25
67
  /**
@@ -158,9 +200,92 @@ const CLAUDE_CODE_EVENTS = {
158
200
  setSessionStatus(root, id, "ended");
159
201
  return null;
160
202
  },
203
+ "pre-tool-use": decideByGrants,
161
204
  "post-tool-use": checkAfterWrite,
162
205
  "message-display": displayReply
163
206
  };
207
+ /**
208
+ * Events that must not stay silent when they cannot decide, because silence would let through what they
209
+ * guard; each says what to print instead. Every other event stays silent on failure.
210
+ */
211
+ const CLAUDE_CODE_FALLBACKS = { "pre-tool-use": askWhenGrantable };
212
+ /** The PreToolUse answer; `ask` hands the call to the usual permission prompt, with the reason shown. */
213
+ const permission = (decision, reason) => JSON.stringify({ hookSpecificOutput: {
214
+ hookEventName: "PreToolUse",
215
+ permissionDecision: decision,
216
+ permissionDecisionReason: `[mainflow] ${reason}`
217
+ } });
218
+ const askBecause = (reason) => permission("ask", `${reason},所以照常詢問`);
219
+ /**
220
+ * Scan a Bash call; null when it mentions no grantable command, so the hook stays silent. A Bash call whose
221
+ * command is not a string cannot be judged, so it is refused.
222
+ */
223
+ function grantableCall(payload) {
224
+ if (typeof payload.tool_name !== "string" || payload.tool_name.toLowerCase() !== "bash") return null;
225
+ const command = payload.tool_input?.command;
226
+ if (typeof command !== "string") return {
227
+ kind: "refused",
228
+ reason: "tool_input.command 不是字串"
229
+ };
230
+ const scan = scanCommand(command);
231
+ return scan.kind === "none" ? null : scan;
232
+ }
233
+ /** Outside a hub, or when deciding failed: ask for a grantable call, stay silent for any other. */
234
+ function askWhenGrantable({ payload }, failure) {
235
+ const reason = failure.kind === "outside-hub" ? "不在 mainflow hub 裡,沒有主脈絡授權可用" : failure.kind === "error" ? `判斷授權時出錯:${failure.message}` : "hook 讀不到 PreToolUse 的輸入";
236
+ if (failure.kind === "unreadable-input") return askBecause(reason);
237
+ try {
238
+ return grantableCall(payload) ? askBecause(reason) : null;
239
+ } catch {
240
+ return askBecause(reason);
241
+ }
242
+ }
243
+ /** The ops log's fields cannot hold its separator, so notes that contain one are written with a broken bar. */
244
+ const opsText = (text) => text.replace(/\s\|\s/g, " ¦ ").replace(/\s+/g, " ").slice(0, 200);
245
+ /** Log the allowed operation with the grant that covered it; a failure only warns, the call stays allowed. */
246
+ function logGranted(root, scope, call, request, grant, warn) {
247
+ try {
248
+ appendOps(root, {
249
+ target: request.action === "publish" ? `npm ${request.name}` : `github release ${request.repos.join("/")}`,
250
+ operation: opsText(call.text),
251
+ result: `授權放行 ${grant.ref}(${describeRequest(request)})`,
252
+ ref: scope.served[grant.mainline]
253
+ });
254
+ } catch (error) {
255
+ warn?.(`mainflow hook claude-code pre-tool-use: allowed by ${grant.ref} but could not write the ops log: ${error.message}`);
256
+ }
257
+ }
258
+ /**
259
+ * PreToolUse: allow a publish or release that a grant covers, ask for every other one. `allow` lets the whole
260
+ * call run, so only the two fixed forms in grant-commands.ts can be allowed. A grant counts only when the
261
+ * session's one action in progress serves its mainline and the mainline is open; the package comes from
262
+ * package.json in the payload's cwd and the repository from config.yaml. Calls that mention no grantable command
263
+ * print nothing, so other hooks and the usual permission rules decide them. Any failure asks: the caller turns a
264
+ * throw into the fallback above.
265
+ */
266
+ function decideByGrants(root, { payload, warn }) {
267
+ const scan = grantableCall(payload);
268
+ if (!scan) return null;
269
+ if (scan.kind === "refused") return askBecause(scan.reason);
270
+ const sessionId = payload.session_id;
271
+ if (typeof sessionId !== "string" || !sessionId) return askBecause("payload 沒有字串的 session_id");
272
+ const scoped = grantScope(root, readSession(root, claudeCodeSessionId(sessionId)));
273
+ if (!scoped.ok) return askBecause(scoped.reason);
274
+ const config = readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
275
+ const place = scopedCwd(payload.cwd, root, config.repos);
276
+ if (!place.ok) return askBecause(place.reason);
277
+ const resolved = resolveFixed(scan.call, {
278
+ cwd: place.dir,
279
+ root,
280
+ repos: config.repos
281
+ });
282
+ if (!resolved.ok) return askBecause(resolved.reason);
283
+ const match = matchGrants(scoped.scope, [resolved.request]);
284
+ if (!match.ok) return askBecause(match.reason);
285
+ const { grant } = match.matches[0];
286
+ logGranted(root, scoped.scope, scan.call, resolved.request, grant, warn);
287
+ return permission("allow", `主脈絡授權 ${grant.ref} 涵蓋 ${describeRequest(resolved.request)}`);
288
+ }
164
289
  /** How many problems outside the written file the reminder lists before it only gives the count. */
165
290
  const OTHER_PROBLEMS_SHOWN = 5;
166
291
  const problemLine = (p) => `- ${p.file}${p.field ? ` › ${p.field}` : ""}:${p.message}`;
@@ -222,9 +347,29 @@ function checkAfterWrite(root, { payload, cli }) {
222
347
  } });
223
348
  }
224
349
  const HOOK_PLATFORMS = { "claude-code": CLAUDE_CODE_EVENTS };
350
+ const HOOK_FALLBACKS = { "claude-code": CLAUDE_CODE_FALLBACKS };
351
+ /**
352
+ * Events that find the hub from $CLAUDE_PROJECT_DIR before the payload's cwd. A publish runs in the package's own
353
+ * repository, which config.yaml may list outside the hub directory (../mainflow), so its cwd leads to no hub.
354
+ */
355
+ const PROJECT_HUB_EVENTS = /* @__PURE__ */ new Set(["pre-tool-use"]);
356
+ function hubRootFor(event, ctx) {
357
+ return (PROJECT_HUB_EVENTS.has(event) && ctx.projectDir && isAbsolute(ctx.projectDir) ? findHubRoot(ctx.projectDir) : null) ?? findHubRoot(typeof ctx.payload.cwd === "string" ? ctx.payload.cwd : process.cwd());
358
+ }
359
+ /** What an event prints when it cannot run its handler; null for the events that stay silent. */
360
+ function hookFallback(platform, event, ctx, failure) {
361
+ const fallback = HOOK_FALLBACKS[platform]?.[event];
362
+ if (!fallback) return null;
363
+ try {
364
+ return fallback(ctx, failure);
365
+ } catch {
366
+ return null;
367
+ }
368
+ }
225
369
  /**
226
370
  * Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
227
- * failure is reported on stderr while the caller still exits 0.
371
+ * failure is reported on stderr while the caller still exits 0. Events listed in the fallbacks print their
372
+ * fallback instead of staying silent.
228
373
  */
229
374
  function runHook(platform, event, ctx) {
230
375
  const handler = HOOK_PLATFORMS[platform]?.[event];
@@ -232,22 +377,31 @@ function runHook(platform, event, ctx) {
232
377
  stdout: null,
233
378
  error: `unknown hook ${platform} ${event}; known: ${Object.entries(HOOK_PLATFORMS).flatMap(([p, e]) => Object.keys(e).map((k) => `${p} ${k}`)).join(", ")}`
234
379
  };
235
- const root = findHubRoot(ctx.payload.cwd ?? process.cwd());
236
- if (!root) return {
237
- stdout: null,
238
- error: null
239
- };
380
+ const warnings = [];
381
+ const errorText = (message) => [...warnings, ...message ? [message] : []].join("\n") || null;
240
382
  try {
241
- return {
242
- stdout: handler(root, ctx),
383
+ const root = hubRootFor(event, ctx);
384
+ if (!root) return {
385
+ stdout: hookFallback(platform, event, ctx, { kind: "outside-hub" }),
243
386
  error: null
244
387
  };
388
+ return {
389
+ stdout: handler(root, {
390
+ ...ctx,
391
+ warn: (message) => warnings.push(message)
392
+ }),
393
+ error: errorText()
394
+ };
245
395
  } catch (error) {
396
+ const message = error.message;
246
397
  return {
247
- stdout: null,
248
- error: `mainflow hook ${platform} ${event}: ${error.message}`
398
+ stdout: hookFallback(platform, event, ctx, {
399
+ kind: "error",
400
+ message
401
+ }),
402
+ error: errorText(`mainflow hook ${platform} ${event}: ${message}`)
249
403
  };
250
404
  }
251
405
  }
252
406
  //#endregion
253
- export { HOOK_PLATFORMS, claudeCodeSessionId, openLink, runHook, subagentTranscript };
407
+ export { HOOK_PLATFORMS, claudeCodeSessionId, hookFallback, openLink, parsePayload, runHook, subagentTranscript };
@@ -1,9 +1,9 @@
1
- import { A as formatOpsEntry, B as waitingForRef, C as SURVEY_SECTION, D as configSchema, E as WORK_STATUS, F as parseRef, I as refExample, L as refMainline, M as mainlineSchema, N as opsEntrySchema, O as decisionSchema, P as parseOpsLine, R as sessionSchema, S as SURVEY_LINES, T as TASK_SECTIONS, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, j as issueSchema, k as findingSchema, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, v as REF_EXAMPLES, w as TASK_REQUIRED_SECTIONS, x as SLUG, y as RESERVED_HUB_NAMES, z as taskSchema } from "./dist-BrotcvKi.js";
1
+ import { A as findingSchema, B as taskSchema, C as SURVEY_LINES, D as WORK_STATUS, E as TASK_SECTIONS, F as parseOpsLine, G as describeRequest, I as parseRef, J as grantRepo, K as grantCovers, L as refExample, M as issueSchema, N as mainlineSchema, O as configSchema, P as opsEntrySchema, R as refMainline, S as SLUG, T as TASK_REQUIRED_SECTIONS, V as waitingForRef, Y as parseGrantTarget, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as formatOpsEntry, k as decisionSchema, l as FINDING_STATUS, n as CLOSED_STATUS, q as grantForm, r as DECIDERS, s as DEFAULT_CATEGORIES, t as CHECKLIST_REF, w as SURVEY_SECTION, x as SESSION_STATUS, y as REF_EXAMPLES, z as sessionSchema } from "./dist-Dy45myuN.js";
2
2
  import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
3
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
- import { parse, stringify } from "yaml";
6
+ import { parse as parse$1, stringify } from "yaml";
7
7
  //#region ../core/dist/paths.js
8
8
  const DATA_DIR = ".mainflow";
9
9
  const ENTRY_DIR = /^([mtdif]\d{3})-(.+)$/;
@@ -64,7 +64,7 @@ function splitFrontMatter(source) {
64
64
  body: source
65
65
  };
66
66
  return {
67
- data: parse(match[1] ?? ""),
67
+ data: parse$1(match[1] ?? ""),
68
68
  body: source.slice(match[0].length)
69
69
  };
70
70
  }
@@ -145,7 +145,7 @@ const toYaml = (data) => stringify(data, { lineWidth: 0 });
145
145
  function readYaml(file, schema) {
146
146
  const text = readFileSync(file, "utf8");
147
147
  return {
148
- data: validate(schema, parse(text), file),
148
+ data: validate(schema, parse$1(text), file),
149
149
  version: contentHash(text)
150
150
  };
151
151
  }
@@ -154,7 +154,7 @@ function mutateYaml(root, file, schema, mutate, expectedVersion) {
154
154
  return withLock(root, () => {
155
155
  const text = readFileSync(file, "utf8");
156
156
  if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
157
- const data = validate(schema, parse(text), file);
157
+ const data = validate(schema, parse$1(text), file);
158
158
  mutate(data);
159
159
  const next = validate(schema, data, file);
160
160
  writeAtomic(file, toYaml(next));
@@ -491,7 +491,7 @@ function loadHub(root, options = {}) {
491
491
  const read = (file, schema, raw) => {
492
492
  let data = raw;
493
493
  if (data === void 0) try {
494
- data = parse(readText(file) ?? "");
494
+ data = parse$1(readText(file) ?? "");
495
495
  } catch (error) {
496
496
  problems.push({
497
497
  file: rel(file),
@@ -520,6 +520,7 @@ function loadHub(root, options = {}) {
520
520
  const config = read(join(dataDir, "config.yaml"), configSchema);
521
521
  if (!config) throw new HubError("invalid config.yaml", problems);
522
522
  if (config.formatVersion !== 2) throw new HubError(config.formatVersion < 2 ? `data format version ${config.formatVersion} is older than this CLI's (2); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${config.formatVersion} is newer than this CLI supports (2); install the matching mainflow version in this hub`, problems);
523
+ problems.push(...repoPathProblems(root, config));
523
524
  const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
524
525
  /** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
525
526
  const readDocument = (file, schema) => {
@@ -568,6 +569,7 @@ function loadHub(root, options = {}) {
568
569
  continue;
569
570
  }
570
571
  checkDirName(mainlineDir, mainline.id);
572
+ problems.push(...grantProblems(mainline, config));
571
573
  const decisions = [];
572
574
  const decisionsDir = join(mainlineDir, "decisions");
573
575
  for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
@@ -873,6 +875,57 @@ function checkReferences(input) {
873
875
  });
874
876
  }
875
877
  }
878
+ /**
879
+ * A listed repository counts as part of the hub, so the PreToolUse hook lets grants act inside it. A path that
880
+ * resolves to / or to a directory above the hub would put everything under it in scope. A path that does not
881
+ * exist is fine: another machine may not have cloned that repository yet.
882
+ */
883
+ function repoPathProblems(root, config) {
884
+ const real = (path) => {
885
+ try {
886
+ return realpathSync(path);
887
+ } catch {
888
+ return null;
889
+ }
890
+ };
891
+ const hub = real(root) ?? root;
892
+ return Object.entries(config.repos).flatMap(([name, path]) => {
893
+ const dir = real(resolve(root, path));
894
+ if (dir === null) return [];
895
+ const problem = (message) => [{
896
+ file: `${DATA_DIR}/config.yaml`,
897
+ field: `repos.${name}`,
898
+ message
899
+ }];
900
+ if (dir === parse(dir).root) return problem(`"${path}" resolves to the file system root; list the repository's own directory`);
901
+ if (hub.startsWith(dir.endsWith(sep) ? dir : dir + sep)) return problem(`"${path}" resolves to ${dir}, above the hub; list the repository's own directory`);
902
+ return [];
903
+ });
904
+ }
905
+ /**
906
+ * What the schema cannot see about a mainline's grants: ids must be unique, since the file is edited directly,
907
+ * and a target that names a repository must name one listed in config.yaml.
908
+ */
909
+ function grantProblems(mainline, config) {
910
+ const problems = [];
911
+ const seen = /* @__PURE__ */ new Set();
912
+ mainline.grants?.forEach((grant, i) => {
913
+ if (seen.has(grant.id)) problems.push({
914
+ file: mainline.file,
915
+ field: `grants.${i}.id`,
916
+ message: `${grant.id} is used twice; give it the next free id`
917
+ });
918
+ seen.add(grant.id);
919
+ const parsed = parseGrantTarget(grant.action, grant.target);
920
+ const repo = parsed.ok ? grantRepo(parsed.target) : void 0;
921
+ if (repo !== void 0 && !(repo in config.repos)) problems.push({
922
+ file: mainline.file,
923
+ field: `grants.${i}.target`,
924
+ message: `repo "${repo}" is not listed in config.yaml; use one of ${Object.keys(config.repos).join(", ") || "(none registered)"}`
925
+ });
926
+ });
927
+ return problems;
928
+ }
876
929
  const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
877
930
  /**
878
931
  * What a reader without the conversation needs to pick the work up: an open mainline with tasks says what
@@ -1171,6 +1224,107 @@ function decorateReply(text, context) {
1171
1224
  /** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
1172
1225
  const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
1173
1226
  //#endregion
1227
+ //#region ../core/dist/grants.js
1228
+ /** Mainline statuses whose grants apply. */
1229
+ const GRANTING_STATUS = ["active", "planning"];
1230
+ /**
1231
+ * The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
1232
+ * must fail closed catches it and asks.
1233
+ */
1234
+ function grantScope(root, session) {
1235
+ if (!session) return {
1236
+ ok: false,
1237
+ reason: "這場會話沒有 mainflow 紀錄"
1238
+ };
1239
+ const doing = session.actions.filter((a) => a.status === "doing");
1240
+ if (doing.length === 0) return {
1241
+ ok: false,
1242
+ reason: `${session.id} 沒有進行中的行動`
1243
+ };
1244
+ if (doing.length > 1) return {
1245
+ ok: false,
1246
+ reason: `${session.id} 同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")}),分不出授權範圍`
1247
+ };
1248
+ const action = doing[0];
1249
+ const mainlines = [...new Set(action.serves.flatMap((ref) => {
1250
+ const parsed = parseRef(ref);
1251
+ const mainline = parsed ? refMainline(parsed) : void 0;
1252
+ return mainline ? [mainline] : [];
1253
+ }))];
1254
+ if (mainlines.length === 0) return {
1255
+ ok: false,
1256
+ reason: `${action.id} 沒有服務任何 checklist 項目`
1257
+ };
1258
+ const grants = [];
1259
+ const served = {};
1260
+ const closed = [];
1261
+ for (const id of mainlines) {
1262
+ const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
1263
+ if (!GRANTING_STATUS.includes(mainline.status)) {
1264
+ closed.push(`${id} 是 ${mainline.status}`);
1265
+ continue;
1266
+ }
1267
+ served[id] = action.serves.find((ref) => ref.startsWith(`${id}/`));
1268
+ for (const grant of mainline.grants ?? []) grants.push({
1269
+ mainline: id,
1270
+ grant,
1271
+ ref: `${id}#${grant.id}`
1272
+ });
1273
+ }
1274
+ if (Object.keys(served).length === 0) return {
1275
+ ok: false,
1276
+ reason: `${action.id} 服務的主脈絡已不在進行中(${closed.join("、")}),授權不再生效`
1277
+ };
1278
+ return {
1279
+ ok: true,
1280
+ scope: {
1281
+ action,
1282
+ grants,
1283
+ served
1284
+ }
1285
+ };
1286
+ }
1287
+ /** Every request must be covered by some grant in scope; the first one that is not is named in the reason. */
1288
+ function matchGrants(scope, requests) {
1289
+ const matches = [];
1290
+ for (const request of requests) {
1291
+ const grant = scope.grants.find((g) => {
1292
+ const parsed = parseGrantTarget(g.grant.action, g.grant.target);
1293
+ return parsed.ok && grantCovers(parsed.target, request);
1294
+ });
1295
+ if (!grant) return {
1296
+ ok: false,
1297
+ reason: `${Object.keys(scope.served).join("、")} 沒有涵蓋 ${describeRequest(request)} 的授權`
1298
+ };
1299
+ matches.push({
1300
+ request,
1301
+ grant
1302
+ });
1303
+ }
1304
+ return {
1305
+ ok: true,
1306
+ matches
1307
+ };
1308
+ }
1309
+ /**
1310
+ * One line per grant in effect for the session, for the briefing and the per-turn reminder, with the fixed form
1311
+ * the hook allows for it; empty when the session has none or they cannot be read, since a briefing must not fail
1312
+ * on them.
1313
+ */
1314
+ function grantLines(root, session) {
1315
+ try {
1316
+ const result = grantScope(root, session);
1317
+ if (!result.ok) return [];
1318
+ return result.scope.grants.map((g) => {
1319
+ const parsed = parseGrantTarget(g.grant.action, g.grant.target);
1320
+ const form = parsed.ok ? grantForm(parsed.target) : void 0;
1321
+ return `- ${g.ref} ${g.grant.action} ${g.grant.target}${form ? `:${form}` : ""}`;
1322
+ });
1323
+ } catch {
1324
+ return [];
1325
+ }
1326
+ }
1327
+ //#endregion
1174
1328
  //#region ../core/dist/sessions.js
1175
1329
  const sessionsDir = (root) => join(root, DATA_DIR, "local", "sessions");
1176
1330
  const sessionFile = (root, id) => join(sessionsDir(root), `${id}.yaml`);
@@ -1192,7 +1346,7 @@ function loadSessions(root) {
1192
1346
  const file = join(dir, name);
1193
1347
  let data;
1194
1348
  try {
1195
- data = parse(readText(file) ?? "");
1349
+ data = parse$1(readText(file) ?? "");
1196
1350
  } catch (error) {
1197
1351
  problems.push({
1198
1352
  file: relative(root, file),
@@ -1544,6 +1698,8 @@ function sessionBriefing(root, session, options) {
1544
1698
  } else {
1545
1699
  lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
1546
1700
  if (doing) lines.push(`服務:${servedLine(root, doing)}`);
1701
+ const grants = grantLines(root, session);
1702
+ if (grants.length > 0) lines.push("目前行動服務的主脈絡預先授權了這些動作:", ...grants);
1547
1703
  if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
1548
1704
  const blocked = session.actions.filter((a) => a.status === "blocked");
1549
1705
  if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
@@ -1562,9 +1718,12 @@ function turnReminder(root, session, options) {
1562
1718
  const blocked = session.actions.filter((a) => a.status === "blocked");
1563
1719
  const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length > 0 ? `[mainflow] ${session.id} 目前沒有進行中的行動。` : blocked.length > 0 ? `[mainflow] ${session.id} 的行動在等外部條件:${blocked.map(waitingLine).join(";")}。條件出現後用 ${options.cli} action set <a編號> --status doing 繼續;要先做別的事,用 ${options.cli} action add 列出行動。` : `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。`;
1564
1720
  const header = headerLine(session);
1721
+ const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
1722
+ const grants = grantLines(root, session);
1565
1723
  return [
1566
1724
  head,
1567
- ...header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [],
1725
+ ...grants.length > 0 ? ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)] : [],
1726
+ ...marker,
1568
1727
  ...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
1569
1728
  ].join("\n");
1570
1729
  }
@@ -1810,6 +1969,7 @@ function createDecision(root, mainline, input) {
1810
1969
  requireSlug(input.slug);
1811
1970
  const status = requireOneOf(input.status ?? "accepted", ["proposed", "accepted"], "new decision status");
1812
1971
  const by = input.by === void 0 ? void 0 : requireOneOf(input.by, DECIDERS, "decided by");
1972
+ const basis = input.basis === void 0 ? void 0 : requireOneOf(input.basis, DECISION_BASES, "decision basis");
1813
1973
  for (const ref of input.affects ?? []) requireAffected(root, mainline, ref);
1814
1974
  return withLock(root, () => {
1815
1975
  const dir = join(mainlineDir(root, mainline), "decisions");
@@ -1823,6 +1983,7 @@ function createDecision(root, mainline, input) {
1823
1983
  status,
1824
1984
  ...status === "accepted" ? { decidedAt: at } : {},
1825
1985
  ...by ? { by } : {},
1986
+ ...basis ? { basis } : {},
1826
1987
  ...input.affects?.length ? { affects: input.affects } : {}
1827
1988
  }, "decision");
1828
1989
  const file = join(dir, `${id}-${input.slug}.md`);
@@ -1846,15 +2007,17 @@ const parseRefIn = (input) => {
1846
2007
  task: task[2]
1847
2008
  } : null;
1848
2009
  };
1849
- /** Settle a proposed decision: record who decided and when; the items it affects stop waiting for it. */
1850
- function acceptDecision(root, refInput, by, expectedVersion) {
2010
+ /** Settle a proposed decision: record who decided, how and when; the items it affects stop waiting for it. */
2011
+ function acceptDecision(root, refInput, settlement = {}, expectedVersion) {
1851
2012
  const ref = requireRef(refInput, "decision");
1852
- const decider = by === void 0 ? void 0 : requireOneOf(by, DECIDERS, "decided by");
2013
+ const decider = settlement.by === void 0 ? void 0 : requireOneOf(settlement.by, DECIDERS, "decided by");
2014
+ const basis = settlement.basis === void 0 ? void 0 : requireOneOf(settlement.basis, DECISION_BASES, "decision basis");
1853
2015
  return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
1854
2016
  if (d.status !== "proposed") throw new WriteError(`${refInput} is ${d.status}; only a proposed decision can be accepted`);
1855
2017
  d.status = "accepted";
1856
2018
  d.decidedAt = nowMinute();
1857
2019
  if (decider) d.by = decider;
2020
+ if (basis) d.basis = basis;
1858
2021
  }, expectedVersion);
1859
2022
  }
1860
2023
  function supersedeDecision(root, refInput, by, expectedVersion) {
@@ -1968,7 +2131,7 @@ function migrateHub(root, options = {}) {
1968
2131
  if (!existsSync(configFile)) throw new WriteError(`no hub at ${root}; run mainflow init first`);
1969
2132
  const rel = (path) => relative(root, path);
1970
2133
  return withLock(root, () => {
1971
- const config = parse(readFileSync(configFile, "utf8"));
2134
+ const config = parse$1(readFileSync(configFile, "utf8"));
1972
2135
  const from = Number(config.formatVersion);
1973
2136
  if (from === 2) throw new WriteError(`${rel(configFile)} is already format 2; nothing to migrate`);
1974
2137
  if (from !== 1) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 to 2`);
@@ -1981,7 +2144,7 @@ function migrateHub(root, options = {}) {
1981
2144
  for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
1982
2145
  const yamlFile = join(mainlineDir, "mainline.yaml");
1983
2146
  if (!existsSync(yamlFile)) continue;
1984
- const m = parse(readFileSync(yamlFile, "utf8"));
2147
+ const m = parse$1(readFileSync(yamlFile, "utf8"));
1985
2148
  dropNull(m, "archivedAt");
1986
2149
  if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
1987
2150
  const criteria = (m.criteria ?? []).map(migrateListItem);
@@ -1998,7 +2161,7 @@ function migrateHub(root, options = {}) {
1998
2161
  for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
1999
2162
  const taskYaml = join(taskDir, "task.yaml");
2000
2163
  if (!existsSync(taskYaml)) continue;
2001
- const t = parse(readFileSync(taskYaml, "utf8"));
2164
+ const t = parse$1(readFileSync(taskYaml, "utf8"));
2002
2165
  const content = typeof t.content === "string" ? t.content.trim() : "";
2003
2166
  drop(t, "content");
2004
2167
  dropNull(t, "parent", "repo");
@@ -2191,4 +2354,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
2191
2354
  return true;
2192
2355
  }
2193
2356
  //#endregion
2194
- export { setFindingStatus as $, addAction as A, transcriptPath as B, setCriterionText as C, updateTask as D, supersedeDecision as E, setSessionArchived as F, mayDecorate as G, decorateReply as H, setSessionInfo as I, createFinding as J, appendOps as K, setSessionStatus as L, handoffSession as M, loadSessions as N, sessionBriefing as O, readSession as P, setFindingArchived as Q, settleSession as R, setCriterion as S, setMainlineTitle as T, endsWithMarker as U, updateAction as V, headerLine as W, planIssue as X, createIssue as Y, reopenIssue as Z, createTask as _, openInApp as a, linksInto as at, setArchived as b, syncHost as c, TASK_FILE as ct, setArchivedMany as d, DATA_DIR as dt, setIssueArchived as et, acceptDecision as f, currentBranch as ft, createMainline as g, createDecision as h, hostName as i, mainlineEdges as it, ensureSession as j, turnReminder as k, migrateHub as l, ConflictError as lt, addRepo as m, worktreesOf as mt, DEFAULT_UI_PORT as n, loadHub as nt, removeHostLink as o, refIndex as ot, addHub as p, findHubRoot as pt, closeIssue as q, caddySites as r, dependencyGraph as rt, siteBlock as s, MAINLINE_FILE as st, APP_BUNDLE_ID as t, HubError as tt, ARCHIVE_KINDS as u, WriteError as ut, dropChecklistItem as v, setMainlineStatus as w, setChecklistItem as x, initHub as y, startSession as z };
2357
+ export { reopenIssue as $, addAction as A, transcriptPath as B, setCriterionText as C, updateTask as D, supersedeDecision as E, setSessionArchived as F, endsWithMarker as G, grantScope as H, setSessionInfo as I, appendOps as J, headerLine as K, setSessionStatus as L, handoffSession as M, loadSessions as N, sessionBriefing as O, readSession as P, planIssue as Q, settleSession as R, setCriterion as S, setMainlineTitle as T, matchGrants as U, updateAction as V, decorateReply as W, createFinding as X, closeIssue as Y, createIssue as Z, createTask as _, worktreesOf as _t, openInApp as a, dependencyGraph as at, setArchived as b, syncHost as c, refIndex as ct, setArchivedMany as d, ConflictError as dt, setFindingArchived as et, acceptDecision as f, WriteError as ft, createMainline as g, findHubRoot as gt, createDecision as h, currentBranch as ht, hostName as i, loadHub as it, ensureSession as j, turnReminder as k, migrateHub as l, MAINLINE_FILE as lt, addRepo as m, DATA_DIR as mt, DEFAULT_UI_PORT as n, setIssueArchived as nt, removeHostLink as o, mainlineEdges as ot, addHub as p, readYaml as pt, mayDecorate as q, caddySites as r, HubError as rt, siteBlock as s, linksInto as st, APP_BUNDLE_ID as t, setFindingStatus as tt, ARCHIVE_KINDS as u, TASK_FILE as ut, dropChecklistItem as v, setMainlineStatus as w, setChecklistItem as x, initHub as y, startSession as z };
package/dist/host.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-BsLqiI0_.js";
1
+ import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-DkaCYnnc.js";
2
2
  export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeHostLink, siteBlock, syncHost };
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ //#region ../schema/dist/index.d.ts
3
+ declare const FORMAT_VERSION = 2;
4
+ declare const ID_PATTERNS: {
5
+ readonly mainline: RegExp;
6
+ readonly task: RegExp;
7
+ readonly checklistItem: RegExp;
8
+ readonly criterion: RegExp;
9
+ readonly decision: RegExp;
10
+ readonly action: RegExp;
11
+ readonly issue: RegExp;
12
+ readonly finding: RegExp;
13
+ readonly grant: RegExp;
14
+ };
15
+ declare const CHECKLIST_REF: RegExp;
16
+ declare const SLUG: RegExp;
17
+ /** One source the survey checks: the line's label, what it holds, and how it may be left unchecked. */
18
+ interface SurveyLine {
19
+ label: string;
20
+ holds: string;
21
+ /** The word that marks the source as not checked, followed by the reason, e.g. 不適用:{理由}. Absent when the source is always checked. */
22
+ skip?: string;
23
+ /** Text a checked line must contain, e.g. the environment a spike ran in. */
24
+ requires?: string;
25
+ }
26
+ //#endregion
27
+ export { SurveyLine as a, SLUG as i, FORMAT_VERSION as n, ID_PATTERNS as r, CHECKLIST_REF as t };
@@ -1,4 +1,4 @@
1
- import { c as syncHost, dt as DATA_DIR, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, ut as WriteError } from "./host-BsLqiI0_.js";
1
+ import { c as syncHost, ft as WriteError, i as hostName, mt as DATA_DIR, n as DEFAULT_UI_PORT, o as removeHostLink } from "./host-DkaCYnnc.js";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { spawn, spawnSync } from "node:child_process";
@@ -176,7 +176,7 @@ async function launchUi(root, hub, configured, options) {
176
176
  }
177
177
  let server;
178
178
  if (!reuse) {
179
- const { serveUi } = await import("./ui-ngIAww3a.js");
179
+ const { serveUi } = await import("./ui-Do7fLlUC.js");
180
180
  server = await serveUi(root, choice.port);
181
181
  writeUiRecord(root, {
182
182
  port: choice.port,
package/dist/launch.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-CBXoFsFC.js";
1
+ import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-DOX1TTGx.js";
2
2
  export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord, startUiInBackground, watchHubGone };
@@ -1,6 +1,6 @@
1
- import { B as transcriptPath, D as updateTask, F as setSessionArchived, N as loadSessions, P as readSession, Q as setFindingArchived, S as setCriterion, T as setMainlineTitle, a as openInApp, at as linksInto, b as setArchived, d as setArchivedMany, dt as DATA_DIR, et as setIssueArchived, it as mainlineEdges, lt as ConflictError, nt as loadHub, ot as refIndex, rt as dependencyGraph, u as ARCHIVE_KINDS, ut as WriteError, w as setMainlineStatus, x as setChecklistItem } from "./host-BsLqiI0_.js";
1
+ import { B as transcriptPath, D as updateTask, F as setSessionArchived, N as loadSessions, P as readSession, S as setCriterion, T as setMainlineTitle, a as openInApp, at as dependencyGraph, b as setArchived, ct as refIndex, d as setArchivedMany, dt as ConflictError, et as setFindingArchived, ft as WriteError, it as loadHub, mt as DATA_DIR, nt as setIssueArchived, ot as mainlineEdges, st as linksInto, u as ARCHIVE_KINDS, w as setMainlineStatus, x as setChecklistItem } from "./host-DkaCYnnc.js";
2
2
  import { t as listProgress } from "./progress-1BfPg2zN.js";
3
- import { l as CLI_VERSION } from "./launch-CBXoFsFC.js";
3
+ import { l as CLI_VERSION } from "./launch-DOX1TTGx.js";
4
4
  import { createRequire } from "node:module";
5
5
  import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watch } from "node:fs";
6
6
  import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";