mulmoterminal 6.7.0 → 7.0.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 (251) hide show
  1. package/README.md +38 -2
  2. package/bin/check-versions.d.ts +7 -0
  3. package/bin/check-versions.js +80 -0
  4. package/bin/has-command.d.ts +4 -0
  5. package/bin/has-command.js +4 -4
  6. package/bin/mulmoterminal.js +7 -1
  7. package/bin/version-drift.d.ts +11 -0
  8. package/bin/version-drift.js +88 -0
  9. package/blueprints/ask/checks/replies.mjs +15 -2
  10. package/blueprints/ask/skills/answer/SKILL.md +7 -3
  11. package/blueprints/ask/skills/keep/SKILL.md +2 -1
  12. package/blueprints/ask/steps.json +2 -1
  13. package/blueprints/docs/checks/chaff.sh +1 -1
  14. package/blueprints/docs/checks/places.d.mts +7 -0
  15. package/blueprints/docs/checks/places.mjs +102 -0
  16. package/blueprints/docs/skills/workspace/SKILL.md +1 -1
  17. package/blueprints/firebase/checks/security-report.sh +11 -0
  18. package/blueprints/firebase/checks/security.sh +41 -0
  19. package/blueprints/firebase/checks/spec.sh +4 -0
  20. package/blueprints/firebase/checks/start-here.sh +8 -0
  21. package/blueprints/firebase/manifest.json +2 -1
  22. package/blueprints/firebase/plan.json +8 -1
  23. package/blueprints/firebase/skills/deploy-production/SKILL.md +14 -1
  24. package/blueprints/firebase/skills/security/SKILL.md +80 -0
  25. package/blueprints/from-collection/checks/actions.sh +48 -0
  26. package/blueprints/from-collection/checks/compare.mjs +42 -0
  27. package/blueprints/from-collection/checks/decisions.mjs +69 -0
  28. package/blueprints/from-collection/checks/firestore-verify.mjs +115 -0
  29. package/blueprints/from-collection/checks/import-firebase.sh +36 -0
  30. package/blueprints/from-collection/checks/import-verify.mjs +45 -0
  31. package/blueprints/from-collection/checks/import.sh +20 -0
  32. package/blueprints/from-collection/checks/spec.sh +43 -0
  33. package/blueprints/from-collection/checks/test-titles.mjs +101 -0
  34. package/blueprints/from-collection/hearing.json +83 -0
  35. package/blueprints/from-collection/manifest.json +8 -0
  36. package/blueprints/from-collection/skills/acceptance/SKILL.md +26 -0
  37. package/blueprints/from-collection/skills/acceptance-firebase/SKILL.md +27 -0
  38. package/blueprints/from-collection/skills/actions/SKILL.md +47 -0
  39. package/blueprints/from-collection/skills/actions-firebase/SKILL.md +46 -0
  40. package/blueprints/from-collection/skills/features-firebase/SKILL.md +28 -0
  41. package/blueprints/from-collection/skills/import/SKILL.md +39 -0
  42. package/blueprints/from-collection/skills/import-firebase/SKILL.md +38 -0
  43. package/blueprints/from-collection/skills/import-production/SKILL.md +29 -0
  44. package/blueprints/from-collection/spec/conversion.md +85 -0
  45. package/blueprints/from-collection/spec/requirements.md +74 -0
  46. package/blueprints/from-collection/steps.json +77 -0
  47. package/blueprints/local/checks/handover.sh +7 -1
  48. package/blueprints/local/checks/security-report.sh +11 -0
  49. package/blueprints/local/checks/security.sh +53 -0
  50. package/blueprints/local/checks/serve.sh +24 -0
  51. package/blueprints/local/checks/smoke.sh +1 -22
  52. package/blueprints/local/checks/spec.sh +4 -0
  53. package/blueprints/local/manifest.json +2 -1
  54. package/blueprints/local/plan.json +7 -0
  55. package/blueprints/local/security/checklist.md +2 -0
  56. package/blueprints/local/skills/handover/SKILL.md +17 -1
  57. package/blueprints/local/skills/security/SKILL.md +90 -0
  58. package/blueprints/polish/checks/targets.mjs +4 -0
  59. package/blueprints/polish/checks/targetsView.d.mts +2 -0
  60. package/blueprints/polish/checks/targetsView.mjs +7 -0
  61. package/blueprints/polish/hearing.json +4 -2
  62. package/blueprints/polish/presets.json +0 -1
  63. package/blueprints/polish/skills/polish/SKILL.md +1 -1
  64. package/blueprints/polish/skills/survey/SKILL.md +2 -1
  65. package/blueprints/polish/steps.json +2 -1
  66. package/blueprints/product/steps.json +1 -0
  67. package/blueprints/refactor/hearing.json +2 -1
  68. package/blueprints/review/checks/findings.mjs +5 -0
  69. package/blueprints/review/checks/findingsView.d.mts +15 -0
  70. package/blueprints/review/checks/findingsView.mjs +33 -0
  71. package/blueprints/review/skills/report/SKILL.md +2 -1
  72. package/blueprints/review/steps.json +2 -1
  73. package/blueprints/style/checks/sources.mjs +4 -1
  74. package/blueprints/style/checks/sourcesView.d.mts +2 -0
  75. package/blueprints/style/checks/sourcesView.mjs +15 -0
  76. package/blueprints/style/skills/report/SKILL.md +1 -1
  77. package/blueprints/style/steps.json +4 -2
  78. package/blueprints/verify/checks/extract.mjs +4 -0
  79. package/blueprints/verify/checks/factsView.d.mts +4 -0
  80. package/blueprints/verify/checks/factsView.mjs +39 -0
  81. package/blueprints/verify/skills/report/SKILL.md +2 -1
  82. package/blueprints/verify/steps.json +2 -1
  83. package/blueprints/write/checks/outlineView.d.mts +4 -0
  84. package/blueprints/write/checks/outlineView.mjs +14 -0
  85. package/blueprints/write/checks/parts.mjs +5 -0
  86. package/blueprints/write/manifest.json +8 -1
  87. package/blueprints/write/steps.json +4 -2
  88. package/common/blueprint/collectionSource.ts +114 -0
  89. package/common/blueprint/hearing.ts +62 -5
  90. package/common/blueprint/manifest.ts +22 -5
  91. package/common/blueprint/plan.ts +5 -0
  92. package/common/blueprint/refusal.ts +7 -2
  93. package/common/blueprint/run.ts +4 -1
  94. package/common/blueprint/sharedAppSource.ts +30 -0
  95. package/common/blueprint/state.ts +4 -0
  96. package/common/blueprint/stepPrompt.ts +69 -1
  97. package/common/collectionActions.ts +31 -0
  98. package/common/dirConfigSource.ts +3 -0
  99. package/common/fileGitStatus.ts +21 -0
  100. package/common/filesPage.ts +39 -0
  101. package/common/keymap.ts +12 -1
  102. package/common/mdPreviewMessage.ts +16 -2
  103. package/common/paletteConfig.ts +31 -0
  104. package/common/promptHistory.ts +9 -0
  105. package/dist/assets/{abnfDiagram-O67JEVCF-COztuCu2.js → abnfDiagram-O67JEVCF-D8GL0pEj.js} +1 -1
  106. package/dist/assets/architecture-7GRP2DOG-TdhGGkKM.js +1 -0
  107. package/dist/assets/{architectureDiagram-NJMV4G6O-CocxKb8D.js → architectureDiagram-NJMV4G6O-kyzehsRR.js} +1 -1
  108. package/dist/assets/{blockDiagram-BEXU5L5S-CoIOnOr8.js → blockDiagram-BEXU5L5S-9hqLban3.js} +1 -1
  109. package/dist/assets/{c4Diagram-YGBWAQC7-DCkPDN1m.js → c4Diagram-YGBWAQC7-CUJxINHi.js} +1 -1
  110. package/dist/assets/channel-C2Xy1RUG.js +1 -0
  111. package/dist/assets/{chunk-3FUC2YCW-BpkN5ntL.js → chunk-3FUC2YCW-Dx91F6cc.js} +1 -1
  112. package/dist/assets/{chunk-5DYCD2WN-CkSUlK9I.js → chunk-5DYCD2WN-e-5CFr2i.js} +1 -1
  113. package/dist/assets/{chunk-742MDFTN-IT8yQzYr.js → chunk-742MDFTN-DLMZmirF.js} +1 -1
  114. package/dist/assets/{chunk-7INBJB4K-JGgoF_Kc.js → chunk-7INBJB4K-CwRDZro8.js} +1 -1
  115. package/dist/assets/{chunk-7M6MHVWA-DeMh7ryC.js → chunk-7M6MHVWA-Gm5DDkAY.js} +1 -1
  116. package/dist/assets/{chunk-7PRAP22T-Mu7uMyeb.js → chunk-7PRAP22T-CoSNjn-E.js} +1 -1
  117. package/dist/assets/{chunk-GTNCS2PH-CZnXYcEC.js → chunk-GTNCS2PH-CLXhqupL.js} +1 -1
  118. package/dist/assets/{chunk-GWA4HPMP-hNVTjL0n.js → chunk-GWA4HPMP-DX5sIYil.js} +1 -1
  119. package/dist/assets/{chunk-MBY4JIJT-BNBCCYaO.js → chunk-MBY4JIJT-Dzr4kplc.js} +1 -1
  120. package/dist/assets/{chunk-NETBCI7D-BFEY_LEK.js → chunk-NETBCI7D-DWhxcXYA.js} +1 -1
  121. package/dist/assets/{chunk-O7XYJQB3-igihGd57.js → chunk-O7XYJQB3-DaFEDFVm.js} +1 -1
  122. package/dist/assets/{chunk-UA2S7LBM-AvoPHYX2.js → chunk-UA2S7LBM-bYQCj6FB.js} +1 -1
  123. package/dist/assets/{chunk-WEXAMYUT-CnzHksV_.js → chunk-WEXAMYUT-DlYEsAVg.js} +1 -1
  124. package/dist/assets/{chunk-Z7XXMR3K-BZUbrMkZ.js → chunk-Z7XXMR3K-Bn6kE_dK.js} +1 -1
  125. package/dist/assets/{chunk-ZIGJFQKS-DFj3hrIY.js → chunk-ZIGJFQKS-CIZD8LBc.js} +1 -1
  126. package/dist/assets/{classDiagram-v2-NBCMYWYE-C2GePjwg.js → classDiagram-v2-NBCMYWYE-CHcNkGSD.js} +1 -1
  127. package/dist/assets/{cynefin-OW5HDTMX-E4NRCqvt.js → cynefin-OW5HDTMX-BgabrmIk.js} +1 -1
  128. package/dist/assets/{cynefinDiagram-VND7K2PF-BnHou4hN.js → cynefinDiagram-VND7K2PF-DwdVdS9K.js} +1 -1
  129. package/dist/assets/{dagre-6A5THRUB-kSCwZxth.js → dagre-6A5THRUB-DaqNJgIs.js} +1 -1
  130. package/dist/assets/{diagram-22UHCM2B-DT04U1z3.js → diagram-22UHCM2B-BRDs0wCw.js} +1 -1
  131. package/dist/assets/{diagram-3UASUU5V-QaR0jD4H.js → diagram-3UASUU5V-BeXv9OSL.js} +1 -1
  132. package/dist/assets/{diagram-ATOU4E4O-lmm7fQMw.js → diagram-ATOU4E4O-D2JkNL-b.js} +1 -1
  133. package/dist/assets/{diagram-CDSNMT55-CdsN9JC6.js → diagram-CDSNMT55-DeYKsB8E.js} +1 -1
  134. package/dist/assets/{diagram-MLGK6HIB-CaVs00rU.js → diagram-MLGK6HIB-BHFLtv-n.js} +1 -1
  135. package/dist/assets/{diagram-MPIPVDR6-BudW9uT0.js → diagram-MPIPVDR6-Brtp-_IQ.js} +1 -1
  136. package/dist/assets/{dist-6UpZENeR.js → dist-B0sdAm9J.js} +1 -1
  137. package/dist/assets/dist-B2YXJLwR.js +11 -0
  138. package/dist/assets/{dist-unjTvGLV.js → dist-BSQTHmad.js} +1 -1
  139. package/dist/assets/dist-BTX-l2yD.js +1 -0
  140. package/dist/assets/dist-ByH4MDuI.js +9 -0
  141. package/dist/assets/dist-C1cqjsKD.js +1 -0
  142. package/dist/assets/{dist-BMBUd4uQ.js → dist-C1s7S4KF.js} +1 -1
  143. package/dist/assets/dist-CWSTMA5n.js +1 -0
  144. package/dist/assets/dist-CeWYOYWG.js +1 -0
  145. package/dist/assets/{dist-DJ_QvH_t.js → dist-SAdtDOGy.js} +2 -2
  146. package/dist/assets/{ebnfDiagram-ZINNZB2B-DT9rFfx-.js → ebnfDiagram-ZINNZB2B-CE9roUQp.js} +1 -1
  147. package/dist/assets/{elk-276RUBZZ-DDeDnR5i.js → elk-276RUBZZ-DQHLfTBN.js} +1 -1
  148. package/dist/assets/{erDiagram-OPXOYQCR-B9Fv1DKw.js → erDiagram-OPXOYQCR-Be7__jhY.js} +1 -1
  149. package/dist/assets/eventmodeling-NTZA5JFV-BXnmc8kB.js +1 -0
  150. package/dist/assets/flowDiagram-KWPJA3E3-C2T1HY21.js +1 -0
  151. package/dist/assets/{ganttDiagram-FUAMR5RP-CzlCs_Rv.js → ganttDiagram-FUAMR5RP-IKV0i-z0.js} +1 -1
  152. package/dist/assets/{gitGraph-4MIJSDKK-DMlpm2X5.js → gitGraph-4MIJSDKK-EofSfZtF.js} +1 -1
  153. package/dist/assets/{gitGraphDiagram-X574FWY7-BodBMwuj.js → gitGraphDiagram-X574FWY7-DaljLxKK.js} +1 -1
  154. package/dist/assets/{index-DtoMXmGR.js → index-0Wsb2_IF.js} +359 -349
  155. package/dist/assets/index-BZZNuDnO.css +1 -0
  156. package/dist/assets/{info-A6RAGUB7-DcD8IyFk.js → info-A6RAGUB7-B6ck_7jX.js} +1 -1
  157. package/dist/assets/{infoDiagram-VRGFBTTK-BaZCI3bY.js → infoDiagram-VRGFBTTK-C8jMc5nJ.js} +1 -1
  158. package/dist/assets/{ishikawaDiagram-OU5B5YK6-C2YA2vvC.js → ishikawaDiagram-OU5B5YK6-BOM9dJ1W.js} +1 -1
  159. package/dist/assets/{journeyDiagram-ZHPQQLJL-CqjWX8sS.js → journeyDiagram-ZHPQQLJL-DIYby1k-.js} +1 -1
  160. package/dist/assets/{kanban-definition-PNTS6WVX-CoM8jlm5.js → kanban-definition-PNTS6WVX-CAmlLeaS.js} +1 -1
  161. package/dist/assets/{line-Bt2FCpGX.js → line-DLTV2ptB.js} +1 -1
  162. package/dist/assets/{mermaid-parser.core-D9qR3oRl.js → mermaid-parser.core-CW3k1W-u.js} +3 -3
  163. package/dist/assets/{mermaid.core-BIXntNJ_.js → mermaid.core-B_zPtmsr.js} +4 -4
  164. package/dist/assets/{mindmap-definition-NLK3R4M7-4FYsrukx.js → mindmap-definition-NLK3R4M7-Ji-mQMX0.js} +1 -1
  165. package/dist/assets/{packet-AYTQ26CC-eQcU7oGW.js → packet-AYTQ26CC-MR7J7hYw.js} +1 -1
  166. package/dist/assets/{pegDiagram-GJSIUBJH-AwYWp-ta.js → pegDiagram-GJSIUBJH-D_wwEoYb.js} +1 -1
  167. package/dist/assets/{pie-WAS4IAKB-CJF4n-uR.js → pie-WAS4IAKB-DNuGDXby.js} +1 -1
  168. package/dist/assets/{pieDiagram-5QR66LMP-CKnVXfdJ.js → pieDiagram-5QR66LMP-C1YCt7xK.js} +1 -1
  169. package/dist/assets/{quadrantDiagram-O4NWA36T-BefBP8Qf.js → quadrantDiagram-O4NWA36T-DN-d9LoE.js} +1 -1
  170. package/dist/assets/{radar-RG4KPBEZ-BjQl0TqH.js → radar-RG4KPBEZ-dW6gQx6l.js} +1 -1
  171. package/dist/assets/{railroad-74A4TZTK-Z4kUresc.js → railroad-74A4TZTK-DnjwnHPx.js} +1 -1
  172. package/dist/assets/railroad-abnf-HS5TGJTU-COO_8DKu.js +1 -0
  173. package/dist/assets/railroad-ebnf-LZEXJU2U-H6w--gix.js +1 -0
  174. package/dist/assets/railroad-peg-WCYAUIDC-91dBFIdz.js +1 -0
  175. package/dist/assets/{railroadDiagram-XR7U4H2S-BUskllkB.js → railroadDiagram-XR7U4H2S-DdfYiy6T.js} +1 -1
  176. package/dist/assets/{requirementDiagram-PLB6GJNP-BCbsSEYH.js → requirementDiagram-PLB6GJNP-BzIEk2VN.js} +1 -1
  177. package/dist/assets/{sankeyDiagram-IPEJSGJF-DuFOhh5F.js → sankeyDiagram-IPEJSGJF-D_zpgGm7.js} +1 -1
  178. package/dist/assets/{sequenceDiagram-PO4LG4MO-CtacIk91.js → sequenceDiagram-PO4LG4MO-WLxiEHkc.js} +1 -1
  179. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bh0PfQR6.js → stateDiagram-v2-GCMORJYK-DYPJzs6R.js} +1 -1
  180. package/dist/assets/{swimlanes-2SLR337P-3VWib0yw.js → swimlanes-2SLR337P-C6VX1K4R.js} +1 -1
  181. package/dist/assets/swimlanesDiagram-TC7HE7FX-CGKdav0R.js +8 -0
  182. package/dist/assets/{timeline-definition-EJHVYXUP-DLUjmMJp.js → timeline-definition-EJHVYXUP-DqVxaMbg.js} +1 -1
  183. package/dist/assets/{treeView-Q6P3EWNA-urctZJZ8.js → treeView-Q6P3EWNA-CswdkPra.js} +1 -1
  184. package/dist/assets/{treemap-WGGIJYW6-7VaIVFkb.js → treemap-WGGIJYW6-DlB2H8xd.js} +1 -1
  185. package/dist/assets/{usecaseDiagram-POWQR4AR-OlI3xHtd.js → usecaseDiagram-POWQR4AR-BkdJels6.js} +1 -1
  186. package/dist/assets/{vennDiagram-UO4OBE2U-ABZr8NaN.js → vennDiagram-UO4OBE2U-Cj8A--rK.js} +1 -1
  187. package/dist/assets/{wardley-WFR3VGLG-CX3iRocH.js → wardley-WFR3VGLG-B0WzVCNm.js} +1 -1
  188. package/dist/assets/{wardleyDiagram-VNRHLVJA-CmHM0PTe.js → wardleyDiagram-VNRHLVJA-BtKepEjT.js} +1 -1
  189. package/dist/assets/{xychartDiagram-PMCCYNJV-CYUP0J_R.js → xychartDiagram-PMCCYNJV-BIVIXtl9.js} +1 -1
  190. package/dist/index.html +3 -3
  191. package/package.json +5 -4
  192. package/server/backends/collectionActionIndex.spec.ts +40 -0
  193. package/server/backends/collectionActionIndex.ts +18 -0
  194. package/server/backends/collectionActionIndexRoute.ts +17 -0
  195. package/server/backends/collections.ts +1 -1
  196. package/server/backends/filesPage.ts +46 -0
  197. package/server/backends/html.ts +1 -1
  198. package/server/backends/remoteHost/handlers/listSkills.ts +55 -6
  199. package/server/backends/remoteHost/listSkillsSession.spec.ts +84 -0
  200. package/server/backends/remoteHost/pluginSkills.spec.ts +110 -0
  201. package/server/backends/remoteHost/pluginSkills.ts +90 -0
  202. package/server/backends/remoteHost/skillOverrides.ts +7 -2
  203. package/server/backends/sharedApp/context.ts +3 -0
  204. package/server/backends/sharedApp/declare.ts +18 -6
  205. package/server/backends/sharedApp/publish.ts +11 -3
  206. package/server/backends/sharedApp/rosterRemovals.ts +59 -0
  207. package/server/blueprint/answersFile.ts +12 -0
  208. package/server/blueprint/collectionSnapshot.ts +207 -0
  209. package/server/blueprint/executor.ts +22 -6
  210. package/server/blueprint/newFolder.ts +13 -0
  211. package/server/blueprint/routes.ts +101 -9
  212. package/server/blueprint/samples.ts +4 -10
  213. package/server/blueprint/sharedAppsProvider.ts +53 -0
  214. package/server/blueprint/wiring.ts +14 -0
  215. package/server/blueprint/writeAllOrNone.ts +11 -0
  216. package/server/config/app-config.ts +18 -0
  217. package/server/config/config-body.ts +3 -1
  218. package/server/config/config-routes.ts +76 -33
  219. package/server/config/config-schema.ts +2 -0
  220. package/server/config/dir-config.ts +6 -1
  221. package/server/config/header-config.ts +31 -7
  222. package/server/config/header-context.ts +1 -1
  223. package/server/config/header-resolve.ts +7 -5
  224. package/server/config/worktree-dir-config.ts +1 -1
  225. package/server/files/files-browse.ts +7 -0
  226. package/server/files/files-git-status.ts +65 -0
  227. package/server/files/mdPreviewEmbed.ts +10 -7
  228. package/server/git/statusEntries.ts +40 -0
  229. package/server/git/worktrees.ts +19 -3
  230. package/server/infra/shared-app-tool.ts +24 -5
  231. package/server/routes/app-routes.ts +4 -0
  232. package/server/session/activity-transition.ts +12 -3
  233. package/server/session/lifecycle.ts +3 -2
  234. package/server/skills/mulmoterminal-header/SKILL.md +25 -1
  235. package/server/skills/mulmoterminal-keys/SKILL.md +48 -1
  236. package/server/skills/mulmoterminal-shared-app/SKILL.md +10 -2
  237. package/dist/assets/architecture-7GRP2DOG-DSPxsQOu.js +0 -1
  238. package/dist/assets/channel-j_IknEot.js +0 -1
  239. package/dist/assets/dist-4u5CgGvM.js +0 -9
  240. package/dist/assets/dist-B2UfqsPh.js +0 -1
  241. package/dist/assets/dist-BkNFiPKG.js +0 -11
  242. package/dist/assets/dist-CBfB3Kbo.js +0 -1
  243. package/dist/assets/dist-CHPrzLIK.js +0 -1
  244. package/dist/assets/dist-D8e4q8MY.js +0 -1
  245. package/dist/assets/eventmodeling-NTZA5JFV-BnIWMpmq.js +0 -1
  246. package/dist/assets/flowDiagram-KWPJA3E3-CgKOYFOK.js +0 -1
  247. package/dist/assets/index-XRjuwB8c.css +0 -1
  248. package/dist/assets/railroad-abnf-HS5TGJTU-DUcNrXOB.js +0 -1
  249. package/dist/assets/railroad-ebnf-LZEXJU2U-CiLdyg9w.js +0 -1
  250. package/dist/assets/railroad-peg-WCYAUIDC-k26xztEI.js +0 -1
  251. package/dist/assets/swimlanesDiagram-TC7HE7FX-DZwaV5aw.js +0 -8
