mulmoterminal 6.6.0 → 6.8.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 (223) hide show
  1. package/README.md +28 -4
  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/hearing.json +5 -2
  10. package/blueprints/ask/steps.json +2 -1
  11. package/blueprints/docs/checks/chaff.sh +1 -1
  12. package/blueprints/docs/skills/workspace/SKILL.md +1 -1
  13. package/blueprints/firebase/checks/security-report.sh +11 -0
  14. package/blueprints/firebase/checks/security.sh +41 -0
  15. package/blueprints/firebase/checks/start-here.sh +8 -0
  16. package/blueprints/firebase/manifest.json +2 -1
  17. package/blueprints/firebase/plan.json +8 -1
  18. package/blueprints/firebase/skills/deploy-production/SKILL.md +14 -1
  19. package/blueprints/firebase/skills/security/SKILL.md +80 -0
  20. package/blueprints/from-collection/checks/spec.sh +26 -0
  21. package/blueprints/from-collection/hearing.json +76 -0
  22. package/blueprints/from-collection/manifest.json +8 -0
  23. package/blueprints/from-collection/skills/acceptance/SKILL.md +26 -0
  24. package/blueprints/from-collection/spec/conversion.md +37 -0
  25. package/blueprints/from-collection/spec/requirements.md +45 -0
  26. package/blueprints/from-collection/steps.json +12 -0
  27. package/blueprints/local/checks/handover.sh +7 -1
  28. package/blueprints/local/checks/security-report.sh +11 -0
  29. package/blueprints/local/checks/security.sh +48 -0
  30. package/blueprints/local/checks/serve.sh +24 -0
  31. package/blueprints/local/checks/smoke.sh +1 -22
  32. package/blueprints/local/checks/spec.sh +4 -0
  33. package/blueprints/local/manifest.json +2 -1
  34. package/blueprints/local/plan.json +7 -0
  35. package/blueprints/local/security/checklist.md +2 -0
  36. package/blueprints/local/skills/handover/SKILL.md +17 -1
  37. package/blueprints/local/skills/security/SKILL.md +90 -0
  38. package/blueprints/polish/checks/targets.mjs +4 -0
  39. package/blueprints/polish/checks/targetsView.d.mts +2 -0
  40. package/blueprints/polish/checks/targetsView.mjs +7 -0
  41. package/blueprints/polish/hearing.json +7 -3
  42. package/blueprints/polish/presets.json +0 -1
  43. package/blueprints/polish/skills/polish/SKILL.md +1 -1
  44. package/blueprints/polish/skills/survey/SKILL.md +2 -1
  45. package/blueprints/polish/steps.json +2 -1
  46. package/blueprints/product/steps.json +1 -0
  47. package/blueprints/refactor/hearing.json +2 -1
  48. package/blueprints/review/checks/findings.mjs +4 -0
  49. package/blueprints/review/checks/findingsView.d.mts +12 -0
  50. package/blueprints/review/checks/findingsView.mjs +30 -0
  51. package/blueprints/review/hearing.json +3 -1
  52. package/blueprints/review/steps.json +2 -1
  53. package/blueprints/style/checks/sources.mjs +4 -1
  54. package/blueprints/style/checks/sourcesView.d.mts +2 -0
  55. package/blueprints/style/checks/sourcesView.mjs +15 -0
  56. package/blueprints/style/hearing.json +3 -1
  57. package/blueprints/style/manifest.json +5 -1
  58. package/blueprints/style/skills/report/SKILL.md +1 -1
  59. package/blueprints/style/steps.json +4 -2
  60. package/blueprints/verify/checks/extract.mjs +3 -0
  61. package/blueprints/verify/checks/factsView.d.mts +4 -0
  62. package/blueprints/verify/checks/factsView.mjs +36 -0
  63. package/blueprints/verify/hearing.json +3 -1
  64. package/blueprints/verify/steps.json +2 -1
  65. package/blueprints/write/checks/outlineView.d.mts +4 -0
  66. package/blueprints/write/checks/outlineView.mjs +14 -0
  67. package/blueprints/write/checks/parts.mjs +5 -0
  68. package/blueprints/write/hearing.json +9 -1
  69. package/blueprints/write/manifest.json +9 -1
  70. package/blueprints/write/steps.json +4 -2
  71. package/common/blueprint/collectionSource.ts +75 -0
  72. package/common/blueprint/hearing.ts +48 -2
  73. package/common/blueprint/manifest.ts +25 -2
  74. package/common/blueprint/plan.ts +5 -0
  75. package/common/blueprint/refusal.ts +62 -3
  76. package/common/blueprint/run.ts +4 -1
  77. package/common/collectionActions.ts +31 -0
  78. package/common/dirConfigSource.ts +3 -0
  79. package/common/filesPage.ts +39 -0
  80. package/common/keymap.ts +20 -1
  81. package/common/mdPreviewMessage.ts +16 -2
  82. package/dist/assets/{abnfDiagram-O67JEVCF-BypT3U3a.js → abnfDiagram-O67JEVCF-Dy6jPGlc.js} +1 -1
  83. package/dist/assets/architecture-7GRP2DOG-DyaLKyU5.js +1 -0
  84. package/dist/assets/{architectureDiagram-NJMV4G6O-CyObEvXl.js → architectureDiagram-NJMV4G6O-Y1wqce2c.js} +1 -1
  85. package/dist/assets/{blockDiagram-BEXU5L5S-D-HVBeq1.js → blockDiagram-BEXU5L5S-B6haVvbR.js} +1 -1
  86. package/dist/assets/{c4Diagram-YGBWAQC7-Qgd--us_.js → c4Diagram-YGBWAQC7-CbqMeQ5M.js} +1 -1
  87. package/dist/assets/channel-T986AWhs.js +1 -0
  88. package/dist/assets/{chunk-3FUC2YCW-BR-5aCbb.js → chunk-3FUC2YCW-Dql-NfIM.js} +1 -1
  89. package/dist/assets/{chunk-5DYCD2WN-Bo8hecoJ.js → chunk-5DYCD2WN-DPBLnLhJ.js} +1 -1
  90. package/dist/assets/{chunk-742MDFTN-CD8LfZUq.js → chunk-742MDFTN-Cfcav64I.js} +1 -1
  91. package/dist/assets/{chunk-7INBJB4K-CX2KLcpU.js → chunk-7INBJB4K-gd2sm2Qq.js} +1 -1
  92. package/dist/assets/{chunk-7M6MHVWA-EAATiixY.js → chunk-7M6MHVWA-Cga-bP1d.js} +1 -1
  93. package/dist/assets/{chunk-7PRAP22T-kBBJ0KJk.js → chunk-7PRAP22T-B5CSlQEt.js} +1 -1
  94. package/dist/assets/{chunk-GTNCS2PH-BMtqOq18.js → chunk-GTNCS2PH-BzDxh0CI.js} +1 -1
  95. package/dist/assets/{chunk-GWA4HPMP-B0Mi72WR.js → chunk-GWA4HPMP-CTVycvRw.js} +1 -1
  96. package/dist/assets/{chunk-MBY4JIJT-DmHkAdrQ.js → chunk-MBY4JIJT-Dj2QOy3d.js} +1 -1
  97. package/dist/assets/{chunk-NETBCI7D-CXGvJJei.js → chunk-NETBCI7D-CVlwwnxD.js} +1 -1
  98. package/dist/assets/{chunk-O7XYJQB3-WBMmFQya.js → chunk-O7XYJQB3-CBuERAl7.js} +1 -1
  99. package/dist/assets/{chunk-UA2S7LBM-DaQ23UEn.js → chunk-UA2S7LBM-Dq3DhUK-.js} +1 -1
  100. package/dist/assets/{chunk-WEXAMYUT-DviQkb8I.js → chunk-WEXAMYUT-D8fWG-1Z.js} +1 -1
  101. package/dist/assets/{chunk-Z7XXMR3K-DOtBujlQ.js → chunk-Z7XXMR3K-zrP0_WoK.js} +1 -1
  102. package/dist/assets/{chunk-ZIGJFQKS-VtmgG3xL.js → chunk-ZIGJFQKS-Dzlt4QPL.js} +1 -1
  103. package/dist/assets/{classDiagram-v2-NBCMYWYE-ndrP6enZ.js → classDiagram-v2-NBCMYWYE-CjA-LmiN.js} +1 -1
  104. package/dist/assets/{cynefin-OW5HDTMX-5iOYoFuE.js → cynefin-OW5HDTMX-CVRZEwmv.js} +1 -1
  105. package/dist/assets/{cynefinDiagram-VND7K2PF-DWaNHdmF.js → cynefinDiagram-VND7K2PF-C9rD0DOQ.js} +1 -1
  106. package/dist/assets/{dagre-6A5THRUB-CRI-Ki8R.js → dagre-6A5THRUB-BuE8Gjyb.js} +1 -1
  107. package/dist/assets/{diagram-22UHCM2B-Cb4_8V3f.js → diagram-22UHCM2B-Cw3M7VEV.js} +1 -1
  108. package/dist/assets/{diagram-3UASUU5V--mTYdfxz.js → diagram-3UASUU5V-Pu3L65WF.js} +1 -1
  109. package/dist/assets/{diagram-ATOU4E4O-BwY4-yUt.js → diagram-ATOU4E4O-OI0bhF_A.js} +1 -1
  110. package/dist/assets/{diagram-CDSNMT55-CW8ENZKa.js → diagram-CDSNMT55-CH39M5AD.js} +1 -1
  111. package/dist/assets/{diagram-MLGK6HIB-Ew0oj4NU.js → diagram-MLGK6HIB-B2nfDfEm.js} +1 -1
  112. package/dist/assets/{diagram-MPIPVDR6-BK0uRGQB.js → diagram-MPIPVDR6-CFiLA_-F.js} +1 -1
  113. package/dist/assets/{dist-Cp7rDLig.js → dist-BSqJ_nUe.js} +1 -1
  114. package/dist/assets/{dist-CNs34037.js → dist-Sj3hCOj7.js} +1 -1
  115. package/dist/assets/{dist-jO1cPpPw.js → dist-lS8XUoi1.js} +1 -1
  116. package/dist/assets/{dist-DRZ3y7AE.js → dist-pmD9XVGa.js} +1 -1
  117. package/dist/assets/{ebnfDiagram-ZINNZB2B-DGu-I1_-.js → ebnfDiagram-ZINNZB2B-ChqhmMzu.js} +1 -1
  118. package/dist/assets/{elk-276RUBZZ-IKdDujmV.js → elk-276RUBZZ-De2Iml3H.js} +1 -1
  119. package/dist/assets/{erDiagram-OPXOYQCR-CLcXgJdC.js → erDiagram-OPXOYQCR-CUt6-AR7.js} +1 -1
  120. package/dist/assets/eventmodeling-NTZA5JFV-DAxZbpPN.js +1 -0
  121. package/dist/assets/flowDiagram-KWPJA3E3-Bss7fLee.js +1 -0
  122. package/dist/assets/{ganttDiagram-FUAMR5RP-lfct59GT.js → ganttDiagram-FUAMR5RP-BETJ1ApO.js} +1 -1
  123. package/dist/assets/{gitGraph-4MIJSDKK-Hr_vAl58.js → gitGraph-4MIJSDKK-DGOqms6x.js} +1 -1
  124. package/dist/assets/{gitGraphDiagram-X574FWY7-DyTAObOG.js → gitGraphDiagram-X574FWY7-Btn58SWQ.js} +1 -1
  125. package/dist/assets/index-8B5Of03P.css +1 -0
  126. package/dist/assets/{index-BfCtnfM3.js → index-Dw_awLi8.js} +345 -342
  127. package/dist/assets/{info-A6RAGUB7-C2RIrvn7.js → info-A6RAGUB7-BIn6JHsL.js} +1 -1
  128. package/dist/assets/{infoDiagram-VRGFBTTK-Cehr7nT0.js → infoDiagram-VRGFBTTK-CLLPPwgt.js} +1 -1
  129. package/dist/assets/{ishikawaDiagram-OU5B5YK6-DNo8VfZy.js → ishikawaDiagram-OU5B5YK6-C0dnEC2O.js} +1 -1
  130. package/dist/assets/{journeyDiagram-ZHPQQLJL-BjQHzZxG.js → journeyDiagram-ZHPQQLJL-DiOFy0_3.js} +1 -1
  131. package/dist/assets/{kanban-definition-PNTS6WVX-CSr7kyll.js → kanban-definition-PNTS6WVX-DL0cY3t2.js} +1 -1
  132. package/dist/assets/{line-CmNgViQc.js → line-CInvdCWV.js} +1 -1
  133. package/dist/assets/{mermaid-parser.core-C_qcrA5s.js → mermaid-parser.core-BGDbqboQ.js} +3 -3
  134. package/dist/assets/{mermaid.core-BG82qqEB.js → mermaid.core-CZVPcJ9v.js} +4 -4
  135. package/dist/assets/{mindmap-definition-NLK3R4M7-psSKkeLb.js → mindmap-definition-NLK3R4M7-U6yboX99.js} +1 -1
  136. package/dist/assets/{packet-AYTQ26CC-BS5zpaST.js → packet-AYTQ26CC-DIDr2_-n.js} +1 -1
  137. package/dist/assets/{pegDiagram-GJSIUBJH-DhpL7ygV.js → pegDiagram-GJSIUBJH-BbpYcKtu.js} +1 -1
  138. package/dist/assets/{pie-WAS4IAKB-T5-dog1P.js → pie-WAS4IAKB-BsD46nmb.js} +1 -1
  139. package/dist/assets/{pieDiagram-5QR66LMP-CmXcDzDT.js → pieDiagram-5QR66LMP-BgdP7pnf.js} +1 -1
  140. package/dist/assets/{quadrantDiagram-O4NWA36T-BIDYLhBi.js → quadrantDiagram-O4NWA36T-mx6H7yGz.js} +1 -1
  141. package/dist/assets/{radar-RG4KPBEZ-_895RNt8.js → radar-RG4KPBEZ-BxzJE1SZ.js} +1 -1
  142. package/dist/assets/{railroad-74A4TZTK-Cz2ZyH7U.js → railroad-74A4TZTK-d2ye0a_H.js} +1 -1
  143. package/dist/assets/railroad-abnf-HS5TGJTU-BJ8b7QD7.js +1 -0
  144. package/dist/assets/railroad-ebnf-LZEXJU2U-YG_Upy_7.js +1 -0
  145. package/dist/assets/railroad-peg-WCYAUIDC-OgQyRk3s.js +1 -0
  146. package/dist/assets/{railroadDiagram-XR7U4H2S-BQ1Mc95v.js → railroadDiagram-XR7U4H2S-CdHoygiZ.js} +1 -1
  147. package/dist/assets/{requirementDiagram-PLB6GJNP-ChVue_nk.js → requirementDiagram-PLB6GJNP-DgUPBmN3.js} +1 -1
  148. package/dist/assets/{sankeyDiagram-IPEJSGJF-Cd9K0txb.js → sankeyDiagram-IPEJSGJF-CCqhFBKm.js} +1 -1
  149. package/dist/assets/{sequenceDiagram-PO4LG4MO-kiPuazKP.js → sequenceDiagram-PO4LG4MO-DsOw9dn3.js} +1 -1
  150. package/dist/assets/{stateDiagram-v2-GCMORJYK-BrYqOUDC.js → stateDiagram-v2-GCMORJYK-KgYznRou.js} +1 -1
  151. package/dist/assets/{swimlanes-2SLR337P-BGv93v9F.js → swimlanes-2SLR337P-cJ978qAp.js} +1 -1
  152. package/dist/assets/swimlanesDiagram-TC7HE7FX-BOVOQpWl.js +8 -0
  153. package/dist/assets/{timeline-definition-EJHVYXUP-B2m2999q.js → timeline-definition-EJHVYXUP-DVrpY7-_.js} +1 -1
  154. package/dist/assets/{treeView-Q6P3EWNA-Bp2HlEG5.js → treeView-Q6P3EWNA-C81INZMi.js} +1 -1
  155. package/dist/assets/{treemap-WGGIJYW6-BBhRxIiH.js → treemap-WGGIJYW6-C6m5_3M_.js} +1 -1
  156. package/dist/assets/{usecaseDiagram-POWQR4AR-uFIx7ToF.js → usecaseDiagram-POWQR4AR-MOU_fUIi.js} +1 -1
  157. package/dist/assets/{vennDiagram-UO4OBE2U-BQGzVBTB.js → vennDiagram-UO4OBE2U-DQ03sLpX.js} +1 -1
  158. package/dist/assets/{wardley-WFR3VGLG-BQk6DDg6.js → wardley-WFR3VGLG-D6oOZcfd.js} +1 -1
  159. package/dist/assets/{wardleyDiagram-VNRHLVJA-q-_GY-qU.js → wardleyDiagram-VNRHLVJA-DTu4wPK_.js} +1 -1
  160. package/dist/assets/{xychartDiagram-PMCCYNJV-B8D1QFx-.js → xychartDiagram-PMCCYNJV-BnFJQnAh.js} +1 -1
  161. package/dist/index.html +2 -2
  162. package/package.json +1 -1
  163. package/server/agents/claude-help-probe.ts +73 -0
  164. package/server/agents/claude-permission-modes.ts +45 -0
  165. package/server/agents/rate-limit-probe.spec.ts +19 -0
  166. package/server/agents/rate-limit-probe.ts +6 -6
  167. package/server/backends/collectionActionIndex.spec.ts +40 -0
  168. package/server/backends/collectionActionIndex.ts +18 -0
  169. package/server/backends/collectionActionIndexRoute.ts +17 -0
  170. package/server/backends/collections.ts +1 -1
  171. package/server/backends/filesPage.ts +46 -0
  172. package/server/backends/html.ts +1 -1
  173. package/server/backends/remoteHost/handlers/listSkills.ts +55 -6
  174. package/server/backends/remoteHost/listSkillsSession.spec.ts +84 -0
  175. package/server/backends/remoteHost/pluginSkills.spec.ts +110 -0
  176. package/server/backends/remoteHost/pluginSkills.ts +90 -0
  177. package/server/backends/remoteHost/skillOverrides.ts +7 -2
  178. package/server/backends/sharedApp/context.ts +3 -0
  179. package/server/backends/sharedApp/declare.ts +18 -6
  180. package/server/backends/sharedApp/publish.ts +11 -3
  181. package/server/backends/sharedApp/rosterRemovals.ts +59 -0
  182. package/server/blueprint/collectionSnapshot.ts +87 -0
  183. package/server/blueprint/executor.ts +34 -18
  184. package/server/blueprint/folderSuggestion.ts +31 -0
  185. package/server/blueprint/installer.ts +12 -10
  186. package/server/blueprint/marketRoutes.ts +12 -7
  187. package/server/blueprint/newFolder.ts +50 -0
  188. package/server/blueprint/refused.ts +16 -0
  189. package/server/blueprint/routes.ts +135 -31
  190. package/server/blueprint/samples.ts +4 -10
  191. package/server/blueprint/wiring.ts +9 -1
  192. package/server/blueprint/writeAllOrNone.ts +11 -0
  193. package/server/config/app-config.ts +6 -0
  194. package/server/config/config-body.ts +1 -0
  195. package/server/config/config-routes.ts +1 -1
  196. package/server/config/config-schema.ts +2 -0
  197. package/server/config/dir-config.ts +6 -1
  198. package/server/config/header-config.ts +31 -7
  199. package/server/config/header-context.ts +1 -1
  200. package/server/config/header-resolve.ts +7 -5
  201. package/server/config/worktree-dir-config.ts +1 -1
  202. package/server/files/mdPreviewEmbed.ts +10 -7
  203. package/server/infra/shared-app-tool.ts +24 -5
  204. package/server/routes/app-routes.ts +4 -0
  205. package/server/routes/ws-routes.ts +5 -7
  206. package/server/session/activity-transition.ts +12 -3
  207. package/server/session/lifecycle.ts +18 -10
  208. package/server/session/pty-kill-plan.ts +10 -0
  209. package/server/session/pty-kill.ts +106 -0
  210. package/server/session/pty-spawn.ts +24 -2
  211. package/server/session/spawn-claude.ts +7 -2
  212. package/server/skills/mulmoterminal-header/SKILL.md +25 -1
  213. package/server/skills/mulmoterminal-keys/SKILL.md +9 -1
  214. package/server/skills/mulmoterminal-shared-app/SKILL.md +10 -2
  215. package/dist/assets/architecture-7GRP2DOG-Bbv-2KeL.js +0 -1
  216. package/dist/assets/channel-BHcWpvnw.js +0 -1
  217. package/dist/assets/eventmodeling-NTZA5JFV-9UzRuTrz.js +0 -1
  218. package/dist/assets/flowDiagram-KWPJA3E3-Bq9iQFMs.js +0 -1
  219. package/dist/assets/index-BHeY7dDr.css +0 -1
  220. package/dist/assets/railroad-abnf-HS5TGJTU-CTBfwbDI.js +0 -1
  221. package/dist/assets/railroad-ebnf-LZEXJU2U-BxMnP09p.js +0 -1
  222. package/dist/assets/railroad-peg-WCYAUIDC-VQLBtQ7q.js +0 -1
  223. package/dist/assets/swimlanesDiagram-TC7HE7FX-XHEHPbk9.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
