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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__03r-lqq._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__12021ot._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1le9jqc._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0clqfab._.js → [root-of-the-server]__1zkek3z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1lbh81u._.js → _12moi5-._.js} +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -5
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +1 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1m84s36._.js → [root-of-the-server]__00-s7h8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__07gm3zl._.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__14muoc4._.js → [root-of-the-server]__0stn0gx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0jzbk4_._.js → [root-of-the-server]__190won8._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ctpynv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0rsd3ll._.js → [root-of-the-server]__1sc84bb._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1ddpc5g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_1db3-8n._.js → _1if9ixi._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_1dkh76z._.js → _1kesqs-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{_0ovx4lz._.js → _1km9-gn._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/lib_1cf5ya2._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/lib_opencode-projects_ts_15bjxkm._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +19 -19
- package/.next/standalone/.next/static/chunks/{0lshnlpy1m6bc.js → 0tyt-3a5rtzy8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0ds64uculrze8.js → 21r7iw3rmixf5.js} +1 -1
- package/.next/standalone/.next/static/chunks/{07j2u70_3qsx8.js → 26lfjhb2kzzgb.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2dbi_x0c8n8fi.js → 2_27jraat038x.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0d_re32wscs0b.js → 2hwc8e923g5df.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1q76jk_1_wgjq.js → 33np9nrh1mvj9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0k_yvjc1yz0pq.js → 36-8i_vbkh85w.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1o0ahew9568ls.js → 391tpdqcas7rq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{00_t-xvc59tkb.js → 3_b73tc78jwsq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1oz0svxwt9574.js → 3i8rg2fv9nd6e.js} +2 -2
- package/.next/standalone/.next/static/chunks/{2817_8e94ah9z.js → 3jqm8ubpv3i-4.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-17utl_jw0wj11.js → turbopack-2x-o099q64i8h.js} +1 -1
- package/.next/standalone/lib/opencode-db.ts +77 -0
- package/.next/standalone/lib/opencode-projects.ts +37 -41
- package/.next/standalone/lib/opencode-sessions.ts +59 -36
- package/.next/standalone/package.json +9 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +24 -9
- package/dist/cli.mjs +734 -382
- package/dist/worker.mjs +179 -164
- package/lib/opencode-db.ts +77 -0
- package/lib/opencode-projects.ts +37 -41
- package/lib/opencode-sessions.ts +59 -36
- package/package.json +9 -9
- package/src/audit/cache.ts +167 -11
- package/src/audit/cli-adapters/claude.ts +111 -1
- package/src/audit/cli-adapters/index.ts +31 -1
- package/src/audit/index.ts +131 -11
- package/src/hooks/manager.ts +50 -3
- package/src/hooks/pack-cli.ts +237 -11
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1l-khtb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1mhmdzs._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qxztj-._.js +0 -3
- /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{0iNzkTsP6rh14Rj5WL17o → 59jsH11LoyXu9UBoHvgGM}/_clientMiddlewareManifest.js +0 -0
- /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",
|
package/src/audit/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
|
305
|
-
if (
|
|
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
|
|
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(
|
|
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 {
|
package/src/hooks/manager.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|