@mmerterden/multi-agent-pipeline 20.28.0 → 20.29.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/CHANGELOG.md +13 -0
- package/README.md +14 -1
- package/docs/facts.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +53 -42
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/phases/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +37 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/uninstall.mjs +1 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +1 -1
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Entries are written while the quality gates are active (lib/unattended.mjs,
|
|
17
17
|
* gatesActive): MULTI_AGENT_UNATTENDED=1, or a run in autopilot mode. A run
|
|
18
|
-
* that is neither writes
|
|
18
|
+
* that is neither writes only the gates in ATTENDED_RECORDED, each entry marked
|
|
19
|
+
* `attended: true`, and checkCommit never counts those. The commit
|
|
19
20
|
* hook's REQUIREMENT is narrower: it applies only with the variable set, since
|
|
20
21
|
* it is security enforcement and a terminal autopilot run has a person at it.
|
|
21
22
|
*
|
|
@@ -32,6 +33,9 @@
|
|
|
32
33
|
* gate-ledger.mjs append --gate <id> --verdict pass|fail|not-applicable
|
|
33
34
|
* [--detail '<json>'] [--state <path>] [--repo <dir>]
|
|
34
35
|
* gate-ledger.mjs check [--state <path>] [--repo <dir>] [--sha <sha>] [--json]
|
|
36
|
+
* gate-ledger.mjs evidence [--state <path>] [--repo <dir>] [--sha <sha>] [--json]
|
|
37
|
+
* the attended commit check: build/test evidence for HEAD, per
|
|
38
|
+
* prefs.global.attendedCommitGate.mode (exit 0 fine, 1 warn, 4 block)
|
|
35
39
|
* gate-ledger.mjs park --outcome awaiting-answer|verification-failed
|
|
36
40
|
* [--waiting-for <step>] [--gate <id>] [--reason <text>] [--state <path>]
|
|
37
41
|
*
|
|
@@ -49,12 +53,21 @@
|
|
|
49
53
|
*/
|
|
50
54
|
|
|
51
55
|
import { spawnSync } from "node:child_process";
|
|
52
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
existsSync,
|
|
58
|
+
readFileSync,
|
|
59
|
+
realpathSync,
|
|
60
|
+
renameSync,
|
|
61
|
+
statSync,
|
|
62
|
+
writeFileSync,
|
|
63
|
+
} from "node:fs";
|
|
53
64
|
import { basename, dirname } from "node:path";
|
|
54
65
|
|
|
55
66
|
import { decisionEntry, withDecision } from "../lib/decisions.mjs";
|
|
56
67
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
57
68
|
import { readRegularFile, readRegularJson } from "../lib/regular-file.mjs";
|
|
69
|
+
import { globalPref } from "../lib/prefs-value.mjs";
|
|
70
|
+
import { modeEntry } from "../lib/run-completion.mjs";
|
|
58
71
|
import { attendedVerdict, gatesActive } from "../lib/unattended.mjs";
|
|
59
72
|
import { listRuns, resolveRunFile } from "./_run-paths.mjs";
|
|
60
73
|
import { acquireLock, releaseLock, stillOurs } from "./write-state.mjs";
|
|
@@ -80,6 +93,41 @@ export const MANDATORY_AT_COMMIT = [
|
|
|
80
93
|
"plan-coverage",
|
|
81
94
|
];
|
|
82
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Gates whose verdict is recorded in an attended run too, marked
|
|
98
|
+
* `attended: true`. `plan-coverage` is what lib/run-completion.mjs reads
|
|
99
|
+
* before a run may write `status: complete`. One locked append per verdict;
|
|
100
|
+
* every other gate writes nothing while the gates are off.
|
|
101
|
+
*/
|
|
102
|
+
export const ATTENDED_RECORDED = new Set([
|
|
103
|
+
"plan-coverage",
|
|
104
|
+
"evidence-gate/build",
|
|
105
|
+
"evidence-gate/test",
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/** What an attended commit is checked for (pre-commit-check.sh, `evidence`). */
|
|
109
|
+
export const ATTENDED_COMMIT_EVIDENCE = ["evidence-gate/build", "evidence-gate/test"];
|
|
110
|
+
|
|
111
|
+
/** prefs.global.attendedCommitGate.mode values; `warn` is the default. */
|
|
112
|
+
export const ATTENDED_COMMIT_MODES = ["off", "warn", "block"];
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The build/test evidence an attended commit on `sha` lacks: every gate in
|
|
116
|
+
* ATTENDED_COMMIT_EVIDENCE whose latest entry for that sha, attended or not,
|
|
117
|
+
* is missing or not passing.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} state
|
|
120
|
+
* @param {string} sha
|
|
121
|
+
* @returns {string[]}
|
|
122
|
+
*/
|
|
123
|
+
export function attendedEvidenceGaps(state, sha) {
|
|
124
|
+
const latest = latestByGate(state?.gates, sha);
|
|
125
|
+
return ATTENDED_COMMIT_EVIDENCE.filter((g) => {
|
|
126
|
+
const e = latest.get(g);
|
|
127
|
+
return !e || !PASSING.has(e.verdict);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
83
131
|
const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
|
|
84
132
|
const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
|
|
85
133
|
const QUESTION_KEYS = ["id", "stepId", "text", "kind", "options"];
|
|
@@ -201,6 +249,59 @@ export function resolveStatePath({ statePath = null, repo = null, env = process.
|
|
|
201
249
|
}
|
|
202
250
|
}
|
|
203
251
|
|
|
252
|
+
export const TREE_MATCH_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The in-progress run an attended commit in `repo` belongs to, or null.
|
|
256
|
+
*
|
|
257
|
+
* Only a run still in progress counts: a finished local-mode run keeps the
|
|
258
|
+
* project root as its worktree forever and would otherwise judge every later
|
|
259
|
+
* commit there. Of several, the one bound to the hook's session (`sessionId`
|
|
260
|
+
* = MULTI_AGENT_SESSION_ID, or `hostSessionId` = the payload's session_id)
|
|
261
|
+
* wins, then the most recently written. A run matched only by its worktree
|
|
262
|
+
* counts while its state was written in the last six hours.
|
|
263
|
+
*
|
|
264
|
+
* @param {{repo: string, session?: string, env?: Record<string,string|undefined>}} opts
|
|
265
|
+
* @returns {string|null}
|
|
266
|
+
*/
|
|
267
|
+
export function liveRunFor({ repo, session = "", env = process.env }) {
|
|
268
|
+
const prevRoot = process.env.LOGS_ROOT;
|
|
269
|
+
if (env.LOGS_ROOT !== undefined) process.env.LOGS_ROOT = env.LOGS_ROOT;
|
|
270
|
+
try {
|
|
271
|
+
const top = toplevel(repo);
|
|
272
|
+
const want = top ? real(top) : null;
|
|
273
|
+
const launched = env.MULTI_AGENT_SESSION_ID || "";
|
|
274
|
+
const hits = [];
|
|
275
|
+
for (const run of listRuns()) {
|
|
276
|
+
const p = resolveRunFile(run.taskId, "agent-state.json", run.project);
|
|
277
|
+
if (!p) continue;
|
|
278
|
+
const doc = readJson(p);
|
|
279
|
+
if (!doc || typeof doc !== "object" || doc.status !== "in_progress") continue;
|
|
280
|
+
const bound =
|
|
281
|
+
(launched && doc.sessionId === launched) || (session && doc.hostSessionId === session);
|
|
282
|
+
const onTree =
|
|
283
|
+
want && typeof doc.worktreePath === "string" && real(doc.worktreePath) === want;
|
|
284
|
+
if (!bound && !onTree) continue;
|
|
285
|
+
let t = 0;
|
|
286
|
+
try {
|
|
287
|
+
t = statSync(p).mtimeMs;
|
|
288
|
+
} catch {
|
|
289
|
+
t = 0;
|
|
290
|
+
}
|
|
291
|
+
// A run that crashed keeps status in_progress forever. Matched only by
|
|
292
|
+
// its worktree, it judges commits there while its state is still being
|
|
293
|
+
// written; once it has gone quiet the tree is the user's again.
|
|
294
|
+
if (!bound && Date.now() - t > TREE_MATCH_MAX_AGE_MS) continue;
|
|
295
|
+
hits.push({ p, bound: bound ? 1 : 0, t });
|
|
296
|
+
}
|
|
297
|
+
hits.sort((a, b) => b.bound - a.bound || b.t - a.t);
|
|
298
|
+
return hits.length ? hits[0].p : null;
|
|
299
|
+
} finally {
|
|
300
|
+
if (prevRoot === undefined) delete process.env.LOGS_ROOT;
|
|
301
|
+
else process.env.LOGS_ROOT = prevRoot;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
204
305
|
/**
|
|
205
306
|
* Do the quality gates run here, and against which state?
|
|
206
307
|
*
|
|
@@ -274,9 +375,17 @@ export async function record(
|
|
|
274
375
|
throw new LedgerError("usage", `verdict must be one of ${VERDICTS.join(", ")}, got ${verdict}`);
|
|
275
376
|
}
|
|
276
377
|
const ctx = gateContext({ statePath, repo, env });
|
|
277
|
-
|
|
378
|
+
const attended = !ctx.active;
|
|
379
|
+
if (attended && !ATTENDED_RECORDED.has(gate)) {
|
|
380
|
+
return { written: false, reason: attendedVerdict(gate).reason };
|
|
381
|
+
}
|
|
278
382
|
const path = ctx.statePath;
|
|
279
|
-
if (!path)
|
|
383
|
+
if (!path) {
|
|
384
|
+
// Attended with no run to write into is the ordinary case outside the
|
|
385
|
+
// pipeline, not a ledger problem worth a line on stderr.
|
|
386
|
+
if (attended) return { written: false, reason: attendedVerdict(gate).reason };
|
|
387
|
+
return { written: false, reason: "no run state found for this repository" };
|
|
388
|
+
}
|
|
280
389
|
let shaRepo = repo;
|
|
281
390
|
if (!shaRepo) {
|
|
282
391
|
const wt = ctx.state?.worktreePath;
|
|
@@ -288,6 +397,7 @@ export async function record(
|
|
|
288
397
|
at: new Date().toISOString(),
|
|
289
398
|
sha: headSha(shaRepo),
|
|
290
399
|
detail: detail && typeof detail === "object" ? detail : { value: detail },
|
|
400
|
+
...(attended ? { attended: true } : {}),
|
|
291
401
|
};
|
|
292
402
|
await updateState(path, (s) => ({
|
|
293
403
|
...s,
|
|
@@ -403,7 +513,12 @@ export function latestByGate(gates, sha) {
|
|
|
403
513
|
* @returns {{ok: boolean, missing: string[], failing: object[], sha: string}}
|
|
404
514
|
*/
|
|
405
515
|
export function checkCommit(state, sha, { mandatory = MANDATORY_AT_COMMIT } = {}) {
|
|
406
|
-
|
|
516
|
+
// An entry written while the gates were off says what an attended run
|
|
517
|
+
// observed; it never stands in for a verdict the unattended contract requires.
|
|
518
|
+
const gated = (Array.isArray(state?.gates) ? state.gates : []).filter(
|
|
519
|
+
(e) => !(e && e.attended === true),
|
|
520
|
+
);
|
|
521
|
+
const latest = latestByGate(gated, sha);
|
|
407
522
|
const missing = [];
|
|
408
523
|
const failing = [];
|
|
409
524
|
for (const g of mandatory) {
|
|
@@ -499,6 +614,47 @@ async function main(argv) {
|
|
|
499
614
|
);
|
|
500
615
|
return 0;
|
|
501
616
|
}
|
|
617
|
+
if (mode === "evidence") {
|
|
618
|
+
// The attended commit check: exit 0 nothing to say (no run, mode off, a
|
|
619
|
+
// mode that commits nothing, evidence present), 1 warn, 4 block. Never 2
|
|
620
|
+
// or 3 for a missing run: outside the pipeline a commit is none of this
|
|
621
|
+
// hook's business.
|
|
622
|
+
const pref = globalPref("attendedCommitGate.mode", "warn");
|
|
623
|
+
const gateMode = ATTENDED_COMMIT_MODES.includes(pref) ? pref : "warn";
|
|
624
|
+
if (gateMode === "off") return 0;
|
|
625
|
+
let session = "";
|
|
626
|
+
if (opts["hook-input"] === "-") {
|
|
627
|
+
try {
|
|
628
|
+
session = String(JSON.parse(readFileSync(0, "utf8"))?.session_id || "");
|
|
629
|
+
} catch {
|
|
630
|
+
session = "";
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const path = opts.state || liveRunFor({ repo, session });
|
|
634
|
+
if (!path) return 0;
|
|
635
|
+
const state = readJson(path);
|
|
636
|
+
if (!state || typeof state !== "object") return 0;
|
|
637
|
+
if (state.status !== "in_progress") return 0;
|
|
638
|
+
if (modeEntry(state)?.commits === false) return 0;
|
|
639
|
+
const sha = opts.sha || headSha(repo);
|
|
640
|
+
if (!sha) return 0;
|
|
641
|
+
const missing = attendedEvidenceGaps(state, sha);
|
|
642
|
+
if (opts.json)
|
|
643
|
+
writeFileSync(1, `${JSON.stringify({ statePath: path, sha, gateMode, missing })}\n`);
|
|
644
|
+
if (!missing.length) return 0;
|
|
645
|
+
const verb = gateMode === "block" ? "BLOCKED" : "warning";
|
|
646
|
+
writeFileSync(
|
|
647
|
+
2,
|
|
648
|
+
`${[
|
|
649
|
+
`pre-commit-check: ${verb}: the run's gate ledger (${path}) has no passing ${missing.join(" / ")} for HEAD ${sha.slice(0, 12)}.`,
|
|
650
|
+
" Run the build and the tests through evidence-gate.mjs (Phase 2 Gate 1) so the commit rests on recorded evidence.",
|
|
651
|
+
gateMode === "block"
|
|
652
|
+
? " prefs.global.attendedCommitGate.mode is block; set it to warn to commit anyway."
|
|
653
|
+
: " The commit goes ahead; prefs.global.attendedCommitGate.mode block refuses it instead.",
|
|
654
|
+
].join("\n")}\n`,
|
|
655
|
+
);
|
|
656
|
+
return gateMode === "block" ? 4 : 1;
|
|
657
|
+
}
|
|
502
658
|
if (mode === "park") {
|
|
503
659
|
const path = resolveStatePath({ statePath: opts.state, repo });
|
|
504
660
|
let pendingQuestion;
|
|
@@ -523,7 +679,7 @@ async function main(argv) {
|
|
|
523
679
|
});
|
|
524
680
|
return 0;
|
|
525
681
|
}
|
|
526
|
-
throw new LedgerError("usage", "mode must be append, check or park");
|
|
682
|
+
throw new LedgerError("usage", "mode must be append, check, evidence or park");
|
|
527
683
|
}
|
|
528
684
|
|
|
529
685
|
if (invokedDirectly(import.meta.url)) {
|
|
@@ -533,7 +689,7 @@ if (invokedDirectly(import.meta.url)) {
|
|
|
533
689
|
process.stderr.write(`gate-ledger: ${err.message}\n`);
|
|
534
690
|
if (err.code === "usage") {
|
|
535
691
|
process.stderr.write(
|
|
536
|
-
"usage: gate-ledger.mjs append|check|park [--gate g] [--verdict v] [--detail json] [--state p] [--repo d] [--sha s] [--json]\n",
|
|
692
|
+
"usage: gate-ledger.mjs append|check|evidence|park [--gate g] [--verdict v] [--detail json] [--state p] [--repo d] [--sha s] [--json]\n",
|
|
537
693
|
);
|
|
538
694
|
process.exit(2);
|
|
539
695
|
}
|
|
@@ -220,15 +220,16 @@ async function main(args) {
|
|
|
220
220
|
const UNREADABLE = gatesOn ? 3 : 2;
|
|
221
221
|
const ZERO_STEPS = gatesOn ? 1 : 2;
|
|
222
222
|
|
|
223
|
+
// Recorded attended too (gate-ledger.mjs ATTENDED_RECORDED, marked
|
|
224
|
+
// `attended: true`): lib/run-completion.mjs reads the verdict before a run
|
|
225
|
+
// may write `status: complete`.
|
|
223
226
|
const finish = async (code, result, detail = {}) => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
);
|
|
231
|
-
}
|
|
227
|
+
await recordQuietly(
|
|
228
|
+
"plan-coverage",
|
|
229
|
+
LEDGER[result.verdict] ?? "fail",
|
|
230
|
+
{ verdict: result.verdict, ...detail },
|
|
231
|
+
{ repo: root, statePath: statePath ?? null },
|
|
232
|
+
);
|
|
232
233
|
return code;
|
|
233
234
|
};
|
|
234
235
|
|
|
@@ -715,6 +715,18 @@ if [ "$IS_COMMIT_HOOK" = "1" ]; then
|
|
|
715
715
|
owned_path_gate || exit 2
|
|
716
716
|
fi
|
|
717
717
|
|
|
718
|
+
# Attended commit inside a pipeline run: the run's gate ledger should hold a
|
|
719
|
+
# passing build and test verdict for HEAD (evidence-gate.mjs records both in an
|
|
720
|
+
# attended run too). prefs.global.attendedCommitGate.mode: warn (default) says so
|
|
721
|
+
# on stderr and lets the commit through, block refuses it, off skips the check.
|
|
722
|
+
# Outside a run gate-ledger.mjs says nothing; any error of its own is not a
|
|
723
|
+
# reason to refuse a commit a person is watching.
|
|
724
|
+
if [ "$IS_COMMIT_HOOK" = "1" ] && ! ma_unattended && command -v node >/dev/null 2>&1; then
|
|
725
|
+
printf '%s' "${HOOK_INPUT:-}" \
|
|
726
|
+
| node "$PRE_COMMIT_DIR/gate-ledger.mjs" evidence --repo "${REPO_ROOT:-$PWD}" --hook-input - >/dev/null
|
|
727
|
+
[ "$?" = "4" ] && exit 2
|
|
728
|
+
fi
|
|
729
|
+
|
|
718
730
|
# Unattended commit: the verification gates must have passed for this HEAD.
|
|
719
731
|
# The environment is the hook's own, inherited from the process the launcher
|
|
720
732
|
# started, so the run cannot talk its way past it by editing its state. An
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-scope-guard.mjs - PreToolUse rules that depend on the active run.
|
|
4
|
+
*
|
|
5
|
+
* Figma (matcher `mcp__.*[Ff]igma.*`): the design is read in the analysis
|
|
6
|
+
* part of a run and nowhere after it (rules/figma-pipeline.md, "No MCP outside
|
|
7
|
+
* analysis phase"). For a run bound to this session by `sessionId` or
|
|
8
|
+
* `hostSessionId` (never by a worktree match) whose status is `in_progress` or
|
|
9
|
+
* `awaiting_input`:
|
|
10
|
+
*
|
|
11
|
+
* - mode `analysis`: allowed at every phase; the analysis command is the
|
|
12
|
+
* design fetch.
|
|
13
|
+
* - any other mode: allowed through Phase `thresholds.mcpAllowedThroughPhase`
|
|
14
|
+
* of schemas/phases.json (Phase 1 Step 1.4 captures the Figma evidence
|
|
15
|
+
* record), denied above it with a reason that says to re-run analysis.
|
|
16
|
+
*
|
|
17
|
+
* An allowed call is appended to `telemetry.mcpCalls[]` by this hook, so the
|
|
18
|
+
* record smoke-no-mcp-in-dev-phases.sh audits no longer depends on the model
|
|
19
|
+
* remembering to write it. A denied call never ran and is not recorded.
|
|
20
|
+
*
|
|
21
|
+
* Worktree boundary (matcher `Edit|Write|NotebookEdit|MultiEdit`), attended
|
|
22
|
+
* only: a file write outside the bound run's worktree (or its other projects'
|
|
23
|
+
* worktrees, its run directory, the log root, the temp directories and
|
|
24
|
+
* ~/.claude) is let through with a warning: `additionalContext` tells the model
|
|
25
|
+
* the target is outside the run, `systemMessage` tells the user. A run with
|
|
26
|
+
* no worktree (`localMode`, `worktreePath: null`) is not checked. The
|
|
27
|
+
* containment test is the one unattended_policy.py applies to its run's
|
|
28
|
+
* worktree, a real-path prefix match; unattended, that policy and its
|
|
29
|
+
* protected paths decide instead, so this rule stays silent there.
|
|
30
|
+
*
|
|
31
|
+
* No run bound to the session: no output, the call proceeds. Any internal
|
|
32
|
+
* error: no output, a line in <log root>/hook-failures.log.
|
|
33
|
+
*
|
|
34
|
+
* Output: the PreToolUse hook JSON (a Figma deny, a boundary warning) or nothing.
|
|
35
|
+
* Exit 0 always.
|
|
36
|
+
*
|
|
37
|
+
* @module pipeline/scripts/run-scope-guard
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
41
|
+
import { homedir, tmpdir } from "node:os";
|
|
42
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
43
|
+
|
|
44
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
45
|
+
import { isUnattended } from "../lib/unattended.mjs";
|
|
46
|
+
import { findActiveRun, logHookFailure, modeOf, phaseContract } from "./_active-run.mjs";
|
|
47
|
+
import { logsRoot } from "./_run-paths.mjs";
|
|
48
|
+
|
|
49
|
+
const HOOK = "run-scope-guard";
|
|
50
|
+
const FIGMA_TOOL = /^mcp__.*figma/i;
|
|
51
|
+
const SESSION_BOUND = new Set(["session", "host-session"]);
|
|
52
|
+
const LIVE = new Set(["in_progress", "awaiting_input"]);
|
|
53
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "MultiEdit"]);
|
|
54
|
+
|
|
55
|
+
const deny = (reason) => ({
|
|
56
|
+
hookSpecificOutput: {
|
|
57
|
+
hookEventName: "PreToolUse",
|
|
58
|
+
permissionDecision: "deny",
|
|
59
|
+
permissionDecisionReason: `run-scope-guard: ${reason}`,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** The last phase a Figma call is allowed in outside an analysis run. */
|
|
64
|
+
export function figmaThroughPhase() {
|
|
65
|
+
const n = Number(phaseContract().thresholds?.mcpAllowedThroughPhase);
|
|
66
|
+
return Number.isInteger(n) ? n : 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function recordMcpCall(statePath, tool, phase) {
|
|
70
|
+
const { updateState } = await import("./gate-ledger.mjs");
|
|
71
|
+
const entry = { tool, phase, timestamp: new Date().toISOString(), recordedBy: HOOK };
|
|
72
|
+
await updateState(statePath, (s) => {
|
|
73
|
+
const telemetry = s.telemetry && typeof s.telemetry === "object" ? s.telemetry : {};
|
|
74
|
+
const calls = Array.isArray(telemetry.mcpCalls) ? telemetry.mcpCalls : [];
|
|
75
|
+
return { ...s, telemetry: { ...telemetry, mcpCalls: [...calls, entry] } };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function figmaDecision(payload, run) {
|
|
80
|
+
const tool = payload.tool_name;
|
|
81
|
+
const state = run.state;
|
|
82
|
+
// A finished, failed or abandoned run governs nothing and records nothing.
|
|
83
|
+
if (!LIVE.has(state.status)) return null;
|
|
84
|
+
const phase = Number(state.currentPhase);
|
|
85
|
+
const at = Number.isInteger(phase) ? phase : 0;
|
|
86
|
+
if (modeOf(state) !== "analysis" && at > figmaThroughPhase()) {
|
|
87
|
+
return deny(
|
|
88
|
+
`${tool} is a Figma call at Phase ${at} of run ${state.taskId || run.statePath}. The design is read during analysis only (through Phase ${figmaThroughPhase()}); later phases work from the analysis document and the repo's Code Connect mappings. If a variant or value is missing, halt and re-run /multi-agent:analysis.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
await recordMcpCall(run.statePath, tool, Math.min(5, Math.max(0, at)));
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The real path of `p`, resolving the nearest existing ancestor. */
|
|
96
|
+
function realish(p) {
|
|
97
|
+
let cur = p;
|
|
98
|
+
const tail = [];
|
|
99
|
+
while (!existsSync(cur)) {
|
|
100
|
+
const up = dirname(cur);
|
|
101
|
+
if (up === cur) return p;
|
|
102
|
+
tail.unshift(basename(cur));
|
|
103
|
+
cur = up;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
return join(realpathSync(cur), ...tail);
|
|
107
|
+
} catch {
|
|
108
|
+
return p;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const under = (path, root) =>
|
|
113
|
+
path === root || path.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
114
|
+
|
|
115
|
+
/** The directories a file write of this run may land in without a warning. */
|
|
116
|
+
export function allowedRoots(run, env = process.env) {
|
|
117
|
+
const s = run.state;
|
|
118
|
+
const roots = [s.worktreePath, s.artifactsPath, dirname(run.statePath)];
|
|
119
|
+
for (const p of Array.isArray(s.projects) ? s.projects : []) roots.push(p?.worktreePath);
|
|
120
|
+
roots.push(env.LOGS_ROOT || logsRoot(), tmpdir(), "/tmp", "/private/tmp", "/var/folders");
|
|
121
|
+
roots.push(join(env.HOME || homedir(), ".claude"));
|
|
122
|
+
return roots.filter((r) => typeof r === "string" && r).map((r) => realish(resolve(r)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function boundaryDecision(payload, run, env) {
|
|
126
|
+
const s = run.state;
|
|
127
|
+
if (isUnattended(env)) return null;
|
|
128
|
+
if (s.status !== "in_progress" || s.localMode === true) return null;
|
|
129
|
+
if (typeof s.worktreePath !== "string" || !s.worktreePath) return null;
|
|
130
|
+
const ti = payload.tool_input || {};
|
|
131
|
+
const raw = ti.file_path || ti.notebook_path;
|
|
132
|
+
if (typeof raw !== "string" || !raw) return null;
|
|
133
|
+
const base = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
134
|
+
const target = realish(isAbsolute(raw) ? raw : resolve(base, raw));
|
|
135
|
+
if (allowedRoots(run, env).some((r) => under(target, r))) return null;
|
|
136
|
+
const msg = `${raw} is outside the worktree of pipeline run ${s.taskId || run.statePath} (${s.worktreePath}). Edits for the run belong in its worktree; if this file really is part of the task, say why before continuing.`;
|
|
137
|
+
return {
|
|
138
|
+
systemMessage: `run-scope-guard: ${msg}`,
|
|
139
|
+
hookSpecificOutput: {
|
|
140
|
+
hookEventName: "PreToolUse",
|
|
141
|
+
additionalContext: `run-scope-guard: ${msg}`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The hook's answer for one payload, or null for no output.
|
|
148
|
+
*
|
|
149
|
+
* @param {object} payload
|
|
150
|
+
* @param {Record<string,string|undefined>} [env]
|
|
151
|
+
*/
|
|
152
|
+
export async function decide(payload, env = process.env) {
|
|
153
|
+
if (!payload || typeof payload !== "object") return null;
|
|
154
|
+
const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
|
155
|
+
const figma = FIGMA_TOOL.test(tool);
|
|
156
|
+
if (!figma && !FILE_TOOLS.has(tool)) return null;
|
|
157
|
+
const run = findActiveRun({
|
|
158
|
+
sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
|
|
159
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : null,
|
|
160
|
+
env,
|
|
161
|
+
});
|
|
162
|
+
// Only a run bound to this session: an old local-mode run matched by its
|
|
163
|
+
// worktree (the project root) must not govern every later session there.
|
|
164
|
+
if (!run || !SESSION_BOUND.has(run.matchedBy)) return null;
|
|
165
|
+
return figma ? figmaDecision(payload, run) : boundaryDecision(payload, run, env);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function main() {
|
|
169
|
+
let payload;
|
|
170
|
+
try {
|
|
171
|
+
payload = JSON.parse(readFileSync(0, "utf8"));
|
|
172
|
+
} catch {
|
|
173
|
+
logHookFailure(HOOK, "the hook payload is not JSON; allowed the call");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
const out = await decide(payload);
|
|
178
|
+
if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
logHookFailure(HOOK, `${err?.message || err}; allowed the call`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const onFatal = (err) => logHookFailure(HOOK, `${err?.message || err}; allowed the call`);
|
|
185
|
+
|
|
186
|
+
// Exit 0 on every path: nothing above sets a non-zero code, and a failure
|
|
187
|
+
// is logged and allowed.
|
|
188
|
+
if (invokedDirectly(import.meta.url)) main().catch(onFatal);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run-scope-guard.sh - PreToolUse rules that depend on the active pipeline run.
|
|
3
|
+
#
|
|
4
|
+
# The decisions are in run-scope-guard.mjs; this wrapper exists because every
|
|
5
|
+
# hook the pipeline registers is a `bash "$HOME/.<host>/scripts/<name>"`
|
|
6
|
+
# command.
|
|
7
|
+
#
|
|
8
|
+
# Contract (Claude Code PreToolUse hook):
|
|
9
|
+
# - Reads the tool-call JSON on stdin.
|
|
10
|
+
# - Prints the hook JSON the host reads (a deny with its reason, or context
|
|
11
|
+
# for the model) or nothing.
|
|
12
|
+
# - Exits 0 on every path. A missing node or an internal error means no
|
|
13
|
+
# output, and the call proceeds.
|
|
14
|
+
|
|
15
|
+
set -u
|
|
16
|
+
|
|
17
|
+
HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
18
|
+
CORE="$HERE/run-scope-guard.mjs"
|
|
19
|
+
[ -f "$CORE" ] || exit 0
|
|
20
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
21
|
+
|
|
22
|
+
node "$CORE" 2>/dev/null || true
|
|
23
|
+
exit 0
|