@mmerterden/multi-agent-pipeline 16.19.0 → 16.20.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 (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/install/_codex-agents.mjs +2 -2
  3. package/install/_dev-only-files.mjs +1 -0
  4. package/package.json +4 -3
  5. package/pipeline/agents/android-architect.md +1 -0
  6. package/pipeline/agents/backend-architect.md +1 -0
  7. package/pipeline/agents/code-reviewer.md +1 -0
  8. package/pipeline/agents/dev-critic.md +2 -1
  9. package/pipeline/agents/explorer.md +1 -0
  10. package/pipeline/agents/ios-architect.md +1 -0
  11. package/pipeline/agents/security-auditor.md +1 -0
  12. package/pipeline/agents/task-clarifier.md +1 -0
  13. package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -1
  14. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
  15. package/pipeline/commands/multi-agent/store-ready/SKILL.md +22 -0
  16. package/pipeline/commands/sim-test.md +10 -5
  17. package/pipeline/multi-agent-refs/channels/pr.md +22 -4
  18. package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +15 -2
  19. package/pipeline/multi-agent-refs/features/review-delta.md +89 -0
  20. package/pipeline/multi-agent-refs/features/scope-check.md +41 -0
  21. package/pipeline/multi-agent-refs/features/verify-by-test.md +6 -5
  22. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  23. package/pipeline/multi-agent-refs/payload-contracts.md +1 -1
  24. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +17 -2
  25. package/pipeline/multi-agent-refs/phases/phase-4-review.md +46 -8
  26. package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
  27. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +2 -0
  28. package/pipeline/multi-agent-refs/phases/phase-7-report.md +4 -2
  29. package/pipeline/multi-agent-refs/rules.md +2 -2
  30. package/pipeline/schemas/agent-state.schema.json +129 -0
  31. package/pipeline/schemas/dev-critic-output.schema.json +5 -0
  32. package/pipeline/schemas/prefs.schema.json +47 -0
  33. package/pipeline/schemas/reviewer-output.schema.json +7 -2
  34. package/pipeline/schemas/scope-check.schema.json +55 -0
  35. package/pipeline/schemas/token-budget.json +3 -3
  36. package/pipeline/schemas/triage-output.schema.json +12 -2
  37. package/pipeline/scripts/README.md +3 -2
  38. package/pipeline/scripts/_fingerprint.mjs +173 -0
  39. package/pipeline/scripts/evidence-gate.mjs +73 -5
  40. package/pipeline/scripts/finding-fingerprint.mjs +101 -0
  41. package/pipeline/scripts/review-delta.mjs +217 -0
  42. package/pipeline/scripts/run-metrics.mjs +20 -0
  43. package/pipeline/scripts/scope-check-gate.mjs +90 -0
  44. package/pipeline/scripts/smoke-cross-cli-behavior.sh +11 -4
  45. package/pipeline/scripts/validate-reviewer.mjs +6 -0
  46. package/pipeline/scripts/validate-triage.mjs +20 -0
  47. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +5 -0
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * review-delta.mjs - classify one Phase 4 round against the round before it.
4
+ *
5
+ * Reviewers re-read the whole diff every round and return findings with no
6
+ * memory of the previous round. This script gives the loop that memory: it
7
+ * fingerprints both rounds (see _fingerprint.mjs) and reports which accepted
8
+ * findings are still present after the fix, which were resolved, which triage
9
+ * downgraded, and which are new. A finding that survives `--trip-cycles`
10
+ * consecutive rework cycles is the autopilot circuit-breaker's trigger 2.
11
+ *
12
+ * Usage:
13
+ * review-delta.mjs --rounds-dir <dir> --iteration N [options]
14
+ * review-delta.mjs --previous <round-N-1.json> --current <round-N.json> [--prior <round-N-2.json> ...] [options]
15
+ *
16
+ * Options:
17
+ * --kind triage|reviewer|dev-critic document shape (default triage)
18
+ * --trip-cycles N recurrence that trips (default 2)
19
+ * --severities a,b accepted severities that count as the
20
+ * rework mandate (default blocking,important)
21
+ *
22
+ * Output (stdout, JSON): {iteration, previousIteration, counts, new[], stillPresent[],
23
+ * resolved[], downgraded[], stillPresentBlocking[], recurrence{}, tripped, plateau}
24
+ * Each list entry is {fingerprint, severity, file, issue} so a log line reads
25
+ * without a lookup. With no previous round: {"skipped":"no-previous-round"}.
26
+ *
27
+ * Exit codes:
28
+ * 0 progress, or nothing to compare (fail-open like evidence-gate)
29
+ * 1 unparseable input or wrong shape
30
+ * 3 trigger-2 signal: a mandate finding recurred >= --trip-cycles times
31
+ * 64 usage
32
+ */
33
+
34
+ import { existsSync, readFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+
37
+ import { annotate, collectFingerprints, KINDS } from "./_fingerprint.mjs";
38
+
39
+ const USAGE = `usage:
40
+ review-delta.mjs --rounds-dir <dir> --iteration N [--kind ${KINDS.join("|")}] [--trip-cycles N] [--severities a,b]
41
+ review-delta.mjs --previous <json> --current <json> [--prior <json> ...] [same options]`;
42
+
43
+ const ROUND_FILE = (n) => `triage-round-${n}.json`;
44
+
45
+ function parseArgs(argv) {
46
+ const opts = { prior: [], kind: "triage", tripCycles: 2, severities: ["blocking", "important"] };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i];
49
+ const next = () => argv[++i];
50
+ if (a === "--rounds-dir") opts.roundsDir = next();
51
+ else if (a === "--iteration") opts.iteration = Number(next());
52
+ else if (a === "--previous") opts.previous = next();
53
+ else if (a === "--current") opts.current = next();
54
+ else if (a === "--prior") opts.prior.push(next());
55
+ else if (a === "--kind") opts.kind = next();
56
+ else if (a === "--trip-cycles") opts.tripCycles = Number(next());
57
+ else if (a === "--severities")
58
+ opts.severities = next()
59
+ .split(",")
60
+ .map((s) => s.trim());
61
+ else return null;
62
+ }
63
+ return opts;
64
+ }
65
+
66
+ function loadDoc(path, kind) {
67
+ const doc = JSON.parse(readFileSync(path, "utf-8"));
68
+ return annotate(doc, kind).doc;
69
+ }
70
+
71
+ /**
72
+ * Resolve the ordered list of round files, newest first: [current, previous, prior...].
73
+ * Returns null when there is no previous round to compare against.
74
+ */
75
+ function resolveRounds(opts) {
76
+ if (opts.roundsDir) {
77
+ if (!Number.isInteger(opts.iteration) || opts.iteration < 1) {
78
+ throw new Error("--iteration must be a positive integer");
79
+ }
80
+ const current = join(opts.roundsDir, ROUND_FILE(opts.iteration));
81
+ if (!existsSync(current)) throw new Error(`current round file missing: ${current}`);
82
+ const chain = [current];
83
+ for (let n = opts.iteration - 1; n >= 1; n--) {
84
+ const p = join(opts.roundsDir, ROUND_FILE(n));
85
+ if (!existsSync(p)) break;
86
+ chain.push(p);
87
+ }
88
+ return { chain, iteration: opts.iteration };
89
+ }
90
+ if (!opts.current) throw new Error("--current is required without --rounds-dir");
91
+ const chain = [opts.current];
92
+ if (opts.previous) chain.push(opts.previous, ...opts.prior);
93
+ return { chain, iteration: chain.length };
94
+ }
95
+
96
+ function entry(fp, finding) {
97
+ return {
98
+ fingerprint: fp,
99
+ severity: finding.severity ?? null,
100
+ file: finding.file ?? null,
101
+ issue: finding.issue ?? null,
102
+ };
103
+ }
104
+
105
+ function buckets(kind) {
106
+ return kind === "triage"
107
+ ? { accepted: ["accepted"], elsewhere: ["deferred", "rejected"] }
108
+ : { accepted: ["findings"], elsewhere: [] };
109
+ }
110
+
111
+ function mandate(doc, kind, severities) {
112
+ return collectFingerprints(doc, { kind, buckets: buckets(kind).accepted, severities });
113
+ }
114
+
115
+ function setEqual(a, b) {
116
+ if (a.size !== b.size) return false;
117
+ for (const x of a) if (!b.has(x)) return false;
118
+ return true;
119
+ }
120
+
121
+ export function computeDelta(docs, { kind, severities, tripCycles }) {
122
+ const [current, previous, ...priors] = docs;
123
+ const b = buckets(kind);
124
+ const prevMandate = mandate(previous, kind, severities);
125
+ const prevAll = collectFingerprints(previous, { kind, buckets: b.accepted });
126
+ const curAccepted = collectFingerprints(current, { kind, buckets: b.accepted });
127
+ const curElsewhere = collectFingerprints(current, { kind, buckets: b.elsewhere });
128
+
129
+ const stillPresent = [];
130
+ const resolved = [];
131
+ const downgraded = [];
132
+ for (const [fp, f] of prevMandate) {
133
+ if (curAccepted.has(fp)) stillPresent.push(entry(fp, curAccepted.get(fp)));
134
+ else if (curElsewhere.has(fp)) downgraded.push(entry(fp, f));
135
+ else resolved.push(entry(fp, f));
136
+ }
137
+ const fresh = [];
138
+ for (const [fp, f] of curAccepted) if (!prevAll.has(fp)) fresh.push(entry(fp, f));
139
+
140
+ const priorMandates = priors.map((d) => mandate(d, kind, severities));
141
+ const recurrence = {};
142
+ for (const { fingerprint } of stillPresent) {
143
+ let n = 1;
144
+ for (const m of priorMandates) {
145
+ if (!m.has(fingerprint)) break;
146
+ n += 1;
147
+ }
148
+ recurrence[fingerprint] = n;
149
+ }
150
+
151
+ let plateau = false;
152
+ if (priors.length > 0 && stillPresent.length > 0) {
153
+ const prevStill = new Set();
154
+ for (const fp of priorMandates[0].keys()) if (prevMandate.has(fp)) prevStill.add(fp);
155
+ plateau = setEqual(prevStill, new Set(stillPresent.map((e) => e.fingerprint)));
156
+ }
157
+
158
+ const tripped = stillPresent.some((e) => recurrence[e.fingerprint] >= tripCycles);
159
+ return {
160
+ counts: {
161
+ new: fresh.length,
162
+ stillPresent: stillPresent.length,
163
+ resolved: resolved.length,
164
+ downgraded: downgraded.length,
165
+ },
166
+ new: fresh,
167
+ stillPresent,
168
+ resolved,
169
+ downgraded,
170
+ stillPresentBlocking: stillPresent.filter((e) => e.severity === "blocking"),
171
+ recurrence,
172
+ tripped,
173
+ plateau,
174
+ };
175
+ }
176
+
177
+ function main() {
178
+ const opts = parseArgs(process.argv.slice(2));
179
+ if (!opts || !KINDS.includes(opts.kind) || !(opts.tripCycles >= 1)) {
180
+ console.error(USAGE);
181
+ return 64;
182
+ }
183
+
184
+ let rounds;
185
+ try {
186
+ rounds = resolveRounds(opts);
187
+ } catch (err) {
188
+ console.error(`error: ${err.message}`);
189
+ return opts.roundsDir && /missing/.test(err.message) ? 1 : 64;
190
+ }
191
+
192
+ if (rounds.chain.length < 2) {
193
+ process.stdout.write(JSON.stringify({ skipped: "no-previous-round" }) + "\n");
194
+ return 0;
195
+ }
196
+
197
+ let docs;
198
+ try {
199
+ docs = rounds.chain.map((p) => loadDoc(p, opts.kind));
200
+ } catch (err) {
201
+ console.error(`error: ${err.message}`);
202
+ return 1;
203
+ }
204
+
205
+ const delta = computeDelta(docs, opts);
206
+ const out = {
207
+ iteration: rounds.iteration,
208
+ previousIteration: rounds.iteration - 1,
209
+ ...delta,
210
+ };
211
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
212
+ return delta.tripped ? 3 : 0;
213
+ }
214
+
215
+ if (process.argv[1] && /review-delta\.mjs$/.test(process.argv[1])) {
216
+ process.exit(main());
217
+ }
@@ -23,6 +23,11 @@
23
23
  * attribution reports "unavailable".
