failproofai 1.0.2-beta.3 → 1.0.2-beta.5

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 (210) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +2 -3
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -6
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +3 -4
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/.next/server/app/index.html +1 -1
  43. package/.next/standalone/.next/server/app/index.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  45. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  46. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  47. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  48. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  49. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  52. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  53. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  54. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  57. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +3 -3
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0e32-7c._.js → [externals]__1qzqcio._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
  84. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0igxt32._.js → [root-of-the-server]__1p8poxd._.js} +2 -2
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1zkek3z._.js +3 -0
  86. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +3 -0
  87. package/.next/standalone/.next/server/chunks/_12moi5-._.js +3 -0
  88. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +6 -5
  89. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +3 -0
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js} +1 -1
  94. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +4 -4
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  106. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__08zpry-._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
  107. package/.next/standalone/.next/server/chunks/ssr/{src_hooks_08o7bqo._.js → [root-of-the-server]__15r6r7w._.js} +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  111. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1tag8_-._.js +5 -0
  113. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
  116. package/.next/standalone/.next/server/chunks/ssr/{_11opd9e._.js → _1kesqs-._.js} +2 -2
  117. package/.next/standalone/.next/server/chunks/ssr/{_15xtekm._.js → _1km9-gn._.js} +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
  125. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  128. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  129. package/.next/standalone/.next/server/middleware.js +1 -1
  130. package/.next/standalone/.next/server/pages/404.html +1 -1
  131. package/.next/standalone/.next/server/pages/500.html +1 -1
  132. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  134. package/.next/standalone/.next/static/chunks/0tyt-3a5rtzy8.js +1 -0
  135. package/.next/standalone/.next/static/chunks/{1mk3vmmc673hu.js → 21r7iw3rmixf5.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{10pok-mrqv426.js → 26lfjhb2kzzgb.js} +1 -1
  137. package/.next/standalone/.next/static/chunks/2_27jraat038x.js +1 -0
  138. package/.next/standalone/.next/static/chunks/2hwc8e923g5df.js +1 -0
  139. package/.next/standalone/.next/static/chunks/{3cs_rf5p765v0.js → 33np9nrh1mvj9.js} +1 -1
  140. package/.next/standalone/.next/static/chunks/{1oml7c7env4-g.js → 354-n7p7labpn.js} +1 -1
  141. package/.next/standalone/.next/static/chunks/36-8i_vbkh85w.js +1 -0
  142. package/.next/standalone/.next/static/chunks/{2jsomd79g7kn0.js → 391tpdqcas7rq.js} +1 -1
  143. package/.next/standalone/.next/static/chunks/{1yywl_a87108w.js → 3_b73tc78jwsq.js} +2 -2
  144. package/.next/standalone/.next/static/chunks/{1za08zjcctyil.js → 3i8rg2fv9nd6e.js} +2 -2
  145. package/.next/standalone/.next/static/chunks/{2ey_ih6j6cm15.js → 3jqm8ubpv3i-4.js} +1 -1
  146. package/.next/standalone/.next/static/chunks/turbopack-2x-o099q64i8h.js +1 -0
  147. package/.next/standalone/SECURITY.md +18 -3
  148. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +7 -0
  149. package/.next/standalone/fp-cloud-cli/uv.lock +6 -6
  150. package/.next/standalone/lib/opencode-db.ts +77 -0
  151. package/.next/standalone/lib/opencode-projects.ts +37 -41
  152. package/.next/standalone/lib/opencode-sessions.ts +59 -36
  153. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  154. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  155. package/.next/standalone/node_modules/next/dist/client/components/segment-cache/cache.js +23 -34
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  160. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  161. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  162. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  163. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  164. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  166. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  168. package/.next/standalone/node_modules/next/package.json +10 -10
  169. package/.next/standalone/package.json +19 -19
  170. package/.next/standalone/sdk/python/CHANGELOG.md +7 -0
  171. package/.next/standalone/sdk/python/uv.lock +23 -23
  172. package/.next/standalone/server.js +1 -1
  173. package/bin/failproofai.mjs +151 -60
  174. package/dist/cli.mjs +3670 -3005
  175. package/dist/worker.mjs +189 -164
  176. package/lib/opencode-db.ts +77 -0
  177. package/lib/opencode-projects.ts +37 -41
  178. package/lib/opencode-sessions.ts +59 -36
  179. package/package.json +19 -19
  180. package/src/audit/cache.ts +201 -40
  181. package/src/audit/cli-adapters/claude.ts +111 -1
  182. package/src/audit/cli-adapters/index.ts +31 -1
  183. package/src/audit/index.ts +131 -11
  184. package/src/audit/replay.ts +15 -91
  185. package/src/hooks/configure-wizard.ts +16 -48
  186. package/src/hooks/handler.ts +8 -1
  187. package/src/hooks/manager.ts +72 -3
  188. package/src/hooks/pack-cli.ts +793 -45
  189. package/src/hooks/pack-manifest.ts +28 -0
  190. package/src/hooks/pack-store.ts +8 -25
  191. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +0 -3
  192. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +0 -3
  193. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +0 -3
  194. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
  195. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
  196. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
  197. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +0 -3
  198. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +0 -3
  199. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +0 -3
  200. package/.next/standalone/.next/server/chunks/_185gyk9._.js +0 -3
  201. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +0 -12
  202. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +0 -5
  203. package/.next/standalone/.next/static/chunks/01nuy-aqshahr.js +0 -1
  204. package/.next/standalone/.next/static/chunks/1pl7h8vllvoi2.js +0 -1
  205. package/.next/standalone/.next/static/chunks/2efnffq2ipgr_.js +0 -1
  206. package/.next/standalone/.next/static/chunks/2gd07pinz0i7f.js +0 -1
  207. package/.next/standalone/.next/static/chunks/turbopack-3kjgdgwci4tqf.js +0 -1
  208. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
  209. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
  210. /package/.next/standalone/.next/static/{G-faMkpZioETgewZ4X4wX → 59jsH11LoyXu9UBoHvgGM}/_ssgManifest.js +0 -0
@@ -10,35 +10,38 @@
10
10
  * sessions live in a SQLite DB rather than a file with a stable mtime).
