@roodriigoooo/pi-scrutiny 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { ScrutinyParams, ScrutinySummary, ScrutinySurface } from "./types.js";
5
+ import { scrutinyDataDir, truncate } from "./util.js";
6
+
7
+ type ExecLike = (command: string, args: string[], options?: { timeout?: number; signal?: AbortSignal }) => Promise<{ stdout?: string; stderr?: string; code?: number; killed?: boolean }>;
8
+
9
+ type Anchors = {
10
+ files: string[];
11
+ symbols: string[];
12
+ terms: string[];
13
+ reasons: string[];
14
+ };
15
+
16
+ type Candidate = {
17
+ kind: "file" | "match" | "prior";
18
+ title: string;
19
+ score: number;
20
+ why: string[];
21
+ preview?: string;
22
+ };
23
+
24
+ const MAX_SCOUT_CHARS = 4_000;
25
+ const MAX_CANDIDATES = 12;
26
+ const MAX_RG_MATCHES = 80;
27
+ const MAX_PATTERN_PARTS = 12;
28
+ const MAX_DIFF_FILES = 30;
29
+ const MAX_PRIOR_RUNS = 3;
30
+
31
+ export async function buildContextScoutSection(input: {
32
+ params: ScrutinyParams;
33
+ surface: ScrutinySurface;
34
+ cwd: string;
35
+ exec: ExecLike;
36
+ signal?: AbortSignal;
37
+ }): Promise<string | undefined> {
38
+ if (input.surface === "verify") return undefined;
39
+ const explicit = extractExplicitAnchors(`${input.params.prompt}\n${input.params.context ?? ""}`);
40
+ const diffFiles = await readDiffFiles(input.exec, input.signal);
41
+ const anchors = buildAnchors({ text: input.params.prompt, explicitFiles: explicit.files, explicitSymbols: explicit.symbols, diffFiles });
42
+
43
+ if (anchors.files.length === 0 && anchors.symbols.length === 0 && anchors.terms.length === 0) {
44
+ return [
45
+ "## Context scout",
46
+ "skipped: no `@file`, path, symbol-like term, prompt keyword, or git diff file found. ask user to choose scope before broad repo scan.",
47
+ ].join("\n");
48
+ }
49
+
50
+ const candidates: Candidate[] = [];
51
+ for (const file of diffFiles.slice(0, MAX_DIFF_FILES)) candidates.push({ kind: "file", title: file, score: 10, why: ["git diff file"] });
52
+ for (const file of explicit.files) candidates.push({ kind: "file", title: file, score: 12, why: ["explicit file anchor"] });
53
+ candidates.push(...await rgCandidates(input.cwd, input.exec, anchors, input.signal));
54
+ candidates.push(...await pathCandidates(input.cwd, input.exec, anchors, input.signal));
55
+ candidates.push(...await priorRunCandidates(input.cwd, anchors));
56
+
57
+ const ranked = dedupeCandidates(candidates).sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)).slice(0, MAX_CANDIDATES);
58
+ return renderScout(anchors, ranked);
59
+ }
60
+
61
+ function buildAnchors(input: { text: string; explicitFiles: string[]; explicitSymbols: string[]; diffFiles: string[] }): Anchors {
62
+ const symbols = unique([...input.explicitSymbols, ...extractSymbols(input.text)]).slice(0, 12);
63
+ const files = unique([...input.explicitFiles, ...input.diffFiles]).slice(0, 40);
64
+ const terms = extractTerms(input.text, symbols, files).slice(0, 12);
65
+ const reasons = [
66
+ input.explicitFiles.length ? "explicit file refs" : undefined,
67
+ input.explicitSymbols.length || symbols.length ? "prompt symbols" : undefined,
68
+ input.diffFiles.length ? "git diff files" : undefined,
69
+ terms.length ? "prompt keywords" : undefined,
70
+ ].filter((item): item is string => Boolean(item));
71
+ return { files, symbols, terms, reasons };
72
+ }
73
+
74
+ function extractExplicitAnchors(text: string): { files: string[]; symbols: string[] } {
75
+ const files: string[] = [];
76
+ const symbols: string[] = [];
77
+ for (const match of text.matchAll(/@([^\s`'"),;]+)/g)) {
78
+ const value = match[1].replace(/^\/+/, "");
79
+ if (looksLikePath(value)) files.push(value.replace(/^\.\//, ""));
80
+ else if (/^[A-Za-z_$][A-Za-z0-9_$.:-]*$/.test(value)) symbols.push(value.replace(/[:.].*$/, ""));
81
+ }
82
+ for (const match of text.matchAll(/(^|[\s([{"'`])((?:\.{1,2}\/)?(?:[A-Za-z0-9_.@+-]+\/)+[A-Za-z0-9_.@+-]+\.[A-Za-z0-9]+)(?=[:\s)'"`,.;]|$)/g)) {
83
+ const file = normalizeFile(match[2]);
84
+ if (file) files.push(file);
85
+ }
86
+ return { files: unique(files), symbols: unique(symbols) };
87
+ }
88
+
89
+ function extractSymbols(text: string): string[] {
90
+ const symbols: string[] = [];
91
+ for (const match of text.matchAll(/`([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?)`/g)) symbols.push(match[1]);
92
+ for (const match of text.matchAll(/\b([A-Z][A-Za-z0-9]+(?:[A-Z][A-Za-z0-9]+)+|[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)\b/g)) symbols.push(match[1]);
93
+ return symbols.filter((symbol) => symbol.length >= 4 && !STOP.has(symbol.toLowerCase()));
94
+ }
95
+
96
+ function extractTerms(text: string, symbols: string[], files: string[]): string[] {
97
+ const skip = new Set(symbols.map((symbol) => symbol.toLowerCase()));
98
+ for (const file of files) for (const part of file.toLowerCase().split(/[^a-z0-9]+/)) skip.add(part);
99
+ return unique((text.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? [])
100
+ .filter((term) => !STOP.has(term) && !skip.has(term) && term.length <= 40));
101
+ }
102
+
103
+ async function readDiffFiles(exec: ExecLike, signal?: AbortSignal): Promise<string[]> {
104
+ try {
105
+ const inside = await exec("git", ["rev-parse", "--is-inside-work-tree"], { timeout: 3_000, signal });
106
+ if (inside.code !== 0 || inside.stdout?.trim() !== "true") return [];
107
+ const result = await exec("git", ["diff", "--name-only", "HEAD", "--"], { timeout: 5_000, signal });
108
+ const stdout = result.stdout?.trim() ? result.stdout : (await exec("git", ["diff", "--name-only", "--"], { timeout: 5_000, signal })).stdout;
109
+ return unique((stdout ?? "").split(/\r?\n/).map(normalizeFile).filter((file): file is string => Boolean(file))).slice(0, MAX_DIFF_FILES);
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ async function rgCandidates(cwd: string, exec: ExecLike, anchors: Anchors, signal?: AbortSignal): Promise<Candidate[]> {
116
+ const patternParts = unique([...anchors.symbols, ...anchors.terms]).slice(0, MAX_PATTERN_PARTS);
117
+ if (patternParts.length === 0) return [];
118
+ const pattern = patternParts.map(escapeRegex).join("|");
119
+ try {
120
+ const result = await exec("rg", [
121
+ "--json", "-n", "-S", "--max-count", "3", "--max-filesize", "1M",
122
+ "--glob", "!node_modules/**", "--glob", "!.git/**", "--glob", "!.pi/scrutiny/**",
123
+ "--glob", "!package-lock.json", "--glob", "!pnpm-lock.yaml", "--glob", "!yarn.lock", "--glob", "!bun.lockb",
124
+ pattern, ".",
125
+ ], { timeout: 6_000, signal });
126
+ return parseRgMatches(cwd, result.stdout ?? "", anchors);
127
+ } catch {
128
+ return [];
129
+ }
130
+ }
131
+
132
+ function parseRgMatches(cwd: string, stdout: string, anchors: Anchors): Candidate[] {
133
+ const candidates: Candidate[] = [];
134
+ const anchorFiles = new Set(anchors.files);
135
+ const terms = new Set([...anchors.terms, ...anchors.symbols].map((item) => item.toLowerCase()));
136
+ for (const line of stdout.split(/\r?\n/)) {
137
+ if (candidates.length >= MAX_RG_MATCHES) break;
138
+ if (!line.trim()) continue;
139
+ let event: any;
140
+ try { event = JSON.parse(line); } catch { continue; }
141
+ if (event.type !== "match") continue;
142
+ const file = normalizeFile(path.relative(cwd, path.resolve(cwd, event.data?.path?.text ?? "")));
143
+ if (!file) continue;
144
+ const lineNumber = Number(event.data?.line_number ?? 0) || undefined;
145
+ const text = String(event.data?.lines?.text ?? "").trim();
146
+ const why: string[] = [];
147
+ let score = 1;
148
+ if (anchorFiles.has(file)) { score += 8; why.push("anchor file"); }
149
+ const lower = `${file}\n${text}`.toLowerCase();
150
+ for (const term of terms) {
151
+ if (lower.includes(term)) {
152
+ score += anchors.symbols.map((symbol) => symbol.toLowerCase()).includes(term) ? 3 : 1;
153
+ why.push(`hit:${term}`);
154
+ }
155
+ }
156
+ if (isTestPath(file)) { score += 3; why.push("test file"); }
157
+ if (isDocConfigPath(file)) { score += 2; why.push("doc/config path"); }
158
+ candidates.push({ kind: "match", title: `${file}${lineNumber ? `:${lineNumber}` : ""}`, score, why: unique(why).slice(0, 5), preview: text });
159
+ }
160
+ return candidates;
161
+ }
162
+
163
+ async function pathCandidates(cwd: string, exec: ExecLike, anchors: Anchors, signal?: AbortSignal): Promise<Candidate[]> {
164
+ const terms = unique([...anchors.terms, ...anchors.symbols.map((symbol) => symbol.toLowerCase())]);
165
+ if (terms.length === 0) return [];
166
+ try {
167
+ const result = await exec("rg", ["--files", "--glob", "!node_modules/**", "--glob", "!.git/**", "--glob", "!.pi/scrutiny/**", "--glob", "!package-lock.json", "--glob", "!pnpm-lock.yaml", "--glob", "!yarn.lock", "--glob", "!bun.lockb"], { timeout: 5_000, signal });
168
+ return (result.stdout ?? "")
169
+ .split(/\r?\n/)
170
+ .map(normalizeFile)
171
+ .filter((file): file is string => Boolean(file))
172
+ .filter((file) => isDocConfigPath(file) || isTestPath(file))
173
+ .map((file): Candidate | undefined => {
174
+ const lower = file.toLowerCase();
175
+ const hits = terms.filter((term) => lower.includes(term));
176
+ return hits.length ? { kind: "file", title: file, score: 4 + hits.length + (isTestPath(file) ? 2 : 0), why: [`path:${hits.slice(0, 3).join(",")}`, isTestPath(file) ? "test file" : "doc/config path"].filter(Boolean) } : undefined;
177
+ })
178
+ .filter((item): item is Candidate => item !== undefined)
179
+ .slice(0, 30);
180
+ } catch {
181
+ return [];
182
+ }
183
+ }
184
+
185
+ async function priorRunCandidates(cwd: string, anchors: Anchors): Promise<Candidate[]> {
186
+ const indexPath = path.join(scrutinyDataDir(cwd), "index.jsonl");
187
+ let lines: string[];
188
+ try {
189
+ lines = (await fs.readFile(indexPath, "utf8")).split(/\r?\n/).filter(Boolean).slice(-100);
190
+ } catch {
191
+ return [];
192
+ }
193
+ const candidates: Candidate[] = [];
194
+ for (const line of lines) {
195
+ let summary: ScrutinySummary;
196
+ try { summary = JSON.parse(line) as ScrutinySummary; } catch { continue; }
197
+ const why: string[] = [];
198
+ let score = 0;
199
+ const fileHits = summary.files.filter((file) => anchors.files.includes(file));
200
+ if (fileHits.length) { score += 8 * fileHits.length; why.push(`file:${fileHits.slice(0, 2).join(",")}`); }
201
+ const symbolHits = summary.symbols.filter((symbol) => anchors.symbols.includes(symbol));
202
+ if (symbolHits.length) { score += 4 * symbolHits.length; why.push(`symbol:${symbolHits.slice(0, 2).join(",")}`); }
203
+ const keywordHits = summary.keywords.filter((keyword) => anchors.terms.includes(keyword));
204
+ if (keywordHits.length) { score += keywordHits.length; why.push(`keyword:${keywordHits.slice(0, 3).join(",")}`); }
205
+ const freshness = await summaryFreshness(cwd, summary);
206
+ if (freshness === "stale") score -= 4;
207
+ if (score <= 0) continue;
208
+ candidates.push({
209
+ kind: "prior",
210
+ title: `${summary.runId} · ${summary.surface} · ${summary.status}${freshness ? ` · ${freshness}` : ""}`,
211
+ score,
212
+ why,
213
+ preview: truncate([summary.prompt, ...summary.signals.slice(0, 2), ...summary.risks.slice(0, 2)].filter(Boolean).join("; "), 260),
214
+ });
215
+ }
216
+ return candidates.sort((a, b) => b.score - a.score).slice(0, MAX_PRIOR_RUNS);
217
+ }
218
+
219
+ async function summaryFreshness(cwd: string, summary: ScrutinySummary): Promise<"fresh" | "stale" | undefined> {
220
+ const hashes = Object.entries(summary.fileHashes ?? {});
221
+ if (!hashes.length) return undefined;
222
+ for (const [file, expected] of hashes) {
223
+ try {
224
+ const abs = path.resolve(cwd, file);
225
+ if (!isInside(cwd, abs)) return "stale";
226
+ const actual = createHash("sha1").update(await fs.readFile(abs)).digest("hex");
227
+ if (actual !== expected) return "stale";
228
+ } catch {
229
+ return "stale";
230
+ }
231
+ }
232
+ return "fresh";
233
+ }
234
+
235
+ function dedupeCandidates(candidates: Candidate[]): Candidate[] {
236
+ const byTitle = new Map<string, Candidate>();
237
+ for (const candidate of candidates) {
238
+ const existing = byTitle.get(candidate.title);
239
+ if (!existing) byTitle.set(candidate.title, candidate);
240
+ else byTitle.set(candidate.title, {
241
+ ...existing,
242
+ score: Math.max(existing.score, candidate.score),
243
+ why: unique([...existing.why, ...candidate.why]).slice(0, 6),
244
+ preview: existing.preview ?? candidate.preview,
245
+ });
246
+ }
247
+ return [...byTitle.values()];
248
+ }
249
+
250
+ function renderScout(anchors: Anchors, candidates: Candidate[]): string {
251
+ const lines: string[] = [];
252
+ lines.push("## Context scout");
253
+ lines.push("cheap local anchor scan. source for orientation only; not authority.");
254
+ lines.push(`anchors: ${anchors.reasons.join(", ") || "none"}`);
255
+ pushInline(lines, "files", anchors.files, 8);
256
+ pushInline(lines, "symbols", anchors.symbols, 8);
257
+ pushInline(lines, "terms", anchors.terms, 8);
258
+ lines.push("");
259
+ if (candidates.length === 0) {
260
+ lines.push("no local candidates found from these anchors. if task depends on broader architecture, inspect scope manually before trusting panel output.");
261
+ return lines.join("\n");
262
+ }
263
+ lines.push("### Candidate context");
264
+ for (const candidate of candidates) {
265
+ lines.push(`- ${candidate.title} [${candidate.kind}; score ${candidate.score}; why: ${candidate.why.join(", ") || "anchor"}]`);
266
+ if (candidate.preview) lines.push(` ${truncate(candidate.preview.replace(/\s+/g, " "), 220)}`);
267
+ }
268
+ return truncate(lines.join("\n"), MAX_SCOUT_CHARS);
269
+ }
270
+
271
+ function pushInline(lines: string[], label: string, items: string[], limit: number): void {
272
+ if (items.length === 0) return;
273
+ lines.push(`${label}: ${items.slice(0, limit).join(", ")}${items.length > limit ? `, +${items.length - limit}` : ""}`);
274
+ }
275
+
276
+ function normalizeFile(raw: string | undefined): string | undefined {
277
+ let file = raw?.trim().replace(/^['"`]|['"`,.;)\]]$/g, "");
278
+ if (!file) return undefined;
279
+ file = file.replace(/^@/, "").replace(/^(?:a|b)\//, "").replace(/^\.\//, "");
280
+ if (!file || file.includes("://") || path.isAbsolute(file)) return undefined;
281
+ if (file.split("/").some((part) => part === "..")) return undefined;
282
+ if (/^(?:node_modules|\.git|\.pi\/scrutiny)\//.test(file)) return undefined;
283
+ if (/^(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/.test(file)) return undefined;
284
+ return file;
285
+ }
286
+
287
+ function looksLikePath(value: string): boolean {
288
+ return value.includes("/") || /\.[A-Za-z0-9]+(?::\d+)?$/.test(value);
289
+ }
290
+
291
+ function isTestPath(file: string): boolean {
292
+ return /(^|\/)(test|tests|spec|__tests__)(\/|$)|\.(test|spec)\.[A-Za-z0-9]+$/i.test(file);
293
+ }
294
+
295
+ function isDocConfigPath(file: string): boolean {
296
+ return /(^|\/)(README|CONTEXT|docs|adr|architecture|service|schema|schemas|proto|openapi|routes|migrations|config|configs)(\/|\.|$)|\.(ya?ml|toml|json|proto|graphql|sql|properties|env)$/i.test(file);
297
+ }
298
+
299
+ function escapeRegex(text: string): string {
300
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
301
+ }
302
+
303
+ function unique(items: string[]): string[] {
304
+ return [...new Set(items.map((item) => item.trim()).filter(Boolean))];
305
+ }
306
+
307
+ function isInside(cwd: string, file: string): boolean {
308
+ const relative = path.relative(cwd, file);
309
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
310
+ }
311
+
312
+ const STOP = new Set([
313
+ "about", "after", "again", "answer", "because", "before", "could", "first", "model", "panel", "there", "these", "thing", "which", "would", "should", "their", "while", "where", "under", "using", "without", "recommendation", "evidence", "position", "panelist", "scrutiny", "surface", "packet", "context", "result", "status", "failed", "error", "output", "outputs", "returned", "usable", "technical", "vocabulary", "shared", "confidence", "deterministic", "analysis", "compare", "review", "change", "changes", "patch", "please", "maybe", "think", "tell", "what", "when", "does", "this", "that", "with", "from", "into", "doesn", "isn", "aren", "have", "been", "will", "just", "really", "basically", "actually", "simply",
314
+ ]);
@@ -0,0 +1,270 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { PanelResponse, ScrutinyRunResult, ScrutinySummary } from "./types.js";
5
+ import { scrutinyDataDir, truncate } from "./util.js";
6
+
7
+ const MAX_ITEMS = 40;
8
+ const MAX_SOURCE_REFS = 60;
9
+ const MAX_HASH_BYTES = 8 * 1024 * 1024;
10
+
11
+ export async function writeRunResult(input: { cwd: string; runDir: string; result: ScrutinyRunResult; prompt?: string }): Promise<void> {
12
+ await fs.writeFile(path.join(input.runDir, "result.json"), JSON.stringify(input.result, null, 2), { encoding: "utf8", mode: 0o600 });
13
+ await writeSurfaceArtifact(input).catch(() => undefined);
14
+ try {
15
+ await writeRunSummary(input);
16
+ } catch {
17
+ // Summary/index is best-effort. Never hide primary result.json write.
18
+ }
19
+ }
20
+
21
+ async function writeSurfaceArtifact(input: { runDir: string; result: ScrutinyRunResult }): Promise<void> {
22
+ const file = surfaceArtifactFile(input.result.surface);
23
+ if (!file) return;
24
+ const artifact = {
25
+ runId: input.result.runId,
26
+ surface: input.result.surface,
27
+ status: input.result.status,
28
+ failure_reason: input.result.failure_reason,
29
+ error: input.result.error,
30
+ packetPath: input.result.packetPath,
31
+ analysis: input.result.analysis,
32
+ panel: input.result.responses.map((response) => ({
33
+ model: response.model,
34
+ role: response.role,
35
+ status: response.status,
36
+ content: response.content,
37
+ error: response.error,
38
+ durationMs: response.durationMs,
39
+ })),
40
+ failed_models: input.result.failed_models,
41
+ verify: input.result.verify ? {
42
+ passed: input.result.verify.passed,
43
+ failed: input.result.verify.failed,
44
+ skipped: input.result.verify.skipped,
45
+ durationMs: input.result.verify.durationMs,
46
+ } : undefined,
47
+ startedAt: input.result.startedAt,
48
+ endedAt: input.result.endedAt,
49
+ durationMs: input.result.durationMs,
50
+ };
51
+ await fs.writeFile(path.join(input.runDir, file), JSON.stringify(artifact, null, 2), { encoding: "utf8", mode: 0o600 });
52
+ }
53
+
54
+ function surfaceArtifactFile(surface: ScrutinyRunResult["surface"]): string | undefined {
55
+ if (surface === "verify") return undefined; // verify already writes verify.json.
56
+ return `${surface}.json`;
57
+ }
58
+
59
+ export async function writeRunSummary(input: { cwd: string; runDir: string; result: ScrutinyRunResult; prompt?: string }): Promise<ScrutinySummary> {
60
+ const summary = await buildRunSummary(input);
61
+ await fs.writeFile(path.join(input.runDir, "summary.json"), JSON.stringify(summary, null, 2), { encoding: "utf8", mode: 0o600 });
62
+ await appendSummaryIndex(input.cwd, summary);
63
+ return summary;
64
+ }
65
+
66
+ async function buildRunSummary(input: { cwd: string; runDir: string; result: ScrutinyRunResult; prompt?: string }): Promise<ScrutinySummary> {
67
+ const { cwd, runDir, result } = input;
68
+ const prompt = truncate((input.prompt?.trim() || extractTask(result.packet) || result.error || "").trim(), 1_000);
69
+ const responseText = result.responses.map((response) => response.content).join("\n");
70
+ const analysisText = analysisToText(result);
71
+ const searchableText = [prompt, result.packet, responseText, analysisText].filter(Boolean).join("\n");
72
+ const sourceRefs = limit(extractSourceRefs(searchableText), MAX_SOURCE_REFS);
73
+ const files = limit(unique([...sourceRefs.map(fileFromRef), ...extractDiffFiles(result.packet)]).filter(Boolean), MAX_ITEMS);
74
+ const symbols = limit(extractSymbols(searchableText), MAX_ITEMS);
75
+ const keywords = limit(extractKeywords([prompt, analysisText, files.join(" ")].join("\n")), MAX_ITEMS);
76
+ const missingContext = limit(extractMissingContext(result.responses, result.analysis?.blind_spots), 8);
77
+ const fileHashes = await hashReferencedFiles(cwd, files);
78
+ const verifyPath = result.verify && await exists(path.join(runDir, "verify.json")) ? rel(cwd, path.join(runDir, "verify.json")) : undefined;
79
+ const responsesPath = await exists(path.join(runDir, "responses.json")) ? rel(cwd, path.join(runDir, "responses.json")) : undefined;
80
+ const surfaceArtifactName = surfaceArtifactFile(result.surface);
81
+ const surfaceArtifactPath = surfaceArtifactName && await exists(path.join(runDir, surfaceArtifactName)) ? rel(cwd, path.join(runDir, surfaceArtifactName)) : undefined;
82
+
83
+ return {
84
+ runId: result.runId,
85
+ surface: result.surface,
86
+ startedAt: result.startedAt,
87
+ endedAt: result.endedAt,
88
+ prompt,
89
+ status: result.status,
90
+ failure_reason: result.failure_reason,
91
+ error: result.error ? truncate(result.error, 500) : undefined,
92
+ files,
93
+ symbols,
94
+ keywords,
95
+ signals: limit(extractSignals(result), 8),
96
+ risks: limit(result.analysis?.risks ?? [], 8).map((item) => truncate(item, 300)),
97
+ contradictions: limit(extractContradictions(result), 6),
98
+ missingContext,
99
+ sourceRefs,
100
+ fileHashes,
101
+ resultPath: rel(cwd, path.join(runDir, "result.json")),
102
+ surfaceArtifactPath,
103
+ packetPath: result.packetPath ? rel(cwd, result.packetPath) : undefined,
104
+ responsesPath,
105
+ verifyPath,
106
+ };
107
+ }
108
+
109
+ async function appendSummaryIndex(cwd: string, summary: ScrutinySummary): Promise<void> {
110
+ const indexPath = path.join(scrutinyDataDir(cwd), "index.jsonl");
111
+ await fs.mkdir(path.dirname(indexPath), { recursive: true, mode: 0o700 });
112
+ await fs.appendFile(indexPath, `${JSON.stringify(summary)}\n`, { encoding: "utf8", mode: 0o600 });
113
+ }
114
+
115
+ function extractTask(packet: string): string {
116
+ const match = packet.match(/^## Task\s*\n([\s\S]*?)(?=\n## |$)/m);
117
+ return match?.[1]?.trim() ?? "";
118
+ }
119
+
120
+ function analysisToText(result: ScrutinyRunResult): string {
121
+ const analysis = result.analysis;
122
+ if (!analysis) return "";
123
+ return [
124
+ ...(analysis.consensus ?? []),
125
+ ...(analysis.risks ?? []),
126
+ ...(analysis.coverage ?? []),
127
+ ...(analysis.blind_spots ?? []),
128
+ ...(analysis.unique_insights ?? []).map((item) => item.insight),
129
+ ...(result.panel_mode === "roles" ? [] : (analysis.contradictions ?? []).flatMap((item) => [item.topic, ...item.stances.map((stance) => stance.stance)])),
130
+ ].join("\n");
131
+ }
132
+
133
+ function extractSignals(result: ScrutinyRunResult): string[] {
134
+ const analysis = result.analysis;
135
+ if (!analysis) return [];
136
+ const consensus = (analysis.consensus ?? []).filter((item) => !/panelists returned usable output|shared technical vocabulary/i.test(item));
137
+ const uniqueInsights = (analysis.unique_insights ?? []).map((item) => item.insight);
138
+ return unique([...consensus, ...(analysis.coverage ?? []), ...uniqueInsights]).map((item) => truncate(item, 300));
139
+ }
140
+
141
+ function extractContradictions(result: ScrutinyRunResult): string[] {
142
+ if (result.panel_mode === "roles") return [];
143
+ return (result.analysis?.contradictions ?? []).map((item) => {
144
+ const stances = item.stances.map((stance) => `${stance.model}: ${stance.stance}`).join(" | ");
145
+ return truncate(`${item.topic}${stances ? ` — ${stances}` : ""}`, 400);
146
+ });
147
+ }
148
+
149
+ function extractMissingContext(responses: PanelResponse[], blindSpots: string[] | undefined): string[] {
150
+ const lines = responses.flatMap((response) => response.content.split(/\r?\n/));
151
+ const missing = lines
152
+ .map(cleanBullet)
153
+ .filter((line) => line.length >= 20 && line.length <= 500)
154
+ .filter((line) => /\b(missing|not shown|not in (the )?packet|insufficient|unknown|cannot determine|can't determine|need(?:s)? to inspect|must inspect|would need|need more evidence|not enough evidence)\b/i.test(line));
155
+ const nonGenericBlindSpots = (blindSpots ?? []).filter((line) => !/^Deterministic analysis does not infer/i.test(line));
156
+ return unique([...missing, ...nonGenericBlindSpots]).map((item) => truncate(item, 300));
157
+ }
158
+
159
+ function extractSourceRefs(text: string): string[] {
160
+ const refs: string[] = [];
161
+ for (const line of text.split(/\r?\n/)) {
162
+ const diff = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
163
+ if (diff) {
164
+ pushRef(refs, diff[1]);
165
+ pushRef(refs, diff[2]);
166
+ continue;
167
+ }
168
+ const marker = line.match(/^(?:---|\+\+\+)\s+(?:a|b)\/(.+)$/);
169
+ if (marker) pushRef(refs, marker[1]);
170
+ const status = line.match(/^\s*(?:[MADRCU?]{1,2}|[ MADRCU?][ MADRCU?])\s+(.+?\.[A-Za-z0-9]+)$/);
171
+ if (status) pushRef(refs, status[1]);
172
+ }
173
+ const pathPattern = /(^|[\s([{"'`])((?:\.{1,2}\/)?(?:[A-Za-z0-9_.@+-]+\/)+[A-Za-z0-9_.@+-]+\.[A-Za-z0-9]+)(?::(\d+))?/g;
174
+ let match: RegExpExecArray | null;
175
+ while ((match = pathPattern.exec(text))) pushRef(refs, match[2], match[3]);
176
+ const rootFilePattern = /(^|[\s([{"'`])([A-Za-z0-9_.@+-]+\.(?:md|mdx|txt|json|ya?ml|toml|ts|tsx|js|jsx|py|java|kt|go|rs|sql|proto|graphql|gradle|xml|properties|env|sh))(?::(\d+))?/gi;
177
+ while ((match = rootFilePattern.exec(text))) pushRef(refs, match[2], match[3]);
178
+ return unique(refs);
179
+ }
180
+
181
+ function extractDiffFiles(packet: string): string[] {
182
+ const files: string[] = [];
183
+ for (const ref of extractSourceRefs(packet)) files.push(fileFromRef(ref));
184
+ return unique(files.filter(Boolean));
185
+ }
186
+
187
+ function pushRef(refs: string[], rawFile: string, line?: string): void {
188
+ const file = normalizeFilePath(rawFile);
189
+ if (!file) return;
190
+ refs.push(line ? `${file}:${line}` : file);
191
+ }
192
+
193
+ function normalizeFilePath(raw: string): string | undefined {
194
+ let file = raw.trim().replace(/^['"`]|['"`,.;)\]]$/g, "");
195
+ file = file.replace(/^(?:a|b)\//, "").replace(/^\.\//, "");
196
+ if (!file || file.includes("://") || path.isAbsolute(file)) return undefined;
197
+ if (file.split("/").some((part) => part === "..")) return undefined;
198
+ if (/^(?:node_modules|\.git|\.pi\/scrutiny)\//.test(file)) return undefined;
199
+ if (/^(?:packet\.md|responses\.json|result\.json|summary\.json|verify\.json)$/.test(file)) return undefined;
200
+ return file;
201
+ }
202
+
203
+ function fileFromRef(ref: string): string {
204
+ const match = ref.match(/^(.+?)(?::\d+)?$/);
205
+ return normalizeFilePath(match?.[1] ?? "") ?? "";
206
+ }
207
+
208
+ function extractSymbols(text: string): string[] {
209
+ const symbols: string[] = [];
210
+ for (const match of text.matchAll(/`([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?)`/g)) symbols.push(match[1]);
211
+ for (const match of text.matchAll(/\b([A-Z][A-Za-z0-9]+(?:[A-Z][A-Za-z0-9]+)+|[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*)\b/g)) symbols.push(match[1]);
212
+ return unique(symbols.filter((symbol) => symbol.length >= 4 && !STOP.has(symbol.toLowerCase())));
213
+ }
214
+
215
+ function extractKeywords(text: string): string[] {
216
+ const tokens = text.toLowerCase().match(/[a-z][a-z0-9_-]{3,}/g) ?? [];
217
+ return unique(tokens.filter((token) => !STOP.has(token) && token.length <= 40));
218
+ }
219
+
220
+ async function hashReferencedFiles(cwd: string, files: string[]): Promise<Record<string, string>> {
221
+ const hashes: Record<string, string> = {};
222
+ for (const file of files) {
223
+ const abs = path.resolve(cwd, file);
224
+ if (!isInside(cwd, abs)) continue;
225
+ try {
226
+ const stat = await fs.stat(abs);
227
+ if (!stat.isFile() || stat.size > MAX_HASH_BYTES) continue;
228
+ const data = await fs.readFile(abs);
229
+ hashes[file] = createHash("sha1").update(data).digest("hex");
230
+ } catch {
231
+ // deleted/missing/generated file; leave unhashed.
232
+ }
233
+ }
234
+ return hashes;
235
+ }
236
+
237
+ async function exists(file: string): Promise<boolean> {
238
+ try {
239
+ await fs.access(file);
240
+ return true;
241
+ } catch {
242
+ return false;
243
+ }
244
+ }
245
+
246
+ function rel(cwd: string, file: string): string {
247
+ const relative = path.relative(cwd, file);
248
+ return relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : file;
249
+ }
250
+
251
+ function isInside(cwd: string, file: string): boolean {
252
+ const relative = path.relative(cwd, file);
253
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
254
+ }
255
+
256
+ function cleanBullet(line: string): string {
257
+ return line.trim().replace(/^[-*•]\s+/, "").replace(/^\d+[.)]\s+/, "").trim();
258
+ }
259
+
260
+ function unique(items: string[]): string[] {
261
+ return [...new Set(items.map((item) => item.trim()).filter(Boolean))];
262
+ }
263
+
264
+ function limit<T>(items: T[], count: number): T[] {
265
+ return items.slice(0, count);
266
+ }
267
+
268
+ const STOP = new Set([
269
+ "about", "after", "again", "answer", "because", "before", "could", "first", "model", "panel", "there", "these", "thing", "which", "would", "should", "their", "while", "where", "under", "using", "without", "recommendation", "evidence", "position", "panelist", "scrutiny", "surface", "packet", "context", "result", "status", "failed", "error", "output", "outputs", "returned", "usable", "technical", "vocabulary", "shared", "confidence", "deterministic", "analysis",
270
+ ]);