24
24
  * consensusVerdict - unanimous-* / split / unverified
25
25
  * buildPassed - per-repo build outcome
26
+ * diff.filesTouched / locAdded / - size of the change, from state.diffRisk
27
+ * locRemoved / maxScore (Phase 4 Step 1.75); null when the
28
+ * run predates the persisted totals
29
+ * reviewDelta.stillPresentFinal / - cross-round classification of the
30
+ * resolvedTotal / tripped last iteration (state.reviewIterations[].delta)
26
31
  *
27
32
  * Usage:
28
33
  * node pipeline/scripts/run-metrics.mjs --state path/to/agent-state.json
@@ -147,6 +152,10 @@ if (projects.length) {
147
152
  buildPassed = typeof b === "string" ? b === "passed" : b && b.ok === true;
148
153
  }
149
154
 
155
+ const diffRisk = state.diffRisk && typeof state.diffRisk === "object" ? state.diffRisk : {};
156
+ const deltas = iterations.map((it) => it.delta).filter((d) => d && typeof d === "object");
157
+ const lastDelta = deltas.length ? deltas[deltas.length - 1] : null;
158
+
150
159
  const metrics = {
151
160
  taskId: state.taskId ?? null,
152
161
  status: state.status ?? null,
@@ -193,6 +202,17 @@ const metrics = {
193
202
  },
194
203
  consensusVerdict: lastTriage.consensus ? lastTriage.consensus.verdict : null,
195
204
  buildPassed,
205
+ diff: {
206
+ filesTouched: Number.isInteger(diffRisk.files) ? diffRisk.files : null,
207
+ locAdded: Number.isInteger(diffRisk.loc_added) ? diffRisk.loc_added : null,
208
+ locRemoved: Number.isInteger(diffRisk.loc_removed) ? diffRisk.loc_removed : null,
209
+ maxScore: typeof diffRisk.max_score === "number" ? diffRisk.max_score : null,
210
+ },
211
+ reviewDelta: {
212
+ stillPresentFinal: lastDelta ? (lastDelta.stillPresent || []).length : null,
213
+ resolvedTotal: deltas.length ? deltas.reduce((n, d) => n + (d.resolved || []).length, 0) : null,
214
+ tripped: deltas.some((d) => d.tripped === true),
215
+ },
196
216
  };
