@mmerterden/multi-agent-pipeline 20.16.0 → 20.17.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 +30 -0
- package/README.md +6 -5
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +9 -1
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +41 -6
- package/install/codex.mjs +3 -3
- package/manifest.json +62 -49
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
- package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
- package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
- package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
- package/pipeline/commands/sim-test.md +71 -6
- package/pipeline/lib/plugin-skill-roots.mjs +80 -0
- package/pipeline/lib/usage-lifecycle.mjs +83 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
- package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
- package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
- package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
- package/pipeline/multi-agent-refs/features/usage-reporting.md +59 -5
- package/pipeline/multi-agent-refs/features/valven.md +30 -3
- package/pipeline/multi-agent-refs/help/en.md +2 -0
- package/pipeline/multi-agent-refs/help/tr.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
- package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
- package/pipeline/schemas/explore-run.schema.json +108 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/_stack-routing.mjs +2 -0
- package/pipeline/scripts/bug-bash.mjs +379 -0
- package/pipeline/scripts/explore-findings.mjs +410 -0
- package/pipeline/scripts/gen-skills-index.mjs +1 -2
- package/pipeline/scripts/pr-timeline.mjs +135 -0
- package/pipeline/scripts/pre-commit-check.sh +4 -2
- package/pipeline/scripts/skill-conformance.mjs +17 -71
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/uninstall.mjs +29 -8
- package/pipeline/scripts/usage-identity.mjs +36 -10
- package/pipeline/scripts/usage-register.mjs +95 -8
- package/pipeline/scripts/usage-report.mjs +52 -2
- package/pipeline/scripts/valven-gate.mjs +292 -11
- package/pipeline/skills/.skill-manifest.json +18 -10
- package/pipeline/skills/.skills-index.json +28 -6
- package/pipeline/skills/shared/README.md +6 -4
- package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
- package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
- package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
- package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
- package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
- package/pipeline/skills/skills-index.md +4 -2
- package/pipeline/skills/shared/core/valven-rules/SKILL.md +0 -62
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* explore-findings.mjs - the findings contract, budgets and stop rules of an
|
|
4
|
+
* exploratory test run, and the packet a fresh judge decides an assertion from.
|
|
5
|
+
*
|
|
6
|
+
* The model drives the app and decides what looks wrong; this script holds the
|
|
7
|
+
* parts that must not be judgement:
|
|
8
|
+
*
|
|
9
|
+
* check --run FILE
|
|
10
|
+
* Whether the run must stop now: the step, time or token budget is
|
|
11
|
+
* spent, or the last three steps were blocked and reported nothing.
|
|
12
|
+
* Prints `{stop, reason, consecutiveBlocked}`.
|
|
13
|
+
*
|
|
14
|
+
* report --run FILE [--json]
|
|
15
|
+
* The verdict (passed | failed | blocked), why the run ended, issues
|
|
16
|
+
* before warnings with the most severe first, blocked steps grouped by
|
|
17
|
+
* cause, and the findings that failed the contract. Markdown, or the
|
|
18
|
+
* evaluation as JSON with --json.
|
|
19
|
+
*
|
|
20
|
+
* judge-packet --assertion TEXT --evidence KIND=PATH ... [--vocabulary TEXT]
|
|
21
|
+
* The only input a judge subagent receives: the statement, the current
|
|
22
|
+
* evidence, and optionally the app's own vocabulary. Run history, step
|
|
23
|
+
* summaries and the acting agent's instructions are refused.
|
|
24
|
+
*
|
|
25
|
+
* judge-verdict --packet FILE --verdict FILE
|
|
26
|
+
* Holds the judge's answer to the packet: passed and failed must cite
|
|
27
|
+
* evidence that was in the packet; anything else, and `inconclusive`,
|
|
28
|
+
* fails as ASSERTION_INCONCLUSIVE.
|
|
29
|
+
*
|
|
30
|
+
* The run file is described by pipeline/schemas/explore-run.schema.json.
|
|
31
|
+
* Exit: 0 printed, 2 usage or unreadable input.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
35
|
+
import { pathToFileURL } from "node:url";
|
|
36
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
37
|
+
|
|
38
|
+
export const BUDGET_DEFAULTS = Object.freeze({ steps: 8, minutes: 10, tokens: 150000 });
|
|
39
|
+
export const BUDGET_LIMITS = Object.freeze({
|
|
40
|
+
steps: [1, 12],
|
|
41
|
+
minutes: [3, 15],
|
|
42
|
+
tokens: [20000, 400000],
|
|
43
|
+
});
|
|
44
|
+
export const STUCK_AFTER = 3;
|
|
45
|
+
|
|
46
|
+
export const SEVERITY_WORDS = { 5: "critical", 4: "high", 3: "medium", 2: "low", 1: "trivial" };
|
|
47
|
+
|
|
48
|
+
const BLOCKED_CAUSE = {
|
|
49
|
+
CREDENTIAL_MISSING: "credentials",
|
|
50
|
+
ENVIRONMENT_UNAVAILABLE: "environment",
|
|
51
|
+
SEED_DATA_MISSING: "seed-data",
|
|
52
|
+
LOCATOR_NOT_FOUND: "automation",
|
|
53
|
+
LOCATOR_AMBIGUOUS: "automation",
|
|
54
|
+
REPLAY_DIVERGED: "automation",
|
|
55
|
+
};
|
|
56
|
+
export const BLOCKED_CAUSES = ["environment", "credentials", "seed-data", "automation", "unknown"];
|
|
57
|
+
|
|
58
|
+
const PRODUCT_FAILURE_CODES = new Set(["ASSERTION_FAILED", "ASSERTION_INCONCLUSIVE"]);
|
|
59
|
+
|
|
60
|
+
const EVIDENCE_KINDS = new Set(["screenshot", "ui-tree", "video", "trace", "log", "text"]);
|
|
61
|
+
const JUDGE_EVIDENCE_KINDS = new Set(["screenshot", "ui-tree", "text"]);
|
|
62
|
+
const JUDGE_KEYS = new Set(["assertion", "evidence", "vocabulary"]);
|
|
63
|
+
|
|
64
|
+
const CLEAR_SECRET =
|
|
65
|
+
/\b(password|passcode|passwd|pin|otp|token|secret|api[-_ ]?key)\s*[:=]\s*(?!secret_ref:)\S+/i;
|
|
66
|
+
|
|
67
|
+
const JUDGE_INSTRUCTIONS =
|
|
68
|
+
"Decide whether the assertion holds on the evidence below and nothing else. " +
|
|
69
|
+
"You have not seen the run that produced it and must not assume anything about it. " +
|
|
70
|
+
'Answer {"verdict": "passed" | "failed" | "inconclusive", "reason": "<one sentence>", ' +
|
|
71
|
+
'"cites": ["<evidence path>", ...]}. Cite only paths listed here. ' +
|
|
72
|
+
"When the evidence does not show enough to decide, answer inconclusive.";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Budget with defaults filled and each value clamped to its range.
|
|
76
|
+
* @param {{steps?:number, minutes?:number, tokens?:number}} [input]
|
|
77
|
+
* @returns {{steps:number, minutes:number, tokens:number, clamped:string[]}}
|
|
78
|
+
*/
|
|
79
|
+
export function resolveBudget(input = {}) {
|
|
80
|
+
const out = { clamped: [] };
|
|
81
|
+
for (const [key, def] of Object.entries(BUDGET_DEFAULTS)) {
|
|
82
|
+
const [lo, hi] = BUDGET_LIMITS[key];
|
|
83
|
+
const raw = Number.isFinite(input?.[key]) ? Number(input[key]) : def;
|
|
84
|
+
const v = Math.min(hi, Math.max(lo, raw));
|
|
85
|
+
if (v !== raw) out.clamped.push(key);
|
|
86
|
+
out[key] = v;
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a step's status and code mean for the verdict.
|
|
93
|
+
* @param {{status:string, code?:string}} step
|
|
94
|
+
* @returns {{outcome:"passed"|"product-failure"|"blocked"|"exhausted", blockedBy?:string}}
|
|
95
|
+
*/
|
|
96
|
+
export function classifyStep(step) {
|
|
97
|
+
const code = step?.code || "";
|
|
98
|
+
if (code === "BUDGET_EXCEEDED" || step?.status === "exhausted") return { outcome: "exhausted" };
|
|
99
|
+
if (step?.status === "passed") return { outcome: "passed" };
|
|
100
|
+
if (step?.status === "failed" && PRODUCT_FAILURE_CODES.has(code)) {
|
|
101
|
+
return { outcome: "product-failure" };
|
|
102
|
+
}
|
|
103
|
+
return { outcome: "blocked", blockedBy: BLOCKED_CAUSE[code] || "unknown" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Contract errors of one finding; empty when it is complete.
|
|
110
|
+
* @param {object} f
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
export function validateFinding(f) {
|
|
114
|
+
const errors = [];
|
|
115
|
+
if (!f || typeof f !== "object") return ["finding is not an object"];
|
|
116
|
+
if (!nonEmpty(f.title)) errors.push("title is required");
|
|
117
|
+
if (f.kind !== "issue" && f.kind !== "warning") errors.push("kind must be issue or warning");
|
|
118
|
+
if (!Number.isInteger(f.severity) || f.severity < 1 || f.severity > 5) {
|
|
119
|
+
errors.push("severity must be an integer from 1 (trivial) to 5 (critical)");
|
|
120
|
+
}
|
|
121
|
+
if (!nonEmpty(f.expected)) errors.push("expected is required");
|
|
122
|
+
if (!nonEmpty(f.observed)) errors.push("observed is required");
|
|
123
|
+
if (!Array.isArray(f.repro) || !f.repro.some(nonEmpty)) {
|
|
124
|
+
errors.push("repro needs at least one step");
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
!Array.isArray(f.evidence) ||
|
|
128
|
+
!f.evidence.length ||
|
|
129
|
+
!f.evidence.every((e) => EVIDENCE_KINDS.has(e?.kind) && nonEmpty(e?.path))
|
|
130
|
+
) {
|
|
131
|
+
errors.push(
|
|
132
|
+
`evidence needs at least one {kind, path}; kinds: ${[...EVIDENCE_KINDS].join(", ")}`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const texts = [f.title, f.expected, f.observed, ...(Array.isArray(f.repro) ? f.repro : [])];
|
|
136
|
+
if (texts.some((t) => typeof t === "string" && CLEAR_SECRET.test(t))) {
|
|
137
|
+
errors.push("a secret value is written in clear text; refer to it as secret_ref:<name>");
|
|
138
|
+
}
|
|
139
|
+
return errors;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function findingSteps(run) {
|
|
143
|
+
return new Set((run.findings || []).map((f) => f?.step).filter(Number.isInteger));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function blockedStreak(run) {
|
|
147
|
+
const reported = findingSteps(run);
|
|
148
|
+
let streak = 0;
|
|
149
|
+
for (const step of [...(run.steps || [])].reverse()) {
|
|
150
|
+
if (classifyStep(step).outcome !== "blocked" || reported.has(step.index)) break;
|
|
151
|
+
streak++;
|
|
152
|
+
}
|
|
153
|
+
return streak;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether the run must stop now.
|
|
158
|
+
* @param {object} run
|
|
159
|
+
* @returns {{stop:boolean, reason:null|"step-limit"|"time"|"tokens"|"stuck", consecutiveBlocked:number}}
|
|
160
|
+
*/
|
|
161
|
+
export function checkRun(run) {
|
|
162
|
+
const budget = resolveBudget(run?.budget);
|
|
163
|
+
const usage = run?.usage || {};
|
|
164
|
+
const consecutiveBlocked = blockedStreak(run || {});
|
|
165
|
+
let reason = null;
|
|
166
|
+
if ((usage.steps ?? (run?.steps || []).length) >= budget.steps) reason = "step-limit";
|
|
167
|
+
else if ((usage.minutes ?? 0) >= budget.minutes) reason = "time";
|
|
168
|
+
else if ((usage.tokens ?? 0) >= budget.tokens) reason = "tokens";
|
|
169
|
+
else if (consecutiveBlocked >= STUCK_AFTER) reason = "stuck";
|
|
170
|
+
return { stop: reason !== null, reason, consecutiveBlocked };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const bySeverity = (a, b) => b.severity - a.severity || a.index - b.index;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The run's verdict and everything the report lists.
|
|
177
|
+
* @param {object} run
|
|
178
|
+
*/
|
|
179
|
+
export function evaluateRun(run) {
|
|
180
|
+
const steps = run?.steps || [];
|
|
181
|
+
const issues = [];
|
|
182
|
+
const warnings = [];
|
|
183
|
+
const invalid = [];
|
|
184
|
+
(run?.findings || []).forEach((f, index) => {
|
|
185
|
+
const errors = validateFinding(f);
|
|
186
|
+
if (errors.length) invalid.push({ index, title: f?.title ?? null, errors });
|
|
187
|
+
else (f.kind === "issue" ? issues : warnings).push({ ...f, index });
|
|
188
|
+
});
|
|
189
|
+
issues.sort(bySeverity);
|
|
190
|
+
warnings.sort(bySeverity);
|
|
191
|
+
|
|
192
|
+
const blocked = Object.fromEntries(BLOCKED_CAUSES.map((c) => [c, []]));
|
|
193
|
+
const productFailures = [];
|
|
194
|
+
let exhausted = 0;
|
|
195
|
+
for (const step of steps) {
|
|
196
|
+
const c = classifyStep(step);
|
|
197
|
+
if (c.outcome === "blocked") blocked[c.blockedBy].push(step);
|
|
198
|
+
else if (c.outcome === "product-failure") productFailures.push(step);
|
|
199
|
+
else if (c.outcome === "exhausted") exhausted++;
|
|
200
|
+
}
|
|
201
|
+
const blockedCount = Object.values(blocked).reduce((n, l) => n + l.length, 0);
|
|
202
|
+
|
|
203
|
+
let verdict;
|
|
204
|
+
if (issues.length) verdict = "failed";
|
|
205
|
+
else if (!steps.length || blockedCount === steps.length) verdict = "blocked";
|
|
206
|
+
else verdict = "passed";
|
|
207
|
+
|
|
208
|
+
const stop = checkRun(run || {});
|
|
209
|
+
return {
|
|
210
|
+
verdict,
|
|
211
|
+
ended: run?.ended || stop.reason || "finished",
|
|
212
|
+
budget: resolveBudget(run?.budget),
|
|
213
|
+
usage: run?.usage || {},
|
|
214
|
+
issues,
|
|
215
|
+
warnings,
|
|
216
|
+
invalid,
|
|
217
|
+
blocked,
|
|
218
|
+
productFailures,
|
|
219
|
+
counts: {
|
|
220
|
+
steps: steps.length,
|
|
221
|
+
blocked: blockedCount,
|
|
222
|
+
exhausted,
|
|
223
|
+
issues: issues.length,
|
|
224
|
+
warnings: warnings.length,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findingBlock(f, n) {
|
|
230
|
+
const ev = f.evidence.map((e) => `${e.kind}=${e.path}`).join(" ");
|
|
231
|
+
return [
|
|
232
|
+
`### ${n}. [${SEVERITY_WORDS[f.severity]} ${f.kind}] ${f.title}`,
|
|
233
|
+
"",
|
|
234
|
+
`- **Screen**: ${f.screen || "-"}`,
|
|
235
|
+
`- **Expected**: ${f.expected}`,
|
|
236
|
+
`- **Observed**: ${f.observed}`,
|
|
237
|
+
`- **Repro**: ${f.repro.map((s, i) => `${i + 1}. ${s}`).join(" ")}`,
|
|
238
|
+
`- **Evidence**: ${ev}`,
|
|
239
|
+
...(f.spec ? [`- **Spec**: ${f.spec}`] : []),
|
|
240
|
+
"",
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Markdown report: verdict, issues, warnings, blocked by cause, invalid findings.
|
|
246
|
+
* @param {ReturnType<typeof evaluateRun>} e
|
|
247
|
+
* @param {object} run
|
|
248
|
+
*/
|
|
249
|
+
export function renderReport(e, run) {
|
|
250
|
+
const u = e.usage;
|
|
251
|
+
const out = [
|
|
252
|
+
`# Exploratory run: ${run?.goal || "(no goal)"}`,
|
|
253
|
+
"",
|
|
254
|
+
`Verdict: ${e.verdict} · ended: ${e.ended} · platform: ${run?.platform || "-"}`,
|
|
255
|
+
`Budget: ${u.steps ?? e.counts.steps}/${e.budget.steps} steps · ${u.minutes ?? "?"}/${e.budget.minutes} min · ${u.tokens ?? "?"}/${e.budget.tokens} tokens`,
|
|
256
|
+
"",
|
|
257
|
+
`## Issues (${e.issues.length})`,
|
|
258
|
+
"",
|
|
259
|
+
];
|
|
260
|
+
e.issues.forEach((f, i) => out.push(findingBlock(f, i + 1)));
|
|
261
|
+
if (!e.issues.length) out.push("None.", "");
|
|
262
|
+
out.push(`## Warnings (${e.warnings.length})`, "");
|
|
263
|
+
e.warnings.forEach((f, i) => out.push(findingBlock(f, i + 1)));
|
|
264
|
+
if (!e.warnings.length) out.push("None.", "");
|
|
265
|
+
out.push(`## Blocked (${e.counts.blocked}) - not product failures`, "");
|
|
266
|
+
let any = false;
|
|
267
|
+
for (const cause of BLOCKED_CAUSES) {
|
|
268
|
+
for (const s of e.blocked[cause]) {
|
|
269
|
+
any = true;
|
|
270
|
+
out.push(
|
|
271
|
+
`- ${cause}: step ${s.index}${s.title ? ` "${s.title}"` : ""} ${s.code || ""}${s.detail ? ` - ${s.detail}` : ""}`.trimEnd(),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (!any) out.push("None.");
|
|
276
|
+
out.push("");
|
|
277
|
+
if (e.invalid.length) {
|
|
278
|
+
out.push(`## Findings that failed the contract (${e.invalid.length})`, "");
|
|
279
|
+
for (const v of e.invalid)
|
|
280
|
+
out.push(`- #${v.index} ${v.title ?? "(untitled)"}: ${v.errors.join("; ")}`);
|
|
281
|
+
out.push("");
|
|
282
|
+
}
|
|
283
|
+
return out.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The judge's whole input. Anything describing the run is refused.
|
|
288
|
+
* @param {{assertion:string, evidence:{kind:string,path:string}[], vocabulary?:string}} input
|
|
289
|
+
*/
|
|
290
|
+
export function judgePacket(input) {
|
|
291
|
+
const extra = Object.keys(input || {}).filter((k) => !JUDGE_KEYS.has(k));
|
|
292
|
+
if (extra.length) {
|
|
293
|
+
throw new Error(
|
|
294
|
+
`a judge sees the assertion and the current evidence only; refused: ${extra.join(", ")}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (!nonEmpty(input?.assertion)) throw new Error("assertion is required");
|
|
298
|
+
const evidence = Array.isArray(input.evidence) ? input.evidence : [];
|
|
299
|
+
if (!evidence.length) throw new Error("a judgement needs evidence: a screenshot or a UI tree");
|
|
300
|
+
for (const e of evidence) {
|
|
301
|
+
if (!JUDGE_EVIDENCE_KINDS.has(e?.kind) || !nonEmpty(e?.path)) {
|
|
302
|
+
throw new Error(
|
|
303
|
+
`evidence must be {kind, path} with kind ${[...JUDGE_EVIDENCE_KINDS].join(" | ")}`,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const packet = {
|
|
308
|
+
instructions: JUDGE_INSTRUCTIONS,
|
|
309
|
+
assertion: input.assertion.trim(),
|
|
310
|
+
evidence: evidence.map((e) => ({ kind: e.kind, path: e.path })),
|
|
311
|
+
};
|
|
312
|
+
if (nonEmpty(input.vocabulary)) packet.vocabulary = input.vocabulary.trim();
|
|
313
|
+
return packet;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The judge's answer held to its packet.
|
|
318
|
+
* @returns {{verdict:"passed"|"failed", code:null|"ASSERTION_FAILED"|"ASSERTION_INCONCLUSIVE", errors:string[]}}
|
|
319
|
+
*/
|
|
320
|
+
export function checkJudgeVerdict(packet, answer) {
|
|
321
|
+
const errors = [];
|
|
322
|
+
const given = new Set((packet?.evidence || []).map((e) => e.path));
|
|
323
|
+
const cites = Array.isArray(answer?.cites) ? answer.cites : [];
|
|
324
|
+
if (!["passed", "failed", "inconclusive"].includes(answer?.verdict)) {
|
|
325
|
+
errors.push("verdict must be passed, failed or inconclusive");
|
|
326
|
+
}
|
|
327
|
+
if (!nonEmpty(answer?.reason)) errors.push("reason is required");
|
|
328
|
+
if (answer?.verdict !== "inconclusive") {
|
|
329
|
+
if (!cites.length) errors.push("a decided verdict cites at least one evidence path");
|
|
330
|
+
const foreign = cites.filter((c) => !given.has(c));
|
|
331
|
+
if (foreign.length)
|
|
332
|
+
errors.push(`cites evidence the judge was not given: ${foreign.join(", ")}`);
|
|
333
|
+
}
|
|
334
|
+
if (errors.length || answer.verdict === "inconclusive") {
|
|
335
|
+
return { verdict: "failed", code: "ASSERTION_INCONCLUSIVE", errors };
|
|
336
|
+
}
|
|
337
|
+
return answer.verdict === "passed"
|
|
338
|
+
? { verdict: "passed", code: null, errors }
|
|
339
|
+
: { verdict: "failed", code: "ASSERTION_FAILED", errors };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function arg(argv, flag) {
|
|
343
|
+
const i = argv.indexOf(flag);
|
|
344
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function args(argv, flag) {
|
|
348
|
+
const out = [];
|
|
349
|
+
argv.forEach((a, i) => {
|
|
350
|
+
if (a === flag && argv[i + 1] !== undefined) out.push(argv[i + 1]);
|
|
351
|
+
});
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
|
|
356
|
+
|
|
357
|
+
function main() {
|
|
358
|
+
const argv = process.argv.slice(2);
|
|
359
|
+
const mode = argv[0];
|
|
360
|
+
const usage = () => {
|
|
361
|
+
process.stderr.write(
|
|
362
|
+
"usage: explore-findings.mjs check --run FILE\n" +
|
|
363
|
+
" explore-findings.mjs report --run FILE [--json]\n" +
|
|
364
|
+
" explore-findings.mjs judge-packet --assertion TEXT --evidence KIND=PATH ... [--vocabulary TEXT]\n" +
|
|
365
|
+
" explore-findings.mjs judge-verdict --packet FILE --verdict FILE\n",
|
|
366
|
+
);
|
|
367
|
+
process.exitCode = 2;
|
|
368
|
+
};
|
|
369
|
+
try {
|
|
370
|
+
if (mode === "check" && arg(argv, "--run")) {
|
|
371
|
+
process.stdout.write(`${JSON.stringify(checkRun(readJson(arg(argv, "--run"))))}\n`);
|
|
372
|
+
} else if (mode === "report" && arg(argv, "--run")) {
|
|
373
|
+
const run = readJson(arg(argv, "--run"));
|
|
374
|
+
const e = evaluateRun(run);
|
|
375
|
+
process.stdout.write(
|
|
376
|
+
argv.includes("--json") ? `${JSON.stringify(e, null, 2)}\n` : `${renderReport(e, run)}\n`,
|
|
377
|
+
);
|
|
378
|
+
} else if (mode === "judge-packet" && arg(argv, "--assertion")) {
|
|
379
|
+
const evidence = args(argv, "--evidence").map((pair) => {
|
|
380
|
+
const at = pair.indexOf("=");
|
|
381
|
+
return at > 0 ? { kind: pair.slice(0, at), path: pair.slice(at + 1) } : { path: pair };
|
|
382
|
+
});
|
|
383
|
+
const input = { assertion: arg(argv, "--assertion"), evidence };
|
|
384
|
+
if (arg(argv, "--vocabulary")) input.vocabulary = arg(argv, "--vocabulary");
|
|
385
|
+
process.stdout.write(`${JSON.stringify(judgePacket(input), null, 2)}\n`);
|
|
386
|
+
} else if (mode === "judge-verdict" && arg(argv, "--packet") && arg(argv, "--verdict")) {
|
|
387
|
+
const result = checkJudgeVerdict(
|
|
388
|
+
readJson(arg(argv, "--packet")),
|
|
389
|
+
readJson(arg(argv, "--verdict")),
|
|
390
|
+
);
|
|
391
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
392
|
+
} else {
|
|
393
|
+
usage();
|
|
394
|
+
}
|
|
395
|
+
} catch (err) {
|
|
396
|
+
process.stderr.write(`explore-findings: ${err.message}\n`);
|
|
397
|
+
process.exitCode = 2;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function invokedDirectly() {
|
|
402
|
+
if (!process.argv[1]) return false;
|
|
403
|
+
try {
|
|
404
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
405
|
+
} catch {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (invokedDirectly()) runMain("explore-findings", main);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file pr-timeline.mjs - a GitHub pull request's timeline for
|
|
5
|
+
* `valven-gate.mjs --pr-meta-file`, plus the fields /multi-agent:review reads
|
|
6
|
+
* from the same file (head and base branch, body, commits).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* pr-timeline.mjs --repo <owner/name> --pr <number> [--out <file>]
|
|
10
|
+
*
|
|
11
|
+
* `gh pr view --json` carries no ready-for-review time and names a review's
|
|
12
|
+
* author by login only, which leaves a bot such as Copilot's reviewer looking
|
|
13
|
+
* like a person. A GraphQL query fills both: `readyAt` from the last
|
|
14
|
+
* READY_FOR_REVIEW_EVENT, and the reviews again with each author's type, so the
|
|
15
|
+
* gate times pickup from ready and leaves bot reviews out. When that query fails
|
|
16
|
+
* the gh pr view output is written as it is and a warning names what is lost.
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* 0 - written
|
|
20
|
+
* 2 - bad arguments, or gh pr view failed
|
|
21
|
+
*
|
|
22
|
+
* @module pipeline/scripts/pr-timeline
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { writeFileSync } from "node:fs";
|
|
27
|
+
import { runMain } from "../lib/fatal.mjs";
|
|
28
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
29
|
+
|
|
30
|
+
const VIEW_FIELDS = [
|
|
31
|
+
"headRefName",
|
|
32
|
+
"baseRefName",
|
|
33
|
+
"body",
|
|
34
|
+
"commits",
|
|
35
|
+
"author",
|
|
36
|
+
"closedAt",
|
|
37
|
+
"createdAt",
|
|
38
|
+
"isDraft",
|
|
39
|
+
"mergedAt",
|
|
40
|
+
"reviewRequests",
|
|
41
|
+
"reviews",
|
|
42
|
+
].join(",");
|
|
43
|
+
|
|
44
|
+
const QUERY = `query($owner:String!,$name:String!,$n:Int!){repository(owner:$owner,name:$name){pullRequest(number:$n){
|
|
45
|
+
timelineItems(last:1,itemTypes:[READY_FOR_REVIEW_EVENT]){nodes{... on ReadyForReviewEvent{createdAt}}}
|
|
46
|
+
reviews(first:100){nodes{author{__typename login} state submittedAt}}}}}`;
|
|
47
|
+
|
|
48
|
+
function gh(args) {
|
|
49
|
+
return execFileSync("gh", args, {
|
|
50
|
+
encoding: "utf-8",
|
|
51
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
52
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Merge the GraphQL timeline into the gh pr view object. Reviews take the
|
|
58
|
+
* GraphQL shape the gate reads (`author.is_bot`), so a bot is recognised.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} view gh pr view JSON
|
|
61
|
+
* @param {object} pullRequest data.repository.pullRequest
|
|
62
|
+
*/
|
|
63
|
+
export function mergeTimeline(view, pullRequest) {
|
|
64
|
+
const readyAt = pullRequest?.timelineItems?.nodes?.[0]?.createdAt ?? null;
|
|
65
|
+
const reviews = (pullRequest?.reviews?.nodes ?? []).map((n) => ({
|
|
66
|
+
author: { login: n?.author?.login ?? null, is_bot: n?.author?.__typename === "Bot" },
|
|
67
|
+
state: n?.state ?? null,
|
|
68
|
+
submittedAt: n?.submittedAt ?? null,
|
|
69
|
+
}));
|
|
70
|
+
return { ...view, readyAt, reviews };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseArgs(argv) {
|
|
74
|
+
const args = {};
|
|
75
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
76
|
+
const key = argv[i];
|
|
77
|
+
const val = argv[i + 1];
|
|
78
|
+
if (!key?.startsWith("--") || val === undefined || val.startsWith("--")) {
|
|
79
|
+
throw new Error(`${key ?? "argument"} needs a value`);
|
|
80
|
+
}
|
|
81
|
+
args[key.slice(2)] = val;
|
|
82
|
+
}
|
|
83
|
+
return args;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fail(msg) {
|
|
87
|
+
process.stderr.write(`pr-timeline: ${msg}\n`);
|
|
88
|
+
process.exitCode = 2;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
let args;
|
|
93
|
+
try {
|
|
94
|
+
args = parseArgs(process.argv.slice(2));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return fail(err.message);
|
|
97
|
+
}
|
|
98
|
+
const [owner, name, extra] = String(args.repo ?? "").split("/");
|
|
99
|
+
if (!owner || !name || extra !== undefined) return fail("--repo must be <owner/name>");
|
|
100
|
+
if (!/^\d+$/.test(String(args.pr ?? ""))) return fail("--pr must be a pull request number");
|
|
101
|
+
|
|
102
|
+
let view;
|
|
103
|
+
try {
|
|
104
|
+
view = JSON.parse(gh(["pr", "view", args.pr, "--repo", args.repo, "--json", VIEW_FIELDS]));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
return fail(`gh pr view failed: ${String(err.stderr || err.message).trim()}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let merged = view;
|
|
110
|
+
try {
|
|
111
|
+
const out = gh([
|
|
112
|
+
"api",
|
|
113
|
+
"graphql",
|
|
114
|
+
"-F",
|
|
115
|
+
`owner=${owner}`,
|
|
116
|
+
"-F",
|
|
117
|
+
`name=${name}`,
|
|
118
|
+
"-F",
|
|
119
|
+
`n=${args.pr}`,
|
|
120
|
+
"-f",
|
|
121
|
+
`query=${QUERY}`,
|
|
122
|
+
]);
|
|
123
|
+
merged = mergeTimeline(view, JSON.parse(out)?.data?.repository?.pullRequest);
|
|
124
|
+
} catch (err) {
|
|
125
|
+
process.stderr.write(
|
|
126
|
+
`pr-timeline: timeline query failed (${String(err.stderr || err.message).trim()}); pickup counts from the opening and bot reviews are not recognised\n`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const json = `${JSON.stringify(merged, null, 2)}\n`;
|
|
131
|
+
if (args.out) writeFileSync(args.out, json);
|
|
132
|
+
else process.stdout.write(json);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (invokedDirectly(import.meta.url)) runMain("pr-timeline", main, { code: 2 });
|
|
@@ -166,8 +166,10 @@ is_binary() {
|
|
|
166
166
|
scan_file() {
|
|
167
167
|
local file="$1" content="$2" folded="${3:-}"
|
|
168
168
|
|
|
169
|
-
# .env files
|
|
170
|
-
|
|
169
|
+
# .env files. A template (.env.example/.sample/.template) documents names,
|
|
170
|
+
# not values, so it falls through to the content checks below instead.
|
|
171
|
+
if echo "$file" | grep -qE '\.env($|\.)' &&
|
|
172
|
+
! echo "$file" | grep -qE '\.env\.(example|sample|template)$'; then
|
|
171
173
|
echo "BLOCKED: .env file staged: $file" >&2
|
|
172
174
|
FOUND=1
|
|
173
175
|
return 0
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
45
45
|
import { dirname, join, resolve, relative, basename, extname, sep } from "node:path";
|
|
46
46
|
import { fileURLToPath } from "node:url";
|
|
47
|
-
import { homedir } from "node:os";
|
|
48
47
|
import { matchesAnyGlob } from "./glob-match.mjs";
|
|
48
|
+
import { pluginSkillRootCandidates } from "../lib/plugin-skill-roots.mjs";
|
|
49
49
|
|
|
50
50
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
51
|
|
|
@@ -56,83 +56,29 @@ const here = dirname(fileURLToPath(import.meta.url));
|
|
|
56
56
|
// checkout, and `~/.claude/scripts`, `~/.copilot/scripts`, `~/.codex/scripts`
|
|
57
57
|
// once installed. install/copilot.mjs copies `scripts/` byte-for-byte with no
|
|
58
58
|
// path rewrite, and Codex puts skills under `multi-agent-refs/skills`, so a
|
|
59
|
-
// hardcoded `~/.claude/skills` is inert on two of the three hosts.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
// the repo. Installed layouts are tried first - that is where a run happens.
|
|
59
|
+
// hardcoded `~/.claude/skills` is inert on two of the three hosts. The roots
|
|
60
|
+
// are derived from this script's own location, and installed layouts are tried
|
|
61
|
+
// before the checkout, because an installed tree is where a run happens.
|
|
63
62
|
// ---------------------------------------------------------------------------
|
|
64
63
|
function skillsRootCandidates() {
|
|
65
64
|
const installRoot = resolve(here, ".."); // ~/.claude | ~/.copilot | ~/.codex
|
|
66
65
|
const repoRoot = resolve(here, "..", ".."); // <repo>
|
|
67
|
-
|
|
66
|
+
// The first registry found under a name wins. On Claude Code the plugin roots
|
|
67
|
+
// come first: a registry that ships in a plugin is what the host loads, and a
|
|
68
|
+
// same-named copy an older install left in ~/.claude/skills must not shadow it.
|
|
69
|
+
// The gate registries the installer lays down follow, so a registry is still
|
|
70
|
+
// found while the user's plugin predates it. Copilot and Codex load no plugin;
|
|
71
|
+
// the copy their installer laid down is the current one and comes first.
|
|
72
|
+
const plugins = pluginSkillRootCandidates(); // checkout, then every cached version
|
|
73
|
+
const installed = [
|
|
68
74
|
join(installRoot, "skills"), // ~/.claude/skills, ~/.copilot/skills
|
|
69
75
|
join(installRoot, "multi-agent-refs", "skills"), // ~/.codex/multi-agent-refs/skills
|
|
70
|
-
...pluginSkillRoots(), // marketplace stack plugins (Claude Code's only stack-skill source)
|
|
71
|
-
join(repoRoot, "pipeline", "skills"), // checkout
|
|
72
76
|
];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
// On Claude Code the stack skills no longer land in ~/.claude/skills - they are
|
|
79
|
-
// loaded from the multi-agent-plugins marketplace. The registries this gate
|
|
80
|
-
// depends on (`ios-coding-standard`, `ios-module-structure`) live in the iOS
|
|
81
|
-
// plugin's knowledge/ tree, so without these roots the registry set silently
|
|
82
|
-
// collapses to the two local compliance catalogs and Phase 4 reads green over
|
|
83
|
-
// criteria that were never loaded. Local checkout is preferred over the plugin
|
|
84
|
-
// cache for the same reason install/_plugin-skills.mjs prefers it: the checkout
|
|
85
|
-
// is what /multi-agent:sync rebuilds, the cache only advances on marketplace
|
|
86
|
-
// refresh. Cache dirs are version-named; newest wins.
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
const PLUGIN_MARKETPLACE = "multi-agent-plugins";
|
|
89
|
-
const STACK_PLUGIN_NAMES = [
|
|
90
|
-
"ai-ios-toolkit",
|
|
91
|
-
"ai-android-toolkit",
|
|
92
|
-
"ai-frontend-toolkit",
|
|
93
|
-
"ai-backend-toolkit",
|
|
94
|
-
"ai-common-toolkit",
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
function semverCompare(a, b) {
|
|
98
|
-
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
|
99
|
-
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
100
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
101
|
-
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
102
|
-
if (d !== 0) return d;
|
|
103
|
-
}
|
|
104
|
-
return a.localeCompare(b);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function pluginSkillRoots() {
|
|
108
|
-
const home = homedir();
|
|
109
|
-
const roots = [];
|
|
110
|
-
for (const name of STACK_PLUGIN_NAMES) {
|
|
111
|
-
const checkout = join(home, PLUGIN_MARKETPLACE, "plugins", name, "skills");
|
|
112
|
-
if (existsSync(checkout)) {
|
|
113
|
-
roots.push(checkout);
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
const cacheRoot = join(home, ".claude", "plugins", "cache", PLUGIN_MARKETPLACE, name);
|
|
117
|
-
if (!existsSync(cacheRoot)) continue;
|
|
118
|
-
const versions = readdirSafe(cacheRoot)
|
|
119
|
-
.filter((v) => {
|
|
120
|
-
try {
|
|
121
|
-
return statSync(join(cacheRoot, v)).isDirectory();
|
|
122
|
-
} catch {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
})
|
|
126
|
-
.sort(semverCompare);
|
|
127
|
-
for (const v of versions.reverse()) {
|
|
128
|
-
const p = join(cacheRoot, v, "skills");
|
|
129
|
-
if (existsSync(p)) {
|
|
130
|
-
roots.push(p);
|
|
131
|
-
break;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return roots;
|
|
77
|
+
const registries = join(installRoot, "multi-agent-refs", "registries"); // ~/.claude
|
|
78
|
+
const checkout = join(repoRoot, "pipeline", "skills");
|
|
79
|
+
return basename(installRoot) === ".claude"
|
|
80
|
+
? [...plugins, registries, ...installed, checkout]
|
|
81
|
+
: [...installed, ...plugins, checkout];
|
|
136
82
|
}
|
|
137
83
|
|
|
138
84
|
/**
|
|
@@ -121,7 +121,7 @@ echo ""
|
|
|
121
121
|
echo "→ 3. No personal-data hardcodes in generic docs"
|
|
122
122
|
# These are patterns that must be placeholders, never literals
|
|
123
123
|
PERSONAL_HARDCODES=(
|
|
124
|
-
"${USER:-$(id -un)}
|
|
124
|
+
"${USER:-$(id -un)}_"
|
|
125
125
|
)
|
|
126
126
|
for p in "${PERSONAL_HARDCODES[@]}"; do
|
|
127
127
|
FOUND=$(find "$CLAUDE_CMDS" -name SKILL.md -exec grep -l -F -- "$p" {} + 2>/dev/null || true)
|