11
11
  */
12
12
  import { createHash } from "node:crypto";
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, openSync, readSync, closeSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  import { BUILTIN_POLICIES } from "../hooks/builtin-policies";
16
- import { readInstalledPacks } from "../hooks/pack-manifest";
17
16
  import { AUDIT_DETECTORS } from "./detectors";
18
- import type { TranscriptAuditResult } from "./types";
17
+ import type { TranscriptAuditResult, DetectorSessionState } from "./types";
19
18
  import { auditCacheDir } from "../hooks/fp-home";
20
19
 
21
20
  let cachedEngineVersion: string | null = null;
22
21
  let cachedDetectorVersion: string | null = null;
23
22
 
24
23
  /**
25
- * Hash of every builtin policy's name + function body, PLUS the identity of every
26
- * installed pack. Changes when policy code changes, invalidating downstream
27
- * caches.
24
+ * Hash of every builtin policy's name and function body. Changes when policy
25
+ * code changes, invalidating downstream caches.
28
26
  *
29
- * Packs belong in this key because they change what a machine would have caught,
30
- * which is exactly what a cached audit result claims to know. They are folded in
31
- * by `id|version|sha256` rather than by source text: the pack's bytes are already
32
- * digest-pinned, and hashing the loaded source instead would drag in the
33
- * per-load temporary filename the loader rewrites into every import specifier —
34
- * which changes on every single run and would cold-rescan the whole history each
35
- * time.
27
+ * Nothing else is in it, and specifically not installed packs. The audit
28
+ * evaluates the implementations compiled into this build and only those —
29
+ * `replay.ts` iterates `BUILTIN_POLICIES` and never reads the installed packs —
30
+ * so a pack cannot move an audit result, and keying on one meant every install
31
+ * or removal cold-rescanned the entire history to reproduce the answers it
32
+ * already had. The key it replaced folded packs in by `id|version|sha256`; the
33
+ * rationale was that packs "change what a machine would have caught", which is
34
+ * true of enforcement and was never true of this replay.
36
35
  *
37
- * **A machine with no packs must hash byte-identically to a build with no pack
38
- * support at all.** Otherwise merely shipping this feature invalidates every
39
- * user's audit cache and forces a ~104-second cold rescan on upgrade, for a
40
- * capability they are not using. Hence the empty pack set contributes nothing —
41
- * not an empty line, not a separator.
36
+ * **A machine with no packs hashes byte-identically to the old key**, because
37
+ * that one contributed nothing for an empty pack set — not an empty line, not a
38
+ * separator. So this change costs a rescan only to machines that had a pack
39
+ * installed, and costs it exactly once.
40
+ *
41
+ * It is deliberately NOT the source text of anything loaded at runtime: the
42
+ * loader rewrites a per-load temporary filename into every import specifier, so
43
+ * hashing loaded source would change on every single run and cold-rescan the
44
+ * whole history each time.
42
45
  */
