@mmerterden/multi-agent-pipeline 20.5.0 → 20.6.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 CHANGED
@@ -16,6 +16,24 @@ Internal file-layout changes that don't affect the slash-command surface are sti
16
16
 
17
17
  ## [Unreleased]
18
18
 
19
+ ## [20.6.0] - 2026-09-26
20
+
21
+ ### Added
22
+
23
+ - `pipeline/lib/normalize-text.mjs` and `pipeline/lib/confusables.json`: NFKC, invisible characters removed and look-alike letters and dashes mapped to ASCII. `agent-guard.py` reads the same table.
24
+ - `eval-guard-robustness.mjs` (in `npm test`): each attribution trailer and every provider in `secret-patterns.json` is passed through disguises (zero-width space, word joiner, Cyrillic letter, non-breaking hyphen) and must still be blocked by `agent-guard`, the outbound gate, the commit hook and the redactor; ordinary Turkish text, emoji and Unicode in code must still pass.
25
+ - `check-rewrite-overlap.mjs` (maintainer tool, not shipped): the share of a rewritten file's 6-word sequences that also appear in its source, with a `--max` threshold.
26
+
27
+ ### Changed
28
+
29
+ - The npm `license` field is `MIT AND LicenseRef-PolyForm-Perimeter-1.0.0`, and `LICENSE` names the skills that stay under PolyForm Perimeter 1.0.0 (listed in `NOTICE-swift-ios-skills.md`). Nothing was removed; the label now matches what the package carries.
30
+
31
+ ### Fixed
32
+
33
+ - `agent-guard` blocked an attribution trailer only when it was written in plain ASCII; one written with a zero-width space, a Cyrillic letter, a non-breaking hyphen or a fullwidth colon passed. The rule now also matches the folded text.
34
+ - The outbound gate, `redact.mjs` and `pre-commit-check.sh` recognise a credential that carries an invisible character. `smoke-secret-parity.sh` runs every provider with a zero-width space through both surfaces.
35
+ - The pre-commit high-entropy scan no longer blocks long identifiers made of words, such as the Info.plist keys `UIApplicationSceneManifest_Generation` and `UISupportedInterfaceOrientations` behind the `INFOPLIST_KEY_` build-setting prefix. A letters-only token passes only when its CamelCase, `_` and `-` segments read like words; random mixed-case tokens are still blocked, including letters-only ones that the previous CamelCase exemption let through. The leading `+` of a staged diff line is stripped before candidates are extracted, so a token at column 0 is judged by its own shape.
36
+
19
37
  ## [20.5.0] - 2026-09-25
20
38
 
21
39
  ### Added