@@ -704,7 +705,7 @@ the `claude` / `codex` sessions themselves.
704
705
  | `CLIENT_PORT` | `6856` | Vite dev-server port (dev only: the URL you open with `yarn dev`). |
705
706
  | `CLAUDE_BIN` | `claude` | The Claude Code binary to spawn. On Windows a bare name is resolved on `PATH` before it reaches the PTY layer (which matches file names exactly): to the `.exe` when there is one, otherwise to the `.cmd` shim an npm-global install leaves, run through `cmd.exe`. |
706
707
  | `CLAUDE_CWD` | current dir | Working directory each `claude` PTY runs in; determines which project's sessions are listed. Via `npx mulmoterminal@latest` it defaults to the directory you ran the command from (override with `--cwd <dir>`, relative allowed); when the server is run directly it falls back to `~/mulmoclaude`. A value read from `.env` must be an absolute path (`~` is not expanded). Running in one of your own project directories does **not** litter it — see the note under the table. |
707
- | `CLAUDE_PERMISSION_MODE` | `auto` | Permission mode passed to each `claude` spawn. |
708
+ | `CLAUDE_PERMISSION_MODE` | `auto` | Permission mode passed to each `claude` spawn. A Claude Code whose `--help` does not list the mode is not started: the cell says so, and for `auto` says to update Claude Code. |
708
709
  | `MT_TITLE_SOURCE` | `transcript` | Where the cell header's AI title comes from. `transcript` reads the title Claude Code writes into its own transcript — no extra process. `headless` restores the old behaviour of summarizing the recent turns with `claude -p`, which costs a model call but follows a session whose topic drifts (Claude's own title is written once and never revised). |
