mainflow 0.41.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/cli.js +755 -155
- package/dist/{dist-zSabglPq.js → dist-D5rZ0MQH.js} +71 -23
- package/dist/facts.d.ts +61 -12
- package/dist/facts.js +56 -13
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +5 -540
- package/dist/hooks.js +126 -92
- package/dist/{host-BAMZmRy8.js → host-BJvqZy-i.js} +1374 -489
- package/dist/host.d.ts +3 -2
- package/dist/host.js +1 -1
- package/dist/{index-Dk4fCI7q.d.ts → index-BqPQp6TF.d.ts} +2 -8
- package/dist/index-DRpamPge.d.ts +550 -0
- package/dist/launch.d.ts +6 -5
- package/dist/launch.js +323 -1
- package/dist/native/mainflow-swap-darwin-arm64 +0 -0
- package/dist/native/mainflow-swap-darwin-x64 +0 -0
- package/dist/{operation-DlR1P_Pt.js → operation-BhBbGauo.js} +610 -343
- package/dist/{ui-9uuI4Jif.js → ui-xxzmhAqF.js} +107 -98
- package/dist/version-Dvv4jwF1.js +6 -0
- package/dist/web/assets/{index-DvYj_6IC.js → index-B4w2iRCQ.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +2 -2
- package/dist/launch-CMeKJsNd.js +0 -327
package/dist/hooks.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { n as hubWriter, r as writtenLines } from "./operation-
|
|
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 {
|
|
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,
|
|
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 = (
|
|
110
|
+
const displayReply = (dirs, { payload }) => {
|
|
111
111
|
const text = payload.delta;
|
|
112
112
|
if (!text || !mayDecorate(text)) return null;
|
|
113
|
-
const hub = loadHub(
|
|
114
|
-
const session = payload.session_id ? readSession(
|
|
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(
|
|
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(
|
|
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
|
|
143
|
-
const
|
|
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
|
|
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" &&
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
192
|
-
if (mainlines.length === 0 || !turnTouchedMainflow(payload.transcript_path, cli)) return null;
|
|
193
|
-
return `[mainflow] 這一輪的回覆提到 ${mainlines.join("、")},也執行了 mainflow 指令或寫入
|
|
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 (
|
|
202
|
-
const request = missingHeaderRequest(
|
|
203
|
-
const session = context.payload.session_id ? readSession(
|
|
204
|
-
if (!request || session && session.actions.length > 0) await settleOnEnd(
|
|
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 (
|
|
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,
|
|
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?.(
|
|
221
|
-
return sessionBriefing(
|
|
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 (
|
|
226
|
+
"user-prompt-submit": async (dirs, { payload, cli, write }) => {
|
|
227
227
|
if (!payload.session_id) return null;
|
|
228
|
-
const session = await recordSession(write,
|
|
228
|
+
const session = await recordSession(write, dirs, payload, claudeCodeSessionId(payload.session_id), false);
|
|
229
229
|
const saved = [];
|
|
230
|
-
const text = turnReminder(
|
|
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 (
|
|
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(
|
|
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 (
|
|
253
|
+
"subagent-stop": async (dirs, { payload, write }) => {
|
|
254
254
|
const id = payload.agent_id ? claudeCodeSessionId(payload.agent_id) : null;
|
|
255
|
-
if (!id || !readSession(
|
|
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": (
|
|
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(
|
|
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(
|
|
342
|
+
const scoped = grantScope(dirs, readSession(dirs, claudeCodeSessionId(sessionId)));
|
|
343
343
|
if (!scoped.ok) return askBecause(scoped.reason);
|
|
344
|
-
const
|
|
345
|
-
const place = scopedCwd(payload.cwd, root,
|
|
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
|
|
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,
|
|
369
|
-
const repos = readYaml(join(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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 (
|
|
408
|
-
const session = readSession(
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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] 寫入 .
|
|
474
|
-
...own.map(
|
|
475
|
-
...others.length ? [...own.length ? [`其他檔案還有 ${others.length} 個問題:`] : [], ...others.slice(0, OTHER_PROBLEMS_SHOWN).map(
|
|
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
|
|
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
|
-
|
|
491
|
-
|
|
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(
|
|
497
|
-
const found = hubFormatVersion(nodeStorage(
|
|
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
|
|
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(
|
|
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(
|
|
581
|
+
stdout: await handler(dirs, {
|
|
548
582
|
...ctx,
|
|
549
583
|
warn,
|
|
550
584
|
write
|