package/LICENSE CHANGED
@@ -19,3 +19,13 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ Third-party content under another license
26
+
27
+ The skills listed in pipeline/skills/shared/external/NOTICE-swift-ios-skills.md
28
+ are derived from dpearson2699/swift-ios-skills and remain under the PolyForm
29
+ Perimeter License 1.0.0 (https://polyformproject.org/licenses/perimeter/1.0.0),
30
+ not the MIT License above. That license's Required Notice is carried in the
31
+ NOTICE file. Other vendored skills keep the licenses their NOTICE files name.
package/docs/facts.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$comment": "Generated by pipeline/scripts/gen-facts.mjs. Do not hand-edit: the site reads this, and a number edited here instead of at its source is the drift this file removes.",
3
- "generatedAt": "2026-09-25",
4
- "version": "20.5.0",
3
+ "generatedAt": "2026-09-26",
4
+ "version": "20.6.0",
5
5
  "phaseSchema": 2,
6
6
  "phases": [
7
7
  {
@@ -40,13 +40,14 @@
40
40
  "skillCountAll": 218,
41
41
  "agentCount": 10,
42
42
  "hosts": ["Claude Code", "Codex CLI", "Copilot CLI"],
43
- "toolCount": 118,
43
+ "toolCount": 124,
44
44
  "toolCategories": {
45
45
  "ios": 40,
46
46
  "android": 31,
47
47
  "web": 18,
48
48
  "code": 8,
49
49
  "design": 7,
50
+ "voice": 6,
50
51
  "pass": 4,
51
52
  "context": 3,
52
53
  "agent": 2,
@@ -55,5 +56,5 @@
55
56
  "media": 1
56
57
  },
57
58
  "pluginSkillCount": 245,
58
- "toolkitVersion": "3.15.0"
59
+ "toolkitVersion": "3.16.0"
59
60
  }
@@ -66,6 +66,7 @@ const DEV_ONLY_TOOLING = Object.freeze([
66
66
  "lint-personas.mjs", // imports install/_codex-agents.mjs, which never ships to scripts/
67
67
  "lint-mcp-refs.mjs",
68
68
  "check-md-links.mjs",
69
+ "check-rewrite-overlap.mjs", // compares a rewrite against a source only a maintainer has
69
70
  "gen-ref-toc.mjs", // regenerates the repo's own reference tree; an install has no pipeline/
70
71
  "validate-schemas.mjs", // validates the repo's own schema files, needs ajv
71
72
  "sync-parity-check.sh",
package/manifest.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "20.5.0",
4
- "commit": "2779213750032f49aefb95d894ef3df49e29e325",
5
- "generatedAt": "2026-09-25T17:15:16.484Z",
3
+ "version": "20.6.0",
4
+ "commit": "3fc39a82ab127eef5b3e5004ebc279232a2d020c",
5
+ "generatedAt": "2026-09-26T14:39:59.575Z",
6
6
  "algorithm": "sha256",
7
- "fileCount": 1218,
7
+ "fileCount": 1220,
8
8
  "files": {
9
- "CHANGELOG.md": "40dbfcb1d32886e1930ea869d1ce2a50c24bde5388085e9689e6e1c34c910a95",
10
- "LICENSE": "8d8c51aaf1e1538b180c69965fa038db71be86798cc3e26a143bebfefe16a43e",
9
+ "CHANGELOG.md": "67d8197e2f23337512cca774519c4f111868adbf736f284c2919a49ae684b6c5",
10
+ "LICENSE": "ab7264a940291b3fd39e40133c06772351490f5e33c88e20fd5d8333227abfa0",
11
11
  "README.md": "0613eebb0b38062bd94c44043595d1950b5c9b470874dd8e402c052fd20960cf",
12
12
  "README.tr.md": "0c6b5e6943d6c3d3bf3a7ed99c705fb9a78d15d7722e6252f58404d3f6d3524d",
13
13
  "SECURITY.md": "0eb89553ddcfc56bfe9712315b55a6630ee2f579631aaeccd6ad24f8ac769a0b",
@@ -33,7 +33,7 @@
33
33
  "docs/best-practices.md": "f8b75b0d38b5896a6a852bb30a1edddeb24dab8b5b7b1d9313290e1241c72024",
34
34
  "docs/ecosystem.md": "e6c00f30e95b5e178f77bdfbefd686c8a4164f39c03865a89ab23465d1a37acf",
35
35
  "docs/engineering.md": "550528cd47c7d2c921729c68d0782a00ac8e77a4bdfec479f6749f9c3235d188",
36
- "docs/facts.json": "2ed95fd7398d21ff4d92fce2e0f6442db876bd68c96002d964491a98902282e2",
36
+ "docs/facts.json": "34a26f599d40a37e2bdd7f35b208ddb211c444b854e942adb88132c96f8ecba6",
37
37
  "docs/features.md": "6c9f0efb00c683e6f611c440c87847b187f8b0eee59e9e812f3a241695bc2ae1",
38
38
  "docs/performance.md": "bbf2f3922c25dcba0c1a9e69ef420e3871a729af5b35009a49b48a5d3c5cd151",
39
39
  "docs/recovery-guide.md": "59a8f1b66b34cb9ff5507b3d54dac3b9915e184aa044d9c758d18cac818d510e",
@@ -44,7 +44,7 @@
44
44
  "install/_codex-instructions.mjs": "add75ffc0e8d8db0e3bf275b46017a897a4cadb83dc66c7d3affd73bcead20f8",
45
45
  "install/_common.mjs": "8e7dac0c08e0712b3fec5ce6b8db08ccfb4bd3e36d98d22d9e1df8dcd5371250",
46
46
  "install/_copilot-instructions.mjs": "73e4b82b2af0f3e1669f772fa0788dac6b540c131e56d5fe38dcf29be85cc019",
47
- "install/_dev-only-files.mjs": "8f8327a19fd03f8aaf7c5f739391ea4c97d47a59bb936e9b8cb485a3e59dbfe6",
47
+ "install/_dev-only-files.mjs": "51285901f3b650ff22a76f8c05b23c69b9ceb6fe3da3333cece9e0caf65065a4",
48
48
  "install/_managed-block.mjs": "7328728a7c82744b66218034389fc295d1384311daa5415408afa94be14303d3",
49
49
  "install/_mcp-register.mjs": "56a785984afbba54aedc3677652ca4ee990d98d45b6599a3937cb656cad53b46",
50
50
  "install/_platform-filter.mjs": "1c66acc79da0da0c1aa5a57050989e84198c3153e6437ee03f1d3bacbb12ff2c",
@@ -61,7 +61,7 @@
61
61
  "install/templates/multi-agent-autopilot-awake.plist.template": "ddd25c76c56fcbb38a89de8e35637372d70ace993d6bc487a88ca9cfb702fe1f",
62
62
  "install/templates/multi-agent-autopilot.plist.template": "122c8a61cd927edd99375d6e3da40f1d42bcceed5b6641785c8b28bb930bc77e",
63
63
  "install/unattended-profile-legacy.json": "f52e86b998956f09ad306b1735a6c10b58580d7082d8a55665a4c6bb15c3aa1f",
64
- "package.json": "e8b5ebb47535e622d1e13c31676a183dca1af3af7b089ba3a0e8a90101429e23",
64
+ "package.json": "ad296bb61988074861178c88e988bfef7332fd592e7f6c88d51b25c81a51ae3d",
65
65
  "pipeline/agents/android-architect.md": "11fb74543cb0ebcf9525ca6654fe95d53da7229e0bf4a1149b31fd9005abc453",
66
66
  "pipeline/agents/backend-architect.md": "b02a606c301e4714131aed70ae6d16eeb77fbc819a001e2752cff2488d4246de",
67
67
  "pipeline/agents/bulk-reader.md": "6597052cd7d205bac2b1c7633f5232fe1a40730664a913b8eb77efd5a786dc25",
@@ -167,6 +167,7 @@
167
167
  "pipeline/lib/autopilot-state.sh": "e97d4cb4fce18be8be3485702be0e6d0948209a14f9725cbc83c2721d293432a",
168
168
  "pipeline/lib/channels-multi-repo.sh": "aef26f888bddbaf7fb0e2e86143d612da776b4a4282bcee8144a090a9050b989",
169
169
  "pipeline/lib/classify-intent.sh": "dab0896bc9707a10ac046d0b31d25a48c363cdde76cb1d720bf847deefc0364c",
170
+ "pipeline/lib/confusables.json": "2638872fc47c7a8c087902938d67a88440e855e66ede487ac1c057ff85f1571f",
170
171
  "pipeline/lib/context-link-extractor.sh": "84a154dcabf57136be17199b21d02120d70cc0ca34e8f48fe24bdda74eddda1d",
171
172
  "pipeline/lib/count-lib.sh": "7bafe152b0ffb9ca6fb9296b7ecaaef33deb9410286b3579fefbb37796044f79",
172
173
  "pipeline/lib/credential-inventory.sh": "adff81b265a9cf4f8c9135011686c12963cf5eeeafcb266be54456c803ffde38",
@@ -192,13 +193,14 @@
192
193
  "pipeline/lib/model-dispatch.sh": "7edd5c92d41ee3bf74972e0827ccbb671853d5410e7fb0af0b7cb020f95e05c5",
193
194
  "pipeline/lib/model-rung.sh": "2a5e04af3c6829d4c72e5f518e668a9cc2feffe6f2abb90a68c485f52e81959a",
194
195
  "pipeline/lib/multi-repo-pipeline.sh": "5cca6bcc4fb857448ddadb31add343952be89423a328267e56496c7b7cdc7710",
195
- "pipeline/lib/outbound-gate.mjs": "9cedbb9ce3e99af6bc4a7d3625a4a2ee4ce841c41425b130de58a52dc82c6cfb",
196
+ "pipeline/lib/normalize-text.mjs": "db5f3b6618c679762227083299ec7f7bef8f1bb6b9179f19449b5ff904812c45",
197
+ "pipeline/lib/outbound-gate.mjs": "4e89bf43554af4c727ef110505658159c7a7a3c53d8a4007f0e2e65dab87896f",
196
198
  "pipeline/lib/parse-complaints.sh": "d787ce870f141cf7f881da80fab4320814044554065e82e94632f061675b4d12",
197
199
  "pipeline/lib/phase-schema.mjs": "8fc0c909f129341295cd8ef2ea9e93cecd0ac493c3a8d83e25879f7ff522f1ab",
198
200
  "pipeline/lib/plan-todos.sh": "4992bed180774a27f84892add8b09e0f7b53e9b8fefb75f60325a04d1af18c56",
199
201
  "pipeline/lib/post-pr-review.sh": "c7a8f8c648ac7aebf904e70ae482c026de5b3cc897b376cd8aa52356fda8b805",
200
202
  "pipeline/lib/pr-request-location.mjs": "622ca4705ec8ee00e0a4609657d8ee684fcdc077f3099ab312cf41b5b0c41efd",
201
- "pipeline/lib/redact.mjs": "63dd8a1d5a7f0d6e2dca764b186212e393e59f480c5f0985afe4685c2361f9fc",
203
+ "pipeline/lib/redact.mjs": "9abe7d74a1fa5d9d1ca9742652b485626e0ae86b62a7229c9ac478813b854bcf",
202
204
  "pipeline/lib/regular-file.mjs": "084dd2c98a664a1e4a937757cc5e5e5d7e162b6aa422f8c7cfdb27f7834c6cbc",
203
205
  "pipeline/lib/repo-cache.sh": "82bbdd3d61746042ad5341554a1077098d4b56f15caf6c5cc092377a273c8f45",
204
206
  "pipeline/lib/repo-hygiene.sh": "67f4227d2bc84198d51205f1f16d055e76a07fe11a4e5c2b8103559c44cefa04",
@@ -217,7 +219,7 @@
217
219
  "pipeline/lib/usage-endpoint.mjs": "9aed3a854e729f82563ca7614c9ab8dbb5af3b42fa0f7c10204b0094627b9906",
218
220
  "pipeline/lib/user-facing.mjs": "08d8a1f4f12e36de88673d24167e244862f56671586a5ce26e73f52821996eed",
219
221
  "pipeline/lib/user-facing.sh": "969038aa575a7075cbc8c2857b3e6bddfa5c8ea8a15811ad65c6660735539b76",
220
- "pipeline/lib/vercel-deploy.sh": "7890a86f050611e7743e101303f1263527713019824a15aa4dddc09940bb0a65",
222
+ "pipeline/lib/vercel-deploy.sh": "50d3d8a0b2c60daf90365396f37337d89a60230a94e7bd4c89b19acf1849de99",
221
223
  "pipeline/multi-agent-refs/_account-picker.md": "42715ba746e1fc681a57436d72fc643e3f16de023f0409fee8782b013f7a144a",
222
224
  "pipeline/multi-agent-refs/_dev-context.md": "4d0905cc537c7f8526d3c92cf09bb44d2ab50680470ee0baa2dd5c3b1d0565d5",
223
225
  "pipeline/multi-agent-refs/_input-parser.md": "4cf222b1c4a405bd71521f5290f45408cc8a47913a0a39166c68210104e90cc5",
@@ -427,7 +429,7 @@
427
429
  "pipeline/scripts/_smoke-root.sh": "dad129959a4e37a0cd0f4f6c04f6d66c5a43752b2bf33989db9c002eb106b832",
428
430
  "pipeline/scripts/_stack-adapter.mjs": "a754c042d237cd03abf11c6909099a7a3ea798cdd13d49b03787797fd3496cd7",
429
431
  "pipeline/scripts/_stack-routing.mjs": "d64421b48eb42efffeae01f1d64ec56182269285de62e80434f3b8b05610e2ab",
430
- "pipeline/scripts/agent-guard.py": "a9856912d62d5769d3cd7fcb070007569398a085412cc7b26385caed66471fca",
432
+ "pipeline/scripts/agent-guard.py": "764e7ce3dd448b37cf7b69b03def5d173ab8dedadd2b2dca098e20ca15fd8604",
431
433
  "pipeline/scripts/agent-guard.sh": "5b57cea687754533c673b66a9dd2281ca06d12ef86bdc0d03fa285fc9e7debf8",
432
434
  "pipeline/scripts/aggregate-metrics.mjs": "c324e349993fb154a885263cf6665af0ff656e9c987b455a263dad8b96cca667",
433
435
  "pipeline/scripts/analysis-story-tree.mjs": "8fd371d3e552a3f60533f18c7699074ae6089016c6663512410c4fa02494548f",
@@ -526,8 +528,8 @@
526
528
  "pipeline/scripts/plan-critique-gate.mjs": "b139394e5f16d11e4997d0bb0b08529f439dbd7fad39781ef4c2475247d8fa97",
527
529
  "pipeline/scripts/plugin-refresh.sh": "687a33199ad963616ab537e32210ec8c967e76ecc42f494ee8a1dc9fe947fc30",
528
530
  "pipeline/scripts/pr-request.mjs": "e6f4ded2366c6599bb345b3011ecc771e13b8a1561afa4c9a50bca4edff17456",
529
- "pipeline/scripts/pre-commit-check.sh": "81937ed7c0974fdfe440e19ce31405a38bbdf2b45a9bf226f490268fcf915942",
530
- "pipeline/scripts/pre-push-check.sh": "ff98e73d02aa75cfa9411c29c838241e8529e832e1421ca9c25c39aa07bcd968",
531
+ "pipeline/scripts/pre-commit-check.sh": "a4a091abff1f3a5695227bfca63cd257b09e859f8bd10bfba5700e0c9334e6ea",
532
+ "pipeline/scripts/pre-push-check.sh": "e449aff7f0336d7d624eff4c6221f56d8621ba9d010669601755e57fd4ee02c7",
531
533
  "pipeline/scripts/probe-evidence-capability.sh": "ac74252eed6b773fdbc7ec475a63606925f1ba7b5e7c6f911329f46fd920eae7",
532
534
  "pipeline/scripts/prune-logs.sh": "c5587b33104c7253e07be8cce8249b50014c52dfdf232de637811ffdd88e95e4",
533
535
  "pipeline/scripts/purge.sh": "95c60b272ae47209893dba9b17528538592bb5636da47940a02734cd9b4f0ce5",
@@ -545,7 +547,7 @@
545
547
  "pipeline/scripts/routine-registry.mjs": "eaffbb840cd0ac977d46659cb3bf5c6cbd21a969ee50491cdb4295d10d9d636b",
546
548
  "pipeline/scripts/run-aggregator.mjs": "e58ecdf900aa30ea1625939d72e6d4e26d13ae4e6bb2ee1fd68d99718def27c8",
547
549
  "pipeline/scripts/run-metrics.mjs": "6db53f3814a992c1f23beae7a54c84310be5af8e84dbff5a6768a91169152f02",
548
- "pipeline/scripts/run-ui-tests.sh": "5f1cb05ab0b387f9b89d8f34a0f60182906f0ca27cca4d41926b7217eee86bc5",
550
+ "pipeline/scripts/run-ui-tests.sh": "11331115a4c3fb436f8f35894b7f12a1be317c7cfcd7b7512aeb42b6a9aa21e3",
549
551
  "pipeline/scripts/runs-index.mjs": "7937dd3d6f3c5b7800d853ff06289fd5551413dda4f46b0f3db81483b7280e66",
550
552
  "pipeline/scripts/scaffold-gate.mjs": "e2935957771f1d332b9968a819de49676152c51991e89f4a9cab7467b6d18701",
551
553
  "pipeline/scripts/scan-agent-config.sh": "41dbd995de799f599dc365ea8cfe55542c48c7387c4f94a596a1e6feec278168",
@@ -592,12 +594,12 @@
592
594
  "pipeline/scripts/verify-citations.mjs": "ed1c3d7d0dbebbc64a26524f0f321d3a0ec1318cbb60dd4517d6b3db77e7fd74",
593
595
  "pipeline/scripts/verify-skills.sh": "871fcdfe5567232cd49a4cce4401911a06c8df6ab712d0597e17ce1bf0f63d80",
594
596
  "pipeline/scripts/verify.mjs": "fdc9843dc33b37d08d132617f158b8fe10cc4ab3be5bf31714bf2453ac0f714f",
595
- "pipeline/scripts/website-deploy-commit.sh": "523482438b265ca1f1883ec5fb450e803566a1239eaccdc423e12c5426140a84",
597
+ "pipeline/scripts/website-deploy-commit.sh": "73cae49db83cf98eeeb08eb8852a13912bd5f05855319b184e348b9ca4dff44f",
596
598
  "pipeline/scripts/worktree-finalize.sh": "ca2bb363bec738cfce57ce5f46962d69664810003cc56dde0a82ab9133dbf557",
597
599
  "pipeline/scripts/worktree-prepare.sh": "9391c828375b127f77c86e3c830dbbaf355a61464c6437319969f2f1eecc72b1",
598
600
  "pipeline/scripts/worktrees.mjs": "90dfd09b70fffe9b56c331a4ae2692cc248f4898f4ee6aee4239ef0b20888a90",
599
601
  "pipeline/scripts/write-state.mjs": "e1198bf970eace07d3eff22b394808a526e473df65d3d58aa2d5a12cfdac8b49",
600
- "pipeline/skills/.skill-manifest.json": "04ce3e26081d98d092e0795f82aace9f6717bcff749a60b9573833343e8608be",
602
+ "pipeline/skills/.skill-manifest.json": "94bbe3de5468d7277f6268a570c82af7d8295166e2d9c27676839646c4faff77",
601
603
  "pipeline/skills/.skills-index.json": "042b1090f3e2ca310c6ce70d1bb304e8c7f96bd73133e504b95d83801608e470",
602
604
  "pipeline/skills/shared/README.md": "98196ea7c9d84637e8917ca4053c34b45de46586f71fb0c0034dce0808af857d",
603
605
  "pipeline/skills/shared/core/apple-archive-compliance/SKILL.md": "7239fef717ea45b79276bfcc2c3903acd57950b6e00dd6a2a66399c4b09cbd51",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmerterden/multi-agent-pipeline",
3
- "version": "20.5.0",
3
+ "version": "20.6.0",
4
4
  "description": "6-phase AI development pipeline with full orchestration on Claude Code, Copilot CLI and Codex CLI. Analysis, planning, TDD, CLI-aware parallel review with consensus surfacing + Fable triage, default-FAIL evidence gates, secret + intent guards, per-phase cost ledger, persistent learnings memory, wiki generation, commit automation. Token-preserving uninstall.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "start": "node index.js",
23
- "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
23
+ "test": "npm run format:check && npm run lint && node --test test/*.test.mjs && node pipeline/scripts/run-smokes.mjs && node pipeline/scripts/lint-skills.mjs && node pipeline/scripts/lint-personas.mjs && node pipeline/scripts/lint-mcp-refs.mjs && node pipeline/scripts/eval-triage.mjs && node pipeline/scripts/eval-golden-tasks.mjs && node pipeline/scripts/eval-intent.mjs && node pipeline/scripts/eval-recall.mjs && node pipeline/scripts/eval-guard-robustness.mjs && node pipeline/scripts/validate-schemas.mjs && node pipeline/scripts/validate-prefs.mjs && node pipeline/scripts/scorecard.mjs",
24
24
  "test:unit": "node --test test/*.test.mjs",
25
25
  "test:smoke": "node pipeline/scripts/run-smokes.mjs",
26
26
  "lint:skills": "node pipeline/scripts/lint-skills.mjs",
@@ -55,7 +55,7 @@
55
55
  "codex-cli"
56
56
  ],
57
57
  "author": "Mert Erden",
58
- "license": "MIT",
58
+ "license": "MIT AND LicenseRef-PolyForm-Perimeter-1.0.0",
59
59
  "publishConfig": {
60
60
  "registry": "https://registry.npmjs.org",
61
61
  "access": "public"
@@ -93,6 +93,7 @@
93
93
  "!pipeline/scripts/smoke-*.sh",
94
94
  "!pipeline/scripts/run-smokes.mjs",
95
95
  "!pipeline/scripts/eval-*.mjs",
96
+ "!pipeline/scripts/check-rewrite-overlap.mjs",
96
97
  "!pipeline/scripts/scorecard.mjs",
97
98
  "!pipeline/scripts/lint-skills.mjs",
98
99
  "!pipeline/scripts/lint-personas.mjs",
@@ -0,0 +1,100 @@
1
+ {
2
+ "_readme": "Characters a guard folds before matching, applied after NFKC. `strip` lists invisible characters that are removed. `map` sends a character that renders like an ASCII one to that ASCII character: the dash family, and the Cyrillic and Greek letters whose glyphs match Latin ones. Chosen from the Unicode confusables data for the letters and punctuation the guard rules use. Read by lib/normalize-text.mjs and scripts/agent-guard.py.",
3
+ "strip": [
4
+ "\u00ad",
5
+ "\u034f",
6
+ "\u061c",
7
+ "\u115f",
8
+ "\u1160",
9
+ "\u17b4",
10
+ "\u17b5",
11
+ "\u180e",
12
+ "\u200b",
13
+ "\u200c",
14
+ "\u200d",
15
+ "\u200e",
16
+ "\u200f",
17
+ "\u202a",
18
+ "\u202b",
19
+ "\u202c",
20
+ "\u202d",
21
+ "\u202e",
22
+ "\u2060",
23
+ "\u2061",
24
+ "\u2062",
25
+ "\u2063",
26
+ "\u2064",
27
+ "\u2066",
28
+ "\u2067",
29
+ "\u2068",
30
+ "\u2069",
31
+ "\u3164",
32
+ "\ufeff",
33
+ "\uffa0"
34
+ ],
35
+ "map": {
36
+ "\u2010": "-",
37
+ "\u2011": "-",
38
+ "\u2012": "-",
39
+ "\u2013": "-",
40
+ "\u2014": "-",
41
+ "\u2015": "-",
42
+ "\u2043": "-",
43
+ "\u2212": "-",
44
+ "\ufe58": "-",
45
+ "\ufe63": "-",
46
+ "\u02d0": ":",
47
+ "\u0589": ":",
48
+ "\u05c3": ":",
49
+ "\u2236": ":",
50
+ "\ua789": ":",
51
+ "\u0410": "A",
52
+ "\u0412": "B",
53
+ "\u0421": "C",
54
+ "\u0415": "E",
55
+ "\u041d": "H",
56
+ "\u0406": "I",
57
+ "\u0408": "J",
58
+ "\u041a": "K",
59
+ "\u041c": "M",
60
+ "\u041e": "O",
61
+ "\u0420": "P",
62
+ "\u0405": "S",
63
+ "\u0422": "T",
64
+ "\u0425": "X",
65
+ "\u0423": "Y",
66
+ "\u0430": "a",
67
+ "\u0441": "c",
68
+ "\u0435": "e",
69
+ "\u0456": "i",
70
+ "\u0458": "j",
71
+ "\u043e": "o",
72
+ "\u0440": "p",
73
+ "\u0455": "s",
74
+ "\u0445": "x",
75
+ "\u0443": "y",
76
+ "\u04bb": "h",
77
+ "\u0501": "d",
78
+ "\u051b": "q",
79
+ "\u051d": "w",
80
+ "\u0391": "A",
81
+ "\u0392": "B",
82
+ "\u0395": "E",
83
+ "\u0396": "Z",
84
+ "\u0397": "H",
85
+ "\u0399": "I",
86
+ "\u039a": "K",
87
+ "\u039c": "M",
88
+ "\u039d": "N",
89
+ "\u039f": "O",
90
+ "\u03a1": "P",
91
+ "\u03a4": "T",
92
+ "\u03a5": "Y",
93
+ "\u03a7": "X",
94
+ "\u03bf": "o",
95
+ "\u03b1": "a",
96
+ "\u03bd": "v",
97
+ "\u03c1": "p",
98
+ "\u0131": "i"
99
+ }
100
+ }
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * normalize-text.mjs - fold look-alike text to the form a guard matches on.
4
+ *
5
+ * NFKC, then the invisible characters in confusables.json removed and its
6
+ * look-alike letters and punctuation mapped to ASCII. A guard matches on the
7
+ * raw text and on this folded copy, so a trailer or a token written with a
8
+ * zero-width space or a Cyrillic letter is still recognised. The folded copy is
9
+ * only ever matched against; nothing written or shown uses it.
10
+ *
11
+ * As a CLI it reads stdin and writes the folded text to stdout.
12
+ *
13
+ * @module pipeline/lib/normalize-text
14
+ */
15
+
16
+ import { readFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { invokedDirectly } from "./invoked-directly.mjs";
19
+
20
+ const TABLE = JSON.parse(readFileSync(join(import.meta.dirname, "confusables.json"), "utf8"));
21
+ const STRIP = new Set(TABLE.strip);
22
+ const MAP = new Map(Object.entries(TABLE.map));
23
+
24
+ /** @param {string} text */
25
+ export function normalizeForMatch(text) {
26
+ if (typeof text !== "string" || !text) return text;
27
+ let out = "";
28
+ for (const ch of text.normalize("NFKC")) {
29
+ if (STRIP.has(ch)) continue;
30
+ out += MAP.get(ch) ?? ch;
31
+ }
32
+ return out;
33
+ }
34
+
35
+ /** The text with its invisible characters removed and nothing else changed. */
36
+ export function stripInvisible(text) {
37
+ if (typeof text !== "string" || !text) return text;
38
+ let out = "";
39
+ for (const ch of text) if (!STRIP.has(ch)) out += ch;
40
+ return out;
41
+ }
42
+
43
+ if (invokedDirectly(import.meta.url)) {
44
+ let input = "";
45
+ process.stdin.setEncoding("utf8");
46
+ process.stdin.on("data", (d) => (input += d));
47
+ process.stdin.on("end", () => process.stdout.write(normalizeForMatch(input)));
48
+ }
@@ -49,6 +49,7 @@
49
49
 
50
50
  import { readFileSync } from "node:fs";
51
51
  import { invokedDirectly } from "./invoked-directly.mjs";
52
+ import { normalizeForMatch } from "./normalize-text.mjs";
52
53
 
53
54
  /**
54
55
  * Each rule is a SHAPE, not a dictionary. A list of known token prefixes goes
@@ -155,11 +156,19 @@ export function scan(text) {
155
156
  const findings = [];
156
157
  const lines = String(text).split("\n");
157
158
  for (let i = 0; i < lines.length; i++) {
159
+ // A credential written with an invisible character or a look-alike letter
160
+ // still matches: the certain rules also run on the folded line.
161
+ const folded = normalizeForMatch(lines[i]);
158
162
  for (const rule of RULES) {
159
163
  // `lastIndex` survives between calls on a /g regex and would make every
160
164
  // second line skip its start. Reset per line.
161
165
  rule.re.lastIndex = 0;
162
- if (rule.re.test(lines[i])) {
166
+ let hit = rule.re.test(lines[i]);
167
+ if (!hit && rule.certain && folded !== lines[i]) {
168
+ rule.re.lastIndex = 0;
169
+ hit = rule.re.test(folded);
170
+ }
171
+ if (hit) {
163
172
  findings.push({ rule: rule.name, certain: rule.certain, line: i + 1 });
164
173
  }
165
174
  }
@@ -15,13 +15,16 @@
15
15
 
16
16
  import { RULES } from "./outbound-gate.mjs";
17
17
  import { invokedDirectly } from "./invoked-directly.mjs";
18
+ import { stripInvisible } from "./normalize-text.mjs";
18
19
 
19
20
  const CERTAIN = RULES.filter((r) => r.certain);
20
21
 
21
22
  /** @param {string} text */
22
23
  export function redact(text) {
23
24
  if (typeof text !== "string" || !text) return text;
24
- let out = text;
25
+ // Invisible characters are removed first: they change nothing a reader sees,
26
+ // and a token split by one would otherwise slip past every rule.
27
+ let out = stripInvisible(text);
25
28
  for (const rule of CERTAIN) {
26
29
  const re = new RegExp(
27
30
  rule.re.source,
@@ -2,11 +2,9 @@
2
2
  #
3
3
  # vercel-deploy.sh - safe wrapper around `vercel` CLI.
4
4
  #
5
- # Why this exists (v8.0.0): on 2026-04-27 a `vercel deploy --token=...`
6
- # invocation failed during the v7.9.1 release flow. The CLI's error retry
7
- # hint printed the failed argv verbatim, including `--token=vcp_...`. That
8
- # leaked the deploy token into the conversation transcript and forced a
9
- # token rotation. See `feedback_vercel_cli_token_leak.md`.
5
+ # Why this exists: when a `vercel` call fails, the CLI's retry hint prints the
6
+ # failed argv verbatim. A token passed as `--token=vcp_...` therefore reaches the
7
+ # terminal, any log, and the conversation transcript, and has to be rotated.
10
8
  #
11
9
  # This wrapper guarantees three properties for every Vercel call:
12
10
  # 1. Tokens are passed via VERCEL_TOKEN env var, NEVER via --token=... argv.
@@ -186,8 +184,8 @@ cmd_deploy() {
186
184
  done
187
185
 
188
186
  # Run the CLI with token via env. Pipe stdout AND stderr through the redact
189
- # filter so even if the CLI dumps argv on failure (it does - that was the
190
- # original v7.9.1 leak), tokens never reach the user's screen or transcript.
187
+ # filter so even if the CLI dumps argv on failure (its retry hint does),
188
+ # tokens never reach the user's screen or transcript.
191
189
  set +e
192
190
  ( cd "$cwd" && VERCEL_TOKEN="$VERCEL_TOKEN" vercel "${cmd_args[@]}" 2>&1 ) \
193
191
  | redact_filter
@@ -20,6 +20,7 @@ import re
20
20
  import shlex
21
21
  import signal
22
22
  import sys
23
+ import unicodedata
23
24
 
24
25
  # An internal deadline well under the hook timeout: if the guard cannot decide
25
26
  # in this long, it prints the blocking verdict when unattended (fail-closed) and
@@ -48,6 +49,34 @@ ATTRIBUTION = re.compile(
48
49
  r"|claude code <",
49
50
  re.IGNORECASE,
50
51
  )
52
+
53
+
54
+ def _load_confusables():
55
+ here = os.path.dirname(os.path.abspath(__file__))
56
+ try:
57
+ with open(os.path.join(here, "..", "lib", "confusables.json"), encoding="utf-8") as fh:
58
+ table = json.load(fh)
59
+ return set(table.get("strip", [])), dict(table.get("map", {}))
60
+ except (OSError, ValueError):
61
+ return set(), {}
62
+
63
+
64
+ _STRIP, _MAP = _load_confusables()
65
+
66
+
67
+ def normalize_for_match(text: str) -> str:
68
+ """NFKC, invisible characters removed, look-alike letters mapped to ASCII.
69
+
70
+ The same fold lib/normalize-text.mjs applies, from the same table. Rules
71
+ that match text content run on the raw command and on this copy, so a
72
+ trailer written with a zero-width space or a Cyrillic letter still matches.
73
+ Without the table only NFKC applies.
74
+ """
75
+ return "".join(
76
+ _MAP.get(ch, ch) for ch in unicodedata.normalize("NFKC", text) if ch not in _STRIP
77
+ )
78
+
79
+
51
80
  # Force flags, and ONLY force flags.
52
81
  #
53
82
  # The previous pattern ended in `-\S*f\S*`, which matches any flag containing an
@@ -228,7 +257,7 @@ def decide(cmd: str, depth: int = 0) -> str:
228
257
  commits = re.search(r"git\s+commit", low) or any(
229
258
  git_subcommand(toks) == "commit" for _, toks in segs
230
259
  )
231
- if commits and ATTRIBUTION.search(cmd):
260
+ if commits and (ATTRIBUTION.search(cmd) or ATTRIBUTION.search(normalize_for_match(cmd))):
232
261
  return "BLOCK_ATTRIB"
233
262
 
234
263
  # Rule 2: force-push to a protected branch. Evaluate each push segment on its
@@ -174,6 +174,19 @@ scan_file() {
174
174
 
175
175
  [ -z "$content" ] && return 0
176
176
 
177
+ # A token written with an invisible character or a look-alike letter: scan a
178
+ # folded copy too. Only non-ASCII content pays for the node call.
179
+ if printf '%s' "$content" | LC_ALL=C grep -q '[^ -~[:space:]]' 2>/dev/null \
180
+ && command -v node >/dev/null 2>&1 \
181
+ && [ -f "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" ]; then
182
+ local folded
183
+ folded=$(printf '%s' "$content" | node "$PRE_COMMIT_DIR/../lib/normalize-text.mjs" 2>/dev/null) || folded=""
184
+ if [ -n "$folded" ] && [ "$folded" != "$content" ]; then
185
+ content="$content
186
+ $folded"
187
+ fi
188
+ fi
189
+
177
190
  # API keys / tokens (key = "long_string" pattern)
178
191
  if echo "$content" | grep -qiE '(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token|secret[_-]?key)\s*[:=]\s*["'"'"'][A-Za-z0-9+/=_-]{20,}'; then
179
192
  echo "BLOCKED: Possible API key/token in $file" >&2
@@ -261,28 +274,58 @@ scan_file() {
261
274
  # repo-root-relative, so the full path can be anchored.
262
275
  if ! echo "$file" | grep -qiE '(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Podfile\.lock|Cartfile\.resolved|Package\.resolved|\.lock$|\.map$|__snapshots__|\.snap$|\.min\.(js|css)$)' \
263
276
  && ! echo "$file" | grep -qE '^pipeline/scripts/smoke-pre-commit\.sh$'; then
264
- candidates=$(echo "$content" | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
277
+ # Staged and working-tree content arrives as diff lines, so the leading "+"
278
+ # is stripped first: it is in the candidate class and would otherwise glue
279
+ # onto a token that starts at column 0 and defeat every shape check below.
280
+ candidates=$(echo "$content" | sed 's/^+//' | grep -oE '[A-Za-z0-9+/=_-]{40,}' || true)
265
281
  if [ -n "$candidates" ]; then
266
282
  entropy_hit=$(printf '%s\n' "$candidates" | awk '
283
+ # Word-shaped identifier: letters and _ or - only, split into segments at
284
+ # separators and case boundaries (UIApplication -> UI, Application), where
285
+ # the segments read like words: average length 4+, none longer than 24,
286
+ # and at least 70 percent carrying a vowel. Random letters-only tokens
287
+ # flip case every one or two characters, so their segments average about
288
+ # two characters with few vowels; a long lowercase run is one oversized
289
+ # segment. Neither shape passes.
290
+ function wordlike(t, n, i, c, p, nx, seg, nseg, total, maxlen, voweled, split_here) {
291
+ if (t !~ /^[A-Za-z_-]+$/) return 0;
292
+ n = length(t); seg = ""; nseg = 0; total = 0; maxlen = 0; voweled = 0;
293
+ for (i = 1; i <= n + 1; i++) {
294
+ c = (i <= n) ? substr(t, i, 1) : "_";
295
+ split_here = 0;
296
+ if (c == "_" || c == "-") split_here = 1;
297
+ else if (seg != "" && c ~ /[A-Z]/) {
298
+ p = substr(seg, length(seg), 1);
299
+ nx = (i < n) ? substr(t, i + 1, 1) : "";
300
+ if (p ~ /[a-z]/ || (nx ~ /[a-z]/ && length(seg) > 1)) split_here = 1;
301
+ }
302
+ if (split_here && seg != "") {
303
+ nseg++; total += length(seg);
304
+ if (length(seg) > maxlen) maxlen = length(seg);
305
+ if (tolower(seg) ~ /[aeiouy]/) voweled++;
306
+ seg = "";
307
+ }
308
+ if (c != "_" && c != "-") seg = seg c;
309
+ }
310
+ if (nseg == 0) return 0;
311
+ return (total / nseg >= 4 && maxlen <= 24 && voweled * 10 >= nseg * 7);
312
+ }
267
313
  {
268
314
  s=$0;
269
315
  # Scoring the joined string measures the variety of a whole SENTENCE
270
316
  # rather than of any token in it, so an ordinary doc path like
271
- # "claude/commands/multi-agent/review-jira/SKILL" scores 4.35 and was
272
- # blocked as a possible credential. 47 of the 400 committed markdown
273
- # files in this repo carried such a path, so each was un-editable - and
274
- # the gate only fired once such a file was staged, which is why it
275
- # stayed invisible.
317
+ # "claude/commands/multi-agent/review-jira/SKILL" scores 4.35 and would
318
+ # be blocked as a possible credential, making every doc that cites
319
+ # such a path un-editable.
276
320
  #
277
- # The reduction is applied ONLY to path-shaped candidates. An earlier
278
- # version of this fix scored the longest slash-free segment for every
279
- # candidate containing a slash, which structurally exempted any secret
280
- # with a slash in it: the canonical 40-char AWS documentation-example
281
- # secret access key (two slashes, full-match H=4.663, blocked before)
282
- # reduced to an 18-char segment and sailed through, and roughly half
283
- # of `openssl rand -base64 32` outputs contain a "/". It is not
284
- # reproduced here on purpose - this gate flags credential-shaped
285
- # strings in comments too, and it is right to.
321
+ # The reduction is applied ONLY to path-shaped candidates. Scoring the
322
+ # longest slash-free segment for every candidate containing a slash
323
+ # would structurally exempt any secret with a slash in it: the
324
+ # canonical 40-char AWS documentation-example secret access key (two
325
+ # slashes, full-match H=4.663) reduces to an 18-char segment, and
326
+ # roughly half of `openssl rand -base64 32` outputs contain a "/". The
327
+ # key is not reproduced here on purpose - this gate flags
328
+ # credential-shaped strings in comments too, and it is right to.
286
329
  # No other check here catches that key: the key-name regex above does
287
330
  # not match "secret_access_key", and the AKIA pattern only covers the
288
331
  # key ID, not the secret.
@@ -299,7 +342,7 @@ scan_file() {
299
342
  if (s ~ /^[0-9]+$/) next; # pure digits
300
343
  if (s ~ /^[0-9a-f]+$/) next; # lowercase hex (git sha / md5 / sha-*)
301
344
  if (s ~ /^[0-9A-F]+$/) next; # uppercase hex
302
- if (s ~ /^([A-Z][a-z]+)+[A-Z]*$/) next; # CamelCase words only (label key / type name), never a credential
345
+ if (wordlike(s)) next; # identifier made of words (plist key / type name), never a credential
303
346
  delete freq;
304
347
  for (i = 1; i <= n; i++) { c = substr(s, i, 1); freq[c]++ }
305
348
  H = 0;
@@ -21,9 +21,9 @@
21
21
  #
22
22
  # Two modes, because a gate that runs INSIDE the push is a gate that breaks
23
23
  # pushes. Git opens the connection to the remote before this hook fires, so a
24
- # six-minute run idles that connection until the server drops it: the first two
25
- # attempts to push the v15.12.0 rename died on a broken pipe with every gate
26
- # green. The work has to happen outside the network window.
24
+ # six-minute run idles that connection until the server drops it, and the push
25
+ # dies on a broken pipe with every gate green. The work has to happen outside
26
+ # the network window.
27
27
  #
28
28
  # pre-push-check.sh --run run every gate and stamp the tree as green
29
29
  # pre-push-check.sh verify the stamp only; refuse the push if absent
@@ -277,11 +277,11 @@ EOF
277
277
  fi
278
278
  }
279
279
 
280
- # Web had no arm here at all until v17.1.0, which meant a repo with a full
281
- # Playwright suite reported "no UI test target" and every downstream consumer
282
- # believed it: the evidence probe closed tier 1, the PR body said UI tests were
283
- # not run, and the reason it gave was true of the runner rather than of the repo.
284
- # That is the failure mode this file exists to avoid, and it was shipped for web.
280
+ # Without a web arm, a repo with a full Playwright suite reports "no UI test
281
+ # target" and every downstream consumer believes it: the evidence probe closes
282
+ # tier 1, the PR body says UI tests were not run, and the reason it gives is
283
+ # true of the runner rather than of the repo. That is the failure mode this file
284
+ # exists to avoid.
285
285
  web_spec_files() {
286
286
  find "$REPO" \
287
287
  -name ".*" -type d -prune -o \
@@ -7,8 +7,9 @@
7
7
  # refuses to build a commit whose author is not a contributor on the project. The
8
8
  # push still succeeds, the deployment is created and sits at UNKNOWN with a 0ms
9
9
  # build (API `readyState: BLOCKED`), and the live site keeps serving the previous
10
- # version. v16.4.0 and v16.5.0 were both pushed that way and neither was built,
11
- # while the sync reported the website as synced both times.
10
+ # version. A successful push is therefore not evidence of a deploy, so the script
11
+ # pins the author before committing and reads the deployment state back after
12
+ # pushing instead of reporting the website as synced on the push alone.
12
13
  #
13
14
  # Usage:
14
15
  # website-deploy-commit.sh <identity-name> <identity-email> <version> [repo-dir]
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
- "generatedAt": "2026-09-25T16:25:54Z",
3
+ "generatedAt": "2026-09-26T13:39:28Z",
4
4
  "skillCount": 218,
5
5
  "entries": [
6
6
  {