197
217
 
198
218
  console.log(JSON.stringify(metrics, null, 2));
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scope-check-gate.mjs - every file in the diff needs a stated reason.
4
+ *
5
+ * Phase 3 writes `.pipeline/scope-check.json` before the handoff: one reason
6
+ * per touched file, the things it deliberately did not do, and the simplifier
7
+ * rationales that used to be discarded. This gate compares that record with
8
+ * the real diff so an unjustified file cannot ride into review unnoticed.
9
+ *
10
+ * Usage:
11
+ * git diff --name-only <base>...HEAD | node scope-check-gate.mjs --check .pipeline/scope-check.json --diff-files -
12
+ * node scope-check-gate.mjs --check scope-check.json --diff-files files.txt [--advisory]
13
+ *
14
+ * Output (stdout, JSON): {ok, justified, unjustified[], unlisted[], notDone}
15
+ * unjustified = in the diff, missing from files[] (or with an empty reason)
16
+ * unlisted = in files[], not in the diff (stale entry; reported, never fatal)
17
+ *
18
+ * Exit codes: 0 every diff file justified (or --advisory), 1 otherwise or on
19
+ * unreadable input, 64 usage.
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+
24
+ import { normalizeFile } from "./_fingerprint.mjs";
25
+
26
+ const USAGE =
27
+ "usage: scope-check-gate.mjs --check <scope-check.json> --diff-files <path|-> [--advisory]";
28
+
29
+ function parseArgs(argv) {
30
+ const opts = {};
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const a = argv[i];
33
+ if (a === "--check") opts.check = argv[++i];
34
+ else if (a === "--diff-files") opts.diffFiles = argv[++i];
35
+ else if (a === "--advisory") opts.advisory = true;
36
+ else return null;
37
+ }
38
+ return opts.check && opts.diffFiles ? opts : null;
39
+ }
40
+
41
+ function readText(path) {
42
+ return path === "-" ? readFileSync(0, "utf-8") : readFileSync(path, "utf-8");
43
+ }
44
+
45
+ export function evaluate(check, diffFiles) {
46
+ const files = Array.isArray(check?.files) ? check.files : [];
47
+ const reasons = new Map();
48
+ for (const f of files) {
49
+ if (!f || typeof f.path !== "string") continue;
50
+ const reason = typeof f.reason === "string" ? f.reason.trim() : "";
51
+ reasons.set(normalizeFile(f.path), reason);
52
+ }
53
+ const diff = [...new Set(diffFiles.map(normalizeFile).filter(Boolean))];
54
+ const unjustified = diff.filter((p) => !(reasons.get(p) ?? "").length);
55
+ const diffSet = new Set(diff);
56
+ const unlisted = [...reasons.keys()].filter((p) => !diffSet.has(p));
57
+ return {
58
+ ok: unjustified.length === 0,
59
+ justified: diff.length - unjustified.length,
60
+ unjustified,
61
+ unlisted,
62
+ notDone: Array.isArray(check?.notDone) ? check.notDone.length : 0,
63
+ };
64
+ }
65
+
66
+ function main() {
67
+ const opts = parseArgs(process.argv.slice(2));
68
+ if (!opts) {
69
+ console.error(USAGE);
70
+ return 64;
71
+ }
72
+ const fail = opts.advisory ? 0 : 1;
73
+ let check;
74
+ let diffFiles;
75
+ try {
76
+ check = JSON.parse(readText(opts.check));
77
+ diffFiles = readText(opts.diffFiles).split("\n");
78
+ } catch (err) {
79
+ console.error(`scope-check-gate: ${err.message}`);
80
+ process.stdout.write(JSON.stringify({ ok: false, error: err.message }) + "\n");
81
+ return fail;
82
+ }
83
+ const result = evaluate(check, diffFiles);
84
+ process.stdout.write(JSON.stringify(result) + "\n");
85
+ return result.ok ? 0 : fail;
86
+ }
87
+
88
+ if (process.argv[1] && /scope-check-gate\.mjs$/.test(process.argv[1])) {
89
+ process.exit(main());
90
+ }
@@ -159,15 +159,22 @@ echo "→ 7. Agent personas deployed to both CLIs"
159
159
 
