@mmerterden/multi-agent-pipeline 20.29.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 +6 -0
- package/README.md +5 -5
- 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 +10 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/manifest.json +149 -48
- package/package.json +2 -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/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 +530 -3
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- 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/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/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/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/prefs.schema.json +19 -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/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/_cost.mjs +66 -1
- 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/autopilot-control.mjs +214 -9
- 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/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -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 +3 -0
- package/pipeline/scripts/work-items.mjs +304 -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,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file service-read.mjs - read one thing from Confluence, Crashlytics or
|
|
4
|
+
* Figma through the clients the pipeline already ships, for a client that
|
|
5
|
+
* shows it next to a run:
|
|
6
|
+
*
|
|
7
|
+
* confluence <page> lib/fetch-confluence.sh: a page by id or by https
|
|
8
|
+
* URL, both only on the configured
|
|
9
|
+
* prefs.global.hosts.confluence; a URL on any other
|
|
10
|
+
* host, or any URL when no host is configured, is
|
|
11
|
+
* refused before the script runs; title, markdown
|
|
12
|
+
* body, API contracts count
|
|
13
|
+
* crashlytics <issue-url> lib/fetch-crashlytics.sh: one Crashlytics issue,
|
|
14
|
+
* title, occurrences, impacted users, top frames
|
|
15
|
+
* figma <file-key> <node> lib/figma-screenshot.sh (REST with the Figma
|
|
16
|
+
* token, tier 2): one PNG render of a frame. The
|
|
17
|
+
* Figma MCP is never called here; the pipeline
|
|
18
|
+
* keeps MCP access to the analysis phase.
|
|
19
|
+
*
|
|
20
|
+
* Search across Confluence, a per-version Crashlytics trend and a Figma file
|
|
21
|
+
* browser have no client in the pipeline, so none is offered; a client asks
|
|
22
|
+
* an agent instead.
|
|
23
|
+
*
|
|
24
|
+
* The scripts run as child processes without blocking the caller's event
|
|
25
|
+
* loop (contract-server.mjs serves other routes meanwhile).
|
|
26
|
+
*
|
|
27
|
+
* Credentials are read only by those scripts, through credential-store.sh.
|
|
28
|
+
* Every text that comes back is scrubbed and redacted; an image comes back as
|
|
29
|
+
* base64.
|
|
30
|
+
*
|
|
31
|
+
* Usage:
|
|
32
|
+
* service-read.mjs confluence <page-id|https-url>
|
|
33
|
+
* service-read.mjs crashlytics <https-issue-url>
|
|
34
|
+
* service-read.mjs figma <file-key> <node-id>
|
|
35
|
+
*
|
|
36
|
+
* MA_FETCH_CONFLUENCE, MA_FETCH_CRASHLYTICS and MA_FIGMA_SCREENSHOT stand in
|
|
37
|
+
* for the scripts (tests).
|
|
38
|
+
*
|
|
39
|
+
* Exit: 0 printed, 1 refused or failed, 2 usage.
|
|
40
|
+
*
|
|
41
|
+
* @module pipeline/scripts/service-read
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
45
|
+
import { homedir, tmpdir } from "node:os";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
|
+
import { fileURLToPath } from "node:url";
|
|
48
|
+
import { createRedactor } from "./transcript-index.mjs";
|
|
49
|
+
import { spawnAsync } from "./_spawn-async.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
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const TIMEOUT_MS = 90_000;
|
|
56
|
+
const MAX_TEXT = 100_000;
|
|
57
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
58
|
+
export const PAGE_ID = /^[0-9]{1,20}$/;
|
|
59
|
+
export const FIGMA_KEY = /^[A-Za-z0-9]{10,64}$/;
|
|
60
|
+
export const FIGMA_NODE = /^[0-9]{1,10}[:-][0-9]{1,10}$/;
|
|
61
|
+
|
|
62
|
+
export class ServiceError extends Error {
|
|
63
|
+
constructor(code, message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.code = code;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const script = (env, name, override) =>
|
|
70
|
+
env[override] ||
|
|
71
|
+
[join(HERE, "..", "lib", name), join(HERE, "..", "..", "lib", name)].find((p) => existsSync(p));
|
|
72
|
+
|
|
73
|
+
function httpsUrl(v, host = null) {
|
|
74
|
+
if (typeof v !== "string" || v.length > 2048) return null;
|
|
75
|
+
let u;
|
|
76
|
+
try {
|
|
77
|
+
u = new URL(v);
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (u.protocol !== "https:" || u.username || u.password) return null;
|
|
82
|
+
if (host && u.hostname !== host) return null;
|
|
83
|
+
return u.toString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function run(env, file, args, failCodes) {
|
|
87
|
+
if (!file) throw new ServiceError("not-installed", "the service client is not installed");
|
|
88
|
+
const r = await spawnAsync("bash", [file, ...args], { env, timeoutMs: TIMEOUT_MS });
|
|
89
|
+
if (r.status !== 0) {
|
|
90
|
+
const why = failCodes[r.status] ?? "the service could not be read";
|
|
91
|
+
throw new ServiceError("service-failed", why);
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
return JSON.parse(r.stdout);
|
|
95
|
+
} catch {
|
|
96
|
+
throw new ServiceError(
|
|
97
|
+
"service-failed",
|
|
98
|
+
"the service answered with something that is not JSON",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function confluenceHost(env) {
|
|
104
|
+
try {
|
|
105
|
+
const prefs = JSON.parse(
|
|
106
|
+
readFileSync(
|
|
107
|
+
env.MULTI_AGENT_PREFS ||
|
|
108
|
+
join(env.HOME || homedir(), ".claude", "multi-agent-preferences.json"),
|
|
109
|
+
"utf8",
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
const h = prefs?.global?.hosts?.confluence;
|
|
113
|
+
return typeof h === "string" && h ? h.replace(/^https?:\/\//, "").replace(/\/.*$/, "") : null;
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One Confluence page, by id or by a URL on the configured host only. */
|
|
120
|
+
export async function confluencePage(page, env = process.env) {
|
|
121
|
+
let args;
|
|
122
|
+
const host = confluenceHost(env);
|
|
123
|
+
if (PAGE_ID.test(String(page))) {
|
|
124
|
+
if (!host) throw new ServiceError("not-configured", "no Confluence host is configured");
|
|
125
|
+
args = ["--host", host, "--page-id", String(page)];
|
|
126
|
+
} else {
|
|
127
|
+
const url = httpsUrl(page);
|
|
128
|
+
if (!url) throw new ServiceError("bad-input", "a page id or an https URL is required");
|
|
129
|
+
if (!host) {
|
|
130
|
+
throw new ServiceError(
|
|
131
|
+
"host-not-allowed",
|
|
132
|
+
"no Confluence host is configured, so only a page id is accepted",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (new URL(url).hostname !== host) {
|
|
136
|
+
throw new ServiceError("host-not-allowed", "that host is not the configured Confluence host");
|
|
137
|
+
}
|
|
138
|
+
args = [url];
|
|
139
|
+
}
|
|
140
|
+
const d = await run(env, script(env, "fetch-confluence.sh", "MA_FETCH_CONFLUENCE"), args, {
|
|
141
|
+
2: "the Confluence credential is missing or was refused",
|
|
142
|
+
3: "Confluence could not be reached",
|
|
143
|
+
6: "that host is not the configured Confluence host",
|
|
144
|
+
});
|
|
145
|
+
const R = createRedactor();
|
|
146
|
+
return {
|
|
147
|
+
contractVersion: CONTRACT_VERSION,
|
|
148
|
+
service: "confluence",
|
|
149
|
+
pageId: d?.source?.pageId ? String(d.source.pageId) : null,
|
|
150
|
+
title: R.excerpt(d?.title ?? "", 300),
|
|
151
|
+
body: R.clean(String(d?.bodyMarkdown ?? "").slice(0, MAX_TEXT)),
|
|
152
|
+
apiContracts: Array.isArray(d?.apiContracts) ? d.apiContracts.length : 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** One Crashlytics issue. */
|
|
157
|
+
export async function crashlyticsIssue(issueUrl, env = process.env) {
|
|
158
|
+
const url = httpsUrl(issueUrl, "console.firebase.google.com");
|
|
159
|
+
if (!url) throw new ServiceError("bad-input", "a Firebase console issue URL is required");
|
|
160
|
+
const d = await run(env, script(env, "fetch-crashlytics.sh", "MA_FETCH_CRASHLYTICS"), [url], {
|
|
161
|
+
2: "the Firebase credential is missing or was refused",
|
|
162
|
+
3: "Crashlytics could not be reached",
|
|
163
|
+
});
|
|
164
|
+
const R = createRedactor();
|
|
165
|
+
const i = d?.issue ?? {};
|
|
166
|
+
return {
|
|
167
|
+
contractVersion: CONTRACT_VERSION,
|
|
168
|
+
service: "crashlytics",
|
|
169
|
+
title: R.excerpt(i.title ?? "", 300),
|
|
170
|
+
type: i.type ? String(i.type) : null,
|
|
171
|
+
occurrences: Number.isFinite(i.occurrences) ? i.occurrences : null,
|
|
172
|
+
impactedUsers: Number.isFinite(i.impactedUsers) ? i.impactedUsers : null,
|
|
173
|
+
stackTrace: (Array.isArray(i.stackTrace) ? i.stackTrace : [])
|
|
174
|
+
.slice(0, 40)
|
|
175
|
+
.map((f) => R.excerpt(typeof f === "string" ? f : JSON.stringify(f), 300)),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One frame rendered as PNG. */
|
|
180
|
+
export async function figmaFrame(fileKey, nodeId, env = process.env) {
|
|
181
|
+
if (!FIGMA_KEY.test(String(fileKey)) || !FIGMA_NODE.test(String(nodeId))) {
|
|
182
|
+
throw new ServiceError("bad-input", "a Figma file key and a node id (1:2) are required");
|
|
183
|
+
}
|
|
184
|
+
const out = mkdtempSync(join(tmpdir(), "ma-figma-"));
|
|
185
|
+
try {
|
|
186
|
+
const file = script(env, "figma-screenshot.sh", "MA_FIGMA_SCREENSHOT");
|
|
187
|
+
if (!file) throw new ServiceError("not-installed", "the service client is not installed");
|
|
188
|
+
const r = await spawnAsync(
|
|
189
|
+
"bash",
|
|
190
|
+
[file, "--file-key", fileKey, "--node-id", nodeId.replace("-", ":"), "--output-dir", out],
|
|
191
|
+
{ env, timeoutMs: TIMEOUT_MS, maxBytes: 1024 * 1024 },
|
|
192
|
+
);
|
|
193
|
+
const why = {
|
|
194
|
+
2: "the Figma token is missing or was refused",
|
|
195
|
+
4: "no such frame",
|
|
196
|
+
5: "Figma is rate limiting; try again later",
|
|
197
|
+
6: "Figma could not be reached",
|
|
198
|
+
};
|
|
199
|
+
if (r.status !== 0)
|
|
200
|
+
throw new ServiceError("service-failed", why[r.status] ?? "the frame could not be rendered");
|
|
201
|
+
const png = readdirSync(out).find((f) => f.endsWith(".png"));
|
|
202
|
+
if (!png || statSync(join(out, png)).size > MAX_IMAGE_BYTES) {
|
|
203
|
+
throw new ServiceError("service-failed", "the frame could not be rendered");
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
contractVersion: CONTRACT_VERSION,
|
|
207
|
+
service: "figma",
|
|
208
|
+
fileKey,
|
|
209
|
+
nodeId: nodeId.replace("-", ":"),
|
|
210
|
+
mime: "image/png",
|
|
211
|
+
data: readFileSync(join(out, png)).toString("base64"),
|
|
212
|
+
};
|
|
213
|
+
} finally {
|
|
214
|
+
rmSync(out, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function main(argv) {
|
|
219
|
+
const [svc, a, b, ...rest] = argv;
|
|
220
|
+
try {
|
|
221
|
+
let out;
|
|
222
|
+
if (svc === "confluence" && a && !b) out = await confluencePage(a);
|
|
223
|
+
else if (svc === "crashlytics" && a && !b) out = await crashlyticsIssue(a);
|
|
224
|
+
else if (svc === "figma" && a && b && !rest.length) out = await figmaFrame(a, b);
|
|
225
|
+
else {
|
|
226
|
+
process.stderr.write(
|
|
227
|
+
"usage: service-read.mjs confluence <page> | crashlytics <issue-url> | figma <file-key> <node-id>\n",
|
|
228
|
+
);
|
|
229
|
+
return 2;
|
|
230
|
+
}
|
|
231
|
+
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|
|
232
|
+
return 0;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
if (!(err instanceof ServiceError)) throw err;
|
|
235
|
+
process.stderr.write(`service-read: ${err.message}\n`);
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (invokedDirectly(import.meta.url)) {
|
|
241
|
+
runMain("service-read", async () => {
|
|
242
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
243
|
+
});
|
|
244
|
+
}
|