package/README.md CHANGED
@@ -303,7 +303,8 @@ with `tmux` installed the agent sessions survive and come back under **Settings
303
303
  survived a restart**; without it they end with the server.
304
304
 
305
305
  **First-run setup (optional).** `npx mulmoterminal@latest init` checks your environment (Node ≥ 22.12
306
- and every CLI in the table above), seeds the launcher's **directory
306
+ and every CLI in the table above), says how to update when Node is behind the latest LTS of its line
307
+ or Claude Code is behind its `stable` release, seeds the launcher's **directory
307
308
  presets** from the projects in your Claude Code history, and writes `~/.mulmoterminal/config.json`.
308
309
  It's **idempotent** — re-run it any time to refresh the presets; it overwrites the managed parts
309
310
  and keeps your other settings. When `claude` is installed it can hand off to the
@@ -766,7 +767,7 @@ The Settings modal (the gear button) persists per-user UI choices to `~/.mulmote
766
767
 
767
768
  ![The Settings modal — a sidebar of grouped sections beside the one being edited](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/settings-tabs.png)
768
769
 
769
- *Open it from the gear button in the toolbar. The **sidebar** groups the sections — Appearance, Projects, Header & launch, Input, Models & servers, Notifications, Integrations, Sessions, Help — and one is on screen at a time; on a phone the sidebar becomes a picker above the section. Settings is available in **English and Japanese**: it follows your browser's language unless you pick one under **Language** (per browser, like the theme). Only this modal is translated so far. Under the title, a **Version** row shows what is running: the version from the shipped `package.json`, plus a `commit <sha>` chip on a git checkout — there the version is only whatever was last released, so the commit is what identifies the build. When something newer exists, the row is followed by the header badge's update notice, command included. Pick a **theme**, set the **terminal font size**, **font** and **scroll speed** (and whether sending **returns to the latest output**), set a custom **attention sound**, list the repos the cross-repo **PRs & Issues** view should aggregate, add **launch commands** for grid cells, register your own **MCP servers**, and turn on the switches for what this app writes on your behalf — **issue work comments**, the **PR clone footer**, the **closing summary**, the **decision digest**, the **dev worklog** — no need to hand-edit the config file. Four settings stay with their skill because a form would be the wrong tool for them (`keymap`, `themes`, `providers`, `buttons`/`chips`); Settings shows what each is doing now and launches that skill. Note that **theme, font size, scroll speed and the return-to-latest switch are stored per browser** (they're display preferences, so a phone and a desktop keep their own); the rest live in `~/.mulmoterminal/config.json` and are shared by every client.*
770
+ *Open it from the gear button in the toolbar. The **sidebar** groups the sections — Appearance, Projects, Header & launch, Input, Models & servers, Notifications, Integrations, Sessions, Help — and one is on screen at a time; on a phone the sidebar becomes a picker above the section. The interface is available in **English, 日本語, 简体中文, 繁體中文 and 한국어**: it follows your browser's language unless you pick one under **Language** (per browser, like the theme). Translated so far: this modal, the grid's status words, and every button's hover tip and screen-reader label; the rest of the app's words are still English. Under the title, a **Version** row shows what is running: the version from the shipped `package.json`, plus a `commit <sha>` chip on a git checkout — there the version is only whatever was last released, so the commit is what identifies the build. When something newer exists, the row is followed by the header badge's update notice, command included. Pick a **theme**, set the **terminal font size**, **font** and **scroll speed** (and whether sending **returns to the latest output**), set a custom **attention sound**, list the repos the cross-repo **PRs & Issues** view should aggregate, add **launch commands** for grid cells, register your own **MCP servers**, and turn on the switches for what this app writes on your behalf — **issue work comments**, the **PR clone footer**, the **closing summary**, the **decision digest**, the **dev worklog** — no need to hand-edit the config file. Four settings stay with their skill because a form would be the wrong tool for them (`keymap`, `themes`, `providers`, `buttons`/`chips`); Settings shows what each is doing now and launches that skill. Note that **theme, font size, scroll speed and the return-to-latest switch are stored per browser** (they're display preferences, so a phone and a desktop keep their own); the rest live in `~/.mulmoterminal/config.json` and are shared by every client.*
770
771
 
771
772
  | Field | Meaning |
772
773
  | ------------ | ------- |
@@ -1287,6 +1288,41 @@ server says when it arrives — including any directory you expanded while waiti
1287
1288
  pane has not read before says so (`Loading…`) rather than claiming to be empty, and a read that
1288
1289
  fails shows the error rather than a tree it can no longer vouch for.
1289
1290
 
1291
+ **Several files open as tabs, when you ask for them.** A plain click in the tree replaces the file
1292
+ in front, as it always has — so someone who never wants a tab never sees one. **Cmd+click** (Ctrl+click
1293
+ off macOS) or the row menu's **Open in a new tab** adds a tab, and a strip of tabs appears under the
1294
+ header once there are two. Opening a file that already has a tab goes to that tab, from the tree, the
1295
+ finder, the search or a clicked terminal path alike. Switching tabs saves the one you leave, exactly as
1296
+ opening another file does, so only the front tab can hold unsaved edits; each tab keeps its own place
1297
+ (Preview or editor, caret, scroll) and the whole strip is remembered with the rest of the pane. Close a
1298
+ tab with its **×**, a middle click, or **Delete** while it has focus; ←/→, Home and End move between them.
1299
+ The tree follows the tab in front, as VS Code's explorer does: its folders open and its row comes into
1300
+ view (a reload still puts the tree back where you left it).
1301
+
1302
+ **The tree shows what git sees.** In a git repository a changed file is tinted and lettered as in VS
1303
+ Code's explorer — `M` modified, `A` added, `U` untracked, `R` renamed — and a folder holding changes
1304
+ carries a dot, so a collapsed tree still says where the agent has been. It is read when the tree
1305
+ loads or reloads, after a save or an outside change to the open file, and every thirty seconds.
1306
+
1307
+ **The editor marks what changed since the last commit.** A bar beside each line that is new (green)
1308
+ or changed (amber), and a notch where lines were removed, as VS Code's gutter shows them — kept up
1309
+ to date as you type. **Changes** in the header also shows the removed lines in place, as a unified
1310
+ diff. Nothing is marked for a file outside git or one not yet committed.
1311
+
1312
+ **HTML pages and images show in the pane too.** An `.html` file has a **Preview** like a Markdown
1313
+ file: the page itself, sandboxed as presentHtml pages are — its scripts run on an opaque origin with
1314
+ no fetch/XHR; images and the curated CDN list still load, and so does an image it links beside it,
1315
+ while a relative stylesheet or script does not. An `.svg` previews as the picture it draws, and a PNG, JPEG, GIF or WebP opens as the image
1316
+ rather than as "not text". A path to any of these clicked in terminal output opens in the pane when
1317
+ it is up, so a chart an agent just wrote stays in the grid. They are served only under the workspace
1318
+ or a terminal's directory, like every other file the app hands to the browser.
1319
+
1320
+ **A link in the Preview opens in the pane.** A link to another file (`./b.md`, `../README.md`) is
1321
+ resolved against the document it is in and opens in a new tab — in Preview when it is Markdown — or
1322
+ goes to the tab that file already has. A link that climbs above the pane's folder is not opened and
1323
+ the pane says so; an external link opens in a new browser tab, and a `#heading` link scrolls the
1324
+ document.
1325
+
1290
1326
  The toggle is not the only way in: while a cell is enlarged, **clicking a file path the agent
