mainflow 0.42.0 → 0.43.0-alpha.1

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,10 +1,10 @@
1
- import { P as configSchema, W as parseRef, Y as taskSchema, rt as describeRequest } from "./dist-zSabglPq.js";
2
- import { A as grantScope, D as loadSessions, Dt as worktreesOf, Et as findHubRoot, F as mayDecorate, M as decorateReply, N as endsWithMarker, O as readSession, P as headerLine, S as turnReminder, Tt as currentBranch, Y as loadHub, bt as formatMismatch, ct as nodeStorage, i as hostName, it as taskFile, j as matchGrants, kt as DATA_DIR, ot as readFrontMatter, q as HubError, st as readYaml, x as sessionBriefing, xt as hubFormatVersion } from "./host-BAMZmRy8.js";
3
- import { n as hubWriter, r as writtenLines } from "./operation-2wx2ex6x.js";
1
+ import { I as configSchema, Y as parseRef, ft as describeRequest, rt as taskSchema, tt as repoPaths } from "./dist-D5rZ0MQH.js";
2
+ import { C as turnReminder, Dt as hubFormatVersion, Et as formatMismatch, F as headerLine, Ft as inRepositoryHubAt, Gt as nodeStorage, Ht as resolveHub, I as mayDecorate, Jt as hubPath, Kt as currentBranch, Lt as notRegisteredMessage, M as matchGrants, N as decorateReply, O as loadSessions, P as endsWithMarker, Pt as hubHoldingFile, Q as HubError, Qt as DATA_DIR, S as sessionBriefing, Xt as worktreesOf, Yt as realPath, dt as readFrontMatter, et as loadHub, ft as readYaml, i as hostName, j as grantScope, k as readSession, lt as taskFile, qt as dataFile } from "./host-BJvqZy-i.js";
3
+ import { n as hubWriter, r as writtenLines } from "./operation-BhBbGauo.js";
4
4
  import { SCENARIO_LINES, SCENARIO_SKIP_KINDS } from "./facts.js";
5
5
  import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
6
6
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
7
- import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { isAbsolute, join, relative, resolve } from "node:path";
8
8
  //#region src/hooks.ts
9
9
  const SUMMARY_LIMIT = 280;
10
10
  /** The parent transcript `<dir>/<session>.jsonl` keeps its subagents under `<dir>/<session>/subagents/`. */
@@ -76,8 +76,8 @@ const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
76
76
  * bring an ended or archived one back, since the viewer is working in it again. Its environment facts are
77
77
  * written when it is created and whenever `refresh` asks, such as on SessionStart after a resume.
78
78
  */
