@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,113 @@
1
+ /**
2
+ * @file _worktree-snapshot.mjs - the working tree as a git tree, and diffs between snapshots.
3
+ *
4
+ * A stage that may edit the repository is judged by what IT changed, not by
5
+ * everything that differs from HEAD: the person's own uncommitted work was
6
+ * there before the stage started, and an audit that reads it as the stage's
7
+ * change would tell the agent to revert it. `snapshotTree` records the whole
8
+ * working tree (tracked and untracked files, ignored files excluded) as a git
9
+ * tree object through a throwaway index, so neither the working tree nor the
10
+ * real index is touched. `treeDiff` diffs two such trees, or a commit and a
11
+ * tree.
12
+ *
13
+ * Every ref a caller passes is checked with `rev-parse --verify` and placed
14
+ * after `--end-of-options`, so a value starting with `-` is never read as an
15
+ * option. Every diff carries explicit `a/` `b/` prefixes, so a user's
16
+ * `diff.noprefix` or `diff.mnemonicPrefix` setting cannot change the paths the
17
+ * parser reads.
18
+ *
19
+ * @module pipeline/scripts/_worktree-snapshot
20
+ */
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ export class GitError extends Error {}
28
+
29
+ /** Run git in `repo`; throws GitError with git's first stderr line on failure. */
30
+ export function git(repo, args, env = {}) {
31
+ const r = spawnSync("git", ["-C", repo, ...args], {
32
+ encoding: "utf8",
33
+ maxBuffer: 256 * 1024 * 1024,
34
+ timeout: 120000,
35
+ env: { ...process.env, ...env },
36
+ });
37
+ if (r.status !== 0) {
38
+ const why = (r.stderr || r.error?.message || `exit ${r.status}`).trim().split("\n")[0];
39
+ throw new GitError(`git ${args.find((a) => !a.startsWith("-")) || ""} failed: ${why}`);
40
+ }
41
+ return r.stdout;
42
+ }
43
+
44
+ /** A value an option was given, refused when it is missing or looks like another option. */
45
+ export function optionValue(name, value) {
46
+ if (typeof value !== "string" || value === "" || value.startsWith("-")) {
47
+ throw new GitError(`${name} needs a value that does not start with "-"`);
48
+ }
49
+ return value;
50
+ }
51
+
52
+ /** The commit `ref` names, as a full sha. */
53
+ export function verifyCommit(repo, ref) {
54
+ optionValue("ref", ref);
55
+ return git(repo, [
56
+ "rev-parse",
57
+ "--verify",
58
+ "--quiet",
59
+ "--end-of-options",
60
+ `${ref}^{commit}`,
61
+ ]).trim();
62
+ }
63
+
64
+ /** The working tree, tracked and untracked, as a tree object sha. */
65
+ export function snapshotTree(repo) {
66
+ const dir = mkdtempSync(join(tmpdir(), "ma-snapshot-"));
67
+ const env = { GIT_INDEX_FILE: join(dir, "index") };
68
+ try {
69
+ let hasHead = true;
70
+ try {
71
+ git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"]);
72
+ } catch {
73
+ hasHead = false;
74
+ }
75
+ if (hasHead) git(repo, ["read-tree", "HEAD"], env);
76
+ git(repo, ["add", "-A", "--", "."], env);
77
+ return git(repo, ["write-tree"], env).trim();
78
+ } finally {
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ }
82
+
83
+ /** Unified diff (-U0) from one tree-ish to another, renames off so moves read as delete + add. */
84
+ export function treeDiff(repo, from, to) {
85
+ return git(repo, [
86
+ "diff-tree",
87
+ "-p",
88
+ "-r",
89
+ "-U0",
90
+ "--no-color",
91
+ "--no-ext-diff",
92
+ "--no-renames",
93
+ "--src-prefix=a/",
94
+ "--dst-prefix=b/",
95
+ "--end-of-options",
96
+ from,
97
+ to,
98
+ ]);
99
+ }
100
+
101
+ /** What changed in the working tree since the snapshot `fromTree` was taken. */
102
+ export function diffSince(repo, fromTree) {
103
+ optionValue("snapshot", fromTree);
104
+ git(repo, ["cat-file", "-e", "--end-of-options", `${fromTree}^{tree}`]);
105
+ return treeDiff(repo, fromTree, snapshotTree(repo));
106
+ }
107
+
108
+ /** The working tree (untracked files included) against the merge base of HEAD and `base`. */
109
+ export function diffFromMergeBase(repo, base) {
110
+ const baseSha = verifyCommit(repo, base);
111
+ const mb = git(repo, ["merge-base", "--end-of-options", baseSha, "HEAD"]).trim();
112
+ return treeDiff(repo, mb, snapshotTree(repo));
113
+ }
@@ -104,6 +104,7 @@ const CHECK_IDS = [
104
104
  "disk-space",
105
105
  "worktree-residue",
106
106
  "usage-report",
107
+ "usage-register",
107
108
  "free-models",
108
109
  "xcode-skills",
109
110
  ];
@@ -936,6 +937,55 @@ function checkUsageReport() {
936
937
  );
937
938
  }