1291
1327
  printed** opens it here too, rather than in a new tab or full-screen — see
1292
1328
  [Clicking a file path](#clicking-a-file-path).
@@ -0,0 +1,7 @@
1
+ export declare const NODE_RELEASES_URL: string;
2
+ export declare const CLAUDE_DIST_TAGS_URL: string;
3
+ export declare function fetchNodeReleases(): Promise<unknown>;
4
+ export declare function fetchClaudeStable(): Promise<string | null>;
5
+ export declare function resolveCommandPath(cmd: string, env?: Record<string, string | undefined>, platform?: string): string | null;
6
+ export declare function readClaudeVersion(binaryPath: string, timeout_ms?: number): Promise<string | null>;
7
+ export declare function checkVersions(options: { claudeBin: string | null }): Promise<{ node: string[]; claude: string[] }>;
@@ -0,0 +1,80 @@
1
+ // The network and process half of `init`'s version check (#2477); the decisions are in
2
+ // bin/version-drift.js. Every probe resolves null on failure — `init` must finish offline.
3
+ import { execFile } from "node:child_process";
4
+ import { realpathSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { extensionsFor, namesAPath, realProbe, searchDirectories } from "./has-command.js";
7
+ import { nodeUpgradeGuide } from "./node-install.js";
8
+ import { claudeDrift, claudeDriftLines, claudeUpdateCommands, nodeDrift, nodeDriftLines, parseClaudeVersion } from "./version-drift.js";
9
+
10
+ export const NODE_RELEASES_URL = "https://nodejs.org/dist/index.json";
11
+ const REGISTRY = (process.env.npm_config_registry || "https://registry.npmjs.org").replace(/\/$/, "");
12
+ export const CLAUDE_DIST_TAGS_URL = `${REGISTRY}/-/package/@anthropic-ai/claude-code/dist-tags`;
13
+
14
+ const FETCH_TIMEOUT_MS = 4000;
15
+ // `claude --version` starts the whole CLI, which is slower than a fetch on a cold disk.
16
+ const CLAUDE_VERSION_TIMEOUT_MS = 8000;
17
+
18
+ async function fetchJson(url) {
19
+ try {
20
+ const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
21
+ return response.ok ? await response.json() : null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export const fetchNodeReleases = () => fetchJson(NODE_RELEASES_URL);
28
+
29
+ export async function fetchClaudeStable() {
30
+ const distTags = await fetchJson(CLAUDE_DIST_TAGS_URL);
31
+ return typeof distTags?.stable === "string" ? distTags.stable : null;
32
+ }
33
+
34
+ /**
35
+ * Where `cmd` lives, symlinks followed, or null — searched with the start-up gate's own PATH rules
36
+ * (bin/has-command.js). Only picks the update command, never gates.
37
+ */
38
+ export function resolveCommandPath(cmd, env = process.env, platform = process.platform) {
39
+ const join = platform === "win32" ? path.win32.join : path.posix.join;
40
+ const candidates = namesAPath(cmd) ? [cmd] : searchDirectories(platform, env).flatMap((dir) => extensionsFor(platform).map((ext) => join(dir, cmd + ext)));
41
+ const found = candidates.find((candidate) => realProbe.isFile(candidate) && (platform === "win32" || realProbe.isExecutable(candidate)));
42
+ if (found === undefined) return null;
43
+ try {
44
+ return realpathSync(found);
45
+ } catch {
46
+ return found;
47
+ }
48
+ }
49
+
50
+ // Own timer rather than execFile's `timeout`: that one only signals the child and then waits for it
51
+ // to exit, so a `claude` that ignores SIGTERM (or a grandchild holding stdout) would hold `init`.
52
+ export function readClaudeVersion(binaryPath, timeout_ms = CLAUDE_VERSION_TIMEOUT_MS) {
53
+ return new Promise((resolve) => {
54
+ const child = execFile(binaryPath, ["--version"], (error, stdout) => {
55
+ clearTimeout(timer);
56
+ resolve(error ? null : parseClaudeVersion(stdout));
57
+ });
58
+ const timer = setTimeout(() => {
59
+ child.kill("SIGKILL");
60
+ resolve(null);
61
+ }, timeout_ms);
62
+ });
63
+ }
64
+
65
+ async function claudeLines(claudeBin) {
66
+ const binaryPath = resolveCommandPath(claudeBin);
67
+ const [localVersion, stable] = await Promise.all([binaryPath ? readClaudeVersion(binaryPath) : null, fetchClaudeStable()]);
68
+ return claudeDriftLines(claudeDrift(localVersion, stable), localVersion, claudeUpdateCommands(binaryPath));
69
+ }
70
+
71
+ async function nodeLines() {
72
+ const releases = await fetchNodeReleases();
73
+ return nodeDriftLines(nodeDrift(process.versions.node, releases), nodeUpgradeGuide(process.execPath, process.platform, process.env));
74
+ }
75
+
76
+ /** The hint lines `init` prints under its Node and Claude Code checks. */
77
+ export async function checkVersions({ claudeBin }) {
78
+ const [node, claude] = await Promise.all([nodeLines(), claudeBin ? claudeLines(claudeBin) : []]);
79
+ return { node, claude };
80
+ }
@@ -3,6 +3,10 @@ export interface CommandProbe {
3
3
  isExecutable: (candidate: string) => boolean;
4
4
  }
5
5
 
6
+ export declare const realProbe: CommandProbe;
7
+ export declare const namesAPath: (cmd: string) => boolean;
8
+ export declare const extensionsFor: (platform: string) => string[];
9
+ export declare const searchDirectories: (platform: string, env: Record<string, string | undefined>) => string[];
6
10
  export declare function hasCommand(
7
11
  cmd: string,
8
12
  options?: { platform?: NodeJS.Platform; env?: Record<string, string | undefined>; probe?: CommandProbe },
@@ -37,9 +37,9 @@ import path from "node:path";
37
37
  const WINDOWS_LAUNCHABLE_EXTENSIONS = ["", ".exe", ".com", ".cmd", ".bat"];
38
38
 
39
39
  /** A name CreateProcess/execvp resolves itself rather than by searching PATH. */
40
- const namesAPath = (cmd) => cmd.includes("/") || cmd.includes("\\");
40
+ export const namesAPath = (cmd) => cmd.includes("/") || cmd.includes("\\");
41
41
 
42
- const realProbe = {
42
+ export const realProbe = {
43
43
  isFile: (candidate) => {
44
44
  try {
45
45
  return statSync(candidate).isFile();
@@ -60,7 +60,7 @@ const realProbe = {
60
60
  };
61
61
 
62
62
  // "" first so an explicit `foo.exe` is found as itself rather than as `foo.exe.EXE`.
63
- const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_EXTENSIONS : [""]);
63
+ export const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_EXTENSIONS : [""]);
64
64
 
65
65
  // A Windows PATH entry may be QUOTED — `"C:\Program Files\tools"` — which the shells strip and a
66
66
  // plain join would not, leaving a path that matches nothing. Same rule as the server's
@@ -69,7 +69,7 @@ const extensionsFor = (platform) => (platform === "win32" ? WINDOWS_LAUNCHABLE_E
69
69
  // The current directory is on neither platform's list. cmd.exe searches it and POSIX shells do
70
70
  // not, but what launches an agent is node-pty on both — and the directory THIS process sits in is
71
71
  // the launch directory, never the one the PTY will run in.
72
- const searchDirectories = (platform, env) => {
72
+ export const searchDirectories = (platform, env) => {
73
73
  const raw = env.PATH || env.Path || "";
74
74
  if (platform === "win32") {
75
75
  return raw
@@ -223,12 +223,18 @@ function passStartupGate(args) {
223
223
  async function runInit(initArgs) {
224
224
  log("Setting up MulmoTerminal…\n");
225
225
 
226
+ const hasClaude = agentInstalled("claude");
227
+ // Imported here so a normal launch never loads the version check or reaches its network.
228
+ const { checkVersions } = await import("./check-versions.js");
229
+ const versionHints = await checkVersions({ claudeBin: hasClaude ? agentBin("claude") : null });
230
+
226
231
  const nodeOk = nodeMeetsMinimum(process.versions.node);
227
232
  console.log(nodeOk ? ` ✓ Node ${process.versions.node}` : ` ✗ Node ${process.versions.node} — MulmoTerminal needs ≥ ${MIN_NODE_LABEL}`);
233
+ versionHints.node.forEach((line) => console.log(line));
228
234
 
229
- const hasClaude = agentInstalled("claude");
230
235
  if (hasClaude) {
231
236
  console.log(" ✓ Claude Code CLI");
237
+ versionHints.claude.forEach((line) => console.log(line));
232
238
  } else {
233
239
  console.log(" ✗ Claude Code CLI — not found");
234
240
  console.log(" → npm install -g @anthropic-ai/claude-code (then run `claude` and log in)");
@@ -0,0 +1,11 @@
1
+ export type Drift = { kind: "current" } | { kind: "unknown" } | { kind: "behind"; latest: string; sameMajor?: boolean };
2
+ export declare const CURRENT: Drift;
3
+ export declare const UNKNOWN: Drift;
4
+ export declare const CLAUDE_UPDATE_NATIVE: string;
5
+ export declare const CLAUDE_UPDATE_NPM: string;
6
+ export declare function nodeDrift(localVersion: string, releases: unknown): Drift;
7
+ export declare function claudeDrift(localVersion: string | null, stableVersion: string | null): Drift;
8
+ export declare function parseClaudeVersion(stdout: string | null | undefined): string | null;
9
+ export declare function claudeUpdateCommands(realPath: string | null): string[];
10
+ export declare function nodeDriftLines(drift: Drift, upgrade: import("./node-install.js").NodeUpgradeGuide): string[];
11
+ export declare function claudeDriftLines(drift: Drift, localVersion: string | null, updateCommands: string[]): string[];
@@ -0,0 +1,88 @@
1
+ // Whether this machine's Node and Claude Code are behind the published releases, and what `init`
2
+ // says about it (#2477). Pure: the releases, the local versions and the binary path are handed in
3
+ // by bin/check-versions.js, so every decision here is testable without the network.
4
+ import { NODE_DOWNLOAD_URL } from "./node-install.js";
5
+ import { isNewerVersion } from "./update-check.js";
6
+
7
+ export const CURRENT = { kind: "current" };
8
+ export const UNKNOWN = { kind: "unknown" };
9
+
10
+ const stripV = (version) => String(version).replace(/^v/, "");
11
+ const majorOf = (version) => Number.parseInt(stripV(version).split(".")[0], 10);
12
+ const newestOf = (versions) => versions.reduce((newest, version) => (isNewerVersion(version, newest) ? version : newest));
13
+ // Remote strings are printed as-is, so only a plain x.y.z is accepted — anything else would compare
14
+ // as zeros (and read as current) or put the registry's text on the user's terminal.
15
+ const isPlainVersion = (version) => typeof version === "string" && /^v?\d+\.\d+\.\d+$/.test(version);
16
+ const isRelease = (entry) => typeof entry === "object" && entry !== null && isPlainVersion(entry.version);
17
+
18
+ /**
19
+ * Node against the LTS releases in nodejs.org's index.json.
20
+ *
21
+ * The target is the newest LTS of the same major: moving someone off a working 22.x because 24.x
22
+ * exists is not this check's call. A major with no LTS release is compared with the newest LTS,
23
+ * unless it is newer than that one — a Current release is not behind anything.
24
+ */
25
+ export function nodeDrift(localVersion, releases) {
26
+ if (!Array.isArray(releases) || Number.isNaN(majorOf(localVersion))) return UNKNOWN;
27
+ const ltsVersions = releases.filter((entry) => isRelease(entry) && entry.lts).map((entry) => stripV(entry.version));
28
+ if (ltsVersions.length === 0) return UNKNOWN;
29
+ const sameMajor = ltsVersions.filter((version) => majorOf(version) === majorOf(localVersion));
30
+ const newestLts = newestOf(ltsVersions);
31
+ if (sameMajor.length === 0 && majorOf(localVersion) > majorOf(newestLts)) return CURRENT;
32
+ const target = sameMajor.length > 0 ? newestOf(sameMajor) : newestLts;
33
+ return isNewerVersion(target, stripV(localVersion)) ? { kind: "behind", latest: target, sameMajor: sameMajor.length > 0 } : CURRENT;
34
+ }
35
+
36
+ /** Claude Code against the npm `stable` dist-tag. Ahead of `stable` (on `latest`) is current. */
37
+ export function claudeDrift(localVersion, stableVersion) {
38
+ if (typeof localVersion !== "string" || !isPlainVersion(stableVersion)) return UNKNOWN;
39
+ return isNewerVersion(stableVersion, localVersion) ? { kind: "behind", latest: stableVersion } : CURRENT;
40
+ }
41
+
42
+ /** `claude --version` prints `2.1.284 (Claude Code)`; anything without a leading version is null. */
43
+ export function parseClaudeVersion(stdout) {
44
+ return /^\s*(\d+\.\d+\.\d+)/.exec(String(stdout ?? ""))?.[1] ?? null;
45
+ }
46
+
47
+ export const CLAUDE_UPDATE_NATIVE = "claude update";
48
+ export const CLAUDE_UPDATE_NPM = "npm install -g @anthropic-ai/claude-code@latest";
49
+
50
+ /**
51
+ * How to update the Claude Code at `realPath`. Only the two layouts checked on a real machine get a
52
+ * single command; anything else gets both, as the server's own "too old" message does.
53
+ */
54
+ export function claudeUpdateCommands(realPath) {
55
+ const path = String(realPath ?? "").replaceAll("\\", "/");
56
+ if (path.includes("/node_modules/@anthropic-ai/claude-code/")) return [CLAUDE_UPDATE_NPM];
57
+ if (path.includes("/.local/share/claude/")) return [CLAUDE_UPDATE_NATIVE];
58
+ return [CLAUDE_UPDATE_NATIVE, `${CLAUDE_UPDATE_NPM} (if you installed it with npm)`];
59
+ }
60
+
61
+ const HINT = " → ";
62
+ const COMMAND = " ";
63
+
64
+ function updateLines(headline, commands, fallback, via = null) {
65
+ if (commands.length === 0) return [`${HINT}${headline} Update: ${fallback}`];
66
+ const withTool = via ? " with " + via : "";
67
+ return [`${HINT}${headline} To update${withTool}:`, ...commands.map((command) => `${COMMAND}${command}`)];
68
+ }
69
+
70
+ /**
71
+ * The lines printed under `✓ Node …`; empty when there is nothing to say. The commands are the
72
+ * install tool's own upgrade (`nodeUpgradeGuide`), which may land on a newer major than the one
73
+ * named — so the tool is named too.
74
+ */
75
+ export function nodeDriftLines(drift, upgrade) {
76
+ if (drift.kind === "unknown") return [`${HINT}○ could not check for a newer Node (offline?)`];
77
+ if (drift.kind !== "behind") return [];
78
+ const headline = drift.sameMajor ? `Node ${drift.latest} is out (latest ${majorOf(drift.latest)}.x LTS).` : `Node ${drift.latest} is the latest LTS.`;
79
+ return updateLines(headline, upgrade.commands, NODE_DOWNLOAD_URL, upgrade.via);
80
+ }
81
+
82
+ /** The lines printed under `✓ Claude Code CLI`; empty when there is nothing to say. */
83
+ export function claudeDriftLines(drift, localVersion, updateCommands) {
84
+ if (localVersion === null) return [`${HINT}○ could not read the Claude Code version`];
85
+ if (drift.kind === "unknown") return [`${HINT}○ could not check for a newer Claude Code (offline?)`];
86
+ if (drift.kind !== "behind") return [];
87
+ return updateLines(`Claude Code ${localVersion} is older than the stable release ${drift.latest}.`, updateCommands, CLAUDE_UPDATE_NATIVE);
88
+ }
@@ -1,7 +1,7 @@
1
1
  // Reads .blueprint/replies.json — an answer to each question the person asked — and answers one question
2
2
  // per mode. Exit 0 is yes.
3
3
  // answer every question has exactly one reply; a reply found in the documents quotes them (chaff cite)
4
- // and names every address it quotes; a reply not found says what was searched. Records the
4
+ // and names every place it quotes (the address, or the heading of the section there); a reply not found says what was searched. Records the
5
5
  // documents' and FAQ.md's fingerprints.
6
6
  // keep the documents are unchanged; FAQ.md holds every question after what it held before when the
7
7
  // person asked to keep the answers, and is untouched otherwise
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { fromBase } from "./base.mjs";
10
10
  const { fail, quotationProblems, readJson } = await import(fromBase("chaff.mjs"));
11
11
  const { digest, documentSource, documentsNamed, fingerprint } = await import(fromBase("documents.mjs"));
12
+ const { headingsOf, namesPlace } = await import(fromBase("places.mjs"));
12
13
 
13
14
  const REPLIES = ".blueprint/replies.json";
14
15
  const REPLIES_PAGE = ".blueprint/replies.md";
@@ -29,6 +30,15 @@ if (answers?.keep === KEEP && documents.includes(FAQ)) fail(`${FAQ} is one of th
29
30
  const indented = (line) => " " + line;
30
31
  const nonEmpty = (value) => typeof value === "string" && value.trim() !== "";
31
32
 
33
+ // Read once per document, and only for one of the documents: a source that is not is refused by the quotation check.
34
+ const headingCache = new Map();
35
+ const headingAt = (source, address) => {
36
+ const resolved = documentSource(documents)(source);
37
+ if (!resolved.path) return undefined;
38
+ if (!headingCache.has(resolved.path)) headingCache.set(resolved.path, headingsOf(resolved.path));
39
+ return headingCache.get(resolved.path).get(address.trim());
40
+ };
41
+
32
42
  const replyProblem = (reply) => {
33
43
  if (typeof reply !== "object" || reply === null) return "a reply is not an object";
34
44
  if (!nonEmpty(reply.question)) return "a reply names no question";
@@ -39,7 +49,10 @@ const replyProblem = (reply) => {
39
49
  if (!Array.isArray(citations)) return `${which}: citations must be an array`;
40
50
  if (reply.found && citations.length === 0) return `${which}: an answer found in the documents quotes them`;
41
51
  if (!citations.every((citation) => nonEmpty(citation?.address))) return `${which}: every quotation needs an "address"`;
42
- const unnamed = citations.filter((citation) => !reply.answer.includes(citation.address.trim()));
52
+ const unnamed = citations.filter(
53
+ (citation) =>
54
+ !reply.answer.includes(citation.address.trim()) && !namesPlace(reply.answer, citation.address, headingAt(String(citation.source), citation.address)),
55
+ );
43
56
  if (unnamed.length > 0) return `${which}: the answer does not name ${unnamed.map((citation) => citation.address).join(", ")}, which it quotes`;
44
57
  if (!reply.found && (!Array.isArray(reply.searched) || !reply.searched.some(nonEmpty))) return `${which}: say what was searched ("searched")`;
45
58
  return null;
@@ -48,11 +48,15 @@ the answers before anything is kept.
48
48
  - `question`: the question exactly as it is in `questions`. One reply per question, none extra.
49
49
  - `found`: `true` when the documents answer it. Then at least one citation.
50
50
  - `citations`: `source` is the document's path as `documents` names it; `address` is where; `quote` is
51
- copied character for character — the check runs `chaff cite` on every one. **Write every cited address in
52
- the `answer` text** so the person can open the document there.
51
+ copied character for character — the check runs `chaff cite` on every one. **Name every cited place in
52
+ the `answer` text** so the person can open the document there: an article or item as the document numbers it
53
+ (第4条第2項, Section 3.1), and a section of a Markdown document by its heading, word for word, in 「」 (「作業用フォルダを信頼しておく」;
54
+ in an English answer, "Getting started") — the check looks for it in those marks.
55
+ chaff's index for a section (`h1.3`) goes in `address` only — it means nothing to the person.
53
56
  - `searched`: when `found` is `false`, the words and places you looked for.
54
57
 
55
- Also write `.blueprint/replies.md` for the person: each question as a heading, the answer, and the quotations.
58
+ Also write `.blueprint/replies.md` for the person: each question as a heading, the answer, and the quotations,
59
+ each with its place named as in the answer (never `h1.3`).
56
60
 
57
61
  ## Done when
58
62
 
@@ -17,7 +17,8 @@ run the check and stop.
17
17
  Otherwise add to `FAQ.md` in this folder (create it if there is none). **Add after what it already holds;
18
18
  never edit or reorder what is there** — the check compares the beginning of the file with what it held
19
19
  before. For each question: the question as a `##` heading, word for word; the answer; and where it is
20
- written (document and address). For a question the documents do not answer, say so — that is worth keeping
20
+ written (the document, and the place named as in the answer: 第4条第2項, or a section's heading in 「」 —
21
+ never chaff's `h1.3`). For a question the documents do not answer, say so — that is worth keeping
21
22
  too.
22
23
 
23
24
  ## Done when
@@ -13,7 +13,8 @@
13
13
  "description": "残すと決めたなら、確かめた答えを FAQ.md に書き足す。",
14
14
  "skill": "skills/keep",
15
15
  "check": "node \"$BLUEPRINT_USECASE/checks/replies.mjs\" keep",
16
- "gates": ["review"]
16
+ "gates": ["review"],
17
+ "reads": [".blueprint/replies.md"]
17
18
  }
18
19
  ]
19
20
  }
@@ -4,4 +4,4 @@
4
4
  if [ -n "${CHAFF_BIN:-}" ]; then
5
5
  exec sh -c "$CHAFF_BIN \"\$@\"" chaff "$@"
6
6
  fi
7
- exec npx -y chaffjs@0.10 "$@"
7
+ exec npx -y chaffjs@0.13 "$@"
@@ -0,0 +1,7 @@
1
+ // Types for places.mjs, which the packs run with plain node and the specs import.
2
+ export function headingsIn(tree: unknown): Map<string, string>;
3
+ export function headingsOf(file: string): Map<string, string>;
4
+ export function namesPlace(text: string, address: string, heading: string | undefined): boolean;
5
+ export function placeNamesIn(tree: unknown): Map<string, string>;
6
+ export function placeNamesOf(file: string): Map<string, string>;
7
+ export function placeNamer(sourcePath: (source: string) => { path?: string; problem?: string }): (source: string, address: string) => string;
@@ -0,0 +1,102 @@
1
+ // Where a quotation sits, named for the person who reads it: a section of a Markdown document by its heading,
2
+ // since chaff's address for it (h1.3) tells nobody anything. Other addresses (第4条第2項, 3.1) already read as the
3
+ // document writes them.
4
+ import { runChaff } from "./chaff.mjs";
5
+
6
+ const sectionsIn = (node) => [
7
+ ...(node?.kind === "section" && typeof node.address === "string" && typeof node.attrs?.heading === "string" ? [[node.address, node.attrs.heading]] : []),
8
+ ...(Array.isArray(node?.children) ? node.children.flatMap(sectionsIn) : []),
9
+ ];
10
+
11
+ const PARTS = new Set(["section", "article"]);
12
+
13
+ const text = (value) => (typeof value === "string" && value !== "" ? value : null);
14
+
15
+ // A Japanese article (第4条, 第二十一条の二): what is under it is named as Japanese law cites it.
16
+ const JA_ARTICLE = /^第[^条\s]+条/u;
17
+ const halfWidth = (digits) => digits.replace(/[0-9]/gu, (digit) => String.fromCharCode(digit.charCodeAt(0) - 0xfee0));
18
+
19
+ // Under a Japanese article, one level down is a paragraph (第2項, numbered from the address when chaff gives it no
20
+ // label) and anything deeper an item (第三号, by its label). chaff calls both "item", so the depth tells them apart.
21
+ function japaneseName(label, own, above, article) {
22
+ const levels = own.slice(article.address.length + 1).split(".");
23
+ if (levels.length === 1) return `${above}第${halfWidth(label ?? levels[0])}項`;
24
+ return `${above}第${label ?? levels.at(-1)}号`;
25
+ }
26
+
27
+ // A part (an article, a section) is named by its label, or by its quoted heading when it has none (a Markdown
28
+ // section); anything below it (an item, a paragraph) adds its own label to the name of what holds it.
29
+ function nameOf(node, own, above, article) {
30
+ const label = text(node?.attrs?.label);
31
+ if (PARTS.has(node?.kind)) {
32
+ const heading = text(node?.attrs?.heading);
33
+ return label ?? (heading === null ? own : `「${heading}」`);
34
+ }
35
+ if (above === null) return label ?? own;
36
+ return article?.japanese && own.startsWith(`${article.address}.`) ? japaneseName(label, own, above, article) : `${above} ${label ?? own}`;
37
+ }
38
+
39
+ const partOf = (node, own, name, article) => (PARTS.has(node?.kind) && own !== null ? { address: own, japanese: JA_ARTICLE.test(name ?? "") } : article);
40
+
41
+ const namesIn = (node, above, article) => {
42
+ const own = text(node?.address);
43
+ const name = own === null ? above : nameOf(node, own, above, article);
44
+ const here = own === null ? [] : [[own, name]];
45
+ const within = partOf(node, own, name, article);
46
+ return [...here, ...(Array.isArray(node?.children) ? node.children.flatMap((child) => namesIn(child, name, within)) : [])];
47
+ };
48
+
49
+ /** Every address in a chaff tree, with the name a person reads for it: 第4条第2項, Section 3.2 (a) (i), 「用意するもの」. */
50
+ export const placeNamesIn = (tree) => new Map(namesIn(tree, null, null));
51
+
52
+ /** The place names of `file`, by address; none when chaff cannot read its tree. */
53
+ export const placeNamesOf = (file) => {
54
+ const run = runChaff(["tree", file, "--format", "json"]);
55
+ if (run.code !== 0) return new Map();
56
+ try {
57
+ return placeNamesIn(JSON.parse(run.stdout));
58
+ } catch {
59
+ return new Map();
60
+ }
61
+ };
62
+
63
+ /**
64
+ * Where a citation sits, for a view a person reads: its place name from the document's tree, read once per document
65
+ * and only for one of `documents` (through `sourcePath`, which resolves or refuses a source); the address itself
66
+ * when there is no name.
67
+ */
68
+ export const placeNamer = (sourcePath) => {
69
+ const cache = new Map();
70
+ return (source, address) => {
71
+ const resolved = sourcePath(source);
72
+ if (!resolved.path) return address;
73
+ if (!cache.has(resolved.path)) cache.set(resolved.path, placeNamesOf(resolved.path));
74
+ return cache.get(resolved.path).get(String(address).trim()) ?? address;
75
+ };
76
+ };
77
+
78
+ /** Every section address in a chaff tree (`tree --format json`), with its heading. */
79
+ export const headingsIn = (tree) => new Map(sectionsIn(tree));
80
+
81
+ /** The section headings of `file`, by address; none when chaff cannot read its tree. */
82
+ export const headingsOf = (file) => {
83
+ const run = runChaff(["tree", file, "--format", "json"]);
84
+ if (run.code !== 0) return new Map();
85
+ try {
86
+ return headingsIn(JSON.parse(run.stdout));
87
+ } catch {
88
+ return new Map();
89
+ }
90
+ };
91
+
92
+ // A heading counts only in quotation marks: a short one (期限) is also an ordinary word, and bare it would be found by accident.
93
+ const QUOTES = [
94
+ ["「", "」"],
95
+ ['"', '"'],
96
+ ["“", "”"],
97
+ ];
98
+
99
+ /** Whether `text` names a place: by its address as written, or by the quoted heading of the section at that address. */
100
+ export const namesPlace = (text, address, heading) =>
101
+ text.includes(address.trim()) ||
102
+ (typeof heading === "string" && heading !== "" && QUOTES.some(([open, close]) => text.includes(`${open}${heading}${close}`)));
@@ -12,7 +12,7 @@ repository, `.git/info/exclude`) until the person has approved the models.
12
12
  1. `.blueprint/` already exists (the build created it). Everything this build records goes there.
13
13
  2. Run chaff once through the base pack's wrapper, so its first download happens now and not in the
14
14
  middle of a later step: `sh <base pack>/checks/chaff.sh rules --json > /dev/null`. The wrapper runs the
15
- pinned chaff (`npx -y chaffjs@0.10`) unless `CHAFF_BIN` names another. If it fails, show the person the
15
+ pinned chaff (`npx -y chaffjs@0.13`) unless `CHAFF_BIN` names another. If it fails, show the person the
16
16
  error and stop — do not work around it.
17
17
  3. If the folder is a git repository, add `.blueprint/` to `.git/info/exclude`. That keeps the build's
18
18
  working files out of commits without touching `.gitignore`, which belongs to the person.
@@ -0,0 +1,11 @@
1
+ #!/bin/sh
2
+ # The security review's report exists, walks every OWASP Top 10:2025 category, and leaves no HIGH or
3
+ # MEDIUM finding open or merely accepted. A finding is one line: `- <HIGH|MEDIUM|LOW> <fixed|open|accepted>: …`.
4
+ set -eu
5
+ report=.blueprint/security-review.md
6
+ [ -s "$report" ] || { echo "missing $report" >&2; exit 1; }
7
+ for category in A01 A02 A03 A04 A05 A06 A07 A08 A09 A10; do
8
+ grep -q "^#.*$category" "$report" || { echo "$report has no section for $category (OWASP Top 10:2025)" >&2; exit 1; }
9
+ done
10
+ open=$(grep -nE '^- (HIGH|MEDIUM) (open|accepted)' "$report" || true)
11
+ [ -z "$open" ] || { printf 'HIGH / MEDIUM findings must be fixed, but are open or accepted in %s:\n%s\n' "$report" "$open" >&2; exit 1; }
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ # The security review is proven by what ships, not by the review's word: the rules refuse what the
3
+ # review's emulator tests say they refuse, the rules end in deny-all, Hosting sends the headers a browser
4
+ # needs, no service account key is in the project, the dependencies have no known high-severity hole,
5
+ # the review's own report is in place, and dev runs what the review left — so production, which ships
6
+ # what dev ran, gets the fixes, and a header that breaks the page shows up here, on dev.
7
+ set -eu
8
+ here="$(dirname "$0")"
9
+ sh "$here/security-report.sh"
10
+ grep -qE 'allow +read, *write: *if +false' firestore.rules || { echo "firestore.rules does not end in a deny-all (allow read, write: if false)" >&2; exit 1; }
11
+ node -e 'const fs=require("node:fs");
12
+ const hosting=[JSON.parse(fs.readFileSync("firebase.json","utf8")).hosting].flat().filter(Boolean);
13
+ if (hosting.length===0) { console.error("firebase.json has no hosting section"); process.exit(1); }
14
+ for (const site of hosting) {
15
+ const all=(site.headers??[]).filter((h)=>h.source==="**").flatMap((h)=>h.headers??[]);
16
+ const value=(key)=>all.find((h)=>h.key.toLowerCase()===key)?.value??"";
17
+ const missing=[];
18
+ if (!/nosniff/i.test(value("x-content-type-options"))) missing.push("X-Content-Type-Options: nosniff");
19
+ if (!value("content-security-policy")) missing.push("Content-Security-Policy");
20
+ if (!/frame-ancestors/i.test(value("content-security-policy")) && !/deny|sameorigin/i.test(value("x-frame-options"))) missing.push("frame-ancestors or X-Frame-Options");
21
+ if (missing.length) { console.error("firebase.json hosting headers for ** lack: "+missing.join(", ")); process.exit(1); }
22
+ }'
23
+ keys=$(grep -rlE '"private_key"|-----BEGIN [A-Z ]*PRIVATE KEY-----' . --exclude-dir=node_modules --exclude-dir=.firebase --exclude-dir=dist 2>/dev/null || true)
24
+ [ -z "$keys" ] || { printf 'a private key is in the project; move it out and revoke it:\n%s\n' "$keys" >&2; exit 1; }
25
+ audit() {
26
+ # Read from the summary, not the exit code: an audit that could not reach the registry must not pass.
27
+ (cd "$1" && yarn audit --groups dependencies --json 2>/dev/null) | node -e 'let s="";process.stdin.on("data",(c)=>s+=c).on("end",()=>{
28
+ const summary = s.split("\n").filter(Boolean).map((line) => JSON.parse(line)).find((entry) => entry.type === "auditSummary");
29
+ if (!summary) { console.error(process.argv[1] + ": yarn audit did not report a summary (is the registry reachable?)"); process.exit(1); }
30
+ const { high = 0, critical = 0 } = summary.data.vulnerabilities;
31
+ if (high + critical > 0) { console.error(`${process.argv[1]}: yarn audit finds ${high} high and ${critical} critical vulnerabilities in dependencies`); process.exit(1); }
32
+ })' "$1"
33
+ }
34
+ audit .
35
+ if [ -f functions/package.json ]; then
36
+ [ -f functions/yarn.lock ] || { echo "functions/ has no yarn.lock; install its dependencies with yarn so they can be audited" >&2; exit 1; }
37
+ audit functions
38
+ fi
39
+ sh "$here/emulator-test.sh" security
40
+ [ .blueprint/build-id -nt .blueprint/security-review.md ] || { echo "dev was not deployed after the review; deploy it to dev again so production ships what dev runs" >&2; exit 1; }
41
+ sh "$here/deploy-check.sh" dev
@@ -6,3 +6,7 @@ if grep -n '{{' .blueprint/spec.md >&2; then
6
6
  echo "unfilled placeholders are left in .blueprint/spec.md" >&2
7
7
  exit 1
8
8
  fi
9
+ # A usecase may hold its spec to more than the template: when it ships its own spec check, that runs too.
10
+ if [ -n "${BLUEPRINT_USECASE:-}" ] && [ -f "$BLUEPRINT_USECASE/checks/spec.sh" ]; then
11
+ sh "$BLUEPRINT_USECASE/checks/spec.sh"
12
+ fi
@@ -0,0 +1,8 @@
1
+ #!/bin/sh
2
+ # The first page the finished build shows says where the published app is and what to try to see it works.
3
+ set -eu
4
+ start=.blueprint/start-here.md
5
+ [ -s "$start" ] || { echo "missing $start" >&2; exit 1; }
6
+ prod=$(sh "$(dirname "$0")/project-id.sh" prod)
7
+ grep -q "https://$prod.web.app" "$start" || { echo "$start does not name the production URL (https://$prod.web.app)" >&2; exit 1; }
8
+ grep -q '^- \[ \] ' "$start" || { echo "$start has no checklist of what to try (- [ ] …)" >&2; exit 1; }
@@ -35,5 +35,6 @@
35
35
  "credentials": [
36
36
  "firebase login",
37
37
  "gcloud auth login"
38
- ]
38
+ ],
39
+ "report": ".blueprint/start-here.md"
39
40
  }