mainflow 0.21.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +21 -9
- package/dist/{dist-BrotcvKi.js → dist-Dy45myuN.js} +370 -3
- package/dist/facts.d.ts +30 -30
- package/dist/facts.js +28 -6
- package/dist/grant-commands.d.ts +73 -0
- package/dist/grant-commands.js +245 -0
- package/dist/hooks.d.ts +23 -2
- package/dist/hooks.js +166 -12
- package/dist/{host-BsLqiI0_.js → host-DkaCYnnc.js} +179 -16
- package/dist/host.js +1 -1
- package/dist/index-CV30LkNZ.d.ts +27 -0
- package/dist/{launch-CBXoFsFC.js → launch-DOX1TTGx.js} +2 -2
- package/dist/launch.js +1 -1
- package/dist/{ui-ngIAww3a.js → ui-Do7fLlUC.js} +2 -2
- package/dist/web/assets/{index-CrBRJSja.js → index-BvKRkaWK.js} +27 -27
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
package/dist/hooks.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import { G as
|
|
1
|
+
import { G as endsWithMarker, H as grantScope, I as setSessionInfo, J as appendOps, K as headerLine, L as setSessionStatus, O as sessionBriefing, P as readSession, R as settleSession, U as matchGrants, W as decorateReply, _t as worktreesOf, gt as findHubRoot, ht as currentBranch, i as hostName, it as loadHub, j as ensureSession, k as turnReminder, mt as DATA_DIR, pt as readYaml, q as mayDecorate, rt as HubError } from "./host-DkaCYnnc.js";
|
|
2
|
+
import { G as describeRequest, O as configSchema } from "./dist-Dy45myuN.js";
|
|
3
|
+
import { resolveFixed, scanCommand, scopedCwd } from "./grant-commands.js";
|
|
2
4
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
3
5
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
6
|
//#region src/hooks.ts
|
|
@@ -20,6 +22,46 @@ const brief = (text) => {
|
|
|
20
22
|
const flat = (text ?? "").replace(/\s+/g, " ").trim();
|
|
21
23
|
return flat.length > SUMMARY_LIMIT ? `${flat.slice(0, 279)}…` : flat;
|
|
22
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* Read a hook's stdin: one JSON object, as Claude Code sends it. Anything else is null, including arrays,
|
|
27
|
+
* scalars, empty input, and an object with a key written twice, since which value the host acted on is unknown.
|
|
28
|
+
*/
|
|
29
|
+
function parsePayload(text) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(text);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || hasDuplicateKeys(text)) return null;
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
/** Whether any object in valid JSON text names a key twice; JSON.parse keeps only the last one. */
|
|
40
|
+
function hasDuplicateKeys(text) {
|
|
41
|
+
const stack = [];
|
|
42
|
+
for (let i = 0; i < text.length; i++) {
|
|
43
|
+
const c = text[i];
|
|
44
|
+
const top = stack.at(-1);
|
|
45
|
+
if (c === "{") stack.push({
|
|
46
|
+
keys: /* @__PURE__ */ new Set(),
|
|
47
|
+
expectKey: true
|
|
48
|
+
});
|
|
49
|
+
else if (c === "[") stack.push(null);
|
|
50
|
+
else if (c === "}" || c === "]") stack.pop();
|
|
51
|
+
else if (c === "," && top) top.expectKey = true;
|
|
52
|
+
else if (c === "\"") {
|
|
53
|
+
const start = i;
|
|
54
|
+
for (i++; text[i] !== "\""; i++) if (text[i] === "\\") i++;
|
|
55
|
+
if (top?.expectKey) {
|
|
56
|
+
const key = JSON.parse(text.slice(start, i + 1));
|
|
57
|
+
if (top.keys.has(key)) return true;
|
|
58
|
+
top.keys.add(key);
|
|
59
|
+
top.expectKey = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
23
65
|
const shortId = (raw) => raw.replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8);
|
|
24
66
|
const claudeCodeSessionId = (raw) => `claude-code-${shortId(raw)}`;
|
|
25
67
|
/**
|
|
@@ -158,9 +200,92 @@ const CLAUDE_CODE_EVENTS = {
|
|
|
158
200
|
setSessionStatus(root, id, "ended");
|
|
159
201
|
return null;
|
|
160
202
|
},
|
|
203
|
+
"pre-tool-use": decideByGrants,
|
|
161
204
|
"post-tool-use": checkAfterWrite,
|
|
162
205
|
"message-display": displayReply
|
|
163
206
|
};
|
|
207
|
+
/**
|
|
208
|
+
* Events that must not stay silent when they cannot decide, because silence would let through what they
|
|
209
|
+
* guard; each says what to print instead. Every other event stays silent on failure.
|
|
210
|
+
*/
|
|
211
|
+
const CLAUDE_CODE_FALLBACKS = { "pre-tool-use": askWhenGrantable };
|
|
212
|
+
/** The PreToolUse answer; `ask` hands the call to the usual permission prompt, with the reason shown. */
|
|
213
|
+
const permission = (decision, reason) => JSON.stringify({ hookSpecificOutput: {
|
|
214
|
+
hookEventName: "PreToolUse",
|
|
215
|
+
permissionDecision: decision,
|
|
216
|
+
permissionDecisionReason: `[mainflow] ${reason}`
|
|
217
|
+
} });
|
|
218
|
+
const askBecause = (reason) => permission("ask", `${reason},所以照常詢問`);
|
|
219
|
+
/**
|
|
220
|
+
* Scan a Bash call; null when it mentions no grantable command, so the hook stays silent. A Bash call whose
|
|
221
|
+
* command is not a string cannot be judged, so it is refused.
|
|
222
|
+
*/
|
|
223
|
+
function grantableCall(payload) {
|
|
224
|
+
if (typeof payload.tool_name !== "string" || payload.tool_name.toLowerCase() !== "bash") return null;
|
|
225
|
+
const command = payload.tool_input?.command;
|
|
226
|
+
if (typeof command !== "string") return {
|
|
227
|
+
kind: "refused",
|
|
228
|
+
reason: "tool_input.command 不是字串"
|
|
229
|
+
};
|
|
230
|
+
const scan = scanCommand(command);
|
|
231
|
+
return scan.kind === "none" ? null : scan;
|
|
232
|
+
}
|
|
233
|
+
/** Outside a hub, or when deciding failed: ask for a grantable call, stay silent for any other. */
|
|
234
|
+
function askWhenGrantable({ payload }, failure) {
|
|
235
|
+
const reason = failure.kind === "outside-hub" ? "不在 mainflow hub 裡,沒有主脈絡授權可用" : failure.kind === "error" ? `判斷授權時出錯:${failure.message}` : "hook 讀不到 PreToolUse 的輸入";
|
|
236
|
+
if (failure.kind === "unreadable-input") return askBecause(reason);
|
|
237
|
+
try {
|
|
238
|
+
return grantableCall(payload) ? askBecause(reason) : null;
|
|
239
|
+
} catch {
|
|
240
|
+
return askBecause(reason);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** The ops log's fields cannot hold its separator, so notes that contain one are written with a broken bar. */
|
|
244
|
+
const opsText = (text) => text.replace(/\s\|\s/g, " ¦ ").replace(/\s+/g, " ").slice(0, 200);
|
|
245
|
+
/** 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) {
|
|
247
|
+
try {
|
|
248
|
+
appendOps(root, {
|
|
249
|
+
target: request.action === "publish" ? `npm ${request.name}` : `github release ${request.repos.join("/")}`,
|
|
250
|
+
operation: opsText(call.text),
|
|
251
|
+
result: `授權放行 ${grant.ref}(${describeRequest(request)})`,
|
|
252
|
+
ref: scope.served[grant.mainline]
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
warn?.(`mainflow hook claude-code pre-tool-use: allowed by ${grant.ref} but could not write the ops log: ${error.message}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* PreToolUse: allow a publish or release that a grant covers, ask for every other one. `allow` lets the whole
|
|
260
|
+
* call run, so only the two fixed forms in grant-commands.ts can be allowed. A grant counts only when the
|
|
261
|
+
* session's one action in progress serves its mainline and the mainline is open; the package comes from
|
|
262
|
+
* package.json in the payload's cwd and the repository from config.yaml. Calls that mention no grantable command
|
|
263
|
+
* print nothing, so other hooks and the usual permission rules decide them. Any failure asks: the caller turns a
|
|
264
|
+
* throw into the fallback above.
|
|
265
|
+
*/
|
|
266
|
+
function decideByGrants(root, { payload, warn }) {
|
|
267
|
+
const scan = grantableCall(payload);
|
|
268
|
+
if (!scan) return null;
|
|
269
|
+
if (scan.kind === "refused") return askBecause(scan.reason);
|
|
270
|
+
const sessionId = payload.session_id;
|
|
271
|
+
if (typeof sessionId !== "string" || !sessionId) return askBecause("payload 沒有字串的 session_id");
|
|
272
|
+
const scoped = grantScope(root, readSession(root, claudeCodeSessionId(sessionId)));
|
|
273
|
+
if (!scoped.ok) return askBecause(scoped.reason);
|
|
274
|
+
const config = readYaml(join(root, DATA_DIR, "config.yaml"), configSchema).data;
|
|
275
|
+
const place = scopedCwd(payload.cwd, root, config.repos);
|
|
276
|
+
if (!place.ok) return askBecause(place.reason);
|
|
277
|
+
const resolved = resolveFixed(scan.call, {
|
|
278
|
+
cwd: place.dir,
|
|
279
|
+
root,
|
|
280
|
+
repos: config.repos
|
|
281
|
+
});
|
|
282
|
+
if (!resolved.ok) return askBecause(resolved.reason);
|
|
283
|
+
const match = matchGrants(scoped.scope, [resolved.request]);
|
|
284
|
+
if (!match.ok) return askBecause(match.reason);
|
|
285
|
+
const { grant } = match.matches[0];
|
|
286
|
+
logGranted(root, scoped.scope, scan.call, resolved.request, grant, warn);
|
|
287
|
+
return permission("allow", `主脈絡授權 ${grant.ref} 涵蓋 ${describeRequest(resolved.request)}`);
|
|
288
|
+
}
|
|
164
289
|
/** How many problems outside the written file the reminder lists before it only gives the count. */
|
|
165
290
|
const OTHER_PROBLEMS_SHOWN = 5;
|
|
166
291
|
const problemLine = (p) => `- ${p.file}${p.field ? ` › ${p.field}` : ""}:${p.message}`;
|
|
@@ -222,9 +347,29 @@ function checkAfterWrite(root, { payload, cli }) {
|
|
|
222
347
|
} });
|
|
223
348
|
}
|
|
224
349
|
const HOOK_PLATFORMS = { "claude-code": CLAUDE_CODE_EVENTS };
|
|
350
|
+
const HOOK_FALLBACKS = { "claude-code": CLAUDE_CODE_FALLBACKS };
|
|
351
|
+
/**
|
|
352
|
+
* Events that find the hub from $CLAUDE_PROJECT_DIR before the payload's cwd. A publish runs in the package's own
|
|
353
|
+
* repository, which config.yaml may list outside the hub directory (../mainflow), so its cwd leads to no hub.
|
|
354
|
+
*/
|
|
355
|
+
const PROJECT_HUB_EVENTS = /* @__PURE__ */ new Set(["pre-tool-use"]);
|
|
356
|
+
function hubRootFor(event, ctx) {
|
|
357
|
+
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());
|
|
358
|
+
}
|
|
359
|
+
/** What an event prints when it cannot run its handler; null for the events that stay silent. */
|
|
360
|
+
function hookFallback(platform, event, ctx, failure) {
|
|
361
|
+
const fallback = HOOK_FALLBACKS[platform]?.[event];
|
|
362
|
+
if (!fallback) return null;
|
|
363
|
+
try {
|
|
364
|
+
return fallback(ctx, failure);
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
225
369
|
/**
|
|
226
370
|
* Run a hook handler. A hook must never break the host session, so outside a hub it stays silent and any
|
|
227
|
-
* failure is reported on stderr while the caller still exits 0.
|
|
371
|
+
* failure is reported on stderr while the caller still exits 0. Events listed in the fallbacks print their
|
|
372
|
+
* fallback instead of staying silent.
|
|
228
373
|
*/
|
|
229
374
|
function runHook(platform, event, ctx) {
|
|
230
375
|
const handler = HOOK_PLATFORMS[platform]?.[event];
|
|
@@ -232,22 +377,31 @@ function runHook(platform, event, ctx) {
|
|
|
232
377
|
stdout: null,
|
|
233
378
|
error: `unknown hook ${platform} ${event}; known: ${Object.entries(HOOK_PLATFORMS).flatMap(([p, e]) => Object.keys(e).map((k) => `${p} ${k}`)).join(", ")}`
|
|
234
379
|
};
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
stdout: null,
|
|
238
|
-
error: null
|
|
239
|
-
};
|
|
380
|
+
const warnings = [];
|
|
381
|
+
const errorText = (message) => [...warnings, ...message ? [message] : []].join("\n") || null;
|
|
240
382
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
383
|
+
const root = hubRootFor(event, ctx);
|
|
384
|
+
if (!root) return {
|
|
385
|
+
stdout: hookFallback(platform, event, ctx, { kind: "outside-hub" }),
|
|
243
386
|
error: null
|
|
244
387
|
};
|
|
388
|
+
return {
|
|
389
|
+
stdout: handler(root, {
|
|
390
|
+
...ctx,
|
|
391
|
+
warn: (message) => warnings.push(message)
|
|
392
|
+
}),
|
|
393
|
+
error: errorText()
|
|
394
|
+
};
|
|
245
395
|
} catch (error) {
|
|
396
|
+
const message = error.message;
|
|
246
397
|
return {
|
|
247
|
-
stdout:
|
|
248
|
-
|
|
398
|
+
stdout: hookFallback(platform, event, ctx, {
|
|
399
|
+
kind: "error",
|
|
400
|
+
message
|
|
401
|
+
}),
|
|
402
|
+
error: errorText(`mainflow hook ${platform} ${event}: ${message}`)
|
|
249
403
|
};
|
|
250
404
|
}
|
|
251
405
|
}
|
|
252
406
|
//#endregion
|
|
253
|
-
export { HOOK_PLATFORMS, claudeCodeSessionId, openLink, runHook, subagentTranscript };
|
|
407
|
+
export { HOOK_PLATFORMS, claudeCodeSessionId, hookFallback, openLink, parsePayload, runHook, subagentTranscript };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { A as
|
|
1
|
+
import { A as findingSchema, B as taskSchema, C as SURVEY_LINES, D as WORK_STATUS, E as TASK_SECTIONS, F as parseOpsLine, G as describeRequest, I as parseRef, J as grantRepo, K as grantCovers, L as refExample, M as issueSchema, N as mainlineSchema, O as configSchema, P as opsEntrySchema, R as refMainline, S as SLUG, T as TASK_REQUIRED_SECTIONS, V as waitingForRef, Y as parseGrantTarget, _ as MERGE_STATES, a as DECISION_SECTIONS, b as RESERVED_HUB_NAMES, c as FINDING_SECTIONS, f as ISSUE_SECTIONS, g as MAINLINE_STATUS, h as MAINLINE_SECTIONS, i as DECISION_BASES, j as formatOpsEntry, k as decisionSchema, l as FINDING_STATUS, n as CLOSED_STATUS, q as grantForm, r as DECIDERS, s as DEFAULT_CATEGORIES, t as CHECKLIST_REF, w as SURVEY_SECTION, x as SESSION_STATUS, y as REF_EXAMPLES, z as sessionSchema } from "./dist-Dy45myuN.js";
|
|
2
2
|
import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
3
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
5
5
|
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
-
import { parse, stringify } from "yaml";
|
|
6
|
+
import { parse as parse$1, stringify } from "yaml";
|
|
7
7
|
//#region ../core/dist/paths.js
|
|
8
8
|
const DATA_DIR = ".mainflow";
|
|
9
9
|
const ENTRY_DIR = /^([mtdif]\d{3})-(.+)$/;
|
|
@@ -64,7 +64,7 @@ function splitFrontMatter(source) {
|
|
|
64
64
|
body: source
|
|
65
65
|
};
|
|
66
66
|
return {
|
|
67
|
-
data: parse(match[1] ?? ""),
|
|
67
|
+
data: parse$1(match[1] ?? ""),
|
|
68
68
|
body: source.slice(match[0].length)
|
|
69
69
|
};
|
|
70
70
|
}
|
|
@@ -145,7 +145,7 @@ const toYaml = (data) => stringify(data, { lineWidth: 0 });
|
|
|
145
145
|
function readYaml(file, schema) {
|
|
146
146
|
const text = readFileSync(file, "utf8");
|
|
147
147
|
return {
|
|
148
|
-
data: validate(schema, parse(text), file),
|
|
148
|
+
data: validate(schema, parse$1(text), file),
|
|
149
149
|
version: contentHash(text)
|
|
150
150
|
};
|
|
151
151
|
}
|
|
@@ -154,7 +154,7 @@ function mutateYaml(root, file, schema, mutate, expectedVersion) {
|
|
|
154
154
|
return withLock(root, () => {
|
|
155
155
|
const text = readFileSync(file, "utf8");
|
|
156
156
|
if (expectedVersion && contentHash(text) !== expectedVersion) throw new ConflictError(`${file} changed since it was read; reload and retry`);
|
|
157
|
-
const data = validate(schema, parse(text), file);
|
|
157
|
+
const data = validate(schema, parse$1(text), file);
|
|
158
158
|
mutate(data);
|
|
159
159
|
const next = validate(schema, data, file);
|
|
160
160
|
writeAtomic(file, toYaml(next));
|
|
@@ -491,7 +491,7 @@ function loadHub(root, options = {}) {
|
|
|
491
491
|
const read = (file, schema, raw) => {
|
|
492
492
|
let data = raw;
|
|
493
493
|
if (data === void 0) try {
|
|
494
|
-
data = parse(readText(file) ?? "");
|
|
494
|
+
data = parse$1(readText(file) ?? "");
|
|
495
495
|
} catch (error) {
|
|
496
496
|
problems.push({
|
|
497
497
|
file: rel(file),
|
|
@@ -520,6 +520,7 @@ function loadHub(root, options = {}) {
|
|
|
520
520
|
const config = read(join(dataDir, "config.yaml"), configSchema);
|
|
521
521
|
if (!config) throw new HubError("invalid config.yaml", problems);
|
|
522
522
|
if (config.formatVersion !== 2) throw new HubError(config.formatVersion < 2 ? `data format version ${config.formatVersion} is older than this CLI's (2); run "mainflow migrate --dry-run" to preview the conversion, then "mainflow migrate"` : `data format version ${config.formatVersion} is newer than this CLI supports (2); install the matching mainflow version in this hub`, problems);
|
|
523
|
+
problems.push(...repoPathProblems(root, config));
|
|
523
524
|
const categories = /* @__PURE__ */ new Set([...DEFAULT_CATEGORIES, ...config.categories ?? []]);
|
|
524
525
|
/** Read a Markdown document's front matter against `schema`, with the attributes every document shares. */
|
|
525
526
|
const readDocument = (file, schema) => {
|
|
@@ -568,6 +569,7 @@ function loadHub(root, options = {}) {
|
|
|
568
569
|
continue;
|
|
569
570
|
}
|
|
570
571
|
checkDirName(mainlineDir, mainline.id);
|
|
572
|
+
problems.push(...grantProblems(mainline, config));
|
|
571
573
|
const decisions = [];
|
|
572
574
|
const decisionsDir = join(mainlineDir, "decisions");
|
|
573
575
|
for (const name of existsSync(decisionsDir) ? readdirSync(decisionsDir).filter((n) => n.endsWith(".md")).sort() : []) {
|
|
@@ -873,6 +875,57 @@ function checkReferences(input) {
|
|
|
873
875
|
});
|
|
874
876
|
}
|
|
875
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* A listed repository counts as part of the hub, so the PreToolUse hook lets grants act inside it. A path that
|
|
880
|
+
* resolves to / or to a directory above the hub would put everything under it in scope. A path that does not
|
|
881
|
+
* exist is fine: another machine may not have cloned that repository yet.
|
|
882
|
+
*/
|
|
883
|
+
function repoPathProblems(root, config) {
|
|
884
|
+
const real = (path) => {
|
|
885
|
+
try {
|
|
886
|
+
return realpathSync(path);
|
|
887
|
+
} catch {
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
const hub = real(root) ?? root;
|
|
892
|
+
return Object.entries(config.repos).flatMap(([name, path]) => {
|
|
893
|
+
const dir = real(resolve(root, path));
|
|
894
|
+
if (dir === null) return [];
|
|
895
|
+
const problem = (message) => [{
|
|
896
|
+
file: `${DATA_DIR}/config.yaml`,
|
|
897
|
+
field: `repos.${name}`,
|
|
898
|
+
message
|
|
899
|
+
}];
|
|
900
|
+
if (dir === parse(dir).root) return problem(`"${path}" resolves to the file system root; list the repository's own directory`);
|
|
901
|
+
if (hub.startsWith(dir.endsWith(sep) ? dir : dir + sep)) return problem(`"${path}" resolves to ${dir}, above the hub; list the repository's own directory`);
|
|
902
|
+
return [];
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* What the schema cannot see about a mainline's grants: ids must be unique, since the file is edited directly,
|
|
907
|
+
* and a target that names a repository must name one listed in config.yaml.
|
|
908
|
+
*/
|
|
909
|
+
function grantProblems(mainline, config) {
|
|
910
|
+
const problems = [];
|
|
911
|
+
const seen = /* @__PURE__ */ new Set();
|
|
912
|
+
mainline.grants?.forEach((grant, i) => {
|
|
913
|
+
if (seen.has(grant.id)) problems.push({
|
|
914
|
+
file: mainline.file,
|
|
915
|
+
field: `grants.${i}.id`,
|
|
916
|
+
message: `${grant.id} is used twice; give it the next free id`
|
|
917
|
+
});
|
|
918
|
+
seen.add(grant.id);
|
|
919
|
+
const parsed = parseGrantTarget(grant.action, grant.target);
|
|
920
|
+
const repo = parsed.ok ? grantRepo(parsed.target) : void 0;
|
|
921
|
+
if (repo !== void 0 && !(repo in config.repos)) problems.push({
|
|
922
|
+
file: mainline.file,
|
|
923
|
+
field: `grants.${i}.target`,
|
|
924
|
+
message: `repo "${repo}" is not listed in config.yaml; use one of ${Object.keys(config.repos).join(", ") || "(none registered)"}`
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
return problems;
|
|
928
|
+
}
|
|
876
929
|
const isOpen$2 = (kind, status) => !CLOSED_STATUS[kind].includes(status);
|
|
877
930
|
/**
|
|
878
931
|
* What a reader without the conversation needs to pick the work up: an open mainline with tasks says what
|
|
@@ -1171,6 +1224,107 @@ function decorateReply(text, context) {
|
|
|
1171
1224
|
/** Cheap test for text that decorateReply could change, so a display hook can skip loading the hub. */
|
|
1172
1225
|
const mayDecorate = (text) => /m\d{3}|[if]\d{3}|\.mainflow\/mainlines/.test(text) || endsWithMarker(text);
|
|
1173
1226
|
//#endregion
|
|
1227
|
+
//#region ../core/dist/grants.js
|
|
1228
|
+
/** Mainline statuses whose grants apply. */
|
|
1229
|
+
const GRANTING_STATUS = ["active", "planning"];
|
|
1230
|
+
/**
|
|
1231
|
+
* The grants in effect for a session. Reading a mainline that is missing or invalid throws, so a caller that
|
|
1232
|
+
* must fail closed catches it and asks.
|
|
1233
|
+
*/
|
|
1234
|
+
function grantScope(root, session) {
|
|
1235
|
+
if (!session) return {
|
|
1236
|
+
ok: false,
|
|
1237
|
+
reason: "這場會話沒有 mainflow 紀錄"
|
|
1238
|
+
};
|
|
1239
|
+
const doing = session.actions.filter((a) => a.status === "doing");
|
|
1240
|
+
if (doing.length === 0) return {
|
|
1241
|
+
ok: false,
|
|
1242
|
+
reason: `${session.id} 沒有進行中的行動`
|
|
1243
|
+
};
|
|
1244
|
+
if (doing.length > 1) return {
|
|
1245
|
+
ok: false,
|
|
1246
|
+
reason: `${session.id} 同時有 ${doing.length} 個進行中的行動(${doing.map((a) => a.id).join("、")}),分不出授權範圍`
|
|
1247
|
+
};
|
|
1248
|
+
const action = doing[0];
|
|
1249
|
+
const mainlines = [...new Set(action.serves.flatMap((ref) => {
|
|
1250
|
+
const parsed = parseRef(ref);
|
|
1251
|
+
const mainline = parsed ? refMainline(parsed) : void 0;
|
|
1252
|
+
return mainline ? [mainline] : [];
|
|
1253
|
+
}))];
|
|
1254
|
+
if (mainlines.length === 0) return {
|
|
1255
|
+
ok: false,
|
|
1256
|
+
reason: `${action.id} 沒有服務任何 checklist 項目`
|
|
1257
|
+
};
|
|
1258
|
+
const grants = [];
|
|
1259
|
+
const served = {};
|
|
1260
|
+
const closed = [];
|
|
1261
|
+
for (const id of mainlines) {
|
|
1262
|
+
const mainline = readFrontMatter(mainlineFile(root, id), mainlineSchema).data;
|
|
1263
|
+
if (!GRANTING_STATUS.includes(mainline.status)) {
|
|
1264
|
+
closed.push(`${id} 是 ${mainline.status}`);
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
served[id] = action.serves.find((ref) => ref.startsWith(`${id}/`));
|
|
1268
|
+
for (const grant of mainline.grants ?? []) grants.push({
|
|
1269
|
+
mainline: id,
|
|
1270
|
+
grant,
|
|
1271
|
+
ref: `${id}#${grant.id}`
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
if (Object.keys(served).length === 0) return {
|
|
1275
|
+
ok: false,
|
|
1276
|
+
reason: `${action.id} 服務的主脈絡已不在進行中(${closed.join("、")}),授權不再生效`
|
|
1277
|
+
};
|
|
1278
|
+
return {
|
|
1279
|
+
ok: true,
|
|
1280
|
+
scope: {
|
|
1281
|
+
action,
|
|
1282
|
+
grants,
|
|
1283
|
+
served
|
|
1284
|
+
}
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
/** Every request must be covered by some grant in scope; the first one that is not is named in the reason. */
|
|
1288
|
+
function matchGrants(scope, requests) {
|
|
1289
|
+
const matches = [];
|
|
1290
|
+
for (const request of requests) {
|
|
1291
|
+
const grant = scope.grants.find((g) => {
|
|
1292
|
+
const parsed = parseGrantTarget(g.grant.action, g.grant.target);
|
|
1293
|
+
return parsed.ok && grantCovers(parsed.target, request);
|
|
1294
|
+
});
|
|
1295
|
+
if (!grant) return {
|
|
1296
|
+
ok: false,
|
|
1297
|
+
reason: `${Object.keys(scope.served).join("、")} 沒有涵蓋 ${describeRequest(request)} 的授權`
|
|
1298
|
+
};
|
|
1299
|
+
matches.push({
|
|
1300
|
+
request,
|
|
1301
|
+
grant
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
return {
|
|
1305
|
+
ok: true,
|
|
1306
|
+
matches
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* One line per grant in effect for the session, for the briefing and the per-turn reminder, with the fixed form
|
|
1311
|
+
* the hook allows for it; empty when the session has none or they cannot be read, since a briefing must not fail
|
|
1312
|
+
* on them.
|
|
1313
|
+
*/
|
|
1314
|
+
function grantLines(root, session) {
|
|
1315
|
+
try {
|
|
1316
|
+
const result = grantScope(root, session);
|
|
1317
|
+
if (!result.ok) return [];
|
|
1318
|
+
return result.scope.grants.map((g) => {
|
|
1319
|
+
const parsed = parseGrantTarget(g.grant.action, g.grant.target);
|
|
1320
|
+
const form = parsed.ok ? grantForm(parsed.target) : void 0;
|
|
1321
|
+
return `- ${g.ref} ${g.grant.action} ${g.grant.target}${form ? `:${form}` : ""}`;
|
|
1322
|
+
});
|
|
1323
|
+
} catch {
|
|
1324
|
+
return [];
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
//#endregion
|
|
1174
1328
|
//#region ../core/dist/sessions.js
|
|
1175
1329
|
const sessionsDir = (root) => join(root, DATA_DIR, "local", "sessions");
|
|
1176
1330
|
const sessionFile = (root, id) => join(sessionsDir(root), `${id}.yaml`);
|
|
@@ -1192,7 +1346,7 @@ function loadSessions(root) {
|
|
|
1192
1346
|
const file = join(dir, name);
|
|
1193
1347
|
let data;
|
|
1194
1348
|
try {
|
|
1195
|
-
data = parse(readText(file) ?? "");
|
|
1349
|
+
data = parse$1(readText(file) ?? "");
|
|
1196
1350
|
} catch (error) {
|
|
1197
1351
|
problems.push({
|
|
1198
1352
|
file: relative(root, file),
|
|
@@ -1544,6 +1698,8 @@ function sessionBriefing(root, session, options) {
|
|
|
1544
1698
|
} else {
|
|
1545
1699
|
lines.push(doing ? `目前行動:${doing.id} ${doing.do}` : "目前沒有進行中的行動。");
|
|
1546
1700
|
if (doing) lines.push(`服務:${servedLine(root, doing)}`);
|
|
1701
|
+
const grants = grantLines(root, session);
|
|
1702
|
+
if (grants.length > 0) lines.push("目前行動服務的主脈絡預先授權了這些動作:", ...grants);
|
|
1547
1703
|
if (upcoming.length > 0) lines.push(`接下來:${upcoming.map((a) => `${a.id} ${a.do}`).join("、")}`);
|
|
1548
1704
|
const blocked = session.actions.filter((a) => a.status === "blocked");
|
|
1549
1705
|
if (blocked.length > 0) lines.push(`等外部條件:${blocked.map(waitingLine).join(";")}`);
|
|
@@ -1562,9 +1718,12 @@ function turnReminder(root, session, options) {
|
|
|
1562
1718
|
const blocked = session.actions.filter((a) => a.status === "blocked");
|
|
1563
1719
|
const head = doing ? `[mainflow] 目前行動 ${doing.id} ${doing.do},服務 ${doing.serves.join("、") || "(未指定)"}。這一輪的工作若不屬於它,先調整佇列再做。` : open.length > 0 ? `[mainflow] ${session.id} 目前沒有進行中的行動。` : blocked.length > 0 ? `[mainflow] ${session.id} 的行動在等外部條件:${blocked.map(waitingLine).join(";")}。條件出現後用 ${options.cli} action set <a編號> --status doing 繼續;要先做別的事,用 ${options.cli} action add 列出行動。` : `[mainflow] ${session.id} 的行動都已結束。繼續工作前先用 ${options.cli} action add 列出下一個行動。`;
|
|
1564
1720
|
const header = headerLine(session);
|
|
1721
|
+
const marker = header ? [`- 這一輪最後一則回覆的最後一行寫「${header}」,回覆範例等其他內容都放在它前面。有顯示 hook 時,它會換成標題與連結。`] : [];
|
|
1722
|
+
const grants = grantLines(root, session);
|
|
1565
1723
|
return [
|
|
1566
1724
|
head,
|
|
1567
|
-
...
|
|
1725
|
+
...grants.length > 0 ? ["- 目前行動服務的主脈絡預先授權了這些動作:", ...grants.map((g) => ` ${g}`)] : [],
|
|
1726
|
+
...marker,
|
|
1568
1727
|
...queueWarnings(root, session, options.cli).map((w) => `- ${w}`)
|
|
1569
1728
|
].join("\n");
|
|
1570
1729
|
}
|
|
@@ -1810,6 +1969,7 @@ function createDecision(root, mainline, input) {
|
|
|
1810
1969
|
requireSlug(input.slug);
|
|
1811
1970
|
const status = requireOneOf(input.status ?? "accepted", ["proposed", "accepted"], "new decision status");
|
|
1812
1971
|
const by = input.by === void 0 ? void 0 : requireOneOf(input.by, DECIDERS, "decided by");
|
|
1972
|
+
const basis = input.basis === void 0 ? void 0 : requireOneOf(input.basis, DECISION_BASES, "decision basis");
|
|
1813
1973
|
for (const ref of input.affects ?? []) requireAffected(root, mainline, ref);
|
|
1814
1974
|
return withLock(root, () => {
|
|
1815
1975
|
const dir = join(mainlineDir(root, mainline), "decisions");
|
|
@@ -1823,6 +1983,7 @@ function createDecision(root, mainline, input) {
|
|
|
1823
1983
|
status,
|
|
1824
1984
|
...status === "accepted" ? { decidedAt: at } : {},
|
|
1825
1985
|
...by ? { by } : {},
|
|
1986
|
+
...basis ? { basis } : {},
|
|
1826
1987
|
...input.affects?.length ? { affects: input.affects } : {}
|
|
1827
1988
|
}, "decision");
|
|
1828
1989
|
const file = join(dir, `${id}-${input.slug}.md`);
|
|
@@ -1846,15 +2007,17 @@ const parseRefIn = (input) => {
|
|
|
1846
2007
|
task: task[2]
|
|
1847
2008
|
} : null;
|
|
1848
2009
|
};
|
|
1849
|
-
/** Settle a proposed decision: record who decided and when; the items it affects stop waiting for it. */
|
|
1850
|
-
function acceptDecision(root, refInput,
|
|
2010
|
+
/** Settle a proposed decision: record who decided, how and when; the items it affects stop waiting for it. */
|
|
2011
|
+
function acceptDecision(root, refInput, settlement = {}, expectedVersion) {
|
|
1851
2012
|
const ref = requireRef(refInput, "decision");
|
|
1852
|
-
const decider = by === void 0 ? void 0 : requireOneOf(by, DECIDERS, "decided by");
|
|
2013
|
+
const decider = settlement.by === void 0 ? void 0 : requireOneOf(settlement.by, DECIDERS, "decided by");
|
|
2014
|
+
const basis = settlement.basis === void 0 ? void 0 : requireOneOf(settlement.basis, DECISION_BASES, "decision basis");
|
|
1853
2015
|
return mutateFrontMatter(root, decisionFile(root, ref.mainline, ref.decision), decisionSchema, (d) => {
|
|
1854
2016
|
if (d.status !== "proposed") throw new WriteError(`${refInput} is ${d.status}; only a proposed decision can be accepted`);
|
|
1855
2017
|
d.status = "accepted";
|
|
1856
2018
|
d.decidedAt = nowMinute();
|
|
1857
2019
|
if (decider) d.by = decider;
|
|
2020
|
+
if (basis) d.basis = basis;
|
|
1858
2021
|
}, expectedVersion);
|
|
1859
2022
|
}
|
|
1860
2023
|
function supersedeDecision(root, refInput, by, expectedVersion) {
|
|
@@ -1968,7 +2131,7 @@ function migrateHub(root, options = {}) {
|
|
|
1968
2131
|
if (!existsSync(configFile)) throw new WriteError(`no hub at ${root}; run mainflow init first`);
|
|
1969
2132
|
const rel = (path) => relative(root, path);
|
|
1970
2133
|
return withLock(root, () => {
|
|
1971
|
-
const config = parse(readFileSync(configFile, "utf8"));
|
|
2134
|
+
const config = parse$1(readFileSync(configFile, "utf8"));
|
|
1972
2135
|
const from = Number(config.formatVersion);
|
|
1973
2136
|
if (from === 2) throw new WriteError(`${rel(configFile)} is already format 2; nothing to migrate`);
|
|
1974
2137
|
if (from !== 1) throw new WriteError(`cannot migrate from format ${String(config.formatVersion)}; this CLI migrates format 1 to 2`);
|
|
@@ -1981,7 +2144,7 @@ function migrateHub(root, options = {}) {
|
|
|
1981
2144
|
for (const mainlineDir of subdirs(join(dataDir, "mainlines"))) {
|
|
1982
2145
|
const yamlFile = join(mainlineDir, "mainline.yaml");
|
|
1983
2146
|
if (!existsSync(yamlFile)) continue;
|
|
1984
|
-
const m = parse(readFileSync(yamlFile, "utf8"));
|
|
2147
|
+
const m = parse$1(readFileSync(yamlFile, "utf8"));
|
|
1985
2148
|
dropNull(m, "archivedAt");
|
|
1986
2149
|
if (isClosed("mainline", m.status) && m.archivedAt) m.closedAt = m.archivedAt;
|
|
1987
2150
|
const criteria = (m.criteria ?? []).map(migrateListItem);
|
|
@@ -1998,7 +2161,7 @@ function migrateHub(root, options = {}) {
|
|
|
1998
2161
|
for (const taskDir of subdirs(join(mainlineDir, "tasks"))) {
|
|
1999
2162
|
const taskYaml = join(taskDir, "task.yaml");
|
|
2000
2163
|
if (!existsSync(taskYaml)) continue;
|
|
2001
|
-
const t = parse(readFileSync(taskYaml, "utf8"));
|
|
2164
|
+
const t = parse$1(readFileSync(taskYaml, "utf8"));
|
|
2002
2165
|
const content = typeof t.content === "string" ? t.content.trim() : "";
|
|
2003
2166
|
drop(t, "content");
|
|
2004
2167
|
dropNull(t, "parent", "repo");
|
|
@@ -2191,4 +2354,4 @@ function openInApp(hub, target, closeAfterMs = 150) {
|
|
|
2191
2354
|
return true;
|
|
2192
2355
|
}
|
|
2193
2356
|
//#endregion
|
|
2194
|
-
export {
|
|
2357
|
+
export { reopenIssue as $, addAction as A, transcriptPath as B, setCriterionText as C, updateTask as D, supersedeDecision as E, setSessionArchived as F, endsWithMarker as G, grantScope as H, setSessionInfo as I, appendOps as J, headerLine as K, setSessionStatus as L, handoffSession as M, loadSessions as N, sessionBriefing as O, readSession as P, planIssue as Q, settleSession as R, setCriterion as S, setMainlineTitle as T, matchGrants as U, updateAction as V, decorateReply as W, createFinding as X, closeIssue as Y, createIssue as Z, createTask as _, worktreesOf as _t, openInApp as a, dependencyGraph as at, setArchived as b, syncHost as c, refIndex as ct, setArchivedMany as d, ConflictError as dt, setFindingArchived as et, acceptDecision as f, WriteError as ft, createMainline as g, findHubRoot as gt, createDecision as h, currentBranch as ht, hostName as i, loadHub as it, ensureSession as j, turnReminder as k, migrateHub as l, MAINLINE_FILE as lt, addRepo as m, DATA_DIR as mt, DEFAULT_UI_PORT as n, setIssueArchived as nt, removeHostLink as o, mainlineEdges as ot, addHub as p, readYaml as pt, mayDecorate as q, caddySites as r, HubError as rt, siteBlock as s, linksInto as st, APP_BUNDLE_ID as t, setFindingStatus as tt, ARCHIVE_KINDS as u, TASK_FILE as ut, dropChecklistItem as v, setMainlineStatus as w, setChecklistItem as x, initHub as y, startSession as z };
|
package/dist/host.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-
|
|
1
|
+
import { a as openInApp, c as syncHost, i as hostName, n as DEFAULT_UI_PORT, o as removeHostLink, r as caddySites, s as siteBlock, t as APP_BUNDLE_ID } from "./host-DkaCYnnc.js";
|
|
2
2
|
export { APP_BUNDLE_ID, DEFAULT_UI_PORT, caddySites, hostName, openInApp, removeHostLink, siteBlock, syncHost };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region ../schema/dist/index.d.ts
|
|
3
|
+
declare const FORMAT_VERSION = 2;
|
|
4
|
+
declare const ID_PATTERNS: {
|
|
5
|
+
readonly mainline: RegExp;
|
|
6
|
+
readonly task: RegExp;
|
|
7
|
+
readonly checklistItem: RegExp;
|
|
8
|
+
readonly criterion: RegExp;
|
|
9
|
+
readonly decision: RegExp;
|
|
10
|
+
readonly action: RegExp;
|
|
11
|
+
readonly issue: RegExp;
|
|
12
|
+
readonly finding: RegExp;
|
|
13
|
+
readonly grant: RegExp;
|
|
14
|
+
};
|
|
15
|
+
declare const CHECKLIST_REF: RegExp;
|
|
16
|
+
declare const SLUG: RegExp;
|
|
17
|
+
/** One source the survey checks: the line's label, what it holds, and how it may be left unchecked. */
|
|
18
|
+
interface SurveyLine {
|
|
19
|
+
label: string;
|
|
20
|
+
holds: string;
|
|
21
|
+
/** The word that marks the source as not checked, followed by the reason, e.g. 不適用:{理由}. Absent when the source is always checked. */
|
|
22
|
+
skip?: string;
|
|
23
|
+
/** Text a checked line must contain, e.g. the environment a spike ran in. */
|
|
24
|
+
requires?: string;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { SurveyLine as a, SLUG as i, FORMAT_VERSION as n, ID_PATTERNS as r, CHECKLIST_REF as t };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as syncHost,
|
|
1
|
+
import { c as syncHost, ft as WriteError, i as hostName, mt as DATA_DIR, n as DEFAULT_UI_PORT, o as removeHostLink } from "./host-DkaCYnnc.js";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -176,7 +176,7 @@ async function launchUi(root, hub, configured, options) {
|
|
|
176
176
|
}
|
|
177
177
|
let server;
|
|
178
178
|
if (!reuse) {
|
|
179
|
-
const { serveUi } = await import("./ui-
|
|
179
|
+
const { serveUi } = await import("./ui-Do7fLlUC.js");
|
|
180
180
|
server = await serveUi(root, choice.port);
|
|
181
181
|
writeUiRecord(root, {
|
|
182
182
|
port: choice.port,
|
package/dist/launch.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-
|
|
1
|
+
import { a as portCandidates, c as watchHubGone, i as networkProbe, n as choosePort, o as readUiRecord, r as launchUi, s as startUiInBackground, t as PORT_SPAN } from "./launch-DOX1TTGx.js";
|
|
2
2
|
export { PORT_SPAN, choosePort, launchUi, networkProbe, portCandidates, readUiRecord, startUiInBackground, watchHubGone };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { B as transcriptPath, D as updateTask, F as setSessionArchived, N as loadSessions, P as readSession,
|
|
1
|
+
import { B as transcriptPath, D as updateTask, F as setSessionArchived, N as loadSessions, P as readSession, S as setCriterion, T as setMainlineTitle, a as openInApp, at as dependencyGraph, b as setArchived, ct as refIndex, d as setArchivedMany, dt as ConflictError, et as setFindingArchived, ft as WriteError, it as loadHub, mt as DATA_DIR, nt as setIssueArchived, ot as mainlineEdges, st as linksInto, u as ARCHIVE_KINDS, w as setMainlineStatus, x as setChecklistItem } from "./host-DkaCYnnc.js";
|
|
2
2
|
import { t as listProgress } from "./progress-1BfPg2zN.js";
|
|
3
|
-
import { l as CLI_VERSION } from "./launch-
|
|
3
|
+
import { l as CLI_VERSION } from "./launch-DOX1TTGx.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, watch } from "node:fs";
|
|
6
6
|
import { basename, dirname, extname, join, normalize, relative, sep } from "node:path";
|