codetrap 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/README.md CHANGED
@@ -56,6 +56,7 @@ codetrap show 1
56
56
  ## Features
57
57
 
58
58
  - **Structured trap recording** — title, category, context, mistake, fix, severity, tags, lifecycle, evidence, before/after code
59
+ - **Session mode capture** — record implementation notes, promote explicit structured trap notes into candidates, and save only user-accepted lessons
59
60
  - **Dual scope** — project-scoped (`.codetrap/traps.db`) and global (`~/.codetrap/traps.db`)
60
61
  - **CLI-first agent API** — `search/show/list/stats/doctor --json` and stdin query support for shell-friendly automation
61
62
  - **Three search modes** — FTS (SQLite FTS5), semantic (Jina embeddings), hybrid (RRF fusion)
@@ -81,9 +82,17 @@ codetrap/
81
82
  │ │ ├── tools.ts 10 MCP tool definitions
82
83
  │ │ └── resources.ts 4 MCP resource URIs
83
84
  │ ├── domain/trap.ts Trap types, builders, schemas
85
+ │ ├── domain/session.ts Session, note, and candidate trap types
84
86
  │ ├── lib/
85
87
  │ │ ├── store.ts Project/global scope orchestration
86
88
  │ │ ├── trap-operations.ts Shared CLI/MCP operation semantics
89
+ │ │ ├── session-operations.ts Session command semantics + accept/reject flow
90
+ │ │ ├── session-store.ts Session files, active state, index, recaps
91
+ │ │ ├── session-codec.ts Session JSON/Markdown/candidate file conversion
92
+ │ │ ├── session-capture.ts Candidate trap extraction from explicit structured notes
93
+ │ │ ├── session-conflicts.ts Candidate vs active-trap conflict checks
94
+ │ │ ├── trap-quality.ts Deterministic candidate quality scoring
95
+ │ │ ├── command-requests.ts CLI/MCP request normalization helpers
87
96
  │ │ ├── output-json.ts Shared CLI/MCP JSON presenters
88
97
  │ │ ├── scope-context.ts cwd/project/global DB context + repo selection
89
98
  │ │ ├── scope-migration.ts Safe project trap scope repair/migration
@@ -115,6 +124,7 @@ codetrap/
115
124
  │ └── tests/
116
125
  │ ├── search-*.test.ts
117
126
  │ ├── trap-*.test.ts
127
+ │ ├── session-cli.test.ts
118
128
  │ ├── mcp-tools.test.ts
119
129
  │ ├── scope.test.ts
120
130
  │ ├── scope-migration-cli.test.ts
@@ -150,8 +160,34 @@ codetrap/
150
160
  | `repair-scope` | Move legacy mis-scoped project traps into the current project (dry-run by default, `--apply` to mutate, `--json`) |
151
161
  | `migrate-project` | Move project traps between initialized projects (`--from-project-path`, `--to-project-path`, dry-run by default, `--apply`, `--json`) |
152
162
  | `embed` | Generate embeddings (requires JINA_API_KEY) |
163
+ | `session` | Start a development session, append notes, promote explicit structured trap notes into candidates, accept/reject candidates, and clean up session files |
164
+ | `web` | Start the local review and trap library console |
153
165
  | `serve` | Start MCP server |
154
166
 
