@xaccefy/pi-casefile 0.1.5 → 0.1.7

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/src/ledger.ts CHANGED
@@ -10,15 +10,21 @@
10
10
  * - Auto-indexing on target, status, priority, severity.
11
11
  */
12
12
 
13
- import { DatabaseSync } from "./sqlite-compat.ts";
14
13
  import { createHash, randomUUID } from "node:crypto";
15
14
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
16
- import { dirname, join, resolve } from "path";
17
- import { homedir } from "os";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { DatabaseSync } from "./sqlite-compat/index.ts";
18
17
 
19
18
  // ── Types ────────────────────────────────────────────────────────────
20
19
 
21
- export const STATUS_VALUES = ["hypothesis", "investigating", "confirmed", "blocked", "killed", "reported"] as const;
20
+ export const STATUS_VALUES = [
21
+ "hypothesis",
22
+ "investigating",
23
+ "confirmed",
24
+ "blocked",
25
+ "killed",
26
+ "reported",
27
+ ] as const;
22
28
  export type CaseStatus = (typeof STATUS_VALUES)[number];
23
29
 
24
30
  export const CONFIDENCE_VALUES = ["low", "medium", "high"] as const;
@@ -30,7 +36,16 @@ export type CaseSeverity = (typeof SEVERITY_VALUES)[number];
30
36
  export const PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "P4"] as const;
31
37
  export type CasePriority = (typeof PRIORITY_VALUES)[number];
32
38
 
33
- export const SEARCH_FIELD_VALUES = ["title", "summary", "evidence", "impact", "target", "endpoint", "bugClass", "poc"] as const;
39
+ export const SEARCH_FIELD_VALUES = [
40
+ "title",
41
+ "summary",
42
+ "evidence",
43
+ "impact",
44
+ "target",
45
+ "endpoint",
46
+ "bugClass",
47
+ "poc",
48
+ ] as const;
34
49
  export type CaseSearchField = (typeof SEARCH_FIELD_VALUES)[number];
35
50
 