79
- async function recordSession(write, root, payload, id, refresh) {
80
- const cwd = payload.cwd ?? root;
79
+ async function recordSession(write, dirs, payload, id, refresh) {
80
+ const cwd = payload.cwd ?? dirs.root;
81
81
  const { session, created } = (await write("session.ensure", {
82
82
  id,
83
83
  agent: "claude-code",
@@ -107,11 +107,11 @@ const openLink = (hub) => (path) => `https://${hostName(hub)}/open${path}`;
107
107
  * only, so the model keeps reading what it wrote. Text with nothing to change
108
108
  * returns before the hub is loaded, because Claude Code holds every batch until the hook answers.
109
109
  */
110
- const displayReply = (root, { payload }) => {
110
+ const displayReply = (dirs, { payload }) => {
111
111
  const text = payload.delta;
112
112
  if (!text || !mayDecorate(text)) return null;
113
- const hub = loadHub(root, { remotes: false });
114
- const session = payload.session_id ? readSession(root, claudeCodeSessionId(payload.session_id)) : null;
113
+ const hub = loadHub(dirs, { remotes: false });
114
+ const session = payload.session_id ? readSession(dirs, claudeCodeSessionId(payload.session_id)) : null;
115
115
  const shown = decorateReply(text, {
116
116
  hub,
117
117
  session,
@@ -125,10 +125,10 @@ const displayReply = (root, { payload }) => {
125
125
  /** Mainline ids a reply mentions, e.g. m001 in `m001/t003`; not inside a longer word or path. */
126
126
  const MAINLINE_MENTION = /(?<![\w/#.[-])(m\d{3})(?![\w\]])/g;
127
127
  /** The open mainlines the text mentions; loads the hub only when the text mentions one. */
128
- function openMainlinesIn(root, text) {
128
+ function openMainlinesIn(dirs, text) {
129
129
  const mentioned = new Set([...text.matchAll(MAINLINE_MENTION)].map((m) => m[1]));
130
130
  if (mentioned.size === 0) return [];
131
- return loadHub(root, { remotes: false }).mainlines.filter((m) => mentioned.has(m.id) && !m.archivedAt && (m.status === "active" || m.status === "planning")).map((m) => m.id);
131
+ return loadHub(dirs, { remotes: false }).mainlines.filter((m) => mentioned.has(m.id) && !m.archivedAt && (m.status === "active" || m.status === "planning")).map((m) => m.id);
132
132
  }
133
133
  /** Tools whose `file_path` input writes a file. */
134
134
  const WRITE_TOOLS = /* @__PURE__ */ new Set([
@@ -139,16 +139,16 @@ const WRITE_TOOLS = /* @__PURE__ */ new Set([
139
139
  ]);
140
140
  /** A Bash command that runs the mainflow CLI: by the form the hook texts give, or as a `mainflow` command word that starts a command, not one that is only an argument. */
141
141
  const isMainflowCommand = (command, cli) => command.includes(cli) || /(?:^|[;&|(])\s*mainflow\s+\S|(?:\.bin|mainflow\/dist)\/(?:cli\.js|mainflow)\b/.test(command);
142
- /** Whether a path lies under a `.mainflow/` directory. */
143
- const inMainflowData = (path) => /(?:^|\/)\.mainflow\//.test(path);
142
+ /** Whether `path` is a file of a hub registered on this machine: it lies in that hub's data directory. */
143
+ const inHubData = (path, env) => hubHoldingFile(path, env) !== null;
144
144
  /** A user record that holds what the user typed; tool results come back as user records too, but carry only tool_result blocks. */
145
145
  const isUserPrompt = (r) => r.type === "user" && !r.isSidechain && (typeof r.message?.content === "string" || Array.isArray(r.message?.content) && r.message.content.some((b) => b.type === "text"));
146
146
  /**
147
147
  * Whether the turn that is ending, everything after the last user message in the transcript, ran a mainflow
148
- * command or wrote a file under `.mainflow/`. False when the transcript is missing or unreadable, so a hook that
148
+ * command or wrote a file of a registered hub. False when the transcript is missing or unreadable, so a hook that
149
149
  * cannot tell never asks.
150
150
  */
151
- function turnTouchedMainflow(transcript, cli) {
151
+ function turnTouchedMainflow(transcript, cli, env) {
152
152
  if (!transcript || !existsSync(transcript)) return false;
153
153
  let lines;
154
154
  try {
@@ -169,7 +169,7 @@ function turnTouchedMainflow(transcript, cli) {
169
169
  for (const block of content) {
170
170
  if (block.type !== "tool_use") continue;
171
171
  if (block.name === "Bash" && typeof block.input?.command === "string" && isMainflowCommand(block.input.command, cli)) return true;
172
- if (block.name && WRITE_TOOLS.has(block.name) && typeof block.input?.file_path === "string" && inMainflowData(block.input.file_path)) return true;
172
+ if (block.name && WRITE_TOOLS.has(block.name) && typeof block.input?.file_path === "string" && inHubData(block.input.file_path, env)) return true;
173
173
  }
174
174
  }
175
175
  return false;
@@ -177,20 +177,20 @@ function turnTouchedMainflow(transcript, cli) {
177
177
  /**
178
178
  * What to ask for when a turn ends without its header line: the header itself when an action is in progress, or
179
179
  * an action first when the queue is empty, the reply talks about an open mainline and the turn ran a mainflow
180
- * command or wrote under `.mainflow/`, since the header only names the session's own action in progress. A reply
180
+ * command or wrote a file of the hub, since the header only names the session's own action in progress. A reply
181
181
  * that only mentions a mainline, in a draft or a question, asks for nothing. Null when there is nothing to ask for.
182
182
  */
183
- function missingHeaderRequest(root, { payload, cli }) {
183
+ function missingHeaderRequest(dirs, { payload, cli, env }) {
184
184
  if (!payload.session_id || payload.stop_hook_active) return null;
185
185
  const message = payload.last_assistant_message ?? "";
186
186
  if (endsWithMarker(message)) return null;
187
- const session = readSession(root, claudeCodeSessionId(payload.session_id));
187
+ const session = readSession(dirs, claudeCodeSessionId(payload.session_id));
188
188
  const header = headerLine(session);
189
189
  if (header) return `[mainflow] 這一輪最後一則回覆的最後一行不是標頭。請只回一則內容為「${header}」的訊息,不加其他文字。`;
190
190
  if (session && session.actions.length > 0) return null;
191
- const mainlines = openMainlinesIn(root, message);
192
- if (mainlines.length === 0 || !turnTouchedMainflow(payload.transcript_path, cli)) return null;
193
- return `[mainflow] 這一輪的回覆提到 ${mainlines.join("、")},也執行了 mainflow 指令或寫入 .mainflow/,但這場會話沒有行動,所以回覆結尾沒有標頭。這一輪若在推進它,先用 ${cli} action add "<做法>" --serves <m/t#c>(還沒拆任務時用解決條件 <m#r>)列出行動並用 ${cli} action set <a編號> --status doing 標為進行中,或用 ${cli} session handoff <會話> 接手上一場沒做完的行動;再只回一則內容為「▸ <m/t> · <a編號>」的訊息,不加其他文字。只是提到、沒有推進時,不必處理。`;
191
+ const mainlines = openMainlinesIn(dirs, message);
192
+ if (mainlines.length === 0 || !turnTouchedMainflow(payload.transcript_path, cli, env)) return null;
193
+ return `[mainflow] 這一輪的回覆提到 ${mainlines.join("、")},也執行了 mainflow 指令或寫入 hub 的資料目錄,但這場會話沒有行動,所以回覆結尾沒有標頭。這一輪若在推進它,先用 ${cli} action add "<做法>" --serves <m/t#c>(還沒拆任務時用解決條件 <m#r>)列出行動並用 ${cli} action set <a編號> --status doing 標為進行中,或用 ${cli} session handoff <會話> 接手上一場沒做完的行動;再只回一則內容為「▸ <m/t> · <a編號>」的訊息,不加其他文字。只是提到、沒有推進時,不必處理。`;
194
194
  }
195
195
  /**
196
196
  * Stop: ask for what the turn's header needs, then settle the session. Only the model knows which message closes a
@@ -198,36 +198,36 @@ function missingHeaderRequest(root, { payload, cli }) {
198
198
  * stops it from asking twice in a row. A session asked to list an action is not settled yet: settling removes a
199
199
  * session with no actions, and the action it is asked to add needs the session to exist.
200
200
  */
201
- const settleAndCheckMarker = async (root, context) => {
202
- const request = missingHeaderRequest(root, context);
203
- const session = context.payload.session_id ? readSession(root, claudeCodeSessionId(context.payload.session_id)) : null;
204
- if (!request || session && session.actions.length > 0) await settleOnEnd(root, context);
201
+ const settleAndCheckMarker = async (dirs, context) => {
202
+ const request = missingHeaderRequest(dirs, context);
203
+ const session = context.payload.session_id ? readSession(dirs, claudeCodeSessionId(context.payload.session_id)) : null;
204
+ if (!request || session && session.actions.length > 0) await settleOnEnd(dirs, context);
205
205
  return request ? JSON.stringify({ hookSpecificOutput: {
206
206
  hookEventName: "Stop",
207
207
  additionalContext: request
208
208
  } }) : null;
209
209
  };
210
210
  const CLAUDE_CODE_EVENTS = {
211
- "session-start": async (root, { payload, envFile, cli, startUi, write }) => {
211
+ "session-start": async (dirs, { payload, envFile, cli, startUi, write }) => {
212
212
  if (!payload.session_id) return null;
213
213
  const id = claudeCodeSessionId(payload.session_id);
214
- const session = await recordSession(write, root, payload, id, true);
214
+ const session = await recordSession(write, dirs, payload, id, true);
215
215
  await write("session.reminder", {
216
216
  id,
217
217
  digest: null
218
218
  });
219
219
  if (envFile) appendFileSync(envFile, `export MAINFLOW_SESSION=${id}\n`);
220
- startUi?.(root);
221
- return sessionBriefing(root, session, {
220
+ startUi?.(dirs);
221
+ return sessionBriefing(dirs, session, {
222
222
  cli,
223
223
  envNote: envFile ? "已設定環境變數 MAINFLOW_SESSION,指令不必再帶 --session" : `執行指令時帶 --session ${id}`
224
224
  });
225
225
  },
226
- "user-prompt-submit": async (root, { payload, cli, write }) => {
226
+ "user-prompt-submit": async (dirs, { payload, cli, write }) => {
227
227
  if (!payload.session_id) return null;
228
- const session = await recordSession(write, root, payload, claudeCodeSessionId(payload.session_id), false);
228
+ const session = await recordSession(write, dirs, payload, claudeCodeSessionId(payload.session_id), false);
229
229
  const saved = [];
230
- const text = turnReminder(root, session, {
230
+ const text = turnReminder(dirs, session, {
231
231
  cli,
232
232
  saveDigest: (digest) => saved.push(write("session.reminder", {
233
233
  id: session.id,
@@ -239,20 +239,20 @@ const CLAUDE_CODE_EVENTS = {
239
239
  },
240
240
  stop: settleAndCheckMarker,
241
241
  "session-end": settleOnEnd,
242
- "subagent-start": async (root, { payload, write }) => {
242
+ "subagent-start": async (dirs, { payload, write }) => {
243
243
  if (!payload.agent_id) return null;
244
244
  const parent = payload.session_id ? claudeCodeSessionId(payload.session_id) : null;
245
245
  await write("session.ensure", {
246
246
  id: claudeCodeSessionId(payload.agent_id),
247
247
  agent: payload.agent_type ? `claude-code/${payload.agent_type}` : "claude-code",
248
- cwd: payload.cwd ?? root,
249
- delegatedFrom: parent && readSession(root, parent) ? parent : null
248
+ cwd: payload.cwd ?? dirs.root,
249
+ delegatedFrom: parent && readSession(dirs, parent) ? parent : null
250
250
  });
251
251
  return null;
252
252
  },
253
- "subagent-stop": async (root, { payload, write }) => {
253
+ "subagent-stop": async (dirs, { payload, write }) => {
254
254
  const id = payload.agent_id ? claudeCodeSessionId(payload.agent_id) : null;
255
- if (!id || !readSession(root, id)) return null;
255
+ if (!id || !readSession(dirs, id)) return null;
256
256
  const transcript = payload.transcript_path ? subagentTranscript(payload.transcript_path, payload.agent_id) : void 0;
257
257
  const meta = transcript ? readMeta(transcript) : {};
258
258
  await write("session.info", {
@@ -270,7 +270,7 @@ const CLAUDE_CODE_EVENTS = {
270
270
  });
271
271
  return null;
272
272
  },
273
- "pre-tool-use": (root, context) => typeof context.payload.tool_name === "string" && WRITE_TOOLS.has(context.payload.tool_name) ? decideStartGate(root, context) : decideByGrants(root, context),
273
+ "pre-tool-use": (dirs, context) => typeof context.payload.tool_name === "string" && WRITE_TOOLS.has(context.payload.tool_name) ? decideStartGate(dirs, context) : decideByGrants(dirs, context),
274
274
  "post-tool-use": checkAfterWrite,
275
275
  "message-display": displayReply
276
276
  };
@@ -333,21 +333,21 @@ async function logGranted(write, scope, call, request, grant, warn) {
333
333
  * print nothing, so other hooks and the usual permission rules decide them. Any failure asks: the caller turns a
334
334
  * throw into the fallback above.
335
335
  */
336
- async function decideByGrants(root, { payload, warn, write }) {
336
+ async function decideByGrants(dirs, { payload, warn, write }) {
337
337
  const scan = grantableCall(payload);
338
338
  if (!scan) return null;
339
339
  if (scan.kind === "refused") return askBecause(scan.reason);
340
340
  const sessionId = payload.session_id;
341
341
  if (typeof sessionId !== "string" || !sessionId) return askBecause("payload 沒有字串的 session_id");
342
- const scoped = grantScope(root, readSession(root, claudeCodeSessionId(sessionId)));
342
+ const scoped = grantScope(dirs, readSession(dirs, claudeCodeSessionId(sessionId)));
343
343
  if (!scoped.ok) return askBecause(scoped.reason);
344
- const config = readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
345
- const place = scopedCwd(payload.cwd, root, config.repos);
344
+ const repos = repoPaths(readYaml(join(dirs.data, "config.yaml"), configSchema).data.repos);
345
+ const place = scopedCwd(payload.cwd, dirs.root, repos);
346
346
  if (!place.ok) return askBecause(place.reason);
347
347
  const resolved = resolveFixed(scan.call, {
348
348
  cwd: place.dir,
349
- root,
350
- repos: config.repos
349
+ root: dirs.root,
350
+ repos
351
351
  });
352
352
  if (!resolved.ok) return askBecause(resolved.reason);
353
353
  const match = matchGrants(scoped.scope, [resolved.request]);
@@ -365,15 +365,15 @@ function hasScenarioLine(body) {
365
365
  return lines.slice(start + 1, end === -1 ? void 0 : end).some((line) => SCENARIO_LINES.confirmed.pattern.test(line) || SCENARIO_LINES.skipped.pattern.test(line));
366
366
  }
367
367
  /** Whether a path lies under `tmp/` at the root of the hub or of a repository config.yaml lists. */
368
- function inProjectTmp(file, root) {
369
- const repos = readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data.repos;
370
- return [root, ...Object.values(repos).map((path) => resolve(root, path))].some((dir) => relative(dir, file).startsWith(`tmp/`));
368
+ function inProjectTmp(file, dirs) {
369
+ const repos = repoPaths(readYaml(join(dirs.data, "config.yaml"), configSchema).data.repos);
370
+ return [dirs.root, ...Object.values(repos).map((path) => resolve(dirs.root, path))].some((dir) => relative(dir, file).startsWith(`tmp/`));
371
371
  }
372
372
  /**
373
373
  * The open tasks of this hub that the session's actions in progress serve, created on or after START_GATE_FROM,
374
374
  * whose 紀錄 has neither line. A task that cannot be read is left out: the gate only stops what it can show is missing.
375
375
  */
376
- function unconfirmedTasks(root, session) {
376
+ function unconfirmedTasks(dirs, session) {
377
377
  const tasks = /* @__PURE__ */ new Set();
378
378
  for (const action of session.actions) {
379
379
  if (action.status !== "doing") continue;
@@ -385,7 +385,7 @@ function unconfirmedTasks(root, session) {
385
385
  return [...tasks].filter((ref) => {
386
386
  const [mainline, task] = ref.split("/");
387
387
  try {
388
- const { data, body } = readFrontMatter(taskFile(root, mainline, task), taskSchema);
388
+ const { data, body } = readFrontMatter(taskFile(dirs, mainline, task), taskSchema);
389
389
  return data.created >= "2026-10-19" && data.status !== "done" && data.status !== "dropped" && !hasScenarioLine(body);
390
390
  } catch {
391
391
  return false;
@@ -395,19 +395,19 @@ function unconfirmedTasks(root, session) {
395
395
  /**
396
396
  * PreToolUse for the tools that write a file: deny the write while a task the session's action in progress
397
397
  * serves has neither a confirmation nor a skip line under 紀錄, so code waits for its acceptance scenarios.
398
- * Writes under `.mainflow/` or a project's `tmp/`, a session with no record or no such task, print nothing. A
398
+ * Writes to a registered hub's data directory or a project's `tmp/`, a session with no record or no such task, print nothing. A
399
399
  * subagent's call carries the main session's session_id, so it is judged by the main session's action. Any failure
400
400
  * stays silent, since the fallback only answers Bash, and the write goes on.
401
401
  */
402
- function decideStartGate(root, { payload, cli }) {
402
+ function decideStartGate(dirs, { payload, cli, env }) {
403
403
  const path = payload.tool_input?.file_path ?? payload.tool_input?.notebook_path;
404
404
  const sessionId = payload.session_id;
405
405
  if (typeof path !== "string" || !path || typeof sessionId !== "string" || !sessionId) return null;
406
- const file = isAbsolute(path) ? path : resolve(typeof payload.cwd === "string" ? payload.cwd : root, path);
407
- if (inMainflowData(file) || inProjectTmp(file, root)) return null;
408
- const session = readSession(root, claudeCodeSessionId(sessionId));
406
+ const file = isAbsolute(path) ? path : resolve(typeof payload.cwd === "string" ? payload.cwd : dirs.root, path);
407
+ if (inHubData(file, env) || inProjectTmp(file, dirs)) return null;
408
+ const session = readSession(dirs, claudeCodeSessionId(sessionId));
409
409
  if (!session) return null;
410
- const missing = unconfirmedTasks(root, session);
410
+ const missing = unconfirmedTasks(dirs, session);
411
411
  if (missing.length === 0) return null;
412
412
  const reason = [
413
413
  `${missing.join("、")} 的「紀錄」沒有驗收情境的確認行,也沒有略過行,所以先不寫檔。`,
@@ -422,35 +422,29 @@ function decideStartGate(root, { payload, cli }) {
422
422
  }
423
423
  /** How many problems outside the written file the reminder lists before it only gives the count. */
424
424
  const OTHER_PROBLEMS_SHOWN = 5;
425
- const problemLine = (p) => `- ${p.file}${p.field ? ` › ${p.field}` : ""}:${p.message}`;
425
+ /** A problem as the reminder lists it, its file as the absolute path the model opens. */
426
+ const problemLine = (dirs, p) => `- ${dataFile(dirs, p.file)}${p.field ? ` › ${p.field}` : ""}:${p.message}`;
426
427
  /**
427
- * The hub a written file belongs to: the nearest `.mainflow` above it that holds config.yaml. It is
428
- * found from the file itself rather than from the session's directory, because a worktree maps the
429
- * session to the main tree's hub while the file was written to the worktree's own copy.
428
+ * After a tool writes into a hub's data directory, run the same checks as `mainflow check` and hand the problems
429
+ * to the model in the same turn. Edit, Write and MultiEdit name the file; Bash only counts when its command mentions
430
+ * `.mainflow` or the hub's data directory, a text match that misses paths built from variables. A clean hub, or a
431
+ * write elsewhere, adds nothing. The hub a written file belongs to is the registered hub whose data directory
432
+ * holds it, found from the file itself rather than from the session's directory, because a session may write
433
+ * into another registered hub.
430
434
  */
431
- function hubOfFile(file) {
432
- for (let dir = dirname(file); dirname(dir) !== dir; dir = dirname(dir)) if (basename(dir) === ".mainflow" && existsSync(join(dir, "config.yaml"))) return dirname(dir);
433
- return null;
434
- }
435
- /**
436
- * After a tool writes under `.mainflow/`, run the same checks as `mainflow check` and hand the problems
437
- * to the model in the same turn. Edit, Write and MultiEdit name the file; Bash only counts when its
438
- * command mentions `.mainflow`, a text match that misses paths built from variables. A clean hub, or a
439
- * write elsewhere, adds nothing.
440
- */
441
- function checkAfterWrite(root, { payload, cli }) {
435
+ function checkAfterWrite(dirs, { payload, cli, env }) {
442
436
  const input = payload.tool_input ?? {};
443
- let hub = root;
437
+ let hub = dirs;
444
438
  let written = null;
445
439
  if (payload.tool_name === "Bash") {
446
- if (!input.command?.includes(".mainflow")) return null;
440
+ if (!input.command?.includes(".mainflow") && !input.command?.includes(dirs.data)) return null;
447
441
  } else {
448
442
  if (!input.file_path) return null;
449
- const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? root, input.file_path);
450
- const owner = hubOfFile(file);
443
+ const file = isAbsolute(input.file_path) ? input.file_path : resolve(payload.cwd ?? dirs.root, input.file_path);
444
+ const owner = hubHoldingFile(file, env);
451
445
  if (!owner) return null;
452
446
  hub = owner;
453
- written = relative(owner, file);
447
+ written = hubPath(owner, realPath(file) ?? file);
454
448
  }
455
449
  let problems;
456
450
  try {
@@ -469,10 +463,11 @@ function checkAfterWrite(root, { payload, cli }) {
469
463
  if (problems.length === 0) return null;
470
464
  const own = written ? problems.filter((p) => p.file === written) : [];
471
465
  const others = problems.filter((p) => !own.includes(p));
466
+ const line = (p) => problemLine(hub, p);
472
467
  const lines = [
473
- written && own.length ? `[mainflow] 剛寫入的 ${written} 有 ${own.length} 個問題,改好再繼續:` : `[mainflow] 寫入 .mainflow/ 後,check 找到 ${problems.length} 個問題:`,
474
- ...own.map(problemLine),
475
- ...others.length ? [...own.length ? [`其他檔案還有 ${others.length} 個問題:`] : [], ...others.slice(0, OTHER_PROBLEMS_SHOWN).map(problemLine)] : [],
468
+ written && own.length ? `[mainflow] 剛寫入的 ${dataFile(hub, written)} 有 ${own.length} 個問題,改好再繼續:` : `[mainflow] 寫入 hub 的資料目錄 ${hub.data} 後,check 找到 ${problems.length} 個問題:`,
469
+ ...own.map(line),
470
+ ...others.length ? [...own.length ? [`其他檔案還有 ${others.length} 個問題:`] : [], ...others.slice(0, OTHER_PROBLEMS_SHOWN).map(line)] : [],
476
471
  ...others.length > OTHER_PROBLEMS_SHOWN ? [`還有 ${others.length - OTHER_PROBLEMS_SHOWN} 個沒列出,跑 ${cli} check 看全部。`] : []
477
472
  ];
478
473
  return JSON.stringify({ hookSpecificOutput: {
@@ -484,17 +479,46 @@ const HOOK_PLATFORMS = { "claude-code": CLAUDE_CODE_EVENTS };
484
479
  const HOOK_FALLBACKS = { "claude-code": CLAUDE_CODE_FALLBACKS };
485
480
  /**
486
481
  * Events that find the hub from $CLAUDE_PROJECT_DIR before the payload's cwd. A publish runs in the package's own
487
- * repository, which config.yaml may list outside the hub directory (../mainflow), so its cwd leads to no hub.
482
+ * repository, which may be one the session's hub only references, so its cwd leads to another hub or to none, while
483
+ * the grants that cover it are the session's hub's.
488
484
  */
489
485
  const PROJECT_HUB_EVENTS = /* @__PURE__ */ new Set(["pre-tool-use"]);
490
- function hubRootFor(event, ctx) {
491
- 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());
486
+ /** The directory whose hub an event works on: $CLAUDE_PROJECT_DIR for the events above when set, the payload's cwd otherwise. */
487
+ const hookDirs = (event, ctx) => [...PROJECT_HUB_EVENTS.has(event) && ctx.projectDir && isAbsolute(ctx.projectDir) ? [ctx.projectDir] : [], typeof ctx.payload.cwd === "string" ? ctx.payload.cwd : process.cwd()];
488
+ /**
489
+ * The registered hub of the first of the event's directories that has one (docs/file-format.md "hub 對照表"). The
490
+ * registry's lock is never waited for, since the host stops a hook that runs past its timeout.
491
+ */
492
+ function hubFor(event, ctx, warn) {
493
+ for (const dir of hookDirs(event, ctx)) {
494
+ const hub = resolveHub(dir, {
495
+ env: ctx.env,
496
+ warn,
497
+ waitForLock: false
498
+ });
499
+ if (hub) return hub;
500
+ }
501
+ return null;
502
+ }
503
+ /**
504
+ * SessionStart in a repository that still keeps a hub's data in its .mainflow/ and that this machine has not
505
+ * registered: tell the model, since every other hook stays silent there and nothing else would show that mainflow is
506
+ * not working.
507
+ */
508
+ function unregisteredNotice(event, ctx) {
509
+ const dir = hookDirs(event, ctx).at(-1);
510
+ const found = inRepositoryHubAt(dir);
511
+ if (!found) return null;
512
+ return {
513
+ stdout: event === "session-start" ? `[mainflow] 這個倉庫的 hub「${found.name}」的資料還在倉庫裡的 .mainflow/,這台機器也還沒登記它,所以 mainflow 的 hook 不會運作。執行 ${ctx.cli} hub migrate-data ${found.root} 把資料搬到使用者層級的 mainflow 目錄並登記後就恢復。` : null,
514
+ error: `mainflow hook claude-code ${event}: ${notRegisteredMessage(dir)}`
515
+ };
492
516
  }
493
517
  /** Events whose output reaches the model before it works, so they carry the notice that the hub's data format does not match this CLI. */
494
518
  const FORMAT_NOTICE_EVENTS = /* @__PURE__ */ new Set(["session-start", "user-prompt-submit"]);
495
519
  /** What the model is told when the hub's data format is not the one this CLI reads and writes; null when it is. */
496
- function formatNotice(root, cli) {
497
- const found = hubFormatVersion(nodeStorage(root));
520
+ function formatNotice(dirs, cli) {
521
+ const found = hubFormatVersion(nodeStorage(dirs));
498
522
  const reason = found === null ? null : formatMismatch(found);
499
523
  if (found === null || reason === null) return null;
500
524
  return {
@@ -526,25 +550,35 @@ async function runHook(platform, event, ctx) {
526
550
  const warnings = [];
527
551
  const errorText = (message) => [...warnings, ...message ? [message] : []].join("\n") || null;
528
552
  try {
529
- const root = hubRootFor(event, ctx);
530
- if (!root) return {
531
- stdout: hookFallback(platform, event, ctx, { kind: "outside-hub" }),
532
- error: null
533
- };
534
553
  const warn = (message) => warnings.push(message);
535
- const writer = ctx.write ?? hubWriter(root);
554
+ const dirs = hubFor(event, ctx, (message) => warn(`mainflow hook ${platform} ${event}: ${message}`));
555
+ if (!dirs) {
556
+ const unregistered = unregisteredNotice(event, ctx);
557
+ return {
558
+ stdout: hookFallback(platform, event, ctx, { kind: "outside-hub" }) ?? unregistered?.stdout ?? null,
559
+ error: errorText(unregistered?.error)
560
+ };
561
+ }
562
+ if (!existsSync(join(dirs.data, "config.yaml"))) {
563
+ const told = FORMAT_NOTICE_EVENTS.has(event);
564
+ return {
565
+ stdout: event === "session-start" ? `[mainflow] hub「${dirs.name}」的資料還沒下載到這台機器,所以 mainflow 的 hook 不會運作。先設好 MAINFLOW_SYNC_CLIENT_ID 與 MAINFLOW_SYNC_CLIENT_SECRET,再執行 ${ctx.cli} sync 下載後就恢復。` : null,
566
+ error: told ? errorText(`mainflow hook ${platform} ${event}: hub "${dirs.name}" has no config.yaml in ${dirs.data} yet; run mainflow sync to download it`) : errorText()
567
+ };
568
+ }
569
+ const writer = ctx.write ?? hubWriter(dirs, process.env, { hook: true });
536
570
  const write = async (op, input) => {
537
571
  const written = await writer(op, input);
538
572
  for (const line of writtenLines(written)) if (line.startsWith("!")) warn(`mainflow hook ${platform} ${event}: ${line.slice(2)}`);
539
573
  return written;
540
574
  };
541
- const notice = FORMAT_NOTICE_EVENTS.has(event) ? formatNotice(root, ctx.cli) : null;
575
+ const notice = FORMAT_NOTICE_EVENTS.has(event) ? formatNotice(dirs, ctx.cli) : null;
542
576
  if (notice) return {
543
577
  stdout: notice.text,
544
578
  error: errorText(`mainflow hook ${platform} ${event}: ${notice.reason}`)
545
579
  };
546
580
  return {
547
- stdout: await handler(root, {
581
+ stdout: await handler(dirs, {
548
582
  ...ctx,
549
583
  warn,
550
584
  write