mainflow 0.20.0 → 0.22.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.
@@ -0,0 +1,245 @@
1
+ import { H as GRANT_FORMS } from "./dist-DCYzrl7J.js";
2
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ //#region src/grant-commands.ts
6
+ /**
7
+ * Text that may hold a grantable command; a call that matches neither is not one this hook decides. It is matched
8
+ * in lowercase with quotes and backslashes removed, because the shell drops those (pn\pm, 'pnpm') and a
9
+ * case-insensitive file system runs NPM as npm.
10
+ */
11
+ const MENTIONS = {
12
+ publish: /\b(?:npm|pnpm)\b[\s\S]*\bpublish\b/,
13
+ release: /\bgh\b[\s\S]*\brelease\b[\s\S]*\b(?:create|new)\b/
14
+ };
15
+ const mentionText = (command) => command.toLowerCase().replace(/[\\'"]/g, "");
16
+ /** How much of a command a reason quotes. */
17
+ const QUOTED = 200;
18
+ const quote = (text) => text.length > QUOTED ? `${text.slice(0, QUOTED)}…` : text;
19
+ var Refused = class extends Error {};
20
+ /** A bare word: no shell syntax at all. */
21
+ const BARE = /^[A-Za-z0-9._/@:=+-]+$/;
22
+ /** A title or notes value in quotes, where the shell expands nothing. */
23
+ const QUOTED_VALUE = /^'[^']*'$|^"[^"\\$`]*"$/;
24
+ /** A git tag a release may create: no path separators, no `..`. */
25
+ const TAG = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
26
+ const OWNER_REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
27
+ /** Split a call into words separated by spaces; only printable ASCII, and quotes only around a whole word. */
28
+ function words(command) {
29
+ const text = command.trim();
30
+ if (!/^[\x20-\x7e]*$/.test(text)) throw new Refused("指令含換行、tab 或其他不可見字元");
31
+ const result = [];
32
+ let i = 0;
33
+ while (i < text.length) {
34
+ if (text[i] === " ") {
35
+ i++;
36
+ continue;
37
+ }
38
+ const quoteChar = text[i] === "'" || text[i] === "\"" ? text[i] : null;
39
+ const space = text.indexOf(" ", i);
40
+ const end = quoteChar ? text.indexOf(quoteChar, i + 1) + 1 : space < 0 ? text.length : space;
41
+ if (quoteChar && (end === 0 || end < text.length && text[end] !== " ")) throw new Refused("引號要包住一整個值");
42
+ const word = text.slice(i, end);
43
+ if (!(quoteChar ? QUOTED_VALUE : BARE).test(word)) throw new Refused(`「${quote(word)}」含 shell 語法`);
44
+ result.push(word);
45
+ i = end;
46
+ }
47
+ return result;
48
+ }
49
+ /** Take the flags after the command words: `values` take the next word, `flags` stand alone; each at most once. */
50
+ function flagsOf(rest, values, flags) {
51
+ const found = /* @__PURE__ */ new Map();
52
+ for (let i = 0; i < rest.length; i++) {
53
+ const word = rest[i];
54
+ if (found.has(word)) throw new Refused(`${word} 重複`);
55
+ if (values.has(word)) {
56
+ const value = rest[++i];
57
+ if (value === void 0) throw new Refused(`${word} 沒有值`);
58
+ found.set(word, value);
59
+ } else if (flags.has(word)) found.set(word, "");
60
+ else throw new Refused(`「${quote(word)}」不在固定寫法裡`);
61
+ }
62
+ return found;
63
+ }
64
+ /** Values other than --title and --notes are bare words, so they cannot hide a quote. */
65
+ function bareValue(flag, value) {
66
+ if (value !== void 0 && !BARE.test(value)) throw new Refused(`${flag} 的值不能加引號`);
67
+ return value;
68
+ }
69
+ function publishForm(list, text) {
70
+ const [tool, sub, ...rest] = list;
71
+ if (tool !== "npm" && tool !== "pnpm" || sub !== "publish") throw new Refused("不是單獨一條 npm publish 或 pnpm publish");
72
+ const flags = flagsOf(rest, /* @__PURE__ */ new Set(["--access", "--tag"]), /* @__PURE__ */ new Set([
73
+ "--ignore-scripts",
74
+ "--dry-run",
75
+ "--provenance",
76
+ ...tool === "pnpm" ? ["--no-git-checks"] : []
77
+ ]));
78
+ if (!flags.has("--ignore-scripts")) throw new Refused("沒有帶 --ignore-scripts,lifecycle 腳本會執行");
79
+ const access = bareValue("--access", flags.get("--access"));
80
+ if (access !== void 0 && access !== "public" && access !== "restricted") throw new Refused("--access 只能是 public 或 restricted");
81
+ const tag = bareValue("--tag", flags.get("--tag"));
82
+ if (tag !== void 0 && !TAG.test(tag)) throw new Refused("--tag 不是 dist-tag 名稱");
83
+ return {
84
+ action: "publish",
85
+ tool,
86
+ text
87
+ };
88
+ }
89
+ function releaseForm(list, text) {
90
+ const [tool, sub, verb, tag, ...rest] = list;
91
+ if (tool !== "gh" || sub !== "release" || verb !== "create") throw new Refused("不是單獨一條 gh release create");
92
+ if (!tag || !TAG.test(tag) || tag.includes("..")) throw new Refused("tag 只能用英數字、點、底線與連字號,不能含 / 或 ..");
93
+ const repo = bareValue("-R", flagsOf(rest, /* @__PURE__ */ new Set([
94
+ "-R",
95
+ "--title",
96
+ "--notes"
97
+ ]), /* @__PURE__ */ new Set([
98
+ "--generate-notes",
99
+ "--draft",
100
+ "--prerelease",
101
+ "--verify-tag"
102
+ ])).get("-R"));
103
+ if (!repo || !OWNER_REPO.test(repo)) throw new Refused("-R <owner/repo> 必填");
104
+ return {
105
+ action: "release",
106
+ tag,
107
+ repo,
108
+ text
109
+ };
110
+ }
111
+ /** Whether a Bash call is one this hook decides and, if so, whether it is in a fixed form. */
112
+ function scanCommand(command) {
113
+ const text = mentionText(command);
114
+ const publish = MENTIONS.publish.test(text);
115
+ const release = MENTIONS.release.test(text);
116
+ if (!publish && !release) return { kind: "none" };
117
+ const forms = [...publish ? [GRANT_FORMS.publish] : [], ...release ? [GRANT_FORMS.release] : []].join(",或");
118
+ try {
119
+ const list = words(command);
120
+ return {
121
+ kind: "fixed",
122
+ call: release && (!publish || list[0] === "gh") ? releaseForm(list, command.trim()) : publishForm(list, command.trim())
123
+ };
124
+ } catch (error) {
125
+ if (error instanceof Refused) return {
126
+ kind: "refused",
127
+ reason: `授權放行只接受${forms}(這次:${error.message})`
128
+ };
129
+ throw error;
130
+ }
131
+ }
132
+ const refuse = (form, why) => ({
133
+ ok: false,
134
+ reason: `授權放行只接受${GRANT_FORMS[form]}(這次:${why})`
135
+ });
136
+ const real = (path) => {
137
+ try {
138
+ return realpathSync(path);
139
+ } catch {
140
+ return null;
141
+ }
142
+ };
143
+ const within = (path, root) => {
144
+ const rel = relative(root, path);
145
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
146
+ };
147
+ /**
148
+ * The payload's cwd after realpath, when it exists and lies in the hub or in a repository config.yaml lists; a
149
+ * listed repository counts as part of the hub even when it sits outside the hub directory, as ../mainflow does.
150
+ */
151
+ function scopedCwd(cwd, root, repos) {
152
+ const scope = (why) => ({
153
+ ok: false,
154
+ reason: `授權只在 hub 與 config.yaml 登記的倉庫內生效(這次:${why})`
155
+ });
156
+ if (typeof cwd !== "string" || !isAbsolute(cwd)) return scope("payload 的 cwd 不是絕對路徑");
157
+ const dir = real(cwd);
158
+ if (!dir) return scope(`目錄 ${quote(cwd)} 不存在`);
159
+ return [root, ...Object.values(repos).map((path) => resolve(root, path))].flatMap((r) => real(r) ?? []).some((r) => within(dir, r)) ? {
160
+ ok: true,
161
+ dir
162
+ } : scope(`${quote(dir)} 不在 hub 或登記的倉庫內`);
163
+ }
164
+ /** A version as npm publishes it: no v prefix, no spaces, no leading zeros. */
165
+ const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
166
+ /** Largest package.json read; a bigger one is not a manifest this hook trusts. */
167
+ const MAX_MANIFEST = 1048576;
168
+ /**
169
+ * Read a file only when it is a regular file of at most MAX_MANIFEST bytes, following symlinks. It is opened
170
+ * without blocking and checked on the open descriptor, so a FIFO or a device such as /dev/zero is refused rather
171
+ * than waited on, and the file checked is the file read.
172
+ */
173
+ function readManifest(file) {
174
+ let fd;
175
+ try {
176
+ fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
177
+ } catch {
178
+ return null;
179
+ }
180
+ try {
181
+ const stat = fstatSync(fd);
182
+ return stat.isFile() && stat.size <= MAX_MANIFEST ? readFileSync(fd, "utf8") : null;
183
+ } catch {
184
+ return null;
185
+ } finally {
186
+ closeSync(fd);
187
+ }
188
+ }
189
+ /** name and version from package.json in `dir`; a private package, or one that publishes another directory, is refused. */
190
+ function resolvePublish(dir) {
191
+ const text = readManifest(join(dir, "package.json"));
192
+ if (text === null) return refuse("publish", `${quote(dir)} 的 package.json 不是 1 MB 以內的一般檔案`);
193
+ let data;
194
+ try {
195
+ data = JSON.parse(text);
196
+ } catch {
197
+ return refuse("publish", `${quote(dir)} 的 package.json 不是 JSON`);
198
+ }
199
+ if (typeof data !== "object" || data === null) return refuse("publish", "package.json 不是物件");
200
+ if (typeof data.name !== "string" || typeof data.version !== "string") return refuse("publish", "package.json 沒有 name 或 version");
201
+ if (!SEMVER.test(data.version)) return refuse("publish", `version ${quote(JSON.stringify(data.version))} 不是標準的 semver`);
202
+ if (data.version.split("+")[0].split(/[.-]/).filter((part) => /^\d+$/.test(part)).some((part) => !Number.isSafeInteger(Number(part)))) return refuse("publish", `version ${quote(data.version)} 有超過 Number.MAX_SAFE_INTEGER 的數字`);
203
+ if (data.private) return refuse("publish", `${data.name} 是 private 套件`);
204
+ if (data.publishConfig?.directory !== void 0) return refuse("publish", "package.json 的 publishConfig.directory 會改發布別的目錄");
205
+ return {
206
+ ok: true,
207
+ request: {
208
+ action: "publish",
209
+ name: data.name,
210
+ version: data.version
211
+ }
212
+ };
213
+ }
214
+ /** The origin remote of a checkout as github.com/owner/repo in lowercase, or null when it is not on github.com. */
215
+ function githubOrigin(dir) {
216
+ const result = spawnSync("git", [
217
+ "-C",
218
+ dir,
219
+ "remote",
220
+ "get-url",
221
+ "origin"
222
+ ], { encoding: "utf8" });
223
+ const url = result.status === 0 ? result.stdout.trim().replace(/\.git$/, "") : "";
224
+ const match = /^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/.exec(url);
225
+ return match ? `github.com/${match[1]}`.toLowerCase() : null;
226
+ }
227
+ function resolveRelease(call, ctx) {
228
+ const slug = `github.com/${call.repo}`.toLowerCase();
229
+ const repos = Object.entries(ctx.repos).filter(([, path]) => githubOrigin(resolve(ctx.root, path)) === slug).map(([name]) => name);
230
+ if (repos.length === 0) return refuse("release", `${quote(call.repo)} 不是 config.yaml 裡任何倉庫的 origin`);
231
+ return {
232
+ ok: true,
233
+ request: {
234
+ action: "release",
235
+ repos,
236
+ tag: call.tag
237
+ }
238
+ };
239
+ }
240
+ /** What a fixed-form call would publish or release; refused when the package or repository does not qualify. */
241
+ function resolveFixed(call, ctx) {
242
+ return call.action === "publish" ? resolvePublish(ctx.cwd) : resolveRelease(call, ctx);
243
+ }
244
+ //#endregion
245
+ export { TAG, githubOrigin, quote, resolveFixed, scanCommand, scopedCwd };
package/dist/hooks.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface ClaudeCodePayload {
15
15
  stop_hook_active?: boolean;
16
16
  /** MessageDisplay: the lines of an assistant message about to be shown */
17
17
  delta?: string;
18
- /** PostToolUse: the tool that ran and its input */
18
+ /** PreToolUse and PostToolUse: the tool about to run or that ran, and its input */
19
19
  tool_name?: string;
20
20
  tool_input?: {
21
21
  file_path?: string;
@@ -32,15 +32,36 @@ export interface HookContext {
32
32
  cli: string;
33
33
  /** Start the hub's interface without waiting; given by the CLI entry point, left out by callers that must not spawn */
34
34
  startUi?: (root: string) => void;
35
+ /** Report something on stderr without changing what the hook prints; runHook sets it */
36
+ warn?: (message: string) => void;
37
+ /** $CLAUDE_PROJECT_DIR: where the session was opened; events in PROJECT_HUB_EVENTS find the hub from it first */
38
+ projectDir?: string;
35
39
  }
40
+ /**
41
+ * Read a hook's stdin: one JSON object, as Claude Code sends it. Anything else is null, including arrays,
42
+ * scalars, empty input, and an object with a key written twice, since which value the host acted on is unknown.
43
+ */
44
+ export declare function parsePayload(text: string): ClaudeCodePayload | null;
36
45
  export declare const claudeCodeSessionId: (raw: string) => string;
37
46
  type Handler = (root: string, ctx: HookContext) => string | null;
38
47
  /** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
39
48
  export declare const openLink: (hub: string) => (path: string) => string;
49
+ /** Why an event could not run its handler: outside a hub, the handler threw, or stdin was not JSON. */
50
+ export type HookFailure = {
51
+ kind: 'outside-hub';
52
+ } | {
53
+ kind: 'error';
54
+ message: string;
55
+ } | {
56
+ kind: 'unreadable-input';
57
+ };
40
58
  export declare const HOOK_PLATFORMS: Record<string, Record<string, Handler>>;
59
+ /** What an event prints when it cannot run its handler; null for the events that stay silent. */
60
+ export declare function hookFallback(platform: string, event: string, ctx: HookContext, failure: HookFailure): string | null;
41
61
  /**
42
62
  * Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
43
- * failure is reported on stderr while the caller still exits 0.
63
+ * failure is reported on stderr while the caller still exits 0. Events listed in the fallbacks print their
64
+ * fallback instead of staying silent.
44
65
  */
45
66
  export declare function runHook(platform: string, event: string, ctx: HookContext): {
46
67
  stdout: string | null;
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-0_zkFbzM.js";
2
+ import { D as configSchema, W as describeRequest } from "./dist-DCYzrl7J.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 };