@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-casefile
2
2
 
3
- Local-first security case ledger for Pi Agent. Tracks hypothesesconfirmed findings with a hard PoC gate, SQLite storage, and an isolated PoC runner.
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 cyber-workflow context injection is **quiet by default** so normal dev work is not flooded with security process text.
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 injects the attacker-oriented cyber workflow plus any active (non-killed/non-reported) cases. When **OFF**, nothing is injected; tools remain available.
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** requires `evidence` + `confidence`
46
- - **confirmed** only via `PromoteFinding` (PoC exit 0) — `CaseUpdate(status:"confirmed")` is rejected
47
- - **reported** requires `CaseReport` first
48
- - **killed** / **reported** are terminal (no further field edits)
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` tool field. Put proof text in `impact` or `evidence`.
50
+ There is **no** `impact_proof` field. Put proof in `impact` or `evidence`.
51
51
 
52
52
  ## Tools
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xaccefy/pi-casefile",
3
- "version": "0.2.9",
3
+ "version": "0.5.0",
4
4
  "description": "Offensive security case tracker for Pi Agent — bug bounties, CTFs, security audits",
5
5
  "keywords": [
6
6
  "pi-package",
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
- const CaseStatusSchema = Type.Union(STATUS_VALUES.map((v) => Type.Literal(v)));
50
- const CaseConfidenceSchema = Type.Union(CONFIDENCE_VALUES.map((v) => Type.Literal(v)));
51
- const CaseSeveritySchema = Type.Union(SEVERITY_VALUES.map((v) => Type.Literal(v)));
52
- const CasePrioritySchema = Type.Union(PRIORITY_VALUES.map((v) => Type.Literal(v)));
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.Union(
150
- SEARCH_FIELD_VALUES.map((v) => Type.Literal(v)),
151
- {
152
- description: "Restrict search to a specific field",
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: "Bidirectionally link two cases. Use to build exploit chains.",
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(params.source_id as string, params.target_id as string);
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, linkedCaseIds: string[] = []): CaseRecord {
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
- linkedCaseIds,
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 linkedCaseIds map
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 1 FROM case_links WHERE source_id = ? AND target_id = ?");
809
- const exists = checkStmt.get(sourceId, targetId);
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 (exists) {
812
- return { source, target, changed: false, reason: "Cases are already linked" };
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 into junction table
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("INSERT INTO case_links (source_id, target_id) VALUES (?, ?)");
819
- linkStmt.run(sourceId, targetId);
820
- linkStmt.run(targetId, sourceId);
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 1 FROM case_links WHERE source_id = ? AND target_id = ?");
855
- const exists = checkStmt.get(sourceId, targetId);
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 (!exists) {
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 linkedCaseIds fetched in a single batch. */
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(`SELECT source_id, target_id FROM case_links WHERE source_id IN (${placeholders})`)
996
- .all(...ids) as { source_id: string; target_id: string }[];
997
- const linkMap = new Map<string, string[]>();
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
- record.linkedCaseIds.length ? `links=${record.linkedCaseIds.join(",")}` : undefined,
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
- const display = Array.isArray(val)
1080
- ? val.join(", ")
1081
- : typeof val === "object"
1082
- ? JSON.stringify(val)
1083
- : val;
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