@mmerterden/multi-agent-pipeline 20.27.1 → 20.28.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 (72) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +6 -5
  3. package/README.tr.md +6 -5
  4. package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
  5. package/docs/architecture.md +4 -4
  6. package/docs/ecosystem.md +5 -5
  7. package/docs/facts.json +14 -12
  8. package/docs/features.md +2 -0
  9. package/install/_dev-only-files.mjs +1 -0
  10. package/install/catalog-history.json +1 -1
  11. package/install/index.mjs +0 -2
  12. package/manifest.json +74 -51
  13. package/package.json +4 -1
  14. package/pipeline/agents/code-reviewer.md +1 -0
  15. package/pipeline/agents/test-gen-analyst.md +98 -0
  16. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  17. package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
  18. package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
  19. package/pipeline/multi-agent-refs/features/doctor.md +13 -0
  20. package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
  21. package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
  22. package/pipeline/multi-agent-refs/help/en.md +1 -0
  23. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  24. package/pipeline/multi-agent-refs/phases/operations.md +2 -2
  25. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +6 -7
  26. package/pipeline/multi-agent-refs/phases/phase-3-review.md +3 -3
  27. package/pipeline/multi-agent-refs/phases/phase-5-report.md +6 -5
  28. package/pipeline/multi-agent-refs/store-ready/gate-3-policy.md +6 -2
  29. package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
  30. package/pipeline/preferences-template.json +3 -0
  31. package/pipeline/rules/app-store-guidelines.md +29 -41
  32. package/pipeline/schemas/prefs.schema.json +14 -0
  33. package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
  34. package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
  35. package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
  36. package/pipeline/schemas/test-gen-results.schema.json +44 -0
  37. package/pipeline/scripts/_handle-patterns.mjs +166 -0
  38. package/pipeline/scripts/_http-post.mjs +141 -0
  39. package/pipeline/scripts/_stack-routing.mjs +4 -0
  40. package/pipeline/scripts/_test-gaming.mjs +227 -0
  41. package/pipeline/scripts/_unified-diff.mjs +104 -0
  42. package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
  43. package/pipeline/scripts/doctor.mjs +51 -0
  44. package/pipeline/scripts/evidence-gate.mjs +74 -3
  45. package/pipeline/scripts/handle-diff-audit.mjs +188 -0
  46. package/pipeline/scripts/routine-registry.mjs +5 -1
  47. package/pipeline/scripts/run-ui-tests.sh +27 -3
  48. package/pipeline/scripts/test-classify.mjs +296 -0
  49. package/pipeline/scripts/test-gen-gate.mjs +409 -0
  50. package/pipeline/scripts/test-gen-state.mjs +313 -0
  51. package/pipeline/scripts/test-integrity-gate.mjs +76 -11
  52. package/pipeline/scripts/uninstall.mjs +1 -0
  53. package/pipeline/scripts/usage-register-beacon.mjs +239 -0
  54. package/pipeline/scripts/usage-register.mjs +128 -33
  55. package/pipeline/scripts/usage-report.mjs +43 -33
  56. package/pipeline/skills/.skill-manifest.json +25 -5
  57. package/pipeline/skills/.skills-index.json +57 -2
  58. package/pipeline/skills/shared/README.md +11 -6
  59. package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +5 -4
  60. package/pipeline/skills/shared/core/apple-archive-compliance/references/rules.yml +8 -8
  61. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  62. package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
  63. package/pipeline/skills/shared/external/app-store-review/SKILL.md +69 -8
  64. package/pipeline/skills/shared/external/app-store-review/evals/evals.json +12 -0
  65. package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +10 -1
  66. package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
  67. package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
  68. package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
  69. package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
  70. package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
  71. package/pipeline/skills/skills-index.md +7 -2
  72. package/install/_telemetry.mjs +0 -61
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file test-classify.mjs - per-test classifications to the verdict the gates read.
4
+ *
5
+ * A red test is a claim about the change only when the base was green and the
6
+ * test stayed red on a second run. The toolkit's `test_results_classify`
7
+ * (multi-agent-toolkit-mcp >= 3.25.0) measures that per test:
8
+ * passed | regression | preexisting | unstable | not_measured. This script
9
+ * takes its answer and applies the pipeline's own rules on top:
10
+ *
11
+ * - `unstable` is never green: a pass only on retry is a flake signal.
12
+ * - `not_measured` stays red, with its reason: no base to compare is not a
13
+ * clean base.
14
+ * - the Phase 0 test baseline (`state.baseline.tests`, status red with
15
+ * `failing[]`) joins the preexisting set. A test it lists that the toolkit
16
+ * could not measure becomes preexisting; a test it lists that the toolkit
17
+ * calls a regression (base measured green) becomes unstable, because the
18
+ * base disagreed with itself.
19
+ * - a tripped breaker (the same failure signature across most runs: an
20
+ * environment, not the tests) is not green.
21
+ * - nothing classified is not green.
22
+ *
23
+ * On a toolkit without the tool, `fallback` classifies from the repeat runs
24
+ * alone (JUnit XML): runs that disagree, or one run that saw a test both fail
25
+ * and pass (a retry, per-device reports), are `unstable`, red in every run is
26
+ * `not_measured` (there is no base run), green in every run is `passed`. The
27
+ * baseline join and the verdict are the same.
28
+ *
29
+ * Usage:
30
+ * node test-classify.mjs merge --classified <toolkit-output.json> [--state <agent-state.json>] [--out <path>]
31
+ * node test-classify.mjs fallback --run <junit.xml|dir> [--run ...] [--state <agent-state.json>] [--out <path>]
32
+ *
33
+ * Output: the verdict as JSON on stdout (and to --out):
34
+ * { schemaVersion, source, green, reason, tests: [{id, status, reason?}],
35
+ * summary: {passed, regression, preexisting, unstable, not_measured},
36
+ * blocking: [ids], inherited: [ids], breaker }
37
+ * Exit: 0 green, 1 not green, 2 usage, 3 input unreadable or malformed,
38
+ * 4 internal error (a crash, never a verdict)
39
+ *
40
+ * @module pipeline/scripts/test-classify
41
+ */
42
+
43
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
44
+ import { dirname, join } from "node:path";
45
+ import { pathToFileURL } from "node:url";
46
+
47
+ export const STATUSES = ["passed", "regression", "preexisting", "unstable", "not_measured"];
48
+ const BLOCKING = new Set(["regression", "unstable", "not_measured"]);
49
+
50
+ class InputError extends Error {}
51
+
52
+ function usage(msg) {
53
+ process.stderr.write(`test-classify: ${msg}\n`);
54
+ process.exit(2);
55
+ }
56
+
57
+ function parseArgs(argv) {
58
+ const out = { run: [] };
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i];
61
+ if (!a.startsWith("--")) continue;
62
+ const v = argv[i + 1];
63
+ if (v === undefined || v.startsWith("--")) usage(`${a} needs a value`);
64
+ if (a === "--run") out.run.push(v);
65
+ else out[a.slice(2)] = v;
66
+ i++;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ function readJson(path, what) {
72
+ let body;
73
+ try {
74
+ body = readFileSync(path, "utf8");
75
+ } catch (err) {
76
+ throw new InputError(`cannot read ${what} ${path}: ${err.message}`);
77
+ }
78
+ try {
79
+ return JSON.parse(body);
80
+ } catch (err) {
81
+ throw new InputError(`${what} ${path} is not JSON: ${err.message}`);
82
+ }
83
+ }
84
+
85
+ /** Test ids compared across runners: case, `()`, and `/` `::` `#` separators ignored. */
86
+ export function normalizeId(id) {
87
+ return String(id)
88
+ .trim()
89
+ .toLowerCase()
90
+ .replace(/\(\)$/, "")
91
+ .replace(/::|[/#]/g, ".");
92
+ }
93
+
94
+ /** The Phase 0 baseline's failing ids, or [] when it recorded none. */
95
+ function baselineFailing(state) {
96
+ const t = state?.baseline?.tests;
97
+ if (!t || t.status !== "red" || !Array.isArray(t.failing)) return { ids: [], logPath: null };
98
+ return { ids: t.failing.map(String), logPath: t.logPath || null };
99
+ }
100
+
101
+ /**
102
+ * Apply the pipeline's rules to classified tests.
103
+ *
104
+ * @param {{tests: {id: string, status: string, reason?: string}[], breaker?: object}} classified
105
+ * @param {object|null} state agent-state.json, for the Phase 0 baseline
106
+ * @param {"toolkit"|"fallback"} source
107
+ */
108
+ export function verdict(classified, state, source) {
109
+ if (!Array.isArray(classified?.tests)) throw new InputError("classification has no tests[]");
110
+ const base = baselineFailing(state);
111
+ const inBase = new Set(base.ids.map(normalizeId));
112
+ const where = base.logPath ? ` (${base.logPath})` : "";
113
+ const tests = classified.tests.map((t) => {
114
+ if (!t || typeof t.id !== "string" || !STATUSES.includes(t.status)) {
115
+ throw new InputError(`not a classified test: ${JSON.stringify(t)}`);
116
+ }
117
+ const listed = inBase.has(normalizeId(t.id));
118
+ if (listed && t.status === "not_measured") {
119
+ return {
120
+ ...t,
121
+ status: "preexisting",
122
+ reason: `listed failing in the Phase 0 baseline${where}`,
123
+ };
124
+ }
125
+ if (listed && t.status === "regression") {
126
+ return {
127
+ ...t,
128
+ status: "unstable",
129
+ reason: `the base measured green here, but the Phase 0 baseline lists it failing${where}: the base disagrees with itself`,
130
+ };
131
+ }
132
+ return { ...t };
133
+ });
134
+
135
+ const summary = Object.fromEntries(STATUSES.map((s) => [s, 0]));
136
+ for (const t of tests) summary[t.status]++;
137
+ const blocking = tests.filter((t) => BLOCKING.has(t.status)).map((t) => t.id);
138
+ const inherited = [
139
+ ...new Set([...tests.filter((t) => t.status === "preexisting").map((t) => t.id), ...base.ids]),
140
+ ];
141
+ const breaker = classified.breaker?.tripped ? classified.breaker : null;
142
+
143
+ let reason;
144
+ if (breaker) {
145
+ reason = `breaker tripped on "${breaker.signature}": ${breaker.reason || "one failure signature across most runs"}`;
146
+ } else if (tests.length === 0) {
147
+ reason = "no test was classified";
148
+ } else if (blocking.length) {
149
+ reason = `${blocking.length} test(s) not green: ${STATUSES.filter(
150
+ (s) => BLOCKING.has(s) && summary[s],
151
+ )
152
+ .map((s) => `${summary[s]} ${s}`)
153
+ .join(", ")}`;
154
+ } else {
155
+ reason = `${summary.passed} passed${summary.preexisting ? `, ${summary.preexisting} preexisting (inherited)` : ""}`;
156
+ }
157
+ return {
158
+ schemaVersion: "1.0.0",
159
+ source,
160
+ green: !breaker && tests.length > 0 && blocking.length === 0,
161
+ reason,
162
+ tests,
163
+ summary,
164
+ blocking,
165
+ inherited,
166
+ breaker,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Per-test outcomes in one JUnit document, every occurrence kept:
172
+ * Map<id, ("pass"|"fail"|"skip")[]>. A runner that retries a test, or writes
173
+ * one entry per device, lists the same id more than once.
174
+ */
175
+ export function junitOutcomes(xml) {
176
+ if (!/<testsuites?\b|<testcase\b/.test(xml)) throw new InputError("not JUnit XML");
177
+ const out = new Map();
178
+ const re = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
179
+ for (const m of xml.matchAll(re)) {
180
+ const attr = (name) => {
181
+ const a = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(m[1]);
182
+ return a ? (a[1] ?? a[2]) : undefined;
183
+ };
184
+ const name = attr("name");
185
+ if (!name) continue;
186
+ const cls = attr("classname");
187
+ const body = m[3] || "";
188
+ const outcome = /<(failure|error)\b/.test(body)
189
+ ? "fail"
190
+ : /<skipped\b/.test(body)
191
+ ? "skip"
192
+ : "pass";
193
+ const id = cls ? `${cls}.${name}` : name;
194
+ if (!out.has(id)) out.set(id, []);
195
+ out.get(id).push(outcome);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ function junitFiles(path) {
201
+ let st;
202
+ try {
203
+ st = statSync(path);
204
+ } catch (err) {
205
+ throw new InputError(`cannot read run ${path}: ${err.message}`);
206
+ }
207
+ if (!st.isDirectory()) return [path];
208
+ return readdirSync(path)
209
+ .filter((f) => f.endsWith(".xml"))
210
+ .sort()
211
+ .map((f) => join(path, f));
212
+ }
213
+
214
+ /** One run's outcome per test: pass, fail, skip, or mixed when the run saw both. */
215
+ function runOutcomes(path) {
216
+ const all = new Map();
217
+ for (const f of junitFiles(path)) {
218
+ let xml;
219
+ try {
220
+ xml = readFileSync(f, "utf8");
221
+ } catch (err) {
222
+ throw new InputError(`cannot read run ${f}: ${err.message}`);
223
+ }
224
+ for (const [id, list] of junitOutcomes(xml)) all.set(id, [...(all.get(id) || []), ...list]);
225
+ }
226
+ const merged = new Map();
227
+ for (const [id, list] of all) {
228
+ const ran = list.filter((o) => o !== "skip");
229
+ if (ran.length === 0) merged.set(id, "skip");
230
+ else if (ran.every((o) => o === "pass")) merged.set(id, "pass");
231
+ else if (ran.every((o) => o === "fail")) merged.set(id, "fail");
232
+ else merged.set(id, "mixed");
233
+ }
234
+ return merged;
235
+ }
236
+
237
+ /**
238
+ * Classify from repeat runs only, for a toolkit without test_results_classify.
239
+ * A test that failed and passed inside one run (a retry, one device of
240
+ * several) is unstable: the run's pass came only on another attempt.
241
+ */
242
+ export function fromRepeats(runPaths) {
243
+ const runs = runPaths.map(runOutcomes);
244
+ const ids = [...new Set(runs.flatMap((r) => [...r.keys()]))];
245
+ const tests = ids.map((id) => {
246
+ const mixedIn = runs.findIndex((r) => r.get(id) === "mixed");
247
+ if (mixedIn !== -1) {
248
+ return {
249
+ id,
250
+ status: "unstable",
251
+ reason: `failed and passed within run ${mixedIn + 1} (a retry or another device)`,
252
+ };
253
+ }
254
+ const seen = runs.map((r) => r.get(id)).filter((o) => o && o !== "skip");
255
+ const passed = seen.filter((o) => o === "pass").length;
256
+ if (seen.length === 0) return { id, status: "not_measured", reason: "skipped in every run" };
257
+ if (passed === seen.length) return { id, status: "passed" };
258
+ if (passed > 0) {
259
+ return { id, status: "unstable", reason: `passed ${passed} of ${seen.length} repeats` };
260
+ }
261
+ return {
262
+ id,
263
+ status: "not_measured",
264
+ reason: `red in all ${seen.length} repeat(s); no base run to compare (test_results_classify unavailable)`,
265
+ };
266
+ });
267
+ return { tests };
268
+ }
269
+
270
+ function main() {
271
+ const [mode, ...rest] = process.argv.slice(2);
272
+ if (mode !== "merge" && mode !== "fallback") usage("first argument is merge or fallback");
273
+ const args = parseArgs(rest);
274
+ if (mode === "merge" && !args.classified) usage("merge needs --classified <toolkit-output.json>");
275
+ if (mode === "fallback" && args.run.length === 0) usage("fallback needs at least one --run");
276
+ let result;
277
+ try {
278
+ const state = args.state ? readJson(args.state, "state") : null;
279
+ const classified =
280
+ mode === "merge" ? readJson(args.classified, "classification") : fromRepeats(args.run);
281
+ result = verdict(classified, state, mode === "merge" ? "toolkit" : "fallback");
282
+ } catch (err) {
283
+ const code = err instanceof InputError ? 3 : 4;
284
+ writeFileSync(1, `${JSON.stringify({ green: false, reason: err.message })}\n`);
285
+ process.exit(code);
286
+ }
287
+ const text = `${JSON.stringify(result, null, 2)}\n`;
288
+ if (args.out) {
289
+ mkdirSync(dirname(args.out), { recursive: true });
290
+ writeFileSync(args.out, text);
291
+ }
292
+ writeFileSync(1, text);
293
+ process.exit(result.green ? 0 : 1);
294
+ }
295
+
296
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();