709
710
  | `MT_TITLE_MODEL` | `haiku` | Model used for the cell header's AI title. Only read when `MT_TITLE_SOURCE=headless`. Accepts a `--model` alias or a full model id. |
710
711
  | `CODEX_BIN` | `codex` | The Codex CLI binary to spawn. |
@@ -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,29 @@ 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
+
1300
+ **HTML pages and images show in the pane too.** An `.html` file has a **Preview** like a Markdown
1301
+ file: the page itself, sandboxed as presentHtml pages are — its scripts run on an opaque origin with
1302
+ no fetch/XHR; images and the curated CDN list still load, and so does an image it links beside it,
1303
+ 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
1304
+ rather than as "not text". A path to any of these clicked in terminal output opens in the pane when
1305
+ it is up, so a chart an agent just wrote stays in the grid. They are served only under the workspace
1306
+ or a terminal's directory, like every other file the app hands to the browser.
1307
+
1308
+ **A link in the Preview opens in the pane.** A link to another file (`./b.md`, `../README.md`) is
1309
+ resolved against the document it is in and opens in a new tab — in Preview when it is Markdown — or
1310
+ goes to the tab that file already has. A link that climbs above the pane's folder is not opened and
1311
+ the pane says so; an external link opens in a new browser tab, and a `#heading` link scrolls the
1312
+ document.
1313
+
1290
1314
  The toggle is not the only way in: while a cell is enlarged, **clicking a file path the agent
