@mmerterden/multi-agent-pipeline 20.27.2 → 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 +41 -0
- package/README.md +20 -6
- package/README.tr.md +6 -5
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
- package/docs/architecture.md +4 -4
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +2 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/index.mjs +0 -2
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +101 -67
- package/package.json +4 -1
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/test-gen-analyst.md +98 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- 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/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +24 -0
- package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/operations.md +19 -2
- 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 +7 -8
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +10 -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 +51 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
- package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
- package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
- package/pipeline/schemas/test-gen-results.schema.json +44 -0
- 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/_handle-patterns.mjs +166 -0
- package/pipeline/scripts/_http-post.mjs +141 -0
- package/pipeline/scripts/_stack-routing.mjs +4 -0
- package/pipeline/scripts/_test-gaming.mjs +227 -0
- package/pipeline/scripts/_unified-diff.mjs +104 -0
- package/pipeline/scripts/_worktree-snapshot.mjs +113 -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 +105 -0
- package/pipeline/scripts/evidence-gate.mjs +82 -7
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- 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/run-ui-tests.sh +27 -3
- package/pipeline/scripts/test-classify.mjs +296 -0
- package/pipeline/scripts/test-gen-gate.mjs +409 -0
- package/pipeline/scripts/test-gen-state.mjs +313 -0
- package/pipeline/scripts/test-integrity-gate.mjs +76 -11
- package/pipeline/scripts/uninstall.mjs +2 -0
- package/pipeline/scripts/usage-register-beacon.mjs +239 -0
- package/pipeline/scripts/usage-register.mjs +128 -33
- package/pipeline/scripts/usage-report.mjs +43 -33
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +23 -3
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +10 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
- package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
- package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
- package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
- package/pipeline/skills/skills-index.md +7 -2
- package/install/_telemetry.mjs +0 -61
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file handle-diff-audit.mjs - did the handle stage add only handles?
|
|
4
|
+
*
|
|
5
|
+
* The test-gen handle stage may change product code for one purpose: adding
|
|
6
|
+
* the automation handles a UI test finds elements by (iOS
|
|
7
|
+
* accessibilityIdentifier, Compose testTag / semantics, web data-testid). A
|
|
8
|
+
* prompt that says "only add attributes" is a wish; this measures it.
|
|
9
|
+
*
|
|
10
|
+
* Per changed file, the removed lines and the added lines with their handle
|
|
11
|
+
* constructs stripped are read as ordered code tokens (_handle-patterns.mjs
|
|
12
|
+
* codeTokens: operators and punctuation kept, whitespace dropped), hunk by
|
|
13
|
+
* hunk in order. The two sequences must be equal. Re-wrapping a tag over
|
|
14
|
+
* several lines or moving a modifier onto its own line keeps them equal; a
|
|
15
|
+
* flipped comparison, a dropped `!`, swapped arguments or a deleted modifier
|
|
16
|
+
* does not. A difference is reported as:
|
|
17
|
+
*
|
|
18
|
+
* lost tokens the removed lines had that the added lines lack
|
|
19
|
+
* extra tokens the added lines carry beyond the removed ones
|
|
20
|
+
* reordered the same tokens in another order
|
|
21
|
+
* new-file a file appeared that is not a handle-constants file (string
|
|
22
|
+
* constants, the enums or objects grouping them, imports,
|
|
23
|
+
* comments, nothing else)
|
|
24
|
+
* deleted-file / binary
|
|
25
|
+
*
|
|
26
|
+
* Snapshot files are listed apart: a new attribute changes a rendered
|
|
27
|
+
* snapshot, and its owners should know, but it is not a violation.
|
|
28
|
+
*
|
|
29
|
+
* What is read:
|
|
30
|
+
* --since <tree> / --baseline <json> only what changed after the stage-start
|
|
31
|
+
* snapshot (test-gen-gate.mjs baseline records its tree),
|
|
32
|
+
* so uncommitted work the person had before the stage is
|
|
33
|
+
* never read as the stage's change
|
|
34
|
+
* --base <ref> the working tree, untracked files included, against a
|
|
35
|
+
* commit (default HEAD)
|
|
36
|
+
* --diff-file <p> a saved unified diff
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* node handle-diff-audit.mjs [--repo <path>] [--since <tree> | --baseline <json> | --base <ref>]
|
|
40
|
+
* node handle-diff-audit.mjs --diff-file <unified.diff>
|
|
41
|
+
*
|
|
42
|
+
* Output: one JSON object on stdout.
|
|
43
|
+
* Exit: 0 only handles (or nothing changed), 2 violation, 3 input unreadable
|
|
44
|
+
* (a missing or option-shaped value, an unknown ref, a failed git call)
|
|
45
|
+
*
|
|
46
|
+
* @module pipeline/scripts/handle-diff-audit
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { pathToFileURL } from "node:url";
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
codeTokens,
|
|
54
|
+
isHandleConstantsFile,
|
|
55
|
+
isHandleLine,
|
|
56
|
+
isSnapshotPath,
|
|
57
|
+
parseUnifiedDiff,
|
|
58
|
+
stripHandles,
|
|
59
|
+
} from "./_handle-patterns.mjs";
|
|
60
|
+
import {
|
|
61
|
+
GitError,
|
|
62
|
+
diffSince,
|
|
63
|
+
optionValue,
|
|
64
|
+
snapshotTree,
|
|
65
|
+
treeDiff,
|
|
66
|
+
verifyCommit,
|
|
67
|
+
} from "./_worktree-snapshot.mjs";
|
|
68
|
+
|
|
69
|
+
const VALUE_FLAGS = new Set(["repo", "base", "since", "baseline", "diff-file"]);
|
|
70
|
+
|
|
71
|
+
function emit(result, code) {
|
|
72
|
+
writeFileSync(1, `${JSON.stringify({ ...result, code })}\n`);
|
|
73
|
+
process.exit(code);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function unreadable(reason) {
|
|
77
|
+
emit({ ok: false, reason }, 3);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseArgs(argv) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (let i = 0; i < argv.length; i++) {
|
|
83
|
+
const a = argv[i];
|
|
84
|
+
if (!a.startsWith("--")) unreadable(`unexpected argument ${a}`);
|
|
85
|
+
const key = a.slice(2);
|
|
86
|
+
if (!VALUE_FLAGS.has(key)) unreadable(`unknown option ${a}`);
|
|
87
|
+
const v = argv[i + 1];
|
|
88
|
+
if (v === undefined || v.startsWith("-"))
|
|
89
|
+
unreadable(`${a} needs a value that does not start with "-"`);
|
|
90
|
+
out[key] = v;
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readDiff(args) {
|
|
97
|
+
if (args["diff-file"]) {
|
|
98
|
+
try {
|
|
99
|
+
return readFileSync(args["diff-file"], "utf8");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return unreadable(`cannot read --diff-file: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const repo = args.repo || process.cwd();
|
|
105
|
+
try {
|
|
106
|
+
if (args.since || args.baseline) {
|
|
107
|
+
let tree = args.since;
|
|
108
|
+
if (!tree) {
|
|
109
|
+
const doc = JSON.parse(readFileSync(args.baseline, "utf8"));
|
|
110
|
+
tree = optionValue("baseline tree", doc.tree);
|
|
111
|
+
}
|
|
112
|
+
return diffSince(repo, tree);
|
|
113
|
+
}
|
|
114
|
+
const base = verifyCommit(repo, args.base || "HEAD");
|
|
115
|
+
return treeDiff(repo, base, snapshotTree(repo));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err instanceof GitError || err instanceof SyntaxError || err.code === "ENOENT") {
|
|
118
|
+
return unreadable(err.message);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Multiset difference: tokens of `a` not covered by `b`. */
|
|
125
|
+
function missing(a, b) {
|
|
126
|
+
const pool = new Map();
|
|
127
|
+
for (const t of b) pool.set(t, (pool.get(t) || 0) + 1);
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const t of a) {
|
|
130
|
+
const n = pool.get(t) || 0;
|
|
131
|
+
if (n > 0) pool.set(t, n - 1);
|
|
132
|
+
else out.push(t);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function audit(diffText) {
|
|
138
|
+
const files = [];
|
|
139
|
+
const violations = [];
|
|
140
|
+
const snapshots = [];
|
|
141
|
+
let handlesAdded = 0;
|
|
142
|
+
for (const [file, f] of parseUnifiedDiff(diffText)) {
|
|
143
|
+
if (isSnapshotPath(file)) {
|
|
144
|
+
snapshots.push(file);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const handles = f.added.filter((l) => isHandleLine(file, l)).length;
|
|
148
|
+
handlesAdded += handles;
|
|
149
|
+
files.push({ file, handles, added: f.added.length, removed: f.removed.length });
|
|
150
|
+
if (f.binary) {
|
|
151
|
+
violations.push({ file, kind: "binary", tokens: [] });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (f.isDeleted) {
|
|
155
|
+
violations.push({ file, kind: "deleted-file", tokens: [] });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (f.isNew) {
|
|
159
|
+
if (!isHandleConstantsFile(f.added)) violations.push({ file, kind: "new-file", tokens: [] });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const before = f.hunks.flatMap((h) => codeTokens(h.removed.map((d) => d.text).join("\n")));
|
|
163
|
+
const after = f.hunks.flatMap((h) =>
|
|
164
|
+
codeTokens(h.added.map((d) => stripHandles(file, d.text)).join("\n")),
|
|
165
|
+
);
|
|
166
|
+
if (before.length === after.length && before.every((t, i) => t === after[i])) continue;
|
|
167
|
+
const lost = missing(before, after);
|
|
168
|
+
const extra = missing(after, before);
|
|
169
|
+
if (lost.length) violations.push({ file, kind: "lost", tokens: [...new Set(lost)] });
|
|
170
|
+
if (extra.length) violations.push({ file, kind: "extra", tokens: [...new Set(extra)] });
|
|
171
|
+
if (!lost.length && !extra.length) {
|
|
172
|
+
const at = before.findIndex((t, i) => t !== after[i]);
|
|
173
|
+
violations.push({ file, kind: "reordered", tokens: before.slice(at, at + 6) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ok: violations.length === 0, handlesAdded, files, snapshots, violations };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
const args = parseArgs(process.argv.slice(2));
|
|
181
|
+
const text = readDiff(args);
|
|
182
|
+
if (!text.trim())
|
|
183
|
+
emit({ ok: true, handlesAdded: 0, files: [], snapshots: [], violations: [] }, 0);
|
|
184
|
+
const result = audit(text);
|
|
185
|
+
emit(result, result.ok ? 0 : 2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -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
|
|
@@ -122,6 +122,10 @@ function cmdGet(home, name) {
|
|
|
122
122
|
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Every saved routine is written with this grant; permission-baseline.mjs
|
|
126
|
+
// freezes it.
|
|
127
|
+
export const ROUTINE_ALLOWED_TOOLS = "Skill, Bash, Read, Edit, Write, AskUserQuestion";
|
|
128
|
+
|
|
125
129
|
function buildSkill(args) {
|
|
126
130
|
const desc = args.description;
|
|
127
131
|
const descTr = args["description-tr"];
|
|
@@ -132,7 +136,7 @@ function buildSkill(args) {
|
|
|
132
136
|
`description: ${JSON.stringify(desc)}`,
|
|
133
137
|
descTr ? `description-tr: ${JSON.stringify(descTr)}` : null,
|
|
134
138
|
`argument-hint: ${JSON.stringify(hint)}`,
|
|
135
|
-
|
|
139
|
+
`allowed-tools: ${ROUTINE_ALLOWED_TOOLS}`,
|
|
136
140
|
"local-only: true",
|
|
137
141
|
"---",
|
|
138
142
|
].filter(Boolean);
|
|
@@ -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
|