@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +14 -12
  7. package/docs/features.md +9 -1
  8. package/install/catalog-history.json +1 -1
  9. package/install/claude.mjs +41 -6
  10. package/install/codex.mjs +3 -3
  11. package/manifest.json +62 -49
  12. package/package.json +1 -1
  13. package/pipeline/commands/multi-agent/bug-bash/SKILL.md +154 -0
  14. package/pipeline/commands/multi-agent/review/SKILL.md +13 -9
  15. package/pipeline/commands/multi-agent/setup/SKILL.md +8 -6
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +4 -3
  17. package/pipeline/commands/multi-agent/update/SKILL.md +5 -1
  18. package/pipeline/commands/sim-test.md +71 -6
  19. package/pipeline/lib/plugin-skill-roots.mjs +80 -0
  20. package/pipeline/lib/usage-lifecycle.mjs +83 -0
  21. package/pipeline/multi-agent-refs/cross-cli-contract.md +20 -4
  22. package/pipeline/multi-agent-refs/features/assertion-judge.md +76 -0
  23. package/pipeline/multi-agent-refs/features/exploratory-testing.md +140 -0
  24. package/pipeline/multi-agent-refs/features/skill-conformance.md +1 -1
  25. package/pipeline/multi-agent-refs/features/usage-reporting.md +59 -5
  26. package/pipeline/multi-agent-refs/features/valven.md +30 -3
  27. package/pipeline/multi-agent-refs/help/en.md +2 -0
  28. package/pipeline/multi-agent-refs/help/tr.md +2 -0
  29. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  30. package/pipeline/multi-agent-refs/phases/phase-4-commit.md +2 -2
  31. package/pipeline/schemas/bug-bash-verification.schema.json +41 -0
  32. package/pipeline/schemas/explore-run.schema.json +108 -0
  33. package/pipeline/schemas/prefs.schema.json +1 -1
  34. package/pipeline/scripts/_stack-routing.mjs +2 -0
  35. package/pipeline/scripts/bug-bash.mjs +379 -0
  36. package/pipeline/scripts/explore-findings.mjs +410 -0
  37. package/pipeline/scripts/gen-skills-index.mjs +1 -2
  38. package/pipeline/scripts/pr-timeline.mjs +135 -0
  39. package/pipeline/scripts/pre-commit-check.sh +4 -2
  40. package/pipeline/scripts/skill-conformance.mjs +17 -71
  41. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  42. package/pipeline/scripts/uninstall.mjs +29 -8
  43. package/pipeline/scripts/usage-identity.mjs +36 -10
  44. package/pipeline/scripts/usage-register.mjs +95 -8
  45. package/pipeline/scripts/usage-report.mjs +52 -2
  46. package/pipeline/scripts/valven-gate.mjs +292 -11
  47. package/pipeline/skills/.skill-manifest.json +18 -10
  48. package/pipeline/skills/.skills-index.json +28 -6
  49. package/pipeline/skills/shared/README.md +6 -4
  50. package/pipeline/skills/shared/core/multi-agent-bug-bash/SKILL.md +150 -0
  51. package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +1 -1
  52. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +5 -1
  53. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +4 -3
  54. package/pipeline/skills/shared/core/multi-agent-update/SKILL.md +4 -1
  55. package/pipeline/skills/shared/external/e2e-testing/SKILL.md +124 -0
  56. package/pipeline/skills/shared/external/e2e-testing/references/document-fixtures.md +65 -0
  57. package/pipeline/skills/shared/external/valven-rules/SKILL.md +93 -0
  58. package/pipeline/skills/shared/{core → external}/valven-rules/references/rules.yml +52 -6
  59. package/pipeline/skills/skills-index.md +4 -2
  60. 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);
@@ -31,8 +31,7 @@ function categorize(name) {
31
31
  if (
32
32
  name.startsWith("multi-agent") ||
33
33
  name === "apple-archive-compliance" ||
34
- name === "google-play-compliance" ||
35
- name === "valven-rules"
34
+ name === "google-play-compliance"
36
35
  )
37
36
  return "pipeline";
38
37
  if (
@@ -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
- if echo "$file" | grep -qE '\.env($|\.)'; then
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. That exact
60
- // bug already shipped once here (see match-skills.mjs header): dynamic skill
61
- // loading exited 1 on every real install while passing a smoke that ran from
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
- return [
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
- // Marketplace plugin roots.
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)}_tkgithub"
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)