1291
1315
  printed** opens it here too, rather than in a new tab or full-screen — see
1292
1316
  [Clicking a file path](#clicking-a-file-path).
@@ -1687,7 +1711,7 @@ narrow and tall for one record you are discussing — and each position keeps it
1687
1711
 
1688
1712
  ![Zoom — one agent enlarged, the others as a filmstrip along the bottom](https://raw.githubusercontent.com/receptron/mulmoterminal/main/docs/guide/images/grid-zoom.png)
1689
1713
 
1690
- - **Set a terminal aside** — the moon button in a cell's header **sinks** it: the tile, its
1714
+ - **Set a terminal aside** — the bed button (a person asleep) in a cell's header **sinks** it: the tile, its
1691
1715
  filmstrip thumbnail and its cockpit-roster row all fade, and the working dot stops pulsing.
1692
1716
  The session stays **connected and keeps its whole history** — this is what to reach for
1693
1717
  instead of `/clear`-ing a cell you are done with for now, which resets the conversation just
@@ -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
+ }
@@ -4,13 +4,16 @@
4
4
  "id": "documents",
5
5
  "label": "質問する相手の文書(このフォルダの中のファイルを 1 行に 1 つ)",
6
6
  "why": "答えはこの文書だけから出し、どこに書いてあるかを示す",
7
- "kind": "text"
7
+ "kind": "text",
8
+ "lines": true,
9
+ "pick": "files"
8
10
  },
