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/cli.d.ts +3 -1
- package/dist/cli.js +818 -209
- package/dist/{dist-CBGKXsoi.js → dist-BbamrIPW.js} +2 -0
- package/dist/facts.d.ts +71 -3
- package/dist/facts.js +74 -7
- package/dist/grant-commands.d.ts +1 -1
- package/dist/grant-commands.js +1 -1
- package/dist/hooks.d.ts +514 -3
- package/dist/hooks.js +116 -39
- package/dist/host-BJnrv8xF.js +7300 -0
- package/dist/host.js +1 -1
- package/dist/index-DV6Gqu8K.d.ts +423 -0
- package/dist/{launch-1i4OAsD6.js → launch-DefacJEL.js} +2 -2
- package/dist/launch.js +1 -1
- package/dist/native/mainflow-swap-linux-x64 +0 -0
- package/dist/native/mainflow-swap.c +67 -0
- package/dist/operation-Bp4U2bx3.js +2862 -0
- package/dist/retro-formats-ECy0gGOL.js +30 -0
- package/dist/ui-CiapFoik.js +658 -0
- package/dist/web/assets/index-CyQML0b0.css +1 -0
- package/dist/web/assets/index-tAb99n75.js +82 -0
- package/dist/web/index.html +2 -2
- package/package.json +3 -1
- package/dist/host-sSWrUJeC.js +0 -3175
- package/dist/index-bIFT1ORw.d.ts +0 -46
- package/dist/ui-DcBLvgqY.js +0 -501
- package/dist/web/assets/index-BUxmYhLT.css +0 -1
- package/dist/web/assets/index-ypKt0hMg.js +0 -80
package/dist/hooks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { Z as describeRequest, j as configSchema } from "./dist-
|
|
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 } =
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 = (
|
|
94
|
-
if (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
|
|
131
|
-
* the session's own action in progress.
|
|
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("、")}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
315
|
+
async function logGranted(write, scope, call, request, grant, warn) {
|
|
247
316
|
try {
|
|
248
|
-
|
|
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(
|
|
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
|
|
467
|
+
warn,
|
|
468
|
+
write
|
|
392
469
|
}),
|
|
393
470
|
error: errorText()
|
|
394
471
|
};
|