@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 +19 -0
- package/README.md +19 -6
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +122 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +189 -77
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +531 -4
- 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 +5 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -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/office/NOTICE.md +42 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +5319 -0
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +429 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +56 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -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-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -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/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -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 +42 -6
- package/pipeline/scripts/run-results.mjs +373 -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/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +4 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/skills-index.md +4 -1
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file run-results.mjs - what a run produced, read from its own files: the
|
|
4
|
+
* diff summary, the review findings, the test outcome, the screenshots and,
|
|
5
|
+
* for an analysis run, the documents it wrote.
|
|
6
|
+
*
|
|
7
|
+
* Every source is a file or a state field the phases already write; nothing is
|
|
8
|
+
* computed here that a phase did not record:
|
|
9
|
+
*
|
|
10
|
+
* diff agent-state `diffRisk` (diff-risk-score.mjs, Phase 3), else the
|
|
11
|
+
* file and line counts of `.review-diff.txt`
|
|
12
|
+
* findings `triage-output.json` (Phase 3 triage), else agent-state
|
|
13
|
+
* `review.findings` (a standalone /multi-agent:review)
|
|
14
|
+
* tests agent-state `uiTest` and the `test-summary` gate entries
|
|
15
|
+
* (test-summary.mjs)
|
|
16
|
+
* evidence `.pipeline/evidence/*.png|jpg` and agent-state `visualEvidence`
|
|
17
|
+
* analysis agent-state `analysisSpec.outputs` (the documents' text) and
|
|
18
|
+
* `analysis.docPath[]`
|
|
19
|
+
*
|
|
20
|
+
* Each file is looked for in the run's log directory, its `artifacts/` copy
|
|
21
|
+
* (worktree-finalize.sh salvages there) and, while the run is live, its
|
|
22
|
+
* worktree. The state's worktreePath is used only when git lists it as a
|
|
23
|
+
* worktree of the state's projectRoot. A part with no source is null, never
|
|
24
|
+
* an empty success.
|
|
25
|
+
*
|
|
26
|
+
* An analysis document is read only when its real path sits inside one of
|
|
27
|
+
* those roots and it is Markdown or HTML; any other path in the state keeps
|
|
28
|
+
* its name with `text: null`, so a state file cannot point the reader at an
|
|
29
|
+
* arbitrary file.
|
|
30
|
+
*
|
|
31
|
+
* Text a person or tool wrote (finding text, test names, document bodies) is
|
|
32
|
+
* scrubbed and run through the credential redactor; paths are reduced to file
|
|
33
|
+
* names.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* run-results.mjs <task-id> [--json]
|
|
37
|
+
* run-results.mjs <task-id> --evidence <name> one image, base64, as JSON
|
|
38
|
+
*
|
|
39
|
+
* Exit: 0 printed, 2 usage, 3 no such run or image.
|
|
40
|
+
*
|
|
41
|
+
* @module pipeline/scripts/run-results
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { execFileSync } from "node:child_process";
|
|
45
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
46
|
+
import { basename, join, sep } from "node:path";
|
|
47
|
+
import { selectRuns } from "./runs-index.mjs";
|
|
48
|
+
import { createRedactor } from "./transcript-index.mjs";
|
|
49
|
+
import { readRegularJson } from "../lib/regular-file.mjs";
|
|
50
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
51
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
52
|
+
|
|
53
|
+
export const CONTRACT_VERSION = "1.0.0";
|
|
54
|
+
export const EVIDENCE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.(?:png|jpe?g)$/i;
|
|
55
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
56
|
+
const MAX_DOC_CHARS = 200_000;
|
|
57
|
+
const MAX_FINDINGS = 200;
|
|
58
|
+
const DOC_EXT = /\.(?:md|markdown|html?)$/i;
|
|
59
|
+
|
|
60
|
+
export class ResultsError extends Error {}
|
|
61
|
+
|
|
62
|
+
function readState(dir) {
|
|
63
|
+
for (const p of [join(dir, "agent-state.json"), join(dir, "artifacts", "agent-state.json")]) {
|
|
64
|
+
if (existsSync(p)) return readRegularJson(p, null) ?? {};
|
|
65
|
+
}
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const realOrNull = (p) => {
|
|
70
|
+
try {
|
|
71
|
+
return realpathSync(p);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The state's worktreePath when git lists it as a worktree of the state's
|
|
79
|
+
* projectRoot, else null.
|
|
80
|
+
*/
|
|
81
|
+
export function verifiedWorktree(state) {
|
|
82
|
+
const wt = typeof state.worktreePath === "string" ? realOrNull(state.worktreePath) : null;
|
|
83
|
+
const root = typeof state.projectRoot === "string" ? realOrNull(state.projectRoot) : null;
|
|
84
|
+
if (!wt || !root) return null;
|
|
85
|
+
let listing;
|
|
86
|
+
try {
|
|
87
|
+
listing = execFileSync("git", ["-C", root, "worktree", "list", "--porcelain"], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
timeout: 5000,
|
|
90
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
for (const line of listing.split("\n")) {
|
|
96
|
+
if (line.startsWith("worktree ") && realOrNull(line.slice(9)) === wt) return wt;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The directories a run's files may be in, most durable first. */
|
|
102
|
+
function roots(run, state) {
|
|
103
|
+
const out = [run.dir, join(run.dir, "artifacts")];
|
|
104
|
+
const wt = verifiedWorktree(state);
|
|
105
|
+
if (wt) out.push(wt);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The real path of `p` when it is a Markdown or HTML file inside one of `dirs`. */
|
|
110
|
+
function containedDoc(p, dirs) {
|
|
111
|
+
const real = realOrNull(p);
|
|
112
|
+
if (!real || !DOC_EXT.test(real)) return null;
|
|
113
|
+
for (const d of dirs) {
|
|
114
|
+
const rd = realOrNull(d);
|
|
115
|
+
if (rd && real.startsWith(rd + sep)) return real;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function firstExisting(dirs, rel) {
|
|
121
|
+
for (const d of dirs) {
|
|
122
|
+
const p = join(d, rel);
|
|
123
|
+
try {
|
|
124
|
+
if (statSync(p).isFile()) return p;
|
|
125
|
+
} catch {
|
|
126
|
+
// Not in this root.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function diffPart(state, dirs) {
|
|
133
|
+
const r = state.diffRisk;
|
|
134
|
+
if (r && typeof r === "object" && Number.isInteger(r.files)) {
|
|
135
|
+
return {
|
|
136
|
+
files: r.files,
|
|
137
|
+
added: Number.isInteger(r.loc_added) ? r.loc_added : null,
|
|
138
|
+
removed: Number.isInteger(r.loc_removed) ? r.loc_removed : null,
|
|
139
|
+
maxScore: typeof r.max_score === "number" ? r.max_score : null,
|
|
140
|
+
signals: Array.isArray(r.signals) ? r.signals.map(String) : [],
|
|
141
|
+
changed: [],
|
|
142
|
+
source: "diffRisk",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const p = firstExisting(dirs, ".review-diff.txt");
|
|
146
|
+
if (!p) return null;
|
|
147
|
+
const files = new Set();
|
|
148
|
+
let added = 0;
|
|
149
|
+
let removed = 0;
|
|
150
|
+
for (const line of readFileSync(p, "utf8").split("\n")) {
|
|
151
|
+
const m = /^\+\+\+ b\/(.+)$/.exec(line);
|
|
152
|
+
if (m) files.add(m[1]);
|
|
153
|
+
else if (line.startsWith("+") && !line.startsWith("+++")) added++;
|
|
154
|
+
else if (line.startsWith("-") && !line.startsWith("---")) removed++;
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
files: files.size,
|
|
158
|
+
added,
|
|
159
|
+
removed,
|
|
160
|
+
maxScore: null,
|
|
161
|
+
signals: [],
|
|
162
|
+
changed: [...files].slice(0, 200).map((f) => basename(f)),
|
|
163
|
+
source: ".review-diff.txt",
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function findingsPart(state, dirs, R) {
|
|
168
|
+
const p = firstExisting(dirs, "triage-output.json");
|
|
169
|
+
const triage = p ? readRegularJson(p, null) : null;
|
|
170
|
+
const shape = (f, verdict) => ({
|
|
171
|
+
severity: String(f?.severity ?? "unknown"),
|
|
172
|
+
file: f?.file ? basename(String(f.file)) : null,
|
|
173
|
+
line: Number.isInteger(f?.line) ? f.line : null,
|
|
174
|
+
issue: R.excerpt(f?.issue ?? f?.title ?? f?.message ?? "", 400),
|
|
175
|
+
fix: f?.fix ? R.excerpt(f.fix, 400) : null,
|
|
176
|
+
reviewer: f?.reviewer ? String(f.reviewer) : null,
|
|
177
|
+
verdict,
|
|
178
|
+
});
|
|
179
|
+
if (triage && typeof triage === "object") {
|
|
180
|
+
const items = [];
|
|
181
|
+
for (const v of ["accepted", "deferred", "rejected"]) {
|
|
182
|
+
for (const f of Array.isArray(triage[v]) ? triage[v] : []) items.push(shape(f, v));
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
approved: typeof triage.approved === "boolean" ? triage.approved : null,
|
|
186
|
+
items: items.slice(0, MAX_FINDINGS),
|
|
187
|
+
total: items.length,
|
|
188
|
+
source: "triage-output.json",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const review = state.review;
|
|
192
|
+
if (review && Array.isArray(review.findings)) {
|
|
193
|
+
const items = review.findings.map((f) => shape(f, f?.status ?? f?.verdict ?? "accepted"));
|
|
194
|
+
return {
|
|
195
|
+
approved: null,
|
|
196
|
+
items: items.slice(0, MAX_FINDINGS),
|
|
197
|
+
total: items.length,
|
|
198
|
+
source: "agent-state review",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function testsPart(state, R) {
|
|
205
|
+
const gates = Array.isArray(state.gates) ? state.gates : [];
|
|
206
|
+
const summaries = gates.filter(
|
|
207
|
+
(g) => g && (g.gate === "test-summary" || g.name === "test-summary"),
|
|
208
|
+
);
|
|
209
|
+
const last = summaries.at(-1) ?? null;
|
|
210
|
+
const ui = state.uiTest && typeof state.uiTest === "object" ? state.uiTest : null;
|
|
211
|
+
if (!last && !ui) return null;
|
|
212
|
+
const count = (k) => {
|
|
213
|
+
const v = last?.counts?.[k] ?? last?.[k];
|
|
214
|
+
return Number.isInteger(v) ? v : null;
|
|
215
|
+
};
|
|
216
|
+
return {
|
|
217
|
+
status: last?.status ?? last?.verdict ?? ui?.status ?? null,
|
|
218
|
+
passed: count("passed"),
|
|
219
|
+
failed: count("failed"),
|
|
220
|
+
skipped: count("skipped"),
|
|
221
|
+
uiTest: ui
|
|
222
|
+
? {
|
|
223
|
+
ran: ui.ran === true,
|
|
224
|
+
status: ui.status ?? null,
|
|
225
|
+
notRunReason: ui.notRunReason ? R.excerpt(ui.notRunReason, 200) : null,
|
|
226
|
+
}
|
|
227
|
+
: null,
|
|
228
|
+
source: last ? "test-summary gate" : "agent-state uiTest",
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function evidenceDirs(dirs) {
|
|
233
|
+
return dirs.map((d) => join(d, ".pipeline", "evidence")).filter((d) => existsSync(d));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function evidencePart(state, dirs) {
|
|
237
|
+
const names = new Map();
|
|
238
|
+
for (const d of evidenceDirs(dirs)) {
|
|
239
|
+
let list;
|
|
240
|
+
try {
|
|
241
|
+
list = readdirSync(d);
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
for (const f of list) if (EVIDENCE_NAME.test(f) && !names.has(f)) names.set(f, null);
|
|
246
|
+
}
|
|
247
|
+
const ve = state.visualEvidence;
|
|
248
|
+
for (const side of ["before", "after"]) {
|
|
249
|
+
for (const e of Array.isArray(ve?.[side]) ? ve[side] : []) {
|
|
250
|
+
const f = typeof e?.file === "string" ? basename(e.file) : null;
|
|
251
|
+
if (f && names.has(f)) names.set(f, side);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...names.entries()].sort().map(([name, phase]) => ({ name, phase }));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function analysisPart(state, dirs, R) {
|
|
258
|
+
const outputs = state.analysisSpec?.outputs;
|
|
259
|
+
const paths = [
|
|
260
|
+
...(Array.isArray(outputs?.localPaths) ? outputs.localPaths : []),
|
|
261
|
+
...(Array.isArray(state.analysis?.docPath) ? state.analysis.docPath : []),
|
|
262
|
+
].filter((p) => typeof p === "string");
|
|
263
|
+
if (!paths.length && !outputs) return null;
|
|
264
|
+
const documents = [];
|
|
265
|
+
for (const p of [...new Set(paths)]) {
|
|
266
|
+
const real = containedDoc(p, dirs);
|
|
267
|
+
try {
|
|
268
|
+
if (!real || !statSync(real).isFile()) throw new Error("not readable here");
|
|
269
|
+
const text = readFileSync(real, "utf8").slice(0, MAX_DOC_CHARS);
|
|
270
|
+
documents.push({ name: basename(p), text: R.clean(text) });
|
|
271
|
+
} catch {
|
|
272
|
+
documents.push({ name: basename(p), text: null });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
documents,
|
|
277
|
+
confluencePages: Array.isArray(outputs?.confluencePageUrls)
|
|
278
|
+
? outputs.confluencePageUrls.length
|
|
279
|
+
: 0,
|
|
280
|
+
jiraIssueKey:
|
|
281
|
+
typeof outputs?.jiraIssueKey === "string" &&
|
|
282
|
+
/^[A-Z][A-Z0-9_]*-[0-9]+$/.test(outputs.jiraIssueKey)
|
|
283
|
+
? outputs.jiraIssueKey
|
|
284
|
+
: null,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function runFor(taskId) {
|
|
289
|
+
const { found, records } = selectRuns({ taskId });
|
|
290
|
+
if (!found || !records.length) throw new ResultsError("no such run");
|
|
291
|
+
return records[0];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Everything a run produced, as one document. */
|
|
295
|
+
export function runResults(taskId) {
|
|
296
|
+
const run = runFor(taskId);
|
|
297
|
+
const state = readState(run.dir);
|
|
298
|
+
const dirs = roots(run, state);
|
|
299
|
+
const R = createRedactor();
|
|
300
|
+
return {
|
|
301
|
+
contractVersion: CONTRACT_VERSION,
|
|
302
|
+
taskId: run.taskId,
|
|
303
|
+
diff: diffPart(state, dirs),
|
|
304
|
+
findings: findingsPart(state, dirs, R),
|
|
305
|
+
tests: testsPart(state, R),
|
|
306
|
+
evidence: evidencePart(state, dirs),
|
|
307
|
+
analysis: analysisPart(state, dirs, R),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* One screenshot as base64. The name must be a bare image file name found in
|
|
313
|
+
* one of the run's evidence directories; its real path must stay inside it.
|
|
314
|
+
*/
|
|
315
|
+
export function runEvidence(taskId, name) {
|
|
316
|
+
if (typeof name !== "string" || !EVIDENCE_NAME.test(name))
|
|
317
|
+
throw new ResultsError("no such image");
|
|
318
|
+
const run = runFor(taskId);
|
|
319
|
+
const state = readState(run.dir);
|
|
320
|
+
for (const d of evidenceDirs(roots(run, state))) {
|
|
321
|
+
const p = join(d, name);
|
|
322
|
+
let real;
|
|
323
|
+
let realDir;
|
|
324
|
+
try {
|
|
325
|
+
real = realpathSync(p);
|
|
326
|
+
realDir = realpathSync(d);
|
|
327
|
+
} catch {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!real.startsWith(realDir + sep)) continue;
|
|
331
|
+
const st = statSync(real);
|
|
332
|
+
if (!st.isFile() || st.size > MAX_IMAGE_BYTES) continue;
|
|
333
|
+
const mime = /\.png$/i.test(name) ? "image/png" : "image/jpeg";
|
|
334
|
+
return {
|
|
335
|
+
contractVersion: CONTRACT_VERSION,
|
|
336
|
+
taskId: run.taskId,
|
|
337
|
+
name,
|
|
338
|
+
mime,
|
|
339
|
+
data: readFileSync(real).toString("base64"),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
throw new ResultsError("no such image");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function main(argv) {
|
|
346
|
+
const [taskId, ...rest] = argv;
|
|
347
|
+
if (!taskId) {
|
|
348
|
+
process.stderr.write("usage: run-results.mjs <task-id> [--json] | --evidence <name>\n");
|
|
349
|
+
return 2;
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
if (rest[0] === "--evidence") {
|
|
353
|
+
process.stdout.write(JSON.stringify(runEvidence(taskId, rest[1])) + "\n");
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
if (rest.some((a) => a !== "--json")) {
|
|
357
|
+
process.stderr.write("usage: run-results.mjs <task-id> [--json] | --evidence <name>\n");
|
|
358
|
+
return 2;
|
|
359
|
+
}
|
|
360
|
+
process.stdout.write(JSON.stringify(runResults(taskId), null, 2) + "\n");
|
|
361
|
+
return 0;
|
|
362
|
+
} catch (err) {
|
|
363
|
+
if (!(err instanceof ResultsError)) throw err;
|
|
364
|
+
process.stderr.write(`run-results: ${err.message}\n`);
|
|
365
|
+
return 3;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (invokedDirectly(import.meta.url)) {
|
|
370
|
+
runMain("run-results", () => {
|
|
371
|
+
process.exitCode = main(process.argv.slice(2));
|
|
372
|
+
});
|
|
373
|
+
}
|
|
@@ -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
|