@xaccefy/pi-casefile 0.2.9 → 0.5.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 +8 -8
- package/package.json +1 -1
- package/src/index.ts +76 -16
- package/src/ledger.ts +114 -41
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-casefile
|
|
2
2
|
|
|
3
|
-
Local
|
|
3
|
+
Local security case book for Pi Agent. Keeps your guesses → proven findings behind a PoC gate, saved in SQLite, run in a sandbox.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ Or via the XPI umbrella package: `pi install npm:@xaccefy/pi-xpi`
|
|
|
12
12
|
|
|
13
13
|
## XP mode (default OFF)
|
|
14
14
|
|
|
15
|
-
The
|
|
15
|
+
The attack-mode text stays **quiet by default** so your normal coding isn't buried in security talk.
|
|
16
16
|
|
|
17
17
|
| Control | Effect |
|
|
18
18
|
|---------|--------|
|
|
@@ -21,7 +21,7 @@ The cyber-workflow context injection is **quiet by default** so normal dev work
|
|
|
21
21
|
| `PI_XP_MODE=on` | Force ON for this process (overrides file) |
|
|
22
22
|
| `PI_XP_MODE=off` | Force OFF |
|
|
23
23
|
|
|
24
|
-
When **ON**, every prompt
|
|
24
|
+
When **ON**, every prompt gets the attacker-minded workflow plus any open cases. When **OFF**, nothing is added; tools still work.
|
|
25
25
|
|
|
26
26
|
State is persisted next to the ledger as `xp-mode` (e.g. `.pi/xp-mode`).
|
|
27
27
|
|
|
@@ -42,12 +42,12 @@ hypothesis → investigating → confirmed → reported
|
|
|
42
42
|
blocked killed (terminal)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
- **investigating**
|
|
46
|
-
- **confirmed** only
|
|
47
|
-
- **reported**
|
|
48
|
-
- **killed** / **reported** are
|
|
45
|
+
- **investigating** needs `evidence` + `confidence`
|
|
46
|
+
- **confirmed** only by running the PoC (`PromoteFinding`, exit 0) — you can't just set status to confirmed
|
|
47
|
+
- **reported** needs `CaseReport` first
|
|
48
|
+
- **killed** / **reported** are final (no more edits)
|
|
49
49
|
|
|
50
|
-
There is **no** `impact_proof`
|
|
50
|
+
There is **no** `impact_proof` field. Put proof in `impact` or `evidence`.
|
|
51
51
|
|
|
52
52
|
## Tools
|
|
53
53
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
formatCases,
|
|
31
31
|
getCaseById,
|
|
32
32
|
getCasefilePath,
|
|
33
|
+
LINK_KIND_VALUES,
|
|
33
34
|
linkCasesResult,
|
|
34
35
|
PRIORITY_VALUES,
|
|
35
36
|
promoteFindingResult,
|
|
@@ -46,10 +47,13 @@ import { runPoc } from "./poc-runner.ts";
|
|
|
46
47
|
|
|
47
48
|
// ── Schemas ───────────────────────────────────────────────────────────
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
50
|
+
// Provider-safe string enums: Type.String({ enum }) serializes as { type: "string", enum: [...] }.
|
|
51
|
+
// Do NOT use Type.Union(Type.Literal...) → anyOf/const (providers drop optional anyOf fields,
|
|
52
|
+
// so status-only / severity-only updates arrive empty and silently no-op).
|
|
53
|
+
const CaseStatusSchema = Type.String({ enum: [...STATUS_VALUES] });
|
|
54
|
+
const CaseConfidenceSchema = Type.String({ enum: [...CONFIDENCE_VALUES] });
|
|
55
|
+
const CaseSeveritySchema = Type.String({ enum: [...SEVERITY_VALUES] });
|
|
56
|
+
const CasePrioritySchema = Type.String({ enum: [...PRIORITY_VALUES] });
|
|
53
57
|
|
|
54
58
|
const CommonFields = {
|
|
55
59
|
status: Type.Optional(CaseStatusSchema),
|
|
@@ -146,12 +150,10 @@ const SearchSchema = Type.Object(
|
|
|
146
150
|
{
|
|
147
151
|
query: Type.String({ description: "Text to search across cases" }),
|
|
148
152
|
field: Type.Optional(
|
|
149
|
-
Type.
|
|
150
|
-
SEARCH_FIELD_VALUES
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
},
|
|
154
|
-
),
|
|
153
|
+
Type.String({
|
|
154
|
+
enum: [...SEARCH_FIELD_VALUES],
|
|
155
|
+
description: "Restrict search to a specific field",
|
|
156
|
+
}),
|
|
155
157
|
),
|
|
156
158
|
status: Type.Optional(CaseStatusSchema),
|
|
157
159
|
confidence: Type.Optional(CaseConfidenceSchema),
|
|
@@ -177,6 +179,13 @@ const LinkSchema = Type.Object(
|
|
|
177
179
|
{
|
|
178
180
|
source_id: Type.String({ description: "First case ID" }),
|
|
179
181
|
target_id: Type.String({ description: "Second case ID to link" }),
|
|
182
|
+
kind: Type.Optional(
|
|
183
|
+
Type.String({
|
|
184
|
+
enum: [...LINK_KIND_VALUES],
|
|
185
|
+
description:
|
|
186
|
+
"Relationship kind from source to target: duplicate | related | blocks | depends-on | caused-by | supersedes | mitigates | same-root-cause. Defaults to related.",
|
|
187
|
+
}),
|
|
188
|
+
),
|
|
180
189
|
},
|
|
181
190
|
{ additionalProperties: false },
|
|
182
191
|
);
|
|
@@ -478,6 +487,43 @@ Keep killed reasons explicit in assumptions/blockers:
|
|
|
478
487
|
- \`environmental_issue\`
|
|
479
488
|
- \`not_applicable\` (true bug / interesting behavior, no realistic attacker value)
|
|
480
489
|
Documenting kills prevents re-opening dead ends.
|
|
490
|
+
|
|
491
|
+
---
|
|
492
|
+
|
|
493
|
+
## 9. Tool Ecosystem (USE PROACTIVELY)
|
|
494
|
+
|
|
495
|
+
You have offensive tools beyond casefile. Use them — do not rely on memory or guesswork.
|
|
496
|
+
|
|
497
|
+
| Tool | When to use | Do NOT skip it when... |
|
|
498
|
+
|------|------------|------------------------|
|
|
499
|
+
| **ExploitSearch** | Before writing any PoC. Search for known techniques, bypasses, and attack primitives relevant to the target stack/vuln class. | ...you are investigating a hypothesis or building a PoC. Ground your approach in real write-ups, not memory. |
|
|
500
|
+
| **engage** | When testing a live web target that requires authentication. Add a session (cookie/OAuth/mTLS) then run curl/httpx/ffuf with auth injected. | ...the user has supplied credentials or a target URL for authenticated testing. |
|
|
501
|
+
| **web_search** | To find CVEs, advisories, prior bug reports, documentation, or any live information about the target. | ...you need to check if a vulnerability is known, find version-specific issues, or research a technology. |
|
|
502
|
+
| **web_fetch** | To read full page content from a URL you already have (advisory, write-up, target page). | ...you have a specific URL to inspect. |
|
|
503
|
+
| **context7** | To look up current library/framework API docs and behavior. | ...you need to understand how a framework feature works (auth, parsing, routing). |
|
|
504
|
+
| **deepwiki** | To ask questions about a public GitHub repository's architecture and internals. | ...the target is an open-source project and you need to understand its design. |
|
|
505
|
+
| **codebase-memory-mcp** | To index a codebase and trace source-to-sink paths. index_repository, get_architecture, search_graph, trace_path. | ...you have access to the target source code and need structural reachability analysis. |
|
|
506
|
+
|
|
507
|
+
**pdtm CLI tools** (run via bash; each takes auth/flags differently — read the flags, do not guess):
|
|
508
|
+
- subfinder -d host (-silent, -t threads) — passive subdomain enum from API sources; no target auth. Pipe to httpx, not straight to nuclei.
|
|
509
|
+
- httpx -u <url> / -l hosts.txt (-t threads, -td tech-detect, -mc match-status, -H "Name: Value") — fast probe of authed endpoints; supports Header/Cookie/Bearer.
|
|
510
|
+
- ffuf -u <url> -w wordlist (-t threads, -rate, -H "..." -b "c=v", -mc/-fs filters) — authed fuzzing / content discovery.
|
|
511
|
+
- whatweb <url> (-a aggression 1-4, -t threads, --cookie) — tech fingerprint; positional URL, no -u.
|
|
512
|
+
- naabu -host <ip> / -l (-p ports, -rate, -c top-ports) — port scan; hosts, not web-auth.
|
|
513
|
+
- katana -u <url> / -list (-d depth, -jc js-crawl, -H "...") — crawl (engage spider already does authed crawl).
|
|
514
|
+
- nuclei -l hosts.txt / -u <url> (-tags, -severity, -type http, -silent; -c threads -bs host-batch -rl rate-limit -timeout 5 -retries 0): FAST when filtered, slow only if naive.
|
|
515
|
+
- Do not run all 9000+ templates. Filter: -tags cve,exposure,rce -severity critical,high -type http -t http/misconfiguration/.
|
|
516
|
+
- Pre-filter targets: subfinder -> httpx -mc 200,403 -> nuclei (cuts ~80% of work).
|
|
517
|
+
- Tune: -c 100-200 -bs 50-100 -rl 300 (avoid Cloudflare tarpit) -timeout 5 -retries 0 -mhe 10.
|
|
518
|
+
- Many hosts: -scan-strategy host-spray (v3). Few hosts many templates: template-spray.
|
|
519
|
+
- engage is auth/session/creds only (cookie/OAuth/mTLS, signup/login) — not a general scanner.
|
|
520
|
+
|
|
521
|
+
**Default behavior when XP mode is ON:**
|
|
522
|
+
1. Start recon with ExploitSearch + web_search before diving into code.
|
|
523
|
+
2. Use engage for any authenticated web target the user has set up.
|
|
524
|
+
3. Use context7/deepwiki to understand framework internals before claiming a vuln.
|
|
525
|
+
4. Use codebase-memory-mcp (if available) to prove reachability structurally.
|
|
526
|
+
5. Log everything to casefile (CaseAdd/CaseUpdate). Do not skip the ledger.
|
|
481
527
|
`.trim();
|
|
482
528
|
|
|
483
529
|
function sanitizeContextText(v?: string, max = 160): string | undefined {
|
|
@@ -967,19 +1013,28 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
967
1013
|
pi.registerTool({
|
|
968
1014
|
name: "CaseLink",
|
|
969
1015
|
label: "Link Cases",
|
|
970
|
-
description:
|
|
1016
|
+
description:
|
|
1017
|
+
"Bidirectionally link two cases. Use to build exploit chains. Optional `kind` records the relationship (duplicate | related | blocks | depends-on | caused-by | supersedes | mitigates | same-root-cause).",
|
|
971
1018
|
promptSnippet: "Link two cases into an exploit chain",
|
|
1019
|
+
promptGuidelines: [
|
|
1020
|
+
"Use CaseLink to bidirectionally link two cases. Pass `kind` to record how they relate (duplicate, blocks, caused-by, supersedes, etc.); omit it for a plain chain link (defaults to related).",
|
|
1021
|
+
],
|
|
972
1022
|
parameters: LinkSchema,
|
|
973
1023
|
|
|
974
1024
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
975
|
-
const result = linkCasesResult(
|
|
1025
|
+
const result = linkCasesResult(
|
|
1026
|
+
params.source_id as string,
|
|
1027
|
+
params.target_id as string,
|
|
1028
|
+
params.kind as string | undefined,
|
|
1029
|
+
);
|
|
976
1030
|
const { source, target } = result;
|
|
1031
|
+
const kindLabel = result.kind ? ` [${result.kind}]` : "";
|
|
977
1032
|
return {
|
|
978
1033
|
content: [
|
|
979
1034
|
{
|
|
980
1035
|
type: "text",
|
|
981
1036
|
text: result.changed
|
|
982
|
-
? `Linked:\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`
|
|
1037
|
+
? `Linked${kindLabel}:\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`
|
|
983
1038
|
: `Link unchanged: ${result.reason ?? "no material change"}\n ${formatCase(source)}\n ↔\n ${formatCase(target)}`,
|
|
984
1039
|
},
|
|
985
1040
|
],
|
|
@@ -988,16 +1043,18 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
988
1043
|
target,
|
|
989
1044
|
changed: result.changed,
|
|
990
1045
|
reason: result.reason,
|
|
1046
|
+
kind: result.kind,
|
|
991
1047
|
},
|
|
992
1048
|
};
|
|
993
1049
|
},
|
|
994
1050
|
|
|
995
1051
|
renderCall(args, theme) {
|
|
1052
|
+
const kind = args.kind ? ` [${args.kind}]` : "";
|
|
996
1053
|
return new Text(
|
|
997
1054
|
theme.fg("toolTitle", theme.bold("CaseLink ")) +
|
|
998
1055
|
theme.fg(
|
|
999
1056
|
"dim",
|
|
1000
|
-
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}`,
|
|
1057
|
+
`${(args.source_id as string) ?? ""} ↔ ${(args.target_id as string) ?? ""}${kind}`,
|
|
1001
1058
|
),
|
|
1002
1059
|
0,
|
|
1003
1060
|
0,
|
|
@@ -1006,11 +1063,12 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1006
1063
|
|
|
1007
1064
|
renderResult(result, _options, theme) {
|
|
1008
1065
|
const details = result.details as
|
|
1009
|
-
| { source?: CaseRecord; target?: CaseRecord; changed?: boolean }
|
|
1066
|
+
| { source?: CaseRecord; target?: CaseRecord; changed?: boolean; kind?: string }
|
|
1010
1067
|
| undefined;
|
|
1011
1068
|
if (!details?.source || !details?.target) {
|
|
1012
1069
|
return new Text("Linked", 0, 0);
|
|
1013
1070
|
}
|
|
1071
|
+
const kindLabel = details.kind ? ` [${details.kind}]` : "";
|
|
1014
1072
|
return new Text(
|
|
1015
1073
|
theme.fg(
|
|
1016
1074
|
details.changed === false ? "warning" : "success",
|
|
@@ -1018,7 +1076,8 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1018
1076
|
) +
|
|
1019
1077
|
theme.fg("accent", details.source.id) +
|
|
1020
1078
|
" ↔ " +
|
|
1021
|
-
theme.fg("accent", details.target.id)
|
|
1079
|
+
theme.fg("accent", details.target.id) +
|
|
1080
|
+
kindLabel,
|
|
1022
1081
|
0,
|
|
1023
1082
|
0,
|
|
1024
1083
|
);
|
|
@@ -1054,6 +1113,7 @@ export default function casefileExtension(pi: ExtensionAPI) {
|
|
|
1054
1113
|
target,
|
|
1055
1114
|
changed: result.changed,
|
|
1056
1115
|
reason: result.reason,
|
|
1116
|
+
kind: result.kind,
|
|
1057
1117
|
},
|
|
1058
1118
|
};
|
|
1059
1119
|
},
|
package/src/ledger.ts
CHANGED
|
@@ -36,6 +36,38 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
|
|
|
36
36
|
export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
|
|
37
37
|
export type CasePriority = (typeof PRIORITY_VALUES)[number];
|
|
38
38
|
|
|
39
|
+
/** Typed relationship kinds for CaseLink. Input values accepted by the tool. */
|
|
40
|
+
export const LINK_KIND_VALUES = [
|
|
41
|
+
"duplicate",
|
|
42
|
+
"related",
|
|
43
|
+
"blocks",
|
|
44
|
+
"depends-on",
|
|
45
|
+
"caused-by",
|
|
46
|
+
"supersedes",
|
|
47
|
+
"mitigates",
|
|
48
|
+
"same-root-cause",
|
|
49
|
+
] as const;
|
|
50
|
+
export type CaseLinkKind = (typeof LINK_KIND_VALUES)[number];
|
|
51
|
+
|
|
52
|
+
/** Default kind when none is specified (preserves pre-kind behavior). */
|
|
53
|
+
export const DEFAULT_LINK_KIND: CaseLinkKind = "related";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inverse of each kind, written to the reverse row so a case lists the
|
|
57
|
+
* relationship from its own perspective. Symmetric kinds map to themselves;
|
|
58
|
+
* directional kinds produce a display-only converse (never accepted as input).
|
|
59
|
+
*/
|
|
60
|
+
export const LINK_KIND_INVERSE: Record<CaseLinkKind, string> = {
|
|
61
|
+
duplicate: "duplicate",
|
|
62
|
+
related: "related",
|
|
63
|
+
blocks: "blocked-by",
|
|
64
|
+
"depends-on": "dependency-of",
|
|
65
|
+
"caused-by": "causes",
|
|
66
|
+
supersedes: "superseded-by",
|
|
67
|
+
mitigates: "mitigated-by",
|
|
68
|
+
"same-root-cause": "same-root-cause",
|
|
69
|
+
};
|
|
70
|
+
|
|
39
71
|
export const SEARCH_FIELD_VALUES = [
|
|
40
72
|
"title",
|
|
41
73
|
"summary",
|
|
@@ -81,7 +113,10 @@ export type CaseRecord = {
|
|
|
81
113
|
reportedAt?: string;
|
|
82
114
|
/** Path to the generated markdown report (set only by writeCaseReport). */
|
|
83
115
|
reportPath?: string;
|
|
116
|
+
/** Flat list of linked case IDs (back-compat; derived from linkedCases). */
|
|
84
117
|
linkedCaseIds: string[];
|
|
118
|
+
/** Linked cases with their relationship kind, from this case's perspective. */
|
|
119
|
+
linkedCases: { id: string; kind: string }[];
|
|
85
120
|
createdAt: string;
|
|
86
121
|
updatedAt: string;
|
|
87
122
|
};
|
|
@@ -133,6 +168,8 @@ export type CaseLinkResult = {
|
|
|
133
168
|
target: CaseRecord;
|
|
134
169
|
changed: boolean;
|
|
135
170
|
reason?: string;
|
|
171
|
+
/** Relationship kind as stated by the caller (source → target). */
|
|
172
|
+
kind: string;
|
|
136
173
|
};
|
|
137
174
|
|
|
138
175
|
export type CaseSearchOptions = {
|
|
@@ -259,11 +296,18 @@ function getDb(): DatabaseSync {
|
|
|
259
296
|
CREATE TABLE IF NOT EXISTS case_links (
|
|
260
297
|
source_id TEXT,
|
|
261
298
|
target_id TEXT,
|
|
299
|
+
kind TEXT NOT NULL DEFAULT 'related',
|
|
262
300
|
PRIMARY KEY (source_id, target_id),
|
|
263
301
|
FOREIGN KEY (source_id) REFERENCES cases(id) ON DELETE CASCADE,
|
|
264
302
|
FOREIGN KEY (target_id) REFERENCES cases(id) ON DELETE CASCADE
|
|
265
303
|
)
|
|
266
304
|
`);
|
|
305
|
+
// Pre-kind ledgers lack the column; add it idempotently. SQLite has no
|
|
306
|
+
// ADD COLUMN IF NOT EXISTS, so guard via pragma table_info.
|
|
307
|
+
const linkCols = db.prepare("PRAGMA table_info(case_links)").all() as { name: string }[];
|
|
308
|
+
if (!linkCols.some((c) => c.name === "kind")) {
|
|
309
|
+
db.exec("ALTER TABLE case_links ADD COLUMN kind TEXT NOT NULL DEFAULT 'related'");
|
|
310
|
+
}
|
|
267
311
|
|
|
268
312
|
// Indexes
|
|
269
313
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_cases_status ON cases(status)`);
|
|
@@ -276,7 +320,7 @@ function getDb(): DatabaseSync {
|
|
|
276
320
|
}
|
|
277
321
|
|
|
278
322
|
// Helper to map DB row to CaseRecord
|
|
279
|
-
function mapRow(row: any,
|
|
323
|
+
function mapRow(row: any, linkedCases: { id: string; kind: string }[] = []): CaseRecord {
|
|
280
324
|
/** Safely parse a JSON column; returns [] for arrays, undefined for objects. */
|
|
281
325
|
const safeParseArray = (raw: unknown): string[] => {
|
|
282
326
|
if (!raw) return [];
|
|
@@ -320,7 +364,8 @@ function mapRow(row: any, linkedCaseIds: string[] = []): CaseRecord {
|
|
|
320
364
|
pocVerified: safeParseObject(row.poc_verified_json),
|
|
321
365
|
reportedAt: row.reported_at || undefined,
|
|
322
366
|
reportPath: row.report_path || undefined,
|
|
323
|
-
|
|
367
|
+
linkedCases,
|
|
368
|
+
linkedCaseIds: linkedCases.map((l) => l.id),
|
|
324
369
|
createdAt: row.created_at,
|
|
325
370
|
updatedAt: row.updated_at,
|
|
326
371
|
};
|
|
@@ -335,14 +380,14 @@ export function readCasefile(): CaseRecord[] {
|
|
|
335
380
|
const stmt = db.prepare("SELECT * FROM cases");
|
|
336
381
|
const rows = stmt.all();
|
|
337
382
|
|
|
338
|
-
// Read all links to construct
|
|
339
|
-
const linkStmt = db.prepare("SELECT source_id, target_id FROM case_links");
|
|
340
|
-
const links = linkStmt.all() as { source_id: string; target_id: string }[];
|
|
383
|
+
// Read all links to construct linkedCases map
|
|
384
|
+
const linkStmt = db.prepare("SELECT source_id, target_id, kind FROM case_links");
|
|
385
|
+
const links = linkStmt.all() as { source_id: string; target_id: string; kind: string }[];
|
|
341
386
|
|
|
342
|
-
const linkMap = new Map<string, string[]>();
|
|
387
|
+
const linkMap = new Map<string, { id: string; kind: string }[]>();
|
|
343
388
|
for (const link of links) {
|
|
344
389
|
if (!linkMap.has(link.source_id)) linkMap.set(link.source_id, []);
|
|
345
|
-
linkMap.get(link.source_id)?.push(link.target_id);
|
|
390
|
+
linkMap.get(link.source_id)?.push({ id: link.target_id, kind: link.kind });
|
|
346
391
|
}
|
|
347
392
|
|
|
348
393
|
return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
@@ -354,12 +399,12 @@ export function getCaseById(id: string): CaseRecord | undefined {
|
|
|
354
399
|
const row = stmt.get(id);
|
|
355
400
|
if (!row) return undefined;
|
|
356
401
|
|
|
357
|
-
const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
|
|
358
|
-
const links = linkStmt.all(id) as { target_id: string }[];
|
|
402
|
+
const linkStmt = db.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?");
|
|
403
|
+
const links = linkStmt.all(id) as { target_id: string; kind: string }[];
|
|
359
404
|
|
|
360
405
|
return mapRow(
|
|
361
406
|
row,
|
|
362
|
-
links.map((l) => l.target_id),
|
|
407
|
+
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
363
408
|
);
|
|
364
409
|
}
|
|
365
410
|
|
|
@@ -510,6 +555,7 @@ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRec
|
|
|
510
555
|
pocVerified: input.pocVerified ?? existing?.pocVerified,
|
|
511
556
|
reportedAt: input.reportedAt ?? existing?.reportedAt,
|
|
512
557
|
reportPath: input.reportPath ?? existing?.reportPath,
|
|
558
|
+
linkedCases: existing?.linkedCases ?? [],
|
|
513
559
|
linkedCaseIds: existing?.linkedCaseIds ?? [],
|
|
514
560
|
createdAt: existing?.createdAt ?? timestamp,
|
|
515
561
|
updatedAt: timestamp,
|
|
@@ -543,11 +589,11 @@ function findDuplicateCaseInDb(
|
|
|
543
589
|
normalizeMatchText(row.bugClass as string) === bugClass
|
|
544
590
|
) {
|
|
545
591
|
const links = db
|
|
546
|
-
.prepare("SELECT target_id FROM case_links WHERE source_id = ?")
|
|
547
|
-
.all(row.id) as { target_id: string }[];
|
|
592
|
+
.prepare("SELECT target_id, kind FROM case_links WHERE source_id = ?")
|
|
593
|
+
.all(row.id) as { target_id: string; kind: string }[];
|
|
548
594
|
return mapRow(
|
|
549
595
|
row,
|
|
550
|
-
links.map((l) => l.target_id),
|
|
596
|
+
links.map((l) => ({ id: l.target_id, kind: l.kind })),
|
|
551
597
|
);
|
|
552
598
|
}
|
|
553
599
|
}
|
|
@@ -709,7 +755,7 @@ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResu
|
|
|
709
755
|
|
|
710
756
|
// Check material equality (we ignore links since links are mutated via CaseLink)
|
|
711
757
|
const norm = (r: CaseRecord) =>
|
|
712
|
-
JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
|
|
758
|
+
JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [], linkedCases: [] });
|
|
713
759
|
if (norm(current) === norm(next)) {
|
|
714
760
|
const reason =
|
|
715
761
|
update.status && update.status === current.status
|
|
@@ -789,11 +835,15 @@ export function promoteFindingResult(id: string, verification: PocVerification):
|
|
|
789
835
|
|
|
790
836
|
// ── Link operations ──────────────────────────────────────────────────
|
|
791
837
|
|
|
792
|
-
export function linkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
838
|
+
export function linkCasesResult(sourceId: string, targetId: string, kind?: string): CaseLinkResult {
|
|
793
839
|
const db = getDb();
|
|
794
840
|
if (sourceId === targetId) {
|
|
795
841
|
throw new Error("Cannot link a case to itself");
|
|
796
842
|
}
|
|
843
|
+
const resolvedKind: CaseLinkKind =
|
|
844
|
+
kind && (LINK_KIND_VALUES as readonly string[]).includes(kind)
|
|
845
|
+
? (kind as CaseLinkKind)
|
|
846
|
+
: DEFAULT_LINK_KIND;
|
|
797
847
|
const source = getCaseById(sourceId);
|
|
798
848
|
const target = getCaseById(targetId);
|
|
799
849
|
if (!source) throw new Error(`Case not found: ${sourceId}`);
|
|
@@ -805,19 +855,30 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
805
855
|
throw new Error(`Cannot link terminal case ${targetId} (${target.status})`);
|
|
806
856
|
}
|
|
807
857
|
|
|
808
|
-
const checkStmt = db.prepare("SELECT
|
|
809
|
-
const
|
|
858
|
+
const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
859
|
+
const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
|
|
810
860
|
|
|
811
|
-
if (
|
|
812
|
-
return {
|
|
861
|
+
if (existing) {
|
|
862
|
+
return {
|
|
863
|
+
source,
|
|
864
|
+
target,
|
|
865
|
+
changed: false,
|
|
866
|
+
reason: "Cases are already linked",
|
|
867
|
+
kind: existing.kind,
|
|
868
|
+
};
|
|
813
869
|
}
|
|
814
870
|
|
|
815
|
-
// Atomic insert both directions
|
|
871
|
+
// Atomic insert both directions: source→target keeps the stated kind, the
|
|
872
|
+
// reverse row stores the inverse so each case lists the edge from its own
|
|
873
|
+
// perspective.
|
|
874
|
+
const inverseKind = LINK_KIND_INVERSE[resolvedKind];
|
|
816
875
|
db.exec("BEGIN");
|
|
817
876
|
try {
|
|
818
|
-
const linkStmt = db.prepare(
|
|
819
|
-
|
|
820
|
-
|
|
877
|
+
const linkStmt = db.prepare(
|
|
878
|
+
"INSERT INTO case_links (source_id, target_id, kind) VALUES (?, ?, ?)",
|
|
879
|
+
);
|
|
880
|
+
linkStmt.run(sourceId, targetId, resolvedKind);
|
|
881
|
+
linkStmt.run(targetId, sourceId, inverseKind);
|
|
821
882
|
|
|
822
883
|
const now = new Date().toISOString();
|
|
823
884
|
const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
|
|
@@ -835,7 +896,7 @@ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkRes
|
|
|
835
896
|
|
|
836
897
|
const finalSource = getCaseById(sourceId)!;
|
|
837
898
|
const finalTarget = getCaseById(targetId)!;
|
|
838
|
-
return { source: finalSource, target: finalTarget, changed: true };
|
|
899
|
+
return { source: finalSource, target: finalTarget, changed: true, kind: resolvedKind };
|
|
839
900
|
}
|
|
840
901
|
|
|
841
902
|
export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
|
|
@@ -851,11 +912,11 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
851
912
|
throw new Error(`Cannot unlink terminal case ${targetId} (${target.status})`);
|
|
852
913
|
}
|
|
853
914
|
|
|
854
|
-
const checkStmt = db.prepare("SELECT
|
|
855
|
-
const
|
|
915
|
+
const checkStmt = db.prepare("SELECT kind FROM case_links WHERE source_id = ? AND target_id = ?");
|
|
916
|
+
const existing = checkStmt.get(sourceId, targetId) as { kind: string } | undefined;
|
|
856
917
|
|
|
857
|
-
if (!
|
|
858
|
-
return { source, target, changed: false, reason: "Cases are not linked" };
|
|
918
|
+
if (!existing) {
|
|
919
|
+
return { source, target, changed: false, reason: "Cases are not linked", kind: "related" };
|
|
859
920
|
}
|
|
860
921
|
|
|
861
922
|
db.exec("BEGIN");
|
|
@@ -881,7 +942,7 @@ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkR
|
|
|
881
942
|
|
|
882
943
|
const finalSource = getCaseById(sourceId)!;
|
|
883
944
|
const finalTarget = getCaseById(targetId)!;
|
|
884
|
-
return { source: finalSource, target: finalTarget, changed: true };
|
|
945
|
+
return { source: finalSource, target: finalTarget, changed: true, kind: existing.kind };
|
|
885
946
|
}
|
|
886
947
|
|
|
887
948
|
// ── Search & Queries ─────────────────────────────────────────────────
|
|
@@ -986,18 +1047,20 @@ function buildCaseWhere(options: CaseSearchOptions): {
|
|
|
986
1047
|
};
|
|
987
1048
|
}
|
|
988
1049
|
|
|
989
|
-
/** Map DB rows to CaseRecords, attaching
|
|
1050
|
+
/** Map DB rows to CaseRecords, attaching linkedCases fetched in a single batch. */
|
|
990
1051
|
function mapRowsWithLinks(db: DatabaseSync, rows: any[]): CaseRecord[] {
|
|
991
1052
|
if (rows.length === 0) return [];
|
|
992
1053
|
const ids = rows.map((r) => r.id);
|
|
993
1054
|
const placeholders = ids.map(() => "?").join(",");
|
|
994
1055
|
const links = db
|
|
995
|
-
.prepare(
|
|
996
|
-
|
|
997
|
-
|
|
1056
|
+
.prepare(
|
|
1057
|
+
`SELECT source_id, target_id, kind FROM case_links WHERE source_id IN (${placeholders})`,
|
|
1058
|
+
)
|
|
1059
|
+
.all(...ids) as { source_id: string; target_id: string; kind: string }[];
|
|
1060
|
+
const linkMap = new Map<string, { id: string; kind: string }[]>();
|
|
998
1061
|
for (const l of links) {
|
|
999
1062
|
if (!linkMap.has(l.source_id)) linkMap.set(l.source_id, []);
|
|
1000
|
-
linkMap.get(l.source_id)!.push(l.target_id);
|
|
1063
|
+
linkMap.get(l.source_id)!.push({ id: l.target_id, kind: l.kind });
|
|
1001
1064
|
}
|
|
1002
1065
|
return rows.map((row) => mapRow(row, linkMap.get(row.id) ?? []));
|
|
1003
1066
|
}
|
|
@@ -1046,6 +1109,9 @@ export function countCases(): {
|
|
|
1046
1109
|
// ── Format helpers ───────────────────────────────────────────────────
|
|
1047
1110
|
|
|
1048
1111
|
export function formatCase(record: CaseRecord): string {
|
|
1112
|
+
const linkBits = record.linkedCases.map((l) =>
|
|
1113
|
+
l.kind && l.kind !== DEFAULT_LINK_KIND ? `${l.id}:${l.kind}` : l.id,
|
|
1114
|
+
);
|
|
1049
1115
|
const bits = [
|
|
1050
1116
|
`${record.id} [${record.status}/${record.confidence}] ${record.title}`,
|
|
1051
1117
|
record.priority ? `priority=${record.priority}` : undefined,
|
|
@@ -1055,7 +1121,7 @@ export function formatCase(record: CaseRecord): string {
|
|
|
1055
1121
|
record.endpoint ? `endpoint=${record.endpoint}` : undefined,
|
|
1056
1122
|
record.target ? `target=${record.target}` : undefined,
|
|
1057
1123
|
record.tags?.length ? `tags=${record.tags.join(",")}` : undefined,
|
|
1058
|
-
|
|
1124
|
+
linkBits.length ? `links=${linkBits.join(",")}` : undefined,
|
|
1059
1125
|
record.nextStep ? `next=${record.nextStep}` : undefined,
|
|
1060
1126
|
].filter(Boolean);
|
|
1061
1127
|
return bits.join(" | ");
|
|
@@ -1072,15 +1138,22 @@ export function formatCaseDetail(record: CaseRecord): string {
|
|
|
1072
1138
|
if (
|
|
1073
1139
|
!val ||
|
|
1074
1140
|
(Array.isArray(val) && !val.length) ||
|
|
1075
|
-
["id", "createdAt", "updatedAt"].includes(key)
|
|
1141
|
+
["id", "createdAt", "updatedAt", "linkedCaseIds"].includes(key)
|
|
1076
1142
|
)
|
|
1077
1143
|
continue;
|
|
1078
1144
|
const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1145
|
+
let display: string;
|
|
1146
|
+
if (key === "linkedCases") {
|
|
1147
|
+
display = (val as { id: string; kind: string }[])
|
|
1148
|
+
.map((l) => `${l.id} (${l.kind})`)
|
|
1149
|
+
.join(", ");
|
|
1150
|
+
} else if (Array.isArray(val)) {
|
|
1151
|
+
display = val.join(", ");
|
|
1152
|
+
} else if (typeof val === "object") {
|
|
1153
|
+
display = JSON.stringify(val);
|
|
1154
|
+
} else {
|
|
1155
|
+
display = String(val);
|
|
1156
|
+
}
|
|
1084
1157
|
lines.push(`${label.padEnd(12)} ${display}`);
|
|
1085
1158
|
}
|
|
1086
1159
|
return lines
|