mainflow 0.27.0 → 0.29.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,5 +1,6 @@
1
- import { B as setSessionStatus, G as grantScope, I as loadSessions, J as endsWithMarker, K as matchGrants, L as readSession, M as turnReminder, P as ensureSession, V as settleSession, X as mayDecorate, Y as headerLine, Z as appendOps, _t as DATA_DIR, bt as worktreesOf, gt as readYaml, i as hostName, j as sessionBriefing, ot as HubError, q as decorateReply, st as loadHub, vt as currentBranch, yt as findHubRoot, z as setSessionInfo } from "./host-sSWrUJeC.js";
2
- import { Z as describeRequest, j as configSchema } from "./dist-CBGKXsoi.js";
1
+ import { D as endsWithMarker, E as decorateReply, G as loadHub, O as headerLine, S as readSession, T as matchGrants, W as HubError, b as turnReminder, et as readYaml, i as hostName, it as worktreesOf, k as mayDecorate, nt as currentBranch, ot as DATA_DIR, rt as findHubRoot, w as grantScope, x as loadSessions, y as sessionBriefing } from "./host-BJnrv8xF.js";
2
+ import { Z as describeRequest, j as configSchema } from "./dist-BbamrIPW.js";
3
+ import { n as hubWriter, r as writtenLines } from "./operation-Bp4U2bx3.js";
3
4
  import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
4
5
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
5
6
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -74,24 +75,27 @@ const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
74
75
  * bring an ended or archived one back, since the viewer is working in it again. Its environment facts are
75
76
  * written when it is created and whenever `refresh` asks, such as on SessionStart after a resume.
76
77
  */