36
51
  export type CaseRecord = {
@@ -55,7 +70,13 @@ export type CaseRecord = {
55
70
  /** Explicit assumptions or unknowns to avoid overstating exploitability. */
56
71
  assumptions?: string[];
57
72
  /** Verification of an on-disk PoC run (set only by promoteFindingResult). */
58
- pocVerified?: { path: string; exitCode: number; ranAt: string; output?: string; sandbox: boolean };
73
+ pocVerified?: {
74
+ path: string;
75
+ exitCode: number;
76
+ ranAt: string;
77
+ output?: string;
78
+ sandbox: boolean;
79
+ };
59
80
  /** ISO timestamp when CaseReport first wrote the markdown report. */
60
81
  reportedAt?: string;
61
82
  /** Path to the generated markdown report (set only by writeCaseReport). */
@@ -131,14 +152,8 @@ export type CaseSearchOptions = {
131
152
  let ledgerPathOverride: string | undefined;
132
153
  let dbInstance: DatabaseSync | undefined;
133
154
 
134
- function nowIso(): string {
135
- return new Date().toISOString();
136
- }
137
-
138
155
  function normalizeList(values: string[] | undefined): string[] {
139
- return Array.from(
140
- new Set((values ?? []).map((v) => v.trim()).filter(Boolean)),
141
- );
156
+ return Array.from(new Set((values ?? []).map((v) => v.trim()).filter(Boolean)));
142
157
  }
143
158
 
144
159
  function normalizeText(value: string | undefined): string | undefined {
@@ -154,14 +169,6 @@ function stableShortId(input: string): string {
154
169
  return createHash("sha1").update(input).digest("hex").slice(0, 10);
155
170
  }
156
171
 
157
- function firstEnv(...names: string[]): { name: string; value: string } | undefined {
158
- for (const name of names) {
159
- const value = process.env[name]?.trim();
160
- if (value) return { name, value };
161
- }
162
- return undefined;
163
- }
164
-
165
172
  function detectWorkspaceRoot(): string {
166
173
  const envs = ["CASEFILE_WORKSPACE_ROOT", "PI_WORKSPACE_ROOT", "GITHUB_WORKSPACE", "PWD"];
167
174
  for (const e of envs) if (process.env[e]) return resolve(process.env[e]!);
@@ -178,25 +185,8 @@ function detectWorkspaceRoot(): string {
178
185
 
179
186
  export function getCasefilePath(): string {
180
187
  if (ledgerPathOverride) return ledgerPathOverride;
181
-
182
- const explicitPath = firstEnv("CASEFILE_PATH", "PI_CASEFILE_PATH");
183
- if (explicitPath) return resolve(explicitPath.value);
184
-
185
- const scopeConfig = firstEnv("CASEFILE_SCOPE", "PI_CASEFILE_SCOPE");
186
- const scope = (scopeConfig?.value.toLowerCase() || "project");
187
-
188
- if (scope === "global" && scopeConfig?.name === "PI_CASEFILE_SCOPE") {
189
- return join(homedir(), ".pi", "casefile", "casefile.db");
190
- }
191
- if (scope === "global") {
192
- return join(homedir(), ".casefile", "casefile.db");
193
- }
194
-
195
- const projectDir = scopeConfig?.name === "CASEFILE_SCOPE"
196
- ? ".casefile"
197
- : ".pi";
198
-
199
- return join(detectWorkspaceRoot(), projectDir, "casefile.db");
188
+ if (process.env.PI_CASEFILE_PATH) return resolve(process.env.PI_CASEFILE_PATH.trim());
189
+ return join(detectWorkspaceRoot(), ".pi", "casefile.db");
200
190
  }
201
191
 
202
192
  export function setCasefilePath(path: string | undefined): void {
@@ -220,7 +210,7 @@ function getDb(): DatabaseSync {
220
210
  }
221
211
 
222
212
  const db = new DatabaseSync(dbPath);
223
-
213
+
224
214
  // Create tables
225
215
  db.exec(`
226
216
  CREATE TABLE IF NOT EXISTS cases (
@@ -326,7 +316,7 @@ function mapRow(row: any, linkedCaseIds: string[] = []): CaseRecord {
326
316
 
327
317
  export function readCasefile(): CaseRecord[] {
328
318
  const db = getDb();
329
-
319
+
330
320
  // Read all cases
331
321
  const stmt = db.prepare("SELECT * FROM cases");
332
322
  const rows = stmt.all();
@@ -334,11 +324,11 @@ export function readCasefile(): CaseRecord[] {
334
324
  // Read all links to construct linkedCaseIds map
335
325
  const linkStmt = db.prepare("SELECT source_id, target_id FROM case_links");
336
326
  const links = linkStmt.all() as { source_id: string; target_id: string }[];
337
-
327
+
338
328
  const linkMap = new Map<string, string[]>();
339
329
  for (const link of links) {
340
330
  if (!linkMap.has(link.source_id)) linkMap.set(link.source_id, []);
341
- linkMap.get(link.source_id)!.push(link.target_id);
331
+ linkMap.get(link.source_id)?.push(link.target_id);
342
332
  }
343
333
 
344
334
  return rows.map((row: any) => mapRow(row, linkMap.get(row.id) ?? []));
@@ -352,8 +342,11 @@ export function getCaseById(id: string): CaseRecord | undefined {
352
342
 
353
343
  const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
354
344
  const links = linkStmt.all(id) as { target_id: string }[];
355
-
356
- return mapRow(row, links.map(l => l.target_id));
345
+
346
+ return mapRow(
347
+ row,
348
+ links.map((l) => l.target_id),
349
+ );
357
350
  }
358
351
 
359
352
  // ── Validation ────────────────────────────────────────────────────────
@@ -373,9 +366,16 @@ function validateCase(record: CaseRecord): void {
373
366
  (record.blockers ?? []).length === 0 &&
374
367
  (record.assumptions ?? []).length === 0
375
368
  ) {
376
- throw new Error("Killed cases require evidence, next step, blockers, or assumptions explaining why");
369
+ throw new Error(
370
+ "Killed cases require evidence, next step, blockers, or assumptions explaining why",
371
+ );
377
372
  }
378
- if (record.status === "reported" && !record.poc && !record.remediation && (record.references ?? []).length === 0) {
373
+ if (
374
+ record.status === "reported" &&
375
+ !record.poc &&
376
+ !record.remediation &&
377
+ (record.references ?? []).length === 0
378
+ ) {
379
379
  throw new Error("Reported cases require poc, remediation, or references");
380
380
  }
381
381
  }
@@ -389,10 +389,14 @@ function validateTransition(
389
389
  if (from === to) return;
390
390
 
391
391
  if (from === "killed") {
392
- throw new Error(`Cannot revive a killed case; open a new case if the lead is revived (was ${from} → ${to})`);
392
+ throw new Error(
393
+ `Cannot revive a killed case; open a new case if the lead is revived (was ${from} → ${to})`,
394
+ );
393
395
  }
394
396
  if (from === "reported") {
395
- throw new Error(`Cannot mutate a reported case; file a follow-up case instead (was ${from} → ${to})`);
397
+ throw new Error(
398
+ `Cannot mutate a reported case; file a follow-up case instead (was ${from} → ${to})`,
399
+ );
396
400
  }
397
401
 
398
402
  if (to === "killed") return;
@@ -402,14 +406,17 @@ function validateTransition(
402
406
  const transitions: Partial<Record<CaseStatus, Partial<Record<CaseStatus, Rule>>>> = {
403
407
  hypothesis: {
404
408
  investigating: (u) =>
405
- !u.evidence ? "INVESTIGATING requires evidence (source→sink trace)" :
406
- !u.confidence ? "INVESTIGATING requires confidence level" :
407
- null,
409
+ !u.evidence
410
+ ? "INVESTIGATING requires evidence (source→sink trace)"
411
+ : !u.confidence
412
+ ? "INVESTIGATING requires confidence level"
413
+ : null,
408
414
  confirmed: () => "Cannot jump hypothesis → confirmed; promote to investigating first",
409
415
  reported: () => "Cannot jump hypothesis → reported; confirm first",
410
416
  },
411
417
  investigating: {
412
- confirmed: () => "investigating → confirmed requires a verified PoC run; use the promote_finding tool",
418
+ confirmed: () =>
419
+ "investigating → confirmed requires a verified PoC run; use the promote_finding tool",
413
420
  hypothesis: () => null,
414
421
  },
415
422
  confirmed: {
@@ -421,9 +428,11 @@ function validateTransition(
421
428
  },
422
429
  blocked: {
423
430
  investigating: (u) =>
424
- !u.evidence ? "INVESTIGATING requires evidence (source→sink trace)" :
425
- !u.confidence ? "INVESTIGATING requires confidence level" :
426
- null,
431
+ !u.evidence
432
+ ? "INVESTIGATING requires evidence (source→sink trace)"
433
+ : !u.confidence
434
+ ? "INVESTIGATING requires confidence level"
435
+ : null,
427
436
  hypothesis: () => null,
428
437
  },
429
438
  };
@@ -440,7 +449,9 @@ function validateTransition(
440
449
 
441
450
  function validateNewCaseInput(input: CaseInput): void {
442
451
  if (input.status && input.status !== "hypothesis" && input.status !== "investigating") {
443
- throw new Error("New cases must start as hypothesis or investigating; promote with CaseUpdate after validation");
452
+ throw new Error(
453
+ "New cases must start as hypothesis or investigating; promote with CaseUpdate after validation",
454
+ );
444
455
  }
445
456
  if (input.status === "investigating") {
446
457
  if (!input.evidence) {
@@ -452,15 +463,10 @@ function validateNewCaseInput(input: CaseInput): void {
452
463
  }
453
464
  }
454
465
 
455
- function buildRecord(
456
- input: NormalizedCaseInput,
457
- existing?: CaseRecord,
458
- ): CaseRecord {
459
- const timestamp = nowIso();
466
+ function buildRecord(input: NormalizedCaseInput, existing?: CaseRecord): CaseRecord {
467
+ const timestamp = new Date().toISOString();
460
468
  const title = ("title" in input ? input.title : existing?.title)?.trim() ?? "";
461
- const id =
462
- existing?.id ??
463
- `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
469
+ const id = existing?.id ?? `case_${stableShortId(`${title}\n${timestamp}\n${randomUUID()}`)}`;
464
470
 
465
471
  return {
466
472
  id,
@@ -477,7 +483,8 @@ function buildRecord(
477
483
  impact: input.impact !== undefined ? normalizeText(input.impact) : existing?.impact,
478
484
  nextStep: input.nextStep !== undefined ? normalizeText(input.nextStep) : existing?.nextStep,
479
485
  poc: input.poc !== undefined ? normalizeText(input.poc) : existing?.poc,
480
- remediation: input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
486
+ remediation:
487
+ input.remediation !== undefined ? normalizeText(input.remediation) : existing?.remediation,
481
488
  references: normalizeList(input.references ?? existing?.references),
482
489
  blockers: normalizeList(input.blockers ?? existing?.blockers),
483
490
  tags: normalizeList(input.tags ?? existing?.tags),
@@ -513,7 +520,10 @@ function findDuplicateCaseInDb(db: DatabaseSync, candidate: CaseRecord): CaseRec
513
520
  // Find links
514
521
  const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
515
522
  const links = linkStmt.all(row.id) as { target_id: string }[];
516
- return mapRow(row, links.map(l => l.target_id));
523
+ return mapRow(
524
+ row,
525
+ links.map((l) => l.target_id),
526
+ );
517
527
  }
518
528
  }
519
529
  return undefined;
@@ -560,7 +570,7 @@ function insertOrReplaceCase(db: DatabaseSync, record: CaseRecord) {
560
570
  record.reportedAt || null,
561
571
  record.reportPath || null,
562
572
  record.createdAt,
563
- record.updatedAt
573
+ record.updatedAt,
564
574
  );
565
575
  }
566
576
 
@@ -584,17 +594,25 @@ export function addCaseResult(input: CaseInput): CaseAddResult {
584
594
  return { record, created: true };
585
595
  }
586
596
 
587
- export function updateCaseResult(
588
- id: string,
589
- update: CaseUpdate,
590
- ): CaseUpdateResult {
597
+ export function updateCaseResult(id: string, update: CaseUpdate): CaseUpdateResult {
591
598
  const db = getDb();
592
599
  const current = getCaseById(id);
593
600
  if (!current) {
594
601
  throw new Error(`Case not found: ${id}`);
595
602
  }
596
603
 
597
- const optionalFields = ["title", "target", "endpoint", "bugClass", "summary", "evidence", "impact", "nextStep", "poc", "remediation"] as const;
604
+ const optionalFields = [
605
+ "title",
606
+ "target",
607
+ "endpoint",
608
+ "bugClass",
609
+ "summary",
610
+ "evidence",
611
+ "impact",
612
+ "nextStep",
613
+ "poc",
614
+ "remediation",
615
+ ] as const;
598
616
  const optionalPatch: Record<string, unknown> = {};
599
617
  for (const field of optionalFields) {
600
618
  if (field in update && update[field] !== undefined) {
@@ -614,7 +632,7 @@ export function updateCaseResult(
614
632
  tags: update.tags ?? current.tags,
615
633
  assumptions: update.assumptions ?? current.assumptions,
616
634
  },
617
- current
635
+ current,
618
636
  );
619
637
 
620
638
  if (update.status && update.status !== current.status) {
@@ -623,11 +641,13 @@ export function updateCaseResult(
623
641
  validateCase(next);
624
642
 
625
643
  // Check material equality (we ignore links since links are mutated via CaseLink)
626
- const norm = (r: CaseRecord) => JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
644
+ const norm = (r: CaseRecord) =>
645
+ JSON.stringify({ ...r, updatedAt: "", createdAt: "", linkedCaseIds: [] });
627
646
  if (norm(current) === norm(next)) {
628
- const reason = update.status && update.status === current.status
629
- ? `Case is already ${current.status}; no material fields changed.`
630
- : "No material fields changed.";
647
+ const reason =
648
+ update.status && update.status === current.status
649
+ ? `Case is already ${current.status}; no material fields changed.`
650
+ : "No material fields changed.";
631
651
  return { record: current, changed: false, reason };
632
652
  }
633
653
 
@@ -649,7 +669,10 @@ export function updateCaseResult(
649
669
  ) {
650
670
  const linkStmt = db.prepare("SELECT target_id FROM case_links WHERE source_id = ?");
651
671
  const links = linkStmt.all(row.id) as { target_id: string }[];
652
- duplicate = mapRow(row, links.map(l => l.target_id));
672
+ duplicate = mapRow(
673
+ row,
674
+ links.map((l) => l.target_id),
675
+ );
653
676
  break;
654
677
  }
655
678
  }
@@ -674,10 +697,7 @@ type PocVerification = {
674
697
  sandbox: boolean;
675
698
  };
676
699
 
677
- export function promoteFindingResult(
678
- id: string,
679
- verification: PocVerification,
680
- ): CaseUpdateResult {
700
+ export function promoteFindingResult(id: string, verification: PocVerification): CaseUpdateResult {
681
701
  const db = getDb();
682
702
  const current = getCaseById(id);
683
703
  if (!current) {
@@ -699,7 +719,9 @@ export function promoteFindingResult(
699
719
  throw new Error("CONFIRMED requires severity; set severity on the case first");
700
720
  }
701
721
  if (verification.exitCode !== 0) {
702
- throw new Error(`PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`);
722
+ throw new Error(
723
+ `PoC verification failed (exit ${verification.exitCode}); cannot promote to confirmed`,
724
+ );
703
725
  }
704
726
 
705
727
  const next = buildRecord(
@@ -707,7 +729,7 @@ export function promoteFindingResult(
707
729
  status: "confirmed",
708
730
  pocVerified: verification,
709
731
  },
710
- current
732
+ current,
711
733
  );
712
734
  validateCase(next);
713
735
 
@@ -717,10 +739,7 @@ export function promoteFindingResult(
717
739
 
718
740
  // ── Link operations ──────────────────────────────────────────────────
719
741
 
720
- export function linkCasesResult(
721
- sourceId: string,
722
- targetId: string,
723
- ): CaseLinkResult {
742
+ export function linkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
724
743
  const db = getDb();
725
744
  if (sourceId === targetId) {
726
745
  throw new Error("Cannot link a case to itself");
@@ -742,7 +761,7 @@ export function linkCasesResult(
742
761
  linkStmt.run(sourceId, targetId);
743
762
  linkStmt.run(targetId, sourceId);
744
763
 
745
- const now = nowIso();
764
+ const now = new Date().toISOString();
746
765
  const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
747
766
  updateTimeStmt.run(now, sourceId);
748
767
  updateTimeStmt.run(now, targetId);
@@ -752,10 +771,7 @@ export function linkCasesResult(
752
771
  return { source: finalSource, target: finalTarget, changed: true };
753
772
  }
754
773
 
755
- export function unlinkCasesResult(
756
- sourceId: string,
757
- targetId: string,
758
- ): CaseLinkResult {
774
+ export function unlinkCasesResult(sourceId: string, targetId: string): CaseLinkResult {
759
775
  const db = getDb();
760
776
  const source = getCaseById(sourceId);
761
777
  const target = getCaseById(targetId);
@@ -769,10 +785,12 @@ export function unlinkCasesResult(
769
785
  return { source, target, changed: false, reason: "Cases are not linked" };
770
786
  }
771
787
 
772
- const unlinkStmt = db.prepare("DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)");
788
+ const unlinkStmt = db.prepare(
789
+ "DELETE FROM case_links WHERE (source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?)",
790
+ );
773
791
  unlinkStmt.run(sourceId, targetId, targetId, sourceId);
774
792
 
775
- const now = nowIso();
793
+ const now = new Date().toISOString();
776
794
  const updateTimeStmt = db.prepare("UPDATE cases SET updated_at = ? WHERE id = ?");
777
795
  updateTimeStmt.run(now, sourceId);
778
796
  updateTimeStmt.run(now, targetId);
@@ -784,10 +802,7 @@ export function unlinkCasesResult(
784
802
 
785
803
  // ── Search & Queries ─────────────────────────────────────────────────
786
804
 
787
- function caseHaystack(
788
- record: CaseRecord,
789
- field?: CaseSearchField,
790
- ): string {
805
+ function caseHaystack(record: CaseRecord, field?: CaseSearchField): string {
791
806
  if (field) {
792
807
  const val = record[field];
793
808
  if (Array.isArray(val)) return val.join(" ").toLowerCase();
@@ -801,9 +816,10 @@ function caseHaystack(
801
816
  .toLowerCase();
802
817
  }
803
818
 
804
- export function searchCases(
805
- options: CaseSearchOptions = {},
806
- ): { cases: CaseRecord[]; total: number } {
819
+ export function searchCases(options: CaseSearchOptions = {}): {
820
+ cases: CaseRecord[];
821
+ total: number;
822
+ } {
807
823
  const query = options.query?.trim().toLowerCase();
808
824
  const field = options.field;
809
825
  const tag = options.tag?.trim().toLowerCase();
@@ -824,9 +840,7 @@ export function searchCases(
824
840
  .filter((r) => !options.confidence || r.confidence === options.confidence)
825
841
  .filter((r) => !options.severity || r.severity === options.severity)
826
842
  .filter((r) => !options.priority || r.priority === options.priority)
827
- .filter(
828
- (r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag),
829
- )
843
+ .filter((r) => !tag || r.tags?.some((t) => t.toLowerCase() === tag))
830
844
  .filter((r) => !query || caseHaystack(r, field).includes(query))
831
845
  .sort((a, b) => {
832
846
  const aStatus = STATUS_ORDER.indexOf(a.status);
@@ -868,9 +882,7 @@ export function formatCase(record: CaseRecord): string {
868
882
  record.endpoint ? `endpoint=${record.endpoint}` : undefined,
869
883
  record.target ? `target=${record.target}` : undefined,
870
884
  record.tags?.length ? `tags=${record.tags.join(",")}` : undefined,
871
- record.linkedCaseIds.length
872
- ? `links=${record.linkedCaseIds.join(",")}`
873
- : undefined,
885
+ record.linkedCaseIds.length ? `links=${record.linkedCaseIds.join(",")}` : undefined,
874
886
  record.nextStep ? `next=${record.nextStep}` : undefined,
875
887
  ].filter(Boolean);
876
888
  return bits.join(" | ");
@@ -884,7 +896,12 @@ export function formatCases(records: CaseRecord[]): string {
884
896
  export function formatCaseDetail(record: CaseRecord): string {
885
897
  const lines = [`═══ ${record.id} ═══`];
886
898
  for (const [key, val] of Object.entries(record)) {
887
- if (!val || (Array.isArray(val) && !val.length) || ["id", "createdAt", "updatedAt"].includes(key)) continue;
899
+ if (
900
+ !val ||
901
+ (Array.isArray(val) && !val.length) ||
902
+ ["id", "createdAt", "updatedAt"].includes(key)
903
+ )
904
+ continue;
888
905
  const label = key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, " $1");
889
906
  const display = Array.isArray(val)
890
907
  ? val.join(", ")
@@ -893,15 +910,9 @@ export function formatCaseDetail(record: CaseRecord): string {
893
910
  : val;
894
911
  lines.push(`${label.padEnd(12)} ${display}`);
895
912
  }
896
- return lines.concat([`Created: ${record.createdAt}`, `Updated: ${record.updatedAt}`]).join("\n");
897
- }
898
-
899
- function slugify(value: string): string {
900
- return value
901
- .toLowerCase()
902
- .replace(/[^a-z0-9]+/g, "-")
903
- .replace(/^-+|-+$/g, "")
904
- .slice(0, 70) || "case";
913
+ return lines
914
+ .concat([`Created: ${record.createdAt}`, `Updated: ${record.updatedAt}`])
915
+ .join("\n");
905
916
  }
906
917
 
907
918
  function mdSection(title: string, body?: string): string {
@@ -917,13 +928,23 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
917
928
 
918
929
  const db = getDb();
919
930
  const dbPath = getCasefilePath();
920
-
931
+
921
932
  const reportDir = join(dirname(dbPath), "report");
922
933
  mkdirSync(reportDir, { recursive: true });
923
934
 
924
- const reportPath = join(reportDir, `${slugify(current.title)}-${current.id}.md`);
925
- const references = current.references?.length ? current.references.map((r) => `- ${r}`).join("\n") : undefined;
926
- const assumptions = current.assumptions?.length ? current.assumptions.map((a) => `- ${a}`).join("\n") : undefined;
935
+ const slug =
936
+ current.title
937
+ .toLowerCase()
938
+ .replace(/[^a-z0-9]+/g, "-")
939
+ .replace(/^-+|-+$/g, "")
940
+ .slice(0, 70) || "case";
941
+ const reportPath = join(reportDir, `${slug}-${current.id}.md`);
942
+ const references = current.references?.length
943
+ ? current.references.map((r) => `- ${r}`).join("\n")
944
+ : undefined;
945
+ const assumptions = current.assumptions?.length
946
+ ? current.assumptions.map((a) => `- ${a}`).join("\n")
947
+ : undefined;
927
948
  const body = [
928
949
  `# ${current.title}`,
929
950
  `**Severity:** ${current.severity ?? "Not assessed"}`,
@@ -941,15 +962,17 @@ export function writeCaseReport(id: string): { path: string; record: CaseRecord
941
962
  mdSection("Remediation", current.remediation),
942
963
  mdSection("Assumptions and Uncertainty", assumptions),
943
964
  mdSection("References", references),
944
- ].filter(Boolean).join("\n");
945
-
965
+ ]
966
+ .filter(Boolean)
967
+ .join("\n");
968
+
946
969
  writeFileSync(reportPath, body, "utf8");
947
970
 
948
971
  const next: CaseRecord = {
949
972
  ...current,
950
973
  reportPath,
951
- reportedAt: current.reportedAt ?? nowIso(),
952
- updatedAt: nowIso(),
974
+ reportedAt: current.reportedAt ?? new Date().toISOString(),
975
+ updatedAt: new Date().toISOString(),
953
976
  };
954
977
 
955
978
  insertOrReplaceCase(db, next);