160
160
  CLAUDE_AGENTS="$HOME/.claude/agents"
161
161
  COPILOT_AGENTS="$HOME/.copilot/agents"
162
- AGENT_FILES=(code-reviewer.md explorer.md ios-architect.md android-architect.md backend-architect.md security-auditor.md)
162
+ AGENT_FILES=()
163
+ if [ -d "$CLAUDE_AGENTS" ]; then
164
+ for f in "$CLAUDE_AGENTS"/*.md; do
165
+ [ -f "$f" ] && AGENT_FILES+=("$(basename "$f")")
166
+ done
167
+ fi
163
168
 
164
169
  if [ -d "$CLAUDE_AGENTS" ] && [ -d "$COPILOT_AGENTS" ]; then
165
170
  missing_on_copilot=0
166
- for a in "${AGENT_FILES[@]}"; do
171
+ for a in ${AGENT_FILES[@]+"${AGENT_FILES[@]}"}; do
167
172
  [ -f "$COPILOT_AGENTS/$a" ] || missing_on_copilot=$((missing_on_copilot+1))
168
173
  done
169
- if [ "$missing_on_copilot" -eq 0 ]; then
170
- pass "all 6 reviewer/explorer personas present on both CLIs"
174
+ if [ "$missing_on_copilot" -eq 0 ] && [ "${#AGENT_FILES[@]}" -gt 0 ]; then
175
+ pass "all ${#AGENT_FILES[@]} personas present on both CLIs"
176
+ elif [ "${#AGENT_FILES[@]}" -eq 0 ]; then
177
+ fail "no persona files under $CLAUDE_AGENTS"
171
178
  else
172
179
  fail "$missing_on_copilot persona file(s) missing from Copilot side"
173
180
  fi
@@ -29,6 +29,7 @@ import { readFileSync } from "node:fs";
29
29
 
30
30
  const ALLOWED_SEVERITIES = new Set(["blocking", "important", "suggestion"]);
31
31
  const ALLOWED_VERDICTS = new Set(["conformant", "violated", "not-applicable"]);
32
+ const FINGERPRINT_RE = /^F:[0-9a-f]{8}$/;
32
33
 
33
34
  /** @returns {Promise<string>} */
