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/facts.js CHANGED
@@ -1,4 +1,4 @@
1
- import { C as SURVEY_SECTION, E as WORK_STATUS, S as SURVEY_LINES, T as TASK_SECTIONS, _ as PRIORITIES, a as DECISION_STATUS, b as SESSION_STATUS, c as FINDING_STATUS, d as ISSUE_SECTIONS, f as ISSUE_STATUS, g as MERGE_STATES, h as MAINLINE_STATUS, i as DECISION_SECTIONS, l as FORMAT_VERSION, m as MAINLINE_SECTIONS, n as CLOSED_STATUS, o as DEFAULT_CATEGORIES, p as LINK_RELS, r as DECIDERS, s as FINDING_SECTIONS, t as CHECKLIST_REF, u as ID_PATTERNS, w as TASK_REQUIRED_SECTIONS, x as SLUG } from "./dist-BrotcvKi.js";
1
+ import { C as SURVEY_LINES, D as WORK_STATUS, E as TASK_SECTIONS, H as GRANT_ACTIONS, S as SLUG, T as TASK_REQUIRED_SECTIONS, U as GRANT_FORMS, W as GRANT_TARGET_FORMS, _ as MERGE_STATES, a as DECISION_SECTIONS, c as FINDING_SECTIONS, d as ID_PATTERNS, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, l as FINDING_STATUS, m as LINK_RELS, n as CLOSED_STATUS, o as DECISION_STATUS, p as ISSUE_STATUS, r as DECIDERS, s as DEFAULT_CATEGORIES, t as CHECKLIST_REF, u as FORMAT_VERSION, v as PRIORITIES, w as SURVEY_SECTION, x as SESSION_STATUS } from "./dist-Dy45myuN.js";
2
2
  //#region src/facts.ts