43
46
  function getEngineVersion(): string {
44
47
  if (cachedEngineVersion) return cachedEngineVersion;
@@ -46,19 +49,21 @@ function getEngineVersion(): string {
46
49
  .map((p) => `${p.name}|${p.fn.toString()}`)
47
50
  .sort()
48
51
  .join("\n");
49
- // Never throws by contract, but this runs inside the audit's own try/catch-free
50
- // path and a cache key is not worth taking the run down for.
51
- let packBlob = "";
52
- try {
53
- packBlob = readInstalledPacks()
54
- .packs.map((pack) => `${pack.id}|${pack.version}|${pack.sha256}`)
55
- .sort()
56
- .join("\n");
57
- } catch {
58
- packBlob = "";
59
- }
60
- const blob = packBlob ? `${builtinBlob}\n${packBlob}` : builtinBlob;
61
- cachedEngineVersion = createHash("sha1").update(blob).digest("hex").slice(0, 16);
52
+ // Packs are NOT in this key, and that is the whole point of the audit's design
53
+ // rather than an omission.
54
+ //
55
+ // `replay.ts` evaluates `BUILTIN_POLICIES` and nothing else — it never reads
56
+ // `readInstalledPacks()`, and third-party pack policies are never replayed. So
57
+ // an installed pack cannot change an audit result, and folding its identity in
58
+ // here invalidated every cached transcript on a change that provably produces
59
+ // the same answers: a full rescan of thousands of transcripts to arrive back
60
+ // where it started. That was survivable while packs were rare; policies ARE
61
+ // packs now, so installing or removing one is routine.
62
+ //
63
+ // If audit is ever extended to evaluate what a pack would actually have
64
+ // caught, this key has to carry pack identity again — and that change belongs
65
+ // in the same commit as the one that makes it true.
66
+ cachedEngineVersion = createHash("sha1").update(builtinBlob).digest("hex").slice(0, 16);
62
67
  return cachedEngineVersion;
63
68
  }
64
69
 
@@ -105,7 +110,29 @@ function getCachePathFor(transcriptPath: string): string {
105
110
  * accidentally on some paths) so we reject them outright and force a
106
111
  * re-scan.
107
112
  */
108
- export const CACHE_SCHEMA_VERSION = 3;
113
+ /**
114
+ * v4: entries carry `bytesScanned`, `anchorHash` and `detectorState` so a
115
+ * transcript that GREW can be resumed from where the last scan stopped
116
+ * instead of being read from byte zero. v3 entries have none of the three;
117
+ * they are rejected rather than treated as "scanned 0 bytes", which would
118
+ * re-scan everything anyway but silently, and rather than as "scanned it
119
+ * all", which would drop every event appended since.
120
+ */
121
+ export const CACHE_SCHEMA_VERSION = 4;
122
+
123
+ /**
124
+ * How much of the head and of the resume boundary is hashed to decide the
125
+ * bytes already scanned are still the same bytes.
126
+ *
127
+ * A transcript is append-only in normal operation, so a grown file is
128
+ * ordinarily the same file plus more. "Ordinarily" is not a guarantee: a
129
+ * compaction can rewrite one in place. Re-reading the whole prefix to be sure
130
+ * would cost exactly what this change exists to avoid, so two cheap windows
131
+ * stand in for it — the first bytes of the file, and the last bytes before the
132
+ * offset being resumed from. A rewrite that leaves both intact and diverges
133
+ * only in between is not a shape any of these writers produce.
134
+ */
135
+ const ANCHOR_WINDOW = 4096;
109
136
 
110
137
  /** Hard expiry: a cached transcript result is rejected on read once it's
111
138
  * this old. Keeps the audit pipeline honest about long-lived results that
@@ -136,39 +163,163 @@ interface CacheEntry {
136
163
  engineVersion: string;
137
164
  detectorVersion: string;
138
165
  result: TranscriptAuditResult;
166
+ /**
167
+ * How far into the file the recorded result actually accounts for, at a LINE
168
+ * boundary. Not the same as `sizeBytes`: a transcript being appended to while
169
+ * the audit reads it ends in a partial line, which is not parsed and must not
170
+ * be counted as scanned or the events in it are lost forever.
171
+ */
172
+ bytesScanned?: number;
173
+ /** Head + boundary fingerprint of the bytes already scanned. */
174
+ anchorHash?: string;
175
+ /** Stateful detectors' session state at `bytesScanned`, Map-aware encoded. */
176
+ detectorState?: string;
139
177
  }
