@reddoorla/maintenance 0.95.0 → 0.96.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 (272) hide show
  1. package/README.md +37 -2
  2. package/dist/airtable-SJV4ZSMS.js +9 -0
  3. package/dist/{announce-PGT4SZZB.js → announce-45HVP4J4.js} +7 -7
  4. package/dist/{blux-4KNXUP2K.js → blux-RUFMNOUJ.js} +5 -5
  5. package/dist/{bump-deps-C5G2EFBB.js → bump-deps-EL3TKMIL.js} +6 -6
  6. package/dist/{chunk-XN37VTDB.js → chunk-37B4LVB3.js} +2 -2
  7. package/dist/{chunk-BVEDKMNG.js → chunk-3EA43S3R.js} +28 -10
  8. package/dist/chunk-3EA43S3R.js.map +1 -0
  9. package/dist/{chunk-XHHMM5HX.js → chunk-43SW3FGW.js} +63 -1
  10. package/dist/chunk-43SW3FGW.js.map +1 -0
  11. package/dist/{chunk-WMBLR5VA.js → chunk-44SDGPGQ.js} +3 -3
  12. package/dist/chunk-4UTJ4G6P.js +52 -0
  13. package/dist/chunk-4UTJ4G6P.js.map +1 -0
  14. package/dist/{chunk-R53VL3RI.js → chunk-5CJVUPJU.js} +1 -1
  15. package/dist/{chunk-R53VL3RI.js.map → chunk-5CJVUPJU.js.map} +1 -1
  16. package/dist/{chunk-4AIYWWKV.js → chunk-62YWOFVN.js} +36 -5
  17. package/dist/chunk-62YWOFVN.js.map +1 -0
  18. package/dist/{chunk-22GKJO5F.js → chunk-6INOBNRO.js} +2 -2
  19. package/dist/{chunk-BYGPUUCT.js → chunk-72FJZ6LO.js} +43 -11
  20. package/dist/chunk-72FJZ6LO.js.map +1 -0
  21. package/dist/{chunk-5GUJ36TL.js → chunk-74QRWE6G.js} +2 -2
  22. package/dist/chunk-A5QKNOXW.js +72 -0
  23. package/dist/chunk-A5QKNOXW.js.map +1 -0
  24. package/dist/{chunk-2NQUCS7O.js → chunk-BIYQN7FH.js} +3 -1
  25. package/dist/{chunk-2NQUCS7O.js.map → chunk-BIYQN7FH.js.map} +1 -1
  26. package/dist/{chunk-S7RYFUIK.js → chunk-CA6ADGOC.js} +2 -2
  27. package/dist/chunk-CGQZFQAY.js +38 -0
  28. package/dist/chunk-CGQZFQAY.js.map +1 -0
  29. package/dist/{chunk-YLYXW5PH.js → chunk-CXICCLNM.js} +34 -4
  30. package/dist/chunk-CXICCLNM.js.map +1 -0
  31. package/dist/{chunk-KZRR4NFG.js → chunk-D3CTOCUR.js} +3 -3
  32. package/dist/{chunk-CWXTNPNZ.js → chunk-DVJNYPFR.js} +3 -3
  33. package/dist/chunk-DVJNYPFR.js.map +1 -0
  34. package/dist/{chunk-AVEQUR44.js → chunk-EASYCD6F.js} +60 -17
  35. package/dist/chunk-EASYCD6F.js.map +1 -0
  36. package/dist/{chunk-BCKEP5S6.js → chunk-ECDJDHKT.js} +2 -2
  37. package/dist/{chunk-DMPHP7UP.js → chunk-EQQ4H6L3.js} +2 -2
  38. package/dist/{chunk-WVNDEYXL.js → chunk-GEUC5VGM.js} +11 -5
  39. package/dist/chunk-GEUC5VGM.js.map +1 -0
  40. package/dist/{chunk-Z2K4JPQ3.js → chunk-H445EH33.js} +2 -2
  41. package/dist/chunk-IHHAYXKY.js +351 -0
  42. package/dist/chunk-IHHAYXKY.js.map +1 -0
  43. package/dist/{chunk-FUWFLAWQ.js → chunk-IMYL67JV.js} +3 -3
  44. package/dist/{chunk-3Z6X3BHU.js → chunk-KN52PM63.js} +7 -2
  45. package/dist/{chunk-3Z6X3BHU.js.map → chunk-KN52PM63.js.map} +1 -1
  46. package/dist/{chunk-WJMPCVOR.js → chunk-LAJ3FF54.js} +12 -4
  47. package/dist/chunk-LAJ3FF54.js.map +1 -0
  48. package/dist/{chunk-5UINJNAJ.js → chunk-MZZDMBDT.js} +16 -3
  49. package/dist/chunk-MZZDMBDT.js.map +1 -0
  50. package/dist/{chunk-DFKBTBO3.js → chunk-NVLREB5R.js} +35 -6
  51. package/dist/chunk-NVLREB5R.js.map +1 -0
  52. package/dist/{chunk-AEUABPAJ.js → chunk-OTEVKRAH.js} +3 -3
  53. package/dist/{chunk-KQB2BX4V.js → chunk-P4A6D6HO.js} +4 -4
  54. package/dist/chunk-P7323YVW.js +134 -0
  55. package/dist/chunk-P7323YVW.js.map +1 -0
  56. package/dist/{chunk-4763KQ6O.js → chunk-PXRN2MER.js} +2 -2
  57. package/dist/{chunk-TR3HFH6J.js → chunk-QFSL2L2B.js} +67 -10
  58. package/dist/chunk-QFSL2L2B.js.map +1 -0
  59. package/dist/{chunk-2LYOAB2E.js → chunk-RRE74VWI.js} +2 -2
  60. package/dist/{chunk-LM2NHRCJ.js → chunk-SHGIDTYF.js} +4 -4
  61. package/dist/chunk-SHGIDTYF.js.map +1 -0
  62. package/dist/{chunk-ZQV7UQNE.js → chunk-SQ4FSNYL.js} +2 -2
  63. package/dist/{chunk-OCNSIBMP.js → chunk-T76MA2FH.js} +135 -1
  64. package/dist/{chunk-OCNSIBMP.js.map → chunk-T76MA2FH.js.map} +1 -1
  65. package/dist/{chunk-6R56OKJZ.js → chunk-UIITN37J.js} +2 -2
  66. package/dist/{chunk-CJ2RMTT7.js → chunk-VEIIUCYL.js} +2 -2
  67. package/dist/{chunk-SVAAONYB.js → chunk-WQLNUEZL.js} +12 -2
  68. package/dist/{chunk-SVAAONYB.js.map → chunk-WQLNUEZL.js.map} +1 -1
  69. package/dist/{chunk-WSJ7RO54.js → chunk-XED2DNAI.js} +23 -5
  70. package/dist/chunk-XED2DNAI.js.map +1 -0
  71. package/dist/{chunk-FSGICFXB.js → chunk-XG2A6GIG.js} +17 -5
  72. package/dist/chunk-XG2A6GIG.js.map +1 -0
  73. package/dist/{chunk-TO7DJYLZ.js → chunk-XY7YV2YQ.js} +178 -39
  74. package/dist/chunk-XY7YV2YQ.js.map +1 -0
  75. package/dist/{chunk-HTABUCF4.js → chunk-Y5BVR4HW.js} +3 -3
  76. package/dist/chunk-Y6FCXRIC.js +3972 -0
  77. package/dist/chunk-Y6FCXRIC.js.map +1 -0
  78. package/dist/{chunk-2SLED3NB.js → chunk-YDZ7XGPZ.js} +1 -1
  79. package/dist/{chunk-2SLED3NB.js.map → chunk-YDZ7XGPZ.js.map} +1 -1
  80. package/dist/{chunk-QF6IQQFP.js → chunk-Z7FDJ35L.js} +18 -12
  81. package/dist/chunk-Z7FDJ35L.js.map +1 -0
  82. package/dist/{chunk-5EEMTJMT.js → chunk-ZH5LOQET.js} +4 -4
  83. package/dist/cli/bin.js +49 -31
  84. package/dist/cli/bin.js.map +1 -1
  85. package/dist/cli/commands/audit.js +18 -18
  86. package/dist/client-L7NAUG6J.js +11 -0
  87. package/dist/configs/eslint.js +27 -1
  88. package/dist/configs/eslint.js.map +1 -1
  89. package/dist/configs/playwright-a11y.d.ts +11 -1
  90. package/dist/configs/playwright-a11y.js +3 -1
  91. package/dist/{convert-to-pnpm-YTFREUJK.js → convert-to-pnpm-4DON7IFS.js} +6 -6
  92. package/dist/{db-BBEM7CBX.js → db-5K4CDEGJ.js} +45 -20
  93. package/dist/db-5K4CDEGJ.js.map +1 -0
  94. package/dist/deadletter-G4M3HSVM.js +17 -0
  95. package/dist/{digest-65BOZ7AT.js → digest-5AJGWS33.js} +45 -22
  96. package/dist/digest-5AJGWS33.js.map +1 -0
  97. package/dist/{digest-collectors-EHFY2OQC.js → digest-collectors-MC5NWEUA.js} +12 -10
  98. package/dist/{digest-state-3KHZF7GD.js → digest-state-ZDPKR7MW.js} +18 -2
  99. package/dist/digest-state-ZDPKR7MW.js.map +1 -0
  100. package/dist/{ensure-site-WIDO6LGU.js → ensure-site-XESZHZEB.js} +39 -11
  101. package/dist/ensure-site-XESZHZEB.js.map +1 -0
  102. package/dist/{fleet-state-UEVZ3X3J.js → fleet-state-GJWGKTLD.js} +6 -4
  103. package/dist/{forms-notify-target-2LWF5C5B.js → forms-notify-target-4NPLX2UG.js} +5 -5
  104. package/dist/{freeze-A6MUCZU4.js → freeze-GUQ43EAU.js} +2 -2
  105. package/dist/{github-signals-GAHNOAHS.js → github-signals-DMU74SAX.js} +10 -10
  106. package/dist/{header-image-YN3AQRY3.js → header-image-BRTOLHJF.js} +6 -5
  107. package/dist/header-image-BRTOLHJF.js.map +1 -0
  108. package/dist/{health-endpoint-KVCJYWET.js → health-endpoint-UCLMSSCN.js} +8 -7
  109. package/dist/{health-endpoint-KVCJYWET.js.map → health-endpoint-UCLMSSCN.js.map} +1 -1
  110. package/dist/{health-mirror-EB76EERV.js → health-mirror-ONRT4DQ5.js} +7 -7
  111. package/dist/{import-airtable-TAKSN4NV.js → import-airtable-IIK2QUUF.js} +3 -3
  112. package/dist/index.d.ts +135 -4
  113. package/dist/index.js +68 -34
  114. package/dist/index.js.map +1 -1
  115. package/dist/{init-FFE4FCO2.js → init-3LMZTCVD.js} +19 -18
  116. package/dist/{init-FFE4FCO2.js.map → init-3LMZTCVD.js.map} +1 -1
  117. package/dist/{launch-4RVWPZGI.js → launch-XTDMC6LD.js} +209 -61
  118. package/dist/launch-XTDMC6LD.js.map +1 -0
  119. package/dist/{lighthouse-VHI77PFB.js → lighthouse-52WSPZ5D.js} +2 -2
  120. package/dist/{lighthouse-airtable-YMH4UA7R.js → lighthouse-airtable-3NOFB42B.js} +3 -3
  121. package/dist/{match-harness-2Q6ISEES.js → match-harness-OW45CB5H.js} +14 -7
  122. package/dist/match-harness-OW45CB5H.js.map +1 -0
  123. package/dist/migrate-NE3DUMTM.js +7 -0
  124. package/dist/{notify-IISTHNBR.js → notify-K3WVH3DM.js} +3 -3
  125. package/dist/{onboard-TM2KESGF.js → onboard-CZYBWVWH.js} +6 -6
  126. package/dist/{orchestrate-MWXENQOS.js → orchestrate-TGDFGY52.js} +8 -8
  127. package/dist/{parity-CAJ477PS.js → parity-AEKYSLN6.js} +5 -5
  128. package/dist/{pipeline-YROAY6DT.js → pipeline-UUPXOS2J.js} +3 -3
  129. package/dist/{preflight-4VQP7JJL.js → preflight-POZZI3GN.js} +9 -9
  130. package/dist/{prismic-ci-6GKKZGB5.js → prismic-ci-AHY5ULJR.js} +38 -8
  131. package/dist/prismic-ci-AHY5ULJR.js.map +1 -0
  132. package/dist/{prismic-models-W6CNLTWH.js → prismic-models-URYGODNY.js} +9 -9
  133. package/dist/prospect/types.d.ts +72 -0
  134. package/dist/prospect/types.js +1 -1
  135. package/dist/{prospect-audit-X4Z67LWW.js → prospect-audit-AJEE3Z4L.js} +16 -7
  136. package/dist/prospect-audit-AJEE3Z4L.js.map +1 -0
  137. package/dist/{prospect-audits-RVYEA4KH.js → prospect-audits-IZVQY3OQ.js} +12 -4
  138. package/dist/protection-audit-3J3UEOVT.js +413 -0
  139. package/dist/protection-audit-3J3UEOVT.js.map +1 -0
  140. package/dist/recipes/sync-configs.js +3 -3
  141. package/dist/{render-YGNGTX3W.js → render-MHUUHPLN.js} +36 -9
  142. package/dist/render-MHUUHPLN.js.map +1 -0
  143. package/dist/{render-from-row-IFSBHWDH.js → render-from-row-65DC6ZP3.js} +4 -4
  144. package/dist/{renovate-dispatch-U5Q52SKT.js → renovate-dispatch-ONJMBUHJ.js} +3 -3
  145. package/dist/{replay-PCSQOGCZ.js → replay-4FVFOMHH.js} +10 -23
  146. package/dist/replay-4FVFOMHH.js.map +1 -0
  147. package/dist/{report-4GFD4MW2.js → report-RC5LGPU3.js} +22 -20
  148. package/dist/report-RC5LGPU3.js.map +1 -0
  149. package/dist/{report-mirror-IP64R7B3.js → report-mirror-KKZZ6RMZ.js} +13 -9
  150. package/dist/{report-mirror-IP64R7B3.js.map → report-mirror-KKZZ6RMZ.js.map} +1 -1
  151. package/dist/{schema-2V7BGYVT.js → schema-KWUQEZ5O.js} +1 -1
  152. package/dist/schema-KWUQEZ5O.js.map +1 -0
  153. package/dist/{self-updating-MWXIZLL7.js → self-updating-RKKIYNV4.js} +8 -7
  154. package/dist/{self-updating-MWXIZLL7.js.map → self-updating-RKKIYNV4.js.map} +1 -1
  155. package/dist/{selftest-MAB2PBXQ.js → selftest-AFLR7YV7.js} +10 -10
  156. package/dist/site-lookup-DTOLJ4EA.js +27 -0
  157. package/dist/site-lookup-DTOLJ4EA.js.map +1 -0
  158. package/dist/{site-mirror-UKHREAD6.js → site-mirror-H2FYMN55.js} +23 -9
  159. package/dist/site-mirror-H2FYMN55.js.map +1 -0
  160. package/dist/{smoke-suite-7DGITCTZ.js → smoke-suite-BWHR3XX5.js} +8 -7
  161. package/dist/{smoke-suite-7DGITCTZ.js.map → smoke-suite-BWHR3XX5.js.map} +1 -1
  162. package/dist/{submissions-5GKJPCIH.js → submissions-II5J35QM.js} +4 -4
  163. package/dist/{submissions-3I3GAQAX.js → submissions-JDZP35JM.js} +49 -9
  164. package/dist/submissions-JDZP35JM.js.map +1 -0
  165. package/dist/{svelte-codemods-WSV2FGNG.js → svelte-codemods-QWGIRNN6.js} +6 -6
  166. package/dist/{sync-K3PKHVLH.js → sync-NKPDCMYW.js} +5 -5
  167. package/dist/{sync-configs-RRIHNLIJ.js → sync-configs-HGLQV55H.js} +6 -6
  168. package/dist/{upgrade-VTD6V4HS.js → upgrade-WZ44IARG.js} +6 -6
  169. package/dist/util/git.d.ts +48 -9
  170. package/dist/util/git.js +1 -1
  171. package/dist/{webflow-IRKOIT7S.js → webflow-624FEBFD.js} +4 -4
  172. package/dist/{websites-TB4TCAWM.js → websites-6DHIHJ2D.js} +4 -2
  173. package/dist/{write-audits-to-airtable-53EVXKMU.js → write-audits-to-airtable-BIK6LNVC.js} +5 -5
  174. package/dist/write-audits-to-airtable-BIK6LNVC.js.map +1 -0
  175. package/package.json +3 -2
  176. package/dist/airtable-JGCPS7JH.js +0 -9
  177. package/dist/chunk-4AIYWWKV.js.map +0 -1
  178. package/dist/chunk-5UINJNAJ.js.map +0 -1
  179. package/dist/chunk-6VKYQATC.js +0 -1708
  180. package/dist/chunk-6VKYQATC.js.map +0 -1
  181. package/dist/chunk-7MKT4I5T.js +0 -62
  182. package/dist/chunk-7MKT4I5T.js.map +0 -1
  183. package/dist/chunk-AVEQUR44.js.map +0 -1
  184. package/dist/chunk-BVEDKMNG.js.map +0 -1
  185. package/dist/chunk-BYGPUUCT.js.map +0 -1
  186. package/dist/chunk-CWXTNPNZ.js.map +0 -1
  187. package/dist/chunk-DFKBTBO3.js.map +0 -1
  188. package/dist/chunk-FSGICFXB.js.map +0 -1
  189. package/dist/chunk-GASBX52O.js +0 -12
  190. package/dist/chunk-GASBX52O.js.map +0 -1
  191. package/dist/chunk-LM2NHRCJ.js.map +0 -1
  192. package/dist/chunk-QF6IQQFP.js.map +0 -1
  193. package/dist/chunk-TO7DJYLZ.js.map +0 -1
  194. package/dist/chunk-TR3HFH6J.js.map +0 -1
  195. package/dist/chunk-WJMPCVOR.js.map +0 -1
  196. package/dist/chunk-WSJ7RO54.js.map +0 -1
  197. package/dist/chunk-WVNDEYXL.js.map +0 -1
  198. package/dist/chunk-XHHMM5HX.js.map +0 -1
  199. package/dist/chunk-YLYXW5PH.js.map +0 -1
  200. package/dist/client-M2V6FHH5.js +0 -11
  201. package/dist/db-BBEM7CBX.js.map +0 -1
  202. package/dist/digest-65BOZ7AT.js.map +0 -1
  203. package/dist/digest-state-3KHZF7GD.js.map +0 -1
  204. package/dist/ensure-site-WIDO6LGU.js.map +0 -1
  205. package/dist/header-image-YN3AQRY3.js.map +0 -1
  206. package/dist/launch-4RVWPZGI.js.map +0 -1
  207. package/dist/match-harness-2Q6ISEES.js.map +0 -1
  208. package/dist/migrate-K4JETR36.js +0 -7
  209. package/dist/prismic-ci-6GKKZGB5.js.map +0 -1
  210. package/dist/prospect-audit-X4Z67LWW.js.map +0 -1
  211. package/dist/protection-audit-SJO53MEQ.js +0 -168
  212. package/dist/protection-audit-SJO53MEQ.js.map +0 -1
  213. package/dist/render-YGNGTX3W.js.map +0 -1
  214. package/dist/replay-PCSQOGCZ.js.map +0 -1
  215. package/dist/report-4GFD4MW2.js.map +0 -1
  216. package/dist/schema-2V7BGYVT.js.map +0 -1
  217. package/dist/site-mirror-UKHREAD6.js.map +0 -1
  218. package/dist/submissions-3I3GAQAX.js.map +0 -1
  219. /package/dist/{airtable-JGCPS7JH.js.map → airtable-SJV4ZSMS.js.map} +0 -0
  220. /package/dist/{announce-PGT4SZZB.js.map → announce-45HVP4J4.js.map} +0 -0
  221. /package/dist/{blux-4KNXUP2K.js.map → blux-RUFMNOUJ.js.map} +0 -0
  222. /package/dist/{bump-deps-C5G2EFBB.js.map → bump-deps-EL3TKMIL.js.map} +0 -0
  223. /package/dist/{chunk-XN37VTDB.js.map → chunk-37B4LVB3.js.map} +0 -0
  224. /package/dist/{chunk-WMBLR5VA.js.map → chunk-44SDGPGQ.js.map} +0 -0
  225. /package/dist/{chunk-22GKJO5F.js.map → chunk-6INOBNRO.js.map} +0 -0
  226. /package/dist/{chunk-5GUJ36TL.js.map → chunk-74QRWE6G.js.map} +0 -0
  227. /package/dist/{chunk-S7RYFUIK.js.map → chunk-CA6ADGOC.js.map} +0 -0
  228. /package/dist/{chunk-KZRR4NFG.js.map → chunk-D3CTOCUR.js.map} +0 -0
  229. /package/dist/{chunk-BCKEP5S6.js.map → chunk-ECDJDHKT.js.map} +0 -0
  230. /package/dist/{chunk-DMPHP7UP.js.map → chunk-EQQ4H6L3.js.map} +0 -0
  231. /package/dist/{chunk-Z2K4JPQ3.js.map → chunk-H445EH33.js.map} +0 -0
  232. /package/dist/{chunk-FUWFLAWQ.js.map → chunk-IMYL67JV.js.map} +0 -0
  233. /package/dist/{chunk-AEUABPAJ.js.map → chunk-OTEVKRAH.js.map} +0 -0
  234. /package/dist/{chunk-KQB2BX4V.js.map → chunk-P4A6D6HO.js.map} +0 -0
  235. /package/dist/{chunk-4763KQ6O.js.map → chunk-PXRN2MER.js.map} +0 -0
  236. /package/dist/{chunk-2LYOAB2E.js.map → chunk-RRE74VWI.js.map} +0 -0
  237. /package/dist/{chunk-ZQV7UQNE.js.map → chunk-SQ4FSNYL.js.map} +0 -0
  238. /package/dist/{chunk-6R56OKJZ.js.map → chunk-UIITN37J.js.map} +0 -0
  239. /package/dist/{chunk-CJ2RMTT7.js.map → chunk-VEIIUCYL.js.map} +0 -0
  240. /package/dist/{chunk-HTABUCF4.js.map → chunk-Y5BVR4HW.js.map} +0 -0
  241. /package/dist/{chunk-5EEMTJMT.js.map → chunk-ZH5LOQET.js.map} +0 -0
  242. /package/dist/{client-M2V6FHH5.js.map → client-L7NAUG6J.js.map} +0 -0
  243. /package/dist/{convert-to-pnpm-YTFREUJK.js.map → convert-to-pnpm-4DON7IFS.js.map} +0 -0
  244. /package/dist/{digest-collectors-EHFY2OQC.js.map → deadletter-G4M3HSVM.js.map} +0 -0
  245. /package/dist/{fleet-state-UEVZ3X3J.js.map → digest-collectors-MC5NWEUA.js.map} +0 -0
  246. /package/dist/{freeze-A6MUCZU4.js.map → fleet-state-GJWGKTLD.js.map} +0 -0
  247. /package/dist/{forms-notify-target-2LWF5C5B.js.map → forms-notify-target-4NPLX2UG.js.map} +0 -0
  248. /package/dist/{import-airtable-TAKSN4NV.js.map → freeze-GUQ43EAU.js.map} +0 -0
  249. /package/dist/{github-signals-GAHNOAHS.js.map → github-signals-DMU74SAX.js.map} +0 -0
  250. /package/dist/{health-mirror-EB76EERV.js.map → health-mirror-ONRT4DQ5.js.map} +0 -0
  251. /package/dist/{lighthouse-VHI77PFB.js.map → import-airtable-IIK2QUUF.js.map} +0 -0
  252. /package/dist/{lighthouse-airtable-YMH4UA7R.js.map → lighthouse-52WSPZ5D.js.map} +0 -0
  253. /package/dist/{migrate-K4JETR36.js.map → lighthouse-airtable-3NOFB42B.js.map} +0 -0
  254. /package/dist/{notify-IISTHNBR.js.map → migrate-NE3DUMTM.js.map} +0 -0
  255. /package/dist/{orchestrate-MWXENQOS.js.map → notify-K3WVH3DM.js.map} +0 -0
  256. /package/dist/{onboard-TM2KESGF.js.map → onboard-CZYBWVWH.js.map} +0 -0
  257. /package/dist/{parity-CAJ477PS.js.map → orchestrate-TGDFGY52.js.map} +0 -0
  258. /package/dist/{pipeline-YROAY6DT.js.map → parity-AEKYSLN6.js.map} +0 -0
  259. /package/dist/{prospect-audits-RVYEA4KH.js.map → pipeline-UUPXOS2J.js.map} +0 -0
  260. /package/dist/{preflight-4VQP7JJL.js.map → preflight-POZZI3GN.js.map} +0 -0
  261. /package/dist/{prismic-models-W6CNLTWH.js.map → prismic-models-URYGODNY.js.map} +0 -0
  262. /package/dist/{render-from-row-IFSBHWDH.js.map → prospect-audits-IZVQY3OQ.js.map} +0 -0
  263. /package/dist/{websites-TB4TCAWM.js.map → render-from-row-65DC6ZP3.js.map} +0 -0
  264. /package/dist/{renovate-dispatch-U5Q52SKT.js.map → renovate-dispatch-ONJMBUHJ.js.map} +0 -0
  265. /package/dist/{selftest-MAB2PBXQ.js.map → selftest-AFLR7YV7.js.map} +0 -0
  266. /package/dist/{submissions-5GKJPCIH.js.map → submissions-II5J35QM.js.map} +0 -0
  267. /package/dist/{svelte-codemods-WSV2FGNG.js.map → svelte-codemods-QWGIRNN6.js.map} +0 -0
  268. /package/dist/{sync-K3PKHVLH.js.map → sync-NKPDCMYW.js.map} +0 -0
  269. /package/dist/{sync-configs-RRIHNLIJ.js.map → sync-configs-HGLQV55H.js.map} +0 -0
  270. /package/dist/{upgrade-VTD6V4HS.js.map → upgrade-WZ44IARG.js.map} +0 -0
  271. /package/dist/{webflow-IRKOIT7S.js.map → webflow-624FEBFD.js.map} +0 -0
  272. /package/dist/{write-audits-to-airtable-53EVXKMU.js.map → websites-6DHIHJ2D.js.map} +0 -0
