@xaccefy/pi-casefile 0.7.1 → 0.7.3
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 +5 -4
- package/package.json +2 -1
- package/skills/casefile/SKILL.md +2 -2
- package/src/index.ts +193 -66
- package/src/ledger.ts +372 -33
- package/src/pipeline-submit.ts +498 -0
- package/src/sqlite-compat/index.ts +0 -1
- package/src/workflow.ts +158 -123
package/src/ledger.ts
CHANGED
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
14
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { type Dirent, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { basename, dirname, join, resolve } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
getScratchpadRoot,
|
|
18
|
+
type ScratchpadPhase,
|
|
19
|
+
scratchpad_read,
|
|
20
|
+
scratchpad_resume,
|
|
21
|
+
} from "./scratchpad.ts";
|
|
16
22
|
import { DatabaseSync } from "./sqlite-compat/index.ts";
|
|
17
23
|
|
|
18
24
|
// ── Types ────────────────────────────────────────────────────────────
|
|
@@ -119,9 +125,9 @@ export type CaseRecord = {
|
|
|
119
125
|
output?: string;
|
|
120
126
|
sandbox: boolean;
|
|
121
127
|
};
|
|
122
|
-
/** ISO timestamp when
|
|
128
|
+
/** ISO timestamp when CaseContext first wrote the context bundle. */
|
|
123
129
|
reportedAt?: string;
|
|
124
|
-
/** Path to the
|
|
130
|
+
/** Path to the final report file (set by writeCaseContext; the reporter agent writes the file). */
|
|
125
131
|
reportPath?: string;
|
|
126
132
|
/** Flat list of linked case IDs (back-compat; derived from linkedCases). */
|
|
127
133
|
linkedCaseIds: string[];
|
|
@@ -486,10 +492,13 @@ function validateCase(record: CaseRecord): void {
|
|
|
486
492
|
"Killed cases require evidence, next step, blockers, or assumptions explaining why",
|
|
487
493
|
);
|
|
488
494
|
}
|
|
489
|
-
// A case becomes REPORTED only
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
495
|
+
// A case becomes REPORTED only after the report FILE exists on disk (the
|
|
496
|
+
// report writer writes it at the path CaseContext recorded). Require both
|
|
497
|
+
// here so validation stays consistent with the confirmed→reported gate.
|
|
498
|
+
if (record.status === "reported" && (!record.reportPath || !existsSync(record.reportPath))) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
"Reported cases require the report file on disk; run CaseContext then have the report writer create it",
|
|
501
|
+
);
|
|
493
502
|
}
|
|
494
503
|
}
|
|
495
504
|
|
|
@@ -541,8 +550,8 @@ function validateTransition(
|
|
|
541
550
|
},
|
|
542
551
|
confirmed: {
|
|
543
552
|
reported: (_, current) =>
|
|
544
|
-
!current?.reportPath
|
|
545
|
-
? "confirmed → reported requires
|
|
553
|
+
!current?.reportPath || !existsSync(current.reportPath)
|
|
554
|
+
? "confirmed → reported requires the report file on disk; run CaseContext, then have the report writer create it"
|
|
546
555
|
: null,
|
|
547
556
|
investigating: () => null,
|
|
548
557
|
},
|
|
@@ -623,7 +632,7 @@ function findDuplicateCaseInDb(
|
|
|
623
632
|
db: DatabaseSync,
|
|
624
633
|
candidate: Pick<CaseRecord, "title" | "target" | "endpoint" | "bugClass">,
|
|
625
634
|
excludeId?: string,
|
|
626
|
-
): CaseRecord | undefined {
|
|
635
|
+
): { record: CaseRecord; near: boolean } | undefined {
|
|
627
636
|
const title = normalizeMatchText(candidate.title);
|
|
628
637
|
if (!title) return undefined;
|
|
629
638
|
|
|
@@ -636,12 +645,14 @@ function findDuplicateCaseInDb(
|
|
|
636
645
|
// ASCII-only and LIKE can't collapse whitespace, so any SQL pre-filter would
|
|
637
646
|
// silently drop rows the JS comparator would call duplicates (e.g. stored
|
|
638
647
|
// "SQL Injection" vs candidate "SQL Injection", or non-ASCII case variants).
|
|
639
|
-
// Case ledgers are small (hundreds of rows); a full
|
|
648
|
+
// Case ledgers are small (hundreds of rows); a full scan of live rows is cheap.
|
|
649
|
+
// Reported rows are excluded: they are terminal — an exact/near duplicate of a
|
|
650
|
+
// reported case is a NEW follow-up case, not a merge target.
|
|
640
651
|
const rows = excludeId
|
|
641
652
|
? (db
|
|
642
|
-
.prepare("SELECT * FROM cases WHERE status
|
|
653
|
+
.prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported') AND id != ?")
|
|
643
654
|
.all(excludeId) as any[])
|
|
644
|
-
: (db.prepare("SELECT * FROM cases WHERE status
|
|
655
|
+
: (db.prepare("SELECT * FROM cases WHERE status NOT IN ('killed', 'reported')").all() as any[]);
|
|
645
656
|
|
|
646
657
|
for (const row of rows) {
|
|
647
658
|
if (
|
|
@@ -650,18 +661,212 @@ function findDuplicateCaseInDb(
|
|
|
650
661
|
normalizeMatchText(row.endpoint as string) === endpoint &&
|
|
651
662
|
normalizeMatchText(row.bugClass as string) === bugClass
|
|
652
663
|
) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
664
|
+
return { record: rowToRecord(db, row), near: false };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Near-duplicate gate: parallel subagents re-phrase the same finding
|
|
669
|
+
// (different prefixes, order, or extra detail), so exact normalized titles
|
|
670
|
+
// miss most duplicates. When BOTH sides have the same non-empty target and
|
|
671
|
+
// the titles share enough significant tokens (≥5-char words, stopwords
|
|
672
|
+
// excluded), treat it as the same case — the agent should CaseUpdate the
|
|
673
|
+
// existing case instead of creating a 31st near-identical one. Calibrated
|
|
674
|
+
// against a real 30-case run: true near-dups shared 3–6 distinctive tokens.
|
|
675
|
+
// The non-empty-target requirement is deliberate: with no target, titles
|
|
676
|
+
// share only generic class vocabulary ("remote code execution in image vs
|
|
677
|
+
// PDF processing") and near-dup would false-merge distinct findings.
|
|
678
|
+
const candidateTokens = new Set(significantTitleTokens(title));
|
|
679
|
+
if (target && candidateTokens.size >= 3) {
|
|
680
|
+
for (const row of rows) {
|
|
681
|
+
const rowTarget = normalizeMatchText(row.target as string);
|
|
682
|
+
if (!rowTarget || rowTarget !== target) continue;
|
|
683
|
+
const shared = countSharedTokens(
|
|
684
|
+
candidateTokens,
|
|
685
|
+
significantTitleTokens(row.title as string),
|
|
659
686
|
);
|
|
687
|
+
if (shared >= NEAR_DUP_MIN_SHARED_TOKENS) {
|
|
688
|
+
return { record: rowToRecord(db, row), near: true };
|
|
689
|
+
}
|
|
660
690
|
}
|
|
661
691
|
}
|
|
692
|
+
|
|
662
693
|
return undefined;
|
|
663
694
|
}
|
|
664
695
|
|
|
696
|
+
// ── Near-duplicate title comparison ───────────────────────────────────
|
|
697
|
+
// Subagent titles phrase the same finding differently, so dedup must survive
|
|
698
|
+
// re-wording. Tokens are ≥5-char words from the lowercased, punctuation-split
|
|
699
|
+
// title, minus a small stopword set ("middleware", "pipeline", …). Two cases
|
|
700
|
+
// with the same target that share ≥3 significant tokens are near-duplicates.
|
|
701
|
+
|
|
702
|
+
const NEAR_DUP_MIN_SHARED_TOKENS = 3;
|
|
703
|
+
|
|
704
|
+
const TITLE_STOPWORDS = new Set([
|
|
705
|
+
// structural / workflow words
|
|
706
|
+
"middleware",
|
|
707
|
+
"middlewares",
|
|
708
|
+
"pipeline",
|
|
709
|
+
"finding",
|
|
710
|
+
"findings",
|
|
711
|
+
"vulnerability",
|
|
712
|
+
"vulnerabilities",
|
|
713
|
+
"issue",
|
|
714
|
+
"issues",
|
|
715
|
+
"result",
|
|
716
|
+
"results",
|
|
717
|
+
"causes",
|
|
718
|
+
"cause",
|
|
719
|
+
"leads",
|
|
720
|
+
"lead",
|
|
721
|
+
// connective / generic
|
|
722
|
+
"allows",
|
|
723
|
+
"allow",
|
|
724
|
+
"using",
|
|
725
|
+
"without",
|
|
726
|
+
"because",
|
|
727
|
+
"through",
|
|
728
|
+
"within",
|
|
729
|
+
"across",
|
|
730
|
+
"via",
|
|
731
|
+
"with",
|
|
732
|
+
"after",
|
|
733
|
+
"before",
|
|
734
|
+
"from",
|
|
735
|
+
"into",
|
|
736
|
+
"that",
|
|
737
|
+
"this",
|
|
738
|
+
"there",
|
|
739
|
+
"their",
|
|
740
|
+
"when",
|
|
741
|
+
"where",
|
|
742
|
+
"which",
|
|
743
|
+
"what",
|
|
744
|
+
"does",
|
|
745
|
+
"doesn",
|
|
746
|
+
"has",
|
|
747
|
+
"have",
|
|
748
|
+
"been",
|
|
749
|
+
"being",
|
|
750
|
+
"not",
|
|
751
|
+
"only",
|
|
752
|
+
"other",
|
|
753
|
+
"another",
|
|
754
|
+
"more",
|
|
755
|
+
"most",
|
|
756
|
+
"some",
|
|
757
|
+
"any",
|
|
758
|
+
"and",
|
|
759
|
+
"the",
|
|
760
|
+
"for",
|
|
761
|
+
"are",
|
|
762
|
+
"was",
|
|
763
|
+
"were",
|
|
764
|
+
"but",
|
|
765
|
+
"can",
|
|
766
|
+
"could",
|
|
767
|
+
"would",
|
|
768
|
+
"should",
|
|
769
|
+
"might",
|
|
770
|
+
// generic security-report vocabulary — class and filler words that appear in
|
|
771
|
+
// nearly every finding title; suppressing them makes the gate count only the
|
|
772
|
+
// distinctive subject (the trigger/location), which is what separates
|
|
773
|
+
// re-phrasings of one bug from different bugs in the same class.
|
|
774
|
+
"endpoint",
|
|
775
|
+
"endpoints",
|
|
776
|
+
"arbitrary",
|
|
777
|
+
"file",
|
|
778
|
+
"files",
|
|
779
|
+
"folder",
|
|
780
|
+
"folders",
|
|
781
|
+
"execution",
|
|
782
|
+
"execute",
|
|
783
|
+
"processing",
|
|
784
|
+
"process",
|
|
785
|
+
"remote",
|
|
786
|
+
"stored",
|
|
787
|
+
"blind",
|
|
788
|
+
"boolean",
|
|
789
|
+
"based",
|
|
790
|
+
"account",
|
|
791
|
+
"accounts",
|
|
792
|
+
"takeover",
|
|
793
|
+
"admin",
|
|
794
|
+
"administrator",
|
|
795
|
+
"administrators",
|
|
796
|
+
"request",
|
|
797
|
+
"requests",
|
|
798
|
+
"response",
|
|
799
|
+
"responses",
|
|
800
|
+
"parameter",
|
|
801
|
+
"parameters",
|
|
802
|
+
"input",
|
|
803
|
+
"inputs",
|
|
804
|
+
"value",
|
|
805
|
+
"values",
|
|
806
|
+
"user",
|
|
807
|
+
"users",
|
|
808
|
+
"data",
|
|
809
|
+
"access",
|
|
810
|
+
"page",
|
|
811
|
+
"pages",
|
|
812
|
+
"report",
|
|
813
|
+
"reports",
|
|
814
|
+
"code",
|
|
815
|
+
"script",
|
|
816
|
+
"scripts",
|
|
817
|
+
"injection",
|
|
818
|
+
"injections",
|
|
819
|
+
"leak",
|
|
820
|
+
"leaks",
|
|
821
|
+
"leaking",
|
|
822
|
+
"expose",
|
|
823
|
+
"exposes",
|
|
824
|
+
"exposed",
|
|
825
|
+
"exposure",
|
|
826
|
+
"disclose",
|
|
827
|
+
"discloses",
|
|
828
|
+
"disclosed",
|
|
829
|
+
"disclosure",
|
|
830
|
+
"bypass",
|
|
831
|
+
"bypasses",
|
|
832
|
+
"bypassing",
|
|
833
|
+
"bypassed",
|
|
834
|
+
]);
|
|
835
|
+
|
|
836
|
+
/** Significant (≥5-char, non-stopword) unique tokens of a title. */
|
|
837
|
+
function significantTitleTokens(title: string): string[] {
|
|
838
|
+
const words = (title ?? "")
|
|
839
|
+
.toLowerCase()
|
|
840
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
841
|
+
.split(" ")
|
|
842
|
+
.filter(Boolean);
|
|
843
|
+
const seen = new Set<string>();
|
|
844
|
+
const out: string[] = [];
|
|
845
|
+
for (const w of words) {
|
|
846
|
+
if (w.length >= 5 && !TITLE_STOPWORDS.has(w) && !seen.has(w)) {
|
|
847
|
+
seen.add(w);
|
|
848
|
+
out.push(w);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return out;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function countSharedTokens(a: Set<string>, b: string[]): number {
|
|
855
|
+
let n = 0;
|
|
856
|
+
for (const t of b) if (a.has(t)) n++;
|
|
857
|
+
return n;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function rowToRecord(db: DatabaseSync, row: any): CaseRecord {
|
|
861
|
+
const links = db
|
|
862
|
+
.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
|
|
863
|
+
.all(row.id) as { target_id: string; kind: string }[];
|
|
864
|
+
return mapRow(
|
|
865
|
+
row,
|
|
866
|
+
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
665
870
|
// ── SQLite Mutation Actions ───────────────────────────────────────────
|
|
666
871
|
|
|
667
872
|
function upsertCase(db: DatabaseSync, record: CaseRecord) {
|
|
@@ -749,9 +954,11 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
|
|
|
749
954
|
const duplicate = findDuplicateCaseInDb(db, record);
|
|
750
955
|
if (duplicate) {
|
|
751
956
|
return {
|
|
752
|
-
record: duplicate,
|
|
957
|
+
record: duplicate.record,
|
|
753
958
|
created: false,
|
|
754
|
-
reason:
|
|
959
|
+
reason: duplicate.near
|
|
960
|
+
? `Near-duplicate of existing case ${duplicate.record.id} (same target, overlapping title) — continue with that case via CaseUpdate`
|
|
961
|
+
: `Duplicate case exists: ${duplicate.record.id}`,
|
|
755
962
|
};
|
|
756
963
|
}
|
|
757
964
|
|
|
@@ -860,7 +1067,9 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
860
1067
|
return {
|
|
861
1068
|
record: current,
|
|
862
1069
|
changed: false,
|
|
863
|
-
reason:
|
|
1070
|
+
reason: duplicate.near
|
|
1071
|
+
? `Update would near-duplicate case ${duplicate.record.id} (same target, overlapping title)`
|
|
1072
|
+
: `Update would create a duplicate of case ${duplicate.record.id}`,
|
|
864
1073
|
};
|
|
865
1074
|
}
|
|
866
1075
|
|
|
@@ -1283,16 +1492,123 @@ function mdSection(title: string, body?: string): string {
|
|
|
1283
1492
|
return `## ${title}\n\n${body?.trim() || "Not recorded."}\n`;
|
|
1284
1493
|
}
|
|
1285
1494
|
|
|
1286
|
-
|
|
1495
|
+
// ── Context bundle completeness ──────────────────────────────────────
|
|
1496
|
+
// The case context is the reporter agent's ONLY window into the run. It must
|
|
1497
|
+
// carry the full audit trail: every case field (including the investigation
|
|
1498
|
+
// trail in evidence/assumptions and the failed disconfirmation attempts), the
|
|
1499
|
+
// linked cases in BOTH directions (chains AND killed dead-ends), and the
|
|
1500
|
+
// pipeline artifacts (recon entry points, traces, skeptic verdicts, PoC logs)
|
|
1501
|
+
// from any scratchpad run that produced this case.
|
|
1502
|
+
|
|
1503
|
+
const CONTEXT_PHASES: ScratchpadPhase[] = [
|
|
1504
|
+
"recon",
|
|
1505
|
+
"hunt",
|
|
1506
|
+
"gapfil",
|
|
1507
|
+
"trace",
|
|
1508
|
+
"skeptic",
|
|
1509
|
+
"validate",
|
|
1510
|
+
"chain",
|
|
1511
|
+
"patch",
|
|
1512
|
+
"report",
|
|
1513
|
+
];
|
|
1514
|
+
|
|
1515
|
+
/** Per-artifact content cap for the context bundle (generous; artifacts are small). */
|
|
1516
|
+
const MAX_ARTIFACT_CHARS = 100_000;
|
|
1517
|
+
|
|
1518
|
+
function buildCompleteRecord(current: CaseRecord): string {
|
|
1519
|
+
const rows: string[] = [];
|
|
1520
|
+
for (const [k, v] of Object.entries(current)) {
|
|
1521
|
+
if (v === undefined || v === null || v === "") continue;
|
|
1522
|
+
let display = typeof v === "object" ? JSON.stringify(v, null, 2) : String(v);
|
|
1523
|
+
// Path-leak guard: the verification objects carry the researcher's local
|
|
1524
|
+
// PoC/disconfirmation script paths — show basenames only (the dedicated
|
|
1525
|
+
// log sections below already render them as basenames).
|
|
1526
|
+
if ((k === "pocVerified" || k === "disconfirmationVerified") && v && typeof v === "object") {
|
|
1527
|
+
const redacted = {
|
|
1528
|
+
...(v as Record<string, unknown>),
|
|
1529
|
+
path: basename((v as { path?: string }).path ?? ""),
|
|
1530
|
+
};
|
|
1531
|
+
display = JSON.stringify(redacted, null, 2);
|
|
1532
|
+
}
|
|
1533
|
+
rows.push(`- **${k}:** ${display.replace(/\n/g, "\n ")}`);
|
|
1534
|
+
}
|
|
1535
|
+
return rows.join("\n");
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** All links touching this case, both directions, with the neighbor's state. */
|
|
1539
|
+
function buildCaseLinks(db: DatabaseSync, id: string): string {
|
|
1540
|
+
const outgoing = db
|
|
1541
|
+
.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
|
|
1542
|
+
.all(id) as { target_id: string; kind: string }[];
|
|
1543
|
+
const incoming = db
|
|
1544
|
+
.prepare("SELECT source_id, kind FROM case_links WHERE target_id = ?")
|
|
1545
|
+
.all(id) as { source_id: string; kind: string }[];
|
|
1546
|
+
const lines: string[] = [];
|
|
1547
|
+
for (const l of outgoing) {
|
|
1548
|
+
const t = getCaseById(l.target_id);
|
|
1549
|
+
lines.push(`- → ${l.target_id} [${l.kind}] ${t?.title ?? "?"} (${t?.status ?? "?"})`);
|
|
1550
|
+
}
|
|
1551
|
+
for (const l of incoming) {
|
|
1552
|
+
const t = getCaseById(l.source_id);
|
|
1553
|
+
lines.push(`- ← ${l.source_id} [${l.kind}] ${t?.title ?? "?"} (${t?.status ?? "?"})`);
|
|
1554
|
+
}
|
|
1555
|
+
return lines.length ? lines.join("\n") : "None.";
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* Pipeline artifacts from every scratchpad run whose checkpoint lists this
|
|
1560
|
+
* case id — recon entry points, per-finding traces, skeptic verdicts, PoC
|
|
1561
|
+
* logs, chain analysis. Missing runs/artifacts are stated, not silently
|
|
1562
|
+
* dropped, so the reporter knows what was never recorded.
|
|
1563
|
+
*/
|
|
1564
|
+
function buildScratchpadSection(caseId: string): string {
|
|
1565
|
+
const root = getScratchpadRoot();
|
|
1566
|
+
if (!existsSync(root)) return "No scratchpad found (no pipeline run artifacts recorded).";
|
|
1567
|
+
let entries: Dirent[] = [];
|
|
1568
|
+
try {
|
|
1569
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
1570
|
+
} catch {
|
|
1571
|
+
return "Scratchpad root unreadable.";
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
const sections: string[] = [];
|
|
1575
|
+
for (const entry of entries) {
|
|
1576
|
+
if (!entry.isDirectory()) continue;
|
|
1577
|
+
const resume = scratchpad_resume(entry.name);
|
|
1578
|
+
if (!resume) continue;
|
|
1579
|
+
const allIds = Object.values(resume.checkpoint.phase_ids ?? {}).flat() as string[];
|
|
1580
|
+
if (!allIds.includes(caseId)) continue;
|
|
1581
|
+
|
|
1582
|
+
sections.push(`### Run: ${entry.name} (project root: ${resume.checkpoint.project_root})`);
|
|
1583
|
+
for (const phase of CONTEXT_PHASES) {
|
|
1584
|
+
const names = resume.artifacts[phase];
|
|
1585
|
+
if (!names?.length) continue;
|
|
1586
|
+
sections.push(`#### ${phase}/`);
|
|
1587
|
+
for (const name of names) {
|
|
1588
|
+
const content = scratchpad_read(entry.name, phase, name) ?? "(unreadable)";
|
|
1589
|
+
const clipped =
|
|
1590
|
+
content.length > MAX_ARTIFACT_CHARS
|
|
1591
|
+
? `${content.slice(0, MAX_ARTIFACT_CHARS)}\n… [truncated ${content.length - MAX_ARTIFACT_CHARS} chars]`
|
|
1592
|
+
: content;
|
|
1593
|
+
sections.push(`\`${name}\`:\n\`\`\`\n${clipped}\n\`\`\``);
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
return sections.length
|
|
1599
|
+
? sections.join("\n")
|
|
1600
|
+
: "No scratchpad run found containing this case id (manual/CTF run without pipeline artifacts).";
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
export function writeCaseContext(id: string): {
|
|
1604
|
+
path: string;
|
|
1605
|
+
contextPath: string;
|
|
1606
|
+
record: CaseRecord;
|
|
1607
|
+
} {
|
|
1287
1608
|
const current = getCaseById(id);
|
|
1288
1609
|
if (!current) throw new Error(`Case not found: ${id}`);
|
|
1289
1610
|
if (current.status !== "confirmed" && current.status !== "reported") {
|
|
1290
|
-
throw new Error("Case
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// Reported cases are terminal artifacts — return the existing report path if present.
|
|
1294
|
-
if (current.status === "reported" && current.reportPath && existsSync(current.reportPath)) {
|
|
1295
|
-
return { path: current.reportPath, record: current };
|
|
1611
|
+
throw new Error("Case context requires a confirmed or reported case");
|
|
1296
1612
|
}
|
|
1297
1613
|
|
|
1298
1614
|
const db = getDb();
|
|
@@ -1307,7 +1623,16 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1307
1623
|
.replace(/[^a-z0-9]+/g, "-")
|
|
1308
1624
|
.replace(/^-+|-+$/g, "")
|
|
1309
1625
|
.slice(0, 70) || "case";
|
|
1310
|
-
|
|
1626
|
+
// The final report path: the reporter agent writes the polished report here.
|
|
1627
|
+
// A previously recorded reportPath is kept stable across calls (the report
|
|
1628
|
+
// file may already exist at it); otherwise derive the default.
|
|
1629
|
+
const reportPath = current.reportPath ?? join(reportDir, `${slug}-${current.id}.md`);
|
|
1630
|
+
// The context bundle: raw material for the report writer (evidence, logs,
|
|
1631
|
+
// verification, timeline). Never cleaned up — it is the audit trail.
|
|
1632
|
+
// ALWAYS regenerated fresh — serving a stored/derived bundle would silently
|
|
1633
|
+
// return stale or fabricated content (e.g. legacy cases reported before the
|
|
1634
|
+
// context bundle existed).
|
|
1635
|
+
const contextPath = join(reportDir, `${slug}-${current.id}.context.md`);
|
|
1311
1636
|
const references = current.references?.length
|
|
1312
1637
|
? current.references.map((r) => `- ${r}`).join("\n")
|
|
1313
1638
|
: undefined;
|
|
@@ -1316,6 +1641,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1316
1641
|
: undefined;
|
|
1317
1642
|
const body = [
|
|
1318
1643
|
`# ${current.title}`,
|
|
1644
|
+
"",
|
|
1645
|
+
"> CASE CONTEXT — raw material for the report writer (reporter agent). Do not ship this file.",
|
|
1646
|
+
`> Final report target: \`${basename(reportPath)}\` (write the polished report there).`,
|
|
1647
|
+
`> Case ID: ${current.id} — strip ALL case IDs and local paths from the final report.`,
|
|
1648
|
+
"",
|
|
1319
1649
|
`**Severity:** ${current.severity ?? "Not assessed"}`,
|
|
1320
1650
|
`**Status:** ${current.status}`,
|
|
1321
1651
|
`**Confidence:** ${current.confidence}`,
|
|
@@ -1344,11 +1674,20 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1344
1674
|
mdSection("Remediation", current.remediation),
|
|
1345
1675
|
mdSection("Assumptions and Uncertainty", assumptions),
|
|
1346
1676
|
mdSection("References", references),
|
|
1677
|
+
mdSection("Complete Case Record (all fields)", buildCompleteRecord(current)),
|
|
1678
|
+
mdSection(
|
|
1679
|
+
"Linked Cases (both directions, incl. killed dead-ends)",
|
|
1680
|
+
buildCaseLinks(db, current.id),
|
|
1681
|
+
),
|
|
1682
|
+
mdSection(
|
|
1683
|
+
"Pipeline Artifacts (scratchpad: recon, traces, skeptic, logs)",
|
|
1684
|
+
buildScratchpadSection(current.id),
|
|
1685
|
+
),
|
|
1347
1686
|
]
|
|
1348
1687
|
.filter(Boolean)
|
|
1349
1688
|
.join("\n");
|
|
1350
1689
|
|
|
1351
|
-
writeFileSync(
|
|
1690
|
+
writeFileSync(contextPath, body, "utf8");
|
|
1352
1691
|
|
|
1353
1692
|
const next: CaseRecord = {
|
|
1354
1693
|
...current,
|
|
@@ -1357,11 +1696,11 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
|
|
|
1357
1696
|
updatedAt: new Date().toISOString(),
|
|
1358
1697
|
};
|
|
1359
1698
|
|
|
1360
|
-
// Enforce the same field invariants as every other write path.
|
|
1699
|
+
// Enforce the same field invariants as every other write path. writeCaseContext
|
|
1361
1700
|
// never changes status (confirmed stays confirmed; the caller flips to reported
|
|
1362
1701
|
// via CaseUpdate, which runs validateTransition), but it does set reportPath —
|
|
1363
1702
|
// validateCase ensures the resulting record is internally consistent.
|
|
1364
1703
|
validateCase(next);
|
|
1365
1704
|
upsertCase(db, next);
|
|
1366
|
-
return { path: reportPath, record: next };
|
|
1705
|
+
return { path: reportPath, contextPath, record: next };
|
|
1367
1706
|
}
|