938
939
 
940
+ // usage-register.mjs runs quietly from setup, update and Phase 0, so the record
941
+ // of its last attempt, and of the failure report it may have sent, is where a
942
+ // machine that never registers shows why.
943
+ const REGISTER_FINE = new Set(["registered", "registered-feedback-only", "enabled"]);
944
+ const REGISTER_QUIET = new Set([
945
+ "token in environment",
946
+ "token in prefs",
947
+ "token already onboarded",
948
+ ]);
949
+
950
+ function beaconSummary(beacon) {
951
+ const last = beacon?.last;
952
+ if (!last || typeof last.outcome !== "string") return "no failure report was sent";
953
+ const when = last.t ? ` at ${last.t}` : "";
954
+ return last.outcome === "sent"
955
+ ? `the failure report (${last.reason}) reached the site${when}`
956
+ : `the failure report (${last.reason}) could not be delivered${when}`;
957
+ }
958
+
959
+ function checkUsageRegister() {
960
+ const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
961
+ if (prefs?.global?.usageLog?.optOut === true) {
962
+ skip("usage-register", "usage reporting is opted out");
963
+ return;
964
+ }
965
+ const logs = join(CLAUDE, "logs", "multi-agent");
966
+ const last = readJson(join(logs, "usage-last-register.json"));
967
+ if (!last || typeof last.status !== "string") {
968
+ skip("usage-register", "no registration has been attempted on this machine yet");
969
+ return;
970
+ }
971
+ if (
972
+ REGISTER_FINE.has(last.status) ||
973
+ (last.status === "skipped" && REGISTER_QUIET.has(last.detail))
974
+ ) {
975
+ ok("usage-register");
976
+ return;
977
+ }
978
+ const beacon = beaconSummary(readJson(join(logs, "usage-register-beacon.json")));
979
+ const when = last.t ? ` at ${last.t}` : "";
980
+ const detail = last.detail ? ` (${last.detail})` : "";
981
+ report(
982
+ "usage-register",
983
+ "WARN",
984
+ `the last usage registration${when} was ${last.status}${detail}; ${beacon}`,
985
+ `run node ${join(HERE, "usage-register.mjs")} --refresh`,
986
+ );
987
+ }
988
+
939
989
  // route-on --free promises that simple tasks leave Claude. With no usable
940
990
  // provider every one of them silently escalates back, which costs nothing but
941
991
  // saves nothing either - the user believes a control is working that is not.