9
11
  {
10
12
  "id": "questions",
11
13
  "label": "質問(1 行に 1 つ)",
12
14
  "why": "質問ごとに一つ答える",
13
- "kind": "text"
15
+ "kind": "text",
16
+ "lines": true
14
17
  },
15
18
  {
16
19
  "id": "keep",
@@ -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.11 "$@"
@@ -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.11`) 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
@@ -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
  }
@@ -59,12 +59,19 @@
59
59
  "check": "sh \"$BLUEPRINT_BASE/checks/protect.sh\"",
60
60
  "gates": ["billing"]
61
61
  },
62
+ {
63
+ "id": "security",
64
+ "title": "セキュリティ診断",
65
+ "description": "本番に公開する前に、OWASP Top 10:2025 に沿って作ったものを攻撃する側の目で読み、突ける穴を直してエミュレータの試験で確かめ、報告を書く。直したものは開発用に公開し直して、画面が壊れていないことまで確かめる。",
66
+ "skill": "skills/security",
67
+ "check": "sh \"$BLUEPRINT_BASE/checks/security.sh\""
68
+ },
62
69
  {
63
70
  "id": "deploy-production",
64
71
  "title": "本番に公開",
65
72
  "description": "開発用で確かめたものと同じビルドを本番に公開する。",
66
73
  "skill": "skills/deploy-production",
67
- "check": "sh \"$BLUEPRINT_BASE/checks/deploy-check.sh\" prod",
74
+ "check": "sh \"$BLUEPRINT_BASE/checks/deploy-check.sh\" prod && sh \"$BLUEPRINT_BASE/checks/start-here.sh\"",
68
75
  "gates": ["deploy-production"]
69
76
  }
70
77
  ]
@@ -12,8 +12,21 @@ This is the **deploy-production** gate: the user has approved it. Deploy the sam
12
12
  then `firebase deploy --project prod`. The check compares the served id with `.blueprint/build-id`, so it
13
13
  proves THIS build is live. Git is not needed for this.
14
14
  2. Tell the user the URL and that the next change should go through dev first.
15
+ 3. Write `.blueprint/start-here.md`, in the spec's language. It is what the person sees the moment the build
16
+ finishes, so it answers "now what?" in the order they will need it:
17
+ - **Open it** — the production URL (`https://<prod-project-id>.web.app`) and who can sign in, and how.
18
+ - **Check that it works** — one checkbox per must-have in the spec, each an action and what should happen
19
+ (`- [ ] 申請を 1 件出す → 承認者の一覧に出る`), using the app's real screen and button names; one for
20
+ someone who should be refused (another account, a signed-out visitor) being refused.
21
+ - **The next change** — edit, try it on dev (`https://<dev-project-id>.web.app`), then publish to production;
22
+ never straight to production.
23
+ - **Costs and safety** — the budget alert and where it goes; where the security review is
24
+ (`.blueprint/security-review.md`).
15
25
 
16
- Done when the check passes: the production site serves the id this deploy wrote to `.blueprint/build-id`.
26
+ Keep it short: it is a first page, not the manual.
27
+
28
+ Done when the check passes: the production site serves the id this deploy wrote to `.blueprint/build-id`, and
29
+ `.blueprint/start-here.md` names the production URL and has the checklist.
17
30
 
18
31
  ## Always
19
32
 
@@ -0,0 +1,80 @@
1
+ ---
2
+ name: blueprint-firebase-security
3
+ description: "Before production, review the app against OWASP Top 10:2025, fix what is exploitable, prove it on the emulators, and redeploy dev."
4
+ ---
5
+
6
+ # Security review
7
+
8
+ Everything is built and runs on dev. Before production, review it the way an attacker would read it, fix what
9
+ is exploitable, and leave a report the person can read. On Firebase the attacker does not need the screen: the
10
+ web config is public, so anyone can call Firestore and the callable functions directly with their own client.
11
+ The rules and the functions are the whole defence.
12
+
13
+ ## Method
14
+
15
+ 1. **Context.** Read `.blueprint/spec.md` (above all "必ず詰める点" and the access table), then `firestore.rules`,
16
+ `functions/src/`, `firebase.json` and the web app's source. Note who may do what and where input enters.
17
+ 2. **Audit, category by category** (the list below). For each rule and each callable, ask what a signed-in
18
+ stranger, a signed-out visitor and a user of another role can do with a hand-written request.
19
+ 3. **Fix** every HIGH and MEDIUM finding, and prove it in `test/blueprint/security.spec.ts` against the
20
+ emulators: the attack is refused (`assertFails`, or the callable throwing) and the legitimate use still works.
21
+ 4. **Report** in `.blueprint/security-review.md` (format below).
22
+ 5. **Redeploy dev** the way the deploy-dev step does (new `.blueprint/build-id`, `firebase deploy --project dev`),
23
+ even when nothing changed, so production ships exactly what dev now runs. Do not deploy to `prod`.
24
+
25
+ Severity: **HIGH** is directly exploitable (read or change someone's data, bypass sign-in or a role, run code).
26
+ **MEDIUM** needs a specific condition but the impact is real. **LOW** is defence in depth. Report a finding only
27
+ when you can state how it is exploited and you are at least 70% sure; a guess is not a finding. Do not report
28
+ denial of service, rate limits, quota exhaustion, or missing validation on a field that cannot cause harm.
29
+
30
+ ## What to look at in this app (OWASP Top 10:2025)
31
+
32
+ - **A01 Broken Access Control** — every `match` opens only what the access table says; ownership and role come
33
+ from `request.auth` (custom claims), never from a field the client wrote; `list` queries cannot read past what
34
+ `get` allows; every callable checks `context.auth` and the role itself.
35
+ - **A02 Security Misconfiguration** — the rules end in deny-all; Hosting sends `Content-Security-Policy` (with
36
+ `frame-ancestors 'none'`, and `connect-src` / `script-src` listing exactly the Firebase and Google hosts the
37
+ app uses), `X-Content-Type-Options: nosniff` and `Referrer-Policy`; App Check enforced; no emulator setting
38
+ reaches the production build.
39
+ - **A03 Software Supply Chain Failures** — `yarn audit` clean of high and critical at the root and in
40
+ `functions/` (install `functions/` with yarn); no dependency the app does not use.
41
+ - **A04 Cryptographic Failures** — no service account key, private key or admin credential anywhere in the
42
+ project or the built site; secrets for functions in Secret Manager, not in code or `.env` that is deployed.
43
+ - **A05 Injection** — the screen never renders stored text as HTML (`v-html` only on text the app wrote);
44
+ functions build no query, URL, shell command or file path from input without checking it.
45
+ - **A06 Insecure Design** — the spec's rules (limits, states, who may change what) are in the rules or the
46
+ functions, not only in the screen; field lists pinned with `keys().hasOnly` and `affectedKeys().hasOnly`.
47
+ - **A07 Authentication Failures** — only the sign-in providers the spec names are enabled; a signed-out or
48
+ unverified user gets nothing the spec does not allow them.
49
+ - **A08 Software or Data Integrity Failures** — roles and privileges are set only by functions (custom claims),
50
+ never by a document a user can write; uploaded files are checked by Storage rules for type and size.
51
+ - **A09 Security Logging and Alerting Failures** — privileged actions and refusals in functions are logged
52
+ without tokens or personal data beyond what the spec needs.
53
+ - **A10 Mishandling of Exceptional Conditions** — callables throw `HttpsError` with a short message, never a
54
+ stack or an internal error text; a failed check refuses rather than lets through.
55
+
56
+ ## The report
57
+
58
+ `.blueprint/security-review.md`, in the spec's language. One section per category, its heading naming the id
59
+ (`## A01 …` through `## A10 …`), each with what you checked and every finding on a line of its own:
60
+
61
+ ```
62
+ - HIGH fixed: <what was wrong, how it could be exploited> — <what changed, which test proves it>
63
+ - LOW accepted: <what, and why it is acceptable for this app>
64
+ ```
65
+
66
+ The state is `fixed`, `open` or `accepted`. A HIGH or MEDIUM may not be `open` or `accepted`: fix it, or ask
67
+ through the blueprint question tool if fixing it would change what the spec asks for. A category with nothing
68
+ to report says what was checked and "指摘なし". Write the report before step 5, so the deploy comes after it.
69
+
70
+ Done when the check passes: the report covers A01–A10 with nothing HIGH or MEDIUM left open, the rules end in
71
+ deny-all, Hosting sends the headers, no private key is in the project, both audits are clean,
72
+ `emulator-test.sh security` passes, and dev serves a build deployed after the report and still renders.
73
+
74
+ ## Always
75
+
76
+ - Read `.blueprint/spec.md` first. It is the agreed specification; do not widen it.
77
+ - When you need a decision, ask it through the blueprint question tool and stop. Do not guess.
78
+ - Never enable billing, deploy to production, delete data or handle a credential unless this step's gates say the
79
+ user approved exactly that.
80
+ - Say you are done by stopping; the executor runs the check. Do not claim success yourself.
@@ -0,0 +1,26 @@
1
+ #!/bin/sh
2
+ # The spec carries the whole source over: every field, view, action and ingest of every copied collection is named in it
3
+ # by a token that says which collection it belongs to — `books.title`, `books.views.board`, `books.actions.tidy`,
4
+ # `books.ingest` — so nothing is dropped without a person seeing it go. A bare key is not enough: two collections
5
+ # share keys (`id`, `name`), and one of them would vouch for the other.
6
+ set -eu
7
+ [ -s .blueprint/source/source.json ] || { echo "missing .blueprint/source/source.json; the build was started without a collection" >&2; exit 1; }
8
+ node -e '
9
+ const fs = require("node:fs");
10
+ const spec = fs.readFileSync(".blueprint/spec.md", "utf8");
11
+ const source = JSON.parse(fs.readFileSync(".blueprint/source/source.json", "utf8"));
12
+ const missing = source.collections.flatMap((slug) => {
13
+ const schema = JSON.parse(fs.readFileSync(`.blueprint/source/collections/${slug}/schema.json`, "utf8"));
14
+ const tokens = [
15
+ ...Object.keys(schema.fields ?? {}).map((key) => `${slug}.${key}`),
16
+ ...(schema.views ?? []).map((view) => `${slug}.views.${view.id}`),
17
+ ...[...(schema.actions ?? []), ...(schema.collectionActions ?? [])].map((action) => `${slug}.actions.${action.id}`),
18
+ ...(schema.ingest ? [`${slug}.ingest`] : []),
19
+ ];
20
+ return tokens.filter((token) => !spec.includes("`" + token + "`"));
21
+ });
22
+ if (missing.length > 0) {
23
+ console.error("the spec does not carry these over from the source (name each in backquotes, as `collection.key`):\n" + missing.join("\n"));
24
+ process.exit(1);
25
+ }
26
+ '