failproofai 1.0.2-beta.4 → 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 (147) 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]__1zkek3z._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{_1lbh81u._.js → _12moi5-._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -5
  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/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +3 -3
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14muoc4._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{_1dkh76z._.js → _1kesqs-._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/{_0ovx4lz._.js → _1km9-gn._.js} +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
  106. package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
  107. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  108. package/.next/standalone/.next/server/pages/404.html +1 -1
  109. package/.next/standalone/.next/server/pages/500.html +1 -1
  110. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  111. package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
  112. package/.next/standalone/.next/static/chunks/{0lshnlpy1m6bc.js → 0tyt-3a5rtzy8.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/{0ds64uculrze8.js → 21r7iw3rmixf5.js} +1 -1
  114. package/.next/standalone/.next/static/chunks/{07j2u70_3qsx8.js → 26lfjhb2kzzgb.js} +1 -1
  115. package/.next/standalone/.next/static/chunks/{2dbi_x0c8n8fi.js → 2_27jraat038x.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{0d_re32wscs0b.js → 2hwc8e923g5df.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{1q76jk_1_wgjq.js → 33np9nrh1mvj9.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{0k_yvjc1yz0pq.js → 36-8i_vbkh85w.js} +1 -1
  119. package/.next/standalone/.next/static/chunks/{1o0ahew9568ls.js → 391tpdqcas7rq.js} +1 -1
  120. package/.next/standalone/.next/static/chunks/{00_t-xvc59tkb.js → 3_b73tc78jwsq.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1oz0svxwt9574.js → 3i8rg2fv9nd6e.js} +2 -2
  122. package/.next/standalone/.next/static/chunks/{2817_8e94ah9z.js → 3jqm8ubpv3i-4.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/{turbopack-17utl_jw0wj11.js → turbopack-2x-o099q64i8h.js} +1 -1
  124. package/.next/standalone/lib/opencode-db.ts +77 -0
  125. package/.next/standalone/lib/opencode-projects.ts +37 -41
  126. package/.next/standalone/lib/opencode-sessions.ts +59 -36
  127. package/.next/standalone/package.json +9 -9
  128. package/.next/standalone/server.js +1 -1
  129. package/bin/failproofai.mjs +24 -9
  130. package/dist/cli.mjs +734 -382
  131. package/dist/worker.mjs +179 -164
  132. package/lib/opencode-db.ts +77 -0
  133. package/lib/opencode-projects.ts +37 -41
  134. package/lib/opencode-sessions.ts +59 -36
  135. package/package.json +9 -9
  136. package/src/audit/cache.ts +167 -11
  137. package/src/audit/cli-adapters/claude.ts +111 -1
  138. package/src/audit/cli-adapters/index.ts +31 -1
  139. package/src/audit/index.ts +131 -11
  140. package/src/hooks/manager.ts +50 -3
  141. package/src/hooks/pack-cli.ts +237 -11
  142. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
  143. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
  144. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
  145. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
  146. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
  147. /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_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",
@@ -15,7 +15,7 @@ import { INTEGRATION_TYPES, type IntegrationType } from "../hooks/types";
15
15
  import { ADAPTERS } from "./cli-adapters";
16
16
  import { AUDIT_DETECTORS } from "./detectors";
17
17
  import { severityForBuiltin } from "./features";
18
- import { readCachedTranscriptResult, writeCachedTranscriptResult } from "./cache";
18
+ import { readCachedTranscript, writeCachedTranscriptResult } from "./cache";
19
19
  import { initReplay, replayEvent, restoreReplay } from "./replay";
20
20
  import {
21
21
  AUDIT_EXAMPLE_MAX_CHARS,
@@ -87,7 +87,24 @@ function parseSinceOpt(since: string | undefined): number | undefined {
87
87
  throw new Error(`Invalid --since value: "${since}" (expected e.g. "7d", "30d", or "2026-04-01")`);
88
88
  }
89
89
 
90
- async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAuditResult> {
90
+ /**
91
+ * What a scan covered, alongside the result — so the caller can record a resume
92
+ * point without re-deriving it.
93
+ *
94
+ * `bytesScanned` is 0 when the source cannot be resumed at all (a database, or
95
+ * an adapter with no `streamEventsFrom`), which is what stops a resume point
96
+ * being written for something that has no bytes to resume from.
97
+ */
98
+ interface ScanOutcome {
99
+ result: TranscriptAuditResult;
100
+ bytesScanned: number;
101
+ detectorState: DetectorSessionState;
102
+ }
103
+
104
+ async function scanOneTranscript(
105
+ meta: TranscriptMetadata,
106
+ resume?: { fromByte: number; detectorState: DetectorSessionState },
107
+ ): Promise<ScanOutcome> {
91
108
  const empty: TranscriptAuditResult = {
92
109
  transcriptPath: meta.transcriptPath,
93
110
  cli: meta.cli,
@@ -102,17 +119,54 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
102
119
  rangeByName: {},
103
120
  };
104
121
 
122
+ const adapter = ADAPTERS[meta.cli];
105
123
  // Stream failures must propagate so the orchestrator counts them in
106
124
  // `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;
125
+ let events: NormalizedToolEvent[];
126
+ let bytesScanned = 0;
127
+ // A resumed scan carries the stateful detectors' state forward across the
128
+ // boundary. Starting them empty would silently change what they find — the
129
+ // re-read detector's countdown spans tool calls, so an edit before the offset
130
+ // and its wasteful re-read after it would stop being a pair.
131
+ const sessionState: DetectorSessionState = resume?.detectorState ?? {};
132
+
133
+ if (adapter.streamEventsFrom) {
134
+ // A full scan is a resume from zero, deliberately — not a separate branch.
135
+ //
136
+ // The separate branch is what got this wrong: it recorded `meta.sizeBytes`
137
+ // as scanned, which is the whole FILE, including a trailing partial line
138
+ // that was never parsed. Transcripts are appended to while the audit reads
139
+ // them, so that tail is routinely half a line — and the next run then
140
+ // resumed past an event nobody had read. Only the reader knows where the
141
+ // last complete line ended, so only the reader gets to say.
142
+ const scan = await adapter.streamEventsFrom(meta, resume?.fromByte ?? 0);
143
+ // Null means it could not be read from there after all — truncated,
144
+ // unreadable, unparseable. A thrown error would count as a scan failure,
145
+ // so fall back to a full read instead and keep the transcript scannable.
146
+ if (!scan) {
147
+ events = await adapter.streamEvents(meta);
148
+ bytesScanned = 0;
149
+ } else {
150
+ events = scan.events;
151
+ bytesScanned = scan.bytesConsumed;
152
+ empty.cwd = scan.cwd ?? "";
153
+ }
154
+ } else {
155
+ // A source with no byte offsets to speak of — a database. Scanned whole,
156
+ // every time, and given no resume point to mislead the next run with.
157
+ events = await adapter.streamEvents(meta);
158
+ bytesScanned = 0;
159
+ }
160
+
161
+ if (events.length === 0) {
162
+ return { result: empty, bytesScanned, detectorState: sessionState };
163
+ }
109
164
 
110
165
  const result = empty;
111
166
  result.eventsScanned = events.length;
112
167
  // Capture the session's cwd from the first event that carried one — every
113
168
  // event in a single transcript shares the same cwd by construction.
114
- result.cwd = events[0].cwd || "";
115
- const sessionState: DetectorSessionState = {};
169
+ result.cwd = result.cwd || events[0].cwd || "";
116
170
 
117
171
  for (const event of events) {
118
172
  // Run audit detectors first (stateful, must see every event).
@@ -146,7 +200,7 @@ async function scanOneTranscript(meta: TranscriptMetadata): Promise<TranscriptAu
146
200
  }
147
201
  }
148
202
 
149
- return result;
203
+ return { result, bytesScanned, detectorState: sessionState };
150
204
  }
151
205
 
152
206
  function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): string {
@@ -159,6 +213,55 @@ function formatPolicyExample(_policyName: string, event: NormalizedToolEvent): s
159
213
  return `${event.toolName}`;
160
214
  }
161
215
 
216
+ /**
217
+ * Fold a tail-scan into the result the cache already held for the same
218
+ * transcript.
219
+ *
220
+ * Mirrors `recordHit` exactly, and has to: counts add, examples stay capped at
221
+ * the same limit, ranges widen. The one asymmetry is `cwd` — the cached value
222
+ * came from the FIRST event in the file and the tail's came from the first
223
+ * event after the offset, so the older one wins.
224
+ */
225
+ function mergeIncremental(
226
+ cached: TranscriptAuditResult,
227
+ tail: TranscriptAuditResult,
228
+ ): TranscriptAuditResult {
229
+ const out: TranscriptAuditResult = {
230
+ ...cached,
231
+ mtimeMs: tail.mtimeMs,
232
+ sizeBytes: tail.sizeBytes,
233
+ cwd: cached.cwd || tail.cwd || "",
234
+ eventsScanned: (cached.eventsScanned ?? 0) + (tail.eventsScanned ?? 0),
235
+ hitsByName: { ...cached.hitsByName },
236
+ examplesByName: {},
237
+ rangeByName: { ...cached.rangeByName },
238
+ };
239
+ for (const [name, list] of Object.entries(cached.examplesByName)) {
240
+ out.examplesByName[name] = [...list];
241
+ }
242
+ for (const [name, count] of Object.entries(tail.hitsByName)) {
243
+ out.hitsByName[name] = (out.hitsByName[name] ?? 0) + count;
244
+ }
245
+ for (const [name, list] of Object.entries(tail.examplesByName)) {
246
+ const exs = out.examplesByName[name] ?? [];
247
+ for (const ex of list) {
248
+ if (exs.length >= AUDIT_MAX_EXAMPLES_PER_NAME) break;
249
+ exs.push(ex);
250
+ }
251
+ out.examplesByName[name] = exs;
252
+ }
253
+ for (const [name, range] of Object.entries(tail.rangeByName)) {
254
+ const existing = out.rangeByName[name];
255
+ if (!existing) {
256
+ out.rangeByName[name] = { ...range };
257
+ } else {
258
+ if (range.first < existing.first) existing.first = range.first;
259
+ if (range.last > existing.last) existing.last = range.last;
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
162
265
  function recordHit(
163
266
  result: TranscriptAuditResult,
164
267
  name: string,
@@ -300,14 +403,31 @@ async function runAuditInner(opts: RunAuditOptions, startedAt: number): Promise<
300
403
  let skipped = 0;
301
404
  let errors = 0;
302
405
  const tasks = allTranscripts.map((meta) => async (): Promise<TranscriptAuditResult> => {
406
+ let resume: { fromByte: number; detectorState: DetectorSessionState } | undefined;
407
+ let cachedPrefix: TranscriptAuditResult | null = null;
303
408
  if (!opts.noCache) {
304
- const cached = readCachedTranscriptResult(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes);
305
- if (cached) return cached;
409
+ const found = readCachedTranscript(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes);
410
+ if (found?.kind === "hit") return found.result;
411
+ resume = found?.kind === "resume"
412
+ ? { fromByte: found.fromByte, detectorState: found.detectorState }
413
+ : undefined;
414
+ cachedPrefix = found?.kind === "resume" ? found.result : null;
306
415
  }
307
416
  try {
308
- const fresh = await scanOneTranscript(meta);
417
+ const scan = await scanOneTranscript(meta, resume);
418
+ // A resumed scan produced hits for the TAIL only; the cached result holds
419
+ // everything before it. Merging is what makes the two halves one answer.
420
+ const fresh = cachedPrefix ? mergeIncremental(cachedPrefix, scan.result) : scan.result;
309
421
  if (!opts.noCache) {
310
- writeCachedTranscriptResult(meta.transcriptPath, meta.mtimeMs, meta.sizeBytes, fresh);
422
+ writeCachedTranscriptResult(
423
+ meta.transcriptPath,
424
+ meta.mtimeMs,
425
+ meta.sizeBytes,
426
+ fresh,
427
+ scan.bytesScanned > 0
428
+ ? { bytesScanned: scan.bytesScanned, detectorState: scan.detectorState }
429
+ : undefined,
430
+ );
311
431
  }
312
432
  return fresh;
313
433
  } catch {
@@ -14,7 +14,7 @@ import { INTEGRATION_TYPES,
14
14
  type HookScope,
15
15
  type IntegrationType,
16
16
  } from "./types";
17
- import { claudeCode, getIntegration, settingsPathsFor } from "./integrations";
17
+ import { claudeCode, getIntegration, settingsPathsFor, type Integration } from "./integrations";
18
18
  import { promptPolicySelection } from "./install-prompt";
19
19
  import { configuredCustomPolicyPaths, readMergedHooksConfig, readScopedHooksConfig, writeScopedHooksConfig, syncConventionPolicies, findProjectConfigDir } from "./hooks-config";
20
20
  import type { HooksConfig, ConventionPolicyRecord } from "./policy-types";
@@ -271,9 +271,44 @@ function deduplicateScopes(scopes: readonly HookScope[], cwd?: string): HookScop
271
271
  * rows of the listing already answer it.
272
272
  */
273
273
  export function hooksInstalledInSettings(scope: HookScope, cwd?: string): boolean {
274
- return INTEGRATION_TYPES.some((id) => {
274
+ return integrationsInstalledAt(scope, cwd).length > 0;
275
+ }
276
+
277
+ /**
278
+ * Which integrations are wired at EXACTLY this scope — the honest form of the
279
+ * question above, and the one the multi-scope warning needs.
280
+ *
281
+ * Two ways an integration answers yes for a scope it is not actually installed
282
+ * at, both of which made `failproofai policies` warn about "hooks in multiple
283
+ * scopes" on a machine whose hooks are in exactly one file:
284
+ *
285
+ * - It does not SUPPORT the scope. Hermes and OpenClaw are user-scope only
286
+ * (`HERMES_HOOK_SCOPES = ["user"]`) and their `getSettingsPath` ignores the
287
+ * scope argument entirely, so they hand back the user file — and report it
288
+ * installed — for `project` and `local` alike. `scopes` already declares
289
+ * this; nothing was reading it.
290
+ * - Its project path RESOLVES to its user path. Run this from `$HOME` and
291
+ * `<cwd>/.claude/settings.json` is `~/.claude/settings.json`: one file,
292
+ * counted as two scopes. Cheap to detect and impossible to get right by
293
+ * asking each integration separately.
294
+ */
295
+ export function integrationsInstalledAt(scope: HookScope, cwd?: string): IntegrationType[] {
296
+ return INTEGRATION_TYPES.filter((id) => {
275
297
  try {
276
- return getIntegration(id).hooksInstalledInSettings(scope, cwd);
298
+ const integration = getIntegration(id);
299
+ if (!integration.scopes.includes(scope)) return false;
300
+ if (!integration.hooksInstalledInSettings(scope, cwd)) return false;
301
+ // A narrower scope that lands on the same file as a wider one is that
302
+ // wider one, seen twice. Attribute it to the widest scope that resolves
303
+ // there so exactly one of them counts.
304
+ const here = integration.getSettingsPath(scope, cwd);
305
+ return !HOOK_SCOPES.some(
306
+ (other) =>
307
+ other !== scope &&
308
+ HOOK_SCOPES.indexOf(other) < HOOK_SCOPES.indexOf(scope) &&
309
+ integration.scopes.includes(other) &&
310
+ safeSettingsPath(integration, other, cwd) === here,
311
+ );
277
312
  } catch {
278
313
  // An integration whose settings file is unreadable is not evidence that
279
314
  // nothing is installed — keep asking the rest.
@@ -282,6 +317,18 @@ export function hooksInstalledInSettings(scope: HookScope, cwd?: string): boolea
282
317
  });
283
318
  }
284
319
 
320
+ function safeSettingsPath(
321
+ integration: Integration,
322
+ scope: HookScope,
323
+ cwd?: string,
324
+ ): string | null {
325
+ try {
326
+ return integration.getSettingsPath(scope, cwd);
327
+ } catch {
328
+ return null;
329
+ }
330
+ }
331
+
285
332
  export interface InstallHooksOptions {
286
333
  /** Replace the enabled set at this scope instead of unioning (default: additive). */
287
334
  replace?: boolean;