3
3
  const COMMAND_SPECS = {
4
4
  init: {
@@ -118,7 +118,7 @@ const COMMAND_SPECS = {
118
118
  needsHub: true
119
119
  },
120
120
  "new decision": {
121
- usage: "new decision <m> <slug> --title <title> [--status proposed] [--by user|ai|relay] [--affects <m/t or m/t#c>,...]",
121
+ usage: "new decision <m> <slug> --title <title> [--status proposed] [--by user|ai|relay] [--basis explicit|recommended] [--affects <m/t or m/t#c>,...]",
122
122
  summary: "Create a decision file; proposed while the question is still open",
123
123
  args: 2,
124
124
  needsHub: true
@@ -196,7 +196,7 @@ const COMMAND_SPECS = {
196
196
  needsHub: true
197
197
  },
198
198
  "decision accept": {
199
- usage: "decision accept <m/d> [--by user|ai|relay]",
199
+ usage: "decision accept <m/d> [--by user|ai|relay] [--basis explicit|recommended]",
200
200
  summary: "Settle a proposed decision; the items it affects stop waiting for it",
201
201
  args: 1,
202
202
  needsHub: true
@@ -300,6 +300,7 @@ const OPTIONS = {
300
300
  repo: { type: "string" },
301
301
  evidence: { type: "string" },
302
302
  by: { type: "string" },
303
+ basis: { type: "string" },
303
304
  port: { type: "string" },
304
305
  "no-open": {
305
306
  type: "boolean",
@@ -356,7 +357,8 @@ const STATUSES = {
356
357
  finding: FINDING_STATUS,
357
358
  session: SESSION_STATUS,
358
359
  merge: MERGE_STATES,
359
- decider: DECIDERS
360
+ decider: DECIDERS,
361
+ basis: DECISION_BASES
360
362
  };
361
363
  /** Reference formats, one example each, in the order a reader meets them. */
362
364
  const REFERENCES = [
@@ -408,6 +410,18 @@ const REFERENCES = [
408
410
  ];
409
411
  /** Relation types a document's links take; readers derive the reverse direction. */
410
412
  const LINKS = { rels: LINK_RELS };
413
+ /**
414
+ * What a mainline's grants authorize: each action with the form of its target. A grant applies only while the
415
+ * session's action in progress serves the mainline and the mainline is active or planning. The PreToolUse hook
416
+ * allows `publish` and `release` only when the command is written in the fixed form in `forms`; the other
417
+ * actions are for the executor to read.
418
+ */
419
+ const GRANTS = {
420
+ actions: GRANT_ACTIONS,
421
+ targets: GRANT_TARGET_FORMS,
422
+ enforcedByHook: ["publish", "release"],
423
+ forms: GRANT_FORMS
424
+ };
411
425
  /** Where each kind of entry lives under the hub root. */
412
426
  const LAYOUT = {
413
427
  config: ".mainflow/config.yaml",
@@ -500,7 +514,8 @@ const FIELDS = {
500
514
  field("archivedAt", false, "封存的日期,和狀態分開"),
501
515
  field("criteria", true, "解決條件清單,每條的欄位見 criterion"),
502
516
  field("budget", false, "預算的金額與單位"),
503
- field("costs", false, "花費清單,每筆有日期、項目、單位,可附預估、實際、備註")
517
+ field("costs", false, "花費清單,每筆有日期、項目、單位,可附預估、實際、備註"),
518
+ field("grants", false, "預先授權清單,每條的欄位見 grant;只在會話進行中的行動服務這條主脈絡、且主脈絡是 active 或 planning 時生效")
504
519
  ],
505
520
  task: [
506
521
  ...shared(...EVERY),
@@ -522,6 +537,7 @@ const FIELDS = {
522
537
  field("decidedAt", false, "裁決的時間,由 CLI 寫入"),
523
538
  field("supersededBy", false, "取代它的決策,superseded 時必填"),
524
539
  field("by", false, "決策者:user、ai、relay"),
540
+ field("basis", false, "裁決方式:explicit(明確選了某個選項)、recommended(照建議預設,沒有明確選)"),
525
541
  field("affects", false, "影響的任務或 checklist 項目;待裁決時其中的項目在等它"),
526
542
  field("reversible", false, "之後能不能低成本改回來")
527
543
  ],
@@ -540,6 +556,12 @@ const FIELDS = {
540
556
  ],
541
557
  criterion: LIST_ITEM,
542
558
  checklistItem: [...LIST_ITEM, field("serves", false, "這個項目推進的解決條件,r{NNN} 清單")],
559
+ grant: [
560
+ field("id", true, "清單內不重複的編號 g{NNN}"),
561
+ field("action", true, "授權的動作:commit、push、merge-pr、publish、release、install-prompts、sync-hubs"),
562
+ field("target", true, "授權的目標,格式依動作而定,見 GRANTS.targets;倉庫名稱需在 config.yaml 登記"),
563
+ field("note", false, "一句話說明")
564
+ ],
543
565
  link: [
544
566
  field("rel", true, "關係類型:depends-on、relates、split-from,反向由讀者推出"),
545
567
  field("to", true, "對方的參照,不能在同一條主脈絡內;其他 hub 的加 {hub}: 前綴"),
@@ -547,4 +569,4 @@ const FIELDS = {
547
569
  ]
548
570
  };
549
571
  //#endregion
550
- export { CHECKLIST_REF, CLASSIFICATION, CLOSED_STATUSES, COMMAND_SPECS, FIELDS, FORMAT_VERSION, ID_PATTERNS, LAYOUT, LINKS, OPTIONS, REFERENCES, SECTIONS, SLUG, STATUSES, SURVEY };
572
+ export { CHECKLIST_REF, CLASSIFICATION, CLOSED_STATUSES, COMMAND_SPECS, FIELDS, FORMAT_VERSION, GRANTS, ID_PATTERNS, LAYOUT, LINKS, OPTIONS, REFERENCES, SECTIONS, SLUG, STATUSES, SURVEY };
@@ -0,0 +1,73 @@
1
+ import "./index-CV30LkNZ.js";
2
+ //#region ../schema/dist/grants.d.ts
3
+ /** An operation about to run, resolved to what a grant is written against. */
4
+ type GrantRequest = {
5
+ action: 'publish';
6
+ name: string;
7
+ version: string;
8
+ } |
9
+ /** `repos` holds every config.yaml name whose checkout points at the repository, usually one */
10
+ {
11
+ action: 'release';
12
+ repos: string[];
13
+ tag: string;
14
+ };
15
+ //#endregion
16
+ //#region src/grant-commands.d.ts
17
+ export declare const quote: (text: string) => string;
18
+ /** A call in one of the fixed forms, not yet checked against the package or the repository. */
19
+ export type Fixed = {
20
+ action: 'publish';
21
+ tool: 'npm' | 'pnpm';
22
+ text: string;
23
+ } | {
24
+ action: 'release';
25
+ tag: string;
26
+ repo: string;
27
+ text: string;
28
+ };
29
+ /** none: nothing grantable, stay silent. refused: not a fixed form, so ask. fixed: one of the two forms. */
30
+ export type Scan = {
31
+ kind: 'none';
32
+ } | {
33
+ kind: 'refused';
34
+ reason: string;
35
+ } | {
36
+ kind: 'fixed';
37
+ call: Fixed;
38
+ };
39
+ /** A git tag a release may create: no path separators, no `..`. */
40
+ export declare const TAG: RegExp;
41
+ /** Whether a Bash call is one this hook decides and, if so, whether it is in a fixed form. */
42
+ export declare function scanCommand(command: string): Scan;
43
+ export interface ResolveContext {
44
+ /** Directory the call runs in, already checked by scopedCwd */
45
+ cwd: string;
46
+ /** Hub root; config.yaml repo paths are relative to it */
47
+ root: string;
48
+ /** config.yaml `repos`: name to path */
49
+ repos: Record<string, string>;
50
+ }
51
+ export type Resolved = {
52
+ ok: true;
53
+ request: GrantRequest;
54
+ } | {
55
+ ok: false;
56
+ reason: string;
57
+ };
58
+ /**
59
+ * The payload's cwd after realpath, when it exists and lies in the hub or in a repository config.yaml lists; a
60
+ * listed repository counts as part of the hub even when it sits outside the hub directory, as ../mainflow does.
61
+ */
62
+ export declare function scopedCwd(cwd: unknown, root: string, repos: Record<string, string>): {
63
+ ok: true;
64
+ dir: string;
65
+ } | {
66
+ ok: false;
67
+ reason: string;
68
+ };
69
+ /** The origin remote of a checkout as github.com/owner/repo in lowercase, or null when it is not on github.com. */
70
+ export declare function githubOrigin(dir: string): string | null;
71
+ /** What a fixed-form call would publish or release; refused when the package or repository does not qualify. */
72
+ export declare function resolveFixed(call: Fixed, ctx: ResolveContext): Resolved;
73
+ //#endregion
@@ -0,0 +1,245 @@
1
+ import { U as GRANT_FORMS } from "./dist-Dy45myuN.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;