34
35
  function readInput() {
@@ -73,6 +74,11 @@ function validateFinding(f, label, errors) {
73
74
  if (typeof f.fix !== "string" || f.fix.length < 4) {
74
75
  errors.push(`${label}: fix too short or missing`);
75
76
  }
77
+ if (f.fingerprint !== undefined && !FINGERPRINT_RE.test(String(f.fingerprint))) {
78
+ errors.push(
79
+ `${label}: fingerprint ${JSON.stringify(f.fingerprint)} is not F: plus 8 hex digits - echo one from <previous-round-findings> or leave it unset`,
80
+ );
81
+ }
76
82
  }
77
83
 
78
84
  /** Rule IDs the reviewer was told to answer for. Empty set = no criteria supplied. */
@@ -32,6 +32,7 @@ const ALLOWED_CONSENSUS_VERDICTS = new Set([
32
32
  "unverified",
33
33
  ]);
34
34
  const ALLOWED_VERIFICATION_RESULTS = new Set(["confirmed", "not-reproduced", "inconclusive"]);
35
+ const FINGERPRINT_RE = /^F:[0-9a-f]{8}$/;
35
36
  const OVER_REJECT_THRESHOLD = 0.8;
36
37
  const OVER_REJECT_MIN_FINDINGS = 5;
37
38
 
@@ -73,6 +74,11 @@ function validateRawFinding(f, label, errors) {
73
74
  if (f.verification !== undefined) {
74
75
  validateVerification(f.verification, `${label}.verification`, errors);
75
76
  }
77
+ if (f.fingerprint !== undefined && !FINGERPRINT_RE.test(String(f.fingerprint))) {
78
+ errors.push(
79
+ `${label}: fingerprint ${JSON.stringify(f.fingerprint)} is not F: plus 8 hex digits (finding-fingerprint.mjs computes it; do not invent one)`,
80
+ );
81
+ }
76
82
  }
77
83
 
78
84
  // v3.2.0 verify-by-test outcome (Phase 4 Step 3.7). Optional; when present:
@@ -237,6 +243,20 @@ function validate(raw) {
237
243
  return { ok: false, code: 1, errors, warnings, corrected };
238
244
  }
239
245
 
246
+ // Two reviewers can report the same issue; the merge is triage's job, so a
247
+ // repeated fingerprint inside accepted[] is surfaced, not rejected.
248
+ const seenFingerprints = new Map();
249
+ parsed.accepted.forEach((f, i) => {
250
+ if (typeof f.fingerprint !== "string") return;
251
+ if (seenFingerprints.has(f.fingerprint)) {
252
+ warnings.push(
253
+ `accepted[${i}] repeats fingerprint ${f.fingerprint} of accepted[${seenFingerprints.get(f.fingerprint)}] - same finding reported twice; Phase 3 should treat them as one`,
254
+ );
255
+ } else {
256
+ seenFingerprints.set(f.fingerprint, i);
257
+ }
258
+ });
259
+
240
260
  // Contradiction auto-correction (bidirectional)
241
261
  const hasBlocker = parsed.accepted.some((f) => f.severity === "blocking");
242
262
  if (parsed.approved === false && !hasBlocker) {
@@ -39,6 +39,11 @@ doc is the contract.
39
39
  | 2 Authoritative | `ios_testflight_validate` → `altool --validate-app`, needs credentials | `SKIPPED` - Play's authoritative check is server-side only and no client ships here |
40
40
  | 3 Policy | `ai-ios-toolkit:app-store-review` skill vs repo source | `ai-android-toolkit:play-store-review` skill vs repo source |
41
41
 
42
+ Gate 1 also fails the package on its own, without the MCP tool, when the archive
43
+ carries no dSYM (iOS) or a `minifyEnabled true` module produced no `mapping.txt`
44
+ (Android). Gate 3 carries a `Crash symbolication` row on both platforms: a dSYM
45
+ upload step for iOS, a `mapping.txt` upload for Android.
46
+
42
47
  Gate 2's asymmetry is reported as an asymmetry. An Android run clears at most 2 of 3
43
48
  gates and must never print `passed`. A skipped gate is never folded into the pass
44
49
  count on either platform.