@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.
- package/CHANGELOG.md +23 -0
- package/install/_codex-agents.mjs +2 -2
- package/install/_dev-only-files.mjs +1 -0
- package/package.json +4 -3
- package/pipeline/agents/android-architect.md +1 -0
- package/pipeline/agents/backend-architect.md +1 -0
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/dev-critic.md +2 -1
- package/pipeline/agents/explorer.md +1 -0
- package/pipeline/agents/ios-architect.md +1 -0
- package/pipeline/agents/security-auditor.md +1 -0
- package/pipeline/agents/task-clarifier.md +1 -0
- package/pipeline/commands/multi-agent/manual-test/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -0
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +22 -0
- package/pipeline/commands/sim-test.md +10 -5
- package/pipeline/multi-agent-refs/channels/pr.md +22 -4
- package/pipeline/multi-agent-refs/features/autopilot-circuit-breaker.md +15 -2
- package/pipeline/multi-agent-refs/features/review-delta.md +89 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +41 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +6 -5
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/payload-contracts.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +17 -2
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +46 -8
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +10 -0
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +2 -0
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +4 -2
- package/pipeline/multi-agent-refs/rules.md +2 -2
- package/pipeline/schemas/agent-state.schema.json +129 -0
- package/pipeline/schemas/dev-critic-output.schema.json +5 -0
- package/pipeline/schemas/prefs.schema.json +47 -0
- package/pipeline/schemas/reviewer-output.schema.json +7 -2
- package/pipeline/schemas/scope-check.schema.json +55 -0
- package/pipeline/schemas/token-budget.json +3 -3
- package/pipeline/schemas/triage-output.schema.json +12 -2
- package/pipeline/scripts/README.md +3 -2
- package/pipeline/scripts/_fingerprint.mjs +173 -0
- package/pipeline/scripts/evidence-gate.mjs +73 -5
- package/pipeline/scripts/finding-fingerprint.mjs +101 -0
- package/pipeline/scripts/review-delta.mjs +217 -0
- package/pipeline/scripts/run-metrics.mjs +20 -0
- package/pipeline/scripts/scope-check-gate.mjs +90 -0
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +11 -4
- package/pipeline/scripts/validate-reviewer.mjs +6 -0
- package/pipeline/scripts/validate-triage.mjs +20 -0
- 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=(
|
|
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
|
|
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.
|