140
178
 
141
- export function readCachedTranscriptResult(
179
+ /** What a cache lookup can say. */
180
+ export type CacheLookup =
181
+ | { kind: "hit"; result: TranscriptAuditResult }
182
+ | {
183
+ kind: "resume";
184
+ result: TranscriptAuditResult;
185
+ fromByte: number;
186
+ detectorState: DetectorSessionState;
187
+ };
188
+
189
+ /**
190
+ * `Map` does not survive `JSON.stringify` — it encodes as `{}`, silently. The
191
+ * one stateful detector keeps its state in one, so a plain round-trip would
192
+ * hand back an empty countdown and quietly change what the detector finds
193
+ * across a resume boundary. Encoded generically rather than per-detector so a
194
+ * detector added later cannot forget to do it.
195
+ */
196
+ function encodeDetectorState(state: DetectorSessionState): string {
197
+ return JSON.stringify(state, (_k, v) =>
198
+ v instanceof Map ? { __map: [...v.entries()] } : v,
199
+ );
200
+ }
201
+
202
+ function decodeDetectorState(raw: string | undefined): DetectorSessionState {
203
+ if (!raw) return {};
204
+ try {
205
+ return JSON.parse(raw, (_k, v) =>
206
+ v && typeof v === "object" && Array.isArray((v as { __map?: unknown }).__map)
207
+ ? new Map((v as { __map: [unknown, unknown][] }).__map)
208
+ : v,
209
+ ) as DetectorSessionState;
210
+ } catch {
211
+ return {};
212
+ }
213
+ }
214
+
215
+ /**
216
+ * A fingerprint of the head of the file and of the bytes immediately before
217
+ * `upTo`, which together stand in for "the prefix has not been rewritten".
218
+ * Returns null when the file cannot be read that far — which is itself an
219
+ * answer: do not resume.
220
+ */
221
+ export function anchorHashFor(transcriptPath: string, upTo: number): string | null {
222
+ if (upTo <= 0) return null;
223
+ let fd: number | null = null;
224
+ try {
225
+ fd = openSync(transcriptPath, "r");
226
+ const head = Buffer.alloc(Math.min(ANCHOR_WINDOW, upTo));
227
+ readSync(fd, head, 0, head.length, 0);
228
+ const tailLen = Math.min(ANCHOR_WINDOW, upTo);
229
+ const tail = Buffer.alloc(tailLen);
230
+ readSync(fd, tail, 0, tailLen, upTo - tailLen);
231
+ return createHash("sha1")
232
+ .update(String(upTo))
233
+ .update(head)
234
+ .update(tail)
235
+ .digest("hex")
236
+ .slice(0, 32);
237
+ } catch {
238
+ return null;
239
+ } finally {
240
+ if (fd !== null) {
241
+ try { closeSync(fd); } catch { /* nothing useful to do */ }
242
+ }
243
+ }
244
+ }
245
+
246
+ /**
247
+ * What the cache can offer for this transcript: everything, the part before it
248
+ * grew, or nothing.
249
+ *
250
+ * The exact-match case is unchanged and still the common one. What is new is
251
+ * the middle answer. Keying validity on `(mtime, size)` alone made the cache
252
+ * all-or-nothing per FILE, so a session gaining a single line was re-parsed and
253
+ * re-replayed from byte zero — and the files that gain lines are the long-lived
254
+ * ones, which are also the largest. The cost of an audit tracked the size of
255
+ * whatever was still being written to, rather than the amount actually written
256
+ * since last time.
257
+ */
258
+ export function readCachedTranscript(
142
259
  transcriptPath: string,
143
260
  mtimeMs: number,
144
261
  sizeBytes: number,
145
- ): TranscriptAuditResult | null {
146
- if (sizeBytes === 0) return null; // OpenCode and other DB-backed sources
262
+ ): CacheLookup | null {
263
+ if (sizeBytes === 0) return null; // DB-backed sources have no file to key on
147
264
  const cachePath = getCachePathFor(transcriptPath);
148
265
  if (!existsSync(cachePath)) return null;
149
266
  try {
150
267
  const raw = readFileSync(cachePath, "utf-8");
151
268
  const entry = JSON.parse(raw) as Partial<CacheEntry>;
152
269
  if (entry.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
153
- if (entry.mtimeMs !== mtimeMs) return null;
154
- if (entry.sizeBytes !== sizeBytes) return null;
155
270
  if (entry.engineVersion !== getEngineVersion()) return null;
156
271
  if (entry.detectorVersion !== getDetectorVersion()) return null;
157
272
  // Number.isFinite (not typeof) so a malformed JSON `Infinity` /
158
273
  // `NaN` is rejected — those would otherwise bypass the TTL check
159
274
  // and pin a stale entry as valid forever.
160
275
  if (!Number.isFinite(entry.cachedAt) || Date.now() - (entry.cachedAt as number) > CACHE_TTL_MS) return null;
161
- return entry.result ?? null;
276
+ if (!entry.result) return null;
277
+
278
+ if (entry.mtimeMs === mtimeMs && entry.sizeBytes === sizeBytes) {
279
+ return { kind: "hit", result: entry.result };
280
+ }
281
+
282
+ // Grew. Everything below has to hold before any of the recorded result can
283
+ // be trusted, and each one failing means a full re-scan rather than a
284
+ // guess.
285
+ const from = entry.bytesScanned;
286
+ if (!Number.isFinite(from) || (from as number) <= 0) return null;
287
+ if ((from as number) > sizeBytes) return null; // truncated, or rewritten shorter
288
+ if (!entry.anchorHash) return null;
289
+ if (anchorHashFor(transcriptPath, from as number) !== entry.anchorHash) return null;
290
+
291
+ return {
292
+ kind: "resume",
293
+ result: entry.result,
294
+ fromByte: from as number,
295
+ detectorState: decodeDetectorState(entry.detectorState),
296
+ };
162
297
  } catch {
163
298
  return null;
164
299
  }
165
300
  }
166
301
 
302
+ /**
303
+ * The exact-match half on its own, for callers that cannot resume.
304
+ *
305
+ * Kept because "is this transcript already fully accounted for" is a question
306
+ * worth being able to ask without also being handed a partial answer to merge.
307
+ */
308
+ export function readCachedTranscriptResult(
309
+ transcriptPath: string,
310
+ mtimeMs: number,
311
+ sizeBytes: number,
312
+ ): TranscriptAuditResult | null {
313
+ const found = readCachedTranscript(transcriptPath, mtimeMs, sizeBytes);
314
+ return found?.kind === "hit" ? found.result : null;
315
+ }
316
+
167
317
  export function writeCachedTranscriptResult(
168
318
  transcriptPath: string,
169
319
  mtimeMs: number,
170
320
  sizeBytes: number,
171
321
  result: TranscriptAuditResult,
322
+ resume?: { bytesScanned: number; detectorState: DetectorSessionState },
172
323
  ): void {
173
324
  if (sizeBytes === 0) return;
174
325
  const cachePath = getCachePathFor(transcriptPath);
@@ -183,6 +334,18 @@ export function writeCachedTranscriptResult(
183
334
  detectorVersion: getDetectorVersion(),
184
335
  result,
185
336
  };
337
+ if (resume && resume.bytesScanned > 0) {
338
+ const anchor = anchorHashFor(transcriptPath, resume.bytesScanned);
339
+ // No anchor means no way to prove the prefix later, so the entry is
340
+ // written WITHOUT resume data: still a valid exact-match entry, just not
341
+ // a resumable one. Recording an offset we cannot verify would be worse
342
+ // than recording none.
343
+ if (anchor) {
344
+ entry.bytesScanned = resume.bytesScanned;
345
+ entry.anchorHash = anchor;
346
+ entry.detectorState = encodeDetectorState(resume.detectorState);
347
+ }
348
+ }
186
349
  // Set 0o600 at file-creation time so there's no window where the file
187
350
  // exists with the umask default (typically 0o644). The chmodSync below is
188
351
  // a belt-and-suspenders pass for the case where the file already existed.
@@ -193,8 +356,6 @@ export function writeCachedTranscriptResult(
193
356
  }
194
357
  }
195
358
 
196
- /** Test helper: reset memoized version hashes (so they recompute after a
197
- * detector or policy is monkey-patched in a test). */
198
359
  export function _resetVersionCache(): void {
199
360
  cachedEngineVersion = null;
200
361
  cachedDetectorVersion = null;
@@ -5,7 +5,7 @@
5
5
  * transcripts under <sessionId>/subagents/) via lib/claude-sessions.ts, then
6
6
  * parses each via lib/log-entries.ts.
7
7
  */
8
- import { readFile } from "node:fs/promises";
8
+ import { readFile, open, type FileHandle } from "node:fs/promises";
9
9
  import {
10
10
  listClaudeProjects,
11
11
  listClaudeTranscripts,
@@ -14,6 +14,7 @@ import {
14
14
  import { parseLogContent, type LogSource } from "../../../lib/log-entries";
15
15
  import type { NormalizedToolEvent, TranscriptMetadata } from "../types";
16
16
  import { logEntriesToEvents } from "./shared";
17
+ import type { IncrementalEvents } from "./index";
17
18
 
18
19
  export interface ListOpts {
19
20
  /** Restrict to sessions whose decoded cwd matches one of these paths. */
@@ -95,3 +96,112 @@ export async function streamClaudeEvents(
95
96
  cwd,
96
97
  });
97
98
  }
99
+
100
+ /**
101
+ * The events after `fromByte`, and nothing before it.
102
+ *
103
+ * Claude writes session transcripts as append-only JSONL, which is what makes
104
+ * this sound: the bytes before `fromByte` were parsed on an earlier run and
105
+ * have not moved. The caller has already established that — see
106
+ * `anchorHashFor` in the audit cache — so this reads from the offset and does
107
+ * not re-verify it.
108
+ *
109
+ * Two details carry the correctness:
110
+ *
111
+ * - The read starts at `fromByte`, which the caller only ever sets to a
112
+ * position it previously reported through `bytesConsumed`, so it always
113
+ * lands at the start of a line.
114
+ * - It stops at the last NEWLINE, not at end-of-file. A transcript is
115
+ * frequently being written while the audit reads it, so the tail is often
116
+ * half a line; parsing it would drop or mangle that event, and reporting
117
+ * the file size as consumed would mean the next run started after it and
118
+ * lost it for good.
119
+ */
120
+ export async function streamClaudeEventsFrom(
121
+ meta: TranscriptMetadata,
122
+ fromByte: number,
123
+ ): Promise<IncrementalEvents | null> {
124
+ if (!Number.isFinite(fromByte) || fromByte < 0) return null;
125
+ let handle: FileHandle | null = null;
126
+ let chunk: string;
127
+ try {
128
+ handle = await open(meta.transcriptPath, "r");
129
+ const { size } = await handle.stat();
130
+ if (fromByte > size) return null; // truncated under us; caller re-scans
131
+ if (fromByte === size) {
132
+ return { events: [], bytesConsumed: fromByte };
133
+ }
134
+ const buf = Buffer.alloc(size - fromByte);
135
+ await handle.read(buf, 0, buf.length, fromByte);
136
+ chunk = buf.toString("utf-8");
137
+ } catch {
138
+ return null;
139
+ } finally {
140
+ if (handle) {
141
+ try { await handle.close(); } catch { /* nothing useful to do */ }
142
+ }
143
+ }
144
+
145
+ // Everything up to the last newline is unambiguously complete. What follows
146
+ // it is either a line still being written, or the last line of a file that
147
+ // simply does not end in a newline — and those need opposite treatment:
148
+ // dropping the first is required, dropping the second loses the final event
149
+ // of every completed transcript.
150
+ //
151
+ // The only signal available in the bytes is whether the remainder PARSES. A
152
+ // half-written JSONL record does not (its braces are unclosed); a whole one
153
+ // missing only its newline does. So a parseable tail is taken and counted,
154
+ // and an unparseable one is left for the next run to pick up whole — which
155
+ // is also what stops it being counted twice when it arrives.
156
+ const lastNewline = chunk.lastIndexOf("\n");
157
+ const head = lastNewline === -1 ? "" : chunk.slice(0, lastNewline + 1);
158
+ const tail = chunk.slice(lastNewline + 1);
159
+ const tailIsWholeLine = tail.trim().length > 0 && (() => {
160
+ try {
161
+ JSON.parse(tail);
162
+ return true;
163
+ } catch {
164
+ return false;
165
+ }
166
+ })();
167
+ const complete = tailIsWholeLine ? head + tail : head;
168
+ if (complete.length === 0) {
169
+ // Nothing whole to parse, and the offset does not move.
170
+ return { events: [], bytesConsumed: fromByte };
171
+ }
172
+ // Byte length, not string length — a multi-byte character in the tail would
173
+ // otherwise put the next resume offset mid-character.
174
+ const bytesConsumed = fromByte + Buffer.byteLength(complete, "utf-8");
175
+
176
+ let entries;
177
+ try {
178
+ entries = await parseLogContent(complete, "session" as LogSource);
179
+ } catch {
180
+ return null;
181
+ }
182
+
183
+ let cwd = "";
184
+ for (const line of complete.split("\n", 50)) {
185
+ if (!line.trim()) continue;
186
+ try {
187
+ const parsed = JSON.parse(line) as { cwd?: unknown };
188
+ if (typeof parsed.cwd === "string" && parsed.cwd.length > 0) {
189
+ cwd = parsed.cwd;
190
+ break;
191
+ }
192
+ } catch {
193
+ // skip malformed lines
194
+ }
195
+ }
196
+
197
+ return {
198
+ events: logEntriesToEvents(entries, {
199
+ cli: "claude",
200
+ sessionId: meta.sessionId,
201
+ transcriptPath: meta.transcriptPath,
202
+ cwd,
203
+ }),
204
+ bytesConsumed,
205
+ cwd: cwd || undefined,
206
+ };
207
+ }
@@ -11,7 +11,7 @@ import type { IntegrationType } from "../../hooks/types";
11
11
  import type { NormalizedToolEvent, TranscriptMetadata } from "../types";
12
12
  import type { ListOpts } from "./claude";
13
13
 
14
- import { listClaudeTranscriptMetadata, streamClaudeEvents } from "./claude";
14
+ import { listClaudeTranscriptMetadata, streamClaudeEvents, streamClaudeEventsFrom } from "./claude";
15
15
  import { listCodexTranscriptMetadata, streamCodexEvents } from "./codex";
16
16
  import { listCopilotTranscriptMetadata, streamCopilotEvents } from "./copilot";
17
17
  import { listCursorTranscriptMetadata, streamCursorEvents } from "./cursor";
@@ -26,10 +26,39 @@ import { listGooseTranscriptMetadata, streamGooseEvents } from "./goose";
26
26
 
27
27
  export type { ListOpts };
28
28
 
29
+ /** What an append-only source can hand back when asked to resume. */
30
+ export interface IncrementalEvents {
31
+ events: NormalizedToolEvent[];
32
+ /**
33
+ * How far into the file `events` accounts for, at a LINE boundary — which is
34
+ * NOT the file size when a transcript is being appended to as it is read. The
35
+ * trailing partial line is left unparsed and uncounted so the next run picks
36
+ * it up whole; recording the file size instead would skip it permanently.
37
+ */
38
+ bytesConsumed: number;
39
+ /** The session cwd, when the resumed region carried one. */
40
+ cwd?: string;
41
+ }
42
+
29
43
  export interface CliAdapter {
30
44
  cli: IntegrationType;
31
45
  listTranscripts: (opts?: ListOpts) => Promise<TranscriptMetadata[]>;
32
46
  streamEvents: (meta: TranscriptMetadata) => Promise<NormalizedToolEvent[]>;
47
+ /**
48
+ * Read only the events after `fromByte`, for sources whose transcripts are
49
+ * append-only files.
50
+ *
51
+ * OPTIONAL, and deliberately a separate method rather than a parameter on
52
+ * `streamEvents`. A parameter an adapter ignored would return the WHOLE
53
+ * transcript to a caller that is about to merge it into an existing partial
54
+ * result — every event counted twice, quietly, on exactly the sources that
55
+ * had not been updated. Absence of this method is what tells the caller it
56
+ * cannot resume, and there is no way to be wrong about that by omission.
57
+ */
58
+ streamEventsFrom?: (
59
+ meta: TranscriptMetadata,
60
+ fromByte: number,
61
+ ) => Promise<IncrementalEvents | null>;
33
62
  }
34
63
 
35
64
  export const ADAPTERS: Record<IntegrationType, CliAdapter> = {
@@ -37,6 +66,7 @@ export const ADAPTERS: Record<IntegrationType, CliAdapter> = {
37
66
  cli: "claude",
38
67
  listTranscripts: listClaudeTranscriptMetadata,
39
68
  streamEvents: streamClaudeEvents,
69
+ streamEventsFrom: streamClaudeEventsFrom,
40
70
  },
41
71
  codex: {
42
72
  cli: "codex",