@@ -1362,6 +1412,7 @@ function main() {
1362
1412
  checkDiskSpace();
1363
1413
  checkWorktreeResidue();
1364
1414
  checkUsageReport();
1415
+ checkUsageRegister();
1365
1416
  checkFreeModels();
1366
1417
  checkXcodeSkills();
1367
1418
  if (PROFILE === "server") checkServerProfile(resolved);
@@ -12,11 +12,23 @@
12
12
  // node pipeline/scripts/evidence-gate.mjs --claim test --status passed --evidence test.log \
13
13
  // --success-pattern 'All tests passed' --failure-pattern 'FAILED'
14
14
  // node pipeline/scripts/evidence-gate.mjs --claim manual --status passed --evidence manual-test.json
15
+ // node pipeline/scripts/evidence-gate.mjs --claim artefact --status passed --evidence gaps.json \
16
+ // --stage-start 2026-10-23T09:00:00Z
15
17
  //
16
18
  // Flags:
17
- // --claim build | test | review | manual
19
+ // --claim build | test | review | manual | artefact
18
20
  // build/test/review select default success/failure markers;
19
- // manual is JSON-based (Phase 5 manual-test.json, see below)
21
+ // manual is JSON-based (Phase 5 manual-test.json, see below);
22
+ // artefact is a stage's own output file, judged only by
23
+ // --stage-start (required with it)
24
+ // --stage-start <t> ISO time or epoch (ms, or seconds) the stage started.
25
+ // The evidence must be this stage's own file, written
26
+ // during it and whole: an mtime earlier than the start
27
+ // less 2s of clock slack is another stage's (or an old
28
+ // run's) file. A .json file is whole when it parses; any
29
+ // other file under 400 bytes, or ending in a letter or
30
+ // digit with no trailing newline, is partial, cut off
31
+ // mid-write. Applies to every claim when given.
20
32
  // --status the claimed status (only "passed" is gated; others pass through)
21
33
  // --evidence <path> the log / artifact that must substantiate a "passed" claim
22
34
  // --require-screenshot manual claim only: every "pass" criterion must name a
@@ -93,7 +105,54 @@ const DEFAULT_MARKERS = {
93
105
  };
94
106
 
95
107
  const JSON_CLAIMS = new Set(["manual"]);
96
- const CLAIMS = [...Object.keys(DEFAULT_MARKERS), ...JSON_CLAIMS];
108
+ const ARTEFACT_CLAIM = "artefact";
109
+ const CLAIMS = [...Object.keys(DEFAULT_MARKERS), ...JSON_CLAIMS, ARTEFACT_CLAIM];
110
+
111
+ const STAGE_SLACK_MS = 2000;
112
+ const PARTIAL_UNDER_BYTES = 400;
113
+
114
+ /** --stage-start as epoch ms: ISO, epoch ms, or epoch seconds. */
115
+ function stageStartMs(value) {
116
+ if (value === undefined) return null;
117
+ const s = String(value).trim();
118
+ if (/^\d+(\.\d+)?$/.test(s)) {
119
+ const n = Number(s);
120
+ return n < 1e12 ? n * 1000 : n;
121
+ }
122
+ const t = Date.parse(s);
123
+ if (value === true || Number.isNaN(t)) usage(`--stage-start is not a time: ${value}`);
124
+ return t;
125
+ }
126
+
127
+ /**
128
+ * Is this the stage's own, whole file? Returns the reason it is not, or null.
129
+ *
130
+ * @param {string} path
131
+ * @param {Buffer} raw
132
+ * @param {number} startMs
133
+ */
134
+ function stageArtefactProblem(path, raw, startMs) {
135
+ const mtime = statSync(path).mtimeMs;
136
+ if (mtime < startMs - STAGE_SLACK_MS) {
137
+ return `written ${new Date(mtime).toISOString()}, before the stage started (${new Date(startMs).toISOString()}); not this stage's output`;
138
+ }
139
+ if (/\.json$/i.test(path)) {
140
+ try {
141
+ JSON.parse(raw.toString("utf8"));
142
+ return null;
143
+ } catch (err) {
144
+ return `partial: not valid JSON (${err.message})`;
145
+ }
146
+ }
147
+ if (raw.length < PARTIAL_UNDER_BYTES) {
148
+ return `partial: ${raw.length} bytes, under ${PARTIAL_UNDER_BYTES}`;
149
+ }
150
+ const last = raw.toString("utf8").slice(-1);
151
+ if (/[A-Za-z0-9]/.test(last)) {
152
+ return "partial: ends mid-token, with no trailing newline";
153
+ }
154
+ return null;
155
+ }
97
156
  const MANUAL_VERDICTS = new Set(["pass", "fail", "not-tested"]);
98
157
 
99
158
  const MAX_PATTERN_LEN = 200;
@@ -291,6 +350,10 @@ if (typeof status !== "string") {
291
350
  }
292
351
  ledgerClaim = claim;
293
352
  ledgerState = typeof args.state === "string" ? args.state : null;
353
+ const stageStart = stageStartMs(args["stage-start"]);
354
+ if (claim === ARTEFACT_CLAIM && stageStart === null) {
355
+ usage("--claim artefact needs --stage-start");
356
+ }
294
357
 
295
358
  // Only a "passed" claim needs proof. failed / pending / anything else is honest
296
359
  // about not succeeding, so it passes through untouched.
@@ -336,6 +399,14 @@ if (body.trim().length === 0) {
336
399
  fail(`${claim} claims passed but evidence file is blank: ${evidence} (default-FAIL)`);
337
400
  }
338
401
 
402
+ if (stageStart !== null) {
403
+ const problem = stageArtefactProblem(evidence, readFileSync(evidence), stageStart);
404
+ if (problem) fail(`${claim} evidence ${evidence}: ${problem} (default-FAIL)`);
405
+ if (claim === ARTEFACT_CLAIM) {
406
+ ok(`${claim} ${evidence} is this stage's own output and whole`);
407
+ }
408
+ }
409
+
339
410
  if (JSON_CLAIMS.has(claim)) {
340
411
  gateManual(claim, evidence, body, args["require-screenshot"] === true);
341
412
  }
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @file handle-diff-audit.mjs - did the handle stage add only handles?
4
+ *
5
+ * The test-gen handle stage may change product code for one purpose: adding
6
+ * the automation handles a UI test finds elements by (iOS
7
+ * accessibilityIdentifier, Compose testTag / semantics, web data-testid). A
8
+ * prompt that says "only add attributes" is a wish; this measures it.
9
+ *
10
+ * Per changed file, the removed lines and the added lines with their handle
11
+ * constructs stripped are read as ordered code tokens (_handle-patterns.mjs
12
+ * codeTokens: operators and punctuation kept, whitespace dropped), hunk by
13
+ * hunk in order. The two sequences must be equal. Re-wrapping a tag over
14
+ * several lines or moving a modifier onto its own line keeps them equal; a
15
+ * flipped comparison, a dropped `!`, swapped arguments or a deleted modifier
16
+ * does not. A difference is reported as:
17
+ *
18
+ * lost tokens the removed lines had that the added lines lack
19
+ * extra tokens the added lines carry beyond the removed ones
20
+ * reordered the same tokens in another order
21
+ * new-file a file appeared that is not a handle-constants file (string
22
+ * constants, the enums or objects grouping them, imports,
23
+ * comments, nothing else)
24
+ * deleted-file / binary
25
+ *
26
+ * Snapshot files are listed apart: a new attribute changes a rendered
27
+ * snapshot, and its owners should know, but it is not a violation.
28
+ *
29
+ * What is read:
30
+ * --since <tree> / --baseline <json> only what changed after the stage-start
31
+ * snapshot (test-gen-gate.mjs baseline records its tree),
32
+ * so uncommitted work the person had before the stage is
33
+ * never read as the stage's change
34
+ * --base <ref> the working tree, untracked files included, against a
35
+ * commit (default HEAD)
36
+ * --diff-file <p> a saved unified diff
37
+ *
38
+ * Usage:
39
+ * node handle-diff-audit.mjs [--repo <path>] [--since <tree> | --baseline <json> | --base <ref>]
40
+ * node handle-diff-audit.mjs --diff-file <unified.diff>
41
+ *
42
+ * Output: one JSON object on stdout.
43
+ * Exit: 0 only handles (or nothing changed), 2 violation, 3 input unreadable
44
+ * (a missing or option-shaped value, an unknown ref, a failed git call)
45
+ *
46
+ * @module pipeline/scripts/handle-diff-audit
47
+ */
48
+
49
+ import { readFileSync, writeFileSync } from "node:fs";
50
+ import { pathToFileURL } from "node:url";
51
+
52
+ import {
53
+ codeTokens,
54
+ isHandleConstantsFile,
55
+ isHandleLine,
56
+ isSnapshotPath,
57
+ parseUnifiedDiff,
58
+ stripHandles,
59
+ } from "./_handle-patterns.mjs";
60
+ import {
61
+ GitError,
62
+ diffSince,
63
+ optionValue,
64
+ snapshotTree,
65
+ treeDiff,
66
+ verifyCommit,
67
+ } from "./_worktree-snapshot.mjs";
68
+
69
+ const VALUE_FLAGS = new Set(["repo", "base", "since", "baseline", "diff-file"]);
70
+
71
+ function emit(result, code) {
72
+ writeFileSync(1, `${JSON.stringify({ ...result, code })}\n`);
73
+ process.exit(code);
74
+ }
75
+
76
+ function unreadable(reason) {
77
+ emit({ ok: false, reason }, 3);
78
+ }
79
+
80
+ function parseArgs(argv) {
81
+ const out = {};
82
+ for (let i = 0; i < argv.length; i++) {
83
+ const a = argv[i];
84
+ if (!a.startsWith("--")) unreadable(`unexpected argument ${a}`);
85
+ const key = a.slice(2);
86
+ if (!VALUE_FLAGS.has(key)) unreadable(`unknown option ${a}`);
87
+ const v = argv[i + 1];
88
+ if (v === undefined || v.startsWith("-"))
89
+ unreadable(`${a} needs a value that does not start with "-"`);
90
+ out[key] = v;
91
+ i++;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ function readDiff(args) {
97
+ if (args["diff-file"]) {
98
+ try {
99
+ return readFileSync(args["diff-file"], "utf8");
100
+ } catch (err) {
101
+ return unreadable(`cannot read --diff-file: ${err.message}`);
102
+ }
103
+ }
104
+ const repo = args.repo || process.cwd();
105
+ try {
106
+ if (args.since || args.baseline) {
107
+ let tree = args.since;
108
+ if (!tree) {
109
+ const doc = JSON.parse(readFileSync(args.baseline, "utf8"));
110
+ tree = optionValue("baseline tree", doc.tree);
111
+ }
112
+ return diffSince(repo, tree);
113
+ }
114
+ const base = verifyCommit(repo, args.base || "HEAD");
115
+ return treeDiff(repo, base, snapshotTree(repo));
116
+ } catch (err) {
117
+ if (err instanceof GitError || err instanceof SyntaxError || err.code === "ENOENT") {
118
+ return unreadable(err.message);
119
+ }
120
+ throw err;
121
+ }
122
+ }
123
+
124
+ /** Multiset difference: tokens of `a` not covered by `b`. */
125
+ function missing(a, b) {
126
+ const pool = new Map();
127
+ for (const t of b) pool.set(t, (pool.get(t) || 0) + 1);
128
+ const out = [];
129
+ for (const t of a) {
130
+ const n = pool.get(t) || 0;
131
+ if (n > 0) pool.set(t, n - 1);
132
+ else out.push(t);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ export function audit(diffText) {
138
+ const files = [];
139
+ const violations = [];
140
+ const snapshots = [];
141
+ let handlesAdded = 0;
142
+ for (const [file, f] of parseUnifiedDiff(diffText)) {
143
+ if (isSnapshotPath(file)) {
144
+ snapshots.push(file);
145
+ continue;
146
+ }
147
+ const handles = f.added.filter((l) => isHandleLine(file, l)).length;
148
+ handlesAdded += handles;
149
+ files.push({ file, handles, added: f.added.length, removed: f.removed.length });
150
+ if (f.binary) {
151
+ violations.push({ file, kind: "binary", tokens: [] });
152
+ continue;
153
+ }
154
+ if (f.isDeleted) {
155
+ violations.push({ file, kind: "deleted-file", tokens: [] });
156
+ continue;
157
+ }
158
+ if (f.isNew) {
159
+ if (!isHandleConstantsFile(f.added)) violations.push({ file, kind: "new-file", tokens: [] });
160
+ continue;
161
+ }
162
+ const before = f.hunks.flatMap((h) => codeTokens(h.removed.map((d) => d.text).join("\n")));
163
+ const after = f.hunks.flatMap((h) =>
164
+ codeTokens(h.added.map((d) => stripHandles(file, d.text)).join("\n")),
165
+ );
166
+ if (before.length === after.length && before.every((t, i) => t === after[i])) continue;
167
+ const lost = missing(before, after);
168
+ const extra = missing(after, before);
169
+ if (lost.length) violations.push({ file, kind: "lost", tokens: [...new Set(lost)] });
170
+ if (extra.length) violations.push({ file, kind: "extra", tokens: [...new Set(extra)] });
171
+ if (!lost.length && !extra.length) {
172
+ const at = before.findIndex((t, i) => t !== after[i]);
173
+ violations.push({ file, kind: "reordered", tokens: before.slice(at, at + 6) });
174
+ }
175
+ }
176
+ return { ok: violations.length === 0, handlesAdded, files, snapshots, violations };
177
+ }
178
+
179
+ function main() {
180
+ const args = parseArgs(process.argv.slice(2));
181
+ const text = readDiff(args);
182
+ if (!text.trim())
183
+ emit({ ok: true, handlesAdded: 0, files: [], snapshots: [], violations: [] }, 0);
184
+ const result = audit(text);
185
+ emit(result, result.ok ? 0 : 2);
186
+ }
187
+
188
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
@@ -122,6 +122,10 @@ function cmdGet(home, name) {
122
122
  process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
123
123
  }
124
124
 
125
+ // Every saved routine is written with this grant; permission-baseline.mjs
126
+ // freezes it.
127
+ export const ROUTINE_ALLOWED_TOOLS = "Skill, Bash, Read, Edit, Write, AskUserQuestion";
128
+
125
129
  function buildSkill(args) {
126
130
  const desc = args.description;
127
131
  const descTr = args["description-tr"];
@@ -132,7 +136,7 @@ function buildSkill(args) {
132
136
  `description: ${JSON.stringify(desc)}`,
133
137
  descTr ? `description-tr: ${JSON.stringify(descTr)}` : null,
134
138
  `argument-hint: ${JSON.stringify(hint)}`,
135
- "allowed-tools: Skill, Bash, Read, Edit, Write, AskUserQuestion",
139
+ `allowed-tools: ${ROUTINE_ALLOWED_TOOLS}`,
136
140
  "local-only: true",
137
141
  "---",
138
142
  ].filter(Boolean);
@@ -27,7 +27,15 @@
27
27
  #
28
28
  # run-ui-tests.sh run --platform <ios|android|web> [--repo <path>] [--changed <f>...]
29
29
  # [--device <udid|serial>] [--log <path>] [--all]
30
- # Run the matching tests (or the whole UI suite with --all).
30
+ # Run the matching tests (or the whole UI suite with --all). Prints
31
+ # UI_TEST_LOG=<path>
32
+ # UI_TEST_SELECTED=<what ran>
33
+ # UI_TEST_RESULT=<per-test report>| (empty when the runner has none)
34
+ # UI_TEST_RESULT_FORMAT=xcresult|junit|
35
+ # The report sits next to the log: <log stem>.xcresult on iOS,
36
+ # <log stem>.junit.xml for Playwright, the module's connected test results
37
+ # directory on Android. It is what test_results_classify reads per test;
38
+ # the log alone says only whether the run passed.
31
39
  #
32
40
  # Env:
33
41
  # UI_TEST_LOG default "$PWD/.pipeline/ui-test.log"
@@ -396,6 +404,7 @@ fi
396
404
  }
397
405
 
398
406
  mkdir -p "$(dirname "$LOG")"
407
+ RESULT=""; RESULT_FORMAT=""
399
408
 
400
409
  case "$PLATFORM" in
401
410
  ios)
@@ -434,9 +443,13 @@ case "$PLATFORM" in
434
443
  [ -n "$DEVICE" ] || { echo "run-ui-tests: no booted simulator and no --device" >&2; exit 2; }
435
444
  fi
436
445
  DEST="platform=iOS Simulator,id=$DEVICE"
446
+ # xcodebuild refuses a result bundle path that already exists.
447
+ RESULT="${LOG%.log}.xcresult"
448
+ rm -rf "$RESULT"
437
449
  (cd "$REPO" && xcodebuild test "${CONTAINER_ARGV[@]}" -scheme "$SCHEME" \
438
- -destination "$DEST" "${ONLY_ARGV[@]}") >"$LOG" 2>&1
450
+ -destination "$DEST" -resultBundlePath "$RESULT" "${ONLY_ARGV[@]}") >"$LOG" 2>&1
439
451
  RC=$?
452
+ if [ -d "$RESULT" ]; then RESULT_FORMAT="xcresult"; else RESULT=""; fi
440
453
  ;;
441
454
  android)
442
455
  command -v adb >/dev/null 2>&1 || { echo "run-ui-tests: adb unavailable" >&2; exit 2; }
@@ -450,6 +463,11 @@ case "$PLATFORM" in
450
463
  # shellcheck disable=SC2086
451
464
  (cd "$REPO" && "$GRADLE" "$TARGET" $ARGS) >"$LOG" 2>&1
452
465
  RC=$?
466
+ MODULE_DIR=$(printf '%s\n' "$TARGET" | sed 's#:connectedAndroidTest$##; s#^:##; s#:#/#g')
467
+ if [ -d "$REPO/$MODULE_DIR/build/outputs/androidTest-results/connected" ]; then
468
+ RESULT="$REPO/$MODULE_DIR/build/outputs/androidTest-results/connected"
469
+ RESULT_FORMAT="junit"
470
+ fi
453
471
  ;;
454
472
  web)
455
473
  command -v npx >/dev/null 2>&1 || { echo "run-ui-tests: npx unavailable" >&2; exit 2; }
@@ -459,7 +477,10 @@ case "$PLATFORM" in
459
477
  # --no-install: npx would otherwise DOWNLOAD the runner on a machine that
460
478
  # does not have it, which turns "this repo has no UI tests" into a silent
461
479
  # network fetch mid-phase.
462
- WEB_ARGV=(npx --no-install playwright test --reporter=line)
480
+ WEB_ARGV=(npx --no-install playwright test "--reporter=line,junit")
481
+ RESULT="${LOG%.log}.junit.xml"
482
+ rm -f "$RESULT"
483
+ export PLAYWRIGHT_JUNIT_OUTPUT_NAME="$RESULT"
463
484
  if [ "$RUN_ALL" -eq 0 ]; then
464
485
  OLDIFS="$IFS"; IFS=','
465
486
  for m in $MATCHES; do WEB_ARGV+=("$m"); done
@@ -474,11 +495,14 @@ case "$PLATFORM" in
474
495
  esac
475
496
  (cd "$REPO" && "${WEB_ARGV[@]}") >"$LOG" 2>&1
476
497
  RC=$?
498
+ if [ -n "$RESULT" ] && [ -s "$RESULT" ]; then RESULT_FORMAT="junit"; else RESULT=""; fi
477
499
  ;;
478
500
  esac
479
501
 
480
502
  printf 'UI_TEST_LOG=%s\n' "$LOG"
481
503
  printf 'UI_TEST_SELECTED=%s\n' "${MATCHES:-$TARGET}"
504
+ printf 'UI_TEST_RESULT=%s\n' "$RESULT"
505
+ printf 'UI_TEST_RESULT_FORMAT=%s\n' "$RESULT_FORMAT"
482
506
 
483
507
  # The exit code alone is not the verdict here for the same reason it is not one
484
508
  # for the build: a runner that died before it reached the tests also exits