@@ -0,0 +1,3972 @@
1
+ // src/recipes/match-harness/template.ts
2
+ var HARNESS_MJS_RELATIVE = "matching/harness.mjs";
3
+ var HARNESS_MJS_TEMPLATE = `// The single source for everything the matching gates need to know about this
4
+ // site. DATA lives in matching/harness.json (site-edited); this file is the
5
+ // READ LAYER \u2014 edit harness.json, not this. (It is installed and upgraded by
6
+ // the \`reddoor-maint match-harness\` recipe, which owns these bytes: a hand
7
+ // edit here is flagged on the next run and never silently overwritten.)
8
+ //
9
+ // It exists because the page table, the two hosts, the matrix, the threshold
10
+ // and the skill path were hand-copied all over matching/. Re-measured
11
+ // 2026-09-09 AFTER the six-probe conversion, over the 216 tracked scripts under
12
+ // matching/ (214 top-level \u2014 212 .mjs + 2 .sh \u2014 plus 2 in states/):
13
+ //
14
+ // \u2022 a hand-typed copy of the page table (three or more gate keys sitting next
15
+ // to their route): 8 files. Exactly ONE of them, probe-chrome-count.mjs,
16
+ // still carries all nine rows; probe-anchors.mjs carries five; the other
17
+ // six are three-row detail triples (team/svc/qa).
18
+ // \u2022 the skill path (~/.claude/skills/matching-a-page): 193 copies
19
+ // \u2022 the viewport matrix (1440/834/390): 51 copies
20
+ // \u2022 REF pointed at a host listed in selfHosts \u2014 i.e. comparing the candidate
21
+ // with itself: 12 scripts, one of which (probe-chrome-count.mjs) is also
22
+ // the last nine-row table carrier
23
+ //
24
+ // The first bullet read "the nine-row page table: 5 copies \u2014 gate.sh,
25
+ // probe-anchor-parity.mjs, sweep-all10.sh, sweep-all16.sh, sweep-final.sh" when
26
+ // it was written here at 922dde3. It was wrong within the hour and wrong on two
27
+ // counts: 4e2cd7b took the table out of gate.sh, and the list never named
28
+ // states/index.mjs or probe-chrome-count.mjs, which were both carrying nine-row
29
+ // copies at the time. A census is a claim about code; it has to be measured
30
+ // against the tree, not recalled.
31
+ //
32
+ // node matching/harness.mjs --env shell-safe KEY='value' lines
33
+ // node matching/harness.mjs --table key<TAB>ref<TAB>cand<TAB>anchors
34
+ // node matching/harness.mjs --check-ref the D11 preflight; exit 2 on failure
35
+ // node matching/harness.mjs --check-run <page> <out-dir> <startedAt-iso>
36
+ // did THIS run leave a countable report?
37
+ // exit 2 when it did not
38
+ import { readFileSync, realpathSync } from "node:fs";
39
+ import { homedir } from "node:os";
40
+ import { join } from "node:path";
41
+ import { fileURLToPath, pathToFileURL } from "node:url";
42
+
43
+ // fileURLToPath, not URL#pathname: pathname is percent-encoded, so a checkout
44
+ // under a directory with a space in it would resolve to a path that does not
45
+ // exist. EXPORTED so next.mjs, strikes.mjs and build-spec.mjs take the resolved
46
+ // directory from here instead of each recomputing it \u2014 three of them did, all
47
+ // with the rejected form, and the one with teeth was strikes.mjs's PAUSED path:
48
+ // under a percent-encoding checkout \`existsSync\` said false and the pause
49
+ // switch failed OPEN (#47).
50
+ export const DIR = fileURLToPath(new URL(".", import.meta.url));
51
+ const CFG = JSON.parse(readFileSync(join(DIR, "harness.json"), "utf8"));
52
+
53
+ // A page key is interpolated into a RegExp (specHeadingRe, below) and into
54
+ // gate.sh's \`grep -qE\` \u2014 unescaped, in both. Measured: \`specHeadingRe("a.c")\`
55
+ // matched \`## abc\` (a WRONG page's section satisfies the check) and
56
+ // \`specHeadingRe("a(b")\` threw. So the vocabulary is refused ONCE, here, where
57
+ // every consumer reads the table \u2014 including gate.sh, which only ever sees keys
58
+ // through \`--table\` \u2014 and a bad key is a load-time error naming itself rather
59
+ // than a fail-open pattern three files away (#50). The set is what a gate key
60
+ // already has to be: it names a run dir, a SPEC heading and a spec-sections
61
+ // file.
62
+ const KEY_RE = /^[a-z0-9-]+$/;
63
+ for (const key of Object.keys(CFG.pages ?? {})) {
64
+ if (!KEY_RE.test(key))
65
+ throw new Error(
66
+ \`harness.json: page key \${JSON.stringify(key)} must match \${KEY_RE} \u2014 keys are interpolated unescaped into specHeadingRe and gate.sh's grep\`,
67
+ );
68
+ }
69
+
70
+ // Env overrides exist for one-off probes only. They are NOT how a site is
71
+ // configured \u2014 harness.json is, so that what a gate ran against is committed.
72
+ export const REF = process.env.MATCH_REF ?? CFG.ref;
73
+ export const CAND = process.env.MATCH_CAND ?? process.env.CAND_BASE ?? CFG.cand;
74
+ export const MATRIX = CFG.matrix;
75
+ export const THRESHOLD = CFG.threshold;
76
+ export const MAX_HEIGHT_DELTA = CFG.maxHeightDelta;
77
+ export const REF_MARK = CFG.refMark;
78
+ export const CAND_MARK = CFG.candMark;
79
+ export const SELF_HOSTS = CFG.selfHosts ?? [];
80
+
81
+ /** One record per gated page, in harness.json order. \`key\` is the gate key
82
+ * (out-<TAG>-<key>, the SPEC heading, spec-sections/<key>.md); \`uid\` is the
83
+ * Prismic uid or null where the page has no /dev/match twin. */
84
+ export const PAGES = Object.entries(CFG.pages).map(([key, p]) => ({ key, ...p }));
85
+ export const byKey = Object.fromEntries(PAGES.map((p) => [p.key, p]));
86
+
87
+ /** Can this page's region count be PREDICTED at all? It needs anchors \u2014 see
88
+ * TOTALS below \u2014 AND a matrix to measure them at. Exported beside TOTALS
89
+ * rather than left for each consumer to re-derive, because "remember to ask
90
+ * first" is exactly what failed: checkRun remembered, next.mjs did not, for
91
+ * two releases.
92
+ *
93
+ * \`MATRIX.length > 0\` is not defensive padding. TOTALS is
94
+ * \`(anchors + 1) * MATRIX.length\`, so an ANCHORED page with \`matrix: []\`
95
+ * yields 0 \u2014 truthy-adjacent, arithmetically fatal. Measured on a page with 3
96
+ * anchors, an empty matrix and 8 passing regions: without this clause the
97
+ * scorer printed \`SCORE 8/0 regions passing\` and \`Backlog is empty \u2014 Phases 5
98
+ * and 6 are what is left\`, exit 0. The absurd fraction would be questioned;
99
+ * the sentence would not. \`pass/0\` is also Infinity, so such a page sorts
100
+ * BEST and can never be named \`worst\` \u2014 the same ranking bug this change set
101
+ * removed for unanchored pages, one input along. */
102
+ export const scorable = (key) =>
103
+ (byKey[key]?.anchors?.length ?? 0) > 0 && MATRIX.length > 0;
104
+
105
+ /** WHY a page is not scorable, in the words of the thing that is actually
106
+ * missing. A refusal that states a cause it did not check is the shape
107
+ * CLAUDE.md names: a field must never be named after something it cannot
108
+ * observe. "no anchors" printed for a page carrying three of them sends the
109
+ * operator to edit the one part of harness.json that was already right. */
110
+ export const unscorableWhy = (key) =>
111
+ (byKey[key]?.anchors?.length ?? 0) === 0
112
+ ? "no anchors"
113
+ : MATRIX.length === 0
114
+ ? "matrix is empty"
115
+ : null;
116
+
117
+ // DERIVED, never hand-typed: page-diff cuts one region before the first anchor
118
+ // ("top") plus one per anchor, at every viewport. The old hand-written map went
119
+ // stale the moment an anchor list changed, and a wrong denominator makes the
120
+ // score a lie in the flattering direction.
121
+ //
122
+ // THAT IDENTITY HOLDS ONLY WITH ANCHORS. \`splitRegions\` (page-diff.mjs:103-110)
123
+ // only cuts by anchor when there are anchors to cut by; with none it falls back
124
+ // to the page's own <section> boxes, and with none of those to an even four-row
125
+ // grid (lib/regions.mjs:27-35, gridRows = 4, labels \`grid-<r>-<c>\`). So the
126
+ // count is DATA-DEPENDENT, the two pages need not even agree, and no formula
127
+ // over anchors can predict it.
128
+ //
129
+ // \`checkRun\` below already reaches this conclusion \u2014 its \`if (secs.length)\`
130
+ // guard declines to assert a region count without anchors, and says why. That
131
+ // fix was applied to the VALIDATOR and never carried to the DENOMINATOR, so
132
+ // next.mjs went on dividing a real pass count by an imaginary total. Measured
133
+ // 2026-09-09 on a seed harness (anchors: [], matrix of 3): page-diff produced
134
+ // 12 grid regions, all passing, and next.mjs printed \`SCORE 12/3 regions
135
+ // passing\` followed by "Backlog is empty \u2014 Phases 5 and 6 are what is left",
136
+ // exit 0. On a matrix of 4 the same seed prints \`SCORE 16/4\`. The absurd
137
+ // fraction would have been questioned; the sentence would not.
138
+ //
139
+ // So an unpredictable page gets NO NUMBER \u2014 \`null\`, not a plausible-looking
140
+ // integer. That is a SIGNAL, not a barrier: \`a + null\` is \`a\`, so a consumer
141
+ // that sums TOTALS without asking \`scorable()\` still gets a too-small
142
+ // denominator. The barrier is \`scorable()\` plus next.mjs's exit-2 refusal.
143
+ export const TOTALS = Object.fromEntries(
144
+ PAGES.map((p) => [p.key, scorable(p.key) ? (p.anchors.length + 1) * MATRIX.length : null]),
145
+ );
146
+
147
+ /** WHY a report's region count is not the one TOTALS predicts for \`page\` \u2014
148
+ * as a string \u2014 or null when it is, or when the page is not scorable and so
149
+ * has no identity to hold (an unanchored page's count is page-diff's own;
150
+ * see TOTALS).
151
+ *
152
+ * ONE predicate for two callers, because the identity lived only in checkRun
153
+ * and only \`gate.sh --check-run\` asked it: next.mjs gated a report through
154
+ * \`uncountable(meta)\` alone, none of whose arms looks at how many regions a
155
+ * report carries, so a page whose report came up SHORT \u2014 an anchor whose text
156
+ * had gone from the rendered page, the ordinary way, is what a slice rename
157
+ * does \u2014 contributed no failing region, fell through to "Backlog is empty" and
158
+ * exited 0 over three regions nobody measured. Same sentence as the
159
+ * \`unmeasured\` guard beside it, one level down (#756). */
160
+ export function regionCountWhy(page, report) {
161
+ const expected = TOTALS[page];
162
+ if (expected == null) return null;
163
+ const n = Array.isArray(report?.regions) ? report.regions.length : 0;
164
+ if (n === expected) return null;
165
+ return \`\${n} region(s), expected \${expected} = (\${byKey[page].anchors.length} anchors + 1) x \${MATRIX.length} viewport(s)\`;
166
+ }
167
+
168
+ /** The SPEC.md heading predicate, shared by gate.sh's preflight and
169
+ * build-spec.mjs so a section can never build fine and then refuse at the
170
+ * gate. Matches the key followed by any non-key character (\`## team\` matches,
171
+ * \`## teamfoo\` does not, \`## our-team\` cannot match \`team\`). */
172
+ export const specHeadingRe = (key) => new RegExp(\`^##+ +\${key}([^A-Za-z0-9_-]|$)\`, "m");
173
+
174
+ export const SKILL_DIR =
175
+ process.env.MATCHING_SKILL_DIR ?? join(homedir(), ".claude/skills/matching-a-page");
176
+ export const PD = join(SKILL_DIR, "page-diff.mjs");
177
+ export const SC = join(SKILL_DIR, "style-census.mjs");
178
+ export const PLAYWRIGHT = pathToFileURL(join(SKILL_DIR, "node_modules/playwright/index.mjs")).href;
179
+
180
+ /** The report format this site's scripts can read, so gate.sh can compare it
181
+ * with \`page-diff --version\` before spending a run and next.mjs can refuse
182
+ * rather than quietly drop a page whose newest report came from another
183
+ * schema. Both do that now \u2014 gate.sh preflights \`page-diff --version\` against
184
+ * this value before spending a run, and next.mjs counts a foreign-schema
185
+ * report as MISSING rather than skipping it. (This said "neither does that
186
+ * yet" \u2014 true at 922dde3 where it was written, false from 4e2cd7b, which
187
+ * gave gate.sh the preflight and did not come back here.) Checked 2026-09-09
188
+ * against the installed skill: \`page-diff --version\` \u2192 \`page-diff 0.1.0
189
+ * report-schema 1\`. */
190
+ export const REPORT_SCHEMA = 1;
191
+
192
+ /**
193
+ * Fail-closed reference preflight. A 200 is NOT evidence: a host that has been
194
+ * repointed at our own build answers 200, and so does a staging host serving a
195
+ * 404 page. Both have happened on a real site \u2014 see the dated measurement in
196
+ * LEDGER.md. A pass here requires an artefact only the reference produces.
197
+ */
198
+ export async function checkRef() {
199
+ if (!REF_MARK) {
200
+ return {
201
+ ok: false,
202
+ why: "harness.json refMark is empty \u2014 set it to a string only the reference serves (a Webflow site id, a build hash). A 200 is not evidence.",
203
+ };
204
+ }
205
+ const host = new URL(REF).host;
206
+ if (SELF_HOSTS.includes(host)) return { ok: false, why: \`REF host \${host} is in selfHosts\` };
207
+ if (host === new URL(CAND).host) return { ok: false, why: \`REF host \${host} equals CAND's host\` };
208
+ let res;
209
+ try {
210
+ res = await fetch(\`\${REF}/\`, { redirect: "manual" });
211
+ } catch (e) {
212
+ return { ok: false, why: \`GET \${REF}/ failed: \${e.message}\` };
213
+ }
214
+ if (res.status !== 200)
215
+ return { ok: false, why: \`GET \${REF}/ \u2192 HTTP \${res.status}, expected 200\` };
216
+ const loc = res.headers.get("location");
217
+ if (loc) return { ok: false, why: \`GET \${REF}/ \u2192 \${res.status} redirect to \${loc}\` };
218
+ const body = await res.text();
219
+ if (!body.includes(REF_MARK))
220
+ return {
221
+ ok: false,
222
+ why: \`\${REF}/ served 200 but WITHOUT refMark \${JSON.stringify(REF_MARK)} \u2014 that is not the reference\`,
223
+ };
224
+ if (CAND_MARK && body.includes(CAND_MARK))
225
+ return {
226
+ ok: false,
227
+ why: \`\${REF}/ contains candMark \${JSON.stringify(CAND_MARK)} \u2014 REF is serving OUR build\`,
228
+ };
229
+ return { ok: true, why: \`\${REF}/ \u2192 200, no redirect, refMark present, candMark absent\` };
230
+ }
231
+
232
+ /**
233
+ * Would next.mjs COUNT a run with this meta? Returns the reason it would not,
234
+ * as a string, or null when it would.
235
+ *
236
+ * A reason string and not a boolean, because the two callers must tell the
237
+ * cases apart: next.mjs treats "schema" as a page BLANKED (it has its own
238
+ * message and its own exit) and merely skips the rest of the diagnostics,
239
+ * while gate.sh prints whatever this says.
240
+ *
241
+ * It lives HERE rather than inside next.mjs because gate.sh now asks the same
242
+ * question, and a question asked twice drifts: a gate that greens a run
243
+ * next.mjs then drops is the same false green one step along. census.sh's
244
+ * GUARD 2c (census.sh:153-168) records exactly that drift between
245
+ * style-census's printer and census-count.mjs's parser \u2014 a COMPLETE census
246
+ * reported as 0 mismatches because the two had versioned apart.
247
+ */
248
+ export function uncountable(m) {
249
+ // Missing schemaVersion means "written before the field existed" = 0. It is
250
+ // not an error on its own; it is only fatal when it would blank a page,
251
+ // which is next.mjs's call to make, not this predicate's.
252
+ if ((m.schemaVersion ?? 0) !== REPORT_SCHEMA) return "schema";
253
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
254
+ // page. An --mask-photos probe of yfv made \`top\` @834 read 43.9% while the
255
+ // real gate had it passing at 1.3%.
256
+ if ((m.mask?.length ?? 0) > 0) return \`mask=[\${m.mask.join(", ")}]\`;
257
+ if (m.neutralizeMedia) return "neutralize-media";
258
+ if (m.maskPhotos) return "mask-photos";
259
+ if (m.truncated) return "truncated";
260
+ if (m.threshold !== THRESHOLD) return \`threshold \${m.threshold} != \${THRESHOLD}\`;
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Did THIS run of page-diff leave a report the scorer will actually count?
266
+ *
267
+ * gate.sh cannot use page-diff's exit status for this. page-diff exits 1 for a
268
+ * region that legitimately FAILED (page-diff.mjs:227) and node exits 1 for the
269
+ * bare \`throw e\` one line below it, so the status cannot tell a finding from a
270
+ * crash-before-looking \u2014 census.sh:17-32 records the same shape for
271
+ * style-census. Measured on 29 Navy with the reference alive and no dev
272
+ * server: every page-diff died in \`page.goto\`, the gate printed \`home exit=1\`
273
+ * and \`ALL DONE\`, exited 0, and wrote no report at all.
274
+ *
275
+ * So the evidence is the artefact only a completed run leaves: the report
276
+ * next.mjs will read, fresh, over the matrix and anchors the table declares.
277
+ * Cheapest and most specific arm first.
278
+ */
279
+ export function checkRun(page, dir, startedAt) {
280
+ const path = join(dir, "report.json");
281
+ let report;
282
+ try {
283
+ report = JSON.parse(readFileSync(path, "utf8"));
284
+ } catch (e) {
285
+ if (e.code === "ENOENT")
286
+ return { ok: false, why: \`\${path}: no report.json \u2014 the run wrote nothing\` };
287
+ return { ok: false, why: \`\${path}: \${e.message}\` };
288
+ }
289
+ const meta = report.meta ?? {};
290
+
291
+ const uncount = uncountable(meta);
292
+ if (uncount)
293
+ return { ok: false, why: \`\${path}: next.mjs would not count this run (\${uncount})\` };
294
+
295
+ // FRESHNESS. lib/report.mjs:45 is \`mkdirSync(outDir, {recursive:true})\` and
296
+ // nothing ever clears the directory, so a crashed re-run under a tag used
297
+ // before leaves the PREVIOUS round's report exactly where it was \u2014 measured
298
+ // 2026-09-09, sha unchanged across the crash. Requiring report.json without
299
+ // this arm reproduces the green one step along. meta.generatedAt is built
300
+ // after \`finally { await browser.close() }\` (page-diff.mjs:163-176), so it
301
+ // is an artefact of the run that wrote it and not of the file's mtime.
302
+ const since = Date.parse(startedAt);
303
+ // NOT skipped when startedAt is unusable: a fail-open default is the exact
304
+ // shape this guard exists to stop.
305
+ if (!Number.isFinite(since))
306
+ return {
307
+ ok: false,
308
+ why: \`startedAt \${JSON.stringify(startedAt)} is not a timestamp \u2014 cannot tell this run's report from a previous round's\`,
309
+ };
310
+ const at = Date.parse(meta.generatedAt ?? "");
311
+ if (!Number.isFinite(at))
312
+ return {
313
+ ok: false,
314
+ why: \`\${path}: no usable meta.generatedAt \u2014 cannot tell this run's report from a previous round's\`,
315
+ };
316
+ if (at < since)
317
+ return {
318
+ ok: false,
319
+ why: \`\${path}: STALE \u2014 written \${meta.generatedAt}, this run started \${startedAt}. page-diff never cleared the directory.\`,
320
+ };
321
+
322
+ // COVERAGE \u2014 what the run was ASKED for, against the table.
323
+ const vws = meta.viewports ?? [];
324
+ if (vws.join(",") !== MATRIX.join(","))
325
+ return {
326
+ ok: false,
327
+ why: \`\${path}: ran viewports [\${vws.join(",")}], harness.json matrix is [\${MATRIX.join(",")}]\`,
328
+ };
329
+ const secs = meta.sections ?? [];
330
+ const want = byKey[page]?.anchors ?? [];
331
+ if (secs.join("\\0") !== want.join("\\0"))
332
+ return {
333
+ ok: false,
334
+ why: \`\${path}: ran sections [\${secs.join(" | ")}], harness.json anchors are [\${want.join(" | ")}]\`,
335
+ };
336
+
337
+ // ...and what it actually PRODUCED. Every viewport the run says it covered
338
+ // has to appear in the regions. Deliberately compared against the run's own
339
+ // meta.viewports and not against MATRIX: the arm above owns "the run used the
340
+ // wrong matrix", and folding the two together would make either one
341
+ // unfalsifiable on its own.
342
+ if (!Array.isArray(report.regions) || report.regions.length === 0)
343
+ return { ok: false, why: \`\${path}: no regions \u2014 nothing was compared\` };
344
+ const seen = new Set(report.regions.map((r) => r.viewport));
345
+ const missing = vws.filter((v) => !seen.has(v));
346
+ if (missing.length)
347
+ return {
348
+ ok: false,
349
+ why: \`\${path}: no region at viewport(s) [\${missing.join(",")}] \u2014 the run covered [\${[...seen].join(",")}]\`,
350
+ };
351
+
352
+ // With anchors the region count is an exact identity: page-diff cuts one
353
+ // region before the first anchor plus one per anchor, at every viewport
354
+ // (regionsFromAnchors, page-diff.mjs:105-109). Measured over the 298 clean
355
+ // gate-shaped runs in the corpus this harness was cut from, all of them
356
+ // anchored: regions.length === (sections + 1) * viewports holds 298/298,
357
+ // while regions.length === TOTALS[page] holds only 260/298 \u2014 the 38 are
358
+ // legitimately narrower HAND rounds. So the two arms ABOVE hold the run's own
359
+ // matrix and anchors to the table, and only then is the count asked of
360
+ // regionCountWhy, whose prediction is the table's: at this point the two
361
+ // derivations are the same number, and a HAND round has already been refused
362
+ // by name for its narrower matrix or anchors, not for the count that follows.
363
+ //
364
+ // WITHOUT anchors there is no such identity, and asserting one is a FALSE
365
+ // REFUSAL of the shape every new site starts in. page-diff falls back to each
366
+ // page's own <section> boxes and, with none, an even four-row grid
367
+ // (splitRegions, page-diff.mjs:103-110), so the count is data-dependent and
368
+ // the two pages need not even agree. Measured 2026-09-09 against the real
369
+ // page-diff on a seed harness (anchors: [], matrix of 4): 16 regions labelled
370
+ // grid-0-0 \u2026 grid-3-0, not the 4 this identity predicted. The 298/298 above
371
+ // was measured over anchored runs only and never covered this case.
372
+ // regionCountWhy returns null for such a page (TOTALS is null there), which
373
+ // is the \`if (secs.length)\` guard this used to carry, kept in one place.
374
+ const count = regionCountWhy(page, report);
375
+ if (count) return { ok: false, why: \`\${path}: \${count}\` };
376
+
377
+ // A green that STATES what it is made of, so a green over nothing reads
378
+ // differently from a green over the matrix (census.sh:219's habit).
379
+ return {
380
+ ok: true,
381
+ why: \`\${report.regions.length} region(s) over \${vws.length} viewport(s), written \${meta.generatedAt}\`,
382
+ };
383
+ }
384
+
385
+ // CLI. Both sides go through realpathSync. \`import.meta.url\` is ALREADY the
386
+ // resolved real path (node resolves symlinks unless --preserve-symlinks) while
387
+ // process.argv[1] is the path as typed, so a plain pathToFileURL compare goes
388
+ // false the moment any component of the invoked path is a symlink \u2014 and then
389
+ // the CLI prints nothing and exits 0, which every caller reads as success.
390
+ // page-diff.mjs:184-189 records exactly that defect and the same fix: "The
391
+ // pathToFileURL compare that replaced the old template string is still false
392
+ // whenever ANY component of the invoked path is a symlink \u2014 which is how this
393
+ // skill is installed now (~/.claude/skills/matching-a-page -> the claude-skills
394
+ // checkout). isMain() resolves the real path on both sides."
395
+ const isMain = () => {
396
+ if (!process.argv[1]) return false;
397
+ try {
398
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
399
+ } catch {
400
+ return false;
401
+ }
402
+ };
403
+
404
+ if (isMain()) {
405
+ const mode = process.argv[2];
406
+ const q = (v) => \`'\${String(v).replace(/'/g, \`'\\\\''\`)}'\`;
407
+ if (mode === "--env") {
408
+ const pairs = [
409
+ ["REF", REF],
410
+ ["CAND", CAND],
411
+ ["MATRIX", MATRIX.join(",")],
412
+ ["VIEWPORTS_SP", MATRIX.join(" ")],
413
+ ["THRESHOLD", THRESHOLD],
414
+ ["MAX_HEIGHT_DELTA", MAX_HEIGHT_DELTA],
415
+ ["PD", PD],
416
+ ["SC", SC],
417
+ ["REPORT_SCHEMA", REPORT_SCHEMA],
418
+ ];
419
+ for (const [k, v] of pairs) console.log(\`\${k}=\${q(v)}\`);
420
+ } else if (mode === "--table") {
421
+ for (const p of PAGES) console.log([p.key, p.ref, p.cand, p.anchors.join(",")].join("\\t"));
422
+ } else if (mode === "--check-ref") {
423
+ const r = await checkRef();
424
+ console.log(\`\${r.ok ? "REF OK" : "REF REFUSED"} \u2014 \${r.why}\`);
425
+ process.exit(r.ok ? 0 : 2);
426
+ } else if (mode === "--check-run") {
427
+ // startedAt is REQUIRED, never defaulted: an optional one is a fail-open
428
+ // door in the one guard that decides whether a page was measured at all.
429
+ const [page, dir, startedAt] = process.argv.slice(3);
430
+ if (!page || !dir || !startedAt) {
431
+ console.error("usage: harness.mjs --check-run <page> <out-dir> <startedAt-iso>");
432
+ process.exit(2);
433
+ }
434
+ const r = checkRun(page, dir, startedAt);
435
+ console.log(\`\${r.ok ? "RUN OK" : "NO RUN"} \u2014 \${r.why}\`);
436
+ process.exit(r.ok ? 0 : 2);
437
+ } else {
438
+ console.error("usage: harness.mjs --env | --table | --check-ref | --check-run");
439
+ process.exit(2);
440
+ }
441
+ }
442
+ `;
443
+ var GATE_SH_RELATIVE = "matching/gate.sh";
444
+ var GATE_SH_TEMPLATE = `#!/usr/bin/env bash
445
+ # The matching gate. This file is generic \u2014 everything specific to a site lives
446
+ # in matching/harness.json (the data) and matching/LEDGER.md (the why). It is
447
+ # installed and upgraded by the \`reddoor-maint match-harness\` recipe, which
448
+ # owns these bytes: a hand edit here is flagged on the next run, never
449
+ # silently overwritten.
450
+ #
451
+ # bash matching/gate.sh <round-tag> [page ...]
452
+ #
453
+ # Runs page-diff for every page in the table (or just the named ones) at the
454
+ # full breakpoint matrix and writes matching/out-<round-tag>-<page>/.
455
+ #
456
+ # The matrix, the anchor lists, the reference and the candidate are DATA. Why a
457
+ # site chose them \u2014 which live breakpoint band hid what, why an anchor is a
458
+ # heading and not a button label \u2014 belongs in matching/LEDGER.md, which is dated
459
+ # and append-only, because JSON holds no comments.
460
+ #
461
+ # NO MASKS and the threshold from harness.json everywhere: the numbers stay
462
+ # honest and a known floor stays visible as its own region. A media-neutralised
463
+ # secondary read is \`node "$PD" ... --neutralize-media\`; next.mjs ignores such
464
+ # runs on purpose (next.mjs:57-64).
465
+ #
466
+ # PREFLIGHT (see the matching rules in CLAUDE.md): a page with no section in
467
+ # matching/SPEC.md has not had Phase 1 done, and its geometry must not be
468
+ # touched. Skipping the spec is how a reference's root-font ladder and its
469
+ # per-component height ladders get found reactively, after the region has
470
+ # already failed several rounds. This refuses the run instead of trusting anyone
471
+ # to remember.
472
+ set -u
473
+ # Everything configurable lives in matching/harness.json; harness.mjs is the one
474
+ # reader. --env emits shell-safe assignments (REF, CAND, MATRIX, VIEWPORTS_SP,
475
+ # THRESHOLD, MAX_HEIGHT_DELTA, PD, SC, REPORT_SCHEMA).
476
+ eval "$(node "$(dirname "$0")/harness.mjs" --env)"
477
+
478
+ # The skill must be able to write reports this site's scripts can read. Cheap,
479
+ # local, and it fails before any browser starts.
480
+ PD_SCHEMA="$(node "$PD" --version 2>/dev/null | awk '{print $4}')"
481
+ if [ "$PD_SCHEMA" != "$REPORT_SCHEMA" ]; then
482
+ echo "gate.sh: page-diff writes report schema '\${PD_SCHEMA:-none}', this harness reads $REPORT_SCHEMA." >&2
483
+ echo " Update matching/harness.mjs REPORT_SCHEMA or the matching-a-page skill." >&2
484
+ exit 2
485
+ fi
486
+ SPEC="$(dirname "$0")/SPEC.md"
487
+ TAG="\${1:?usage: gate.sh <round-tag> [page ...]}"
488
+ # The tag must not contain a hyphen. Output dirs are "out-<TAG>-<page>", and
489
+ # next.mjs recovers the page with /^out-[^-]+-(.+)$/ \u2014 it splits on the FIRST
490
+ # hyphen, so a tag like "r-forms-2026-08-07" yields the page key
491
+ # "forms-2026-08-07-yfv" and that run is silently never counted. It cannot split
492
+ # on the last hyphen instead, because page keys have hyphens of their own
493
+ # ("our-team", "ask-the-doctor"). Failing here is the cheap end of that: a
494
+ # mis-tagged round otherwise LOOKS green because next.mjs keeps reading an older
495
+ # report for the page you just changed. (Cost this once, 2026-08-07.)
496
+ case "$TAG" in
497
+ *-*)
498
+ echo "gate.sh: round tag must not contain a hyphen (got '$TAG')." >&2
499
+ echo " out-<TAG>-<page> is parsed on the first hyphen, so a" >&2
500
+ echo " hyphenated tag hides the run from next.mjs. Try '\${TAG//-/}'." >&2
501
+ exit 2
502
+ ;;
503
+ esac
504
+ shift || true
505
+ WANT=("$@")
506
+
507
+ # What this round actually measured. These are read by the terminal block far
508
+ # below, and the page loop is \`done < <(...)\` \u2014 process substitution, NOT a
509
+ # pipe \u2014 precisely so they survive it (gate.sh's own note at the loop, and
510
+ # census.sh:193-198, record the same trap). Converting that loop to a pipe
511
+ # would leave every counter here reading 0, which is the green this file now
512
+ # exists to stop.
513
+ MEASURED=0
514
+ ATTEMPTED=0
515
+ UNMEASURED=""
516
+ SEEN=""
517
+
518
+ # Fail closed on the reference before spending a single run. A 200 is NOT
519
+ # evidence: a production host that has cut over to OUR build answers 200, and
520
+ # every region then scores near zero against itself. --check-ref requires an
521
+ # artefact only the reference serves (harness.json refMark) and refuses a
522
+ # redirect, a host listed in selfHosts, and a body carrying candMark. Measured
523
+ # on the site this harness was cut from, 2026-09-09 AFTER the consolidation:
524
+ # 12 of its 214 top-level matching scripts still assign REF a host listed in
525
+ # selfHosts \u2014 i.e. compare the candidate with itself. It was 33 of 229 before.
526
+ # (This comment read "33 of its 230" while sitting in the post-consolidation
527
+ # tree, contradicting harness.mjs's 12 three files away.)
528
+ if ! node "$(dirname "$0")/harness.mjs" --check-ref; then
529
+ echo "gate.sh: refusing to gate against an unverified reference." >&2
530
+ exit 2
531
+ fi
532
+
533
+ # Set SPEC_OPTIONAL=1 only for a read-only baseline sweep of pages you are not
534
+ # about to edit. It is recorded in the round tag so the exemption is visible.
535
+ SPEC_OPTIONAL="\${SPEC_OPTIONAL:-0}"
536
+
537
+ has_spec() { # page
538
+ # NB: the obvious \`( |$|\\b)\` guard is rejected by ugrep as an empty
539
+ # subexpression, and a preflight that errors out fails CLOSED \u2014 it refused all
540
+ # 9 pages while SPEC.md was complete. Match the page key followed by any
541
+ # non-key character (so \`## team\` matches but \`## teamfoo\` does not; \`##
542
+ # our-team\` cannot match \`team\` because the key must follow the spaces).
543
+ [ -f "$SPEC" ] && grep -qE "^##+ +$1([^A-Za-z0-9_-]|$)" "$SPEC"
544
+ }
545
+
546
+ run() { # tag refpath candpath sections
547
+ local page="$1" refpath="$2" candpath="$3" sections="$4"
548
+ # BEFORE the WANT filter, so the known-page list stays complete even when
549
+ # WANT matches nothing and there is a typo to name. Mirrors census.sh:107.
550
+ SEEN="$SEEN $page"
551
+ if [ \${#WANT[@]} -gt 0 ]; then
552
+ local hit=0
553
+ for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
554
+ [ $hit -eq 1 ] || return 0
555
+ fi
556
+ if ! has_spec "$page"; then
557
+ if [ "$SPEC_OPTIONAL" = "1" ]; then
558
+ echo "########## $page ##########"
559
+ echo "WARNING: no '## $page' section in matching/SPEC.md \u2014 Phase 1 not done."
560
+ echo " Running anyway because SPEC_OPTIONAL=1 (baseline read only)."
561
+ echo " Do NOT apply geometry fixes off this run."
562
+ else
563
+ echo "########## $page ##########"
564
+ echo "REFUSED: no '## $page' section in matching/SPEC.md."
565
+ echo " Phase 1 (section census + per-section spec, read from"
566
+ echo " matching/spec/) comes before geometry."
567
+ echo " See CLAUDE.md rule 2. SPEC_OPTIONAL=1 for a baseline read."
568
+ FAILED_PREFLIGHT=1
569
+ return 0
570
+ fi
571
+ fi
572
+ echo "########## $page ##########"
573
+ # Same clock source and same format as page-diff's own meta.generatedAt, so
574
+ # the freshness comparison below is between two ISO strings from one clock.
575
+ local started
576
+ started="$(node -e 'process.stdout.write(new Date().toISOString())')"
577
+ node "$PD" --ref "$REF$refpath" --cand "$CAND$candpath" \\
578
+ --viewports "$MATRIX" --threshold "$THRESHOLD" \\
579
+ --sections "$sections" --out "matching/out-$TAG-$page" \\
580
+ > "matching/out-$TAG-$page.log" 2>&1
581
+ # IMMEDIATELY after the invocation. Any command in between \u2014 the echo
582
+ # included \u2014 destroys $?.
583
+ local status=$?
584
+ echo "$page exit=$status"
585
+ ATTEMPTED=$((ATTEMPTED + 1))
586
+ # $status may DENY a green; it may never GRANT one, and here it does neither.
587
+ # page-diff exits 1 for a region that legitimately FAILED (page-diff.mjs:227)
588
+ # and node exits 1 for the bare \`throw e\` below it, so the status cannot tell
589
+ # a finding from a crash before it looked \u2014 and a matching round's steady
590
+ # state IS a failing gate, so denying on it would break every normal round.
591
+ # Measured on 29 Navy with the reference alive and no dev server: \`home
592
+ # exit=1\`, \`ALL DONE\`, exit 0, and no matching/out-*-home/ written at all.
593
+ # What only a completed run leaves is the report next.mjs will count.
594
+ # census.sh:17-32 is the same argument for style-census.
595
+ local evidence
596
+ if evidence="$(node "$(dirname "$0")/harness.mjs" --check-run "$page" "matching/out-$TAG-$page" "$started" 2>&1)"; then
597
+ MEASURED=$((MEASURED + 1))
598
+ else
599
+ UNMEASURED="$UNMEASURED $page"
600
+ echo " NOT MEASURED: $evidence"
601
+ fi
602
+ }
603
+
604
+ # The page table is matching/harness.json. Process substitution, NOT a pipe:
605
+ # a pipe would run this loop in a subshell and FAILED_PREFLIGHT would not
606
+ # survive to the check below, so a refused page would exit 0.
607
+ while IFS=$'\\t' read -r key refpath candpath anchors; do
608
+ run "$key" "$refpath" "$candpath" "$anchors"
609
+ done < <(node "$(dirname "$0")/harness.mjs" --table)
610
+
611
+ # BOTH kinds of incompleteness are reported in one run: a page refused before
612
+ # it ran, and a page that ran and left nothing countable. Exiting on the first
613
+ # would hide the second from an operator who then fixes only what was printed.
614
+ INCOMPLETE=0
615
+ if [ -n "$UNMEASURED" ]; then
616
+ echo
617
+ echo "GATE INCOMPLETE ($TAG) \u2014 $((ATTEMPTED - MEASURED)) of $ATTEMPTED page(s) produced no"
618
+ echo "countable report:$UNMEASURED"
619
+ echo "Those pages have NOT been measured. next.mjs drops an unreported page from"
620
+ echo "BOTH sides of the score, so the pages that did report would read as the"
621
+ echo "whole site. Full runs: matching/out-$TAG-<page>.log"
622
+ INCOMPLETE=1
623
+ fi
624
+ if [ "\${FAILED_PREFLIGHT:-0}" = "1" ]; then
625
+ echo
626
+ echo "GATE INCOMPLETE ($TAG) \u2014 one or more pages were refused for a missing"
627
+ echo "SPEC.md section. Those pages have NOT been measured; do not report a"
628
+ echo "score for them."
629
+ INCOMPLETE=1
630
+ fi
631
+ if [ "$INCOMPLETE" = "1" ]; then
632
+ exit 2
633
+ fi
634
+ # A green over ZERO pages is still a green. Measured: \`gate.sh nosuch
635
+ # nosuchpage\` printed ALL DONE and exited 0 having run nothing and printed no
636
+ # page header at all. census.sh:199-209 refuses exactly this shape, and
637
+ # strikes.mjs:147-156 was written because a typo silently greened rule 3 on six
638
+ # of nine pages.
639
+ if [ "$ATTEMPTED" -eq 0 ]; then
640
+ if [ -n "$SEEN" ]; then
641
+ echo "gate.sh: \\"\${WANT[*]:-}\\" matches no page \u2014 refusing to report ALL DONE." >&2
642
+ echo " known pages:$SEEN" >&2
643
+ else
644
+ echo "gate.sh: the page table is empty \u2014 refusing to report ALL DONE." >&2
645
+ echo " node matching/harness.mjs --table printed no rows; check" >&2
646
+ echo " \\"pages\\" in matching/harness.json." >&2
647
+ fi
648
+ exit 2
649
+ fi
650
+ # A green that STATES what it is made of (census.sh:219), so a green over
651
+ # nothing cannot read like a green over the table.
652
+ echo "ALL DONE ($TAG) \u2014 $MEASURED of $ATTEMPTED page(s) measured, each one counted."
653
+ `;
654
+ var CENSUS_SH_RELATIVE = "matching/census.sh";
655
+ var CENSUS_SH_TEMPLATE = `#!/usr/bin/env bash
656
+ # Phase 3 style gate \u2014 the mechanical net for the type spec.
657
+ #
658
+ # bash matching/census.sh # all 9 pages, all 3 viewports
659
+ # bash matching/census.sh home yfv # just these
660
+ #
661
+ # style-census diffs the computed type tuple (family, weight, size,
662
+ # line-height, letter-spacing, transform, colour) of every text snippet present
663
+ # on BOTH pages. It is the only gate that catches the 11px footer line and the
664
+ # cyan-vs-teal link that page-diff is structurally blind to \u2014 a region can be
665
+ # pixel-clean at 0.10 and still be wearing the wrong colour on small text.
666
+ #
667
+ # Prints a per-page/per-viewport mismatch count and leaves the full runs in
668
+ # matching/census-<page>-<vw>.log. Exits 1 while any count is non-zero, and 2
669
+ # when it cannot honestly report a count at all.
670
+ #
671
+ # EVIDENCE \u2014 read before adding a guard here, or removing one. A green here
672
+ # has to be produced by a census that RAN. It was not: with style-census.mjs
673
+ # absent this gate printed "Phase 3 CLEAN \u2014 0 undeclared type mismatches" and
674
+ # exited 0. Every run died on module resolution, census-count.mjs read each
675
+ # crash log as "0 0 0" (it counts \` y=\` rows and a stack trace has none \u2014
676
+ # census-count.mjs:35-56), and no rows was reported as no mismatches. Measured
677
+ # again with style-census.mjs PRESENT and the candidate host down: identical
678
+ # output, because style-census exits 1 both when it finds mismatches
679
+ # (style-census.mjs:187) and when it dies before it looks \u2014 so the exit status
680
+ # cannot tell a finding from a crash and may not grant a green either.
681
+ #
682
+ # The guards below are what next.mjs:83-89 (no parseable gate run \u2014 refusing to
683
+ # report a score), strikes.mjs:87-97 (refusing to report "clear") and
684
+ # gate.sh:37-42 (preflight page-diff before spending a run) already do. Each
685
+ # demands an artefact only a working census produces; none of them can turn a
686
+ # red green.
687
+ set -uo pipefail
688
+
689
+ # PAUSE SWITCH \u2014 the same early exit next.mjs and strikes.mjs take, for the same
690
+ # reason (see next.mjs). This gate is the third door into a round: measured on
691
+ # the site this was cut from, PAUSED since 2026-09-01, a run here would have
692
+ # spent 27 browser pairs against the live reference during a declared pause. A
693
+ # switch two of three gates honour is not a switch
694
+ # (reddoorla/reddoor-maintenance#735).
695
+ if [ -f "$(dirname "$0")/PAUSED" ]; then
696
+ echo "MATCHING PAUSED \u2014 no agenda, and none is to be inferred."
697
+ echo
698
+ cat "$(dirname "$0")/PAUSED"
699
+ exit 0
700
+ fi
701
+
702
+ # Resolved BEFORE the cd. $0 is the path as TYPED, so a relatively invoked
703
+ # census.sh that resolved the harness afterwards would look for it under
704
+ # whatever root the cd landed on.
705
+ HARNESS="$(cd "$(dirname "$0")" && pwd)/harness.mjs"
706
+ cd "$(dirname "$0")/.."
707
+
708
+ # Everything configurable lives in matching/harness.json; harness.mjs is the one
709
+ # reader. --env supplies REF, CAND, SC and VIEWPORTS_SP \u2014 this gate held its own
710
+ # copy of all four, and its REF went stale when production cut over to our own
711
+ # build (LEDGER "REFERENCE MOVED", verified 2026-08-10) while gate.sh's was
712
+ # repointed the same day.
713
+ eval "$(node "$HARNESS" --env)"
714
+ NODE="\${NODE:-node}"
715
+
716
+ # The matrix is DATA, read as VIEWPORTS_SP, the way gate.sh reads MATRIX. The
717
+ # old VIEWPORTS override is retired rather than silently ignored: a census run
718
+ # over a matrix harness.json does not name is a column set the pixel gate never
719
+ # measured, and it would look exactly like a clean one.
720
+ if [ -n "\${VIEWPORTS:-}" ]; then
721
+ echo "census.sh: the VIEWPORTS override is retired \u2014 the matrix is data now." >&2
722
+ echo " Edit \\"matrix\\" in matching/harness.json, then unset it." >&2
723
+ exit 2
724
+ fi
725
+
726
+ # GUARD 1 \u2014 the tool is there and it starts. gate.sh compares \`page-diff
727
+ # --version\` with the harness's REPORT_SCHEMA before spending a run
728
+ # (gate.sh:37-42); style-census has no --version to compare (page-diff.mjs
729
+ # defines one at 191-195; nothing in style-census.mjs writes one), so the
730
+ # artefact demanded here is the other one only it produces: the usage banner it
731
+ # prints on exit 2 when --ref/--cand are missing (style-census.mjs:151-157).
732
+ # That banner is not free \u2014 \`import { chromium } from "playwright"\` runs first
733
+ # (style-census.mjs:19) \u2014 so a skill directory with no playwright installed
734
+ # fails HERE, once, instead of writing one crash log per page per viewport that
735
+ # every reader downstream counts as zero.
736
+ if [ ! -f "$SC" ]; then
737
+ echo "census.sh: no style-census at $SC \u2014 refusing to report a count." >&2
738
+ echo " Install the matching-a-page skill, or point MATCHING_SKILL_DIR" >&2
739
+ echo " at a checkout that has style-census.mjs." >&2
740
+ exit 2
741
+ fi
742
+ SC_USAGE="$("$NODE" "$SC" 2>&1)"
743
+ case "$SC_USAGE" in
744
+ *"style-census"*"--ref"*"--cand"*"--vw"*) ;;
745
+ *)
746
+ echo "census.sh: $SC did not answer with a style-census --ref/--cand/--vw usage" >&2
747
+ echo " banner \u2014 refusing to report a count. It printed:" >&2
748
+ echo " \${SC_USAGE:-(nothing at all)}" >&2
749
+ exit 2
750
+ ;;
751
+ esac
752
+
753
+ declare -a WANT=("$@")
754
+
755
+ # page -> "refpath candpath": the same table gate.sh drives, from the same file.
756
+ # cut drops the anchors column, which the style census has no argument for
757
+ # (style-census.mjs: --ref <url> --cand <url> [--vw 1440]).
758
+ pages() { node "$HARNESS" --table | cut -f1-3; }
759
+
760
+ TOTAL=0
761
+ AMB=0
762
+ DECL=0
763
+ ROWS=0
764
+ RUNS=0
765
+ BROKEN=0
766
+ SEEN=""
767
+ # One column per viewport, from the matrix. Flush with the data rows below, at
768
+ # last: the old header hardcoded four fixed fields separated by a literal space,
769
+ # which the rows do not have, so its labels sat 1/2/3 columns right of them.
770
+ printf '%-10s' page
771
+ for vw in $VIEWPORTS_SP; do printf '%8s' "$vw"; done
772
+ printf '\\n'
773
+ while read -r page refpath candpath; do
774
+ [ -z "$page" ] && continue
775
+ SEEN="$SEEN $page"
776
+ if [ \${#WANT[@]} -gt 0 ]; then
777
+ hit=0
778
+ for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
779
+ [ $hit -eq 1 ] || continue
780
+ fi
781
+ ROWS=$((ROWS + 1))
782
+ line=$(printf '%-10s' "$page")
783
+ for vw in $VIEWPORTS_SP; do
784
+ log="matching/census-$page-$vw.log"
785
+ RUNS=$((RUNS + 1))
786
+ "$NODE" "$SC" --ref "$REF$refpath" --cand "$CAND$candpath" --vw "$vw" >"$log" 2>&1
787
+ # GUARD 2 \u2014 this run produced a census. The header and the counts line are
788
+ # written together at style-census.mjs:166-169 and only after both pages
789
+ # have been walked, so they are the artefact a completed run leaves and a
790
+ # crashed one cannot. The viewport is read back out of the header because a
791
+ # --vw that did not take would otherwise fill three differently named logs
792
+ # with the same 1440 census. \`ref runs\`/\`cand runs\` must be non-zero: two
793
+ # pages that rendered no text at all agree perfectly and mean nothing.
794
+ if ! grep -qE "^=== style census, viewport $vw ===\\$" "$log" ||
795
+ ! grep -qE "^ref runs: [1-9][0-9]* +cand runs: [1-9][0-9]* +mismatches: [0-9]+ +ambiguous: [0-9]+\\$" "$log"; then
796
+ BROKEN=$((BROKEN + 1))
797
+ echo "census.sh: $page @$vw produced no usable census \u2014 see $log" >&2
798
+ line="$line$(printf '%8s' '!!')"
799
+ continue
800
+ fi
801
+ # census-count splits the log three ways: REAL mismatches, AMBIGUOUS
802
+ # same-text collisions (style-census's own split), and DECLARED rows the
803
+ # operator has already ruled on (matching/census-deviations.mjs, the same
804
+ # contract as floors.mjs). Only the first is outstanding work \u2014 without the
805
+ # third, this gate can never reach zero and its number means nothing.
806
+ read -r n a d <<<"$("$NODE" matching/census-count.mjs "$log")"
807
+ # GUARD 2b \u2014 three integers, or the counter did not count. census-count.mjs
808
+ # is the reader that turned a stack trace into "0 0 0"; a run of it that
809
+ # dies (a broken census-deviations.mjs, a bad path) prints nothing at all,
810
+ # and bash reads an empty $n as zero in the arithmetic below.
811
+ counted=1
812
+ for v in "\${n:-}" "\${a:-}" "\${d:-}"; do
813
+ case "$v" in "" | *[!0-9]*) counted=0 ;; esac
814
+ done
815
+ if [ "$counted" -eq 0 ]; then
816
+ BROKEN=$((BROKEN + 1))
817
+ echo "census.sh: census-count.mjs did not return three counts for $log" >&2
818
+ line="$line$(printf '%8s' '!!')"
819
+ continue
820
+ fi
821
+ # GUARD 2c \u2014 the number reported is the number the census MEASURED. GUARD 2
822
+ # matched the counts line for PRESENCE and threw its numbers away, so the
823
+ # count printed below came from census-count's row parse and nothing ever
824
+ # compared the two. Measured 2026-09-09: a COMPLETE census whose counts line
825
+ # said "mismatches: 3", whose \`y=\` rows carried one leading space instead of
826
+ # two (census-count.mjs:39 matches /^ {2}y=/), was reported as 0 and this
827
+ # gate printed "Phase 3 CLEAN \u2014 0 undeclared type mismatches", exit 0. The
828
+ # printer lives in the SKILL (style-census.mjs:171-173) and the parser is
829
+ # copied into every site, so the two version independently and drift needs
830
+ # nobody to touch either repo.
831
+ #
832
+ # The identity is exact for mismatches: style-census prints at most 100 rows
833
+ # and states the remainder on its own line (style-census.mjs:175-177), so
834
+ # parsed + remainder == reported. Ambiguous rows are capped the same way
835
+ # (:184) but the remainder is NOT stated, so only a floor is checkable
836
+ # there \u2014 any is not none, and the parse may never exceed the report.
837
+ counts=$(grep -E "^ref runs: [1-9][0-9]* +cand runs: [1-9][0-9]* +mismatches: [0-9]+ +ambiguous: [0-9]+$" "$log" | tail -1)
838
+ said_m=\${counts##*mismatches: }
839
+ said_m=\${said_m%% *}
840
+ said_a=\${counts##*ambiguous: }
841
+ more=$(sed -n 's/^ *\u2026 and \\([0-9][0-9]*\\) more (truncated print, all counted)$/\\1/p' "$log" | tail -1)
842
+ if [ $((n + d + \${more:-0})) -ne "$said_m" ] ||
843
+ [ "$a" -gt "$said_a" ] ||
844
+ { [ "$said_a" -gt 0 ] && [ "$a" -eq 0 ]; }; then
845
+ BROKEN=$((BROKEN + 1))
846
+ echo "census.sh: $log reports mismatches: $said_m ambiguous: $said_a, but" >&2
847
+ echo " census-count.mjs read $((n + d)) mismatch row(s)\${more:+ (+ $more truncated)} and $a ambiguous." >&2
848
+ echo " The census and its reader disagree; there is no count to report." >&2
849
+ line="$line$(printf '%8s' '!!')"
850
+ continue
851
+ fi
852
+ AMB=$((AMB + a))
853
+ DECL=$((DECL + d))
854
+ TOTAL=$((TOTAL + n))
855
+ line="$line$(printf '%8s' "$n")"
856
+ done
857
+ echo "$line"
858
+ done < <(pages)
859
+
860
+ echo
861
+ # GUARD 3 \u2014 something was actually censused. Process substitution above, NOT a
862
+ # pipe: a pipe runs the loop in a subshell and every counter here would still
863
+ # read 0, which is exactly the green this file exists to stop (gate.sh:123-125
864
+ # records the same trap for FAILED_PREFLIGHT). A page name that matches nothing
865
+ # must not report CLEAN either \u2014 strikes.mjs:147-156 is the same refusal, and
866
+ # it was written because a typo silently greened rule 3 on six of nine pages.
867
+ if [ "$ROWS" -eq 0 ]; then
868
+ if [ -n "$SEEN" ]; then
869
+ echo "census.sh: \\"\${WANT[*]:-}\\" matches no page \u2014 refusing to report CLEAN." >&2
870
+ echo " known pages:$SEEN" >&2
871
+ else
872
+ echo "census.sh: the page table is empty \u2014 refusing to report CLEAN." >&2
873
+ echo " node matching/harness.mjs --table printed no rows; check" >&2
874
+ echo " \\"pages\\" in matching/harness.json." >&2
875
+ fi
876
+ exit 2
877
+ fi
878
+ if [ "$BROKEN" -gt 0 ]; then
879
+ echo "CENSUS INCOMPLETE \u2014 $BROKEN of $RUNS run(s) produced no usable census (marked !!)."
880
+ echo "Those page/viewport pairs have NOT been measured; there is no count to"
881
+ echo "report for them, and the totals below would be a floor, not a score."
882
+ echo "Full runs: matching/census-<page>-<vw>.log"
883
+ exit 2
884
+ fi
885
+ if [ "$TOTAL" -eq 0 ]; then
886
+ echo "Phase 3 CLEAN \u2014 0 undeclared type mismatches ($DECL declared, $AMB ambiguous)."
887
+ echo "($RUNS censused run(s) over $ROWS page(s), each one counted.)"
888
+ [ "$AMB" -gt 0 ] && echo "($AMB ambiguous same-text rows remain: each needs ADJUDICATING," &&
889
+ echo " not fixing \u2014 our element matches, another sharing its text does not.)"
890
+ exit 0
891
+ fi
892
+ echo "$TOTAL type mismatch(es) remain (+ $AMB ambiguous, $DECL declared)."
893
+ echo "Full runs: matching/census-<page>-<vw>.log"
894
+ echo "A mismatch is a defect or a ledgered deviation. An ambiguous row is neither"
895
+ echo "until you look: the census keys on TEXT, so two different elements sharing a"
896
+ echo "string land under one key and only one of them may be wrong."
897
+ exit 1
898
+ `;
899
+ var NEXT_MJS_RELATIVE = "matching/next.mjs";
900
+ var NEXT_MJS_TEMPLATE = `// What is still broken, ranked. Exits 1 while work remains.
901
+ //
902
+ // node matching/next.mjs
903
+ //
904
+ // Round protocol step 0 (repo CLAUDE.md rule 5). A commit is a CHECKPOINT, not
905
+ // a stopping point: after committing, run this. If it exits 1 there is a named
906
+ // next action and the round continues without handing control back.
907
+ //
908
+ // Reads the most recent gate log per page rather than the whole corpus, so it
909
+ // reflects HEAD rather than history (that is strikes.mjs's job).
910
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
911
+ import { join } from "node:path";
912
+ // DIR comes from harness.mjs (imported below \u2014 ESM hoists it, so it is bound
913
+ // before the pause check runs). It was \`new URL(".", import.meta.url).pathname\`
914
+ // here, which percent-encodes, so under a checkout path with a space in it this
915
+ // read a directory that does not exist (#47).
916
+
917
+ // PAUSE SWITCH. While matching/PAUSED exists this hands out no agenda and
918
+ // exits 0. It is deliberately the FIRST thing that runs: no report is read, no
919
+ // score is printed, nothing tempting is put on screen to argue with.
920
+ //
921
+ // Why an exit code and not a note somewhere: rule 5 is a LOOP \u2014 "after
922
+ // committing, run next.mjs; while it exits 1 there is a named next action and
923
+ // the round continues". A pause written as prose loses to that loop, because
924
+ // the loop is mechanical and the prose is not. Exiting 0 satisfies rule 5
925
+ // truthfully rather than suspending it: there is no next action.
926
+ const PAUSE = join(DIR, "PAUSED");
927
+ if (existsSync(PAUSE)) {
928
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
929
+ console.log(readFileSync(PAUSE, "utf8").trimEnd());
930
+ process.exit(0);
931
+ }
932
+
933
+ import { FLOORS, ACCEPTED } from "./floors.mjs";
934
+ import {
935
+ DIR,
936
+ TOTALS,
937
+ THRESHOLD,
938
+ MAX_HEIGHT_DELTA,
939
+ REPORT_SCHEMA,
940
+ uncountable,
941
+ scorable,
942
+ unscorableWhy,
943
+ regionCountWhy,
944
+ byKey,
945
+ } from "./harness.mjs";
946
+
947
+ // Reports written by a different page-diff, by page key. Kept rather than
948
+ // dropped: silently ignoring them is how a page vanishes from the score.
949
+ const schemaMismatch = new Set();
950
+
951
+ const latest = new Map();
952
+ for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
953
+ const m = /^out-[^-]+-(.+)$/.exec(d);
954
+ // Membership in the page table is asked of the TABLE. \`!TOTALS[m[1]]\` was a
955
+ // NUMBER standing in for a fact: it is null for an unanchored page and 0 for
956
+ // an empty matrix, and either dropped the page out of \`latest\` while
957
+ // \`Object.keys(TOTALS)\` still listed it \u2014 so a gate run that had just
958
+ // SUCCEEDED came back as "no countable gate run".
959
+ if (!m || !byKey[m[1]]) continue;
960
+ let report, mtime;
961
+ try {
962
+ const p = join(DIR, d, "report.json");
963
+ report = JSON.parse(readFileSync(p, "utf8"));
964
+ mtime = statSync(p).mtimeMs;
965
+ } catch {
966
+ continue;
967
+ }
968
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
969
+ // page. Picking one up as "latest" silently reports scores nobody can ship \u2014
970
+ // it happened immediately: an --mask-photos probe of yfv made \`top\` @834 read
971
+ // 43.9% here while the real gate had it passing at 1.3%. Missing
972
+ // schemaVersion means "written before the field existed" = 0; it is not an
973
+ // error on its own, only when it would blank a page.
974
+ //
975
+ // The predicate itself lives in harness.mjs now, because gate.sh asks the
976
+ // same question per run and two copies of one question drift \u2014 a gate that
977
+ // greens a run this file then drops is the same false green one step along.
978
+ const why = uncountable(report.meta ?? {});
979
+ if (why === "schema") {
980
+ schemaMismatch.add(m[1]);
981
+ continue;
982
+ }
983
+ if (why) continue;
984
+ const prev = latest.get(m[1]);
985
+ if (!prev || mtime > prev.mtime) latest.set(m[1], { dir: d, mtime, report });
986
+ }
987
+
988
+ const blanked = [...schemaMismatch].filter((p) => !latest.has(p));
989
+ if (blanked.length) {
990
+ console.error(
991
+ \`next: \${blanked.length} page(s) have no run at report schema \${REPORT_SCHEMA} \u2014 \` +
992
+ \`their newest reports came from a different page-diff (\${blanked.sort().join(", ")}).\\n\` +
993
+ \` Re-run: bash matching/gate.sh <tag> \${blanked.sort().join(" ")}\`,
994
+ );
995
+ process.exit(2);
996
+ }
997
+ if (latest.size === 0) {
998
+ console.error(
999
+ "next: no parseable gate run under matching/ \u2014 refusing to report a score.\\n" +
1000
+ " Run bash matching/gate.sh <tag> first.",
1001
+ );
1002
+ process.exit(2);
1003
+ }
1004
+
1005
+ const rows = [];
1006
+ const accepted = [];
1007
+ let openTotal = 0;
1008
+ let floorTotal = 0;
1009
+ for (const [page, { dir, report }] of latest) {
1010
+ const fails = report.regions.filter((r) => !r.pass);
1011
+ for (const f of fails) {
1012
+ const floor = FLOORS.find((fl) => fl.match(f, page));
1013
+ if (floor) {
1014
+ floorTotal++;
1015
+ continue;
1016
+ }
1017
+ const ack = ACCEPTED.find((a) => a.match(f, page));
1018
+ if (ack) {
1019
+ accepted.push({ page, vw: f.viewport, label: f.label, why: ack.why });
1020
+ continue;
1021
+ }
1022
+ openTotal++;
1023
+ rows.push({
1024
+ page,
1025
+ vw: f.viewport,
1026
+ label: f.label,
1027
+ mm: f.mismatchFraction,
1028
+ dh: f.heightDeltaFraction ?? 0,
1029
+ dir,
1030
+ });
1031
+ }
1032
+ }
1033
+
1034
+ // Pages that REPORTED but cannot be scored: no anchors, so their regions are
1035
+ // whatever page-diff's fallback cut and there is no model for one to be right
1036
+ // against. Kept separate from \`scored\` and never ranked with it.
1037
+ const unscorable = [...latest.entries()]
1038
+ .filter(([p]) => !scorable(p))
1039
+ .map(([p, v]) => ({ p, regions: v.report.regions.length }))
1040
+ .sort((a, b) => a.p.localeCompare(b.p));
1041
+
1042
+ // \`.filter(scorable)\` before \`.map\`, because the sort below divides by \`total\`
1043
+ // and an unanchored page's ratio is NOT BOUNDED BY 1. A passing one scored
1044
+ // 16/4 = 4.0 and sorted LAST, i.e. best, so \`worst\` could never name the one
1045
+ // page whose Phase 1 was not done; a failing one scored 0/4 = 0.0, sorted
1046
+ // FIRST, and put \`grid-0-0\`, \`grid-1-0\` \u2026 on the agenda \u2014 an instruction to fix
1047
+ // geometry against regions page-diff invented.
1048
+ const scored = [...latest.entries()]
1049
+ .filter(([p]) => scorable(p))
1050
+ .map(([p, v]) => ({
1051
+ p,
1052
+ pass: v.report.regions.filter((r) => r.pass).length,
1053
+ total: TOTALS[p],
1054
+ }))
1055
+ .sort((a, b) => a.pass / a.total - b.pass / b.total);
1056
+
1057
+ // The denominator is the DECLARED site, not the pages that happened to report.
1058
+ // Summed over \`scored\` it shrank to match the numerator: 8 of 9 pages reporting
1059
+ // read SCORE 160/160 while the ninth, whose page-diff had crashed, was in
1060
+ // neither the numerator nor the denominator nor the list below. harness.mjs:61-67
1061
+ // already gives the reason \u2014 "a wrong denominator makes the score a lie in the
1062
+ // flattering direction" \u2014 and that fix was applied per REGION (\`total:
1063
+ // TOTALS[p]\`) and never per PAGE.
1064
+ const unmeasured = Object.keys(TOTALS)
1065
+ .filter((p) => !latest.has(p))
1066
+ .sort();
1067
+
1068
+ // Pages that REPORTED, are scorable, and whose newest report carries FEWER
1069
+ // regions than TOTALS predicts. \`uncountable()\` cannot see this \u2014 none of its
1070
+ // arms counts regions \u2014 so a short report was fully countable, contributed no
1071
+ // failing region, and fell through to "Backlog is empty", exit 0, over regions
1072
+ // nobody had measured. The score line stayed honest (\`3/6\`), which is what made
1073
+ // it easy to walk past. The identity is harness.mjs's regionCountWhy, the same
1074
+ // one gate.sh's --check-run refuses a run with \u2014 asked here of the report being
1075
+ // SCORED, because a report can reach \`latest\` without ever passing through the
1076
+ // gate (#756).
1077
+ const short = scored
1078
+ .map((s) => ({ p: s.p, why: regionCountWhy(s.p, latest.get(s.p).report) }))
1079
+ .filter((s) => s.why)
1080
+ .sort((a, b) => a.p.localeCompare(b.p));
1081
+ const sum = scored.reduce((a, s) => a + s.pass, 0);
1082
+ // Summed EXPLICITLY over the scorable pages rather than over TOTALS' values:
1083
+ // \`a + null\` is silently \`a\`, and a denominator that is right only because of a
1084
+ // coercion is the same lie one refactor away.
1085
+ const pagesAll = Object.keys(TOTALS).length;
1086
+ const pagesScorable = Object.keys(TOTALS).filter((p) => scorable(p)).length;
1087
+ const max = Object.keys(TOTALS).reduce((a, p) => a + (scorable(p) ? TOTALS[p] : 0), 0);
1088
+
1089
+ // A score is printed only if SOMETHING can carry one. \`SCORE 0/0\` is a third
1090
+ // lie and the one that reads best of all, so it is never printed: with nothing
1091
+ // scorable the line says so in words and gives no fraction to quote.
1092
+ console.log(
1093
+ (pagesScorable === 0
1094
+ ? \`NO SCORE \u2014 0 of \${pagesAll} page(s) can carry one.\`
1095
+ : \`SCORE \${sum}/\${max} regions passing over \${pagesScorable} of \${pagesAll} page(s)\` +
1096
+ (unmeasured.length ? \` \u2014 \${unmeasured.length} page(s) NOT MEASURED\` : "")) + "\\n",
1097
+ );
1098
+ console.log(
1099
+ [
1100
+ ...scored.map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`),
1101
+ // The run's OWN region count, as EVIDENCE FOR THE REFUSAL \u2014 it proves a run
1102
+ // happened and explains why it cannot be scored, so a refusal is not
1103
+ // mistaken for a crash. The pass FRACTION is deliberately withheld: it is
1104
+ // the number with no referent, and the number that gets quoted.
1105
+ ...unscorable.map(
1106
+ (u) =>
1107
+ \` \${u.p.padEnd(9)} \${String(u.regions).padStart(2)} region(s) NOT SCORABLE \u2014 \${unscorableWhy(u.p)}\`,
1108
+ ),
1109
+ // \`?/N\`, never \`0/N\`: an unmeasured page is not a page that scored zero,
1110
+ // and printing zero would be a different lie. \`?/?\` when the page is ALSO
1111
+ // unanchored \u2014 \`?/null\` would name the denominator "null", which is worse
1112
+ // than the number it replaced.
1113
+ ...unmeasured.map((p) => \` \${p.padEnd(9)} ?/\${TOTALS[p] ?? "?"} NOT MEASURED\`),
1114
+ ].join("\\n"),
1115
+ );
1116
+
1117
+ if (accepted.length) {
1118
+ console.log(\`\\nOperator-ACCEPTED failures (left failing on purpose):\`);
1119
+ for (const a of accepted)
1120
+ console.log(\` \${a.page} @\${a.vw} "\${a.label}" \u2014 \${a.why.slice(0, 96)}\u2026\`);
1121
+ }
1122
+
1123
+ // BEFORE the \`!rows.length\` branch, and deliberately so: an unmeasured page
1124
+ // contributes no failing region, so that branch would print "Backlog is empty"
1125
+ // and exit 0 over a page nobody had looked at. Exit 2 matches the two guards
1126
+ // above (\`blanked\`, \`latest.size === 0\`) \u2014 neither "clean" nor "work remains"
1127
+ // but "this cannot be scored", the one answer rule 5's while-it-exits-1 loop
1128
+ // cannot swallow. The score print stays above it so the partial state is still
1129
+ // visible.
1130
+ // A page with no anchors has not finished Phase 1, so a score has no referent
1131
+ // and this refuses to invent one \u2014 the same answer \`refMark: ""\` gets from
1132
+ // checkRef and an absent \`## <page>\` SPEC section gets from gate.sh. All three
1133
+ // are seed sentinels, and this was the one that failed OPEN. Repo CLAUDE.md
1134
+ // rule 1's corollary is the whole argument: a field that can only observe
1135
+ // configuration must never be named after the thing it cannot observe, and
1136
+ // "12 of 12 grid rows passed" observes a screenshot cut into quarters, not a
1137
+ // design anyone specced.
1138
+ //
1139
+ // PRINTED here, EXITED below: \`unmeasured\` and \`unscorable\` are different pages
1140
+ // with different remedies, and exiting inside the first block would hide the
1141
+ // second from an operator who then fixes only what was printed.
1142
+ if (unscorable.length) {
1143
+ console.error(
1144
+ \`\\nnext: \${unscorable.length} page(s) have no anchors, so their regions are page-diff's own\\n\` +
1145
+ \` fallback cut and cannot be scored \u2014 \` +
1146
+ unscorable.map((u) => \`\${u.p} (\${u.regions} region(s))\`).join(", ") +
1147
+ \`.\\n Set "anchors" for them in matching/harness.json to section texts that exist on\\n\` +
1148
+ \` BOTH the reference and the candidate, then re-run: bash matching/gate.sh <tag> \` +
1149
+ unscorable.map((u) => u.p).join(" "),
1150
+ );
1151
+ }
1152
+
1153
+ if (short.length) {
1154
+ console.error(
1155
+ \`\\nnext: \${short.length} page(s) reported fewer regions than their anchors predict, so their\\n\` +
1156
+ \` score has regions nobody measured \u2014 \` +
1157
+ short.map((s) => \`\${s.p}: \${s.why}\`).join("; ") +
1158
+ \`.\\n An anchor whose text is no longer on the page cuts no region: check the anchors in\\n\` +
1159
+ \` matching/harness.json against BOTH renders, then re-run: bash matching/gate.sh <tag> \` +
1160
+ short.map((s) => s.p).join(" "),
1161
+ );
1162
+ }
1163
+
1164
+ if (unmeasured.length) {
1165
+ console.error(
1166
+ \`\\nnext: \${unmeasured.length} page(s) have no countable gate run \u2014 \${unmeasured.join(", ")}.\\n\` +
1167
+ \` Re-run: bash matching/gate.sh <tag> \${unmeasured.join(" ")}\`,
1168
+ );
1169
+ process.exit(2);
1170
+ }
1171
+
1172
+ // See above: deliberately separate statements, not an \`else\` chain \u2014 every
1173
+ // refusal is PRINTED before the first one EXITS.
1174
+ if (unscorable.length) process.exit(2);
1175
+ if (short.length) process.exit(2);
1176
+
1177
+ if (!rows.length) {
1178
+ console.log(
1179
+ \`\\nNo open geometry failures. \${floorTotal} declared floor(s) remain.\`,
1180
+ );
1181
+ console.log(
1182
+ "Backlog is empty \u2014 Phases 5 (states) and 6 (adversarial review) are what is left.",
1183
+ );
1184
+ process.exit(0);
1185
+ }
1186
+
1187
+ // Worst page first, then worst region inside it: fix where the model is most wrong.
1188
+ //
1189
+ // INVARIANT, documented rather than guarded because no test could redden a
1190
+ // guard here: \`scored[0]\` is safe because \`scored\` is empty only when every
1191
+ // page in \`latest\` is unanchored \u2014 and that means \`unscorable.length > 0\`, so
1192
+ // the exit above already fired. (\`latest\` being empty is caught further up.)
1193
+ // \`rows\` likewise still contains unanchored pages' failing regions; they are
1194
+ // never printed because that same exit fires first. Both facts depend on the
1195
+ // exit staying ABOVE this line \u2014 a second filter here would be a second place
1196
+ // to keep in sync, which is how checkRun and next.mjs drifted apart to begin
1197
+ // with. If that exit ever moves, this becomes a TypeError.
1198
+ const worst = scored[0].p;
1199
+ rows.sort(
1200
+ (a, b) =>
1201
+ (a.page === worst ? -1 : 0) - (b.page === worst ? -1 : 0) || b.mm - a.mm,
1202
+ );
1203
+
1204
+ console.log(
1205
+ \`\\n\${openTotal} open failure(s) + \${floorTotal} declared floor(s).\`,
1206
+ );
1207
+ console.log(\`\\nNEXT: \${worst} \u2014 worst page. Its open regions:\\n\`);
1208
+ for (const r of rows.filter((r) => r.page === worst)) {
1209
+ const why = [];
1210
+ if (r.mm > THRESHOLD) why.push(\`pixels \${(r.mm * 100).toFixed(1)}%\`);
1211
+ if (Math.abs(r.dh) > MAX_HEIGHT_DELTA)
1212
+ why.push(\`height \${(r.dh * 100).toFixed(1)}%\`);
1213
+ console.log(
1214
+ \` @\${String(r.vw).padEnd(5)} \${r.label.slice(0, 44).padEnd(45)} \${why.join(" + ")}\`,
1215
+ );
1216
+ }
1217
+ // Only scripts the match-harness recipe INSTALLS may be named here: this is the
1218
+ // first thing an operator is told to run on a failing agenda, and on every site
1219
+ // but this one \`probe-anchor-parity.mjs\` (a site-specific probe, never shipped)
1220
+ // failed module-not-found \u2014 reddoorla/reddoor-maintenance#732, #767.
1221
+ console.log(\`\\nBefore treating any of these as geometry:\`);
1222
+ console.log(
1223
+ \` node matching/strikes.mjs \${worst} # has it stalled? then change the MODEL\`,
1224
+ );
1225
+ console.log(
1226
+ \`\\nRound continues. Do not hand back control with work outstanding.\`,
1227
+ );
1228
+ process.exit(1);
1229
+ `;
1230
+ var STRIKES_MJS_RELATIVE = "matching/strikes.mjs";
1231
+ var STRIKES_MJS_TEMPLATE = `// The 3-strike rule, made countable.
1232
+ //
1233
+ // node matching/strikes.mjs [page]
1234
+ //
1235
+ // The matching skill says: "A region that has not improved after 3 fix ->
1236
+ // re-run cycles: stop. Present the three attempts. Do not widen the threshold,
1237
+ // mask it, or reclassify it \u2014 a stuck region means your model of it is wrong."
1238
+ //
1239
+ // That rule was blown twice on this project (services \`top\`, yfv \`top\`) purely
1240
+ // because nobody was counting. This reads every matching/out-*/report.json,
1241
+ // reconstructs each region's history in chronological order, and prints the
1242
+ // regions that are OUT OF STRIKES. Exit 1 when any exist, so a round that
1243
+ // starts by running this cannot quietly attempt a fourth cycle.
1244
+ //
1245
+ // WHAT THIS ACTUALLY MEASURES (read before trusting a number): report.json
1246
+ // cannot record INTENT, so a "cycle" here is any completed gate run in which
1247
+ // the region was still failing and did not improve by IMPROVE_PP. Runs aimed at
1248
+ // a different region on the same page therefore count too. That makes this a
1249
+ // STALL detector, not a literal attempt counter \u2014 "this has been failing,
1250
+ // unchanged, across N gate runs while you were working on this page." That is
1251
+ // the more useful discipline number anyway, and it is honest about being an
1252
+ // upper bound. Improvement resets the count: real progress earns a fresh start.
1253
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
1254
+ import { join } from "node:path";
1255
+ import { FLOORS, ACCEPTED } from "./floors.mjs";
1256
+ import {
1257
+ DIR,
1258
+ PAGES,
1259
+ THRESHOLD,
1260
+ MAX_HEIGHT_DELTA,
1261
+ REPORT_SCHEMA,
1262
+ } from "./harness.mjs";
1263
+
1264
+ // PAUSE SWITCH \u2014 see matching/next.mjs for why this is an exit code. Round
1265
+ // protocol step 1 is "run strikes.mjs; the stalled regions are the agenda", so
1266
+ // this is the second door into the backlog and has to be shut too. Exits 0:
1267
+ // there are no strikes to spend when no round is being played.
1268
+ //
1269
+ // DIR is harness.mjs's fileURLToPath'd directory, not URL#pathname: pathname
1270
+ // percent-encodes, so under a checkout path with a space this \`existsSync\` was
1271
+ // false and the pause switch failed OPEN \u2014 an agenda handed out mid-pause (#47).
1272
+ const PAUSED = join(DIR, "PAUSED");
1273
+ if (existsSync(PAUSED)) {
1274
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
1275
+ console.log(readFileSync(PAUSED, "utf8").trimEnd());
1276
+ process.exit(0);
1277
+ }
1278
+
1279
+ const IMPROVE_PP = 0.01; // 1 percentage point = the smallest move worth calling progress
1280
+ const MAX_STRIKES = 3;
1281
+ const only = process.argv[2] ?? null;
1282
+
1283
+ // The gate key for a report, from its ref path. Deriving it by string surgery
1284
+ // disagreed with the gate on exactly six of the nine rows \u2014 yfv, contact, atd,
1285
+ // svc, qa and team (measured 2026-09-09 against harness.json); the table knows.
1286
+ // keyOf (below) stays as the fallback for a run whose ref was rewritten.
1287
+ const pageOf = (ref) => {
1288
+ const path = new URL(ref).pathname.replace(/\\/$/, "") || "/";
1289
+ return PAGES.find((p) => p.ref === path)?.key ?? null;
1290
+ };
1291
+
1292
+ const runs = [];
1293
+ const unreadable = [];
1294
+ for (const dir of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
1295
+ let report;
1296
+ try {
1297
+ report = JSON.parse(readFileSync(join(DIR, dir, "report.json"), "utf8"));
1298
+ } catch {
1299
+ continue; // not a completed run dir
1300
+ }
1301
+ if (!report.meta?.ref || !Array.isArray(report.regions)) continue;
1302
+ // A report from another schema is still usable for a STALL count as long as
1303
+ // it carries the two fields this reads. Anything else is counted and named,
1304
+ // never silently dropped \u2014 an under-counted history reads as "clear".
1305
+ const usable = report.regions.every(
1306
+ (r) =>
1307
+ typeof r.mismatchFraction === "number" && typeof r.pass === "boolean",
1308
+ );
1309
+ if (!usable) {
1310
+ unreadable.push(\`\${dir} (schema \${report.meta.schemaVersion ?? 0})\`);
1311
+ continue;
1312
+ }
1313
+ runs.push({
1314
+ dir,
1315
+ at: report.meta.generatedAt,
1316
+ meta: report.meta,
1317
+ regions: report.regions,
1318
+ });
1319
+ }
1320
+
1321
+ // The failure mode of a stall detector is always "clear", which is exactly the
1322
+ // answer that stops nobody. A corpus this could not read is not a clean one.
1323
+ if (runs.length === 0) {
1324
+ console.error(
1325
+ \`strikes: no parseable gate run under matching/ \u2014 refusing to report "clear".\` +
1326
+ (unreadable.length
1327
+ ? \`\\n \${unreadable.length} report(s) unreadable at schema \${REPORT_SCHEMA}: \${unreadable.slice(0, 5).join(", ")}\`
1328
+ : ""),
1329
+ );
1330
+ process.exit(2);
1331
+ }
1332
+ if (unreadable.length) {
1333
+ console.error(
1334
+ \`strikes: ignored \${unreadable.length} unreadable report(s) \u2014 the history below is incomplete.\` +
1335
+ \`\\n \${unreadable.slice(0, 5).join(", ")}\`,
1336
+ );
1337
+ }
1338
+
1339
+ runs.sort((a, b) => a.at.localeCompare(b.at));
1340
+
1341
+ // The gate page KEY, recovered from the run dir the way next.mjs does it
1342
+ // (out-<TAG>-<page>, split on the first hyphen \u2014 the tag is hyphen-free by
1343
+ // gate.sh's own preflight). \`pageOf\` now returns that same key off the table,
1344
+ // so the two vocabularies agree and the round protocol in CLAUDE.md \u2014 "run
1345
+ // \`strikes.mjs <page>\` with the key you just passed to gate.sh", which until
1346
+ // 2026-08-13 silently matched nothing \u2014 works either way. This stays because
1347
+ // a dir key is not always a gate key: 69 of this corpus's 377 run dirs are
1348
+ // hand-named probes (out-band3, out-390masked), and FLOORS matches on it.
1349
+ const keyOf = (dir) => /^out-[^-]+-(.+)$/.exec(dir)?.[1] ?? null;
1350
+
1351
+ // key -> chronological list of {dir, at, mm, pass, masked}
1352
+ const history = new Map();
1353
+ // Regions the OPERATOR accepted, by key \u2014 counted so the skip is visible, never
1354
+ // silent (next.mjs prints them under their own heading for the same reason).
1355
+ const acceptedKeys = new Set();
1356
+ for (const run of runs) {
1357
+ const page = pageOf(run.meta.ref) ?? keyOf(run.dir) ?? "unknown";
1358
+ const gateKey = keyOf(run.dir);
1359
+ if (only && page !== only && gateKey !== only) continue;
1360
+ for (const r of run.regions) {
1361
+ // A DECLARED FLOOR is flat by definition \u2014 reporting it as stalled is noise
1362
+ // that hides a real stall. It stays in the LEDGER; it does not belong here.
1363
+ if (FLOORS.some((fl) => fl.match(r, gateKey || page))) continue;
1364
+ const key = \`\${page}|\${r.viewport}|\${r.label}\`;
1365
+ // An operator-ACCEPTED region will not move either \u2014 that is what accepting
1366
+ // it meant \u2014 so it is flat by definition too. Left in, it tripped this
1367
+ // detector on every run from the decision onward, and the one lever that
1368
+ // silenced it was the reclassification rule 3 forbids: moving the entry to
1369
+ // FLOORS (reddoorla/reddoor-maintenance#772).
1370
+ if (ACCEPTED.some((a) => a.match(r, gateKey || page))) {
1371
+ acceptedKeys.add(key);
1372
+ continue;
1373
+ }
1374
+ if (!history.has(key)) history.set(key, []);
1375
+ history.get(key).push({
1376
+ dir: run.dir,
1377
+ at: run.at,
1378
+ mm: r.mismatchFraction,
1379
+ dh: r.heightDeltaFraction,
1380
+ pass: r.pass,
1381
+ // a run with masks/neutralised media is not comparable to a clean one
1382
+ dirty:
1383
+ (run.meta.mask?.length ?? 0) > 0 ||
1384
+ run.meta.neutralizeMedia ||
1385
+ run.meta.maskPhotos,
1386
+ });
1387
+ }
1388
+ }
1389
+
1390
+ // A name that matches no run must NOT report "clear". This check exists to stop
1391
+ // work on a stalled region, so failing open is the one thing it may never do \u2014
1392
+ // a typo'd or wrong-vocabulary page silently greened rule 3 for six of the nine
1393
+ // pages. Fail loud instead, and say what the vocabulary is \u2014 the TABLE's keys,
1394
+ // which is all a user should ever type. This used to list every name seen in
1395
+ // the corpus (pageOf AND keyOf of every run dir): 26 names on this site, of
1396
+ // which 9 were pages, 3 were a retired vocabulary each backed by one legacy run
1397
+ // that read as *clear*, and 14 were probe dirs. keyOf stays as the LOOKUP
1398
+ // fallback for exactly those dirs; it was never the vocabulary (#48).
1399
+ if (only && history.size === 0) {
1400
+ console.error(
1401
+ \`strikes: "\${only}" matches no gate run \u2014 refusing to report "clear".\\n\` +
1402
+ \` known pages: \${PAGES.map((p) => p.key).sort().join(", ")}\`,
1403
+ );
1404
+ process.exit(2);
1405
+ }
1406
+
1407
+ if (acceptedKeys.size)
1408
+ console.log(
1409
+ \`strikes: \${acceptedKeys.size} region(s) skipped as operator-accepted (floors.mjs ACCEPTED).\`,
1410
+ );
1411
+
1412
+ const stuck = [];
1413
+ for (const [key, all] of history) {
1414
+ const h = all.filter((e) => !e.dirty); // compare like with like
1415
+ if (h.length === 0) continue;
1416
+ const last = h[h.length - 1];
1417
+ if (last.pass) continue;
1418
+
1419
+ let best = Infinity;
1420
+ let strikes = 0;
1421
+ let sinceIdx = 0;
1422
+ for (let i = 0; i < h.length; i++) {
1423
+ if (h[i].mm < best - IMPROVE_PP) {
1424
+ best = Math.min(best, h[i].mm);
1425
+ strikes = 0;
1426
+ sinceIdx = i;
1427
+ } else {
1428
+ best = Math.min(best, h[i].mm);
1429
+ strikes++;
1430
+ }
1431
+ }
1432
+ if (strikes >= MAX_STRIKES) stuck.push({ key, strikes, h, sinceIdx, last });
1433
+ }
1434
+
1435
+ // Triage order: the worst-matching region that has never moved is the one whose
1436
+ // model is most wrong. Sorting by strike count instead would put a 0.3% region
1437
+ // that only fails on height above a 76% region that fails on everything.
1438
+ stuck.sort((a, b) => b.last.mm - a.last.mm);
1439
+
1440
+ if (stuck.length === 0) {
1441
+ console.log(
1442
+ \`strikes: clear \u2014 no failing region has stalled for \${MAX_STRIKES}+ gate runs\` +
1443
+ (only ? \` on \${only}\` : "") +
1444
+ ".",
1445
+ );
1446
+ process.exit(0);
1447
+ }
1448
+
1449
+ const why = (e) => {
1450
+ const reasons = [];
1451
+ if (e.mm > THRESHOLD) reasons.push(\`pixels \${(e.mm * 100).toFixed(1)}%\`);
1452
+ if (Math.abs(e.dh ?? 0) > MAX_HEIGHT_DELTA)
1453
+ reasons.push(\`height \${((e.dh ?? 0) * 100).toFixed(1)}%\`);
1454
+ return reasons.join(" + ") || "marginal";
1455
+ };
1456
+
1457
+ console.log(
1458
+ \`STALLED \u2014 \${stuck.length} failing region(s) have not moved in \${MAX_STRIKES}+ gate runs.\\n\` +
1459
+ \`These need a new model or the operator, not another attempt. Worst first:\\n\`,
1460
+ );
1461
+ for (const { key, strikes, h, sinceIdx, last } of stuck.slice(0, 20)) {
1462
+ const [page, vw, label] = key.split("|");
1463
+ console.log(
1464
+ \`\${page} @\${vw} "\${label}" \u2014 \${why(last)}, flat across \${strikes} runs\`,
1465
+ );
1466
+ const window = h.slice(Math.max(0, sinceIdx));
1467
+ const shown = window.length > 4 ? [window[0], ...window.slice(-3)] : window;
1468
+ for (const [i, e] of shown.entries()) {
1469
+ const gap =
1470
+ window.length > 4 && i === 1
1471
+ ? \` ... \${window.length - 4} more ...\\n\`
1472
+ : "";
1473
+ console.log(
1474
+ gap +
1475
+ \` \${(e.mm * 100).toFixed(1).padStart(5)}% \${e.at.slice(0, 16).replace("T", " ")} \${e.dir}\`,
1476
+ );
1477
+ }
1478
+ console.log();
1479
+ }
1480
+ if (stuck.length > 20) console.log(\`... and \${stuck.length - 20} more.\\n\`);
1481
+ console.log(
1482
+ "Per the matching skill: present the attempts to the operator.\\n" +
1483
+ "Do NOT widen the threshold, add a mask, or reclassify them as a floor.",
1484
+ );
1485
+ process.exit(1);
1486
+ `;
1487
+ var BUILD_SPEC_MJS_RELATIVE = "matching/build-spec.mjs";
1488
+ var BUILD_SPEC_MJS_TEMPLATE = `// Assemble matching/SPEC.md from the per-page sections in spec-sections/.
1489
+ //
1490
+ // node matching/build-spec.mjs
1491
+ //
1492
+ // SPEC.md is the Phase 1 deliverable and the thing matching/gate.sh preflights
1493
+ // against, so it is GENERATED rather than hand-maintained: edit the section
1494
+ // file, rebuild. The page list comes from matching/harness.mjs (the same
1495
+ // reader gate.sh's \`--table\` loop uses) and the heading test from its
1496
+ // specHeadingRe, whose pattern is has_spec's grep verbatim \u2014 so a section can
1497
+ // no longer build fine here and then re-block its own page at the gate.
1498
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
1499
+ import { join } from "node:path";
1500
+ // DIR too: harness.mjs resolves it with fileURLToPath, and the URL#pathname
1501
+ // form this file used percent-encodes a space in the checkout path (#47).
1502
+ import { DIR, PAGES, specHeadingRe } from "./harness.mjs";
1503
+
1504
+ const SECTIONS = join(DIR, "spec-sections");
1505
+
1506
+ // Order = live's nav order, then the detail templates. Both fall out of the
1507
+ // table's own order within each group, so this is not a second list to keep.
1508
+ const KEYS = [
1509
+ ...PAGES.filter((p) => p.group === "nav"),
1510
+ ...PAGES.filter((p) => p.group !== "nav"),
1511
+ ].map((p) => p.spec ?? p.key);
1512
+
1513
+ const parts = [];
1514
+ const missing = [];
1515
+
1516
+ const chrome = join(SECTIONS, "_chrome.md");
1517
+ if (existsSync(chrome)) parts.push(readFileSync(chrome, "utf8").trim());
1518
+ else missing.push("_chrome");
1519
+
1520
+ for (const page of KEYS) {
1521
+ const file = join(SECTIONS, \`\${page}.md\`);
1522
+ if (!existsSync(file)) {
1523
+ missing.push(page);
1524
+ continue;
1525
+ }
1526
+ const body = readFileSync(file, "utf8").trim();
1527
+ // The preflight greps \`^##+ +<page>\`; a section that does not open with its
1528
+ // own key would build fine here and then refuse at the gate, which is the
1529
+ // most confusing possible failure. Catch it now.
1530
+ if (!specHeadingRe(page).test(body)) {
1531
+ console.error(
1532
+ \`WARNING: \${page}.md does not open with a '## \${page}' heading \u2014 gate.sh will still refuse this page.\`,
1533
+ );
1534
+ }
1535
+ parts.push(body);
1536
+ }
1537
+
1538
+ // The header is a spec-section like any other, so a site can say what a
1539
+ // citation looks like there and what its own ladders are. DEFAULT_HEADER is
1540
+ // what a site gets before it writes one: the two paragraphs that are true of
1541
+ // every project, and nothing that is not.
1542
+ const DEFAULT_HEADER = \`# Reference spec
1543
+
1544
+ GENERATED by \\\`node matching/build-spec.mjs\\\` from \\\`matching/spec-sections/\\\`.
1545
+ Edit the section files, not this one.
1546
+
1547
+ This is the Phase 1 deliverable of the \\\`matching-a-page\\\` skill and the
1548
+ precondition \\\`matching/gate.sh\\\` enforces: a page with no section here does not
1549
+ get a geometry round (see the matching rules in CLAUDE.md).
1550
+
1551
+ \`;
1552
+ const headerFile = join(SECTIONS, "_header.md");
1553
+ const header = existsSync(headerFile)
1554
+ ? readFileSync(headerFile, "utf8").trimEnd() + "\\n\\n"
1555
+ : DEFAULT_HEADER;
1556
+
1557
+ writeFileSync(join(DIR, "SPEC.md"), header + parts.join("\\n\\n---\\n\\n") + "\\n");
1558
+
1559
+ const built = KEYS.length - missing.filter((m) => m !== "_chrome").length;
1560
+ console.log(
1561
+ \`SPEC.md built \u2014 \${built}/\${KEYS.length} pages\${missing.length ? \`, MISSING: \${missing.join(", ")}\` : ""}\`,
1562
+ );
1563
+ process.exit(missing.length ? 1 : 0);
1564
+ `;
1565
+ var CENSUS_COUNT_MJS_RELATIVE = "matching/census-count.mjs";
1566
+ var CENSUS_COUNT_MJS_TEMPLATE = `// Count one style-census log, splitting declared deviations out of the total.
1567
+ //
1568
+ // node matching/census-count.mjs matching/census-home-1440.log
1569
+ // -> "<real> <ambiguous> <declared>"
1570
+ //
1571
+ // census.sh calls this per page/viewport. Parsing the log rather than importing
1572
+ // style-census keeps the project's declared-deviation policy out of the shared
1573
+ // skill \u2014 the skill reports what it sees; the project decides what it has
1574
+ // already agreed to.
1575
+ import { readFileSync } from "node:fs";
1576
+ import { DECLARED } from "./census-deviations.mjs";
1577
+
1578
+ const path = process.argv[2];
1579
+ if (!path) {
1580
+ console.error("usage: census-count.mjs <census log>");
1581
+ process.exit(2);
1582
+ }
1583
+
1584
+ // A log that cannot be READ is not a log with nothing IN it. This printed
1585
+ // "0 0 0" and exited 0, and census.sh added that to its total \u2014 the same green
1586
+ // it used to grant for a style-census that never ran. There is no count to
1587
+ // give here; say so and let the caller decide, which is never zero.
1588
+ let text = "";
1589
+ try {
1590
+ text = readFileSync(path, "utf8");
1591
+ } catch (e) {
1592
+ console.error(\`census-count: cannot read \${path} \u2014 \${e.message}\`);
1593
+ console.error(" a missing log is not a census with no findings.");
1594
+ process.exit(2);
1595
+ }
1596
+
1597
+ const [head, tail = ""] = text.split("--- AMBIGUOUS");
1598
+
1599
+ /** Pull \`{label, ref, cand}\` rows out of a census section. */
1600
+ function rows(section) {
1601
+ const out = [];
1602
+ const lines = section.split("\\n");
1603
+ for (let i = 0; i < lines.length; i++) {
1604
+ if (!/^ {2}y=/.test(lines[i])) continue;
1605
+ const label = lines[i].replace(/^\\s*y=\\s*\\d+\\s*/, "").trim();
1606
+ const refs = [];
1607
+ const cands = [];
1608
+ for (
1609
+ let j = i + 1;
1610
+ j < lines.length && /^ {4}(ref|cand):/.test(lines[j]);
1611
+ j++
1612
+ ) {
1613
+ const v = lines[j].replace(/^\\s*(ref|cand):\\s*/, "").trim();
1614
+ (lines[j].trim().startsWith("ref:") ? refs : cands).push(v);
1615
+ }
1616
+ if (refs.length && cands.length)
1617
+ out.push({ label, ref: refs[0], cand: cands[0] });
1618
+ else out.push({ label, ref: refs[0] ?? "", cand: cands[0] ?? "" });
1619
+ }
1620
+ return out;
1621
+ }
1622
+
1623
+ const mism = rows(head);
1624
+ const amb = rows(tail);
1625
+ const declared = mism.filter((r) => DECLARED.some((d) => d.match(r)));
1626
+ console.log(
1627
+ \`\${mism.length - declared.length} \${amb.length} \${declared.length}\`,
1628
+ );
1629
+ `;
1630
+ var HARNESS_JSON_RELATIVE = "matching/harness.json";
1631
+ var HARNESS_JSON_TEMPLATE = `{
1632
+ "ref": "__REF__",
1633
+ "cand": "http://localhost:5173",
1634
+ "matrix": [
1635
+ 1440,
1636
+ 834,
1637
+ 390
1638
+ ],
1639
+ "threshold": 0.1,
1640
+ "maxHeightDelta": 0.05,
1641
+ "refMark": "",
1642
+ "candMark": "_app/immutable",
1643
+ "selfHosts": [],
1644
+ "pages": {
1645
+ "home": {
1646
+ "uid": "home",
1647
+ "ref": "/",
1648
+ "cand": "/dev/match/home",
1649
+ "published": "/",
1650
+ "anchors": [],
1651
+ "spec": "home",
1652
+ "group": "nav"
1653
+ }
1654
+ }
1655
+ }
1656
+ `;
1657
+ var FLOORS_MJS_RELATIVE = "matching/floors.mjs";
1658
+ var FLOORS_MJS_TEMPLATE = `// The failures we have already established are NOT geometry. Shared by
1659
+ // next.mjs (which reports them separately from the backlog) and strikes.mjs
1660
+ // (which must not report them as stalled \u2014 a region that cannot move is flat
1661
+ // by definition, and three strikes on one is noise that hides a real stall).
1662
+ // Every entry needs a matching LEDGER entry to stay honest.
1663
+ //
1664
+ // NOTE the second matcher argument. Region records carry \`label\` and
1665
+ // \`viewport\` but not the page, so an entry keyed on label+viewport alone is
1666
+ // safe only where the label is unique site-wide. "top" is not \u2014 every page has
1667
+ // one \u2014 so such an entry would silently accept the \`top\` failure on EVERY page
1668
+ // and hide every real one. next.mjs and strikes.mjs pass the page key as the
1669
+ // second argument: take \`(r, page)\` unless you have checked the label.
1670
+
1671
+ /** Reference behaviour we cannot reproduce (declared floors). */
1672
+ export const FLOORS = [];
1673
+
1674
+ /** Regions the OPERATOR has looked at and chosen to leave failing. These are
1675
+ * not floors and are not silently dropped: next.mjs prints them under their
1676
+ * own heading with the decision, so the number never quietly improves. */
1677
+ export const ACCEPTED = [];
1678
+ `;
1679
+ var CENSUS_DEVIATIONS_MJS_RELATIVE = "matching/census-deviations.mjs";
1680
+ var CENSUS_DEVIATIONS_MJS_TEMPLATE = `// Style-census rows the OPERATOR has looked at and chosen to keep. Same
1681
+ // contract as matching/floors.mjs: nothing is hidden, everything is counted
1682
+ // under its own heading, and every entry needs a LEDGER entry to stay honest.
1683
+ //
1684
+ // node matching/census-count.mjs <log> -> "<real> <ambiguous> <declared>"
1685
+ //
1686
+ // Without this, \`census.sh\` can never reach zero and an "N remaining" figure
1687
+ // says nothing about how much work is left \u2014 one permanent decision is counted
1688
+ // once per page per viewport, so a single ACK can account for dozens of rows.
1689
+
1690
+ /** A row is \`{ label, ref, cand }\`, each of ref/cand a "fam | wt | size | lh |
1691
+ * ls | transform | colour" tuple string. */
1692
+ export const DECLARED = [];
1693
+ `;
1694
+ var SPEC_CHROME_MD_RELATIVE = "matching/spec-sections/_chrome.md";
1695
+ var SPEC_CHROME_MD_TEMPLATE = `## shared chrome
1696
+
1697
+ Site-wide furniture that renders identically on every page \u2014 header, footer, and
1698
+ anything else the per-page sections would otherwise restate. Phase 1 of the
1699
+ \`matching-a-page\` skill fills this in; until it does, this section is a
1700
+ placeholder and \`matching/SPEC.md\` has no shared-chrome contract to cite.
1701
+ `;
1702
+ var SPEC_HEADER_MD_RELATIVE = "matching/spec-sections/_header.md";
1703
+ var SPEC_HEADER_MD_TEMPLATE = `# Reference spec
1704
+
1705
+ GENERATED by \`node matching/build-spec.mjs\` from \`matching/spec-sections/\`.
1706
+ Edit the section files, not this one.
1707
+
1708
+ This is the Phase 1 deliverable of the \`matching-a-page\` skill and the
1709
+ precondition \`matching/gate.sh\` enforces: a page with no section here does not
1710
+ get a geometry round (see the matching rules in CLAUDE.md).
1711
+ `;
1712
+ var LEDGER_MD_RELATIVE = "matching/LEDGER.md";
1713
+ var LEDGER_MD_TEMPLATE = `# Deviations / masks / floors ledger
1714
+
1715
+ Append-only, and written at the moment a decision is made \u2014 not reconstructed at
1716
+ the end of a round, when the reason has already been lost. An entry is never
1717
+ edited to be right: a later entry corrects an earlier one and says which.
1718
+
1719
+ Every entry in \`matching/floors.mjs\` and \`matching/census-deviations.mjs\`, and
1720
+ every mask in \`matching/harness.json\`, needs a line here. Without one the gate
1721
+ has been quietly widened and nothing records who widened it, or why.
1722
+
1723
+ - [deviation | floor | mask | a11y] \`<region or selector>\` \u2014 what differs, why
1724
+ it is accepted, and the evidence: a spec citation, a census row, a gate run.
1725
+ `;
1726
+ var MATCH_ROUTE_SERVER_RELATIVE = "src/routes/dev/match/[uid]/+page.server.ts";
1727
+ var MATCH_ROUTE_SERVER_TEMPLATE = `import { error } from "@sveltejs/kit";
1728
+ import { dev } from "$app/environment";
1729
+ import { documents } from "$lib/site-pages.js";
1730
+
1731
+ // Local matching surface: renders the assemblies in $lib/site-pages.js \u2014 the
1732
+ // same module a Prismic Migration API script publishes from (start from the
1733
+ // starter's scripts/import/migrate.example.ts; no \`reddoor-maint\` command does
1734
+ // this, the seed is per-site work) \u2014 so a fix made to pass a gate is a fix to
1735
+ // what ships. Not prerendered, SSR-on-demand, dev-only.
1736
+ export const prerender = false;
1737
+
1738
+ // A migration script resolves images to asset ids (migration.createAsset);
1739
+ // here they only need a URL. Dimensions are nominal \u2014 slices size their own
1740
+ // image boxes in CSS.
1741
+ const devImg = (u: string) => ({
1742
+ url: u,
1743
+ alt: null,
1744
+ copyright: null,
1745
+ dimensions: { width: 1600, height: 1067 },
1746
+ edit: { x: 0, y: 0, zoom: 1, background: "transparent" },
1747
+ id: u,
1748
+ });
1749
+
1750
+ export async function load({ params }) {
1751
+ // FIRST statement: everything below reads fixtures that must not be reachable
1752
+ // from a production build. The launch recipe asserts this route 404s on the
1753
+ // deployed URL, with /dev/a11y-fixtures as the 200 control.
1754
+ if (!dev) error(404, { message: "Not found" });
1755
+
1756
+ const docs = documents(devImg) as Array<{ uid: string; data: { slices?: unknown[] } }>;
1757
+ const doc = docs.find((d) => d.uid === params.uid);
1758
+ // The leading token is a MACHINE tell for the launch recipe's dev-guard. This
1759
+ // 404 renders through the site's own +error.svelte exactly like a guarded
1760
+ // route's does, so on a site whose uids lack "home" an UNGUARDED twin would
1761
+ // pass the deployed check forever; the token is what tells the two apart.
1762
+ // Reword the prose freely \u2014 the token is the contract (#719).
1763
+ if (!doc)
1764
+ error(404, {
1765
+ message: \`reddoor-match-twin:no-assembly: no assembly for "\${params.uid}" (have: \${docs.map((d) => d.uid).join(", ") || "none"})\`,
1766
+ });
1767
+
1768
+ return { uid: params.uid, slices: doc.data.slices ?? [] };
1769
+ }
1770
+ `;
1771
+ var MATCH_ROUTE_PAGE_RELATIVE = "src/routes/dev/match/[uid]/+page.svelte";
1772
+ var MATCH_ROUTE_PAGE_TEMPLATE = `<script lang="ts">
1773
+ import { SliceZone } from "@prismicio/svelte";
1774
+ import { components } from "$lib/slices";
1775
+
1776
+ let { data } = $props();
1777
+ </script>
1778
+
1779
+ <SliceZone slices={data.slices as never} {components} />
1780
+ `;
1781
+ var SITE_PAGES_JS_RELATIVE = "src/lib/site-pages.js";
1782
+ var SITE_PAGES_JS_TEMPLATE = `// The page assemblies for this site \u2014 the SINGLE source of truth for both
1783
+ // consumers: a Prismic Migration API script, which publishes them (start from
1784
+ // the starter's scripts/import/migrate.example.ts \u2014 no \`reddoor-maint\` command
1785
+ // does this; the seed is per-site work), and src/routes/dev/match/[uid], the
1786
+ // local matching surface. Because both read from here, any fix made to pass a
1787
+ // gate is a fix to what ships.
1788
+ //
1789
+ // THE MIGRATION API DROPS SILENTLY. It validates against the slice models
1790
+ // registered in Prismic and discards every field the model does not declare \u2014
1791
+ // HTTP 200, no warning. A fixture field with no model behind it renders here and
1792
+ // vanishes on the published route. src/lib/site-pages.test.ts is the mechanical
1793
+ // check; run it before every seed.
1794
+ //
1795
+ // PURE by contract: no node:*, no fetch, no token, no side effects at import,
1796
+ // so Vite can bundle it into the dev route and node can import it into the seed.
1797
+
1798
+ export const lang = "en-us";
1799
+
1800
+ /**
1801
+ * @param {(url: string) => unknown} img resolves an image URL to whatever the
1802
+ * caller needs \u2014 an asset \`{id}\` for the seed, a \`{url}\` for the dev route.
1803
+ * @returns {Array<{type: string, uid: string, title: string, data: Record<string, unknown>}>}
1804
+ */
1805
+ export function documents(img) {
1806
+ void img;
1807
+ return [];
1808
+ }
1809
+ `;
1810
+ var SITE_PAGES_TEST_RELATIVE = "src/lib/site-pages.test.ts";
1811
+ var SITE_PAGES_TEST_TEMPLATE = `// The page assemblies in src/lib/site-pages.js are the SINGLE source of truth
1812
+ // shared by two consumers: the local matching route (src/routes/dev/match/[uid])
1813
+ // and a Prismic Migration API script, which publishes them (start from the
1814
+ // starter's scripts/import/migrate.example.ts \u2014 no \`reddoor-maint\` command does
1815
+ // this; the seed is per-site work).
1816
+ //
1817
+ // Those two consumers do NOT validate the same way. The dev route hands the
1818
+ // object straight to the slice components, so any field a fixture sets is
1819
+ // simply there. The Migration API validates against the slice models registered
1820
+ // in Prismic and SILENTLY DROPS every field the model does not declare \u2014 no
1821
+ // error, no warning, a 200. A page can gate green locally and publish wrong.
1822
+ //
1823
+ // This test is the mechanical check. It fails the moment a fixture carries a
1824
+ // field its slice model does not declare \u2014 before the seed runs, not after the
1825
+ // content is published. Run it before every seed.
1826
+ import { describe, expect, it } from "vitest";
1827
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
1828
+ import { fileURLToPath } from "node:url";
1829
+ import { dirname, join } from "node:path";
1830
+ import { documents } from "./site-pages.js";
1831
+
1832
+ const HERE = dirname(fileURLToPath(import.meta.url));
1833
+ const SLICES = join(HERE, "slices");
1834
+
1835
+ type Variation = { primary: string[]; items: string[] };
1836
+ type Slice = {
1837
+ slice_type: string;
1838
+ variation: string;
1839
+ primary?: Record<string, unknown>;
1840
+ items?: Array<Record<string, unknown>>;
1841
+ };
1842
+
1843
+ /** Every slice model in src/lib/slices, indexed by its Prismic slice id. */
1844
+ function loadModels(): Record<string, Record<string, Variation>> {
1845
+ const out: Record<string, Record<string, Variation>> = {};
1846
+ if (!existsSync(SLICES)) return out;
1847
+ for (const dir of readdirSync(SLICES)) {
1848
+ const file = join(SLICES, dir, "model.json");
1849
+ if (!existsSync(file)) continue;
1850
+ const model = JSON.parse(readFileSync(file, "utf8"));
1851
+ out[model.id] = Object.fromEntries(
1852
+ (model.variations ?? []).map((v: Record<string, unknown>) => [
1853
+ v.id,
1854
+ {
1855
+ primary: Object.keys((v.primary as object) ?? {}),
1856
+ items: Object.keys((v.items as object) ?? {}),
1857
+ },
1858
+ ]),
1859
+ );
1860
+ }
1861
+ return out;
1862
+ }
1863
+
1864
+ /** Image resolver stub \u2014 shape only; this test never reads image values. */
1865
+ const stubImg = () => ({ url: "https://example.test/x.jpg" });
1866
+
1867
+ describe("site-pages documents vs slice models", () => {
1868
+ const models = loadModels();
1869
+ const docs = documents(stubImg) as Array<{ uid: string; data: { slices?: Slice[] } }>;
1870
+ const pages: Array<[string, Slice[]]> = docs.map((d) => [d.uid, d.data.slices ?? []]);
1871
+
1872
+ it("declares every slice type the documents use", () => {
1873
+ const missing = new Set<string>();
1874
+ for (const [, slices] of pages)
1875
+ for (const s of slices) if (!models[s.slice_type]) missing.add(s.slice_type);
1876
+ expect([...missing]).toEqual([]);
1877
+ });
1878
+
1879
+ it("declares every variation the documents use", () => {
1880
+ const missing: string[] = [];
1881
+ for (const [uid, slices] of pages)
1882
+ for (const s of slices) {
1883
+ const model = models[s.slice_type];
1884
+ if (model && !model[s.variation]) missing.push(\`\${uid}: \${s.slice_type}/\${s.variation}\`);
1885
+ }
1886
+ expect(missing).toEqual([]);
1887
+ });
1888
+
1889
+ // The one that catches a silent Migration-API drop.
1890
+ it("declares every field the documents set, so Prismic strips nothing", () => {
1891
+ const stripped: string[] = [];
1892
+ for (const [uid, slices] of pages)
1893
+ for (const s of slices) {
1894
+ const variation = models[s.slice_type]?.[s.variation];
1895
+ if (!variation) continue;
1896
+ for (const key of Object.keys(s.primary ?? {}))
1897
+ if (!variation.primary.includes(key))
1898
+ stripped.push(\`\${uid} \${s.slice_type}/\${s.variation} primary.\${key}\`);
1899
+ const itemKeys = new Set((s.items ?? []).flatMap((i) => Object.keys(i)));
1900
+ for (const key of itemKeys)
1901
+ if (!variation.items.includes(key))
1902
+ stripped.push(\`\${uid} \${s.slice_type}/\${s.variation} items.\${key}\`);
1903
+ }
1904
+ expect(stripped).toEqual([]);
1905
+ });
1906
+ });
1907
+ `;
1908
+ var MATCH_HARNESS_FILES = [
1909
+ { rel: HARNESS_MJS_RELATIVE, template: HARNESS_MJS_TEMPLATE, owner: "recipe" },
1910
+ { rel: GATE_SH_RELATIVE, template: GATE_SH_TEMPLATE, owner: "recipe" },
1911
+ { rel: CENSUS_SH_RELATIVE, template: CENSUS_SH_TEMPLATE, owner: "recipe" },
1912
+ { rel: NEXT_MJS_RELATIVE, template: NEXT_MJS_TEMPLATE, owner: "recipe" },
1913
+ { rel: STRIKES_MJS_RELATIVE, template: STRIKES_MJS_TEMPLATE, owner: "recipe" },
1914
+ { rel: BUILD_SPEC_MJS_RELATIVE, template: BUILD_SPEC_MJS_TEMPLATE, owner: "recipe" },
1915
+ { rel: CENSUS_COUNT_MJS_RELATIVE, template: CENSUS_COUNT_MJS_TEMPLATE, owner: "recipe" },
1916
+ { rel: HARNESS_JSON_RELATIVE, template: HARNESS_JSON_TEMPLATE, owner: "site" },
1917
+ { rel: FLOORS_MJS_RELATIVE, template: FLOORS_MJS_TEMPLATE, owner: "site" },
1918
+ { rel: CENSUS_DEVIATIONS_MJS_RELATIVE, template: CENSUS_DEVIATIONS_MJS_TEMPLATE, owner: "site" },
1919
+ { rel: SPEC_CHROME_MD_RELATIVE, template: SPEC_CHROME_MD_TEMPLATE, owner: "site" },
1920
+ { rel: SPEC_HEADER_MD_RELATIVE, template: SPEC_HEADER_MD_TEMPLATE, owner: "site" },
1921
+ { rel: LEDGER_MD_RELATIVE, template: LEDGER_MD_TEMPLATE, owner: "site" },
1922
+ { rel: MATCH_ROUTE_SERVER_RELATIVE, template: MATCH_ROUTE_SERVER_TEMPLATE, owner: "recipe" },
1923
+ { rel: MATCH_ROUTE_PAGE_RELATIVE, template: MATCH_ROUTE_PAGE_TEMPLATE, owner: "recipe" },
1924
+ { rel: SITE_PAGES_JS_RELATIVE, template: SITE_PAGES_JS_TEMPLATE, owner: "site" },
1925
+ { rel: SITE_PAGES_TEST_RELATIVE, template: SITE_PAGES_TEST_TEMPLATE, owner: "recipe" }
1926
+ ];
1927
+ var HARNESS_MJS_PREV_0_95_0 = `// The single source for everything the matching gates need to know about this
1928
+ // site. DATA lives in matching/harness.json (site-edited); this file is the
1929
+ // READ LAYER \u2014 edit harness.json, not this. (It is installed and upgraded by
1930
+ // the \`reddoor-maint match-harness\` recipe, which owns these bytes: a hand
1931
+ // edit here is flagged on the next run and never silently overwritten.)
1932
+ //
1933
+ // It exists because the page table, the two hosts, the matrix, the threshold
1934
+ // and the skill path were hand-copied all over matching/. Re-measured
1935
+ // 2026-09-09 AFTER the six-probe conversion, over the 216 tracked scripts under
1936
+ // matching/ (214 top-level \u2014 212 .mjs + 2 .sh \u2014 plus 2 in states/):
1937
+ //
1938
+ // \u2022 a hand-typed copy of the page table (three or more gate keys sitting next
1939
+ // to their route): 8 files. Exactly ONE of them, probe-chrome-count.mjs,
1940
+ // still carries all nine rows; probe-anchors.mjs carries five; the other
1941
+ // six are three-row detail triples (team/svc/qa).
1942
+ // \u2022 the skill path (~/.claude/skills/matching-a-page): 193 copies
1943
+ // \u2022 the viewport matrix (1440/834/390): 51 copies
1944
+ // \u2022 REF pointed at a host listed in selfHosts \u2014 i.e. comparing the candidate
1945
+ // with itself: 12 scripts, one of which (probe-chrome-count.mjs) is also
1946
+ // the last nine-row table carrier
1947
+ //
1948
+ // The first bullet read "the nine-row page table: 5 copies \u2014 gate.sh,
1949
+ // probe-anchor-parity.mjs, sweep-all10.sh, sweep-all16.sh, sweep-final.sh" when
1950
+ // it was written here at 922dde3. It was wrong within the hour and wrong on two
1951
+ // counts: 4e2cd7b took the table out of gate.sh, and the list never named
1952
+ // states/index.mjs or probe-chrome-count.mjs, which were both carrying nine-row
1953
+ // copies at the time. A census is a claim about code; it has to be measured
1954
+ // against the tree, not recalled.
1955
+ //
1956
+ // node matching/harness.mjs --env shell-safe KEY='value' lines
1957
+ // node matching/harness.mjs --table key<TAB>ref<TAB>cand<TAB>anchors
1958
+ // node matching/harness.mjs --check-ref the D11 preflight; exit 2 on failure
1959
+ import { readFileSync, realpathSync } from "node:fs";
1960
+ import { homedir } from "node:os";
1961
+ import { join } from "node:path";
1962
+ import { fileURLToPath, pathToFileURL } from "node:url";
1963
+
1964
+ // fileURLToPath, not URL#pathname: pathname is percent-encoded, so a checkout
1965
+ // under a directory with a space in it would resolve to a path that does not
1966
+ // exist.
1967
+ const DIR = fileURLToPath(new URL(".", import.meta.url));
1968
+ const CFG = JSON.parse(readFileSync(join(DIR, "harness.json"), "utf8"));
1969
+
1970
+ // Env overrides exist for one-off probes only. They are NOT how a site is
1971
+ // configured \u2014 harness.json is, so that what a gate ran against is committed.
1972
+ export const REF = process.env.MATCH_REF ?? CFG.ref;
1973
+ export const CAND = process.env.MATCH_CAND ?? process.env.CAND_BASE ?? CFG.cand;
1974
+ export const MATRIX = CFG.matrix;
1975
+ export const THRESHOLD = CFG.threshold;
1976
+ export const MAX_HEIGHT_DELTA = CFG.maxHeightDelta;
1977
+ export const REF_MARK = CFG.refMark;
1978
+ export const CAND_MARK = CFG.candMark;
1979
+ export const SELF_HOSTS = CFG.selfHosts ?? [];
1980
+
1981
+ /** One record per gated page, in harness.json order. \`key\` is the gate key
1982
+ * (out-<TAG>-<key>, the SPEC heading, spec-sections/<key>.md); \`uid\` is the
1983
+ * Prismic uid or null where the page has no /dev/match twin. */
1984
+ export const PAGES = Object.entries(CFG.pages).map(([key, p]) => ({ key, ...p }));
1985
+ export const byKey = Object.fromEntries(PAGES.map((p) => [p.key, p]));
1986
+
1987
+ // DERIVED, never hand-typed: page-diff cuts one region before the first anchor
1988
+ // ("top") plus one per anchor, at every viewport. The old hand-written map went
1989
+ // stale the moment an anchor list changed, and a wrong denominator makes the
1990
+ // score a lie in the flattering direction.
1991
+ export const TOTALS = Object.fromEntries(
1992
+ PAGES.map((p) => [p.key, (p.anchors.length + 1) * MATRIX.length]),
1993
+ );
1994
+
1995
+ /** The SPEC.md heading predicate, shared by gate.sh's preflight and
1996
+ * build-spec.mjs so a section can never build fine and then refuse at the
1997
+ * gate. Matches the key followed by any non-key character (\`## team\` matches,
1998
+ * \`## teamfoo\` does not, \`## our-team\` cannot match \`team\`). */
1999
+ export const specHeadingRe = (key) => new RegExp(\`^##+ +\${key}([^A-Za-z0-9_-]|$)\`, "m");
2000
+
2001
+ export const SKILL_DIR =
2002
+ process.env.MATCHING_SKILL_DIR ?? join(homedir(), ".claude/skills/matching-a-page");
2003
+ export const PD = join(SKILL_DIR, "page-diff.mjs");
2004
+ export const SC = join(SKILL_DIR, "style-census.mjs");
2005
+ export const PLAYWRIGHT = pathToFileURL(join(SKILL_DIR, "node_modules/playwright/index.mjs")).href;
2006
+
2007
+ /** The report format this site's scripts can read, so gate.sh can compare it
2008
+ * with \`page-diff --version\` before spending a run and next.mjs can refuse
2009
+ * rather than quietly drop a page whose newest report came from another
2010
+ * schema. Both do that now \u2014 gate.sh preflights \`page-diff --version\` against
2011
+ * this value before spending a run, and next.mjs counts a foreign-schema
2012
+ * report as MISSING rather than skipping it. (This said "neither does that
2013
+ * yet" \u2014 true at 922dde3 where it was written, false from 4e2cd7b, which
2014
+ * gave gate.sh the preflight and did not come back here.) Checked 2026-09-09
2015
+ * against the installed skill: \`page-diff --version\` \u2192 \`page-diff 0.1.0
2016
+ * report-schema 1\`. */
2017
+ export const REPORT_SCHEMA = 1;
2018
+
2019
+ /**
2020
+ * Fail-closed reference preflight. A 200 is NOT evidence: a host that has been
2021
+ * repointed at our own build answers 200, and so does a staging host serving a
2022
+ * 404 page. Both have happened on a real site \u2014 see the dated measurement in
2023
+ * LEDGER.md. A pass here requires an artefact only the reference produces.
2024
+ */
2025
+ export async function checkRef() {
2026
+ if (!REF_MARK) {
2027
+ return {
2028
+ ok: false,
2029
+ why: "harness.json refMark is empty \u2014 set it to a string only the reference serves (a Webflow site id, a build hash). A 200 is not evidence.",
2030
+ };
2031
+ }
2032
+ const host = new URL(REF).host;
2033
+ if (SELF_HOSTS.includes(host)) return { ok: false, why: \`REF host \${host} is in selfHosts\` };
2034
+ if (host === new URL(CAND).host) return { ok: false, why: \`REF host \${host} equals CAND's host\` };
2035
+ let res;
2036
+ try {
2037
+ res = await fetch(\`\${REF}/\`, { redirect: "manual" });
2038
+ } catch (e) {
2039
+ return { ok: false, why: \`GET \${REF}/ failed: \${e.message}\` };
2040
+ }
2041
+ if (res.status !== 200)
2042
+ return { ok: false, why: \`GET \${REF}/ \u2192 HTTP \${res.status}, expected 200\` };
2043
+ const loc = res.headers.get("location");
2044
+ if (loc) return { ok: false, why: \`GET \${REF}/ \u2192 \${res.status} redirect to \${loc}\` };
2045
+ const body = await res.text();
2046
+ if (!body.includes(REF_MARK))
2047
+ return {
2048
+ ok: false,
2049
+ why: \`\${REF}/ served 200 but WITHOUT refMark \${JSON.stringify(REF_MARK)} \u2014 that is not the reference\`,
2050
+ };
2051
+ if (CAND_MARK && body.includes(CAND_MARK))
2052
+ return {
2053
+ ok: false,
2054
+ why: \`\${REF}/ contains candMark \${JSON.stringify(CAND_MARK)} \u2014 REF is serving OUR build\`,
2055
+ };
2056
+ return { ok: true, why: \`\${REF}/ \u2192 200, no redirect, refMark present, candMark absent\` };
2057
+ }
2058
+
2059
+ // CLI. Both sides go through realpathSync. \`import.meta.url\` is ALREADY the
2060
+ // resolved real path (node resolves symlinks unless --preserve-symlinks) while
2061
+ // process.argv[1] is the path as typed, so a plain pathToFileURL compare goes
2062
+ // false the moment any component of the invoked path is a symlink \u2014 and then
2063
+ // the CLI prints nothing and exits 0, which every caller reads as success.
2064
+ // page-diff.mjs:184-189 records exactly that defect and the same fix: "The
2065
+ // pathToFileURL compare that replaced the old template string is still false
2066
+ // whenever ANY component of the invoked path is a symlink \u2014 which is how this
2067
+ // skill is installed now (~/.claude/skills/matching-a-page -> the claude-skills
2068
+ // checkout). isMain() resolves the real path on both sides."
2069
+ const isMain = () => {
2070
+ if (!process.argv[1]) return false;
2071
+ try {
2072
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
2073
+ } catch {
2074
+ return false;
2075
+ }
2076
+ };
2077
+
2078
+ if (isMain()) {
2079
+ const mode = process.argv[2];
2080
+ const q = (v) => \`'\${String(v).replace(/'/g, \`'\\\\''\`)}'\`;
2081
+ if (mode === "--env") {
2082
+ const pairs = [
2083
+ ["REF", REF],
2084
+ ["CAND", CAND],
2085
+ ["MATRIX", MATRIX.join(",")],
2086
+ ["VIEWPORTS_SP", MATRIX.join(" ")],
2087
+ ["THRESHOLD", THRESHOLD],
2088
+ ["MAX_HEIGHT_DELTA", MAX_HEIGHT_DELTA],
2089
+ ["PD", PD],
2090
+ ["SC", SC],
2091
+ ["REPORT_SCHEMA", REPORT_SCHEMA],
2092
+ ];
2093
+ for (const [k, v] of pairs) console.log(\`\${k}=\${q(v)}\`);
2094
+ } else if (mode === "--table") {
2095
+ for (const p of PAGES) console.log([p.key, p.ref, p.cand, p.anchors.join(",")].join("\\t"));
2096
+ } else if (mode === "--check-ref") {
2097
+ const r = await checkRef();
2098
+ console.log(\`\${r.ok ? "REF OK" : "REF REFUSED"} \u2014 \${r.why}\`);
2099
+ process.exit(r.ok ? 0 : 2);
2100
+ } else {
2101
+ console.error("usage: harness.mjs --env | --table | --check-ref");
2102
+ process.exit(2);
2103
+ }
2104
+ }
2105
+ `;
2106
+ var GATE_SH_PREV_0_95_0 = `#!/usr/bin/env bash
2107
+ # The matching gate. This file is generic \u2014 everything specific to a site lives
2108
+ # in matching/harness.json (the data) and matching/LEDGER.md (the why). It is
2109
+ # installed and upgraded by the \`reddoor-maint match-harness\` recipe, which
2110
+ # owns these bytes: a hand edit here is flagged on the next run, never
2111
+ # silently overwritten.
2112
+ #
2113
+ # bash matching/gate.sh <round-tag> [page ...]
2114
+ #
2115
+ # Runs page-diff for every page in the table (or just the named ones) at the
2116
+ # full breakpoint matrix and writes matching/out-<round-tag>-<page>/.
2117
+ #
2118
+ # The matrix, the anchor lists, the reference and the candidate are DATA. Why a
2119
+ # site chose them \u2014 which live breakpoint band hid what, why an anchor is a
2120
+ # heading and not a button label \u2014 belongs in matching/LEDGER.md, which is dated
2121
+ # and append-only, because JSON holds no comments.
2122
+ #
2123
+ # NO MASKS and the threshold from harness.json everywhere: the numbers stay
2124
+ # honest and a known floor stays visible as its own region. A media-neutralised
2125
+ # secondary read is \`node "$PD" ... --neutralize-media\`; next.mjs ignores such
2126
+ # runs on purpose (next.mjs:57-64).
2127
+ #
2128
+ # PREFLIGHT (see the matching rules in CLAUDE.md): a page with no section in
2129
+ # matching/SPEC.md has not had Phase 1 done, and its geometry must not be
2130
+ # touched. Skipping the spec is how a reference's root-font ladder and its
2131
+ # per-component height ladders get found reactively, after the region has
2132
+ # already failed several rounds. This refuses the run instead of trusting anyone
2133
+ # to remember.
2134
+ set -u
2135
+ # Everything configurable lives in matching/harness.json; harness.mjs is the one
2136
+ # reader. --env emits shell-safe assignments (REF, CAND, MATRIX, VIEWPORTS_SP,
2137
+ # THRESHOLD, MAX_HEIGHT_DELTA, PD, SC, REPORT_SCHEMA).
2138
+ eval "$(node "$(dirname "$0")/harness.mjs" --env)"
2139
+
2140
+ # The skill must be able to write reports this site's scripts can read. Cheap,
2141
+ # local, and it fails before any browser starts.
2142
+ PD_SCHEMA="$(node "$PD" --version 2>/dev/null | awk '{print $4}')"
2143
+ if [ "$PD_SCHEMA" != "$REPORT_SCHEMA" ]; then
2144
+ echo "gate.sh: page-diff writes report schema '\${PD_SCHEMA:-none}', this harness reads $REPORT_SCHEMA." >&2
2145
+ echo " Update matching/harness.mjs REPORT_SCHEMA or the matching-a-page skill." >&2
2146
+ exit 2
2147
+ fi
2148
+ SPEC="$(dirname "$0")/SPEC.md"
2149
+ TAG="\${1:?usage: gate.sh <round-tag> [page ...]}"
2150
+ # The tag must not contain a hyphen. Output dirs are "out-<TAG>-<page>", and
2151
+ # next.mjs recovers the page with /^out-[^-]+-(.+)$/ \u2014 it splits on the FIRST
2152
+ # hyphen, so a tag like "r-forms-2026-08-07" yields the page key
2153
+ # "forms-2026-08-07-yfv" and that run is silently never counted. It cannot split
2154
+ # on the last hyphen instead, because page keys have hyphens of their own
2155
+ # ("our-team", "ask-the-doctor"). Failing here is the cheap end of that: a
2156
+ # mis-tagged round otherwise LOOKS green because next.mjs keeps reading an older
2157
+ # report for the page you just changed. (Cost this once, 2026-08-07.)
2158
+ case "$TAG" in
2159
+ *-*)
2160
+ echo "gate.sh: round tag must not contain a hyphen (got '$TAG')." >&2
2161
+ echo " out-<TAG>-<page> is parsed on the first hyphen, so a" >&2
2162
+ echo " hyphenated tag hides the run from next.mjs. Try '\${TAG//-/}'." >&2
2163
+ exit 2
2164
+ ;;
2165
+ esac
2166
+ shift || true
2167
+ WANT=("$@")
2168
+
2169
+ # Fail closed on the reference before spending a single run. A 200 is NOT
2170
+ # evidence: a production host that has cut over to OUR build answers 200, and
2171
+ # every region then scores near zero against itself. --check-ref requires an
2172
+ # artefact only the reference serves (harness.json refMark) and refuses a
2173
+ # redirect, a host listed in selfHosts, and a body carrying candMark. Measured
2174
+ # on the site this harness was cut from, 2026-09-09 AFTER the consolidation:
2175
+ # 12 of its 214 top-level matching scripts still assign REF a host listed in
2176
+ # selfHosts \u2014 i.e. compare the candidate with itself. It was 33 of 229 before.
2177
+ # (This comment read "33 of its 230" while sitting in the post-consolidation
2178
+ # tree, contradicting harness.mjs's 12 three files away.)
2179
+ if ! node "$(dirname "$0")/harness.mjs" --check-ref; then
2180
+ echo "gate.sh: refusing to gate against an unverified reference." >&2
2181
+ exit 2
2182
+ fi
2183
+
2184
+ # Set SPEC_OPTIONAL=1 only for a read-only baseline sweep of pages you are not
2185
+ # about to edit. It is recorded in the round tag so the exemption is visible.
2186
+ SPEC_OPTIONAL="\${SPEC_OPTIONAL:-0}"
2187
+
2188
+ has_spec() { # page
2189
+ # NB: the obvious \`( |$|\\b)\` guard is rejected by ugrep as an empty
2190
+ # subexpression, and a preflight that errors out fails CLOSED \u2014 it refused all
2191
+ # 9 pages while SPEC.md was complete. Match the page key followed by any
2192
+ # non-key character (so \`## team\` matches but \`## teamfoo\` does not; \`##
2193
+ # our-team\` cannot match \`team\` because the key must follow the spaces).
2194
+ [ -f "$SPEC" ] && grep -qE "^##+ +$1([^A-Za-z0-9_-]|$)" "$SPEC"
2195
+ }
2196
+
2197
+ run() { # tag refpath candpath sections
2198
+ local page="$1" refpath="$2" candpath="$3" sections="$4"
2199
+ if [ \${#WANT[@]} -gt 0 ]; then
2200
+ local hit=0
2201
+ for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
2202
+ [ $hit -eq 1 ] || return 0
2203
+ fi
2204
+ if ! has_spec "$page"; then
2205
+ if [ "$SPEC_OPTIONAL" = "1" ]; then
2206
+ echo "########## $page ##########"
2207
+ echo "WARNING: no '## $page' section in matching/SPEC.md \u2014 Phase 1 not done."
2208
+ echo " Running anyway because SPEC_OPTIONAL=1 (baseline read only)."
2209
+ echo " Do NOT apply geometry fixes off this run."
2210
+ else
2211
+ echo "########## $page ##########"
2212
+ echo "REFUSED: no '## $page' section in matching/SPEC.md."
2213
+ echo " Phase 1 (section census + per-section spec, read from"
2214
+ echo " matching/spec/) comes before geometry."
2215
+ echo " See CLAUDE.md rule 2. SPEC_OPTIONAL=1 for a baseline read."
2216
+ FAILED_PREFLIGHT=1
2217
+ return 0
2218
+ fi
2219
+ fi
2220
+ echo "########## $page ##########"
2221
+ node "$PD" --ref "$REF$refpath" --cand "$CAND$candpath" \\
2222
+ --viewports "$MATRIX" --threshold "$THRESHOLD" \\
2223
+ --sections "$sections" --out "matching/out-$TAG-$page" \\
2224
+ > "matching/out-$TAG-$page.log" 2>&1
2225
+ echo "$page exit=$?"
2226
+ }
2227
+
2228
+ # The page table is matching/harness.json. Process substitution, NOT a pipe:
2229
+ # a pipe would run this loop in a subshell and FAILED_PREFLIGHT would not
2230
+ # survive to the check below, so a refused page would exit 0.
2231
+ while IFS=$'\\t' read -r key refpath candpath anchors; do
2232
+ run "$key" "$refpath" "$candpath" "$anchors"
2233
+ done < <(node "$(dirname "$0")/harness.mjs" --table)
2234
+
2235
+ if [ "\${FAILED_PREFLIGHT:-0}" = "1" ]; then
2236
+ echo
2237
+ echo "GATE INCOMPLETE ($TAG) \u2014 one or more pages were refused for a missing"
2238
+ echo "SPEC.md section. Those pages have NOT been measured; do not report a"
2239
+ echo "score for them."
2240
+ exit 2
2241
+ fi
2242
+ echo "ALL DONE ($TAG)"
2243
+ `;
2244
+ var NEXT_MJS_PREV_0_95_0 = `// What is still broken, ranked. Exits 1 while work remains.
2245
+ //
2246
+ // node matching/next.mjs
2247
+ //
2248
+ // Round protocol step 0 (repo CLAUDE.md rule 5). A commit is a CHECKPOINT, not
2249
+ // a stopping point: after committing, run this. If it exits 1 there is a named
2250
+ // next action and the round continues without handing control back.
2251
+ //
2252
+ // Reads the most recent gate log per page rather than the whole corpus, so it
2253
+ // reflects HEAD rather than history (that is strikes.mjs's job).
2254
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2255
+ import { join } from "node:path";
2256
+
2257
+ const DIR = new URL(".", import.meta.url).pathname;
2258
+
2259
+ // PAUSE SWITCH. While matching/PAUSED exists this hands out no agenda and
2260
+ // exits 0. It is deliberately the FIRST thing that runs: no report is read, no
2261
+ // score is printed, nothing tempting is put on screen to argue with.
2262
+ //
2263
+ // Why an exit code and not a note somewhere: rule 5 is a LOOP \u2014 "after
2264
+ // committing, run next.mjs; while it exits 1 there is a named next action and
2265
+ // the round continues". A pause written as prose loses to that loop, because
2266
+ // the loop is mechanical and the prose is not. Exiting 0 satisfies rule 5
2267
+ // truthfully rather than suspending it: there is no next action.
2268
+ const PAUSE = join(DIR, "PAUSED");
2269
+ if (existsSync(PAUSE)) {
2270
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
2271
+ console.log(readFileSync(PAUSE, "utf8").trimEnd());
2272
+ process.exit(0);
2273
+ }
2274
+
2275
+ import { FLOORS, ACCEPTED } from "./floors.mjs";
2276
+ import { TOTALS, THRESHOLD, MAX_HEIGHT_DELTA, REPORT_SCHEMA } from "./harness.mjs";
2277
+
2278
+ // Reports written by a different page-diff, by page key. Kept rather than
2279
+ // dropped: silently ignoring them is how a page vanishes from the score.
2280
+ const schemaMismatch = new Set();
2281
+
2282
+ const latest = new Map();
2283
+ for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
2284
+ const m = /^out-[^-]+-(.+)$/.exec(d);
2285
+ if (!m || !TOTALS[m[1]]) continue;
2286
+ let report, mtime;
2287
+ try {
2288
+ const p = join(DIR, d, "report.json");
2289
+ report = JSON.parse(readFileSync(p, "utf8"));
2290
+ mtime = statSync(p).mtimeMs;
2291
+ } catch {
2292
+ continue;
2293
+ }
2294
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
2295
+ // page. Picking one up as "latest" silently reports scores nobody can ship \u2014
2296
+ // it happened immediately: an --mask-photos probe of yfv made \`top\` @834 read
2297
+ // 43.9% here while the real gate had it passing at 1.3%.
2298
+ const meta = report.meta ?? {};
2299
+ // Missing schemaVersion means "written before the field existed" = 0. It is
2300
+ // not an error on its own; it is only fatal when it would blank a page.
2301
+ if ((meta.schemaVersion ?? 0) !== REPORT_SCHEMA) {
2302
+ schemaMismatch.add(m[1]);
2303
+ continue;
2304
+ }
2305
+ if (
2306
+ (meta.mask?.length ?? 0) > 0 ||
2307
+ meta.neutralizeMedia ||
2308
+ meta.maskPhotos ||
2309
+ meta.truncated
2310
+ )
2311
+ continue;
2312
+ if (meta.threshold !== THRESHOLD) continue;
2313
+ const prev = latest.get(m[1]);
2314
+ if (!prev || mtime > prev.mtime) latest.set(m[1], { dir: d, mtime, report });
2315
+ }
2316
+
2317
+ const blanked = [...schemaMismatch].filter((p) => !latest.has(p));
2318
+ if (blanked.length) {
2319
+ console.error(
2320
+ \`next: \${blanked.length} page(s) have no run at report schema \${REPORT_SCHEMA} \u2014 \` +
2321
+ \`their newest reports came from a different page-diff (\${blanked.sort().join(", ")}).\\n\` +
2322
+ \` Re-run: bash matching/gate.sh <tag> \${blanked.sort().join(" ")}\`,
2323
+ );
2324
+ process.exit(2);
2325
+ }
2326
+ if (latest.size === 0) {
2327
+ console.error(
2328
+ "next: no parseable gate run under matching/ \u2014 refusing to report a score.\\n" +
2329
+ " Run bash matching/gate.sh <tag> first.",
2330
+ );
2331
+ process.exit(2);
2332
+ }
2333
+
2334
+ const rows = [];
2335
+ const accepted = [];
2336
+ let openTotal = 0;
2337
+ let floorTotal = 0;
2338
+ for (const [page, { dir, report }] of latest) {
2339
+ const fails = report.regions.filter((r) => !r.pass);
2340
+ for (const f of fails) {
2341
+ const floor = FLOORS.find((fl) => fl.match(f, page));
2342
+ if (floor) {
2343
+ floorTotal++;
2344
+ continue;
2345
+ }
2346
+ const ack = ACCEPTED.find((a) => a.match(f, page));
2347
+ if (ack) {
2348
+ accepted.push({ page, vw: f.viewport, label: f.label, why: ack.why });
2349
+ continue;
2350
+ }
2351
+ openTotal++;
2352
+ rows.push({
2353
+ page,
2354
+ vw: f.viewport,
2355
+ label: f.label,
2356
+ mm: f.mismatchFraction,
2357
+ dh: f.heightDeltaFraction ?? 0,
2358
+ dir,
2359
+ });
2360
+ }
2361
+ }
2362
+
2363
+ const scored = [...latest.entries()]
2364
+ .map(([p, v]) => ({
2365
+ p,
2366
+ pass: v.report.regions.filter((r) => r.pass).length,
2367
+ total: TOTALS[p],
2368
+ }))
2369
+ .sort((a, b) => a.pass / a.total - b.pass / b.total);
2370
+
2371
+ const sum = scored.reduce((a, s) => a + s.pass, 0);
2372
+ const max = scored.reduce((a, s) => a + s.total, 0);
2373
+ console.log(\`SCORE \${sum}/\${max} regions passing\\n\`);
2374
+ console.log(
2375
+ scored
2376
+ .map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`)
2377
+ .join("\\n"),
2378
+ );
2379
+
2380
+ if (accepted.length) {
2381
+ console.log(\`\\nOperator-ACCEPTED failures (left failing on purpose):\`);
2382
+ for (const a of accepted)
2383
+ console.log(\` \${a.page} @\${a.vw} "\${a.label}" \u2014 \${a.why.slice(0, 96)}\u2026\`);
2384
+ }
2385
+
2386
+ if (!rows.length) {
2387
+ console.log(
2388
+ \`\\nNo open geometry failures. \${floorTotal} declared floor(s) remain.\`,
2389
+ );
2390
+ console.log(
2391
+ "Backlog is empty \u2014 Phases 5 (states) and 6 (adversarial review) are what is left.",
2392
+ );
2393
+ process.exit(0);
2394
+ }
2395
+
2396
+ // Worst page first, then worst region inside it: fix where the model is most wrong.
2397
+ const worst = scored[0].p;
2398
+ rows.sort(
2399
+ (a, b) =>
2400
+ (a.page === worst ? -1 : 0) - (b.page === worst ? -1 : 0) || b.mm - a.mm,
2401
+ );
2402
+
2403
+ console.log(
2404
+ \`\\n\${openTotal} open failure(s) + \${floorTotal} declared floor(s).\`,
2405
+ );
2406
+ console.log(\`\\nNEXT: \${worst} \u2014 worst page. Its open regions:\\n\`);
2407
+ for (const r of rows.filter((r) => r.page === worst)) {
2408
+ const why = [];
2409
+ if (r.mm > THRESHOLD) why.push(\`pixels \${(r.mm * 100).toFixed(1)}%\`);
2410
+ if (Math.abs(r.dh) > MAX_HEIGHT_DELTA)
2411
+ why.push(\`height \${(r.dh * 100).toFixed(1)}%\`);
2412
+ console.log(
2413
+ \` @\${String(r.vw).padEnd(5)} \${r.label.slice(0, 44).padEnd(45)} \${why.join(" + ")}\`,
2414
+ );
2415
+ }
2416
+ console.log(\`\\nBefore treating any of these as geometry:\`);
2417
+ console.log(
2418
+ \` node matching/probe-anchor-parity.mjs \${worst} # is the gate cutting comparably?\`,
2419
+ );
2420
+ console.log(
2421
+ \` node matching/strikes.mjs \${worst} # has it stalled? then change the MODEL\`,
2422
+ );
2423
+ console.log(
2424
+ \`\\nRound continues. Do not hand back control with work outstanding.\`,
2425
+ );
2426
+ process.exit(1);
2427
+ `;
2428
+ var HARNESS_MJS_PREV_0_95_1 = `// The single source for everything the matching gates need to know about this
2429
+ // site. DATA lives in matching/harness.json (site-edited); this file is the
2430
+ // READ LAYER \u2014 edit harness.json, not this. (It is installed and upgraded by
2431
+ // the \`reddoor-maint match-harness\` recipe, which owns these bytes: a hand
2432
+ // edit here is flagged on the next run and never silently overwritten.)
2433
+ //
2434
+ // It exists because the page table, the two hosts, the matrix, the threshold
2435
+ // and the skill path were hand-copied all over matching/. Re-measured
2436
+ // 2026-09-09 AFTER the six-probe conversion, over the 216 tracked scripts under
2437
+ // matching/ (214 top-level \u2014 212 .mjs + 2 .sh \u2014 plus 2 in states/):
2438
+ //
2439
+ // \u2022 a hand-typed copy of the page table (three or more gate keys sitting next
2440
+ // to their route): 8 files. Exactly ONE of them, probe-chrome-count.mjs,
2441
+ // still carries all nine rows; probe-anchors.mjs carries five; the other
2442
+ // six are three-row detail triples (team/svc/qa).
2443
+ // \u2022 the skill path (~/.claude/skills/matching-a-page): 193 copies
2444
+ // \u2022 the viewport matrix (1440/834/390): 51 copies
2445
+ // \u2022 REF pointed at a host listed in selfHosts \u2014 i.e. comparing the candidate
2446
+ // with itself: 12 scripts, one of which (probe-chrome-count.mjs) is also
2447
+ // the last nine-row table carrier
2448
+ //
2449
+ // The first bullet read "the nine-row page table: 5 copies \u2014 gate.sh,
2450
+ // probe-anchor-parity.mjs, sweep-all10.sh, sweep-all16.sh, sweep-final.sh" when
2451
+ // it was written here at 922dde3. It was wrong within the hour and wrong on two
2452
+ // counts: 4e2cd7b took the table out of gate.sh, and the list never named
2453
+ // states/index.mjs or probe-chrome-count.mjs, which were both carrying nine-row
2454
+ // copies at the time. A census is a claim about code; it has to be measured
2455
+ // against the tree, not recalled.
2456
+ //
2457
+ // node matching/harness.mjs --env shell-safe KEY='value' lines
2458
+ // node matching/harness.mjs --table key<TAB>ref<TAB>cand<TAB>anchors
2459
+ // node matching/harness.mjs --check-ref the D11 preflight; exit 2 on failure
2460
+ // node matching/harness.mjs --check-run <page> <out-dir> <startedAt-iso>
2461
+ // did THIS run leave a countable report?
2462
+ // exit 2 when it did not
2463
+ import { readFileSync, realpathSync } from "node:fs";
2464
+ import { homedir } from "node:os";
2465
+ import { join } from "node:path";
2466
+ import { fileURLToPath, pathToFileURL } from "node:url";
2467
+
2468
+ // fileURLToPath, not URL#pathname: pathname is percent-encoded, so a checkout
2469
+ // under a directory with a space in it would resolve to a path that does not
2470
+ // exist.
2471
+ const DIR = fileURLToPath(new URL(".", import.meta.url));
2472
+ const CFG = JSON.parse(readFileSync(join(DIR, "harness.json"), "utf8"));
2473
+
2474
+ // Env overrides exist for one-off probes only. They are NOT how a site is
2475
+ // configured \u2014 harness.json is, so that what a gate ran against is committed.
2476
+ export const REF = process.env.MATCH_REF ?? CFG.ref;
2477
+ export const CAND = process.env.MATCH_CAND ?? process.env.CAND_BASE ?? CFG.cand;
2478
+ export const MATRIX = CFG.matrix;
2479
+ export const THRESHOLD = CFG.threshold;
2480
+ export const MAX_HEIGHT_DELTA = CFG.maxHeightDelta;
2481
+ export const REF_MARK = CFG.refMark;
2482
+ export const CAND_MARK = CFG.candMark;
2483
+ export const SELF_HOSTS = CFG.selfHosts ?? [];
2484
+
2485
+ /** One record per gated page, in harness.json order. \`key\` is the gate key
2486
+ * (out-<TAG>-<key>, the SPEC heading, spec-sections/<key>.md); \`uid\` is the
2487
+ * Prismic uid or null where the page has no /dev/match twin. */
2488
+ export const PAGES = Object.entries(CFG.pages).map(([key, p]) => ({ key, ...p }));
2489
+ export const byKey = Object.fromEntries(PAGES.map((p) => [p.key, p]));
2490
+
2491
+ /** Can this page's region count be PREDICTED at all? It needs anchors \u2014 see
2492
+ * TOTALS below \u2014 AND a matrix to measure them at. Exported beside TOTALS
2493
+ * rather than left for each consumer to re-derive, because "remember to ask
2494
+ * first" is exactly what failed: checkRun remembered, next.mjs did not, for
2495
+ * two releases.
2496
+ *
2497
+ * \`MATRIX.length > 0\` is not defensive padding. TOTALS is
2498
+ * \`(anchors + 1) * MATRIX.length\`, so an ANCHORED page with \`matrix: []\`
2499
+ * yields 0 \u2014 truthy-adjacent, arithmetically fatal. Measured on a page with 3
2500
+ * anchors, an empty matrix and 8 passing regions: without this clause the
2501
+ * scorer printed \`SCORE 8/0 regions passing\` and \`Backlog is empty \u2014 Phases 5
2502
+ * and 6 are what is left\`, exit 0. The absurd fraction would be questioned;
2503
+ * the sentence would not. \`pass/0\` is also Infinity, so such a page sorts
2504
+ * BEST and can never be named \`worst\` \u2014 the same ranking bug this change set
2505
+ * removed for unanchored pages, one input along. */
2506
+ export const scorable = (key) =>
2507
+ (byKey[key]?.anchors?.length ?? 0) > 0 && MATRIX.length > 0;
2508
+
2509
+ /** WHY a page is not scorable, in the words of the thing that is actually
2510
+ * missing. A refusal that states a cause it did not check is the shape
2511
+ * CLAUDE.md names: a field must never be named after something it cannot
2512
+ * observe. "no anchors" printed for a page carrying three of them sends the
2513
+ * operator to edit the one part of harness.json that was already right. */
2514
+ export const unscorableWhy = (key) =>
2515
+ (byKey[key]?.anchors?.length ?? 0) === 0
2516
+ ? "no anchors"
2517
+ : MATRIX.length === 0
2518
+ ? "matrix is empty"
2519
+ : null;
2520
+
2521
+ // DERIVED, never hand-typed: page-diff cuts one region before the first anchor
2522
+ // ("top") plus one per anchor, at every viewport. The old hand-written map went
2523
+ // stale the moment an anchor list changed, and a wrong denominator makes the
2524
+ // score a lie in the flattering direction.
2525
+ //
2526
+ // THAT IDENTITY HOLDS ONLY WITH ANCHORS. \`splitRegions\` (page-diff.mjs:103-110)
2527
+ // only cuts by anchor when there are anchors to cut by; with none it falls back
2528
+ // to the page's own <section> boxes, and with none of those to an even four-row
2529
+ // grid (lib/regions.mjs:27-35, gridRows = 4, labels \`grid-<r>-<c>\`). So the
2530
+ // count is DATA-DEPENDENT, the two pages need not even agree, and no formula
2531
+ // over anchors can predict it.
2532
+ //
2533
+ // \`checkRun\` below already reaches this conclusion \u2014 its \`if (secs.length)\`
2534
+ // guard declines to assert a region count without anchors, and says why. That
2535
+ // fix was applied to the VALIDATOR and never carried to the DENOMINATOR, so
2536
+ // next.mjs went on dividing a real pass count by an imaginary total. Measured
2537
+ // 2026-09-09 on a seed harness (anchors: [], matrix of 3): page-diff produced
2538
+ // 12 grid regions, all passing, and next.mjs printed \`SCORE 12/3 regions
2539
+ // passing\` followed by "Backlog is empty \u2014 Phases 5 and 6 are what is left",
2540
+ // exit 0. On a matrix of 4 the same seed prints \`SCORE 16/4\`. The absurd
2541
+ // fraction would have been questioned; the sentence would not.
2542
+ //
2543
+ // So an unpredictable page gets NO NUMBER \u2014 \`null\`, not a plausible-looking
2544
+ // integer. That is a SIGNAL, not a barrier: \`a + null\` is \`a\`, so a consumer
2545
+ // that sums TOTALS without asking \`scorable()\` still gets a too-small
2546
+ // denominator. The barrier is \`scorable()\` plus next.mjs's exit-2 refusal.
2547
+ export const TOTALS = Object.fromEntries(
2548
+ PAGES.map((p) => [p.key, scorable(p.key) ? (p.anchors.length + 1) * MATRIX.length : null]),
2549
+ );
2550
+
2551
+ /** The SPEC.md heading predicate, shared by gate.sh's preflight and
2552
+ * build-spec.mjs so a section can never build fine and then refuse at the
2553
+ * gate. Matches the key followed by any non-key character (\`## team\` matches,
2554
+ * \`## teamfoo\` does not, \`## our-team\` cannot match \`team\`). */
2555
+ export const specHeadingRe = (key) => new RegExp(\`^##+ +\${key}([^A-Za-z0-9_-]|$)\`, "m");
2556
+
2557
+ export const SKILL_DIR =
2558
+ process.env.MATCHING_SKILL_DIR ?? join(homedir(), ".claude/skills/matching-a-page");
2559
+ export const PD = join(SKILL_DIR, "page-diff.mjs");
2560
+ export const SC = join(SKILL_DIR, "style-census.mjs");
2561
+ export const PLAYWRIGHT = pathToFileURL(join(SKILL_DIR, "node_modules/playwright/index.mjs")).href;
2562
+
2563
+ /** The report format this site's scripts can read, so gate.sh can compare it
2564
+ * with \`page-diff --version\` before spending a run and next.mjs can refuse
2565
+ * rather than quietly drop a page whose newest report came from another
2566
+ * schema. Both do that now \u2014 gate.sh preflights \`page-diff --version\` against
2567
+ * this value before spending a run, and next.mjs counts a foreign-schema
2568
+ * report as MISSING rather than skipping it. (This said "neither does that
2569
+ * yet" \u2014 true at 922dde3 where it was written, false from 4e2cd7b, which
2570
+ * gave gate.sh the preflight and did not come back here.) Checked 2026-09-09
2571
+ * against the installed skill: \`page-diff --version\` \u2192 \`page-diff 0.1.0
2572
+ * report-schema 1\`. */
2573
+ export const REPORT_SCHEMA = 1;
2574
+
2575
+ /**
2576
+ * Fail-closed reference preflight. A 200 is NOT evidence: a host that has been
2577
+ * repointed at our own build answers 200, and so does a staging host serving a
2578
+ * 404 page. Both have happened on a real site \u2014 see the dated measurement in
2579
+ * LEDGER.md. A pass here requires an artefact only the reference produces.
2580
+ */
2581
+ export async function checkRef() {
2582
+ if (!REF_MARK) {
2583
+ return {
2584
+ ok: false,
2585
+ why: "harness.json refMark is empty \u2014 set it to a string only the reference serves (a Webflow site id, a build hash). A 200 is not evidence.",
2586
+ };
2587
+ }
2588
+ const host = new URL(REF).host;
2589
+ if (SELF_HOSTS.includes(host)) return { ok: false, why: \`REF host \${host} is in selfHosts\` };
2590
+ if (host === new URL(CAND).host) return { ok: false, why: \`REF host \${host} equals CAND's host\` };
2591
+ let res;
2592
+ try {
2593
+ res = await fetch(\`\${REF}/\`, { redirect: "manual" });
2594
+ } catch (e) {
2595
+ return { ok: false, why: \`GET \${REF}/ failed: \${e.message}\` };
2596
+ }
2597
+ if (res.status !== 200)
2598
+ return { ok: false, why: \`GET \${REF}/ \u2192 HTTP \${res.status}, expected 200\` };
2599
+ const loc = res.headers.get("location");
2600
+ if (loc) return { ok: false, why: \`GET \${REF}/ \u2192 \${res.status} redirect to \${loc}\` };
2601
+ const body = await res.text();
2602
+ if (!body.includes(REF_MARK))
2603
+ return {
2604
+ ok: false,
2605
+ why: \`\${REF}/ served 200 but WITHOUT refMark \${JSON.stringify(REF_MARK)} \u2014 that is not the reference\`,
2606
+ };
2607
+ if (CAND_MARK && body.includes(CAND_MARK))
2608
+ return {
2609
+ ok: false,
2610
+ why: \`\${REF}/ contains candMark \${JSON.stringify(CAND_MARK)} \u2014 REF is serving OUR build\`,
2611
+ };
2612
+ return { ok: true, why: \`\${REF}/ \u2192 200, no redirect, refMark present, candMark absent\` };
2613
+ }
2614
+
2615
+ /**
2616
+ * Would next.mjs COUNT a run with this meta? Returns the reason it would not,
2617
+ * as a string, or null when it would.
2618
+ *
2619
+ * A reason string and not a boolean, because the two callers must tell the
2620
+ * cases apart: next.mjs treats "schema" as a page BLANKED (it has its own
2621
+ * message and its own exit) and merely skips the rest of the diagnostics,
2622
+ * while gate.sh prints whatever this says.
2623
+ *
2624
+ * It lives HERE rather than inside next.mjs because gate.sh now asks the same
2625
+ * question, and a question asked twice drifts: a gate that greens a run
2626
+ * next.mjs then drops is the same false green one step along. census.sh's
2627
+ * GUARD 2c (census.sh:153-168) records exactly that drift between
2628
+ * style-census's printer and census-count.mjs's parser \u2014 a COMPLETE census
2629
+ * reported as 0 mismatches because the two had versioned apart.
2630
+ */
2631
+ export function uncountable(m) {
2632
+ // Missing schemaVersion means "written before the field existed" = 0. It is
2633
+ // not an error on its own; it is only fatal when it would blank a page,
2634
+ // which is next.mjs's call to make, not this predicate's.
2635
+ if ((m.schemaVersion ?? 0) !== REPORT_SCHEMA) return "schema";
2636
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
2637
+ // page. An --mask-photos probe of yfv made \`top\` @834 read 43.9% while the
2638
+ // real gate had it passing at 1.3%.
2639
+ if ((m.mask?.length ?? 0) > 0) return \`mask=[\${m.mask.join(", ")}]\`;
2640
+ if (m.neutralizeMedia) return "neutralize-media";
2641
+ if (m.maskPhotos) return "mask-photos";
2642
+ if (m.truncated) return "truncated";
2643
+ if (m.threshold !== THRESHOLD) return \`threshold \${m.threshold} != \${THRESHOLD}\`;
2644
+ return null;
2645
+ }
2646
+
2647
+ /**
2648
+ * Did THIS run of page-diff leave a report the scorer will actually count?
2649
+ *
2650
+ * gate.sh cannot use page-diff's exit status for this. page-diff exits 1 for a
2651
+ * region that legitimately FAILED (page-diff.mjs:227) and node exits 1 for the
2652
+ * bare \`throw e\` one line below it, so the status cannot tell a finding from a
2653
+ * crash-before-looking \u2014 census.sh:17-32 records the same shape for
2654
+ * style-census. Measured on 29 Navy with the reference alive and no dev
2655
+ * server: every page-diff died in \`page.goto\`, the gate printed \`home exit=1\`
2656
+ * and \`ALL DONE\`, exited 0, and wrote no report at all.
2657
+ *
2658
+ * So the evidence is the artefact only a completed run leaves: the report
2659
+ * next.mjs will read, fresh, over the matrix and anchors the table declares.
2660
+ * Cheapest and most specific arm first.
2661
+ */
2662
+ export function checkRun(page, dir, startedAt) {
2663
+ const path = join(dir, "report.json");
2664
+ let report;
2665
+ try {
2666
+ report = JSON.parse(readFileSync(path, "utf8"));
2667
+ } catch (e) {
2668
+ if (e.code === "ENOENT")
2669
+ return { ok: false, why: \`\${path}: no report.json \u2014 the run wrote nothing\` };
2670
+ return { ok: false, why: \`\${path}: \${e.message}\` };
2671
+ }
2672
+ const meta = report.meta ?? {};
2673
+
2674
+ const uncount = uncountable(meta);
2675
+ if (uncount)
2676
+ return { ok: false, why: \`\${path}: next.mjs would not count this run (\${uncount})\` };
2677
+
2678
+ // FRESHNESS. lib/report.mjs:45 is \`mkdirSync(outDir, {recursive:true})\` and
2679
+ // nothing ever clears the directory, so a crashed re-run under a tag used
2680
+ // before leaves the PREVIOUS round's report exactly where it was \u2014 measured
2681
+ // 2026-09-09, sha unchanged across the crash. Requiring report.json without
2682
+ // this arm reproduces the green one step along. meta.generatedAt is built
2683
+ // after \`finally { await browser.close() }\` (page-diff.mjs:163-176), so it
2684
+ // is an artefact of the run that wrote it and not of the file's mtime.
2685
+ const since = Date.parse(startedAt);
2686
+ // NOT skipped when startedAt is unusable: a fail-open default is the exact
2687
+ // shape this guard exists to stop.
2688
+ if (!Number.isFinite(since))
2689
+ return {
2690
+ ok: false,
2691
+ why: \`startedAt \${JSON.stringify(startedAt)} is not a timestamp \u2014 cannot tell this run's report from a previous round's\`,
2692
+ };
2693
+ const at = Date.parse(meta.generatedAt ?? "");
2694
+ if (!Number.isFinite(at))
2695
+ return {
2696
+ ok: false,
2697
+ why: \`\${path}: no usable meta.generatedAt \u2014 cannot tell this run's report from a previous round's\`,
2698
+ };
2699
+ if (at < since)
2700
+ return {
2701
+ ok: false,
2702
+ why: \`\${path}: STALE \u2014 written \${meta.generatedAt}, this run started \${startedAt}. page-diff never cleared the directory.\`,
2703
+ };
2704
+
2705
+ // COVERAGE \u2014 what the run was ASKED for, against the table.
2706
+ const vws = meta.viewports ?? [];
2707
+ if (vws.join(",") !== MATRIX.join(","))
2708
+ return {
2709
+ ok: false,
2710
+ why: \`\${path}: ran viewports [\${vws.join(",")}], harness.json matrix is [\${MATRIX.join(",")}]\`,
2711
+ };
2712
+ const secs = meta.sections ?? [];
2713
+ const want = byKey[page]?.anchors ?? [];
2714
+ if (secs.join("\\0") !== want.join("\\0"))
2715
+ return {
2716
+ ok: false,
2717
+ why: \`\${path}: ran sections [\${secs.join(" | ")}], harness.json anchors are [\${want.join(" | ")}]\`,
2718
+ };
2719
+
2720
+ // ...and what it actually PRODUCED. Every viewport the run says it covered
2721
+ // has to appear in the regions. Deliberately compared against the run's own
2722
+ // meta.viewports and not against MATRIX: the arm above owns "the run used the
2723
+ // wrong matrix", and folding the two together would make either one
2724
+ // unfalsifiable on its own.
2725
+ if (!Array.isArray(report.regions) || report.regions.length === 0)
2726
+ return { ok: false, why: \`\${path}: no regions \u2014 nothing was compared\` };
2727
+ const seen = new Set(report.regions.map((r) => r.viewport));
2728
+ const missing = vws.filter((v) => !seen.has(v));
2729
+ if (missing.length)
2730
+ return {
2731
+ ok: false,
2732
+ why: \`\${path}: no region at viewport(s) [\${missing.join(",")}] \u2014 the run covered [\${[...seen].join(",")}]\`,
2733
+ };
2734
+
2735
+ // With anchors the region count is an exact identity: page-diff cuts one
2736
+ // region before the first anchor plus one per anchor, at every viewport
2737
+ // (regionsFromAnchors, page-diff.mjs:105-109). Measured over the 298 clean
2738
+ // gate-shaped runs in the corpus this harness was cut from, all of them
2739
+ // anchored: regions.length === (sections + 1) * viewports holds 298/298,
2740
+ // while regions.length === TOTALS[page] holds only 260/298 \u2014 the 38 are
2741
+ // legitimately narrower HAND rounds. So the identity is checked against the
2742
+ // run's OWN meta and the matrix/anchors are checked against the table above.
2743
+ //
2744
+ // WITHOUT anchors there is no such identity, and asserting one is a FALSE
2745
+ // REFUSAL of the shape every new site starts in. page-diff falls back to each
2746
+ // page's own <section> boxes and, with none, an even four-row grid
2747
+ // (splitRegions, page-diff.mjs:103-110), so the count is data-dependent and
2748
+ // the two pages need not even agree. Measured 2026-09-09 against the real
2749
+ // page-diff on a seed harness (anchors: [], matrix of 4): 16 regions labelled
2750
+ // grid-0-0 \u2026 grid-3-0, not the 4 this identity predicted. The 298/298 above
2751
+ // was measured over anchored runs only and never covered this case.
2752
+ if (secs.length) {
2753
+ const expected = (secs.length + 1) * vws.length;
2754
+ if (report.regions.length !== expected)
2755
+ return {
2756
+ ok: false,
2757
+ why: \`\${path}: \${report.regions.length} region(s), expected \${expected} = (\${secs.length} anchors + 1) x \${vws.length} viewport(s)\`,
2758
+ };
2759
+ }
2760
+
2761
+ // A green that STATES what it is made of, so a green over nothing reads
2762
+ // differently from a green over the matrix (census.sh:219's habit).
2763
+ return {
2764
+ ok: true,
2765
+ why: \`\${report.regions.length} region(s) over \${vws.length} viewport(s), written \${meta.generatedAt}\`,
2766
+ };
2767
+ }
2768
+
2769
+ // CLI. Both sides go through realpathSync. \`import.meta.url\` is ALREADY the
2770
+ // resolved real path (node resolves symlinks unless --preserve-symlinks) while
2771
+ // process.argv[1] is the path as typed, so a plain pathToFileURL compare goes
2772
+ // false the moment any component of the invoked path is a symlink \u2014 and then
2773
+ // the CLI prints nothing and exits 0, which every caller reads as success.
2774
+ // page-diff.mjs:184-189 records exactly that defect and the same fix: "The
2775
+ // pathToFileURL compare that replaced the old template string is still false
2776
+ // whenever ANY component of the invoked path is a symlink \u2014 which is how this
2777
+ // skill is installed now (~/.claude/skills/matching-a-page -> the claude-skills
2778
+ // checkout). isMain() resolves the real path on both sides."
2779
+ const isMain = () => {
2780
+ if (!process.argv[1]) return false;
2781
+ try {
2782
+ return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
2783
+ } catch {
2784
+ return false;
2785
+ }
2786
+ };
2787
+
2788
+ if (isMain()) {
2789
+ const mode = process.argv[2];
2790
+ const q = (v) => \`'\${String(v).replace(/'/g, \`'\\\\''\`)}'\`;
2791
+ if (mode === "--env") {
2792
+ const pairs = [
2793
+ ["REF", REF],
2794
+ ["CAND", CAND],
2795
+ ["MATRIX", MATRIX.join(",")],
2796
+ ["VIEWPORTS_SP", MATRIX.join(" ")],
2797
+ ["THRESHOLD", THRESHOLD],
2798
+ ["MAX_HEIGHT_DELTA", MAX_HEIGHT_DELTA],
2799
+ ["PD", PD],
2800
+ ["SC", SC],
2801
+ ["REPORT_SCHEMA", REPORT_SCHEMA],
2802
+ ];
2803
+ for (const [k, v] of pairs) console.log(\`\${k}=\${q(v)}\`);
2804
+ } else if (mode === "--table") {
2805
+ for (const p of PAGES) console.log([p.key, p.ref, p.cand, p.anchors.join(",")].join("\\t"));
2806
+ } else if (mode === "--check-ref") {
2807
+ const r = await checkRef();
2808
+ console.log(\`\${r.ok ? "REF OK" : "REF REFUSED"} \u2014 \${r.why}\`);
2809
+ process.exit(r.ok ? 0 : 2);
2810
+ } else if (mode === "--check-run") {
2811
+ // startedAt is REQUIRED, never defaulted: an optional one is a fail-open
2812
+ // door in the one guard that decides whether a page was measured at all.
2813
+ const [page, dir, startedAt] = process.argv.slice(3);
2814
+ if (!page || !dir || !startedAt) {
2815
+ console.error("usage: harness.mjs --check-run <page> <out-dir> <startedAt-iso>");
2816
+ process.exit(2);
2817
+ }
2818
+ const r = checkRun(page, dir, startedAt);
2819
+ console.log(\`\${r.ok ? "RUN OK" : "NO RUN"} \u2014 \${r.why}\`);
2820
+ process.exit(r.ok ? 0 : 2);
2821
+ } else {
2822
+ console.error("usage: harness.mjs --env | --table | --check-ref | --check-run");
2823
+ process.exit(2);
2824
+ }
2825
+ }
2826
+ `;
2827
+ var CENSUS_SH_PREV_0_95_1 = `#!/usr/bin/env bash
2828
+ # Phase 3 style gate \u2014 the mechanical net for the type spec.
2829
+ #
2830
+ # bash matching/census.sh # all 9 pages, all 3 viewports
2831
+ # bash matching/census.sh home yfv # just these
2832
+ #
2833
+ # style-census diffs the computed type tuple (family, weight, size,
2834
+ # line-height, letter-spacing, transform, colour) of every text snippet present
2835
+ # on BOTH pages. It is the only gate that catches the 11px footer line and the
2836
+ # cyan-vs-teal link that page-diff is structurally blind to \u2014 a region can be
2837
+ # pixel-clean at 0.10 and still be wearing the wrong colour on small text.
2838
+ #
2839
+ # Prints a per-page/per-viewport mismatch count and leaves the full runs in
2840
+ # matching/census-<page>-<vw>.log. Exits 1 while any count is non-zero, and 2
2841
+ # when it cannot honestly report a count at all.
2842
+ #
2843
+ # EVIDENCE \u2014 read before adding a guard here, or removing one. A green here
2844
+ # has to be produced by a census that RAN. It was not: with style-census.mjs
2845
+ # absent this gate printed "Phase 3 CLEAN \u2014 0 undeclared type mismatches" and
2846
+ # exited 0. Every run died on module resolution, census-count.mjs read each
2847
+ # crash log as "0 0 0" (it counts \` y=\` rows and a stack trace has none \u2014
2848
+ # census-count.mjs:35-56), and no rows was reported as no mismatches. Measured
2849
+ # again with style-census.mjs PRESENT and the candidate host down: identical
2850
+ # output, because style-census exits 1 both when it finds mismatches
2851
+ # (style-census.mjs:187) and when it dies before it looks \u2014 so the exit status
2852
+ # cannot tell a finding from a crash and may not grant a green either.
2853
+ #
2854
+ # The guards below are what next.mjs:83-89 (no parseable gate run \u2014 refusing to
2855
+ # report a score), strikes.mjs:87-97 (refusing to report "clear") and
2856
+ # gate.sh:37-42 (preflight page-diff before spending a run) already do. Each
2857
+ # demands an artefact only a working census produces; none of them can turn a
2858
+ # red green.
2859
+ set -uo pipefail
2860
+ # Resolved BEFORE the cd. $0 is the path as TYPED, so a relatively invoked
2861
+ # census.sh that resolved the harness afterwards would look for it under
2862
+ # whatever root the cd landed on.
2863
+ HARNESS="$(cd "$(dirname "$0")" && pwd)/harness.mjs"
2864
+ cd "$(dirname "$0")/.."
2865
+
2866
+ # Everything configurable lives in matching/harness.json; harness.mjs is the one
2867
+ # reader. --env supplies REF, CAND, SC and VIEWPORTS_SP \u2014 this gate held its own
2868
+ # copy of all four, and its REF went stale when production cut over to our own
2869
+ # build (LEDGER "REFERENCE MOVED", verified 2026-08-10) while gate.sh's was
2870
+ # repointed the same day.
2871
+ eval "$(node "$HARNESS" --env)"
2872
+ NODE="\${NODE:-node}"
2873
+
2874
+ # The matrix is DATA, read as VIEWPORTS_SP, the way gate.sh reads MATRIX. The
2875
+ # old VIEWPORTS override is retired rather than silently ignored: a census run
2876
+ # over a matrix harness.json does not name is a column set the pixel gate never
2877
+ # measured, and it would look exactly like a clean one.
2878
+ if [ -n "\${VIEWPORTS:-}" ]; then
2879
+ echo "census.sh: the VIEWPORTS override is retired \u2014 the matrix is data now." >&2
2880
+ echo " Edit \\"matrix\\" in matching/harness.json, then unset it." >&2
2881
+ exit 2
2882
+ fi
2883
+
2884
+ # GUARD 1 \u2014 the tool is there and it starts. gate.sh compares \`page-diff
2885
+ # --version\` with the harness's REPORT_SCHEMA before spending a run
2886
+ # (gate.sh:37-42); style-census has no --version to compare (page-diff.mjs
2887
+ # defines one at 191-195; nothing in style-census.mjs writes one), so the
2888
+ # artefact demanded here is the other one only it produces: the usage banner it
2889
+ # prints on exit 2 when --ref/--cand are missing (style-census.mjs:151-157).
2890
+ # That banner is not free \u2014 \`import { chromium } from "playwright"\` runs first
2891
+ # (style-census.mjs:19) \u2014 so a skill directory with no playwright installed
2892
+ # fails HERE, once, instead of writing one crash log per page per viewport that
2893
+ # every reader downstream counts as zero.
2894
+ if [ ! -f "$SC" ]; then
2895
+ echo "census.sh: no style-census at $SC \u2014 refusing to report a count." >&2
2896
+ echo " Install the matching-a-page skill, or point MATCHING_SKILL_DIR" >&2
2897
+ echo " at a checkout that has style-census.mjs." >&2
2898
+ exit 2
2899
+ fi
2900
+ SC_USAGE="$("$NODE" "$SC" 2>&1)"
2901
+ case "$SC_USAGE" in
2902
+ *"style-census"*"--ref"*"--cand"*"--vw"*) ;;
2903
+ *)
2904
+ echo "census.sh: $SC did not answer with a style-census --ref/--cand/--vw usage" >&2
2905
+ echo " banner \u2014 refusing to report a count. It printed:" >&2
2906
+ echo " \${SC_USAGE:-(nothing at all)}" >&2
2907
+ exit 2
2908
+ ;;
2909
+ esac
2910
+
2911
+ declare -a WANT=("$@")
2912
+
2913
+ # page -> "refpath candpath": the same table gate.sh drives, from the same file.
2914
+ # cut drops the anchors column, which the style census has no argument for
2915
+ # (style-census.mjs: --ref <url> --cand <url> [--vw 1440]).
2916
+ pages() { node "$HARNESS" --table | cut -f1-3; }
2917
+
2918
+ TOTAL=0
2919
+ AMB=0
2920
+ DECL=0
2921
+ ROWS=0
2922
+ RUNS=0
2923
+ BROKEN=0
2924
+ SEEN=""
2925
+ # One column per viewport, from the matrix. Flush with the data rows below, at
2926
+ # last: the old header hardcoded four fixed fields separated by a literal space,
2927
+ # which the rows do not have, so its labels sat 1/2/3 columns right of them.
2928
+ printf '%-10s' page
2929
+ for vw in $VIEWPORTS_SP; do printf '%8s' "$vw"; done
2930
+ printf '\\n'
2931
+ while read -r page refpath candpath; do
2932
+ [ -z "$page" ] && continue
2933
+ SEEN="$SEEN $page"
2934
+ if [ \${#WANT[@]} -gt 0 ]; then
2935
+ hit=0
2936
+ for w in "\${WANT[@]}"; do [ "$w" = "$page" ] && hit=1; done
2937
+ [ $hit -eq 1 ] || continue
2938
+ fi
2939
+ ROWS=$((ROWS + 1))
2940
+ line=$(printf '%-10s' "$page")
2941
+ for vw in $VIEWPORTS_SP; do
2942
+ log="matching/census-$page-$vw.log"
2943
+ RUNS=$((RUNS + 1))
2944
+ "$NODE" "$SC" --ref "$REF$refpath" --cand "$CAND$candpath" --vw "$vw" >"$log" 2>&1
2945
+ # GUARD 2 \u2014 this run produced a census. The header and the counts line are
2946
+ # written together at style-census.mjs:166-169 and only after both pages
2947
+ # have been walked, so they are the artefact a completed run leaves and a
2948
+ # crashed one cannot. The viewport is read back out of the header because a
2949
+ # --vw that did not take would otherwise fill three differently named logs
2950
+ # with the same 1440 census. \`ref runs\`/\`cand runs\` must be non-zero: two
2951
+ # pages that rendered no text at all agree perfectly and mean nothing.
2952
+ if ! grep -qE "^=== style census, viewport $vw ===\\$" "$log" ||
2953
+ ! grep -qE "^ref runs: [1-9][0-9]* +cand runs: [1-9][0-9]* +mismatches: [0-9]+ +ambiguous: [0-9]+\\$" "$log"; then
2954
+ BROKEN=$((BROKEN + 1))
2955
+ echo "census.sh: $page @$vw produced no usable census \u2014 see $log" >&2
2956
+ line="$line$(printf '%8s' '!!')"
2957
+ continue
2958
+ fi
2959
+ # census-count splits the log three ways: REAL mismatches, AMBIGUOUS
2960
+ # same-text collisions (style-census's own split), and DECLARED rows the
2961
+ # operator has already ruled on (matching/census-deviations.mjs, the same
2962
+ # contract as floors.mjs). Only the first is outstanding work \u2014 without the
2963
+ # third, this gate can never reach zero and its number means nothing.
2964
+ read -r n a d <<<"$("$NODE" matching/census-count.mjs "$log")"
2965
+ # GUARD 2b \u2014 three integers, or the counter did not count. census-count.mjs
2966
+ # is the reader that turned a stack trace into "0 0 0"; a run of it that
2967
+ # dies (a broken census-deviations.mjs, a bad path) prints nothing at all,
2968
+ # and bash reads an empty $n as zero in the arithmetic below.
2969
+ counted=1
2970
+ for v in "\${n:-}" "\${a:-}" "\${d:-}"; do
2971
+ case "$v" in "" | *[!0-9]*) counted=0 ;; esac
2972
+ done
2973
+ if [ "$counted" -eq 0 ]; then
2974
+ BROKEN=$((BROKEN + 1))
2975
+ echo "census.sh: census-count.mjs did not return three counts for $log" >&2
2976
+ line="$line$(printf '%8s' '!!')"
2977
+ continue
2978
+ fi
2979
+ # GUARD 2c \u2014 the number reported is the number the census MEASURED. GUARD 2
2980
+ # matched the counts line for PRESENCE and threw its numbers away, so the
2981
+ # count printed below came from census-count's row parse and nothing ever
2982
+ # compared the two. Measured 2026-09-09: a COMPLETE census whose counts line
2983
+ # said "mismatches: 3", whose \`y=\` rows carried one leading space instead of
2984
+ # two (census-count.mjs:39 matches /^ {2}y=/), was reported as 0 and this
2985
+ # gate printed "Phase 3 CLEAN \u2014 0 undeclared type mismatches", exit 0. The
2986
+ # printer lives in the SKILL (style-census.mjs:171-173) and the parser is
2987
+ # copied into every site, so the two version independently and drift needs
2988
+ # nobody to touch either repo.
2989
+ #
2990
+ # The identity is exact for mismatches: style-census prints at most 100 rows
2991
+ # and states the remainder on its own line (style-census.mjs:175-177), so
2992
+ # parsed + remainder == reported. Ambiguous rows are capped the same way
2993
+ # (:184) but the remainder is NOT stated, so only a floor is checkable
2994
+ # there \u2014 any is not none, and the parse may never exceed the report.
2995
+ counts=$(grep -E "^ref runs: [1-9][0-9]* +cand runs: [1-9][0-9]* +mismatches: [0-9]+ +ambiguous: [0-9]+$" "$log" | tail -1)
2996
+ said_m=\${counts##*mismatches: }
2997
+ said_m=\${said_m%% *}
2998
+ said_a=\${counts##*ambiguous: }
2999
+ more=$(sed -n 's/^ *\u2026 and \\([0-9][0-9]*\\) more (truncated print, all counted)$/\\1/p' "$log" | tail -1)
3000
+ if [ $((n + d + \${more:-0})) -ne "$said_m" ] ||
3001
+ [ "$a" -gt "$said_a" ] ||
3002
+ { [ "$said_a" -gt 0 ] && [ "$a" -eq 0 ]; }; then
3003
+ BROKEN=$((BROKEN + 1))
3004
+ echo "census.sh: $log reports mismatches: $said_m ambiguous: $said_a, but" >&2
3005
+ echo " census-count.mjs read $((n + d)) mismatch row(s)\${more:+ (+ $more truncated)} and $a ambiguous." >&2
3006
+ echo " The census and its reader disagree; there is no count to report." >&2
3007
+ line="$line$(printf '%8s' '!!')"
3008
+ continue
3009
+ fi
3010
+ AMB=$((AMB + a))
3011
+ DECL=$((DECL + d))
3012
+ TOTAL=$((TOTAL + n))
3013
+ line="$line$(printf '%8s' "$n")"
3014
+ done
3015
+ echo "$line"
3016
+ done < <(pages)
3017
+
3018
+ echo
3019
+ # GUARD 3 \u2014 something was actually censused. Process substitution above, NOT a
3020
+ # pipe: a pipe runs the loop in a subshell and every counter here would still
3021
+ # read 0, which is exactly the green this file exists to stop (gate.sh:123-125
3022
+ # records the same trap for FAILED_PREFLIGHT). A page name that matches nothing
3023
+ # must not report CLEAN either \u2014 strikes.mjs:147-156 is the same refusal, and
3024
+ # it was written because a typo silently greened rule 3 on six of nine pages.
3025
+ if [ "$ROWS" -eq 0 ]; then
3026
+ if [ -n "$SEEN" ]; then
3027
+ echo "census.sh: \\"\${WANT[*]:-}\\" matches no page \u2014 refusing to report CLEAN." >&2
3028
+ echo " known pages:$SEEN" >&2
3029
+ else
3030
+ echo "census.sh: the page table is empty \u2014 refusing to report CLEAN." >&2
3031
+ echo " node matching/harness.mjs --table printed no rows; check" >&2
3032
+ echo " \\"pages\\" in matching/harness.json." >&2
3033
+ fi
3034
+ exit 2
3035
+ fi
3036
+ if [ "$BROKEN" -gt 0 ]; then
3037
+ echo "CENSUS INCOMPLETE \u2014 $BROKEN of $RUNS run(s) produced no usable census (marked !!)."
3038
+ echo "Those page/viewport pairs have NOT been measured; there is no count to"
3039
+ echo "report for them, and the totals below would be a floor, not a score."
3040
+ echo "Full runs: matching/census-<page>-<vw>.log"
3041
+ exit 2
3042
+ fi
3043
+ if [ "$TOTAL" -eq 0 ]; then
3044
+ echo "Phase 3 CLEAN \u2014 0 undeclared type mismatches ($DECL declared, $AMB ambiguous)."
3045
+ echo "($RUNS censused run(s) over $ROWS page(s), each one counted.)"
3046
+ [ "$AMB" -gt 0 ] && echo "($AMB ambiguous same-text rows remain: each needs ADJUDICATING," &&
3047
+ echo " not fixing \u2014 our element matches, another sharing its text does not.)"
3048
+ exit 0
3049
+ fi
3050
+ echo "$TOTAL type mismatch(es) remain (+ $AMB ambiguous, $DECL declared)."
3051
+ echo "Full runs: matching/census-<page>-<vw>.log"
3052
+ echo "A mismatch is a defect or a ledgered deviation. An ambiguous row is neither"
3053
+ echo "until you look: the census keys on TEXT, so two different elements sharing a"
3054
+ echo "string land under one key and only one of them may be wrong."
3055
+ exit 1
3056
+ `;
3057
+ var NEXT_MJS_PREV_0_95_1 = `// What is still broken, ranked. Exits 1 while work remains.
3058
+ //
3059
+ // node matching/next.mjs
3060
+ //
3061
+ // Round protocol step 0 (repo CLAUDE.md rule 5). A commit is a CHECKPOINT, not
3062
+ // a stopping point: after committing, run this. If it exits 1 there is a named
3063
+ // next action and the round continues without handing control back.
3064
+ //
3065
+ // Reads the most recent gate log per page rather than the whole corpus, so it
3066
+ // reflects HEAD rather than history (that is strikes.mjs's job).
3067
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3068
+ import { join } from "node:path";
3069
+
3070
+ const DIR = new URL(".", import.meta.url).pathname;
3071
+
3072
+ // PAUSE SWITCH. While matching/PAUSED exists this hands out no agenda and
3073
+ // exits 0. It is deliberately the FIRST thing that runs: no report is read, no
3074
+ // score is printed, nothing tempting is put on screen to argue with.
3075
+ //
3076
+ // Why an exit code and not a note somewhere: rule 5 is a LOOP \u2014 "after
3077
+ // committing, run next.mjs; while it exits 1 there is a named next action and
3078
+ // the round continues". A pause written as prose loses to that loop, because
3079
+ // the loop is mechanical and the prose is not. Exiting 0 satisfies rule 5
3080
+ // truthfully rather than suspending it: there is no next action.
3081
+ const PAUSE = join(DIR, "PAUSED");
3082
+ if (existsSync(PAUSE)) {
3083
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
3084
+ console.log(readFileSync(PAUSE, "utf8").trimEnd());
3085
+ process.exit(0);
3086
+ }
3087
+
3088
+ import { FLOORS, ACCEPTED } from "./floors.mjs";
3089
+ import {
3090
+ TOTALS,
3091
+ THRESHOLD,
3092
+ MAX_HEIGHT_DELTA,
3093
+ REPORT_SCHEMA,
3094
+ uncountable,
3095
+ scorable,
3096
+ unscorableWhy,
3097
+ byKey,
3098
+ } from "./harness.mjs";
3099
+
3100
+ // Reports written by a different page-diff, by page key. Kept rather than
3101
+ // dropped: silently ignoring them is how a page vanishes from the score.
3102
+ const schemaMismatch = new Set();
3103
+
3104
+ const latest = new Map();
3105
+ for (const d of readdirSync(DIR).filter((d) => d.startsWith("out-"))) {
3106
+ const m = /^out-[^-]+-(.+)$/.exec(d);
3107
+ // Membership in the page table is asked of the TABLE. \`!TOTALS[m[1]]\` was a
3108
+ // NUMBER standing in for a fact: it is null for an unanchored page and 0 for
3109
+ // an empty matrix, and either dropped the page out of \`latest\` while
3110
+ // \`Object.keys(TOTALS)\` still listed it \u2014 so a gate run that had just
3111
+ // SUCCEEDED came back as "no countable gate run".
3112
+ if (!m || !byKey[m[1]]) continue;
3113
+ let report, mtime;
3114
+ try {
3115
+ const p = join(DIR, d, "report.json");
3116
+ report = JSON.parse(readFileSync(p, "utf8"));
3117
+ mtime = statSync(p).mtimeMs;
3118
+ } catch {
3119
+ continue;
3120
+ }
3121
+ // A masked / media-neutralised run is a DIAGNOSTIC, never the state of the
3122
+ // page. Picking one up as "latest" silently reports scores nobody can ship \u2014
3123
+ // it happened immediately: an --mask-photos probe of yfv made \`top\` @834 read
3124
+ // 43.9% here while the real gate had it passing at 1.3%. Missing
3125
+ // schemaVersion means "written before the field existed" = 0; it is not an
3126
+ // error on its own, only when it would blank a page.
3127
+ //
3128
+ // The predicate itself lives in harness.mjs now, because gate.sh asks the
3129
+ // same question per run and two copies of one question drift \u2014 a gate that
3130
+ // greens a run this file then drops is the same false green one step along.
3131
+ const why = uncountable(report.meta ?? {});
3132
+ if (why === "schema") {
3133
+ schemaMismatch.add(m[1]);
3134
+ continue;
3135
+ }
3136
+ if (why) continue;
3137
+ const prev = latest.get(m[1]);
3138
+ if (!prev || mtime > prev.mtime) latest.set(m[1], { dir: d, mtime, report });
3139
+ }
3140
+
3141
+ const blanked = [...schemaMismatch].filter((p) => !latest.has(p));
3142
+ if (blanked.length) {
3143
+ console.error(
3144
+ \`next: \${blanked.length} page(s) have no run at report schema \${REPORT_SCHEMA} \u2014 \` +
3145
+ \`their newest reports came from a different page-diff (\${blanked.sort().join(", ")}).\\n\` +
3146
+ \` Re-run: bash matching/gate.sh <tag> \${blanked.sort().join(" ")}\`,
3147
+ );
3148
+ process.exit(2);
3149
+ }
3150
+ if (latest.size === 0) {
3151
+ console.error(
3152
+ "next: no parseable gate run under matching/ \u2014 refusing to report a score.\\n" +
3153
+ " Run bash matching/gate.sh <tag> first.",
3154
+ );
3155
+ process.exit(2);
3156
+ }
3157
+
3158
+ const rows = [];
3159
+ const accepted = [];
3160
+ let openTotal = 0;
3161
+ let floorTotal = 0;
3162
+ for (const [page, { dir, report }] of latest) {
3163
+ const fails = report.regions.filter((r) => !r.pass);
3164
+ for (const f of fails) {
3165
+ const floor = FLOORS.find((fl) => fl.match(f, page));
3166
+ if (floor) {
3167
+ floorTotal++;
3168
+ continue;
3169
+ }
3170
+ const ack = ACCEPTED.find((a) => a.match(f, page));
3171
+ if (ack) {
3172
+ accepted.push({ page, vw: f.viewport, label: f.label, why: ack.why });
3173
+ continue;
3174
+ }
3175
+ openTotal++;
3176
+ rows.push({
3177
+ page,
3178
+ vw: f.viewport,
3179
+ label: f.label,
3180
+ mm: f.mismatchFraction,
3181
+ dh: f.heightDeltaFraction ?? 0,
3182
+ dir,
3183
+ });
3184
+ }
3185
+ }
3186
+
3187
+ // Pages that REPORTED but cannot be scored: no anchors, so their regions are
3188
+ // whatever page-diff's fallback cut and there is no model for one to be right
3189
+ // against. Kept separate from \`scored\` and never ranked with it.
3190
+ const unscorable = [...latest.entries()]
3191
+ .filter(([p]) => !scorable(p))
3192
+ .map(([p, v]) => ({ p, regions: v.report.regions.length }))
3193
+ .sort((a, b) => a.p.localeCompare(b.p));
3194
+
3195
+ // \`.filter(scorable)\` before \`.map\`, because the sort below divides by \`total\`
3196
+ // and an unanchored page's ratio is NOT BOUNDED BY 1. A passing one scored
3197
+ // 16/4 = 4.0 and sorted LAST, i.e. best, so \`worst\` could never name the one
3198
+ // page whose Phase 1 was not done; a failing one scored 0/4 = 0.0, sorted
3199
+ // FIRST, and put \`grid-0-0\`, \`grid-1-0\` \u2026 on the agenda \u2014 an instruction to fix
3200
+ // geometry against regions page-diff invented.
3201
+ const scored = [...latest.entries()]
3202
+ .filter(([p]) => scorable(p))
3203
+ .map(([p, v]) => ({
3204
+ p,
3205
+ pass: v.report.regions.filter((r) => r.pass).length,
3206
+ total: TOTALS[p],
3207
+ }))
3208
+ .sort((a, b) => a.pass / a.total - b.pass / b.total);
3209
+
3210
+ // The denominator is the DECLARED site, not the pages that happened to report.
3211
+ // Summed over \`scored\` it shrank to match the numerator: 8 of 9 pages reporting
3212
+ // read SCORE 160/160 while the ninth, whose page-diff had crashed, was in
3213
+ // neither the numerator nor the denominator nor the list below. harness.mjs:61-67
3214
+ // already gives the reason \u2014 "a wrong denominator makes the score a lie in the
3215
+ // flattering direction" \u2014 and that fix was applied per REGION (\`total:
3216
+ // TOTALS[p]\`) and never per PAGE.
3217
+ const unmeasured = Object.keys(TOTALS)
3218
+ .filter((p) => !latest.has(p))
3219
+ .sort();
3220
+ const sum = scored.reduce((a, s) => a + s.pass, 0);
3221
+ // Summed EXPLICITLY over the scorable pages rather than over TOTALS' values:
3222
+ // \`a + null\` is silently \`a\`, and a denominator that is right only because of a
3223
+ // coercion is the same lie one refactor away.
3224
+ const pagesAll = Object.keys(TOTALS).length;
3225
+ const pagesScorable = Object.keys(TOTALS).filter((p) => scorable(p)).length;
3226
+ const max = Object.keys(TOTALS).reduce((a, p) => a + (scorable(p) ? TOTALS[p] : 0), 0);
3227
+
3228
+ // A score is printed only if SOMETHING can carry one. \`SCORE 0/0\` is a third
3229
+ // lie and the one that reads best of all, so it is never printed: with nothing
3230
+ // scorable the line says so in words and gives no fraction to quote.
3231
+ console.log(
3232
+ (pagesScorable === 0
3233
+ ? \`NO SCORE \u2014 0 of \${pagesAll} page(s) can carry one.\`
3234
+ : \`SCORE \${sum}/\${max} regions passing over \${pagesScorable} of \${pagesAll} page(s)\` +
3235
+ (unmeasured.length ? \` \u2014 \${unmeasured.length} page(s) NOT MEASURED\` : "")) + "\\n",
3236
+ );
3237
+ console.log(
3238
+ [
3239
+ ...scored.map((s) => \` \${s.p.padEnd(9)} \${String(s.pass).padStart(2)}/\${s.total}\`),
3240
+ // The run's OWN region count, as EVIDENCE FOR THE REFUSAL \u2014 it proves a run
3241
+ // happened and explains why it cannot be scored, so a refusal is not
3242
+ // mistaken for a crash. The pass FRACTION is deliberately withheld: it is
3243
+ // the number with no referent, and the number that gets quoted.
3244
+ ...unscorable.map(
3245
+ (u) =>
3246
+ \` \${u.p.padEnd(9)} \${String(u.regions).padStart(2)} region(s) NOT SCORABLE \u2014 \${unscorableWhy(u.p)}\`,
3247
+ ),
3248
+ // \`?/N\`, never \`0/N\`: an unmeasured page is not a page that scored zero,
3249
+ // and printing zero would be a different lie. \`?/?\` when the page is ALSO
3250
+ // unanchored \u2014 \`?/null\` would name the denominator "null", which is worse
3251
+ // than the number it replaced.
3252
+ ...unmeasured.map((p) => \` \${p.padEnd(9)} ?/\${TOTALS[p] ?? "?"} NOT MEASURED\`),
3253
+ ].join("\\n"),
3254
+ );
3255
+
3256
+ if (accepted.length) {
3257
+ console.log(\`\\nOperator-ACCEPTED failures (left failing on purpose):\`);
3258
+ for (const a of accepted)
3259
+ console.log(\` \${a.page} @\${a.vw} "\${a.label}" \u2014 \${a.why.slice(0, 96)}\u2026\`);
3260
+ }
3261
+
3262
+ // BEFORE the \`!rows.length\` branch, and deliberately so: an unmeasured page
3263
+ // contributes no failing region, so that branch would print "Backlog is empty"
3264
+ // and exit 0 over a page nobody had looked at. Exit 2 matches the two guards
3265
+ // above (\`blanked\`, \`latest.size === 0\`) \u2014 neither "clean" nor "work remains"
3266
+ // but "this cannot be scored", the one answer rule 5's while-it-exits-1 loop
3267
+ // cannot swallow. The score print stays above it so the partial state is still
3268
+ // visible.
3269
+ // A page with no anchors has not finished Phase 1, so a score has no referent
3270
+ // and this refuses to invent one \u2014 the same answer \`refMark: ""\` gets from
3271
+ // checkRef and an absent \`## <page>\` SPEC section gets from gate.sh. All three
3272
+ // are seed sentinels, and this was the one that failed OPEN. Repo CLAUDE.md
3273
+ // rule 1's corollary is the whole argument: a field that can only observe
3274
+ // configuration must never be named after the thing it cannot observe, and
3275
+ // "12 of 12 grid rows passed" observes a screenshot cut into quarters, not a
3276
+ // design anyone specced.
3277
+ //
3278
+ // PRINTED here, EXITED below: \`unmeasured\` and \`unscorable\` are different pages
3279
+ // with different remedies, and exiting inside the first block would hide the
3280
+ // second from an operator who then fixes only what was printed.
3281
+ if (unscorable.length) {
3282
+ console.error(
3283
+ \`\\nnext: \${unscorable.length} page(s) have no anchors, so their regions are page-diff's own\\n\` +
3284
+ \` fallback cut and cannot be scored \u2014 \` +
3285
+ unscorable.map((u) => \`\${u.p} (\${u.regions} region(s))\`).join(", ") +
3286
+ \`.\\n Set "anchors" for them in matching/harness.json to section texts that exist on\\n\` +
3287
+ \` BOTH the reference and the candidate, then re-run: bash matching/gate.sh <tag> \` +
3288
+ unscorable.map((u) => u.p).join(" "),
3289
+ );
3290
+ }
3291
+
3292
+ if (unmeasured.length) {
3293
+ console.error(
3294
+ \`\\nnext: \${unmeasured.length} page(s) have no countable gate run \u2014 \${unmeasured.join(", ")}.\\n\` +
3295
+ \` Re-run: bash matching/gate.sh <tag> \${unmeasured.join(" ")}\`,
3296
+ );
3297
+ process.exit(2);
3298
+ }
3299
+
3300
+ // See above: deliberately a second statement, not an \`else\`.
3301
+ if (unscorable.length) process.exit(2);
3302
+
3303
+ if (!rows.length) {
3304
+ console.log(
3305
+ \`\\nNo open geometry failures. \${floorTotal} declared floor(s) remain.\`,
3306
+ );
3307
+ console.log(
3308
+ "Backlog is empty \u2014 Phases 5 (states) and 6 (adversarial review) are what is left.",
3309
+ );
3310
+ process.exit(0);
3311
+ }
3312
+
3313
+ // Worst page first, then worst region inside it: fix where the model is most wrong.
3314
+ //
3315
+ // INVARIANT, documented rather than guarded because no test could redden a
3316
+ // guard here: \`scored[0]\` is safe because \`scored\` is empty only when every
3317
+ // page in \`latest\` is unanchored \u2014 and that means \`unscorable.length > 0\`, so
3318
+ // the exit above already fired. (\`latest\` being empty is caught further up.)
3319
+ // \`rows\` likewise still contains unanchored pages' failing regions; they are
3320
+ // never printed because that same exit fires first. Both facts depend on the
3321
+ // exit staying ABOVE this line \u2014 a second filter here would be a second place
3322
+ // to keep in sync, which is how checkRun and next.mjs drifted apart to begin
3323
+ // with. If that exit ever moves, this becomes a TypeError.
3324
+ const worst = scored[0].p;
3325
+ rows.sort(
3326
+ (a, b) =>
3327
+ (a.page === worst ? -1 : 0) - (b.page === worst ? -1 : 0) || b.mm - a.mm,
3328
+ );
3329
+
3330
+ console.log(
3331
+ \`\\n\${openTotal} open failure(s) + \${floorTotal} declared floor(s).\`,
3332
+ );
3333
+ console.log(\`\\nNEXT: \${worst} \u2014 worst page. Its open regions:\\n\`);
3334
+ for (const r of rows.filter((r) => r.page === worst)) {
3335
+ const why = [];
3336
+ if (r.mm > THRESHOLD) why.push(\`pixels \${(r.mm * 100).toFixed(1)}%\`);
3337
+ if (Math.abs(r.dh) > MAX_HEIGHT_DELTA)
3338
+ why.push(\`height \${(r.dh * 100).toFixed(1)}%\`);
3339
+ console.log(
3340
+ \` @\${String(r.vw).padEnd(5)} \${r.label.slice(0, 44).padEnd(45)} \${why.join(" + ")}\`,
3341
+ );
3342
+ }
3343
+ console.log(\`\\nBefore treating any of these as geometry:\`);
3344
+ console.log(
3345
+ \` node matching/probe-anchor-parity.mjs \${worst} # is the gate cutting comparably?\`,
3346
+ );
3347
+ console.log(
3348
+ \` node matching/strikes.mjs \${worst} # has it stalled? then change the MODEL\`,
3349
+ );
3350
+ console.log(
3351
+ \`\\nRound continues. Do not hand back control with work outstanding.\`,
3352
+ );
3353
+ process.exit(1);
3354
+ `;
3355
+ var STRIKES_MJS_PREV_0_95_1 = `// The 3-strike rule, made countable.
3356
+ //
3357
+ // node matching/strikes.mjs [page]
3358
+ //
3359
+ // The matching skill says: "A region that has not improved after 3 fix ->
3360
+ // re-run cycles: stop. Present the three attempts. Do not widen the threshold,
3361
+ // mask it, or reclassify it \u2014 a stuck region means your model of it is wrong."
3362
+ //
3363
+ // That rule was blown twice on this project (services \`top\`, yfv \`top\`) purely
3364
+ // because nobody was counting. This reads every matching/out-*/report.json,
3365
+ // reconstructs each region's history in chronological order, and prints the
3366
+ // regions that are OUT OF STRIKES. Exit 1 when any exist, so a round that
3367
+ // starts by running this cannot quietly attempt a fourth cycle.
3368
+ //
3369
+ // WHAT THIS ACTUALLY MEASURES (read before trusting a number): report.json
3370
+ // cannot record INTENT, so a "cycle" here is any completed gate run in which
3371
+ // the region was still failing and did not improve by IMPROVE_PP. Runs aimed at
3372
+ // a different region on the same page therefore count too. That makes this a
3373
+ // STALL detector, not a literal attempt counter \u2014 "this has been failing,
3374
+ // unchanged, across N gate runs while you were working on this page." That is
3375
+ // the more useful discipline number anyway, and it is honest about being an
3376
+ // upper bound. Improvement resets the count: real progress earns a fresh start.
3377
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3378
+ import { join } from "node:path";
3379
+ import { FLOORS } from "./floors.mjs";
3380
+ import {
3381
+ PAGES,
3382
+ THRESHOLD,
3383
+ MAX_HEIGHT_DELTA,
3384
+ REPORT_SCHEMA,
3385
+ } from "./harness.mjs";
3386
+
3387
+ // PAUSE SWITCH \u2014 see matching/next.mjs for why this is an exit code. Round
3388
+ // protocol step 1 is "run strikes.mjs; the stalled regions are the agenda", so
3389
+ // this is the second door into the backlog and has to be shut too. Exits 0:
3390
+ // there are no strikes to spend when no round is being played.
3391
+ const PAUSED = join(new URL(".", import.meta.url).pathname, "PAUSED");
3392
+ if (existsSync(PAUSED)) {
3393
+ console.log("MATCHING PAUSED \u2014 no agenda, and none is to be inferred.\\n");
3394
+ console.log(readFileSync(PAUSED, "utf8").trimEnd());
3395
+ process.exit(0);
3396
+ }
3397
+
3398
+ const IMPROVE_PP = 0.01; // 1 percentage point = the smallest move worth calling progress
3399
+ const MAX_STRIKES = 3;
3400
+ const ROOT = new URL(".", import.meta.url).pathname;
3401
+ const only = process.argv[2] ?? null;
3402
+
3403
+ // The gate key for a report, from its ref path. Deriving it by string surgery
3404
+ // disagreed with the gate on exactly six of the nine rows \u2014 yfv, contact, atd,
3405
+ // svc, qa and team (measured 2026-09-09 against harness.json); the table knows.
3406
+ // keyOf (below) stays as the fallback for a run whose ref was rewritten.
3407
+ const pageOf = (ref) => {
3408
+ const path = new URL(ref).pathname.replace(/\\/$/, "") || "/";
3409
+ return PAGES.find((p) => p.ref === path)?.key ?? null;
3410
+ };
3411
+
3412
+ const runs = [];
3413
+ const unreadable = [];
3414
+ for (const dir of readdirSync(ROOT).filter((d) => d.startsWith("out-"))) {
3415
+ let report;
3416
+ try {
3417
+ report = JSON.parse(readFileSync(join(ROOT, dir, "report.json"), "utf8"));
3418
+ } catch {
3419
+ continue; // not a completed run dir
3420
+ }
3421
+ if (!report.meta?.ref || !Array.isArray(report.regions)) continue;
3422
+ // A report from another schema is still usable for a STALL count as long as
3423
+ // it carries the two fields this reads. Anything else is counted and named,
3424
+ // never silently dropped \u2014 an under-counted history reads as "clear".
3425
+ const usable = report.regions.every(
3426
+ (r) =>
3427
+ typeof r.mismatchFraction === "number" && typeof r.pass === "boolean",
3428
+ );
3429
+ if (!usable) {
3430
+ unreadable.push(\`\${dir} (schema \${report.meta.schemaVersion ?? 0})\`);
3431
+ continue;
3432
+ }
3433
+ runs.push({
3434
+ dir,
3435
+ at: report.meta.generatedAt,
3436
+ meta: report.meta,
3437
+ regions: report.regions,
3438
+ });
3439
+ }
3440
+
3441
+ // The failure mode of a stall detector is always "clear", which is exactly the
3442
+ // answer that stops nobody. A corpus this could not read is not a clean one.
3443
+ if (runs.length === 0) {
3444
+ console.error(
3445
+ \`strikes: no parseable gate run under matching/ \u2014 refusing to report "clear".\` +
3446
+ (unreadable.length
3447
+ ? \`\\n \${unreadable.length} report(s) unreadable at schema \${REPORT_SCHEMA}: \${unreadable.slice(0, 5).join(", ")}\`
3448
+ : ""),
3449
+ );
3450
+ process.exit(2);
3451
+ }
3452
+ if (unreadable.length) {
3453
+ console.error(
3454
+ \`strikes: ignored \${unreadable.length} unreadable report(s) \u2014 the history below is incomplete.\` +
3455
+ \`\\n \${unreadable.slice(0, 5).join(", ")}\`,
3456
+ );
3457
+ }
3458
+
3459
+ runs.sort((a, b) => a.at.localeCompare(b.at));
3460
+
3461
+ // The gate page KEY, recovered from the run dir the way next.mjs does it
3462
+ // (out-<TAG>-<page>, split on the first hyphen \u2014 the tag is hyphen-free by
3463
+ // gate.sh's own preflight). \`pageOf\` now returns that same key off the table,
3464
+ // so the two vocabularies agree and the round protocol in CLAUDE.md \u2014 "run
3465
+ // \`strikes.mjs <page>\` with the key you just passed to gate.sh", which until
3466
+ // 2026-08-13 silently matched nothing \u2014 works either way. This stays because
3467
+ // a dir key is not always a gate key: 69 of this corpus's 377 run dirs are
3468
+ // hand-named probes (out-band3, out-390masked), and FLOORS matches on it.
3469
+ const keyOf = (dir) => /^out-[^-]+-(.+)$/.exec(dir)?.[1] ?? null;
3470
+
3471
+ // key -> chronological list of {dir, at, mm, pass, masked}
3472
+ const history = new Map();
3473
+ const seenNames = new Set();
3474
+ for (const run of runs) {
3475
+ const page = pageOf(run.meta.ref) ?? keyOf(run.dir) ?? "unknown";
3476
+ const gateKey = keyOf(run.dir);
3477
+ seenNames.add(page);
3478
+ if (gateKey) seenNames.add(gateKey);
3479
+ if (only && page !== only && gateKey !== only) continue;
3480
+ for (const r of run.regions) {
3481
+ // A DECLARED FLOOR is flat by definition \u2014 reporting it as stalled is noise
3482
+ // that hides a real stall. It stays in the LEDGER; it does not belong here.
3483
+ if (FLOORS.some((fl) => fl.match(r, gateKey || page))) continue;
3484
+ const key = \`\${page}|\${r.viewport}|\${r.label}\`;
3485
+ if (!history.has(key)) history.set(key, []);
3486
+ history.get(key).push({
3487
+ dir: run.dir,
3488
+ at: run.at,
3489
+ mm: r.mismatchFraction,
3490
+ dh: r.heightDeltaFraction,
3491
+ pass: r.pass,
3492
+ // a run with masks/neutralised media is not comparable to a clean one
3493
+ dirty:
3494
+ (run.meta.mask?.length ?? 0) > 0 ||
3495
+ run.meta.neutralizeMedia ||
3496
+ run.meta.maskPhotos,
3497
+ });
3498
+ }
3499
+ }
3500
+
3501
+ // A name that matches no run must NOT report "clear". This check exists to stop
3502
+ // work on a stalled region, so failing open is the one thing it may never do \u2014
3503
+ // a typo'd or wrong-vocabulary page silently greened rule 3 for six of the nine
3504
+ // pages. Fail loud instead, and say what the vocabulary is.
3505
+ if (only && history.size === 0) {
3506
+ console.error(
3507
+ \`strikes: "\${only}" matches no gate run \u2014 refusing to report "clear".\\n\` +
3508
+ \` known pages: \${[...seenNames].sort().join(", ")}\`,
3509
+ );
3510
+ process.exit(2);
3511
+ }
3512
+
3513
+ const stuck = [];
3514
+ for (const [key, all] of history) {
3515
+ const h = all.filter((e) => !e.dirty); // compare like with like
3516
+ if (h.length === 0) continue;
3517
+ const last = h[h.length - 1];
3518
+ if (last.pass) continue;
3519
+
3520
+ let best = Infinity;
3521
+ let strikes = 0;
3522
+ let sinceIdx = 0;
3523
+ for (let i = 0; i < h.length; i++) {
3524
+ if (h[i].mm < best - IMPROVE_PP) {
3525
+ best = Math.min(best, h[i].mm);
3526
+ strikes = 0;
3527
+ sinceIdx = i;
3528
+ } else {
3529
+ best = Math.min(best, h[i].mm);
3530
+ strikes++;
3531
+ }
3532
+ }
3533
+ if (strikes >= MAX_STRIKES) stuck.push({ key, strikes, h, sinceIdx, last });
3534
+ }
3535
+
3536
+ // Triage order: the worst-matching region that has never moved is the one whose
3537
+ // model is most wrong. Sorting by strike count instead would put a 0.3% region
3538
+ // that only fails on height above a 76% region that fails on everything.
3539
+ stuck.sort((a, b) => b.last.mm - a.last.mm);
3540
+
3541
+ if (stuck.length === 0) {
3542
+ console.log(
3543
+ \`strikes: clear \u2014 no failing region has stalled for \${MAX_STRIKES}+ gate runs\` +
3544
+ (only ? \` on \${only}\` : "") +
3545
+ ".",
3546
+ );
3547
+ process.exit(0);
3548
+ }
3549
+
3550
+ const why = (e) => {
3551
+ const reasons = [];
3552
+ if (e.mm > THRESHOLD) reasons.push(\`pixels \${(e.mm * 100).toFixed(1)}%\`);
3553
+ if (Math.abs(e.dh ?? 0) > MAX_HEIGHT_DELTA)
3554
+ reasons.push(\`height \${((e.dh ?? 0) * 100).toFixed(1)}%\`);
3555
+ return reasons.join(" + ") || "marginal";
3556
+ };
3557
+
3558
+ console.log(
3559
+ \`STALLED \u2014 \${stuck.length} failing region(s) have not moved in \${MAX_STRIKES}+ gate runs.\\n\` +
3560
+ \`These need a new model or the operator, not another attempt. Worst first:\\n\`,
3561
+ );
3562
+ for (const { key, strikes, h, sinceIdx, last } of stuck.slice(0, 20)) {
3563
+ const [page, vw, label] = key.split("|");
3564
+ console.log(
3565
+ \`\${page} @\${vw} "\${label}" \u2014 \${why(last)}, flat across \${strikes} runs\`,
3566
+ );
3567
+ const window = h.slice(Math.max(0, sinceIdx));
3568
+ const shown = window.length > 4 ? [window[0], ...window.slice(-3)] : window;
3569
+ for (const [i, e] of shown.entries()) {
3570
+ const gap =
3571
+ window.length > 4 && i === 1
3572
+ ? \` ... \${window.length - 4} more ...\\n\`
3573
+ : "";
3574
+ console.log(
3575
+ gap +
3576
+ \` \${(e.mm * 100).toFixed(1).padStart(5)}% \${e.at.slice(0, 16).replace("T", " ")} \${e.dir}\`,
3577
+ );
3578
+ }
3579
+ console.log();
3580
+ }
3581
+ if (stuck.length > 20) console.log(\`... and \${stuck.length - 20} more.\\n\`);
3582
+ console.log(
3583
+ "Per the matching skill: present the attempts to the operator.\\n" +
3584
+ "Do NOT widen the threshold, add a mask, or reclassify them as a floor.",
3585
+ );
3586
+ process.exit(1);
3587
+ `;
3588
+ var BUILD_SPEC_MJS_PREV_0_95_1 = `// Assemble matching/SPEC.md from the per-page sections in spec-sections/.
3589
+ //
3590
+ // node matching/build-spec.mjs
3591
+ //
3592
+ // SPEC.md is the Phase 1 deliverable and the thing matching/gate.sh preflights
3593
+ // against, so it is GENERATED rather than hand-maintained: edit the section
3594
+ // file, rebuild. The page list comes from matching/harness.mjs (the same
3595
+ // reader gate.sh's \`--table\` loop uses) and the heading test from its
3596
+ // specHeadingRe, whose pattern is has_spec's grep verbatim \u2014 so a section can
3597
+ // no longer build fine here and then re-block its own page at the gate.
3598
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
3599
+ import { join } from "node:path";
3600
+ import { PAGES, specHeadingRe } from "./harness.mjs";
3601
+
3602
+ const DIR = new URL(".", import.meta.url).pathname;
3603
+ const SECTIONS = join(DIR, "spec-sections");
3604
+
3605
+ // Order = live's nav order, then the detail templates. Both fall out of the
3606
+ // table's own order within each group, so this is not a second list to keep.
3607
+ const KEYS = [
3608
+ ...PAGES.filter((p) => p.group === "nav"),
3609
+ ...PAGES.filter((p) => p.group !== "nav"),
3610
+ ].map((p) => p.spec ?? p.key);
3611
+
3612
+ const parts = [];
3613
+ const missing = [];
3614
+
3615
+ const chrome = join(SECTIONS, "_chrome.md");
3616
+ if (existsSync(chrome)) parts.push(readFileSync(chrome, "utf8").trim());
3617
+ else missing.push("_chrome");
3618
+
3619
+ for (const page of KEYS) {
3620
+ const file = join(SECTIONS, \`\${page}.md\`);
3621
+ if (!existsSync(file)) {
3622
+ missing.push(page);
3623
+ continue;
3624
+ }
3625
+ const body = readFileSync(file, "utf8").trim();
3626
+ // The preflight greps \`^##+ +<page>\`; a section that does not open with its
3627
+ // own key would build fine here and then refuse at the gate, which is the
3628
+ // most confusing possible failure. Catch it now.
3629
+ if (!specHeadingRe(page).test(body)) {
3630
+ console.error(
3631
+ \`WARNING: \${page}.md does not open with a '## \${page}' heading \u2014 gate.sh will still refuse this page.\`,
3632
+ );
3633
+ }
3634
+ parts.push(body);
3635
+ }
3636
+
3637
+ // The header is a spec-section like any other, so a site can say what a
3638
+ // citation looks like there and what its own ladders are. DEFAULT_HEADER is
3639
+ // what a site gets before it writes one: the two paragraphs that are true of
3640
+ // every project, and nothing that is not.
3641
+ const DEFAULT_HEADER = \`# Reference spec
3642
+
3643
+ GENERATED by \\\`node matching/build-spec.mjs\\\` from \\\`matching/spec-sections/\\\`.
3644
+ Edit the section files, not this one.
3645
+
3646
+ This is the Phase 1 deliverable of the \\\`matching-a-page\\\` skill and the
3647
+ precondition \\\`matching/gate.sh\\\` enforces: a page with no section here does not
3648
+ get a geometry round (see the matching rules in CLAUDE.md).
3649
+
3650
+ \`;
3651
+ const headerFile = join(SECTIONS, "_header.md");
3652
+ const header = existsSync(headerFile)
3653
+ ? readFileSync(headerFile, "utf8").trimEnd() + "\\n\\n"
3654
+ : DEFAULT_HEADER;
3655
+
3656
+ writeFileSync(join(DIR, "SPEC.md"), header + parts.join("\\n\\n---\\n\\n") + "\\n");
3657
+
3658
+ const built = KEYS.length - missing.filter((m) => m !== "_chrome").length;
3659
+ console.log(
3660
+ \`SPEC.md built \u2014 \${built}/\${KEYS.length} pages\${missing.length ? \`, MISSING: \${missing.join(", ")}\` : ""}\`,
3661
+ );
3662
+ process.exit(missing.length ? 1 : 0);
3663
+ `;
3664
+ var MATCH_ROUTE_SERVER_PREV_0_95_1 = `import { error } from "@sveltejs/kit";
3665
+ import { dev } from "$app/environment";
3666
+ import { documents } from "$lib/site-pages.js";
3667
+
3668
+ // Local matching surface: renders the EXACT assembly \`reddoor-maint
3669
+ // prismic-seed\` publishes, from the same module, so a fix made to pass a gate
3670
+ // is a fix to what ships. Not prerendered, SSR-on-demand, dev-only.
3671
+ export const prerender = false;
3672
+
3673
+ // The seed resolves images to asset ids; here they only need a URL. Dimensions
3674
+ // are nominal \u2014 slices size their own image boxes in CSS.
3675
+ const devImg = (u: string) => ({
3676
+ url: u,
3677
+ alt: null,
3678
+ copyright: null,
3679
+ dimensions: { width: 1600, height: 1067 },
3680
+ edit: { x: 0, y: 0, zoom: 1, background: "transparent" },
3681
+ id: u,
3682
+ });
3683
+
3684
+ export async function load({ params }) {
3685
+ // FIRST statement: everything below reads fixtures that must not be reachable
3686
+ // from a production build. The launch recipe asserts this route 404s on the
3687
+ // deployed URL, with /dev/a11y-fixtures as the 200 control.
3688
+ if (!dev) error(404, { message: "Not found" });
3689
+
3690
+ const docs = documents(devImg) as Array<{ uid: string; data: { slices?: unknown[] } }>;
3691
+ const doc = docs.find((d) => d.uid === params.uid);
3692
+ if (!doc)
3693
+ error(404, {
3694
+ message: \`no assembly for "\${params.uid}" (have: \${docs.map((d) => d.uid).join(", ") || "none"})\`,
3695
+ });
3696
+
3697
+ return { uid: params.uid, slices: doc.data.slices ?? [] };
3698
+ }
3699
+ `;
3700
+ var SITE_PAGES_TEST_PREV_0_95_1 = `// The page assemblies in src/lib/site-pages.js are the SINGLE source of truth
3701
+ // shared by two consumers: the local matching route (src/routes/dev/match/[uid])
3702
+ // and \`reddoor-maint prismic-seed\`, which publishes them to Prismic.
3703
+ //
3704
+ // Those two consumers do NOT validate the same way. The dev route hands the
3705
+ // object straight to the slice components, so any field a fixture sets is
3706
+ // simply there. The Migration API validates against the slice models registered
3707
+ // in Prismic and SILENTLY DROPS every field the model does not declare \u2014 no
3708
+ // error, no warning, a 200. A page can gate green locally and publish wrong.
3709
+ //
3710
+ // This test is the mechanical check. It fails the moment a fixture carries a
3711
+ // field its slice model does not declare \u2014 before the seed runs, not after the
3712
+ // content is published. Run it before every seed.
3713
+ import { describe, expect, it } from "vitest";
3714
+ import { readFileSync, readdirSync, existsSync } from "node:fs";
3715
+ import { fileURLToPath } from "node:url";
3716
+ import { dirname, join } from "node:path";
3717
+ import { documents } from "./site-pages.js";
3718
+
3719
+ const HERE = dirname(fileURLToPath(import.meta.url));
3720
+ const SLICES = join(HERE, "slices");
3721
+
3722
+ type Variation = { primary: string[]; items: string[] };
3723
+ type Slice = {
3724
+ slice_type: string;
3725
+ variation: string;
3726
+ primary?: Record<string, unknown>;
3727
+ items?: Array<Record<string, unknown>>;
3728
+ };
3729
+
3730
+ /** Every slice model in src/lib/slices, indexed by its Prismic slice id. */
3731
+ function loadModels(): Record<string, Record<string, Variation>> {
3732
+ const out: Record<string, Record<string, Variation>> = {};
3733
+ if (!existsSync(SLICES)) return out;
3734
+ for (const dir of readdirSync(SLICES)) {
3735
+ const file = join(SLICES, dir, "model.json");
3736
+ if (!existsSync(file)) continue;
3737
+ const model = JSON.parse(readFileSync(file, "utf8"));
3738
+ out[model.id] = Object.fromEntries(
3739
+ (model.variations ?? []).map((v: Record<string, unknown>) => [
3740
+ v.id,
3741
+ {
3742
+ primary: Object.keys((v.primary as object) ?? {}),
3743
+ items: Object.keys((v.items as object) ?? {}),
3744
+ },
3745
+ ]),
3746
+ );
3747
+ }
3748
+ return out;
3749
+ }
3750
+
3751
+ /** Image resolver stub \u2014 shape only; this test never reads image values. */
3752
+ const stubImg = () => ({ url: "https://example.test/x.jpg" });
3753
+
3754
+ describe("site-pages documents vs slice models", () => {
3755
+ const models = loadModels();
3756
+ const docs = documents(stubImg) as Array<{ uid: string; data: { slices?: Slice[] } }>;
3757
+ const pages: Array<[string, Slice[]]> = docs.map((d) => [d.uid, d.data.slices ?? []]);
3758
+
3759
+ it("declares every slice type the documents use", () => {
3760
+ const missing = new Set<string>();
3761
+ for (const [, slices] of pages)
3762
+ for (const s of slices) if (!models[s.slice_type]) missing.add(s.slice_type);
3763
+ expect([...missing]).toEqual([]);
3764
+ });
3765
+
3766
+ it("declares every variation the documents use", () => {
3767
+ const missing: string[] = [];
3768
+ for (const [uid, slices] of pages)
3769
+ for (const s of slices) {
3770
+ const model = models[s.slice_type];
3771
+ if (model && !model[s.variation]) missing.push(\`\${uid}: \${s.slice_type}/\${s.variation}\`);
3772
+ }
3773
+ expect(missing).toEqual([]);
3774
+ });
3775
+
3776
+ // The one that catches a silent Migration-API drop.
3777
+ it("declares every field the documents set, so Prismic strips nothing", () => {
3778
+ const stripped: string[] = [];
3779
+ for (const [uid, slices] of pages)
3780
+ for (const s of slices) {
3781
+ const variation = models[s.slice_type]?.[s.variation];
3782
+ if (!variation) continue;
3783
+ for (const key of Object.keys(s.primary ?? {}))
3784
+ if (!variation.primary.includes(key))
3785
+ stripped.push(\`\${uid} \${s.slice_type}/\${s.variation} primary.\${key}\`);
3786
+ const itemKeys = new Set((s.items ?? []).flatMap((i) => Object.keys(i)));
3787
+ for (const key of itemKeys)
3788
+ if (!variation.items.includes(key))
3789
+ stripped.push(\`\${uid} \${s.slice_type}/\${s.variation} items.\${key}\`);
3790
+ }
3791
+ expect(stripped).toEqual([]);
3792
+ });
3793
+ });
3794
+ `;
3795
+ var MATCH_HARNESS_PREVIOUS = {
3796
+ "matching/harness.mjs": [HARNESS_MJS_PREV_0_95_0, HARNESS_MJS_PREV_0_95_1],
3797
+ "matching/gate.sh": [GATE_SH_PREV_0_95_0],
3798
+ "matching/next.mjs": [NEXT_MJS_PREV_0_95_0, NEXT_MJS_PREV_0_95_1],
3799
+ "matching/census.sh": [CENSUS_SH_PREV_0_95_1],
3800
+ "matching/strikes.mjs": [STRIKES_MJS_PREV_0_95_1],
3801
+ "matching/build-spec.mjs": [BUILD_SPEC_MJS_PREV_0_95_1],
3802
+ "src/routes/dev/match/[uid]/+page.server.ts": [MATCH_ROUTE_SERVER_PREV_0_95_1],
3803
+ "src/lib/site-pages.test.ts": [SITE_PAGES_TEST_PREV_0_95_1]
3804
+ };
3805
+ var MATCH_HARNESS_COUPLED = [
3806
+ "matching/harness.mjs",
3807
+ "matching/gate.sh",
3808
+ "matching/census.sh",
3809
+ "matching/next.mjs",
3810
+ "matching/strikes.mjs",
3811
+ "matching/build-spec.mjs"
3812
+ ];
3813
+ var UNGUARDED_TWIN_TELL = "reddoor-match-twin:no-assembly";
3814
+ var GITIGNORE_MARKER = "# reddoor-maint match-harness: scripts + records tracked, workspace ignored";
3815
+ var GITIGNORE_BLOCK = `scratch-diff*/
3816
+ matching/*
3817
+ !matching/*.sh
3818
+ !matching/*.mjs
3819
+ !matching/*.md
3820
+ # The pause switch. Extensionless on purpose (it is a sentinel, not a doc), so
3821
+ # the whitelist above misses it \u2014 and an ignored switch is not a switch: it
3822
+ # would work on one machine and be absent from every fresh clone, CI run and
3823
+ # agent that most needs to see it.
3824
+ !matching/PAUSED
3825
+ !matching/spec-sections/
3826
+ !matching/states/
3827
+ matching/states/*
3828
+ !matching/states/*.mjs
3829
+ # derived artefacts, even where the extensions above would catch them
3830
+ matching/*.log
3831
+ matching/*.json
3832
+ # ...except the page table itself, which is the harness's configuration and the
3833
+ # one thing a fresh clone cannot reconstruct.
3834
+ !matching/harness.json
3835
+ matching/*.png
3836
+ `;
3837
+ var PRETTIERIGNORE_MARKER = "# reddoor-maint match-harness";
3838
+ var PRETTIERIGNORE_BLOCK = `# Matching harness \u2014 every file the \`match-harness\` recipe OWNS. It installs
3839
+ # them byte-for-byte and byte-compares them on the next install, so a site whose
3840
+ # prettier config differs must not reformat them: a reformatted file is
3841
+ # indistinguishable from a hand edit, and the recipe refuses to overwrite a hand
3842
+ # edit. Measured on a \`useTabs\`/\`singleQuote\` site, all three src/ entries
3843
+ # below are rewritten by \`prettier --write .\`, and the next upgrade is refused
3844
+ # for all three.
3845
+ #
3846
+ # The bracketed route segment MUST stay backslash-escaped. This file is
3847
+ # gitignore glob syntax, in which \`[uid]\` is a CHARACTER CLASS matching one of
3848
+ # \`u\`, \`i\`, \`d\` \u2014 an unescaped entry silently ignores nothing at all.
3849
+ #
3850
+ # \`matching/*.mjs\` also catches two SITE-owned records (floors.mjs,
3851
+ # census-deviations.mjs). Accepted, not overlooked: eslint lints .mjs, so those
3852
+ # two keep a style check either way, and naming seven scripts instead of one
3853
+ # glob goes stale on the next script the harness gains.
3854
+ #
3855
+ # harness.json is here because it is a TABLE, not prose: it carries a
3856
+ # hand-chosen row layout that prettier reflows.
3857
+ #
3858
+ # Everything else under matching/ (SPEC.md, LEDGER.md, spec-sections/,
3859
+ # states/*.mjs) stays inside \`prettier --check .\`. That is deliberately narrow:
3860
+ # eslint does not lint Markdown, so for the records prettier is the ONLY style
3861
+ # check there is, and a blanket \`matching/\` entry would leave them with none.
3862
+ matching/*.mjs
3863
+ matching/*.sh
3864
+ matching/harness.json
3865
+ src/routes/dev/match/\\[uid\\]/+page.server.ts
3866
+ src/routes/dev/match/\\[uid\\]/+page.svelte
3867
+ src/lib/site-pages.test.ts
3868
+ `;
3869
+ var CLAUDE_MD_MARKER = "## Matching rules (installed by reddoor-maint match-harness)";
3870
+ var CLAUDE_MD_BLOCK = `
3871
+ The \`matching-a-page\` skill governs a live-reference rebuild. These five rules
3872
+ exist because the skill alone did not hold on the project this harness came
3873
+ from \u2014 each one is a drift that actually happened, with the mechanical check
3874
+ that now catches it. \`matching/harness.json\` is this site's configuration;
3875
+ \`matching/LEDGER.md\` is the dated record of every deviation, floor and mask.
3876
+
3877
+ ### 1. Source prescribes, rects only verify
3878
+
3879
+ Every geometry fix must cite the rule it came from: a line in the captured
3880
+ reference stylesheet under \`matching/spec/\`, or the reference's HTML. "The probe
3881
+ says the gap is 40px" is not a source. If you cannot name the line you are
3882
+ guessing \u2014 go read the stylesheet first.
3883
+
3884
+ **Check:** the commit body must name the file:line for each fix.
3885
+ **Operator's challenge:** _"which line of the reference stylesheet says that?"_
3886
+
3887
+ ### 2. Phase 1 before Phase 4
3888
+
3889
+ A page gets its section census and per-section spec in \`matching/SPEC.md\` BEFORE
3890
+ its geometry is touched. No SPEC section, no geometry round. The census is the
3891
+ coverage denominator; skipping it is how a reference's root-font ladder and its
3892
+ per-component height ladders get discovered reactively, after the region has
3893
+ already failed several rounds.
3894
+
3895
+ **Check:** \`matching/gate.sh\` refuses to run a page with no \`SPEC.md\` section.
3896
+ **Operator's challenge:** _"show me the SPEC section for that region."_
3897
+
3898
+ ### 3. Three strikes, then stop
3899
+
3900
+ A failing region that has not improved across 3+ gate runs does not get a fourth
3901
+ attempt. Present the attempts. Never widen the threshold, add a mask, or
3902
+ reclassify it as a floor to make it go away.
3903
+
3904
+ **Check:** \`node matching/strikes.mjs <page>\` \u2014 exits 1 while any region is
3905
+ stalled, and is the first thing a geometry round runs.
3906
+ **Operator's challenge:** _"how many runs has that region been flat?"_
3907
+
3908
+ ### 4. A gate closes an item, nothing else
3909
+
3910
+ No fix is "done" because the code changed. Paste the gate header \u2014 it is
3911
+ self-describing, so a nonstandard threshold or an undisclosed mask is visible.
3912
+ The threshold and the matrix are whatever \`matching/harness.json\` says, on every
3913
+ page, never a subset.
3914
+
3915
+ **Check:** \`bash matching/census.sh <page>\` exits 0 \u2014 the Phase 3 style gate,
3916
+ and the only one that sees an 11px footer line or a cyan-vs-teal link the pixel
3917
+ diff is structurally blind to. A remaining row is fixed at its source or
3918
+ declared in \`matching/census-deviations.mjs\` with a LEDGER line, never ignored.
3919
+ **Operator's challenge:** _"paste the gate header."_
3920
+
3921
+ ### 5. A commit is a checkpoint, not a stopping point
3922
+
3923
+ Do not hand control back between rounds. After committing, run
3924
+ \`node matching/next.mjs\`: while it exits 1 there is a named next action, and the
3925
+ round continues. Report when the backlog is empty, when a decision is genuinely
3926
+ the operator's (three strikes, a novel floor, a threshold change), or when
3927
+ asked \u2014 not because a commit felt like a natural place to summarise.
3928
+
3929
+ This exists because the failure was habitual, not deliberate: a turn ends when
3930
+ user-facing prose gets written, and "I just committed something good" is the
3931
+ moment that invites writing it. The gate stops incorrect work; this stops
3932
+ premature stopping.
3933
+
3934
+ **Pausing:** create \`matching/PAUSED\` holding a note that says what the pause
3935
+ means; \`next.mjs\` and \`strikes.mjs\` then exit 0 while it exists. This rule says
3936
+ "do not stop while work is named"; it never said "find work to name". An empty
3937
+ agenda and a paused one end the round the same way. Deleting the switch is the
3938
+ operator's call.
3939
+
3940
+ **Check:** \`node matching/next.mjs\` \u2014 exits 1 while any non-floor region fails.
3941
+ **Operator's challenge:** _"what does next.mjs say?"_
3942
+
3943
+ ### Round protocol
3944
+
3945
+ 1. \`node matching/strikes.mjs <page>\` \u2014 if it exits 1, the stalled regions are
3946
+ the agenda, and stalled ones get escalated rather than re-attempted.
3947
+ 2. Confirm the page has a \`matching/SPEC.md\` section; write
3948
+ \`matching/spec-sections/<page>.md\` and run \`node matching/build-spec.mjs\` if
3949
+ not.
3950
+ 3. Fix, each change citing its source line.
3951
+ 4. \`bash matching/gate.sh <tag> <page>\` \u2014 paste the header.
3952
+ 5. \`bash matching/census.sh <page>\` \u2014 exits 0 or the round is not closed; a
3953
+ remaining row is fixed at its source or declared with a LEDGER line.
3954
+ 6. Append to \`matching/LEDGER.md\` at the moment a deviation, floor or mask is
3955
+ decided, not reconstructed at the end.
3956
+ 7. \`pnpm verify\`, then commit and push.
3957
+ `;
3958
+
3959
+ export {
3960
+ HARNESS_JSON_RELATIVE,
3961
+ MATCH_HARNESS_FILES,
3962
+ MATCH_HARNESS_PREVIOUS,
3963
+ MATCH_HARNESS_COUPLED,
3964
+ UNGUARDED_TWIN_TELL,
3965
+ GITIGNORE_MARKER,
3966
+ GITIGNORE_BLOCK,
3967
+ PRETTIERIGNORE_MARKER,
3968
+ PRETTIERIGNORE_BLOCK,
3969
+ CLAUDE_MD_MARKER,
3970
+ CLAUDE_MD_BLOCK
3971
+ };
3972
+ //# sourceMappingURL=chunk-Y6FCXRIC.js.map