77
- function recordSession(root, payload, id, refresh) {
78
+ async function recordSession(write, root, payload, id, refresh) {
78
79
  const cwd = payload.cwd ?? root;
79
- const { session, created } = ensureSession(root, {
80
+ const { session, created } = (await write("session.ensure", {
80
81
  id,
81
82
  agent: "claude-code",
82
83
  cwd
83
- });
84
+ })).value;
84
85
  if (!created && !refresh) return session;
85
- return setSessionInfo(root, id, {
86
- transcript: payload.transcript_path,
87
- worktree: worktreesOf(cwd)?.current,
88
- branch: currentBranch(cwd) ?? void 0,
89
- effort: payload.effort?.level
90
- });
86
+ return (await write("session.info", {
87
+ id,
88
+ info: {
89
+ transcript: payload.transcript_path,
90
+ worktree: worktreesOf(cwd)?.current,
91
+ branch: currentBranch(cwd) ?? void 0,
92
+ effort: payload.effort?.level
93
+ }
94
+ })).value;
91
95
  }
92
96
  /** Settle the main session by the rules settleSession states; prints nothing. */
93
- const settleOnEnd = (root, { payload }) => {
94
- if (payload.session_id) settleSession(root, claudeCodeSessionId(payload.session_id));
97
+ const settleOnEnd = async (_root, { payload, write }) => {
98
+ if (payload.session_id) await write("session.settle", { id: claudeCodeSessionId(payload.session_id) });
95
99
  return null;
96
100
  };
97
101
  /** How a hub's interface is linked from a reply: through its /open route, which shows the address in the app. */
@@ -125,10 +129,55 @@ function openMainlinesIn(root, text) {
125
129
  if (mentioned.size === 0) return [];
126
130
  return loadHub(root, { remotes: false }).mainlines.filter((m) => mentioned.has(m.id) && !m.archivedAt && (m.status === "active" || m.status === "planning")).map((m) => m.id);
127
131
  }
132
+ /** Tools whose `file_path` input writes a file. */
133
+ const WRITE_TOOLS = /* @__PURE__ */ new Set([
134
+ "Edit",
135
+ "Write",
136
+ "MultiEdit",
137
+ "NotebookEdit"
138
+ ]);
139
+ /** 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. */
140
+ const isMainflowCommand = (command, cli) => command.includes(cli) || /(?:^|[;&|(])\s*mainflow\s+\S|(?:\.bin|mainflow\/dist)\/(?:cli\.js|mainflow)\b/.test(command);
141
+ /** Whether a path lies under a `.mainflow/` directory. */
142
+ const inMainflowData = (path) => /(?:^|\/)\.mainflow\//.test(path);
143
+ /** A user record that holds what the user typed; tool results come back as user records too, but carry only tool_result blocks. */
144
+ 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"));
145
+ /**
146
+ * Whether the turn that is ending, everything after the last user message in the transcript, ran a mainflow
147
+ * command or wrote a file under `.mainflow/`. False when the transcript is missing or unreadable, so a hook that
148
+ * cannot tell never asks.
149
+ */
150
+ function turnTouchedMainflow(transcript, cli) {
151
+ if (!transcript || !existsSync(transcript)) return false;
152
+ let lines;
153
+ try {
154
+ lines = readFileSync(transcript, "utf8").split("\n");
155
+ } catch {
156
+ return false;
157
+ }
158
+ for (let i = lines.length - 1; i >= 0; i--) {
159
+ if (!lines[i].trim()) continue;
160
+ let record;
161
+ try {
162
+ record = JSON.parse(lines[i]);
163
+ } catch {
164
+ continue;
165
+ }
166
+ if (isUserPrompt(record)) return false;
167
+ const content = Array.isArray(record.message?.content) ? record.message.content : [];
168
+ for (const block of content) {
169
+ if (block.type !== "tool_use") continue;
170
+ if (block.name === "Bash" && typeof block.input?.command === "string" && isMainflowCommand(block.input.command, cli)) return true;
171
+ if (block.name && WRITE_TOOLS.has(block.name) && typeof block.input?.file_path === "string" && inMainflowData(block.input.file_path)) return true;
172
+ }
173
+ }
174
+ return false;
175
+ }
128
176
  /**
129
177
  * What to ask for when a turn ends without its header line: the header itself when an action is in progress, or
130
- * an action first when the queue is empty but the reply talks about an open mainline, since the header only names
131
- * the session's own action in progress. Null when there is nothing to ask for.
178
+ * an action first when the queue is empty, the reply talks about an open mainline and the turn ran a mainflow
179
+ * command or wrote under `.mainflow/`, since the header only names the session's own action in progress. A reply
180
+ * that only mentions a mainline, in a draft or a question, asks for nothing. Null when there is nothing to ask for.
132
181
  */
133
182
  function missingHeaderRequest(root, { payload, cli }) {
134
183
  if (!payload.session_id || payload.stop_hook_active) return null;
@@ -139,8 +188,8 @@ function missingHeaderRequest(root, { payload, cli }) {
139
188
  if (header) return `[mainflow] 這一輪最後一則回覆的最後一行不是標頭。請只回一則內容為「${header}」的訊息,不加其他文字。`;
140
189
  if (session && session.actions.length > 0) return null;
141
190
  const mainlines = openMainlinesIn(root, message);
142
- if (mainlines.length === 0) return null;
143
- return `[mainflow] 這一輪的回覆提到 ${mainlines.join("、")},但這場會話沒有行動,所以回覆結尾沒有標頭。這一輪若在推進它,先用 ${cli} action add "<做法>" --serves <m/t#c>(還沒拆任務時用解決條件 <m#r>)列出行動並用 ${cli} action set <a編號> --status doing 標為進行中,或用 ${cli} session handoff <會話> 接手上一場沒做完的行動;再只回一則內容為「▸ <m/t> · <a編號>」的訊息,不加其他文字。只是提到、沒有推進時,不必處理。`;
191
+ if (mainlines.length === 0 || !turnTouchedMainflow(payload.transcript_path, cli)) return null;
192
+ 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編號>」的訊息,不加其他文字。只是提到、沒有推進時,不必處理。`;
144
193
  }
145
194
  /**
146
195
  * Stop: ask for what the turn's header needs, then settle the session. Only the model knows which message closes a
@@ -148,20 +197,24 @@ function missingHeaderRequest(root, { payload, cli }) {
148
197
  * stops it from asking twice in a row. A session asked to list an action is not settled yet: settling removes a
149
198
  * session with no actions, and the action it is asked to add needs the session to exist.
150
199
  */
151
- const settleAndCheckMarker = (root, context) => {
200
+ const settleAndCheckMarker = async (root, context) => {
152
201
  const request = missingHeaderRequest(root, context);
153
202
  const session = context.payload.session_id ? readSession(root, claudeCodeSessionId(context.payload.session_id)) : null;
154
- if (!request || session && session.actions.length > 0) settleOnEnd(root, context);
203
+ if (!request || session && session.actions.length > 0) await settleOnEnd(root, context);
155
204
  return request ? JSON.stringify({ hookSpecificOutput: {
156
205
  hookEventName: "Stop",
157
206
  additionalContext: request
158
207
  } }) : null;
159
208
  };
160
209
  const CLAUDE_CODE_EVENTS = {
161
- "session-start": (root, { payload, envFile, cli, startUi }) => {
210
+ "session-start": async (root, { payload, envFile, cli, startUi, write }) => {
162
211
  if (!payload.session_id) return null;
163
212
  const id = claudeCodeSessionId(payload.session_id);
164
- const session = recordSession(root, payload, id, true);
213
+ const session = await recordSession(write, root, payload, id, true);
214
+ await write("session.reminder", {
215
+ id,
216
+ digest: null
217
+ });
165
218
  if (envFile) appendFileSync(envFile, `export MAINFLOW_SESSION=${id}\n`);
166
219
  startUi?.(root);
167
220
  return sessionBriefing(root, session, {
@@ -169,16 +222,26 @@ const CLAUDE_CODE_EVENTS = {
169
222
  envNote: envFile ? "已設定環境變數 MAINFLOW_SESSION,指令不必再帶 --session" : `執行指令時帶 --session ${id}`
170
223
  });
171
224
  },
172
- "user-prompt-submit": (root, { payload, cli }) => {
225
+ "user-prompt-submit": async (root, { payload, cli, write }) => {
173
226
  if (!payload.session_id) return null;
174
- return turnReminder(root, recordSession(root, payload, claudeCodeSessionId(payload.session_id), false), { cli });
227
+ const session = await recordSession(write, root, payload, claudeCodeSessionId(payload.session_id), false);
228
+ const saved = [];
229
+ const text = turnReminder(root, session, {
230
+ cli,
231
+ saveDigest: (digest) => saved.push(write("session.reminder", {
232
+ id: session.id,
233
+ digest
234
+ }).catch(() => void 0))
235
+ });
236
+ await Promise.all(saved);
237
+ return text;
175
238
  },
176
239
  stop: settleAndCheckMarker,
177
240
  "session-end": settleOnEnd,
178
- "subagent-start": (root, { payload }) => {
241
+ "subagent-start": async (root, { payload, write }) => {
179
242
  if (!payload.agent_id) return null;
180
243
  const parent = payload.session_id ? claudeCodeSessionId(payload.session_id) : null;
181
- ensureSession(root, {
244
+ await write("session.ensure", {
182
245
  id: claudeCodeSessionId(payload.agent_id),
183
246
  agent: payload.agent_type ? `claude-code/${payload.agent_type}` : "claude-code",
184
247
  cwd: payload.cwd ?? root,
@@ -186,18 +249,24 @@ const CLAUDE_CODE_EVENTS = {
186
249
  });
187
250
  return null;
188
251
  },
189
- "subagent-stop": (root, { payload }) => {
252
+ "subagent-stop": async (root, { payload, write }) => {
190
253
  const id = payload.agent_id ? claudeCodeSessionId(payload.agent_id) : null;
191
254
  if (!id || !readSession(root, id)) return null;
192
255
  const transcript = payload.transcript_path ? subagentTranscript(payload.transcript_path, payload.agent_id) : void 0;
193
256
  const meta = transcript ? readMeta(transcript) : {};
194
- setSessionInfo(root, id, {
195
- transcript: transcript && existsSync(transcript) ? transcript : void 0,
196
- description: meta.description,
197
- depth: meta.spawnDepth,
198
- summary: brief(payload.last_assistant_message) || void 0
257
+ await write("session.info", {
258
+ id,
259
+ info: {
260
+ transcript: transcript && existsSync(transcript) ? transcript : void 0,
261
+ description: meta.description,
262
+ depth: meta.spawnDepth,
263
+ summary: brief(payload.last_assistant_message) || void 0
264
+ }
265
+ });
266
+ await write("session.set", {
267
+ id,
268
+ status: "ended"
199
269
  });
200
- setSessionStatus(root, id, "ended");
201
270
  return null;
202
271
  },
203
272
  "pre-tool-use": decideByGrants,
@@ -243,9 +312,9 @@ function askWhenGrantable({ payload }, failure) {
243
312
  /** The ops log's fields cannot hold its separator, so notes that contain one are written with a broken bar. */
244
313
  const opsText = (text) => text.replace(/\s\|\s/g, " ¦ ").replace(/\s+/g, " ").slice(0, 200);
245
314
  /** 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) {
315
+ async function logGranted(write, scope, call, request, grant, warn) {
247
316
  try {
248
- appendOps(root, {
317
+ await write("ops.add", {
249
318
  target: request.action === "publish" ? `npm ${request.name}` : `github release ${request.repos.join("/")}`,
250
319
  operation: opsText(call.text),
251
320
  result: `授權放行 ${grant.ref}(${describeRequest(request)})`,
@@ -263,7 +332,7 @@ function logGranted(root, scope, call, request, grant, warn) {
263
332
  * print nothing, so other hooks and the usual permission rules decide them. Any failure asks: the caller turns a
264
333
  * throw into the fallback above.
265
334
  */
266
- function decideByGrants(root, { payload, warn }) {
335
+ async function decideByGrants(root, { payload, warn, write }) {
267
336
  const scan = grantableCall(payload);
268
337
  if (!scan) return null;
269
338
  if (scan.kind === "refused") return askBecause(scan.reason);
@@ -283,7 +352,7 @@ function decideByGrants(root, { payload, warn }) {
283
352
  const match = matchGrants(scoped.scope, [resolved.request]);
284
353
  if (!match.ok) return askBecause(match.reason);
285
354
  const { grant } = match.matches[0];
286
- logGranted(root, scoped.scope, scan.call, resolved.request, grant, warn);
355
+ await logGranted(write, scoped.scope, scan.call, resolved.request, grant, warn);
287
356
  return permission("allow", `主脈絡授權 ${grant.ref} 涵蓋 ${describeRequest(resolved.request)}`);
288
357
  }
289
358
  /** How many problems outside the written file the reminder lists before it only gives the count. */
@@ -371,7 +440,7 @@ function hookFallback(platform, event, ctx, failure) {
371
440
  * failure is reported on stderr while the caller still exits 0. Events listed in the fallbacks print their
372
441
  * fallback instead of staying silent.
373
442
  */
374
- function runHook(platform, event, ctx) {
443
+ async function runHook(platform, event, ctx) {
375
444
  const handler = HOOK_PLATFORMS[platform]?.[event];
376
445
  if (!handler) return {
377
446
  stdout: null,
@@ -385,10 +454,18 @@ function runHook(platform, event, ctx) {
385
454
  stdout: hookFallback(platform, event, ctx, { kind: "outside-hub" }),
386
455
  error: null
387
456
  };
457
+ const warn = (message) => warnings.push(message);
458
+ const writer = ctx.write ?? hubWriter(root);
459
+ const write = async (op, input) => {
460
+ const written = await writer(op, input);
461
+ for (const line of writtenLines(written)) if (line.startsWith("!")) warn(`mainflow hook ${platform} ${event}: ${line.slice(2)}`);
462
+ return written;
463
+ };
388
464
  return {
389
- stdout: handler(root, {
465
+ stdout: await handler(root, {
390
466
  ...ctx,
391
- warn: (message) => warnings.push(message)
467
+ warn,
468
+ write
392
469
  }),
393
470
  error: errorText()
394
471
  };