@xaccefy/pi-casefile 0.9.4 → 0.10.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.
- package/README.md +31 -67
- package/package.json +13 -16
- package/src/confirmation.ts +729 -0
- package/src/evidence.ts +4 -4
- package/src/index.ts +189 -293
- package/src/ledger-internal.ts +321 -0
- package/src/ledger.ts +75 -1142
- package/src/oob-oracle.ts +279 -0
- package/src/poc-runner.ts +10 -0
- package/src/scratchpad.ts +5 -6
- package/src/workflow.ts +46 -327
- package/skills/casefile/SKILL.md +0 -44
- package/src/ledger-worker-entry.ts +0 -35
- package/src/ledger-worker.ts +0 -77
- package/src/pipeline-submit.ts +0 -797
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal shared plumbing for the casefile ledger modules.
|
|
3
|
+
*
|
|
4
|
+
* The single home for the DB handle, record builder, validation, transaction
|
|
5
|
+
* wrapper, and text helpers. Both ledger.ts and its sibling modules
|
|
6
|
+
* (confirmation.ts) import these from here — one copy, no drift. NOT a public
|
|
7
|
+
* API — external callers import from ledger.ts, which re-exports what they need.
|
|
8
|
+
*
|
|
9
|
+
* Circular-import note: this module must stay leaf-like. It may import types
|
|
10
|
+
* from ledger.ts but no runtime values, or the ledger ⇄ sibling cycle gains
|
|
11
|
+
* an edge.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
import type { CaseRecord, EvidenceItem, NormalizedCaseInput } from "./ledger.ts";
|
|
16
|
+
import type { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
17
|
+
|
|
18
|
+
// ── Text helpers ─────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export function normalizeList(values: string[] | undefined): string[] {
|
|
21
|
+
return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeText(value: string | undefined): string | undefined {
|
|
25
|
+
const trimmed = value?.trim();
|
|
26
|
+
return trimmed || undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function stableShortId(input: string): string {
|
|
30
|
+
return createHash("sha1").update(input).digest("hex").slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { normalizeMatchText };
|
|
34
|
+
|
|
35
|
+
function normalizeMatchText(value: string | undefined): string {
|
|
36
|
+
return normalizeText(value)?.toLowerCase().replace(/\s+/g, " ") ?? "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── DB handle (module-global; ledger.ts owns lifecycle) ─────────────
|
|
40
|
+
|
|
41
|
+
let dbInstance: DatabaseSync | undefined;
|
|
42
|
+
let opener: (() => DatabaseSync) | undefined;
|
|
43
|
+
|
|
44
|
+
/** ledger.ts registers its schema-init opener here at module load. */
|
|
45
|
+
export function setDbOpener(impl: () => DatabaseSync): void {
|
|
46
|
+
opener = impl;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getDb(): DatabaseSync {
|
|
50
|
+
if (!dbInstance && opener) {
|
|
51
|
+
// Lazy open through the owner (schema init + safe-state checks) so any
|
|
52
|
+
// sibling module can start the ledger, not just ledger.ts call sites.
|
|
53
|
+
dbInstance = opener();
|
|
54
|
+
}
|
|
55
|
+
if (!dbInstance) {
|
|
56
|
+
throw new Error("Ledger database not initialized — open it via ledger.ts first");
|
|
57
|
+
}
|
|
58
|
+
return dbInstance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Called by ledger.ts's getDb() after opening (or reopening) the database. */
|
|
62
|
+
export function setDbInstance(db: DatabaseSync): void {
|
|
63
|
+
dbInstance = db;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function hasDbInstance(): boolean {
|
|
67
|
+
return dbInstance !== undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function closeDb(): void {
|
|
71
|
+
if (!dbInstance) return;
|
|
72
|
+
try {
|
|
73
|
+
dbInstance.close();
|
|
74
|
+
} catch {
|
|
75
|
+
// Best-effort close.
|
|
76
|
+
}
|
|
77
|
+
dbInstance = undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Record building & persistence primitives ────────────────────────
|
|
81
|
+
|
|
82
|
+
export function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
|
|
83
|
+
const timestamp = new Date().toISOString();
|
|
84
|
+
const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
|
|
85
|
+
const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
title,
|
|
90
|
+
status: input.status ?? existing?.status ?? "hypothesis",
|
|
91
|
+
// Once a case has been investigating/confirmed it never forgets — the kill
|
|
92
|
+
// gate must not be defeatable by demoting first.
|
|
93
|
+
everAdvanced:
|
|
94
|
+
existing?.everAdvanced === true ||
|
|
95
|
+
input.status === "investigating" ||
|
|
96
|
+
input.status === "confirmed",
|
|
97
|
+
confidence: input.confidence ?? existing?.confidence ?? "low",
|
|
98
|
+
severity: input.severity ?? existing?.severity,
|
|
99
|
+
priority: input.priority ?? existing?.priority,
|
|
100
|
+
target: input.target !== undefined ? normalizeText(input.target) : existing?.target,
|
|
101
|
+
endpoint: input.endpoint !== undefined ? normalizeText(input.endpoint) : existing?.endpoint,
|
|
102
|
+
bugClass: input.bugClass !== undefined ? normalizeText(input.bugClass) : existing?.bugClass,
|
|
103
|
+
summary: input.summary !== undefined ? normalizeText(input.summary) : existing?.summary,
|
|
104
|
+
evidence: input.evidence !== undefined ? normalizeText(input.evidence) : existing?.evidence,
|
|
105
|
+
impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
|
|
106
|
+
nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
|
|
107
|
+
poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
|
|
108
|
+
remediation:
|
|
109
|
+
input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
|
|
110
|
+
references: normalizeList(input.references ?? existing?.references),
|
|
111
|
+
blockers: normalizeList(input.blockers ?? existing?.blockers),
|
|
112
|
+
tags: normalizeList(input.tags ?? existing?.tags),
|
|
113
|
+
assumptions: normalizeList(input.assumptions ?? existing?.assumptions),
|
|
114
|
+
disproveIf: normalizeList(input.disproveIf ?? existing?.disproveIf),
|
|
115
|
+
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
116
|
+
disconfirmation:
|
|
117
|
+
input.disconfirmation !== undefined
|
|
118
|
+
? normalizeText(input.disconfirmation)
|
|
119
|
+
: existing?.disconfirmation,
|
|
120
|
+
invariant: input.invariant !== undefined ? normalizeText(input.invariant) : existing?.invariant,
|
|
121
|
+
disconfirmationVerified: input.disconfirmationVerified ?? existing?.disconfirmationVerified,
|
|
122
|
+
controlVerified: input.controlVerified ?? existing?.controlVerified,
|
|
123
|
+
pendingConfirmation: input.pendingConfirmation ?? existing?.pendingConfirmation,
|
|
124
|
+
confirmerVerdict: input.confirmerVerdict ?? existing?.confirmerVerdict,
|
|
125
|
+
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
126
|
+
reportPath: input.reportPath ?? existing?.reportPath,
|
|
127
|
+
evidenceItems: existing?.evidenceItems ?? [],
|
|
128
|
+
coverageItems: existing?.coverageItems ?? [],
|
|
129
|
+
linkedCases: existing?.linkedCases ?? [],
|
|
130
|
+
createdAt: existing?.createdAt ?? timestamp,
|
|
131
|
+
updatedAt: timestamp,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function validateCase(record: CaseRecord): void {
|
|
136
|
+
if (!record.title.trim()) throw new Error("Case title cannot be empty");
|
|
137
|
+
// Falsification conditions are load-bearing: they are required at creation
|
|
138
|
+
// and must not be erasable later (CaseUpdate({ disproveIf: [] }) would wipe
|
|
139
|
+
// the hypothesis's falsifiability). Re-check on every write.
|
|
140
|
+
if (record.status !== "reported" && !(record.disproveIf ?? []).some((d) => d.trim())) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"Cases require disproveIf — falsification conditions (what would disprove this hypothesis). " +
|
|
143
|
+
"They cannot be cleared once set.",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
// Keep this gate in lockstep with the confirmation module: a case may only
|
|
147
|
+
// be CONFIRMED when it has evidence, a PoC, demonstrated impact, a severity,
|
|
148
|
+
// and a named target (what host/repo/scope this affects).
|
|
149
|
+
if (
|
|
150
|
+
record.status === "confirmed" &&
|
|
151
|
+
(!record.evidence ||
|
|
152
|
+
!record.poc ||
|
|
153
|
+
!record.impact ||
|
|
154
|
+
!record.severity ||
|
|
155
|
+
!record.target ||
|
|
156
|
+
!record.disconfirmation)
|
|
157
|
+
) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Confirmed cases require evidence, poc, impact, severity, target, and disconfirmation",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (record.status === "blocked" && (record.blockers ?? []).length === 0) {
|
|
163
|
+
throw new Error("Blocked cases require at least one blocker");
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
record.status === "killed" &&
|
|
167
|
+
!record.evidence &&
|
|
168
|
+
!record.nextStep &&
|
|
169
|
+
(record.blockers ?? []).length === 0 &&
|
|
170
|
+
(record.assumptions ?? []).length === 0
|
|
171
|
+
) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
// A case becomes REPORTED only after a report FILE that passes the content
|
|
177
|
+
// gate exists on disk. Existence is not enough: any non-empty file — or a
|
|
178
|
+
// directory — would otherwise flip the case to a permanent, immutable state.
|
|
179
|
+
if (record.status === "reported") {
|
|
180
|
+
// Imported lazily as a type-only dependency: validateReportFile is defined
|
|
181
|
+
// in format helpers within ledger.ts and injected here to avoid a runtime
|
|
182
|
+
// cycle. The setter runs at module init in ledger.ts.
|
|
183
|
+
if (validateReportFileImpl === undefined) {
|
|
184
|
+
throw new Error("Report validation unavailable — ledger not fully initialized");
|
|
185
|
+
}
|
|
186
|
+
const reportError = validateReportFileImpl(record.reportPath, record);
|
|
187
|
+
if (reportError) {
|
|
188
|
+
throw new Error(`Reported cases require a valid report file: ${reportError}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let validateReportFileImpl:
|
|
194
|
+
| ((reportPath: string | undefined, record: CaseRecord) => string | null)
|
|
195
|
+
| undefined;
|
|
196
|
+
|
|
197
|
+
export function setValidateReportFile(
|
|
198
|
+
impl: (reportPath: string | undefined, record: CaseRecord) => string | null,
|
|
199
|
+
): void {
|
|
200
|
+
validateReportFileImpl = impl;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function withImmediateTransaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
204
|
+
db.exec("BEGIN IMMEDIATE");
|
|
205
|
+
try {
|
|
206
|
+
const value = fn();
|
|
207
|
+
db.exec("COMMIT");
|
|
208
|
+
return value;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
try {
|
|
211
|
+
db.exec("ROLLBACK");
|
|
212
|
+
} catch {
|
|
213
|
+
// ignore rollback errors
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
220
|
+
// Use ON CONFLICT DO UPDATE (not INSERT OR REPLACE) so FK CASCADE does not
|
|
221
|
+
// wipe case_links when updating an existing primary key.
|
|
222
|
+
const stmt = db.prepare(`
|
|
223
|
+
INSERT INTO cases (
|
|
224
|
+
id, title, status, ever_advanced, confidence, severity, priority, target, endpoint, bugClass,
|
|
225
|
+
summary, evidence, impact, nextStep, poc, remediation,
|
|
226
|
+
references_json, blockers_json, tags_json, assumptions_json, poc_verified_json,
|
|
227
|
+
disconfirmation, disconfirmation_verified_json, disprove_if_json, control_verified_json,
|
|
228
|
+
pending_confirmation_json, confirmer_verdict_json,
|
|
229
|
+
reported_at, report_path, invariant, created_at, updated_at
|
|
230
|
+
) VALUES (
|
|
231
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
232
|
+
?, ?, ?, ?, ?, ?,
|
|
233
|
+
?, ?, ?, ?, ?,
|
|
234
|
+
?, ?, ?, ?,
|
|
235
|
+
?, ?,
|
|
236
|
+
?, ?, ?, ?, ?
|
|
237
|
+
)
|
|
238
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
239
|
+
title = excluded.title,
|
|
240
|
+
status = excluded.status,
|
|
241
|
+
ever_advanced = excluded.ever_advanced,
|
|
242
|
+
confidence = excluded.confidence,
|
|
243
|
+
severity = excluded.severity,
|
|
244
|
+
priority = excluded.priority,
|
|
245
|
+
target = excluded.target,
|
|
246
|
+
endpoint = excluded.endpoint,
|
|
247
|
+
bugClass = excluded.bugClass,
|
|
248
|
+
summary = excluded.summary,
|
|
249
|
+
evidence = excluded.evidence,
|
|
250
|
+
impact = excluded.impact,
|
|
251
|
+
nextStep = excluded.nextStep,
|
|
252
|
+
poc = excluded.poc,
|
|
253
|
+
remediation = excluded.remediation,
|
|
254
|
+
references_json = excluded.references_json,
|
|
255
|
+
blockers_json = excluded.blockers_json,
|
|
256
|
+
tags_json = excluded.tags_json,
|
|
257
|
+
assumptions_json = excluded.assumptions_json,
|
|
258
|
+
poc_verified_json = excluded.poc_verified_json,
|
|
259
|
+
disconfirmation = excluded.disconfirmation,
|
|
260
|
+
disconfirmation_verified_json = excluded.disconfirmation_verified_json,
|
|
261
|
+
disprove_if_json = excluded.disprove_if_json,
|
|
262
|
+
control_verified_json = excluded.control_verified_json,
|
|
263
|
+
pending_confirmation_json = excluded.pending_confirmation_json,
|
|
264
|
+
confirmer_verdict_json = excluded.confirmer_verdict_json,
|
|
265
|
+
invariant = excluded.invariant,
|
|
266
|
+
reported_at = excluded.reported_at,
|
|
267
|
+
report_path = excluded.report_path,
|
|
268
|
+
created_at = excluded.created_at,
|
|
269
|
+
updated_at = excluded.updated_at
|
|
270
|
+
`);
|
|
271
|
+
|
|
272
|
+
stmt.run(
|
|
273
|
+
record.id,
|
|
274
|
+
record.title,
|
|
275
|
+
record.status,
|
|
276
|
+
record.everAdvanced ? 1 : 0,
|
|
277
|
+
record.confidence,
|
|
278
|
+
record.severity || null,
|
|
279
|
+
record.priority || null,
|
|
280
|
+
record.target || null,
|
|
281
|
+
record.endpoint || null,
|
|
282
|
+
record.bugClass || null,
|
|
283
|
+
record.summary || null,
|
|
284
|
+
record.evidence || null,
|
|
285
|
+
record.impact || null,
|
|
286
|
+
record.nextStep || null,
|
|
287
|
+
record.poc || null,
|
|
288
|
+
record.remediation || null,
|
|
289
|
+
JSON.stringify(record.references),
|
|
290
|
+
JSON.stringify(record.blockers),
|
|
291
|
+
JSON.stringify(record.tags),
|
|
292
|
+
JSON.stringify(record.assumptions),
|
|
293
|
+
record.pocVerified ? JSON.stringify(record.pocVerified) : null,
|
|
294
|
+
record.disconfirmation || null,
|
|
295
|
+
record.disconfirmationVerified ? JSON.stringify(record.disconfirmationVerified) : null,
|
|
296
|
+
JSON.stringify(record.disproveIf),
|
|
297
|
+
record.controlVerified ? JSON.stringify(record.controlVerified) : null,
|
|
298
|
+
record.pendingConfirmation ? JSON.stringify(record.pendingConfirmation) : null,
|
|
299
|
+
record.confirmerVerdict ? JSON.stringify(record.confirmerVerdict) : null,
|
|
300
|
+
record.reportedAt || null,
|
|
301
|
+
record.reportPath || null,
|
|
302
|
+
record.invariant || null,
|
|
303
|
+
record.createdAt,
|
|
304
|
+
record.updatedAt,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function insertEvidenceItem(db: DatabaseSync, item: EvidenceItem): void {
|
|
309
|
+
db.prepare(
|
|
310
|
+
`INSERT INTO evidence_items (id, case_id, role, artifact_path, sha256, summary, created_at)
|
|
311
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
312
|
+
).run(
|
|
313
|
+
item.id,
|
|
314
|
+
item.caseId,
|
|
315
|
+
item.role,
|
|
316
|
+
item.artifactPath ?? null,
|
|
317
|
+
item.sha256 ?? null,
|
|
318
|
+
item.summary,
|
|
319
|
+
item.createdAt,
|
|
320
|
+
);
|
|
321
|
+
}
|