talos-code 0.0.1 → 0.1.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 (401) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +111 -3
  3. package/THIRD_PARTY_NOTICES.md +40 -0
  4. package/dist/archive/zip.js +73 -0
  5. package/dist/args.js +97 -0
  6. package/dist/automations/history-store.js +34 -0
  7. package/dist/automations/policy-store.js +43 -0
  8. package/dist/automations/runner.js +107 -0
  9. package/dist/automations/schedule.js +85 -0
  10. package/dist/automations/windows-task.js +53 -0
  11. package/dist/commands/advanced-cli.js +233 -0
  12. package/dist/commands/checkpoint-cli.js +45 -0
  13. package/dist/commands/config-cli.js +107 -0
  14. package/dist/commands/context.js +17 -0
  15. package/dist/commands/extensions-cli.js +207 -0
  16. package/dist/commands/project-cli.js +74 -0
  17. package/dist/commands/project-commands.js +143 -0
  18. package/dist/commands/provider-cli.js +185 -0
  19. package/dist/commands/services-cli.js +248 -0
  20. package/dist/commands/session-cli.js +171 -0
  21. package/dist/commands/system-cli.js +405 -0
  22. package/dist/config/commands.js +60 -0
  23. package/dist/config/load.js +382 -0
  24. package/dist/config/migrations.js +48 -0
  25. package/dist/config/types.js +11 -0
  26. package/dist/diagnostics/development-log.js +154 -0
  27. package/dist/diagnostics/doctor.js +104 -0
  28. package/dist/diagnostics/redact.js +80 -0
  29. package/dist/diagnostics/zip.js +52 -0
  30. package/dist/errors.js +156 -0
  31. package/dist/events/bridge.js +130 -0
  32. package/dist/extensions/installer.js +178 -0
  33. package/dist/extensions/package-schema.js +24 -0
  34. package/dist/headless/result.js +84 -0
  35. package/dist/headless/run.js +230 -0
  36. package/dist/i18n/en/approval.js +43 -0
  37. package/dist/i18n/en/common.js +9 -0
  38. package/dist/i18n/en/credentials.js +82 -0
  39. package/dist/i18n/en/errors.js +246 -0
  40. package/dist/i18n/en/firstrun.js +89 -0
  41. package/dist/i18n/en/providers.js +37 -0
  42. package/dist/i18n/en/screen.js +736 -0
  43. package/dist/i18n/en/tools.js +127 -0
  44. package/dist/i18n/en.js +14 -0
  45. package/dist/i18n/error-view.js +307 -0
  46. package/dist/i18n/index.js +19 -0
  47. package/dist/i18n/kernel-map.js +139 -0
  48. package/dist/io.js +45 -0
  49. package/dist/main.js +330 -0
  50. package/dist/paths.js +41 -0
  51. package/dist/protocol/v2/codec.js +9 -0
  52. package/dist/protocol/v2/events.js +555 -0
  53. package/dist/protocol/v2/index.js +4 -0
  54. package/dist/protocol/v2/replay.js +28 -0
  55. package/dist/protocol/v2/types.js +1 -0
  56. package/dist/provider/control-plane.js +135 -0
  57. package/dist/provider/environment-keys.js +275 -0
  58. package/dist/provider/health.js +110 -0
  59. package/dist/provider/missing-key.js +48 -0
  60. package/dist/provider/model-catalog.js +168 -0
  61. package/dist/provider/provider-text.js +13 -0
  62. package/dist/provider/store.js +95 -0
  63. package/dist/provider/system-keyring.js +214 -0
  64. package/dist/runtime/active-run.js +63 -0
  65. package/dist/runtime/agent-tree.js +74 -0
  66. package/dist/runtime/attachments.js +84 -0
  67. package/dist/runtime/brokered-executor.js +395 -0
  68. package/dist/runtime/context-status.js +76 -0
  69. package/dist/runtime/create-runtime.js +111 -0
  70. package/dist/runtime/model-profile.js +361 -0
  71. package/dist/runtime/output-store.js +137 -0
  72. package/dist/runtime/provider-attempts.js +185 -0
  73. package/dist/runtime/replay-buffer.js +153 -0
  74. package/dist/runtime/repo.js +95 -0
  75. package/dist/runtime/session-facade.js +135 -0
  76. package/dist/runtime/session-summary.js +125 -0
  77. package/dist/runtime/supervisor.js +262 -0
  78. package/dist/runtime/talos-composition.js +1006 -0
  79. package/dist/runtime/types.js +5 -0
  80. package/dist/runtime/usage-snapshot.js +82 -0
  81. package/dist/security/auto-classifier.js +38 -0
  82. package/dist/security/credential-free-environment.js +54 -0
  83. package/dist/security/evaluate.js +243 -0
  84. package/dist/security/execution-backends.js +236 -0
  85. package/dist/security/execution-broker.js +102 -0
  86. package/dist/security/forge-scan.js +74 -0
  87. package/dist/security/from-approval.js +39 -0
  88. package/dist/security/mxc-execution-backend.js +281 -0
  89. package/dist/security/permission-engine.js +138 -0
  90. package/dist/security/permission-explanation.js +54 -0
  91. package/dist/security/persist.js +179 -0
  92. package/dist/security/plugin-guard.js +230 -0
  93. package/dist/security/process-tree-evidence-store.js +74 -0
  94. package/dist/security/process-tree-probe.js +554 -0
  95. package/dist/security/project-resource-inventory.js +186 -0
  96. package/dist/security/project-trust-gate.js +38 -0
  97. package/dist/security/project-trust.js +367 -0
  98. package/dist/security/rule-parser.js +201 -0
  99. package/dist/security/shell-segmentation.js +68 -0
  100. package/dist/security/trust-authority.js +324 -0
  101. package/dist/security/types.js +1 -0
  102. package/dist/security/workspace-identity.js +102 -0
  103. package/dist/services/automation-facade.js +59 -0
  104. package/dist/services/forge-facade.js +77 -0
  105. package/dist/services/hook-facade.js +240 -0
  106. package/dist/services/index.js +16 -0
  107. package/dist/services/library-facade.js +174 -0
  108. package/dist/services/mcp-facade.js +348 -0
  109. package/dist/services/memory-facade.js +126 -0
  110. package/dist/services/notes-facade.js +157 -0
  111. package/dist/services/plugin-facade.js +308 -0
  112. package/dist/services/research-facade.js +110 -0
  113. package/dist/services/task-board-facade.js +236 -0
  114. package/dist/services/workflow-catalog.js +246 -0
  115. package/dist/sessions/export-format.js +98 -0
  116. package/dist/sessions/metadata-store.js +160 -0
  117. package/dist/sessions/share.js +122 -0
  118. package/dist/sessions/transfer.js +16 -0
  119. package/dist/subcommands.js +62 -0
  120. package/dist/tui/agent-roster.js +36 -0
  121. package/dist/tui/app.js +3761 -0
  122. package/dist/tui/approval.js +36 -0
  123. package/dist/tui/boot/boot-sequence.js +76 -0
  124. package/dist/tui/boot/cinematic.js +243 -0
  125. package/dist/tui/boot/desktop-logo.js +82 -0
  126. package/dist/tui/boot.js +3 -0
  127. package/dist/tui/busy-input.js +58 -0
  128. package/dist/tui/catalog-service.js +559 -0
  129. package/dist/tui/components/assistant-stream.js +1 -0
  130. package/dist/tui/components/command-menu.js +68 -0
  131. package/dist/tui/components/composer.js +219 -0
  132. package/dist/tui/components/diff.js +35 -0
  133. package/dist/tui/components/footer.js +48 -0
  134. package/dist/tui/components/header.js +6 -0
  135. package/dist/tui/components/markdown.js +305 -0
  136. package/dist/tui/components/status-indicator.js +32 -0
  137. package/dist/tui/components/terminal-shell.js +205 -0
  138. package/dist/tui/components/thinking-row.js +19 -0
  139. package/dist/tui/components/tool-row.js +97 -0
  140. package/dist/tui/components/transcript-virtualizer.js +165 -0
  141. package/dist/tui/components/transcript.js +43 -0
  142. package/dist/tui/diff-model.js +77 -0
  143. package/dist/tui/editor-history.js +21 -0
  144. package/dist/tui/editor.js +210 -0
  145. package/dist/tui/event-adapter.js +436 -0
  146. package/dist/tui/exit-output.js +58 -0
  147. package/dist/tui/external-editor.js +53 -0
  148. package/dist/tui/file-completion.js +89 -0
  149. package/dist/tui/focus-manager.js +5 -0
  150. package/dist/tui/highlight.js +30 -0
  151. package/dist/tui/input-router.js +18 -0
  152. package/dist/tui/interrupt.js +99 -0
  153. package/dist/tui/keybindings.js +194 -0
  154. package/dist/tui/keymap-resolver.js +91 -0
  155. package/dist/tui/launch-state.js +139 -0
  156. package/dist/tui/line-diff.js +57 -0
  157. package/dist/tui/live-activity.js +45 -0
  158. package/dist/tui/metrics.js +123 -0
  159. package/dist/tui/onboarding.js +36 -0
  160. package/dist/tui/overlays/agent-tree.js +39 -0
  161. package/dist/tui/overlays/approval-dialog.js +640 -0
  162. package/dist/tui/overlays/automation-center.js +51 -0
  163. package/dist/tui/overlays/checkpoint-picker.js +47 -0
  164. package/dist/tui/overlays/context-inspector.js +36 -0
  165. package/dist/tui/overlays/effort-line.js +110 -0
  166. package/dist/tui/overlays/forge-center.js +46 -0
  167. package/dist/tui/overlays/help-dialog.js +16 -0
  168. package/dist/tui/overlays/history-picker.js +37 -0
  169. package/dist/tui/overlays/hook-center.js +70 -0
  170. package/dist/tui/overlays/library-center.js +55 -0
  171. package/dist/tui/overlays/mcp-center.js +60 -0
  172. package/dist/tui/overlays/memory-center.js +63 -0
  173. package/dist/tui/overlays/model-picker.js +72 -0
  174. package/dist/tui/overlays/notes-center.js +53 -0
  175. package/dist/tui/overlays/overlay-host.js +8 -0
  176. package/dist/tui/overlays/plugin-center.js +76 -0
  177. package/dist/tui/overlays/provider-picker.js +145 -0
  178. package/dist/tui/overlays/queue-editor.js +32 -0
  179. package/dist/tui/overlays/research-center.js +59 -0
  180. package/dist/tui/overlays/scroll-window.js +234 -0
  181. package/dist/tui/overlays/session-picker.js +115 -0
  182. package/dist/tui/overlays/tasks-center.js +83 -0
  183. package/dist/tui/overlays/theme-picker.js +21 -0
  184. package/dist/tui/overlays/transcript-search.js +58 -0
  185. package/dist/tui/overlays/trust-center.js +29 -0
  186. package/dist/tui/overlays/workflow-center.js +46 -0
  187. package/dist/tui/project-file-index.js +214 -0
  188. package/dist/tui/project-references.js +85 -0
  189. package/dist/tui/project-trust-prompt.js +287 -0
  190. package/dist/tui/prompt-history-store.js +116 -0
  191. package/dist/tui/queue-store.js +110 -0
  192. package/dist/tui/regions/budget.js +59 -0
  193. package/dist/tui/regions/views.js +96 -0
  194. package/dist/tui/render-coordinator.js +148 -0
  195. package/dist/tui/render-scheduler.js +4 -0
  196. package/dist/tui/run.js +69 -0
  197. package/dist/tui/selection-list.js +29 -0
  198. package/dist/tui/session-controller.js +778 -0
  199. package/dist/tui/session-export.js +54 -0
  200. package/dist/tui/shell-input.js +35 -0
  201. package/dist/tui/shell-layout.js +48 -0
  202. package/dist/tui/shell-model.js +98 -0
  203. package/dist/tui/slash-commands.js +80 -0
  204. package/dist/tui/state.js +151 -0
  205. package/dist/tui/status-bar.js +125 -0
  206. package/dist/tui/status-view.js +40 -0
  207. package/dist/tui/terminal-capabilities.js +9 -0
  208. package/dist/tui/terminal-session.js +11 -0
  209. package/dist/tui/text-width.js +66 -0
  210. package/dist/tui/theme-catalog.js +25 -0
  211. package/dist/tui/theme-store.js +23 -0
  212. package/dist/tui/theme.js +23 -0
  213. package/dist/tui/tool-display.js +135 -0
  214. package/dist/tui/tool-facts.js +1 -0
  215. package/dist/tui/tools/bash-renderer.js +32 -0
  216. package/dist/tui/tools/change.js +61 -0
  217. package/dist/tui/tools/edit-renderer.js +17 -0
  218. package/dist/tui/tools/generic-renderer.js +13 -0
  219. package/dist/tui/tools/list-renderer.js +14 -0
  220. package/dist/tui/tools/read-renderer.js +13 -0
  221. package/dist/tui/tools/registry.js +20 -0
  222. package/dist/tui/tools/row-format.js +187 -0
  223. package/dist/tui/tools/search-renderer.js +38 -0
  224. package/dist/tui/tools/shared.js +97 -0
  225. package/dist/tui/tools/write-renderer.js +15 -0
  226. package/dist/tui/transcript-model.js +441 -0
  227. package/dist/tui/ui-preferences.js +79 -0
  228. package/dist/tui/usage-view.js +77 -0
  229. package/dist/tui/vim-mode.js +99 -0
  230. package/dist/update/check.js +15 -0
  231. package/dist/update/npm.js +51 -0
  232. package/dist/update/run.js +129 -0
  233. package/dist/version.js +2 -0
  234. package/dist/workspace/checkpoint-store.js +210 -0
  235. package/dist/workspace/checkpoint.js +413 -0
  236. package/dist/workspace/restore.js +232 -0
  237. package/package.json +63 -5
  238. package/vendor/context-engine/package.json +11 -0
  239. package/vendor/context-engine/src/compaction-planner.mjs +57 -0
  240. package/vendor/context-engine/src/contracts.mjs +48 -0
  241. package/vendor/context-engine/src/engine.mjs +445 -0
  242. package/vendor/context-engine/src/node/context-export.mjs +164 -0
  243. package/vendor/context-engine/src/node/legacy-import.mjs +92 -0
  244. package/vendor/context-engine/src/node/migrations/001-context.sql +126 -0
  245. package/vendor/context-engine/src/node/sqlite-store.mjs +85 -0
  246. package/vendor/context-engine/src/node/sqlite-worker.mjs +559 -0
  247. package/vendor/context-engine/src/profiles.mjs +10 -0
  248. package/vendor/context-engine/src/retrieval.mjs +104 -0
  249. package/vendor/context-engine/src/summary.mjs +97 -0
  250. package/vendor/context-engine/src/usage.mjs +34 -0
  251. package/vendor/harness-ui/package.json +38 -0
  252. package/vendor/harness-ui/src/acp-agent.mjs +350 -0
  253. package/vendor/harness-ui/src/agent-service.mjs +2188 -0
  254. package/vendor/harness-ui/src/agui-events.mjs +452 -0
  255. package/vendor/harness-ui/src/ambiente-solo-server.mjs +121 -0
  256. package/vendor/harness-ui/src/artifact-store.mjs +39 -0
  257. package/vendor/harness-ui/src/assistenza.mjs +123 -0
  258. package/vendor/harness-ui/src/automation-scheduler.mjs +69 -0
  259. package/vendor/harness-ui/src/automation-store.mjs +145 -0
  260. package/vendor/harness-ui/src/browser-annota.mjs +639 -0
  261. package/vendor/harness-ui/src/browser-frame.mjs +211 -0
  262. package/vendor/harness-ui/src/browser-proxy-universale.mjs +519 -0
  263. package/vendor/harness-ui/src/browser-proxy.mjs +87 -0
  264. package/vendor/harness-ui/src/browser-sessione-viva.mjs +329 -0
  265. package/vendor/harness-ui/src/browser-stream.mjs +445 -0
  266. package/vendor/harness-ui/src/browser-vivo.mjs +694 -0
  267. package/vendor/harness-ui/src/chat-image-attachments.mjs +72 -0
  268. package/vendor/harness-ui/src/config.mjs +697 -0
  269. package/vendor/harness-ui/src/contesto-del-progetto.mjs +385 -0
  270. package/vendor/harness-ui/src/context-asset-adapter.mjs +72 -0
  271. package/vendor/harness-ui/src/context-desktop-service.mjs +253 -0
  272. package/vendor/harness-ui/src/context-embedding-runtime.mjs +252 -0
  273. package/vendor/harness-ui/src/context-inference-scheduler.mjs +81 -0
  274. package/vendor/harness-ui/src/context-native-compaction.mjs +75 -0
  275. package/vendor/harness-ui/src/context-provider-adapter.mjs +141 -0
  276. package/vendor/harness-ui/src/context-runtime.mjs +118 -0
  277. package/vendor/harness-ui/src/context-token-counters.mjs +184 -0
  278. package/vendor/harness-ui/src/context-tool-catalog.mjs +86 -0
  279. package/vendor/harness-ui/src/context-tool-output.mjs +72 -0
  280. package/vendor/harness-ui/src/costo-elenco.mjs +252 -0
  281. package/vendor/harness-ui/src/custom-task.mjs +171 -0
  282. package/vendor/harness-ui/src/doctor.mjs +142 -0
  283. package/vendor/harness-ui/src/document-filename.mjs +97 -0
  284. package/vendor/harness-ui/src/document-generator.mjs +493 -0
  285. package/vendor/harness-ui/src/document-report.mjs +331 -0
  286. package/vendor/harness-ui/src/duckduckgo-search.mjs +155 -0
  287. package/vendor/harness-ui/src/elenco-profondo.mjs +337 -0
  288. package/vendor/harness-ui/src/favicon-proxy.mjs +113 -0
  289. package/vendor/harness-ui/src/forge-contract.mjs +221 -0
  290. package/vendor/harness-ui/src/frequent-dirs.mjs +134 -0
  291. package/vendor/harness-ui/src/generated-image-store.mjs +147 -0
  292. package/vendor/harness-ui/src/generation-idle.mjs +332 -0
  293. package/vendor/harness-ui/src/gguf-header.mjs +207 -0
  294. package/vendor/harness-ui/src/git-service.mjs +626 -0
  295. package/vendor/harness-ui/src/gitignore-elenco.mjs +604 -0
  296. package/vendor/harness-ui/src/harness-receipt-keypair.mjs +207 -0
  297. package/vendor/harness-ui/src/hf-direct-transfer.mjs +170 -0
  298. package/vendor/harness-ui/src/hf-hub-client.mjs +106 -0
  299. package/vendor/harness-ui/src/hf-image-proxy.mjs +105 -0
  300. package/vendor/harness-ui/src/hf-model-transfer.mjs +245 -0
  301. package/vendor/harness-ui/src/hook-registry.mjs +186 -0
  302. package/vendor/harness-ui/src/http-app.mjs +6259 -0
  303. package/vendor/harness-ui/src/http-lifecycle.mjs +132 -0
  304. package/vendor/harness-ui/src/id-archivio.mjs +27 -0
  305. package/vendor/harness-ui/src/image-generator.mjs +143 -0
  306. package/vendor/harness-ui/src/istruzioni-di-progetto.mjs +234 -0
  307. package/vendor/harness-ui/src/kernel/dist/kernelPerIlBanco.js +518 -0
  308. package/vendor/harness-ui/src/kernel/talosHarness.mjs +10437 -0
  309. package/vendor/harness-ui/src/library-policy-store.mjs +175 -0
  310. package/vendor/harness-ui/src/library-store.mjs +652 -0
  311. package/vendor/harness-ui/src/llama-server-supervisor.mjs +629 -0
  312. package/vendor/harness-ui/src/local-model-store.mjs +339 -0
  313. package/vendor/harness-ui/src/local-runtime-contract.mjs +66 -0
  314. package/vendor/harness-ui/src/local-runtime-events.mjs +44 -0
  315. package/vendor/harness-ui/src/local-runtime-llama-server.mjs +244 -0
  316. package/vendor/harness-ui/src/local-runtime-probe.mjs +401 -0
  317. package/vendor/harness-ui/src/machine-capacity.mjs +66 -0
  318. package/vendor/harness-ui/src/mappa-cartelle.mjs +491 -0
  319. package/vendor/harness-ui/src/mcp-client.mjs +98 -0
  320. package/vendor/harness-ui/src/mcp-registry.mjs +157 -0
  321. package/vendor/harness-ui/src/mcp-session.mjs +177 -0
  322. package/vendor/harness-ui/src/memory-store.mjs +227 -0
  323. package/vendor/harness-ui/src/model-catalog-models-dev.mjs +276 -0
  324. package/vendor/harness-ui/src/model-catalog.mjs +129 -0
  325. package/vendor/harness-ui/src/model-destination.mjs +189 -0
  326. package/vendor/harness-ui/src/modifica-ancorata.mjs +177 -0
  327. package/vendor/harness-ui/src/native-provider-adapter.mjs +205 -0
  328. package/vendor/harness-ui/src/notes-store.mjs +250 -0
  329. package/vendor/harness-ui/src/openai-compatible-runtime.mjs +428 -0
  330. package/vendor/harness-ui/src/openrouter-oauth.mjs +339 -0
  331. package/vendor/harness-ui/src/path-policy.mjs +442 -0
  332. package/vendor/harness-ui/src/plugin-registry.mjs +780 -0
  333. package/vendor/harness-ui/src/plugin-session.mjs +180 -0
  334. package/vendor/harness-ui/src/process-policy.mjs +345 -0
  335. package/vendor/harness-ui/src/prompt-enhancer-provider.mjs +94 -0
  336. package/vendor/harness-ui/src/provider-auth-cloud.mjs +95 -0
  337. package/vendor/harness-ui/src/provider-credential-store.mjs +541 -0
  338. package/vendor/harness-ui/src/provider-probe.mjs +582 -0
  339. package/vendor/harness-ui/src/provider-registry.mjs +1633 -0
  340. package/vendor/harness-ui/src/pty-terminal.mjs +312 -0
  341. package/vendor/harness-ui/src/public-problem.mjs +109 -0
  342. package/vendor/harness-ui/src/research/card.mjs +235 -0
  343. package/vendor/harness-ui/src/research/citations.mjs +142 -0
  344. package/vendor/harness-ui/src/research/collector.mjs +275 -0
  345. package/vendor/harness-ui/src/research/deposito-a-pezzi.mjs +139 -0
  346. package/vendor/harness-ui/src/research/dossier.mjs +114 -0
  347. package/vendor/harness-ui/src/research/esportazioni.mjs +560 -0
  348. package/vendor/harness-ui/src/research/fetch-cache.mjs +465 -0
  349. package/vendor/harness-ui/src/research/fidelity.mjs +122 -0
  350. package/vendor/harness-ui/src/research/independence.mjs +159 -0
  351. package/vendor/harness-ui/src/research/ledger.mjs +166 -0
  352. package/vendor/harness-ui/src/research/markdown-server.mjs +565 -0
  353. package/vendor/harness-ui/src/research/narration.mjs +181 -0
  354. package/vendor/harness-ui/src/research/open-cards.mjs +131 -0
  355. package/vendor/harness-ui/src/research/opposing.mjs +305 -0
  356. package/vendor/harness-ui/src/research/outline.mjs +111 -0
  357. package/vendor/harness-ui/src/research/page-budget.mjs +209 -0
  358. package/vendor/harness-ui/src/research/pdf.mjs +291 -0
  359. package/vendor/harness-ui/src/research/plan.mjs +301 -0
  360. package/vendor/harness-ui/src/research/raccolta-viva.mjs +452 -0
  361. package/vendor/harness-ui/src/research/recheck-document.mjs +69 -0
  362. package/vendor/harness-ui/src/research/recheck-history.mjs +192 -0
  363. package/vendor/harness-ui/src/research/recheck.mjs +194 -0
  364. package/vendor/harness-ui/src/research/report.mjs +203 -0
  365. package/vendor/harness-ui/src/research/run.mjs +527 -0
  366. package/vendor/harness-ui/src/research/synthesis.mjs +318 -0
  367. package/vendor/harness-ui/src/research/verification.mjs +572 -0
  368. package/vendor/harness-ui/src/research-orchestrator.mjs +2679 -0
  369. package/vendor/harness-ui/src/research-store.mjs +1133 -0
  370. package/vendor/harness-ui/src/runtime-build-manifest.mjs +26 -0
  371. package/vendor/harness-ui/src/runtime-contract.mjs +59 -0
  372. package/vendor/harness-ui/src/runtime-owner-adapter.mjs +1348 -0
  373. package/vendor/harness-ui/src/runtime-owner-contract.mjs +32 -0
  374. package/vendor/harness-ui/src/scheda-di-lavoro.mjs +249 -0
  375. package/vendor/harness-ui/src/search-source-store.mjs +172 -0
  376. package/vendor/harness-ui/src/session-registry.mjs +6095 -0
  377. package/vendor/harness-ui/src/session-store.mjs +220 -0
  378. package/vendor/harness-ui/src/sessione-pronta.mjs +73 -0
  379. package/vendor/harness-ui/src/setup-stato.mjs +31 -0
  380. package/vendor/harness-ui/src/sezioni-istruzioni.mjs +204 -0
  381. package/vendor/harness-ui/src/skill-registry.mjs +120 -0
  382. package/vendor/harness-ui/src/sse-replay-coalescente.mjs +0 -0
  383. package/vendor/harness-ui/src/static-files.mjs +96 -0
  384. package/vendor/harness-ui/src/stream-partition.mjs +123 -0
  385. package/vendor/harness-ui/src/subagent-orchestrator.mjs +453 -0
  386. package/vendor/harness-ui/src/task-catalog.mjs +65 -0
  387. package/vendor/harness-ui/src/tasks-store.mjs +220 -0
  388. package/vendor/harness-ui/src/terminal-registry.mjs +312 -0
  389. package/vendor/harness-ui/src/terminal-ws.mjs +170 -0
  390. package/vendor/harness-ui/src/tool-forge-store.mjs +299 -0
  391. package/vendor/harness-ui/src/tool-schema-normalize.mjs +100 -0
  392. package/vendor/harness-ui/src/usage-cache.mjs +315 -0
  393. package/vendor/harness-ui/src/workspace-browser.mjs +213 -0
  394. package/vendor/harness-ui/src/workspace-context.mjs +124 -0
  395. package/vendor/harness-ui/src/workspace-disk.mjs +62 -0
  396. package/vendor/harness-ui/src/workspace-files.mjs +589 -0
  397. package/vendor/harness-ui/src/workspace-info.mjs +189 -0
  398. package/vendor/harness-ui/src/workspace-launch-store.mjs +150 -0
  399. package/vendor/harness-ui/src/workspace-tree.mjs +67 -0
  400. package/vendor/harness-ui/src/workspace-watcher.mjs +161 -0
  401. package/vendor/manifest.json +170 -0
