failproofai 1.0.2-beta.4 → 1.0.2-beta.6

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 (155) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  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 +2 -2
  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 +2 -2
  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.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  34. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  35. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/app/index.html +1 -1
  38. package/.next/standalone/.next/server/app/index.rsc +14 -14
  39. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  40. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  41. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  42. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  43. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  44. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  47. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  48. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  53. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  62. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  63. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  67. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0clqfab._.js → [root-of-the-server]__1tg4pu6._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{_1lbh81u._.js → _19uwam-._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +7 -7
  77. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +6 -6
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14muoc4._.js → [root-of-the-server]__128se8m._.js} +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{_0ovx4lz._.js → _0no96tu._.js} +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/{_1dkh76z._.js → _11fc-hb._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
  108. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  109. package/.next/standalone/.next/server/pages/404.html +1 -1
  110. package/.next/standalone/.next/server/pages/500.html +1 -1
  111. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  112. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  113. package/.next/standalone/.next/static/chunks/{0lshnlpy1m6bc.js → 0931avh-06lj5.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{0k_yvjc1yz0pq.js → 0h-3zffoy-_ji.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{0ds64uculrze8.js → 0p84npzxdgpc5.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{0d_re32wscs0b.js → 0sdgnflvw8kta.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{2dbi_x0c8n8fi.js → 0z7au3njlak0j.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{1o0ahew9568ls.js → 1id7o44jjegf0.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{00_t-xvc59tkb.js → 276n-edifojbd.js} +2 -2
  120. package/.next/standalone/.next/static/chunks/{07j2u70_3qsx8.js → 2jfskwd2u8o5r.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{2817_8e94ah9z.js → 2pxr7nr1nyotn.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{1oz0svxwt9574.js → 3i8rg2fv9nd6e.js} +2 -2
  123. package/.next/standalone/.next/static/chunks/{1q76jk_1_wgjq.js → 3vu_7_eor1m-k.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/{turbopack-17utl_jw0wj11.js → turbopack-2x-o099q64i8h.js} +1 -1
  125. package/.next/standalone/lib/opencode-db.ts +77 -0
  126. package/.next/standalone/lib/opencode-projects.ts +37 -41
  127. package/.next/standalone/lib/opencode-sessions.ts +59 -36
  128. package/.next/standalone/package.json +9 -9
  129. package/.next/standalone/server.js +1 -1
  130. package/bin/failproofai.mjs +116 -34
  131. package/dist/cli.mjs +1732 -1177
  132. package/dist/worker.mjs +180 -165
  133. package/lib/opencode-db.ts +77 -0
  134. package/lib/opencode-projects.ts +37 -41
  135. package/lib/opencode-sessions.ts +59 -36
  136. package/package.json +9 -9
  137. package/src/audit/cache.ts +167 -11
  138. package/src/audit/cli-adapters/claude.ts +111 -1
  139. package/src/audit/cli-adapters/index.ts +31 -1
  140. package/src/audit/cli.ts +12 -0
  141. package/src/audit/index.ts +164 -13
  142. package/src/hooks/configure-wizard.ts +112 -18
  143. package/src/hooks/fp-reset.ts +1 -1
  144. package/src/hooks/handler.ts +13 -1
  145. package/src/hooks/manager.ts +69 -4
  146. package/src/hooks/onboarding-attempt.ts +4 -4
  147. package/src/hooks/pack-cli.ts +399 -32
  148. package/src/hooks/pack-store.ts +21 -8
  149. package/src/hooks/read-stdin.ts +78 -8
  150. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
  151. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
  152. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
  153. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_buildManifest.js +0 -0
  154. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_clientMiddlewareManifest.js +0 -0
  155. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → mAYN9BOxWVFREnVp3yCHp}/_ssgManifest.js +0 -0
@@ -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",
package/src/audit/cli.ts CHANGED
@@ -25,6 +25,8 @@
25
25
  * an exit code. See `runScheduledAudit`.
26
26
  */
27
27
  import { runAudit } from "./index";
28
+ import { hasInstalledPacks } from "../hooks/pack-manifest";
29
+ import { CORE_SOURCE } from "../hooks/pack-store";
28
30
  import { acquireAuditLock, type AuditLockInfo } from "./audit-lock";
29
31
  import { writeDashboardCache } from "./dashboard-cache";
30
32
  import type { AuditResult, RunAuditOptions } from "./types";
@@ -504,6 +506,16 @@ export async function runPostSetupAudit(): Promise<void> {
504
506
  writeDashboardCache({}, result);
505
507
  printSummary(result);
506
508
  process.stdout.write("\n");
509
+ // The audit says what ALREADY happened; nothing here says how to stop it
510
+ // happening again. This is the first thing a new machine runs, and setup
511
+ // installs no policies by design, so without this the whole first session
512
+ // ends on a count of findings and no way to act on it.
513
+ if (!hasInstalledPacks()) {
514
+ process.stdout.write(
515
+ ` ${c(DIM, "none of this is being enforced yet. take ours, or anyone's:")}\n` +
516
+ ` ${c(CYAN, `failproofai policies add ${CORE_SOURCE}`)}\n\n`,
517
+ );
518
+ }
507
519
  } finally {
508
520
  attempt.lock.release();
509
521
  }
@@ -10,12 +10,13 @@
10
10
  import { batchAll } from "../../lib/concurrency";
11
11
  import { BUILTIN_POLICIES } from "../hooks/builtin-policies";
12
12
  import { readMergedHooksConfig } from "../hooks/hooks-config";
13
+ import { readInstalledPacks } from "../hooks/pack-manifest";
13
14
  import { normalizePolicyName } from "../hooks/policy-registry";
14
15
  import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
15
16
  import { ADAPTERS } from "./cli-adapters";
16
17
  import { AUDIT_DETECTORS } from "./detectors";
17
18
  import { severityForBuiltin } from "./features";
18
- import { readCachedTranscriptResult, writeCachedTranscriptResult } from "./cache";
19
+ import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
19
20
  import { initReplay, replayEvent, restoreReplay } from "./replay";
20
21
  import {
21
22
  AUDIT_EXAMPLE_MAX_CHARS,
@@ -87,7 +88,24 @@ function parseSinceOpt(since: string | undefined): number | undefined {
87
88
  throw new Error(`Invalid --since value: "${since}" (expected e.g. "7d", "30d", or "2026-04-01")`);
88
89
  }
89
90
 
90
- async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAuditResult> {
91
+ /**
92
+ * What a scan covered, alongside the result — so the caller can record a resume
93
+ * point without re-deriving it.
94
+ *
95
+ * `bytesScanned` is 0 when the source cannot be resumed at all (a database, or
96
+ * an adapter with no `streamEventsFrom`), which is what stops a resume point
97
+ * being written for something that has no bytes to resume from.
98
+ */
99
+ interface ScanOutcome {
100
+ result: TranscriptAuditResult;
101
+ bytesScanned: number;
102
+ detectorState: DetectorSessionState;
103
+ }
104
+
105
+ async function scanOneTranscript(
106
+ meta: TranscriptMetadata,
107
+ resume?: { fromByte: number; detectorState: DetectorSessionState },
108
+ ): Promise<ScanOutcome> {
91
109
  const empty: TranscriptAuditResult = {
92
110
  transcriptPath: meta.transcriptPath,
93
111
  cli: meta.cli,
@@ -102,17 +120,54 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
102
120
  rangeByName: {},
103
121
  };
104
122
 
123
+ const adapter = ADAPTERS[meta.cli];
105
124
  // Stream failures must propagate so the orchestrator counts them in
106
125
  // `errors` rather than silently returning an empty hits map.
107
- const events = await ADAPTERS[meta.cli].streamEvents(meta);
108
- if (events.length === 0) return empty;
126
+ let events: NormalizedToolEvent[];
127
+ let bytesScanned = 0;
128
+ // A resumed scan carries the stateful detectors' state forward across the
129
+ // boundary. Starting them empty would silently change what they find — the
130
+ // re-read detector's countdown spans tool calls, so an edit before the offset
131
+ // and its wasteful re-read after it would stop being a pair.
132
+ const sessionState: DetectorSessionState = resume?.detectorState ?? {};
133
+
134
+ if (adapter.streamEventsFrom) {
135
+ // A full scan is a resume from zero, deliberately — not a separate branch.
136
+ //
137
+ // The separate branch is what got this wrong: it recorded `meta.sizeBytes`
138
+ // as scanned, which is the whole FILE, including a trailing partial line
139
+ // that was never parsed. Transcripts are appended to while the audit reads
140
+ // them, so that tail is routinely half a line — and the next run then
141
+ // resumed past an event nobody had read. Only the reader knows where the
142
+ // last complete line ended, so only the reader gets to say.
143
+ const scan = await adapter.streamEventsFrom(meta, resume?.fromByte ?? 0);
144
+ // Null means it could not be read from there after all — truncated,
145
+ // unreadable, unparseable. A thrown error would count as a scan failure,
146
+ // so fall back to a full read instead and keep the transcript scannable.
147
+ if (!scan) {
148
+ events = await adapter.streamEvents(meta);
149
+ bytesScanned = 0;
150
+ } else {
151
+ events = scan.events;
152
+ bytesScanned = scan.bytesConsumed;
153
+ empty.cwd = scan.cwd ?? "";
154
+ }
155
+ } else {
156
+ // A source with no byte offsets to speak of — a database. Scanned whole,
157
+ // every time, and given no resume point to mislead the next run with.
158
+ events = await adapter.streamEvents(meta);
159
+ bytesScanned = 0;
160
+ }
161
+
162
+ if (events.length === 0) {
163
+ return { result: empty, bytesScanned, detectorState: sessionState };
164
+ }
109
165
 
110
166
  const result = empty;
111
167
  result.eventsScanned = events.length;
112
168
  // Capture the session's cwd from the first event that carried one — every
113
169
  // event in a single transcript shares the same cwd by construction.
114
- result.cwd = events[0].cwd || "";
115
- const sessionState: DetectorSessionState = {};
170
+ result.cwd = result.cwd || events[0].cwd || "";
116
171
 
117
172
  for (const event of events) {
118
173
  // Run audit detectors first (stateful, must see every event).
@@ -146,7 +201,7 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
146
201
  }
147
202
  }
148
203
 
149
- return result;
204
+ return { result, bytesScanned, detectorState: sessionState };
150
205
  }
151
206
 
152
207
  function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): string {
@@ -159,6 +214,55 @@ function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): s
159
214
  return `${event.toolName}`;
160
215
  }
161
216
 
217
+ /**
218
+ * Fold a tail-scan into the result the cache already held for the same
219
+ * transcript.
220
+ *
221
+ * Mirrors `recordHit` exactly, and has to: counts add, examples stay capped at
222
+ * the same limit, ranges widen. The one asymmetry is `cwd` — the cached value
223
+ * came from the FIRST event in the file and the tail's came from the first
224
+ * event after the offset, so the older one wins.
225
+ */
226
+ function mergeIncremental(
227
+ cached: TranscriptAuditResult,
228
+ tail: TranscriptAuditResult,
229
+ ): TranscriptAuditResult {
230
+ const out: TranscriptAuditResult = {
231
+ ...cached,
232
+ mtimeMs: tail.mtimeMs,
233
+ sizeBytes: tail.sizeBytes,
234
+ cwd: cached.cwd || tail.cwd || "",
235
+ eventsScanned: (cached.eventsScanned ?? 0) + (tail.eventsScanned ?? 0),
236
+ hitsByName: { ...cached.hitsByName },
237
+ examplesByName: {},
238
+ rangeByName: { ...cached.rangeByName },
239
+ };
240
+ for (const [name, list] of Object.entries(cached.examplesByName)) {
241
+ out.examplesByName[name] = [...list];
242
+ }
243
+ for (const [name, count] of Object.entries(tail.hitsByName)) {
244
+ out.hitsByName[name] = (out.hitsByName[name] ?? 0) + count;
245
+ }
246
+ for (const [name, list] of Object.entries(tail.examplesByName)) {
247
+ const exs = out.examplesByName[name] ?? [];
248
+ for (const ex of list) {
249
+ if (exs.length >= AUDIT_MAX_EXAMPLES_PER_NAME) break;
250
+ exs.push(ex);
251
+ }
252
+ out.examplesByName[name] = exs;
253
+ }
254
+ for (const [name, range] of Object.entries(tail.rangeByName)) {
255
+ const existing = out.rangeByName[name];
256
+ if (!existing) {
257
+ out.rangeByName[name] = { ...range };
258
+ } else {
259
+ if (range.first < existing.first) existing.first = range.first;
260
+ if (range.last > existing.last) existing.last = range.last;
261
+ }
262
+ }
263
+ return out;
264
+ }
265
+
162
266
  function recordHit(
163
267
  result: TranscriptAuditResult,
164
268
  name: string,
@@ -276,12 +380,42 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
276
380
  const clis = (opts.clis ?? Array.from(INTEGRATION_TYPES)) as IntegrationType[];
277
381
  const sinceMs = parseSinceOpt(opts.since);
278
382
 
279
- // Snapshot which builtin policies the user currently has enabled — drives
280
- // the "already protected" vs "slipping through" split in the report.
383
+ // What the user actually has switched on — drives the "already protected"
384
+ // vs "slipping through" split.
385
+ //
386
+ // Read from BOTH places it can live, because it moved and this only followed
387
+ // it half way. `enabledPolicies` is the pre-packs key, still written by
388
+ // `policies add <name>` and still true on a machine running the migration
389
+ // shim. Everything installed as a PACK records its selection in
390
+ // `installed.json` instead, and `pack-store` never writes `enabledPolicies`
391
+ // at all — so reading that key alone meant installing a pack could not move
392
+ // this split. Every policy read as slipping through no matter what was
393
+ // enforcing, and the number the audit leads with never changed however many
394
+ // policies you installed. Verified: same 302 hits, same 17 findings, before
395
+ // and after installing all 38.
396
+ //
397
+ // The split, not the REPLAY. What gets replayed stays the compiled builtins
398
+ // on purpose — see `initReplay`: an audit is a fixed yardstick, and one that
399
+ // changed shape with whatever pack a machine happened to have could not be
400
+ // compared against its own history. A pack policy sharing a builtin's name
401
+ // marks that builtin protected, which is exactly right: the same rule is
402
+ // enforcing, whichever artifact carries it.
281
403
  const userConfig = readMergedHooksConfig();
282
404
  const enabledBuiltins = new Set(
283
405
  (userConfig.enabledPolicies ?? []).map((n) => normalizePolicyName(n)),
284
406
  );
407
+ try {
408
+ for (const pack of readInstalledPacks().packs) {
409
+ // `enabled: null` means the whole pack was taken, so every policy in its
410
+ // catalog counts — the field records a SELECTION, and its absence is not
411
+ // an empty one.
412
+ const taken = pack.enabled ?? pack.policies.map((entry) => entry.name);
413
+ for (const name of taken) enabledBuiltins.add(normalizePolicyName(name));
414
+ }
415
+ } catch {
416
+ // An unreadable manifest must not fail the audit. It costs the split its
417
+ // pack half, which is the same answer this gave for every machine before.
418
+ }
285
419
 
286
420
  // 1. Discover transcripts across all selected CLIs.
287
421
  const allTranscripts: TranscriptMetadata[] = [];
@@ -300,14 +434,31 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
300
434
  let skipped = 0;
301
435
  let errors = 0;
302
436
  const tasks = allTranscripts.map((meta) => async (): Promise<TranscriptAuditResult> => {
437
+ let resume: { fromByte: number; detectorState: DetectorSessionState } | undefined;
438
+ let cachedPrefix: TranscriptAuditResult | null = null;
303
439
  if (!opts.noCache) {
304
- const cached = readCachedTranscriptResult(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes);
305
- if (cached) return cached;
440
+ const found = readCachedTranscript(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes);
441
+ if (found?.kind === "hit") return found.result;
442
+ resume = found?.kind === "resume"
443
+ ? { fromByte: found.fromByte, detectorState: found.detectorState }
444
+ : undefined;
445
+ cachedPrefix = found?.kind === "resume" ? found.result : null;
306
446
  }
307
447
  try {
308
- const fresh = await scanOneTranscript(meta);
448
+ const scan = await scanOneTranscript(meta, resume);
449
+ // A resumed scan produced hits for the TAIL only; the cached result holds
450
+ // everything before it. Merging is what makes the two halves one answer.
451
+ const fresh = cachedPrefix ? mergeIncremental(cachedPrefix, scan.result) : scan.result;
309
452
  if (!opts.noCache) {
310
- writeCachedTranscriptResult(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes, fresh);
453
+ writeCachedTranscriptResult(
454
+ meta.transcriptPath,
455
+ meta.mtimeMs,
456
+ meta.sizeBytes,
457
+ fresh,
458
+ scan.bytesScanned > 0
459
+ ? { bytesScanned: scan.bytesScanned, detectorState: scan.detectorState }
460
+ : undefined,
461
+ );
311
462
  }
312
463
  return fresh;
313
464
  } catch {