167
+ ### Session Mode
168
+
169
+ Session mode stores temporary working memory in `.codetrap/sessions/`. It does not add anything to `traps.db` until a candidate is explicitly accepted.
170
+
171
+ ```bash
172
+ codetrap session start "implement agent harness" --spec docs/agent-harness-spec.md --module agent-runtime
173
+ codetrap session note --kind decision --text "Defaulted tool calls to 30s because the spec does not define timeout behavior."
174
+ codetrap session note --kind review --text $'Title: Do not parse nested tool calls with regex\nContext: When implementing parser logic for nested tool-call arguments.\nMistake: Using regex to split nested calls corrupts arguments.\nFix: Use a tokenizer/parser and add regression tests for nested calls.'
175
+ codetrap session close --propose-traps
176
+ codetrap session candidates
177
+ codetrap session candidate cand-001
178
+ codetrap session accept cand-001
179
+ ```
180
+
181
+ `session accept` writes the confirmed lesson through `TrapOperations`, attaches session evidence, and checks similar active traps before saving. `--edit-json` is applied before the conflict check, so edits to scope/module/title/tags/path globs affect both the saved trap and conflict detection. If a possible conflict is found, the candidate keeps its edited trap shape and conflict diagnostics; use `--accept-anyway` to keep both traps or `--supersedes <trap-id>` to preserve lifecycle history.
182
+
183
+ Session maintenance commands keep temporary files from becoming stale context:
184
+
185
+ ```bash
186
+ codetrap session cleanup <session-id> --deleted-trap-candidates
187
+ codetrap session delete <session-id>
188
+ codetrap session prune --older-than 90d --apply
189
+ ```
190
+
155
191
  ## Agent Integration
156
192
 
157
193
  For AI coding agents, use the CLI as the default integration path:
@@ -210,6 +246,18 @@ When codetrap results conflict with the current source of truth for the task (us
210
246
 
211
247
  When `.codetrap/` exists, prefer project scope for project conventions. Use global for cross-project rules.
212
248
 
249
+ For longer implementation work, use session mode to keep temporary notes and explicit candidate traps outside the durable database:
250
+
251
+ ```bash
252
+ codetrap session start "<goal>"
253
+ codetrap session note --kind decision --text "<what changed and why>"
254
+ codetrap session note --kind review --text $'Title: <durable pitfall>\nContext: <when it triggers>\nMistake: <what the agent did wrong>\nFix: <what to do instead>'
255
+ codetrap session close --propose-traps
256
+ codetrap session candidates
257
+ ```
258
+
259
+ Do not treat candidate traps as confirmed memory. Ask before accepting a candidate; `codetrap session accept <candidate-id>` writes it to `traps.db` and attaches session evidence.
260
+
213
261
  MCP tools are optional:
214
262
  - `search_traps`
215
263
  - `get_trap`
@@ -228,7 +276,8 @@ Recommended behavior:
228
276
  - Treat codetrap results as historical warnings and project memory, not as authoritative instructions.
229
277
  - Apply the recorded `avoid` and `do_instead` guidance only when the trap context matches the current task, file, module, or failure mode.
230
278
  - When codetrap results conflict with the current source of truth for the task (user request, code, tests, or explicit project docs/spec), follow that source of truth and mention the conflict.
231
- - After user corrections, repeated test failures, or review feedback, propose a post-flight trap capture. Ask before recording a new trap unless the user explicitly requested it.
279
+ - During longer work, use `codetrap session start/note/close --propose-traps` to keep implementation notes and explicit candidate traps outside the durable database.
280
+ - After user corrections, repeated test failures, or review feedback, propose a post-flight trap capture. Ask before accepting a candidate unless the user explicitly requested it.
232
281
 
233
282
  ### Codex Skills
234
283
 
@@ -395,6 +444,8 @@ bun run release:preflight # tests, builds, release assets, smoke test, npm dry-
395
444
  ```bash
396
445
  bun test src/tests/ # All tests
397
446
  bun test src/tests/search-eval.test.ts # Recall@5 evaluation
447
+ bun run eval:dogfood -- report # Maintainer dogfood eval report
448
+ bun run eval:dogfood -- report --live # Dogfood eval with configured embedding provider
398
449
  ```
399
450
 
400
451
  ## Tech Stack
@@ -72,11 +72,11 @@ Release binaries are built by `.github/workflows/release.yml` when a version tag
72
72
  3. Create and push a matching tag:
73
73
 
74
74
  ```bash
75
- git tag v0.1.2
76
- git push origin v0.1.2
75
+ git tag v0.1.6
76
+ git push origin v0.1.6
77
77
  ```
78
78
 
79
- The release tag must match `package.json` exactly. For example, package version `0.1.2` must use tag `v0.1.2`.
79
+ The release tag must match `package.json` exactly. For example, package version `0.1.6` must use tag `v0.1.6`.
80
80
 
81
81
  The workflow runs:
82
82
 
@@ -312,6 +312,24 @@ To add a lesson:
312
312
 
313
313
  codetrap add --json '{...}' --output-json
314
314
 
315
+ For longer implementation work, keep temporary notes and explicit candidate traps in session files first:
316
+
317
+ ```bash
318
+ codetrap session start "<goal>"
319
+ codetrap session note --kind decision --text "<what changed and why>"
320
+ codetrap session note --kind review --text $'Title: <durable pitfall>\nContext: <when it triggers>\nMistake: <what the agent did wrong>\nFix: <what to do instead>'
321
+ codetrap session close --propose-traps
322
+ codetrap session candidates
323
+ ```
324
+
325
+ Only accepted candidates are written to `traps.db`:
326
+
327
+ ```bash
328
+ codetrap session accept <candidate-id>
329
+ ```
330
+
331
+ `codetrap session accept --edit-json ...` applies the edit before conflict detection. If a possible active-trap conflict is found, the candidate remains proposed and records conflict diagnostics until you choose `--accept-anyway`, `--supersedes <trap-id>`, or reject it.
332
+
315
333
  To save a lesson from an external article or reference, let the agent read the source and attach the URL as evidence after the user confirms the trap:
316
334
 
317
335
  codetrap add_trap_evidence <id> --scope global --source_type article --source_ref "https://example.com/post" --output-json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codetrap",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Capture and retrieve coding pitfalls so AI doesn't repeat mistakes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "src/domain",
31
31
  "src/lib",
32
32
  "src/mcp",
33
+ "src/web",
33
34
  "src/index.ts",
34
35
  "src/mcp-server.ts",
35
36
  "skills",
@@ -50,6 +51,7 @@
50
51
  "build:release": "bun run scripts/build-release.ts",
51
52
  "release:preflight": "bun run scripts/release-preflight.ts",
52
53
  "check:release-version": "bun run scripts/check-release-version.ts",
54
+ "eval:dogfood": "bun run scripts/dogfood-eval.ts",
53
55
  "build": "bun build ./src/index.ts --compile --outfile dist/codetrap && bun build ./src/mcp-server.ts --compile --outfile dist/codetrap-serve",
54
56
  "build:cli": "bun build ./src/index.ts --compile --outfile dist/codetrap",
55
57
  "build:serve": "bun build ./src/mcp-server.ts --compile --outfile dist/codetrap-serve"
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {
4
+ DEFAULT_SEARCH_EVAL_FIXTURE,
5
+ formatSearchEvalReport,
6
+ recordDogfoodCase,
7
+ reportDogfood,
8
+ } from "../src/lib/search-eval";
9
+
10
+ async function main(): Promise<void> {
11
+ const args = parseArgs(process.argv.slice(2));
12
+ const command = args.positionals[0];
13
+ const fixturePath = args.opts.fixture ?? DEFAULT_SEARCH_EVAL_FIXTURE;
14
+
15
+ try {
16
+ if (command === "record") {
17
+ console.log(JSON.stringify(recordDogfoodCase(fixturePath, args.opts.json), null, 2));
18
+ return;
19
+ }
20
+
21
+ if (command === "report") {
22
+ const result = await reportDogfood(fixturePath, args.opts.live === "true");
23
+ console.log(args.opts.json === "true" ? JSON.stringify(result, null, 2) : formatSearchEvalReport(result));
24
+ return;
25
+ }
26
+
27
+ throw new Error([
28
+ "Usage:",
29
+ " bun run eval:dogfood -- report [--live] [--json] [--fixture path]",
30
+ " bun run eval:dogfood -- record --json '<record>' [--fixture path]",
31
+ ].join("\n"));
32
+ } catch (error) {
33
+ console.error(error instanceof Error ? error.message : String(error));
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ function parseArgs(args: string[]): { opts: Record<string, string>; positionals: string[] } {
39
+ const opts: Record<string, string> = {};
40
+ const positionals: string[] = [];
41
+ for (let i = 0; i < args.length; i++) {
42
+ const arg = args[i];
43
+ if (arg.startsWith("--")) {
44
+ const key = arg.slice(2);
45
+ opts[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : "true";
46
+ } else {
47
+ positionals.push(arg);
48
+ }
49
+ }
50
+ return { opts, positionals };
51
+ }
52
+
53
+ await main();
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { TrapStore } from "../lib/store";
3
3
  import { formatTrapShort, formatTrapDetails, formatTrapActionCard } from "../lib/format";
4
4
  import type { Trap } from "../domain/trap";
5
+ import type { SessionMetadata } from "../domain/session";
5
6
  import {
6
7
  formatScopeMigrationText,
7
8
  runScopeMigration,
@@ -9,7 +10,17 @@ import {
9
10
  } from "../lib/scope-migration";
10
11
  import { TrapOperations } from "../lib/trap-operations";
11
12
  import { buildDoctorReport, formatDoctorText } from "../lib/doctor";
13
+ import { formatEmbedText } from "../lib/embed-output";
12
14
  import { searchDefaultsFromConfig } from "../lib/config";
15
+ import { SessionStore } from "../lib/session-store";
16
+ import { SessionOperations, type SessionConflictResult } from "../lib/session-operations";
17
+ import {
18
+ CANDIDATES_FILE,
19
+ NOTES_FILE,
20
+ RECAP_FILE,
21
+ sessionRelativeDir,
22
+ sessionRelativeFile,
23
+ } from "../lib/session-codec";
13
24
  import {
14
25
  toCliSearchJson,
15
26
  toListJson,
@@ -28,6 +39,16 @@ import {
28
39
  evidenceRequestFromArgs,
29
40
  listRequestFromArgs,
30
41
  searchRequestFromArgs,
42
+ sessionAcceptRequestFromArgs,
43
+ sessionCandidateRequestFromArgs,
44
+ sessionCloseRequestFromArgs,
45
+ sessionIdRequestFromArgs,
46
+ sessionListRequestFromArgs,
47
+ sessionNoteRequestFromArgs,
48
+ sessionPruneRequestFromArgs,
49
+ sessionRejectRequestFromArgs,
50
+ sessionShowRequestFromArgs,
51
+ sessionStartRequestFromArgs,
31
52
  statsRequestFromArgs,
32
53
  } from "../lib/command-requests";
33
54
 
@@ -80,10 +101,12 @@ export async function executeCommand(strip: string[], store: TrapStore): Promise
80
101
  return cmdScopeMigration("migrate-project", args, operations);
81
102
  case "embed":
82
103
  return cmdEmbed(args, store);
104
+ case "session":
105
+ return cmdSession(args, store, operations);
83
106
  default:
84
107
  return errorResult([
85
108
  `Unknown command: ${sub}`,
86
- "Commands: init, add, search, list, show, edit, delete, add_trap_evidence, archive_trap, supersede_trap, export, import, stats, doctor, repair-scope, migrate-project, embed",
109
+ "Commands: init, add, search, list, show, edit, delete, add_trap_evidence, archive_trap, supersede_trap, export, import, stats, doctor, repair-scope, migrate-project, embed, session",
87
110
  ].join("\n"));
88
111
  }
89
112
  }
@@ -359,17 +382,255 @@ async function cmdEmbed(args: string[], store: TrapStore): Promise<CommandResult
359
382
  const { opts } = parseArgs(args);
360
383
  try {
361
384
  const result = await store.ensureEmbeddings(embedRequestFromArgs(opts));
362
- return textResult([
363
- ...result.scopes.map((scoped) =>
364
- `[${scoped.scope}] embeddings generated: ${scoped.generated}, skipped: ${scoped.skipped}, batches: ${scoped.batches}`
365
- ),
366
- `Total generated: ${result.generated}, skipped: ${result.skipped}, batches: ${result.batches}`,
367
- ].join("\n"));
385
+ return textResult(formatEmbedText(result));
386
+ } catch (error) {
387
+ return errorFrom(error);
388
+ }
389
+ }
390
+
391
+ async function cmdSession(args: string[], store: TrapStore, trapOperations: TrapOperations): Promise<CommandResult> {
392
+ const sub = args[0];
393
+ const rest = args.slice(1);
394
+ const projectRoot = store.getProjectRoot();
395
+ if (!projectRoot) {
396
+ return errorResult("Not in a project. Run 'codetrap init' first.");
397
+ }
398
+
399
+ const sessions = new SessionOperations(new SessionStore(projectRoot), trapOperations);
400
+ try {
401
+ switch (sub) {
402
+ case "start":
403
+ return cmdSessionStart(rest, sessions);
404
+ case "note":
405
+ return cmdSessionNote(rest, sessions);
406
+ case "status":
407
+ return cmdSessionStatus(rest, sessions);
408
+ case "list":
409
+ return cmdSessionList(rest, sessions);
410
+ case "show":
411
+ return cmdSessionShow(rest, sessions);
412
+ case "notes":
413
+ return cmdSessionNotes(rest, sessions);
414
+ case "close":
415
+ return cmdSessionClose(rest, sessions);
416
+ case "candidates":
417
+ return cmdSessionCandidates(rest, sessions);
418
+ case "candidate":
419
+ return cmdSessionCandidate(rest, sessions);
420
+ case "accept":
421
+ return cmdSessionAccept(rest, sessions);
422
+ case "reject":
423
+ return cmdSessionReject(rest, sessions);
424
+ case "delete":
425
+ return cmdSessionDelete(rest, sessions);
426
+ case "prune":
427
+ return cmdSessionPrune(rest, sessions);
428
+ case "cleanup":
429
+ return cmdSessionCleanup(rest, sessions);
430
+ default:
431
+ return errorResult("Usage: codetrap session <start|note|status|list|show|notes|close|candidates|candidate|accept|reject|delete|prune|cleanup>");
432
+ }
368
433
  } catch (error) {
369
434
  return errorFrom(error);
370
435
  }
371
436
  }
372
437
 
438
+ function cmdSessionStart(args: string[], sessions: SessionOperations): CommandResult {
439
+ const { opts, positionals } = parseArgs(args);
440
+ const session = sessions.startSession(sessionStartRequestFromArgs(positionals, opts));
441
+ const payload = sessionPayload(session);
442
+ if (opts.json !== undefined) return jsonResult(payload);
443
+ return textResult([
444
+ `Started session ${session.id}`,
445
+ `Notes: ${payload.notes_path}`,
446
+ ].join("\n"));
447
+ }
448
+
449
+ function cmdSessionNote(args: string[], sessions: SessionOperations): CommandResult {
450
+ const { opts, positionals } = parseArgs(args);
451
+ const result = sessions.addNote(sessionNoteRequestFromArgs(positionals, opts, {
452
+ isTTY: process.stdin.isTTY === true,
453
+ read: () => readFileSync(0, "utf-8"),
454
+ }));
455
+ const payload = {
456
+ session_id: result.session.id,
457
+ kind: result.note.kind,
458
+ note: result.note,
459
+ notes_path: result.notes_path,
460
+ };
461
+ if (opts.json !== undefined) return jsonResult(payload);
462
+ return textResult([
463
+ `Added ${result.note.kind} note to session ${result.session.id}.`,
464
+ `Notes: ${result.notes_path}`,
465
+ ].join("\n"));
466
+ }
467
+
468
+ function cmdSessionStatus(args: string[], sessions: SessionOperations): CommandResult {
469
+ const { opts } = parseArgs(args);
470
+ const status = sessions.status();
471
+ if (opts.json !== undefined) {
472
+ return jsonResult({
473
+ active_session_id: status.active_session_id,
474
+ session: status.session ? sessionPayload(status.session) : null,
475
+ });
476
+ }
477
+ if (!status.session) return textResult("No active session.");
478
+ return textResult([
479
+ `Active session ${status.session.id}`,
480
+ `Goal: ${status.session.goal}`,
481
+ `Notes: ${sessionRelativeFile(status.session.id, NOTES_FILE)}`,
482
+ ].join("\n"));
483
+ }
484
+
485
+ function cmdSessionList(args: string[], sessions: SessionOperations): CommandResult {
486
+ const { opts } = parseArgs(args);
487
+ const entries = sessions.listSessions(sessionListRequestFromArgs(opts));
488
+ if (opts.json !== undefined) return jsonResult(entries);
489
+ if (entries.length === 0) return textResult("No sessions found.");
490
+ return textResult(entries.map((entry) => `${entry.id} [${entry.status}] ${entry.goal}`).join("\n"));
491
+ }
492
+
493
+ function cmdSessionShow(args: string[], sessions: SessionOperations): CommandResult {
494
+ const { opts, positionals } = parseArgs(args);
495
+ const request = sessionShowRequestFromArgs(positionals);
496
+ const shown = sessions.showSession(request.sessionId);
497
+ if (opts.json !== undefined) {
498
+ return jsonResult({
499
+ ...sessionPayload(shown.session),
500
+ session_dir: shown.session_dir,
501
+ recap: shown.recap,
502
+ });
503
+ }
504
+ if (shown.recap) return textResult(shown.recap.trimEnd());
505
+ return textResult([
506
+ `Session ${shown.session.id} [${shown.session.status}]`,
507
+ `Goal: ${shown.session.goal}`,
508
+ `Notes: ${shown.notes_path}`,
509
+ ].join("\n"));
510
+ }
511
+
512
+ function cmdSessionNotes(args: string[], sessions: SessionOperations): CommandResult {
513
+ const { opts, positionals } = parseArgs(args);
514
+ const request = sessionIdRequestFromArgs(positionals);
515
+ const summary = sessions.summarizeNotes(request.sessionId);
516
+ if (opts.json !== undefined) return jsonResult(summary);
517
+ return textResult(summary.content.trimEnd());
518
+ }
519
+
520
+ function cmdSessionClose(args: string[], sessions: SessionOperations): CommandResult {
521
+ const { opts, positionals } = parseArgs(args);
522
+ const request = sessionCloseRequestFromArgs(positionals, opts);
523
+ const result = sessions.closeSession(request.sessionId, request.proposeTraps);
524
+ const payload = {
525
+ ...sessionPayload(result.session),
526
+ recap_path: result.recap_path,
527
+ candidate_count: result.candidate_count,
528
+ traps_written: result.traps_written,
529
+ };
530
+ if (opts.json !== undefined) return jsonResult(payload);
531
+ const lines = [
532
+ `Closed session ${result.session.id}`,
533
+ `Generated ${RECAP_FILE}`,
534
+ ];
535
+ if (opts["propose-traps"] !== undefined) {
536
+ lines.push(`Proposed ${result.candidate_count} candidate traps`);
537
+ lines.push(`0 traps were written. Use \`codetrap session accept <candidate-id> --session ${result.session.id}\` to save one.`);
538
+ }
539
+ return textResult(lines.join("\n"));
540
+ }
541
+
542
+ function cmdSessionCandidates(args: string[], sessions: SessionOperations): CommandResult {
543
+ const { opts, positionals } = parseArgs(args);
544
+ const request = sessionIdRequestFromArgs(positionals);
545
+ const document = sessions.candidateDocument(request.sessionId);
546
+ if (opts.json !== undefined) return jsonResult(document);
547
+ if (document.candidates.length === 0) return textResult("No candidate traps found.");
548
+ return textResult(document.candidates.map((candidate) =>
549
+ `${candidate.id} [${candidate.status}] ${candidate.trap.title} (${candidate.quality_score.toFixed(2)})`
550
+ ).join("\n"));
551
+ }
552
+
553
+ function cmdSessionCandidate(args: string[], sessions: SessionOperations): CommandResult {
554
+ const { opts, positionals } = parseArgs(args);
555
+ const request = sessionCandidateRequestFromArgs(positionals, opts);
556
+ const result = sessions.getCandidate(request.candidateId, request.sessionId);
557
+ if (opts.json !== undefined) return jsonResult({ session_id: result.session.id, candidate: result.candidate });
558
+ return textResult(JSON.stringify(result.candidate, null, 2));
559
+ }
560
+
561
+ async function cmdSessionAccept(args: string[], sessions: SessionOperations): Promise<CommandResult> {
562
+ const { opts, positionals } = parseArgs(args);
563
+ const accepted = await sessions.acceptCandidate(sessionAcceptRequestFromArgs(positionals, opts));
564
+ if (!accepted.success) return possibleConflictResult(accepted, opts.json !== undefined);
565
+ const payload = {
566
+ success: true,
567
+ session_id: accepted.session.id,
568
+ candidate_id: accepted.candidate.id,
569
+ status: accepted.candidate.status,
570
+ trap_id: accepted.trap_id,
571
+ scope: accepted.scope,
572
+ evidence_id: accepted.evidence_id,
573
+ superseded_id: accepted.superseded_id,
574
+ };
575
+ if (opts.json !== undefined) return jsonResult(payload);
576
+ const lines = [`Accepted ${accepted.candidate.id}; wrote trap #${accepted.trap_id} to ${accepted.scope} scope.`];
577
+ if (accepted.superseded_id !== null) lines.push(`Superseded trap #${accepted.superseded_id}.`);
578
+ return textResult(lines.join("\n"));
579
+ }
580
+
581
+ function cmdSessionReject(args: string[], sessions: SessionOperations): CommandResult {
582
+ const { opts, positionals } = parseArgs(args);
583
+ const rejected = sessions.rejectCandidate(sessionRejectRequestFromArgs(positionals, opts));
584
+ const payload = {
585
+ success: true,
586
+ session_id: rejected.session.id,
587
+ candidate_id: rejected.candidate.id,
588
+ status: rejected.candidate.status,
589
+ reason: rejected.candidate.rejection_reason ?? null,
590
+ };
591
+ if (opts.json !== undefined) return jsonResult(payload);
592
+ return textResult(`Rejected ${rejected.candidate.id}.`);
593
+ }
594
+
595
+ function cmdSessionDelete(args: string[], sessions: SessionOperations): CommandResult {
596
+ const { opts, positionals } = parseArgs(args);
597
+ const request = sessionShowRequestFromArgs(positionals);
598
+ const result = sessions.deleteSession(request.sessionId);
599
+ const payload = { success: result.deleted, ...result };
600
+ if (opts.json !== undefined) return jsonResult(payload);
601
+ return textResult(`Deleted session ${result.session_id}.`);
602
+ }
603
+
604
+ function cmdSessionPrune(args: string[], sessions: SessionOperations): CommandResult {
605
+ const { opts } = parseArgs(args);
606
+ const result = sessions.pruneSessions(sessionPruneRequestFromArgs(opts));
607
+ if (opts.json !== undefined) return jsonResult(result);
608
+ const verb = result.dry_run ? "Would delete" : "Deleted";
609
+ const lines = [`${verb} ${result.dry_run ? result.sessions.length : result.deleted_count} session(s) older than ${result.cutoff}.`];
610
+ if (result.dry_run && result.sessions.length > 0) {
611
+ lines.push("Run with --apply to delete them.");
612
+ }
613
+ lines.push(...result.sessions.map((session) => `- ${session.id} [${session.status}] ${session.goal}`));
614
+ return textResult(lines.join("\n"));
615
+ }
616
+
617
+ function cmdSessionCleanup(args: string[], sessions: SessionOperations): CommandResult {
618
+ const { opts, positionals } = parseArgs(args);
619
+ if (opts["deleted-trap-candidates"] === undefined && opts.deleted_trap_candidates === undefined) {
620
+ return errorResult("Usage: codetrap session cleanup [session-id] --deleted-trap-candidates [--json]");
621
+ }
622
+ const request = sessionIdRequestFromArgs(positionals);
623
+ const result = sessions.cleanupDeletedTrapCandidates(request.sessionId);
624
+ const payload = {
625
+ success: true,
626
+ session_id: result.session.id,
627
+ removed_count: result.removed_count,
628
+ removed_candidate_ids: result.removed_candidate_ids,
629
+ };
630
+ if (opts.json !== undefined) return jsonResult(payload);
631
+ return textResult(`Removed ${result.removed_count} deleted-trap candidate(s) from session ${result.session.id}.`);
632
+ }
633
+
373
634
  function formatStatsText(stats: ReturnType<TrapOperations["getStats"]>): string {
374
635
  const sections: string[] = [];
375
636
  if (stats.project) {
@@ -417,3 +678,43 @@ function errorFrom(error: unknown): CommandResult {
417
678
  function errorMessage(error: unknown): string {
418
679
  return error instanceof Error ? error.message : String(error);
419
680
  }
681
+
682
+ function sessionPayload(session: SessionMetadata) {
683
+ return {
684
+ ...session,
685
+ session_dir: sessionRelativeDir(session.id),
686
+ notes_path: sessionRelativeFile(session.id, NOTES_FILE),
687
+ recap_path: sessionRelativeFile(session.id, RECAP_FILE),
688
+ candidate_traps_path: sessionRelativeFile(session.id, CANDIDATES_FILE),
689
+ };
690
+ }
691
+
692
+ function possibleConflictResult(
693
+ result: SessionConflictResult,
694
+ asJson: boolean
695
+ ): CommandResult {
696
+ const payload = {
697
+ success: false,
698
+ error: "Possible active trap conflict found.",
699
+ session_id: result.session_id,
700
+ candidate_id: result.candidate_id,
701
+ possible_conflicts: result.possible_conflicts,
702
+ next_actions: [
703
+ `codetrap session accept ${result.candidate_id} --session ${result.session_id} --accept-anyway`,
704
+ `codetrap session accept ${result.candidate_id} --session ${result.session_id} --supersedes <trap-id>`,
705
+ `codetrap session reject ${result.candidate_id} --session ${result.session_id} --reason <reason>`,
706
+ ],
707
+ };
708
+ if (asJson) return jsonResult(payload, 1);
709
+
710
+ return errorResult([
711
+ "Possible active trap conflict found:",
712
+ ...result.possible_conflicts.map((conflict) => [
713
+ `#${conflict.trap_id} ${conflict.title}`,
714
+ ` reason: ${conflict.reason}`,
715
+ ` fix: ${conflict.fix}`,
716
+ ].join("\n")),
717
+ "",
718
+ `Use --accept-anyway to save as a new trap, or --supersedes <trap-id> to preserve lifecycle history.`,
719
+ ].join("\n"));
720
+ }
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { getGlobalDB, getProjectDB } from "../lib/scope";
3
3
  import { initSchema } from "./schema";
4
4
 
5
- let globalDB: Database | null = null;
5
+ const globalDBs = new Map<string, Database>();
6
6
  const projectDBs = new Map<string, Database>();
7
7
 
8
8
  export function openDatabase(path = ":memory:"): Database {
@@ -11,12 +11,12 @@ export function openDatabase(path = ":memory:"): Database {
11
11
  return db;
12
12
  }
13
13
 
14
- export function openGlobal(): Database {
15
- if (!globalDB) {
16
- const path = getGlobalDB();
17
- globalDB = openDatabase(path);
14
+ export function openGlobal(home?: string): Database {
15
+ const path = getGlobalDB(home);
16
+ if (!globalDBs.has(path)) {
17
+ globalDBs.set(path, openDatabase(path));
18
18
  }
19
- return globalDB;
19
+ return globalDBs.get(path)!;
20
20
  }
21
21
 
22
22
  export function openProject(root: string): Database {
@@ -29,8 +29,8 @@ export function openProject(root: string): Database {
29
29
  }
30
30
 
31
31
  function configureDatabase(db: Database): void {
32
+ db.exec("PRAGMA busy_timeout=5000");
32
33
  db.exec("PRAGMA journal_mode=WAL");
33
34
  db.exec("PRAGMA foreign_keys=ON");
34
- db.exec("PRAGMA busy_timeout=5000");
35
35
  initSchema(db);
36
36
  }