@@ -0,0 +1,1133 @@
1
+ /**
2
+ * research-store.mjs — FASE N, ottavo sistema, "Fetta onesta" di Deep
3
+ * Research (owner, AskUserQuestion 30/8: "Fetta onesta (consigliato)").
4
+ * Letto alla fonte PRIMA di disegnare questa fetta: `mobile/src/lib/
5
+ * research/*` (21 file, macchina a stati event-sourced — `researchRun.ts`
6
+ * — con verifica/indipendenza/citazioni/approvazione piano) e il
7
+ * CONTRATTO vero del tool, `mobile/src/lib/tools/researchTools.ts` (8
8
+ * tool, letti verbatim, non presunti). L'owner ha scelto esplicitamente
9
+ * la fetta scoperta: stesso CONTRATTO (nomi/schema/semantica dei tool),
10
+ * esecuzione riusando la macchina GIÀ COSTRUITA di Harness Desktop
11
+ * (talosLavora via avviaESegui, l'abort già usato da ferma() per la
12
+ * pausa, resume() per la ripresa, library-store.mjs per il rapporto
13
+ * finale) invece del motore event-sourced mobile (pianificazione a più
14
+ * linee di indagine, verifica/indipendenza/citazioni/approvazione piano)
15
+ * — dichiarato debito, non perso in silenzio.
16
+ *
17
+ * ⭐⭐⭐ Deep Research è PER-PROGETTO, non GLOBALE come Notes/Tasks/Memory
18
+ * — decisione DIVERSA dagli ultimi tre sistemi, verificata non presunta:
19
+ * il tool mobile stesso dice "i rapporti di ricerca SONO file di
20
+ * Libreria" (`library_list` li trova, li elenca come documenti — vedi
21
+ * il commento di testa di researchTools.ts) — e la Libreria desktop è
22
+ * PER-PROGETTO (FASE N, prima fetta: `.harness-ui-library/` dentro il
23
+ * workspace, mai cross-chat come sul mobile). Una ricerca avviata
24
+ * mentre si lavora sul progetto C deve finire nella Libreria di QUEL
25
+ * progetto, raggiungibile dalle sessioni future sullo STESSO progetto —
26
+ * non in un limbo globale che nessun `library_list` di progetto
27
+ * vedrebbe mai. Storage quindi accanto a `.harness-ui-library/`, dentro
28
+ * il workspace: `.harness-ui-research/<id>/`, una CARTELLA per ricerca
29
+ * dall'11/09/2026 (era `<id>.json`, un file solo: vedi il blocco «L4 —
30
+ * DA UN FILE A UNA CARTELLA» più sotto per cosa c'è dentro adesso e per
31
+ * come si leggono ancora le ricerche nate nella forma vecchia). Mai un
32
+ * array riscritto — la classe di bug già evitata altrove.
33
+ *
34
+ * ⛔⛔⛔ Deliberatamente SENZA lo stato "sta girando ORA" come campo:
35
+ * mobile stesso separa "il giornale" (`sources.list()`) da "sta girando
36
+ * adesso" (`sources.isRunning(id)`, calcolato al volo — vedi
37
+ * `TalosResearchToolSources.isRunning` in researchTools.ts) — questo
38
+ * store tiene SOLO `terminata` (null finché non è definitivamente
39
+ * conclusa: 'done'|'failed'|'cancelled'). "In corso" vs "in pausa" si
40
+ * derivano DAL VIVO in research-orchestrator.mjs confrontando
41
+ * `terminata` con lo stato reale della sessione in session-registry —
42
+ * per costruzione non può disallinearsi, mai duplicato qui.
43
+ *
44
+ * Stile DI: stesso pattern di library-store.mjs/notes-store.mjs —
45
+ * funzioni async con `deps` opzionali per i test, mai un vero
46
+ * filesystem mockato altrove.
47
+ */
48
+ import { createHash, randomUUID } from 'node:crypto';
49
+ import { promises as fsp } from 'node:fs';
50
+ import { dirname, join, resolve, sep } from 'node:path';
51
+
52
+ export class ResearchStoreError extends Error {
53
+ constructor(message, code = 'RESEARCH_INVALID') {
54
+ super(message);
55
+ this.name = 'ResearchStoreError';
56
+ this.code = code;
57
+ }
58
+ }
59
+
60
+ export const CARTELLA_RICERCA = '.harness-ui-research';
61
+
62
+ /*
63
+ * ⭐⭐⭐ L2 (11/09/2026) — GLI STATI TERMINALI DI UNA RICERCA, e il perché ce ne vogliono sei.
64
+ *
65
+ * Prima di oggi erano tre (`done`/`failed`/`cancelled`) e `done` si calcolava da `comeFinita`
66
+ * del kernel: cioè dal PROCESSO («la corsa è finita da sola»), mai dal PRODOTTO («c'è un
67
+ * rapporto»). Sulla sessione `d2a453a8` del 11/09 le due cose divergevano — corsa riuscita,
68
+ * rapporto inesistente — e la sezione mostrava un timbro verde su una scusa di 290 byte.
69
+ *
70
+ * ⛔ `failed` da solo non bastava per la ragione opposta: metteva sotto una parola sola tre
71
+ * guasti che si curano in modo DIVERSO. «Bloccata dal permesso» si cura cambiando il
72
+ * permesso (ed è il guasto di stasera), «giri esauriti» si cura alzando i giri o
73
+ * snellendo il contesto (è un guasto già noto e misurato sul banco), «senza rapporto» si
74
+ * cura ri-chiedendo il deposito. Un nome solo per tre cure diverse non è azionabile.
75
+ *
76
+ * ⛔ E i valori vivono QUI, non in una stringa sparsa per il codice: il frontend li legge
77
+ * come contratto (`stato` di ogni voce), e una lista scritta due volte è una lista che
78
+ * diverge.
79
+ */
80
+ export const STATI_TERMINATI = Object.freeze([
81
+ 'done',
82
+ 'failed',
83
+ 'cancelled',
84
+ 'senza-rapporto',
85
+ 'bloccata-dal-permesso',
86
+ 'giri-esauriti',
87
+ ]);
88
+
89
+ /*
90
+ * ═══════════════════════════════════════════════════════════════════════════
91
+ * L4 (11/09/2026) — DA UN FILE A UNA CARTELLA, E IL GIORNALE CHE LA RENDE RIPRENDIBILE
92
+ * ═══════════════════════════════════════════════════════════════════════════
93
+ *
94
+ * Prima di oggi una ricerca era UN file: `.harness-ui-research/<id>.json`, riscritto per intero
95
+ * a ogni aggiornamento. Va bene per quattro campi; non va bene per una cosa che costa denaro e
96
+ * che si deve poter RIPRENDERE dopo un riavvio, perché un file riscritto sopra sé stesso dice
97
+ * cosa crede sia vero adesso e non dice mai che un passo era finito prima che il processo
98
+ * morisse (`src/research/run.mjs`, testa del file: «la differenza fra pagare una ricerca una
99
+ * volta e pagarla due»).
100
+ *
101
+ * ⇒ La forma di §6.2 del disegno:
102
+ *
103
+ * .harness-ui-research/<id>/
104
+ * ├── meta.json la voce (era `<id>.json`) — riscritta, ma SEMPRE in modo atomico
105
+ * ├── giornale.jsonl gli eventi del motore — SOLO append, mai riscritto
106
+ * ├── piano.json il piano approvato — riscritto in modo atomico
107
+ * ├── fonti/<sha256>.txt il testo TENUTO di una fonte — scritto UNA volta, mai sopra
108
+ * └── rapporto.md il rapporto depositato da `research_deposit` (già qui da L1)
109
+ *
110
+ * ⛔⛔ IL VINCOLO CHE COMANDA TUTTO: ciò che è costato denaro non si sovrascrive mai. Lezione
111
+ * già pagata in questo repo — il rilancio che ha distrutto 56 righe e $2,64 con un
112
+ * `writeFileSync(dove,'')` riuscito, senza un errore da nessuna parte.
113
+ * ⇒ due regole, non una: (a) il giornale è **solo append**; (b) tutto il resto si scrive su
114
+ * un temporaneo e poi si `rename`, così un crash a metà lascia il file PRECEDENTE intatto.
115
+ *
116
+ * ⭐ RICERCA WEB PRIMA DI SCRIVERE (obbligo owner) — `WebSearch` era esaurito (200/200, come per
117
+ * L1-L3), quindi fonti primarie via `WebFetch`, lette l'11/09/2026:
118
+ *
119
+ * - **LWN, «Ensuring data reaches disk»** (<https://lwn.net/Articles/457667/>): la sequenza
120
+ * sicura è **cinque** passi, non due — «1. create a new temp file (on the same file
121
+ * system!) 2. write data to the temp file 3. fsync() the temp file 4. rename the temp file
122
+ * to the appropriate name 5. fsync() the containing directory». I due `fsync` fanno lavori
123
+ * diversi: il primo porta i DATI su disco prima del rename, il secondo la VOCE di
124
+ * directory. ⇒ `scriviAtomico` qui sotto fa 1-2-3-4; il punto 5 è dichiarato e **non
125
+ * fatto**, vedi sotto il perché su Windows.
126
+ * - **`rename(2)`** (<https://man7.org/linux/man-pages/man2/rename.2.html>): «If newpath
127
+ * already exists, it will be atomically replaced, so that there is no point at which
128
+ * another process attempting to access newpath will find it missing». È la garanzia su cui
129
+ * poggia tutto: un lettore vede il vecchio o il nuovo, mai mezzo file.
130
+ * - **`MoveFileExW` / `MOVEFILE_REPLACE_EXISTING`**
131
+ * (<https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>) +
132
+ * **libuv `src/win/fs.c`** (letto alla fonte: `fs__rename` chiama
133
+ * `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`, e `fs__fsync` chiama `FlushFileBuffers`).
134
+ * ⛔ **Questo è il vincolo che NON conoscevo e che la ricerca ha aggiunto**: la pagina
135
+ * Windows promette che il contenuto viene **sostituito** «provided that security
136
+ * requirements regarding ACLs are met» — NON usa mai la parola «atomically» come fa POSIX,
137
+ * e non c'è modo di fare l'`fsync` della *directory* (su Windows non si apre una directory
138
+ * come file descriptor). ⇒ il punto 5 di LWN qui non è disponibile, e lo scrivo invece di
139
+ * lasciar credere che la ricetta sia applicata per intero. Ciò che resta garantito su
140
+ * entrambe le piattaforme è quello che serve davvero al vincolo: **se il rename non
141
+ * riesce, il file vecchio è ancora intatto** — ed è la prova che il test «crash fra
142
+ * temporaneo e rename» misura.
143
+ * - **GraphFlow** — [arXiv:2605.14968](https://arxiv.org/abs/2605.14968), 14/05/2026: «a
144
+ * durable engine records outcomes in an **append-only event log** and can enforce contracts
145
+ * at system boundaries, **supporting replay, retries, and audit**». ⇒ conferma la forma, e
146
+ * nomina le tre cose che il giornale compra insieme: ripresa, ritentativi e verificabilità.
147
+ * - **Verified Detection … in Multi-Agent LLM Systems** —
148
+ * [arXiv:2606.17182](https://arxiv.org/abs/2606.17182), 15/06/2026: le macchine a esecuzione
149
+ * durevole impongono la semantica «by **deterministic replay**». ⇒ è la ragione per cui il
150
+ * giornale porta SOLO fatti (`resultRef`, non il carico) e per cui rigiocare due volte lo
151
+ * stesso file deve dare lo stesso stato — provato, non dichiarato.
152
+ *
153
+ * ⭐ E dentro il PROPRIO codebase, prima ancora che fuori (lezione 06/09 «chi guarda da fuori
154
+ * inventa quello che dentro aveva già»): `session-store.mjs` ha già il giornale JSONL con la
155
+ * coda per percorso (W0-07, 04/09 — due `appendFile` concorrenti intrecciati su un record da
156
+ * 1,5 MiB) e la lettura che tollera l'ultima riga spezzata; `local-model-store.mjs`,
157
+ * `harness-receipt-keypair.mjs` e `generated-image-store.mjs` hanno già temporaneo+`rename`.
158
+ * Qui NON si inventa un sesto modo: si riusa la stessa forma, con le due differenze
159
+ * dichiarate più sotto (`flush` sempre acceso; una riga rotta **in mezzo** si salta invece di
160
+ * far fallire la lettura).
161
+ */
162
+
163
+ /** La voce di metadata, dentro la cartella della ricerca. Era `<id>.json` accanto ad essa. */
164
+ export const NOME_META = 'meta.json';
165
+ /** Il giornale degli eventi — SOLO append. */
166
+ export const NOME_GIORNALE = 'giornale.jsonl';
167
+ /** Il piano approvato. */
168
+ export const NOME_PIANO = 'piano.json';
169
+ /** La cartella del testo TENUTO delle fonti, indirizzato dal contenuto. */
170
+ export const CARTELLA_FONTI = 'fonti';
171
+ /**
172
+ * ⭐⭐⭐ L9 (12/09/2026) — L'INDICE url → `fonti/<sha256>.txt`, e perché serve un file in più.
173
+ *
174
+ * `fonti/` è indirizzata dal CONTENUTO: è la proprietà che rende impossibile sovrascrivere una
175
+ * pagina già pagata, ed è quella che vogliamo tenere. Ma un'impronta non dice da quale indirizzo
176
+ * quel testo venga, e la verifica ha esattamente quella domanda: «l'affermazione cita
177
+ * <https://…>: dov'è il testo di quella pagina?». Finché il processo vive la risposta sta in
178
+ * memoria; dopo un riavvio non c'è più — e una ricerca ripresa consegnerebbe affermazioni «non
179
+ * verificate» per un motivo che non è vero (il testo c'è, non si sa solo di chi sia).
180
+ *
181
+ * ⛔ Non si mette l'URL nel NOME del file: un indirizzo non è un nome di file (lunghezza,
182
+ * caratteri vietati su Windows, due indirizzi che normalizzano uguale) e si perderebbe
183
+ * l'indirizzamento per contenuto. Un indice a parte costa una scrittura atomica e non tocca
184
+ * niente di ciò che già funziona.
185
+ * ⛔ E l'indice è un RISPARMIO, non una prova: se manca, la verifica lo dice invece di
186
+ * inventare — mai il contrario.
187
+ */
188
+ export const NOME_INDICE_FONTI = 'indice-fonti.json';
189
+
190
+ /**
191
+ * ⭐ Il numero di formato vive sulla VOCE, non su un file a parte, e serve a una cosa sola: dire
192
+ * se una ricerca è nata prima o dopo il record recintato. `2` = nata con la cartella e col
193
+ * giornale; assente o `1` = migrata da `<id>.json`, cioè una ricerca il cui rapporto può essere
194
+ * solo prosa perché il record recintato non esisteva quando è stata fatta. Il cancello di
195
+ * consegna (`research-orchestrator.mjs`) legge questo campo per decidere se il **ripiego** sulla
196
+ * forma minima è lecito — mai per decidere se lo stato è `done`.
197
+ */
198
+ export const FORMATO_CORRENTE = 2;
199
+
200
+ /** La voce, nella forma di oggi: `<progetto>/.harness-ui-research/<id>/meta.json`. */
201
+ export function percorsoMeta(cartella, id) {
202
+ return join(cartella, CARTELLA_RICERCA, id, NOME_META);
203
+ }
204
+
205
+ /** La voce, nella forma di ieri: `<progetto>/.harness-ui-research/<id>.json`. Si legge ancora. */
206
+ export function percorsoVoceLegacy(cartella, id) {
207
+ return join(cartella, CARTELLA_RICERCA, `${id}.json`);
208
+ }
209
+
210
+ export function percorsoGiornale(cartella, id) {
211
+ return join(cartella, CARTELLA_RICERCA, id, NOME_GIORNALE);
212
+ }
213
+
214
+ export function percorsoPiano(cartella, id) {
215
+ return join(cartella, CARTELLA_RICERCA, id, NOME_PIANO);
216
+ }
217
+
218
+ export function cartellaDelleFonti(cartella, id) {
219
+ return join(cartella, CARTELLA_RICERCA, id, CARTELLA_FONTI);
220
+ }
221
+
222
+ export function percorsoIndiceFonti(cartella, id) {
223
+ return join(cartella, CARTELLA_RICERCA, id, NOME_INDICE_FONTI);
224
+ }
225
+
226
+ /**
227
+ * ⭐⭐⭐ LA SCRITTURA CHE NON PUÒ DISTRUGGERE QUELLA DI PRIMA.
228
+ *
229
+ * Temporaneo **nella stessa cartella** (LWN: «on the same file system!» — un temporaneo in
230
+ * `%TEMP%` renderebbe il `rename` una copia, che non è atomica), `flush:true` per portare i byte
231
+ * su disco prima del rename (LWN passo 3; su Node è `FlushFileBuffers`/`fsync` sotto), poi
232
+ * `rename`.
233
+ *
234
+ * ⛔ Il `catch` **non degrada in silenzio**: pulisce il temporaneo e **rilancia**. Un temporaneo
235
+ * lasciato lì sporcherebbe la cartella della ricerca a ogni guasto, e un errore inghiottito
236
+ * qui vorrebbe dire «salvato» su una voce mai salvata — la bugia esatta che L2 ha tolto dallo
237
+ * stato. (Lezione 10/09: «il catch GIUSTO nasconde il bug SBAGLIATO».)
238
+ *
239
+ * ⛔ Il punto 5 di LWN (`fsync` della directory) **non c'è**, ed è dichiarato: su Windows non si
240
+ * apre una directory per farne il flush, e questo prodotto gira lì. Conseguenza onesta: dopo
241
+ * un crash del SISTEMA (non del processo) la voce di directory potrebbe non essere ancora
242
+ * durevole. Il file vecchio resta comunque intatto: nessuna perdita di ciò che era già pagato.
243
+ */
244
+ /*
245
+ * ════════════════════════════════════════════════════════════════════════════════════════════
246
+ * ⛔⛔⛔ 12/09/2026 — SU WINDOWS UN LETTORE FA FALLIRE IL RENAME. IL RITENTO.
247
+ * ════════════════════════════════════════════════════════════════════════════════════════════
248
+ *
249
+ * Il difetto, **riprodotto** (non dedotto) eseguendo la suite intera e poi isolato in sei righe:
250
+ *
251
+ * const h = fs.openSync(meta, 'r'); // un LETTORE qualunque, in sola lettura
252
+ * fs.renameSync(tmp, meta); // → EPERM: operation not permitted, rename
253
+ *
254
+ * Senza il lettore aperto, lo stesso rename **riesce**. ⇒ non è il disco, non è un permesso:
255
+ * è la contesa. Su Windows `MoveFileExW` non può sostituire una destinazione che qualcun altro
256
+ * tiene aperta, e libuv apre i file **senza** `FILE_SHARE_DELETE`.
257
+ *
258
+ * ⛔ E chi era il lettore, in produzione? **La sezione Ricerca**, che interroga l'elenco e la
259
+ * scheda **mentre** la ricerca gira. Quando la ricerca finiva, `onConclusioneRicerca` chiamava
260
+ * `aggiornaRicerca` → qui → EPERM → l'eccezione usciva e la voce restava **`running` sul disco
261
+ * per sempre**: una ricerca conclusa e pagata che a schermo non finiva mai. Visto in due corse
262
+ * su tre della suite intera; mai eseguendo il file da solo (è una gara, e la vince chi ha il
263
+ * disco più lento).
264
+ *
265
+ * ── Ricerca web PRIMA di scrivere (fonte + data) ────────────────────────────────────────────
266
+ * · **graceful-fs, `polyfills.js`** (isaacs) — letto il 12/09/2026. È il pattern di riferimento,
267
+ * quello che npm usa da anni: ritenta il `rename` su **`EACCES`, `EPERM`, `EBUSY`**, perché
268
+ * «on Windows, A/V software can lock the directory, causing this to fail with an EACCES or
269
+ * EPERM if the directory contains newly created files».
270
+ * ⛔ **Il vincolo che non conoscevo e che ha cambiato il codice**: si aspetta con `setTimeout`
271
+ * e mai con un ciclo stretto, perché «Windows scheduling gives CPU to a busy looping
272
+ * process, which can cause the program causing the lock contention to be **starved of CPU**
273
+ * by node, so the contention doesn't resolve». Un ritento che gira a vuoto **impedisce** al
274
+ * lettore di chiudere il suo handle: la cura diventerebbe la causa.
275
+ * ⛔ **E una cosa di graceful-fs che NON si copia**: prima di ogni ritento lui controlla che la
276
+ * destinazione non esista e, se esiste, si ferma. Serve al caso di npm, dove la
277
+ * destinazione **non deve** esserci. Qui la destinazione esiste **sempre** (stiamo
278
+ * sostituendo `meta.json`): copiarlo farebbe uscire ogni ritento al primo giro, cioè non
279
+ * ritentare affatto.
280
+ * ⛔ La sua finestra è di **60 secondi** (pensata per Parity bit9, che «may lock files for up
281
+ * to a minute»). Qui no: questa scrittura sta **dentro una richiesta HTTP** e dentro la
282
+ * conclusione di una ricerca. Dieci tentativi, attese 20→200 ms, **1,3 s** in tutto.
283
+ * · **MoveFileExW / `MOVEFILE_REPLACE_EXISTING`** — <https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw>,
284
+ * riletta il 12/09/2026: sostituisce «provided that security requirements regarding access
285
+ * control lists (ACLs) are met», e «to delete or rename a file, you must have either delete
286
+ * permission on the file or delete child permission in the parent directory». ⛔ **Va detto
287
+ * quello che NON dice**: la pagina non nomina gli handle aperti né l'errore che ne esce. Il
288
+ * legame «lettore aperto ⇒ EPERM» qui non viene da lei: viene dalla **riproduzione** qui
289
+ * sopra e dal test che la esegue su disco vero.
290
+ * · **Node, `fs.rename`/`fsPromises.rename`** — <https://nodejs.org/docs/latest/api/fs.html>:
291
+ * ⚠️ lettura **NON riuscita**. La pagina è tornata troncata da `WebFetch` due volte e le
292
+ * sezioni dei due metodi non si sono lette alla lettera. Segnato come lettura mancata, non
293
+ * come lettura fatta: il comportamento su cui poggia questo codice è **misurato**, non citato.
294
+ */
295
+ const RENAME_TENTATIVI = 10;
296
+ const RENAME_ATTESA_INIZIALE_MS = 20;
297
+ const RENAME_ATTESA_MASSIMA_MS = 200;
298
+ /** ⛔ I tre di graceful-fs, e nessuno in più: un `ENOSPC` o un `EROFS` ritentati sono 1,3 s buttati. */
299
+ const CODICI_DI_CONTESA = new Set(['EPERM', 'EBUSY', 'EACCES']);
300
+ /** Il nome DICHIARATO che prende un temporaneo quando il rename non riesce mai. Vedi `scriviAtomico`. */
301
+ export const SUFFISSO_NON_RINOMINATO = '.non-rinominato';
302
+
303
+ /**
304
+ * Il `rename`, ritentato finché la contesa non passa.
305
+ *
306
+ * ⛔ Torna **quanti ritenti sono serviti** invece di `undefined`: una cura che non si può contare
307
+ * è una cura di cui nessuno saprà mai se è servita. Il test se ne serve, e domani una sonda
308
+ * potrà dirlo all'owner.
309
+ * ⛔ Un codice che non è di contesa **non si ritenta**: si rilancia subito. Aspettare 1,3 secondi
310
+ * per un disco pieno è tempo rubato a chi sta guardando lo schermo.
311
+ *
312
+ * @returns {Promise<number>} quanti ritenti sono serviti (0 = è andata al primo colpo)
313
+ */
314
+ export async function rinominaConRitento(temporaneo, percorso, deps = {}) {
315
+ const renameFn = deps.renameFn ?? fsp.rename;
316
+ // ⛔ `setTimeout`, MAI un ciclo stretto: vedi graceful-fs sopra — un ciclo affamerebbe di CPU
317
+ // proprio il processo che tiene il file aperto, e la contesa non si scioglierebbe mai.
318
+ const attendiFn = deps.attendiFn ?? ((ms) => new Promise((risolvi) => { setTimeout(risolvi, ms); }));
319
+ const tentativi = Number.isSafeInteger(deps.tentativiRename) && deps.tentativiRename > 0
320
+ ? deps.tentativiRename
321
+ : RENAME_TENTATIVI;
322
+
323
+ for (let tentativo = 0; ; tentativo += 1) {
324
+ try {
325
+ await renameFn(temporaneo, percorso);
326
+ return tentativo;
327
+ } catch (errore) {
328
+ if (!CODICI_DI_CONTESA.has(errore?.code) || tentativo >= tentativi - 1) throw errore;
329
+ // 20, 40, 80, 160, poi 200 fisso: 1,3 s in tutto su dieci tentativi.
330
+ await attendiFn(Math.min(RENAME_ATTESA_INIZIALE_MS * (2 ** tentativo), RENAME_ATTESA_MASSIMA_MS));
331
+ }
332
+ }
333
+ }
334
+
335
+ export async function scriviAtomico(percorso, contenuto, deps = {}) {
336
+ const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
337
+ const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
338
+ const rmFn = deps.rmFn ?? fsp.rm;
339
+ const renameFn = deps.renameFn ?? fsp.rename;
340
+ const randomUUIDFn = deps.randomUUIDFn ?? randomUUID;
341
+ await mkdirFn(dirname(percorso), { recursive: true });
342
+ const temporaneo = `${percorso}.tmp-${process.pid}-${randomUUIDFn()}`;
343
+
344
+ try {
345
+ await writeFileFn(temporaneo, contenuto, { encoding: 'utf8', flush: true });
346
+ } catch (errore) {
347
+ /* ⛔ Qui il temporaneo SI PULISCE, ed è l'unico caso in cui è giusto: una scrittura fallita
348
+ lascia byte a metà, e dei byte a metà non si salva niente. */
349
+ try { await rmFn(temporaneo, { force: true }); } catch { /* può non essere mai nato: pulire è un di più, non una condizione. */ }
350
+ throw errore;
351
+ }
352
+
353
+ try {
354
+ await rinominaConRitento(temporaneo, percorso, { ...deps, renameFn });
355
+ } catch (errore) {
356
+ /*
357
+ * ⛔⛔⛔ QUI IL TEMPORANEO NON SI BUTTA PIÙ, ed è un cambio di contratto voluto (12/09).
358
+ *
359
+ * Fino a stamattina questo ramo faceva `rm` del temporaneo, col motivo scritto accanto: «una
360
+ * cartella di ricerca piena di `.tmp-` è il segno di un guasto inghiottito». Il motivo era
361
+ * buono, la conclusione no: a questo punto la scrittura è **riuscita** — i byte sono interi e
362
+ * già sul disco — ed è solo il rename a non essere passato. Cancellarli butta lavoro **già
363
+ * pagato** per tenere pulita una cartella, che è esattamente lo scambio che il vincolo di
364
+ * questo file vieta («ciò che è costato denaro non si sovrascrive mai»).
365
+ * ⛔ E la cura al disordine non è buttare: è **dare un nome**. Il file resta accanto come
366
+ * `<nome>.non-rinominato` — dichiarato, riconoscibile, e **uno solo**: un secondo guasto
367
+ * sovrascrive quello di prima invece di accumulare scorie con un UUID diverso ogni volta.
368
+ * ⛔ Se anche il parcheggio fallisce (la cartella intera è bloccata) non si lancia da qui: si
369
+ * tiene il nome casuale e lo si **dice**. Un errore di recupero che copre l'errore vero è
370
+ * il difetto che questo repo ha già pagato («il catch giusto nasconde il bug sbagliato»).
371
+ */
372
+ let dove = temporaneo;
373
+ try {
374
+ await renameFn(temporaneo, `${percorso}${SUFFISSO_NON_RINOMINATO}`);
375
+ dove = `${percorso}${SUFFISSO_NON_RINOMINATO}`;
376
+ } catch { /* resta col nome casuale, e il messaggio qui sotto lo dice per esteso. */ }
377
+ /* ⛔ Si ARRICCHISCE l'errore originale invece di crearne uno nuovo: `code`, `errno`, `path` e
378
+ la pila appartengono al guasto vero, e un chiamante che filtra sul codice deve continuare
379
+ a vederlo. */
380
+ errore.message = `${errore.message} — il file vecchio è intatto e il nuovo contenuto NON è perso: sta in ${dove}`;
381
+ throw errore;
382
+ }
383
+
384
+ return percorso;
385
+ }
386
+
387
+ /**
388
+ * @returns {Promise<Array<object>>} — [] se la cartella non esiste ancora
389
+ * (mai un errore: nessuna ricerca avviata su questo progetto è uno
390
+ * stato onesto, non un guasto). Più recenti prime, come `research_list`
391
+ * mobile ("«che ricerche ho fatto» quasi sempre vuol dire «le ultime»").
392
+ */
393
+ export async function elencaRicerche({ cartella }, deps = {}) {
394
+ const readdirFn = deps.readdirFn ?? fsp.readdir;
395
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
396
+ const cartellaRicerca = join(cartella, CARTELLA_RICERCA);
397
+ let voci;
398
+ try {
399
+ voci = await readdirFn(cartellaRicerca, { withFileTypes: true });
400
+ } catch {
401
+ return [];
402
+ }
403
+ /*
404
+ * ⭐ L4 — DUE FORME SULLO STESSO DISCO, e l'elenco le vede entrambe.
405
+ * ⛔ La chiave è l'ID, non il file: durante una migrazione interrotta (meta.json già scritto,
406
+ * `<id>.json` non ancora tolto) la stessa ricerca esiste in due posti, e mostrarla due volte
407
+ * sarebbe un elenco che mente. Vince la CARTELLA — è la forma nuova, ed è quella che l'ultima
408
+ * scrittura ha prodotto.
409
+ */
410
+ const perId = new Map();
411
+ for (const voce of voci) {
412
+ const nome = typeof voce === 'string' ? voce : voce.name;
413
+ const eCartella = typeof voce === 'string' ? false : voce.isDirectory();
414
+ const percorso = eCartella ? join(cartellaRicerca, nome, NOME_META) : join(cartellaRicerca, nome);
415
+ if (!eCartella && !nome.endsWith('.json')) continue;
416
+ const id = eCartella ? nome : nome.slice(0, -'.json'.length);
417
+ if (!eCartella && perId.has(id)) continue; // la cartella, già letta, vince sul file legacy.
418
+ try {
419
+ const letta = JSON.parse(await readFileFn(percorso, 'utf8'));
420
+ if (eCartella || !perId.has(id)) perId.set(id, letta);
421
+ } catch {
422
+ // ⛔ una voce corrotta (o una cartella senza meta.json: una ricerca nuova può avere solo
423
+ // il rapporto se la metadata non è ancora stata migrata) non impedisce di vedere le altre
424
+ // — stesso principio di leggiRegistro (session-store.mjs) su un'ultima riga tollerata.
425
+ }
426
+ }
427
+ const ricerche = [...perId.values()];
428
+ ricerche.sort((a, b) => String(b.avviataAlle || '').localeCompare(String(a.avviataAlle || '')));
429
+ return ricerche;
430
+ }
431
+
432
+ /**
433
+ * ⭐ L4 — legge la voce nella forma di oggi (`<id>/meta.json`) e, se non c'è, in quella di ieri
434
+ * (`<id>.json`). **Leggere non migra**: la migrazione costa una scrittura, e una scrittura su
435
+ * venti voci solo per disegnare un elenco sarebbe esattamente la migrazione «in blocco» che il
436
+ * disegno vieta. Si migra al primo tocco che scrive — vedi `migraRicerca`.
437
+ *
438
+ * @returns {Promise<object|null>} — null se l'id non esiste in nessuna delle due forme.
439
+ */
440
+ export async function leggiRicerca({ cartella, id }, deps = {}) {
441
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
442
+ for (const percorso of [percorsoMeta(cartella, id), percorsoVoceLegacy(cartella, id)]) {
443
+ let grezzo;
444
+ try {
445
+ grezzo = await readFileFn(percorso, 'utf8');
446
+ } catch (errore) {
447
+ // ⛔ Non solo ENOENT: su Windows chiedere `<id>/meta.json` quando `<id>` è un FILE dà
448
+ // ENOTDIR/ENOENT a seconda del punto, e su POSIX dà ENOTDIR. Entrambi vogliono dire «in
449
+ // questa forma non c'è», non «il disco è rotto»: si prova l'altra forma.
450
+ if (errore?.code === 'ENOENT' || errore?.code === 'ENOTDIR') continue;
451
+ throw new ResearchStoreError(`${id}: metadata presente ma illeggibile: ${errore.message}`, 'RESEARCH_READ_FAILED');
452
+ }
453
+ return JSON.parse(grezzo);
454
+ }
455
+ return null;
456
+ }
457
+
458
+ /**
459
+ * Crea la voce di metadata — chiamata da research-orchestrator.mjs SUBITO
460
+ * dopo che avviaESegui ha già assegnato un sessionId: l'id di una
461
+ * ricerca È il sessionId della sessione che la esegue (nessuna doppia
462
+ * mappatura ricerca→sessione, un solo spazio di identità, mai
463
+ * disallineabile).
464
+ */
465
+ export async function creaRicerca({ cartella, id, domanda, profondita, padreId = null, nome = null, modello = null, modelloGiudice = null }, deps = {}) {
466
+ const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
467
+ const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
468
+ if (typeof id !== 'string' || id.length === 0) {
469
+ throw new ResearchStoreError('Una ricerca vuole un id', 'RESEARCH_INVALID');
470
+ }
471
+ if (typeof domanda !== 'string' || domanda.trim().length === 0) {
472
+ throw new ResearchStoreError('Una ricerca vuole una domanda', 'RESEARCH_INVALID');
473
+ }
474
+ if (!idRicercaValido(id)) {
475
+ // ⛔ L4 — l'id è diventato un NOME DI CARTELLA: quello che prima poteva al più sporcare un
476
+ // nome di file adesso può attraversare il disco. Il controllo c'era già a valle (nel
477
+ // kernel, per il deposito); qui è a monte, sul dato, dove nasce.
478
+ throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
479
+ }
480
+ const cartellaRicerca = cartellaDellaRicerca(cartella, id);
481
+ await mkdirFn(cartellaRicerca, { recursive: true });
482
+ const adesso = new Date().toISOString();
483
+ const voce = {
484
+ id, domanda: domanda.trim(), profondita: profondita || 'deep',
485
+ titolo: null, avviataAlle: adesso, aggiornataAlle: adesso,
486
+ terminata: null, reportLibraryId: null,
487
+ /*
488
+ * ⭐ BC-44 (12/09/2026) — nasce `null`, e resta `null` su ogni voce nata prima di oggi: il
489
+ * lettore lo normalizza, nessuna migrazione, nessuna riga già pagata riscritta.
490
+ */
491
+ motivoErrore: null,
492
+ /*
493
+ * ⭐ L4 — il numero di formato. `2` = nata nella cartella, col giornale. Una voce senza
494
+ * questo campo è nata prima dell'11/09 e il suo rapporto non può contenere il record
495
+ * recintato: è l'unico caso in cui il cancello accetta il ripiego sulla forma minima.
496
+ */
497
+ formato: FORMATO_CORRENTE,
498
+ /*
499
+ * ⭐ L1/§6.6 (11/09) — DUE campi nuovi, entrambi `null` per ogni voce nata prima di oggi
500
+ * (il lettore li normalizza, vedi `research-orchestrator.elenca`): non serve nessuna
501
+ * migrazione, e nessuna riga già pagata viene riscritta.
502
+ *
503
+ * `padreId`: la sessione che ha chiamato `research_start`. Il 11/09 la ricerca
504
+ * `d2a453a8` risultava `padreId:null` e all'owner è sembrata «una sessione nuova»: lo
505
+ * era davvero, anche nel registro. Qui il legame è persistito ANCHE sulla metadata della
506
+ * ricerca, non solo sulla voce di sessione, perché la sezione «Ricerca approfondita» la
507
+ * legge dal disco anche dopo un riavvio, quando la voce di sessione non c'è più.
508
+ *
509
+ * `nome`: la domanda troncata — l'etichetta umana della riga in elenco. Stessa ragione:
510
+ * il nome della SESSIONE vive in memoria (`voce.nome`), questo sopravvive al riavvio.
511
+ */
512
+ padreId: typeof padreId === 'string' && padreId.length > 0 ? padreId : null,
513
+ nome: typeof nome === 'string' && nome.trim().length > 0 ? nome.trim() : null,
514
+ /*
515
+ * ⭐⭐⭐ L8 (12/09/2026) — CON QUALE MODELLO È STATA FATTA. Scritto qui e non dedotto.
516
+ *
517
+ * Il 12/09 la ricerca `3029dea2` è partita con `z-ai/glm-4.7-flash` mentre la chat che
518
+ * l'aveva ordinata girava con `z-ai/glm-5.3-flash` (intestazioni delle due sessioni nello
519
+ * store: `modello` riga 1 di ciascun `.jsonl`). Nessuno poteva accorgersene dalla sezione,
520
+ * perché la voce della ricerca non diceva con che cosa fosse stata fatta — e due ricerche
521
+ * fatte con due modelli diversi non sono confrontabili.
522
+ *
523
+ * ⛔ `null` per ogni voce nata prima di oggi: onesto, mai il modello di oggi attribuito a
524
+ * una corsa di ieri. E sulla METADATA, non solo sulla voce di sessione, perché la voce
525
+ * di sessione vive in memoria e la sezione legge dal disco anche dopo un riavvio.
526
+ */
527
+ modello: typeof modello === 'string' && modello.trim().length > 0 ? modello.trim() : null,
528
+ /*
529
+ * ⭐⭐⭐ L9 (12/09/2026) — CHI GIUDICHERÀ, scelto alla NASCITA e non al deposito.
530
+ *
531
+ * ⛔ Perché qui e non dopo: la scelta del giudice è «chiunque tranne l'autore»
532
+ * (`verification.mjs:talosResearchPickJudge`), e l'autore è il modello di QUESTA corsa.
533
+ * Deciderlo al momento del deposito vorrebbe dire rileggere quale modello fosse
534
+ * configurato allora — cioè un'altra ora, un'altra impostazione, un altro giudice, e un
535
+ * rapporto che non sa dire chi l'ha controllato. Scritto alla nascita, sopravvive a un
536
+ * riavvio come tutto il resto della metadata.
537
+ * ⛔ `null` è una risposta VERA e frequente: nessun altro modello ammesso oltre all'autore.
538
+ * Allora il rapporto esce con `judge: null` e lo DICE («nessun giudice indipendente
539
+ * disponibile: l'autore non può verificare sé stesso»), invece di far timbrare al modello
540
+ * le proprie affermazioni. La misura che lo vieta è vecchia e netta: Panickssery, Bowman
541
+ * e Feng, «LLM Evaluators Recognize and Favor Their Own Generations» (arXiv:2404.13076,
542
+ * 15/04/2024, letta il 12/09/2026) — «a linear correlation between self-recognition
543
+ * capability and the strength of self-preference bias».
544
+ */
545
+ modelloGiudice: typeof modelloGiudice === 'string' && modelloGiudice.trim().length > 0 ? modelloGiudice.trim() : null,
546
+ };
547
+ // ⛔ L4 — atomica anche alla nascita: una voce scritta a metà è una ricerca che l'elenco non
548
+ // vede più, e la sessione che la esegue sta già spendendo denaro.
549
+ await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify(voce, null, 2), { ...deps, writeFileFn, mkdirFn });
550
+ return voce;
551
+ }
552
+
553
+ /**
554
+ * ⭐⭐⭐ L4 — LA MIGRAZIONE, AL PRIMO TOCCO CHE SCRIVE. Mai in blocco.
555
+ *
556
+ * Una ricerca vecchia vive in `<id>.json`. Quando qualcosa la aggiorna (e solo allora) la voce
557
+ * passa in `<id>/meta.json`, e il file vecchio si toglie **dopo** che il nuovo è sul disco.
558
+ *
559
+ * ⛔ L'ordine non è arbitrario ed è tutto il punto: se il processo muore fra le due operazioni,
560
+ * sul disco ci sono ENTRAMBE le copie — `elencaRicerche` dà la precedenza alla cartella,
561
+ * quindi la ricerca appare una volta sola e con i dati NUOVI. L'ordine opposto (togliere prima
562
+ * di scrivere) avrebbe una finestra in cui la ricerca non esiste: è la classe di guasto di
563
+ * `writeFileSync(dove,'')`, e non si ripete.
564
+ * ⛔ `formato` resta **assente** su ciò che è migrato, e non è una dimenticanza: quella voce è
565
+ * nata quando il record recintato non esisteva, e il cancello di consegna deve continuare a
566
+ * saperlo per sempre. Un `formato: 2` messo qui trasformerebbe una migrazione di contenitore
567
+ * in una promessa sul contenuto.
568
+ *
569
+ * @returns {Promise<object|null>} — la voce migrata, o `null` se non c'era niente da migrare.
570
+ */
571
+ export async function migraRicerca({ cartella, id }, deps = {}) {
572
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
573
+ const rmFn = deps.rmFn ?? fsp.rm;
574
+ if (!idRicercaValido(id)) return null;
575
+ const legacy = percorsoVoceLegacy(cartella, id);
576
+ let grezzo;
577
+ try {
578
+ grezzo = await readFileFn(legacy, 'utf8');
579
+ } catch {
580
+ return null; // niente forma vecchia: o è già migrata, o non è mai esistita.
581
+ }
582
+ let voce;
583
+ try {
584
+ voce = JSON.parse(grezzo);
585
+ } catch (errore) {
586
+ // ⛔ Un `<id>.json` illeggibile NON si cancella e NON si sostituisce: è l'unica copia di
587
+ // qualcosa che è costato denaro. Si dice, e si lascia dov'è.
588
+ throw new ResearchStoreError(`${id}: la voce da migrare è illeggibile, lasciata dov'era: ${errore.message}`, 'RESEARCH_READ_FAILED');
589
+ }
590
+ await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify({ ...voce, migrataDa: `${id}.json` }, null, 2), deps);
591
+ await rmFn(legacy, { force: true });
592
+ return { ...voce, migrataDa: `${id}.json` };
593
+ }
594
+
595
+ /**
596
+ * Aggiorna campi (titolo/terminata/reportLibraryId) — `null` se l'id non
597
+ * esiste. `undefined` per un campo significa "non toccarlo" (mai
598
+ * confuso con `null`, un valore esplicito — stesso principio già
599
+ * imparato sul clamping di `limit` in Notes: non trattare "assente"
600
+ * come "falso"). Mai il campo "in corso": si deriva dal vivo (vedi la
601
+ * doc di testa), questa funzione non può farlo disallineare.
602
+ *
603
+ * ⭐⭐⭐ L2 (11/09) — `terminata` non è più solo `'done'|'failed'|'cancelled'`: vedi
604
+ * `STATI_TERMINATI` qui sotto. Questa funzione NON valida il valore, di proposito — è lo
605
+ * stesso principio già in uso per `titolo`: lo store scrive ciò che il chiamante decide, e
606
+ * la macchina degli stati vive tutta in `research-orchestrator.mjs`, in un posto solo.
607
+ */
608
+ export async function aggiornaRicerca({ cartella, id, titolo, terminata, reportLibraryId, conclusaAlle, ultimoMessaggio, motivoDettaglio, motivoErrore }, deps = {}) {
609
+ /*
610
+ * ⭐⭐⭐ L4 — QUESTO È «IL PRIMO TOCCO». Un aggiornamento è una scrittura: se la voce è ancora
611
+ * nella forma vecchia, qui si migra — una volta, per quella ricerca, e solo perché stavamo
612
+ * comunque per scrivere. Nessun costo aggiunto a chi legge.
613
+ */
614
+ await migraRicerca({ cartella, id }, deps);
615
+ const voce = await leggiRicerca({ cartella, id }, deps);
616
+ if (!voce) return null;
617
+ const aggiornata = {
618
+ ...voce,
619
+ titolo: titolo !== undefined ? titolo : voce.titolo,
620
+ terminata: terminata !== undefined ? terminata : voce.terminata,
621
+ reportLibraryId: reportLibraryId !== undefined ? reportLibraryId : voce.reportLibraryId,
622
+ /*
623
+ * ⭐ L2 (11/09) — TRE campi nuovi, stessa disciplina «undefined = non toccarlo» degli altri.
624
+ * `conclusaAlle`: l'istante in cui la ricerca è finita DAVVERO. Prima si leggeva
625
+ * `aggiornataAlle`, che però si muove a ogni rinomina: una ricerca rinominata sembrava
626
+ * essersi conclusa il giorno della rinomina.
627
+ * `ultimoMessaggio`: ciò che il modello ha detto alla fine — un ALLEGATO, mai il rapporto.
628
+ * `motivoDettaglio`: il pezzo variabile della frase umana (es. «il rapporto non elenca
629
+ * nessuna fonte»), perché la frase la compone il lettore e il dettaglio lo sa solo chi ha
630
+ * riletto il file in quel momento.
631
+ */
632
+ conclusaAlle: conclusaAlle !== undefined ? conclusaAlle : (voce.conclusaAlle ?? null),
633
+ ultimoMessaggio: ultimoMessaggio !== undefined ? ultimoMessaggio : (voce.ultimoMessaggio ?? null),
634
+ motivoDettaglio: motivoDettaglio !== undefined ? motivoDettaglio : (voce.motivoDettaglio ?? null),
635
+ /*
636
+ * ⭐⭐⭐ BC-44 (12/09/2026) — PERCHÉ LA CORSA È CADUTA, e non solo CHE è caduta.
637
+ *
638
+ * `{classe, transitorio, codice, messaggio}` oppure `null`. Stessa disciplina degli altri:
639
+ * `undefined` = non toccarlo, `null` = azzeralo (lo fa la ripresa, che riapre la corsa).
640
+ *
641
+ * ⛔ Questo file NON classifica e non valida: scrive ciò che il chiamante decide, come per
642
+ * `terminata`. La tabella transitorio/non transitorio vive in un posto solo
643
+ * (`research-orchestrator.mjs`, `classificaErroreDiCorsa`) — due classificatori che
644
+ * divergono sarebbero due verità sullo stesso guasto.
645
+ * ⛔ `messaggio` è la frase GREZZA del fornitore (es. «Upstream idle timeout exceeded»):
646
+ * resta qui, sul disco, per la diagnosi, e NON esce dalla voce esposta — a schermo va la
647
+ * frase italiana che compone `motivoDelloStato`.
648
+ */
649
+ motivoErrore: motivoErrore !== undefined ? motivoErrore : (voce.motivoErrore ?? null),
650
+ aggiornataAlle: new Date().toISOString(),
651
+ };
652
+ await scriviAtomico(percorsoMeta(cartella, id), JSON.stringify(aggiornata, null, 2), deps);
653
+ return aggiornata;
654
+ }
655
+
656
+ /**
657
+ * Elimina SOLO la metadata — il rapporto in Libreria (se esiste) è
658
+ * responsabilità del CHIAMANTE (research-orchestrator.mjs, che conosce
659
+ * `reportLibraryId` PRIMA di chiamare questa funzione e compone le due
660
+ * cancellazioni, stesso principio "questo file non sa COME" già in uso
661
+ * altrove). `null` se l'id non esiste già (idempotente, mai
662
+ * un'eccezione — stesso principio di `eliminaVoce` in library-store.mjs,
663
+ * "It may already be gone").
664
+ */
665
+ export async function eliminaRicerca({ cartella, id }, deps = {}) {
666
+ const rmFn = deps.rmFn ?? fsp.rm;
667
+ if (!idRicercaValido(id)) return null;
668
+ const esistente = await leggiRicerca({ cartella, id }, deps);
669
+ if (!esistente) return null;
670
+ /*
671
+ * ⭐ L4 — adesso una ricerca è una CARTELLA: si toglie tutta (giornale, piano, fonti,
672
+ * rapporto), non solo la voce. ⛔ Ed è una cancellazione CHIESTA da una persona (o dal
673
+ * modello via `research_delete`), non un effetto collaterale: è l'unico posto di questo file
674
+ * dove qualcosa di pagato sparisce, e sparisce perché qualcuno l'ha ordinato.
675
+ * ⛔ Si toglie anche il `<id>.json` di una ricerca mai migrata: altrimenti l'elenco
676
+ * continuerebbe a mostrare una ricerca dichiarata eliminata.
677
+ */
678
+ await rmFn(cartellaDellaRicerca(cartella, id), { recursive: true, force: true });
679
+ await rmFn(percorsoVoceLegacy(cartella, id), { force: true });
680
+ return { id };
681
+ }
682
+
683
+ /*
684
+ * ─────────────────────────────────────────────────────────────────────────
685
+ * L1+L2 (11/09/2026) — LA CARTELLA DELLA RICERCA, IL RAPPORTO E LA SUA FORMA
686
+ * ─────────────────────────────────────────────────────────────────────────
687
+ *
688
+ * ⛔ Perché esiste, riprodotto sui dati veri (sessione `d2a453a8-67e3-4c7a-85a0-c3e1dbe10b35`
689
+ * del 4174, 11/09, ore 18:56-19:00): la ricerca partiva `permessi:'Read only'`
690
+ * (`research-orchestrator.mjs:170`, scritto a mano), `document_create` le veniva offerto lo
691
+ * stesso e negato a runtime (`REFUSED. la sessione è in sola lettura…`, due volte), e «il
692
+ * rapporto» era l'ULTIMO MESSAGGIO con del testo — cioè 290 byte di scusa, salvati in
693
+ * Libreria come `Research - ….md` e timbrati `terminata:'done'`. 9 `web_search`, 14 `naviga`,
694
+ * 484.171 token di ingresso pagati, e il documento permanente era la frase con cui il modello
695
+ * si giustificava per non averlo scritto. Nessun errore da nessuna parte.
696
+ *
697
+ * ⇒ Il rapporto smette di essere «l'ultima frase» e diventa un ARTEFATTO su disco, depositato
698
+ * da un attrezzo dedicato (`research_deposit`) dentro la cartella della ricerca. Questo file
699
+ * sa DOVE vive e CHE FORMA MINIMA deve avere; non sa chi lo scrive.
700
+ *
701
+ * ⭐ Ricerca web PRIMA di scrivere (obbligo owner), fonte + data:
702
+ * - «Agent Safety Is Action Alignment», Li & Zhao — arXiv:2606.28739, 27/06/2026: il minimo
703
+ * privilegio va imposto «outside the model at the action boundary», non con l'allineamento
704
+ * del modello. ⇒ il confine è il cancello del kernel; la forma del rapporto qui sotto è un
705
+ * controllo di PRODOTTO, mai il confine di sicurezza.
706
+ * - «When Lower Privileges Suffice: Investigating Over-Privileged Tool Selection in LLM
707
+ * Agents», Yang, Bu, Yi et al. — arXiv:2606.20023, 18/06/2026 (v2 07/07): la scelta di un
708
+ * attrezzo a privilegio più alto quando ne basterebbe uno più basso è comune «and is further
709
+ * amplified by transient failures». ⇒ è ESATTAMENTE la corsa di stasera: dopo il primo
710
+ * REFUSED il modello ha insistito con `document_create` invece di cercare una via più
711
+ * stretta. La cura non è un prompt migliore («prompt-level controls provide only limited
712
+ * mitigation»): è togliere dalla lista l'attrezzo più largo e offrirne uno più stretto.
713
+ */
714
+
715
+ /** La cartella di UNA ricerca: `<progetto>/.harness-ui-research/<id>/`. Mai l'id nudo dal modello — vedi `idRicercaValido`. */
716
+ export function cartellaDellaRicerca(cartella, id) {
717
+ return join(cartella, CARTELLA_RICERCA, id);
718
+ }
719
+
720
+ /** Il rapporto di UNA ricerca. Un nome solo, deciso qui: chi scrive e chi rilegge non possono divergere. */
721
+ export const NOME_RAPPORTO = 'rapporto.md';
722
+
723
+ export function percorsoRapporto(cartella, id) {
724
+ return join(cartellaDellaRicerca(cartella, id), NOME_RAPPORTO);
725
+ }
726
+
727
+ /**
728
+ * ⛔ Un id di ricerca è UN SOLO segmento di percorso, e solo caratteri che non possono
729
+ * attraversare una cartella: niente `/`, niente backslash, niente `:`, niente `.`/`..`. È la
730
+ * stessa disciplina di `resolve`+`startsWith` che il kernel applica al percorso finale, ma
731
+ * spostata a monte, sul DATO — due controlli indipendenti sullo stesso confine, non uno
732
+ * ripetuto. (Gli UUID di `randomUUID()` passano; qualunque tentativo di uscire no.)
733
+ */
734
+ export function idRicercaValido(id) {
735
+ return typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id);
736
+ }
737
+
738
+ /**
739
+ * `resolve` + `startsWith(radice + sep)` — la STESSA forma del controllo di
740
+ * `livello-scrittura-area` nel kernel (`talosHarness.mjs`), scritta una volta sola qui perché
741
+ * la usano sia il deposito sia i test. ⛔ `radice` assente non è un «vince tutto»: senza una
742
+ * radice da controllare un percorso non è verificabile, quindi è FUORI.
743
+ */
744
+ export function dentroLaRadice(radice, percorso) {
745
+ if (typeof radice !== 'string' || radice.length === 0) return false;
746
+ if (typeof percorso !== 'string' || percorso.length === 0) return false;
747
+ const r = resolve(radice);
748
+ const p = resolve(percorso);
749
+ return p === r || p.startsWith(r + sep);
750
+ }
751
+
752
+ /**
753
+ * Salva il rapporto di una ricerca — usato dall'attrezzo `research_deposit`.
754
+ * ⛔ Il percorso NON viene mai dal modello: si ricostruisce qui da `cartella` (del progetto) e
755
+ * `id` (della ricerca, che il server conosce). Il controllo `dentroLaRadice` resta comunque,
756
+ * in seconda battuta: un cancello si prova anche quando la prima difesa dovrebbe bastare.
757
+ * @returns {Promise<{percorso:string, byte:number}>}
758
+ */
759
+ export async function scriviRapporto({ cartella, id, testo }, deps = {}) {
760
+ const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
761
+ const writeFileFn = deps.writeFileFn ?? fsp.writeFile;
762
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
763
+ if (typeof testo !== 'string' || testo.trim().length === 0) {
764
+ throw new ResearchStoreError('Un rapporto vuole del testo', 'RESEARCH_INVALID');
765
+ }
766
+ const radice = cartellaDellaRicerca(cartella, id);
767
+ const percorso = percorsoRapporto(cartella, id);
768
+ if (!dentroLaRadice(radice, percorso)) {
769
+ throw new ResearchStoreError(`${percorso} non risolve dentro ${radice}`, 'RESEARCH_INVALID');
770
+ }
771
+ await mkdirFn(radice, { recursive: true });
772
+ // ⛔ L4 — atomica: un rapporto è il prodotto per cui la ricerca è stata pagata. Se la scrittura
773
+ // muore a metà, quello che c'era prima (un deposito precedente) resta leggibile.
774
+ await scriviAtomico(percorso, testo, { ...deps, mkdirFn, writeFileFn });
775
+ return { percorso, byte: Buffer.byteLength(testo, 'utf8') };
776
+ }
777
+
778
+ /** @returns {Promise<string|null>} — il testo del rapporto, `null` se non esiste (mai un'eccezione per un'assenza). */
779
+ export async function leggiRapporto({ cartella, id }, deps = {}) {
780
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
781
+ if (!idRicercaValido(id)) return null;
782
+ try {
783
+ return await readFileFn(percorsoRapporto(cartella, id), 'utf8');
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ /**
790
+ * ⭐⭐⭐ LA FORMA MINIMA DI UN RAPPORTO — il cancello di consegna del disegno §6.5.
791
+ *
792
+ * ⛔ È DICHIARATAMENTE MINIMA, e il perché sta scritto qui invece che in una nota: il record
793
+ * recintato vero (piano, linee, affermazioni con verdetto, fonti con citazione) arriva da
794
+ * `src/research/report.mjs`, che un altro agente sta portando dal mobile in parallelo. Fino
795
+ * ad allora questa funzione controlla TRE cose sole, e `research-orchestrator.mjs` accetta un
796
+ * `rileggiRapportoFn` iniettabile perché il giorno in cui quel record esiste si cambia il
797
+ * PUNTO DI INNESTO, non la macchina degli stati.
798
+ *
799
+ * Le tre cose, e perché proprio queste:
800
+ * 1. un'INTESTAZIONE (una riga che comincia con `#`) — un rapporto ha un titolo; una scusa no;
801
+ * 2. almeno un'AFFERMAZIONE — una riga di prosa fuori dalla sezione fonti;
802
+ * 3. almeno una FONTE — un URL http(s) dentro una sezione fonti/sources/riferimenti.
803
+ *
804
+ * ⛔ Il punto 3 è quello che la scusa di stasera non poteva superare in nessun modo, ed è anche
805
+ * quello che la ricerca accademica indica come la misura da mostrare: «Sci-MMR»
806
+ * (arXiv:2609.11243, 10/09/2026) misura che l'accuratezza della risposta supera di oltre 20
807
+ * punti il recupero completo delle PROVE — cioè si risponde bene senza avere le prove. Un
808
+ * rapporto senza una fonte non è un rapporto corto: è un rapporto senza il pezzo che conta.
809
+ *
810
+ * ⛔ NON controlla la lunghezza minima e NON giudica la qualità: un rapporto breve ma
811
+ * documentato passa, ed è giusto così. Il cancello dice «c'è un artefatto rileggibile», non
812
+ * «è un buon artefatto» — confonderli produrrebbe la bugia opposta.
813
+ *
814
+ * @returns {{ok:boolean, intestazione:string|null, affermazioni:number, fonti:string[], motivo:string|null}}
815
+ */
816
+ export function rileggiRapportoMinimo(testo) {
817
+ if (typeof testo !== 'string' || testo.trim().length === 0) {
818
+ return { ok: false, intestazione: null, affermazioni: 0, fonti: [], motivo: 'il rapporto è vuoto' };
819
+ }
820
+ const righe = testo.split(/\r?\n/);
821
+ const rigaTitolo = righe.find((r) => /^#{1,6}\s+\S/.test(r.trim()));
822
+ const intestazione = rigaTitolo ? rigaTitolo.trim().replace(/^#{1,6}\s+/, '') : null;
823
+ /*
824
+ * ⛔ «Dentro la sezione fonti» si decide riga per riga, non cercando gli URL ovunque: un URL
825
+ * citato in mezzo alla prosa è una citazione, non la bibliografia — e un rapporto che elenca
826
+ * le sue fonti è esattamente ciò che «Cited but Not Verified» (arXiv:2605.06635, 07/05/2026)
827
+ * misura come il pezzo che gli agenti di ricerca profonda sbagliano di più: la fonte sostiene
828
+ * davvero l'affermazione solo il 39-77% delle volte. Verificarle richiede prima di AVERLE.
829
+ */
830
+ const INTESTAZIONE_FONTI = /^#{1,6}\s*(fonti|sources|riferimenti|references|bibliografia)\b/i;
831
+ let inFonti = false;
832
+ const fonti = [];
833
+ let affermazioni = 0;
834
+ for (const grezza of righe) {
835
+ const riga = grezza.trim();
836
+ if (/^#{1,6}\s+\S/.test(riga)) {
837
+ inFonti = INTESTAZIONE_FONTI.test(riga);
838
+ continue;
839
+ }
840
+ if (riga.length === 0) continue;
841
+ if (inFonti) {
842
+ const url = riga.match(/https?:\/\/[^\s)<>\]"']+/g);
843
+ if (url) fonti.push(...url);
844
+ continue;
845
+ }
846
+ // una riga di prosa: non un separatore orizzontale, non una riga vuota.
847
+ if (/^[-*_=\s|]+$/.test(riga)) continue;
848
+ affermazioni += 1;
849
+ }
850
+ if (!intestazione) return { ok: false, intestazione: null, affermazioni, fonti, motivo: 'il rapporto non ha un\'intestazione' };
851
+ if (affermazioni === 0) return { ok: false, intestazione, affermazioni, fonti, motivo: 'il rapporto non contiene nessuna affermazione' };
852
+ if (fonti.length === 0) return { ok: false, intestazione, affermazioni, fonti, motivo: 'il rapporto non elenca nessuna fonte' };
853
+ return { ok: true, intestazione, affermazioni, fonti, motivo: null };
854
+ }
855
+
856
+ /*
857
+ * ═══════════════════════════════════════════════════════════════════════════
858
+ * L4 — IL GIORNALE, IL PIANO E LE FONTI TENUTE
859
+ * ═══════════════════════════════════════════════════════════════════════════
860
+ */
861
+
862
+ /**
863
+ * ⭐⭐⭐ LA CODA PER PERCORSO — copiata, non reinventata, da `session-store.mjs` (W0-07, 04/09).
864
+ *
865
+ * ⛔ Perché serve, e non è prudenza teorica: nello store dell'owner il file `b7b1b7d2…` (31/08)
866
+ * aveva quattro righe rotte, la prima spezzata a **1.572.866 byte, esattamente 1,5 MiB**,
867
+ * perché due `appendFile` concorrenti si erano intrecciati. `O_APPEND` è atomico per SINGOLA
868
+ * chiamata di scrittura: un record grande viene spezzato in più chiamate, ed è lì che due
869
+ * scrittori si infilano l'uno dentro l'altro. ⇒ le scritture sullo STESSO file si mettono in
870
+ * fila; file diversi non si aspettano (la chiave è il percorso), così una ricerca lenta non
871
+ * rallenta le altre.
872
+ * ⛔ Un errore su una scrittura non blocca la coda: la successiva parte comunque.
873
+ */
874
+ const codeDelGiornale = new Map();
875
+
876
+ /**
877
+ * ⭐⭐⭐ UN EVENTO NEL GIORNALE — SOLO APPEND, MAI UNA RISCRITTURA.
878
+ *
879
+ * `evento` è uno degli undici che `src/research/run.mjs` conosce (`TalosResearchEvent`): questo
880
+ * file non li interpreta, li mette a registro. ⛔ Il giornale porta FATTI e RIFERIMENTI, mai il
881
+ * carico: il testo di una pagina sta in `fonti/<sha256>.txt` e nel giornale ne compare il nome
882
+ * (`resultRef`). Un giornale che porta cento kilobyte per riga è un giornale che nessuno
883
+ * rigioca.
884
+ *
885
+ * ⭐ `flush: true` di default — ed è la DIFFERENZA dichiarata rispetto a `session-store.mjs`,
886
+ * che di proposito non fa `fsync` a ogni riga. La ragione è che le due cose non hanno lo
887
+ * stesso valore: là ogni riga è un evento di interfaccia fra migliaia, qui una riga è un passo
888
+ * di ricerca PAGATO, e sono pochi per corsa. La ricerca del 04/09 lo diceva già e allora era
889
+ * restato debito: «una scrittura riuscita vive nella cache del kernel finché non c'è un
890
+ * `fsync`». Qui il debito si chiude, perché qui si può permettere.
891
+ *
892
+ * @returns {Promise<void>}
893
+ */
894
+ export async function accodaEvento({ cartella, id, evento, durevole = true, separaRiga = false }, deps = {}) {
895
+ const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
896
+ const appendFileFn = deps.appendFileFn ?? fsp.appendFile;
897
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
898
+ if (!evento || typeof evento !== 'object' || typeof evento.kind !== 'string' || evento.kind.length === 0) {
899
+ throw new ResearchStoreError('Un evento del giornale vuole un `kind`', 'RESEARCH_INVALID');
900
+ }
901
+ const percorso = percorsoGiornale(cartella, id);
902
+ // ⛔ Serializzato SUBITO, non dentro la coda: `evento` potrebbe cambiare mentre aspetta il turno.
903
+ // BC-49: una riga mozzata dal crash non deve assorbire il checkpoint successivo.
904
+ const riga = `${separaRiga ? '\n' : ''}${JSON.stringify(evento)}\n`;
905
+ const precedente = codeDelGiornale.get(percorso) ?? Promise.resolve();
906
+ const corrente = precedente.catch(() => {}).then(async () => {
907
+ await mkdirFn(dirname(percorso), { recursive: true });
908
+ await appendFileFn(percorso, riga, durevole ? { encoding: 'utf8', flush: true } : 'utf8');
909
+ });
910
+ codeDelGiornale.set(percorso, corrente);
911
+ corrente.catch(() => {}).finally(() => {
912
+ if (codeDelGiornale.get(percorso) === corrente) codeDelGiornale.delete(percorso);
913
+ });
914
+ return corrente;
915
+ }
916
+
917
+ /**
918
+ * ⭐⭐⭐ IL GIORNALE RILETTO — E NON SI RIFIUTA MAI DI CARICARE.
919
+ *
920
+ * ⛔ Una riga illeggibile si **salta**, ovunque sia, e si conta. È una deviazione VOLUTA da
921
+ * `session-store.leggiRegistro`, che invece lancia su una riga rotta che non sia l'ultima, e
922
+ * il motivo è scritto nella testa di `src/research/run.mjs`: «l'unica cosa che non deve fare
923
+ * mai è rifiutarsi di caricare: un giro che non si può rigiocare è un giro il cui lavoro
924
+ * pagato è perso». Là il file è una trascrizione da mostrare; qui è la prova di ciò che è
925
+ * stato speso, e perderla tutta per una riga è il guasto peggiore dei due.
926
+ * ⛔ `righeSaltate` non è decorazione: senza quel numero «il giornale si è caricato» e «il
927
+ * giornale si è caricato per intero» sarebbero la stessa frase, e non lo sono.
928
+ *
929
+ * @returns {Promise<{eventi: object[], righeSaltate: number, byte: number}>}
930
+ */
931
+ export async function leggiGiornale({ cartella, id, rigoroso = false }, deps = {}) {
932
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
933
+ const vuoto = { eventi: [], righeSaltate: 0, byte: 0 };
934
+ if (!idRicercaValido(id)) return vuoto;
935
+ let testo;
936
+ try {
937
+ testo = await readFileFn(percorsoGiornale(cartella, id), 'utf8');
938
+ } catch (errore) {
939
+ if (rigoroso && errore?.code !== 'ENOENT') throw errore;
940
+ return vuoto; // nessun giornale è uno stato onesto: una ricerca vecchia non ne ha mai avuto uno.
941
+ }
942
+ const eventi = [];
943
+ let righeSaltate = 0;
944
+ for (const riga of testo.split('\n')) {
945
+ if (riga.trim() === '') continue;
946
+ try {
947
+ const letto = JSON.parse(riga);
948
+ if (letto && typeof letto === 'object' && typeof letto.kind === 'string') eventi.push(letto);
949
+ else righeSaltate += 1;
950
+ } catch {
951
+ righeSaltate += 1; // riga mozzata da un processo morto a metà `appendFile`, o rumore: si salta.
952
+ }
953
+ }
954
+ return { eventi, righeSaltate, byte: Buffer.byteLength(testo, 'utf8') };
955
+ }
956
+
957
+ /** Il piano approvato, scritto atomicamente. `piano` è `TalosResearchBranch[]` (vedi `run.mjs`). */
958
+ export async function scriviPiano({ cartella, id, piano }, deps = {}) {
959
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
960
+ await scriviAtomico(percorsoPiano(cartella, id), JSON.stringify(piano, null, 2), deps);
961
+ return percorsoPiano(cartella, id);
962
+ }
963
+
964
+ /** @returns {Promise<object|null>} — `null` se non c'è (o è illeggibile): un piano assente non è un guasto. */
965
+ export async function leggiPiano({ cartella, id }, deps = {}) {
966
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
967
+ if (!idRicercaValido(id)) return null;
968
+ try {
969
+ return JSON.parse(await readFileFn(percorsoPiano(cartella, id), 'utf8'));
970
+ } catch {
971
+ return null;
972
+ }
973
+ }
974
+
975
+ /**
976
+ * ⭐⭐⭐ IL TESTO TENUTO DI UNA FONTE, INDIRIZZATO DAL CONTENUTO.
977
+ *
978
+ * Il nome del file è lo `sha256` del testo. Tre cose vengono gratis, e nessuna è un vezzo:
979
+ * 1. **due linee d'indagine che leggono la stessa pagina la tengono una volta sola** — è il
980
+ * caso normale (§6.2, e la cache del fetch di L6 punta allo stesso risparmio);
981
+ * 2. **niente si sovrascrive mai**: stesso contenuto ⇒ stesso nome ⇒ la seconda scrittura non
982
+ * ha niente da cambiare, e si salta. È il vincolo «ciò che è costato denaro» applicato
983
+ * senza dover ricordare di applicarlo;
984
+ * 3. il `resultRef` del giornale è un nome **stabile e verificabile**: chi rilegge può
985
+ * ricalcolare l'impronta e accorgersi se il file è stato manomesso.
986
+ *
987
+ * @returns {Promise<{ref:string, percorso:string, byte:number, giaPresente:boolean}>}
988
+ */
989
+ export async function scriviFonte({ cartella, id, testo }, deps = {}) {
990
+ const mkdirFn = deps.mkdirFn ?? fsp.mkdir;
991
+ const statFn = deps.statFn ?? fsp.stat;
992
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
993
+ if (typeof testo !== 'string' || testo.length === 0) {
994
+ throw new ResearchStoreError('Una fonte tenuta vuole del testo', 'RESEARCH_INVALID');
995
+ }
996
+ const impronta = createHash('sha256').update(testo, 'utf8').digest('hex');
997
+ const ref = `${CARTELLA_FONTI}/${impronta}.txt`;
998
+ const percorso = join(cartellaDelleFonti(cartella, id), `${impronta}.txt`);
999
+ await mkdirFn(cartellaDelleFonti(cartella, id), { recursive: true });
1000
+ try {
1001
+ await statFn(percorso);
1002
+ return { ref, percorso, byte: Buffer.byteLength(testo, 'utf8'), giaPresente: true };
1003
+ } catch { /* non c'è ancora: si scrive. */ }
1004
+ await scriviAtomico(percorso, testo, deps);
1005
+ return { ref, percorso, byte: Buffer.byteLength(testo, 'utf8'), giaPresente: false };
1006
+ }
1007
+
1008
+ /**
1009
+ * Rilegge una fonte tenuta dal suo `ref` (`fonti/<sha256>.txt`).
1010
+ *
1011
+ * ⛔ Il `ref` arriva dal giornale, cioè da un file su disco che qualcuno potrebbe aver
1012
+ * modificato: si accetta **solo** la forma esatta `fonti/<64 esadecimali>.txt`, e in più il
1013
+ * percorso risolto si ricontrolla con `dentroLaRadice`. Due difese indipendenti sullo stesso
1014
+ * confine, come già per il deposito del rapporto (L1) — mai una sola.
1015
+ *
1016
+ * @returns {Promise<string|null>}
1017
+ */
1018
+ export async function leggiFonte({ cartella, id, ref }, deps = {}) {
1019
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
1020
+ if (!idRicercaValido(id)) return null;
1021
+ const combacia = typeof ref === 'string' ? ref.match(/^fonti\/([0-9a-f]{64})\.txt$/) : null;
1022
+ if (!combacia) return null;
1023
+ const percorso = join(cartellaDelleFonti(cartella, id), `${combacia[1]}.txt`);
1024
+ if (!dentroLaRadice(cartellaDellaRicerca(cartella, id), percorso)) return null;
1025
+ try {
1026
+ return await readFileFn(percorso, 'utf8');
1027
+ } catch {
1028
+ return null;
1029
+ }
1030
+ }
1031
+
1032
+ /**
1033
+ * ⭐⭐⭐ L9 — L'INDICE delle fonti tenute: url → ref, più ciò che la fonte dichiara di sé.
1034
+ *
1035
+ * ⛔ Si riscrive per intero e in modo ATOMICO, mai in append: è una mappa, non un registro, e
1036
+ * una mappa scritta a pezzi può finire con due voci per lo stesso indirizzo che si
1037
+ * contraddicono. Il registro solo-append è il giornale, e ha un altro mestiere.
1038
+ * ⛔ Una voce nuova NON cancella una vecchia con lo stesso url a meno che non sia più FORTE:
1039
+ * `page` batte `snippet`, e mai il contrario — una prova più debole non deve poter
1040
+ * sostituire una più forte (`raccolta-viva.mjs` fa la stessa scelta in memoria).
1041
+ *
1042
+ * @param {{cartella: string, id: string, voci: readonly object[]}} input
1043
+ */
1044
+ export async function scriviIndiceFonti({ cartella, id, voci }, deps = {}) {
1045
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
1046
+ const elenco = Array.isArray(voci) ? voci : [];
1047
+ await scriviAtomico(percorsoIndiceFonti(cartella, id), JSON.stringify(elenco, null, 2), deps);
1048
+ return percorsoIndiceFonti(cartella, id);
1049
+ }
1050
+
1051
+ /** @returns {Promise<readonly object[]>} — `[]` se non c'è o è illeggibile: un indice assente non è un guasto. */
1052
+ export async function leggiIndiceFonti({ cartella, id }, deps = {}) {
1053
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
1054
+ if (!idRicercaValido(id)) return [];
1055
+ try {
1056
+ const letto = JSON.parse(await readFileFn(percorsoIndiceFonti(cartella, id), 'utf8'));
1057
+ return Array.isArray(letto) ? letto : [];
1058
+ } catch {
1059
+ return [];
1060
+ }
1061
+ }
1062
+
1063
+ /** I `ref` delle fonti tenute, in ordine stabile. `[]` se non ce ne sono (mai un errore). */
1064
+ export async function elencaFonti({ cartella, id }, deps = {}) {
1065
+ const readdirFn = deps.readdirFn ?? fsp.readdir;
1066
+ if (!idRicercaValido(id)) return [];
1067
+ try {
1068
+ const nomi = await readdirFn(cartellaDelleFonti(cartella, id));
1069
+ return nomi.filter((n) => /^[0-9a-f]{64}\.txt$/.test(n)).sort().map((n) => `${CARTELLA_FONTI}/${n}`);
1070
+ } catch {
1071
+ return [];
1072
+ }
1073
+ }
1074
+
1075
+ /**
1076
+ * ⭐ L4 — L'IMPRONTA DEL RAPPORTO, per la cache dell'elenco.
1077
+ *
1078
+ * `elenca()` adesso rilegge il rapporto di ogni voce `done` (la lista mentiva: mostrava
1079
+ * «Conclusa» dove il dettaglio diceva «senza rapporto»). Venti letture a ogni apertura della
1080
+ * sezione però si pagano, e si pagherebbero anche quando non è cambiato niente: questa funzione
1081
+ * dà `mtimeMs` e `size`, che sono la chiave con cui il lettore sa se può riusare il giudizio già
1082
+ * dato. ⛔ `null` se il rapporto non c'è: un'assenza non è un guasto, ed è essa stessa una
1083
+ * risposta valida da mettere in cache.
1084
+ *
1085
+ * @returns {Promise<{mtimeMs:number, size:number}|null>}
1086
+ */
1087
+ export async function statRapporto({ cartella, id }, deps = {}) {
1088
+ const statFn = deps.statFn ?? fsp.stat;
1089
+ if (!idRicercaValido(id)) return null;
1090
+ try {
1091
+ const s = await statFn(percorsoRapporto(cartella, id));
1092
+ return { mtimeMs: Number(s.mtimeMs), size: Number(s.size) };
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+
1098
+ /** L'istantanea della cache del fetch, accanto al giornale. */
1099
+ export const NOME_CACHE_FETCH = 'cache.json';
1100
+
1101
+ export function percorsoCacheFetch(cartella, id) {
1102
+ return join(cartella, CARTELLA_RICERCA, id, NOME_CACHE_FETCH);
1103
+ }
1104
+
1105
+ /**
1106
+ * ⭐⭐⭐ L4 + L6 — L'ISTANTANEA DELLA CACHE DEL FETCH, SU DISCO.
1107
+ *
1108
+ * `src/research/fetch-cache.mjs` produce con `snapshot()` un oggetto JSON-serializzabile e
1109
+ * dichiara, nella sua stessa doc, che «questo modulo non scrive niente su disco»: la scrittura
1110
+ * è di chi persiste, cioè di qui. ⛔ Atomica come tutto il resto — un'istantanea scritta a metà
1111
+ * farebbe ripagare pagine già pagate, che è esattamente il costo che esiste per evitare.
1112
+ */
1113
+ export async function scriviIstantaneaCache({ cartella, id, istantanea }, deps = {}) {
1114
+ if (!idRicercaValido(id)) throw new ResearchStoreError(`id di ricerca non valido: ${String(id)}`, 'RESEARCH_INVALID');
1115
+ await scriviAtomico(percorsoCacheFetch(cartella, id), JSON.stringify(istantanea), deps);
1116
+ return percorsoCacheFetch(cartella, id);
1117
+ }
1118
+
1119
+ /**
1120
+ * @returns {Promise<object|null>} — `null` se non c'è o non è JSON.
1121
+ * ⛔ Non valida la VERSIONE: quella la controlla `restore()` nel modulo che la possiede, e
1122
+ * duplicare qui il numero di versione creerebbe due posti che divergono. Qui si dice solo se
1123
+ * c'è qualcosa di leggibile; il giudizio su cosa farne è di chi sa cos'è.
1124
+ */
1125
+ export async function leggiIstantaneaCache({ cartella, id }, deps = {}) {
1126
+ const readFileFn = deps.readFileFn ?? fsp.readFile;
1127
+ if (!idRicercaValido(id)) return null;
1128
+ try {
1129
+ return JSON.parse(await readFileFn(percorsoCacheFetch(cartella, id), 'utf8'));
1130
+ } catch {
1131
+ return null;
1132
+ }
1133
+ }