codetrap 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +121 -38
  3. package/docs/installation.md +18 -10
  4. package/package.json +4 -1
  5. package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
  6. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
  7. package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
  8. package/plugins/codetrap-agent/hooks.json +11 -0
  9. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
  11. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
  12. package/scripts/release-preflight.ts +55 -0
  13. package/skills/codetrap-add/SKILL.md +4 -1
  14. package/skills/codetrap-check/SKILL.md +24 -4
  15. package/skills/codetrap-search/SKILL.md +32 -12
  16. package/src/commands/command-result.ts +29 -0
  17. package/src/commands/router.ts +6 -400
  18. package/src/commands/workflow.ts +466 -0
  19. package/src/db/embedding-queries.ts +33 -0
  20. package/src/db/queries.ts +119 -20
  21. package/src/db/repository.ts +39 -2
  22. package/src/db/schema.ts +35 -0
  23. package/src/domain/trap.ts +31 -2
  24. package/src/index.ts +13 -1
  25. package/src/lib/config.ts +102 -0
  26. package/src/lib/constants.ts +1 -1
  27. package/src/lib/doctor.ts +76 -0
  28. package/src/lib/embedding-health.ts +49 -0
  29. package/src/lib/format.ts +5 -1
  30. package/src/lib/output-json.ts +116 -0
  31. package/src/lib/scope-context.ts +116 -0
  32. package/src/lib/scope-migration.ts +360 -0
  33. package/src/lib/scope.ts +6 -4
  34. package/src/lib/search-normalizer.ts +6 -0
  35. package/src/lib/search-policy.ts +276 -0
  36. package/src/lib/search-result-card.ts +1 -0
  37. package/src/lib/search-service.ts +36 -98
  38. package/src/lib/store.ts +96 -107
  39. package/src/lib/trap-archive.ts +9 -42
  40. package/src/lib/trap-codec.ts +113 -0
  41. package/src/lib/trap-json-fields.ts +12 -0
  42. package/src/lib/trap-mutation-result.ts +36 -0
  43. package/src/lib/trap-operations.ts +27 -6
  44. package/src/lib/trap-scope-match.ts +112 -0
  45. package/src/lib/trap-search-document.ts +8 -1
  46. package/src/lib/trap-transfer.ts +88 -0
  47. package/src/mcp/server.ts +75 -57
  48. package/src/mcp/tools.ts +32 -5
package/src/db/queries.ts CHANGED
@@ -12,32 +12,37 @@ import {
12
12
  } from "../domain/trap";
13
13
  import { prepareFTSQuery } from "../lib/fts-query";
14
14
  import { normalizeQuery } from "../lib/search-normalizer";
15
- import { normalizeEvidenceForExport } from "../lib/trap-archive";
16
- import { encodeEvidenceRelatedFiles, encodeTrapTags } from "../lib/trap-json-fields";
15
+ import {
16
+ encodeTrapInsertFields,
17
+ mergeTrapUpdateForSearchText,
18
+ normalizeEvidenceForExport,
19
+ normalizeTrapForExport,
20
+ } from "../lib/trap-codec";
21
+ import { encodeEvidenceRelatedFiles, encodeTrapPathGlobs, encodeTrapTags } from "../lib/trap-json-fields";
17
22
  import { buildTrapSearchText, passageFieldsChanged, searchTextFieldsChanged } from "../lib/trap-search-document";
18
23
 
19
24
  export type TrapStatusFilter = TrapStatus | "all";
25
+ export type TrapRecordInsert = Omit<Trap, "id">;
20
26
 
21
27
  export function insertTrap(db: Database, input: TrapInput): number {
22
- const tags = encodeTrapTags(input.tags);
23
- const searchText = buildTrapSearchText({ ...input, tags });
28
+ const fields = encodeTrapInsertFields(input);
24
29
  const stmt = db.prepare(`
25
30
  INSERT INTO traps (
26
31
  title, category, tags, scope, context, mistake, fix, search_text,
27
32
  before_code, after_code, severity, state_key, status, supersedes_id,
28
- valid_from, valid_until, project_path
33
+ valid_from, valid_until, project_path, path_globs, module, owner
29
34
  )
30
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?)
35
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)
31
36
  `);
32
37
  const result = stmt.run(
33
38
  input.title,
34
39
  input.category,
35
- tags,
40
+ fields.tags,
36
41
  input.scope,
37
42
  input.context,
38
43
  input.mistake,
39
44
  input.fix,
40
- searchText,
45
+ fields.search_text,
41
46
  input.before_code ?? null,
42
47
  input.after_code ?? null,
43
48
  input.severity ?? DEFAULT_SEVERITY,
@@ -45,7 +50,10 @@ export function insertTrap(db: Database, input: TrapInput): number {
45
50
  DEFAULT_TRAP_STATUS,
46
51
  null,
47
52
  null,
48
- input.project_path ?? null
53
+ input.project_path ?? null,
54
+ fields.path_globs,
55
+ normalizeOptionalText(input.module),
56
+ normalizeOptionalText(input.owner)
49
57
  );
50
58
  return Number(result.lastInsertRowid);
51
59
  }
@@ -53,7 +61,7 @@ export function insertTrap(db: Database, input: TrapInput): number {
53
61
  export function searchTraps(
54
62
  db: Database,
55
63
  query: string,
56
- opts: { category?: string; scope?: string; limit?: number; status?: TrapStatusFilter } = {}
64
+ opts: { category?: string; scope?: string; limit?: number; status?: TrapStatusFilter; module?: string; owner?: string } = {}
57
65
  ): TrapSearchResult[] {
58
66
  const prepared = prepareFTSQuery(normalizeQuery(query));
59
67
  if (!prepared) return [];
@@ -86,7 +94,7 @@ export function getTrap(db: Database, id: number): Trap | null {
86
94
 
87
95
  export function listTraps(
88
96
  db: Database,
89
- opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatusFilter } = {}
97
+ opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatusFilter; module?: string; owner?: string } = {}
90
98
  ): Trap[] {
91
99
  const conditions: string[] = [];
92
100
  const params: SQLQueryBindings[] = [];
@@ -108,24 +116,24 @@ export function updateTrap(db: Database, id: number, input: TrapUpdate): boolean
108
116
  const current = searchTextFieldsChanged(input) || passageFieldsChanged(input) ? getTrap(db, id) : null;
109
117
 
110
118
  for (const key of TRAP_UPDATE_FIELDS) {
111
- if (key === "tags") continue;
119
+ if (key === "tags" || key === "path_globs") continue;
112
120
  const value = input[key];
113
121
  if (value !== undefined) {
114
122
  updates.push(`${key} = ?`);
115
- params.push(value ?? null);
123
+ params.push(key === "module" || key === "owner" ? normalizeOptionalText(value) : value ?? null);
116
124
  }
117
125
  }
118
126
  if (input.tags !== undefined) {
119
127
  updates.push("tags = ?");
120
128
  params.push(encodeTrapTags(input.tags));
121
129
  }
130
+ if (input.path_globs !== undefined) {
131
+ updates.push("path_globs = ?");
132
+ params.push(encodeTrapPathGlobs(input.path_globs));
133
+ }
122
134
 
123
135
  if (current && searchTextFieldsChanged(input)) {
124
- const merged = {
125
- ...current,
126
- ...input,
127
- tags: input.tags !== undefined ? encodeTrapTags(input.tags) : current.tags,
128
- };
136
+ const merged = mergeTrapUpdateForSearchText(current, input);
129
137
  updates.push("search_text = ?");
130
138
  params.push(buildTrapSearchText(merged));
131
139
  }
@@ -260,11 +268,79 @@ export function getStats(db: Database): {
260
268
  export function exportTraps(db: Database): TrapExportRecord[] {
261
269
  const traps = db.query("SELECT * FROM traps").all() as Trap[];
262
270
  return traps.map((trap) => ({
263
- ...trap,
271
+ ...normalizeTrapForExport(trap),
264
272
  evidence: listTrapEvidence(db, trap.id).map(normalizeEvidenceForExport),
265
273
  }));
266
274
  }
267
275
 
276
+ export function exportProjectTrapsByPath(db: Database, projectPath: string): TrapExportRecord[] {
277
+ const traps = db
278
+ .query("SELECT * FROM traps WHERE scope = 'project' AND project_path = ? ORDER BY id")
279
+ .all(projectPath) as Trap[];
280
+ return traps.map((trap) => ({
281
+ ...normalizeTrapForExport(trap),
282
+ evidence: listTrapEvidence(db, trap.id).map(normalizeEvidenceForExport),
283
+ }));
284
+ }
285
+
286
+ export function insertTrapRecord(db: Database, record: TrapRecordInsert): number {
287
+ const stmt = db.prepare(`
288
+ INSERT INTO traps (
289
+ title, category, tags, scope, context, mistake, fix, search_text,
290
+ before_code, after_code, severity, state_key, status, supersedes_id,
291
+ valid_from, valid_until, project_path, path_globs, module, owner,
292
+ hit_count, created_at, updated_at
293
+ )
294
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
295
+ `);
296
+ const result = stmt.run(
297
+ record.title,
298
+ record.category,
299
+ record.tags,
300
+ record.scope,
301
+ record.context,
302
+ record.mistake,
303
+ record.fix,
304
+ record.search_text,
305
+ record.before_code,
306
+ record.after_code,
307
+ record.severity,
308
+ record.state_key,
309
+ record.status,
310
+ record.supersedes_id,
311
+ record.valid_from,
312
+ record.valid_until,
313
+ record.project_path,
314
+ record.path_globs,
315
+ normalizeOptionalText(record.module),
316
+ normalizeOptionalText(record.owner),
317
+ record.hit_count,
318
+ record.created_at,
319
+ record.updated_at
320
+ );
321
+ return Number(result.lastInsertRowid);
322
+ }
323
+
324
+ export function updateTrapSupersedesId(db: Database, id: number, supersedesId: number): boolean {
325
+ const result = db.prepare("UPDATE traps SET supersedes_id = ? WHERE id = ?").run(supersedesId, id);
326
+ return result.changes > 0;
327
+ }
328
+
329
+ export function deleteTrapsByIds(db: Database, ids: number[]): number {
330
+ if (ids.length === 0) return 0;
331
+ let deleted = 0;
332
+ const stmt = db.prepare("DELETE FROM traps WHERE id = ?");
333
+ const tx = db.transaction(() => {
334
+ for (const id of ids) {
335
+ if (!getTrap(db, id)) continue;
336
+ stmt.run(id);
337
+ deleted++;
338
+ }
339
+ });
340
+ tx();
341
+ return deleted;
342
+ }
343
+
268
344
  export function countTraps(db: Database, opts: { scope?: string; category?: string; status?: TrapStatusFilter } = {}): number {
269
345
  const conditions: string[] = [];
270
346
  const params: SQLQueryBindings[] = [];
@@ -274,10 +350,17 @@ export function countTraps(db: Database, opts: { scope?: string; category?: stri
274
350
  return row.c;
275
351
  }
276
352
 
353
+ export function countProjectTrapsByPath(db: Database, projectPath: string): number {
354
+ const row = db
355
+ .query("SELECT COUNT(*) as c FROM traps WHERE scope = 'project' AND project_path = ?")
356
+ .get(projectPath) as { c: number };
357
+ return row.c;
358
+ }
359
+
277
360
  function addTrapFilters(
278
361
  conditions: string[],
279
362
  params: SQLQueryBindings[],
280
- opts: { category?: string; scope?: string; status?: TrapStatusFilter },
363
+ opts: { category?: string; scope?: string; status?: TrapStatusFilter; module?: string; owner?: string },
281
364
  alias?: string
282
365
  ): void {
283
366
  const prefix = alias ? `${alias}.` : "";
@@ -293,4 +376,20 @@ function addTrapFilters(
293
376
  conditions.push(`${prefix}status = ?`);
294
377
  params.push(opts.status ?? DEFAULT_TRAP_STATUS);
295
378
  }
379
+ const module = normalizeOptionalText(opts.module);
380
+ if (module) {
381
+ conditions.push(`(${prefix}module IS NULL OR ${prefix}module = '' OR ${prefix}module = ?)`);
382
+ params.push(module);
383
+ }
384
+ const owner = normalizeOptionalText(opts.owner);
385
+ if (owner) {
386
+ conditions.push(`(${prefix}owner IS NULL OR ${prefix}owner = '' OR ${prefix}owner = ?)`);
387
+ params.push(owner);
388
+ }
389
+ }
390
+
391
+ function normalizeOptionalText(value: unknown): string | null {
392
+ if (typeof value !== "string") return value == null ? null : String(value);
393
+ const trimmed = value.trim();
394
+ return trimmed === "" ? null : trimmed;
296
395
  }
@@ -19,11 +19,15 @@ import { runEmbeddingJob } from "../lib/embedding-job";
19
19
  import { passageFieldsChanged } from "../lib/trap-search-document";
20
20
  import * as queries from "./queries";
21
21
  import type { TrapStatus } from "../lib/constants";
22
+ import { TrapSearchPolicy } from "../lib/search-policy";
22
23
 
23
24
  export type TrapStats = ReturnType<typeof queries.getStats>;
25
+ export type EmbeddingStateCounts = ReturnType<typeof embeddingQueries.getEmbeddingStateCounts>;
26
+ export type TrapRecordInsert = queries.TrapRecordInsert;
24
27
 
25
28
  export class TrapRepository {
26
29
  private readonly searchService: SearchService;
30
+ private readonly searchPolicy = new TrapSearchPolicy();
27
31
 
28
32
  constructor(
29
33
  private readonly db: Database,
@@ -54,8 +58,13 @@ export class TrapRepository {
54
58
  };
55
59
  }
56
60
 
57
- list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all" } = {}): Trap[] {
58
- return queries.listTraps(this.db, opts);
61
+ list(opts: { category?: string; scope?: string; limit?: number; offset?: number; status?: TrapStatus | "all"; path?: string; module?: string; owner?: string } = {}): Trap[] {
62
+ const limit = opts.limit ?? 50;
63
+ const queryLimit = opts.path ? Math.max(limit * 5, 250) : limit;
64
+ return queries
65
+ .listTraps(this.db, { ...opts, limit: queryLimit })
66
+ .filter((trap) => this.searchPolicy.matchesTrap(trap, opts))
67
+ .slice(0, limit);
59
68
  }
60
69
 
61
70
  update(id: number, input: TrapUpdate): boolean {
@@ -95,10 +104,38 @@ export class TrapRepository {
95
104
  return queries.getStats(this.db);
96
105
  }
97
106
 
107
+ embeddingStats(config: EmbeddingConfig | null): EmbeddingStateCounts {
108
+ return embeddingQueries.getEmbeddingStateCounts(this.db, config);
109
+ }
110
+
98
111
  exportAll(): TrapExportRecord[] {
99
112
  return queries.exportTraps(this.db);
100
113
  }
101
114
 
115
+ exportProjectTrapsByPath(projectPath: string): TrapExportRecord[] {
116
+ return queries.exportProjectTrapsByPath(this.db, projectPath);
117
+ }
118
+
119
+ insertTrapRecord(record: TrapRecordInsert): number {
120
+ return queries.insertTrapRecord(this.db, record);
121
+ }
122
+
123
+ updateTrapSupersedesId(id: number, supersedesId: number): boolean {
124
+ return queries.updateTrapSupersedesId(this.db, id, supersedesId);
125
+ }
126
+
127
+ deleteTrapsByIds(ids: number[]): number {
128
+ return queries.deleteTrapsByIds(this.db, ids);
129
+ }
130
+
131
+ countProjectTrapsByPath(projectPath: string): number {
132
+ return queries.countProjectTrapsByPath(this.db, projectPath);
133
+ }
134
+
135
+ transaction<T>(callback: () => T): T {
136
+ return this.db.transaction(callback)();
137
+ }
138
+
102
139
  getEmbedding(trapId: number): StoredEmbedding | null {
103
140
  return embeddingQueries.getEmbedding(this.db, trapId);
104
141
  }
package/src/db/schema.ts CHANGED
@@ -140,6 +140,41 @@ function applyMigrations(db: Database, from: number): void {
140
140
 
141
141
  db.prepare("UPDATE schema_version SET version = ?").run(4);
142
142
  }
143
+
144
+ if (from < 5) {
145
+ if (!columnExists(db, "traps", "path_globs")) {
146
+ db.exec("ALTER TABLE traps ADD COLUMN path_globs TEXT NOT NULL DEFAULT '[]'");
147
+ }
148
+ if (!columnExists(db, "traps", "module")) {
149
+ db.exec("ALTER TABLE traps ADD COLUMN module TEXT");
150
+ }
151
+ if (!columnExists(db, "traps", "owner")) {
152
+ db.exec("ALTER TABLE traps ADD COLUMN owner TEXT");
153
+ }
154
+
155
+ const rows = db
156
+ .query("SELECT id, title, context, mistake, fix, tags, before_code, after_code, path_globs, module, owner FROM traps")
157
+ .all() as {
158
+ id: number;
159
+ title: string;
160
+ context: string;
161
+ mistake: string;
162
+ fix: string;
163
+ tags: string;
164
+ before_code: string | null;
165
+ after_code: string | null;
166
+ path_globs: string;
167
+ module: string | null;
168
+ owner: string | null;
169
+ }[];
170
+
171
+ const update = db.prepare("UPDATE traps SET search_text = ? WHERE id = ?");
172
+ for (const row of rows) {
173
+ update.run(buildTrapSearchText(row), row.id);
174
+ }
175
+
176
+ db.prepare("UPDATE schema_version SET version = ?").run(5);
177
+ }
143
178
  }
144
179
 
145
180
  function columnExists(db: Database, table: string, column: string): boolean {
@@ -30,6 +30,9 @@ export interface Trap {
30
30
  valid_from: string;
31
31
  valid_until: string | null;
32
32
  project_path: string | null;
33
+ path_globs: string;
34
+ module: string | null;
35
+ owner: string | null;
33
36
  hit_count: number;
34
37
  created_at: string;
35
38
  updated_at: string;
@@ -47,6 +50,9 @@ export interface TrapInput {
47
50
  after_code?: string;
48
51
  severity?: string;
49
52
  project_path?: string | null;
53
+ path_globs?: string[];
54
+ module?: string | null;
55
+ owner?: string | null;
50
56
  }
51
57
 
52
58
  export interface TrapSearchResult {
@@ -55,6 +61,13 @@ export interface TrapSearchResult {
55
61
  sources?: ("fts" | "semantic")[];
56
62
  score?: number;
57
63
  diagnostics?: { code: string; message: string }[];
64
+ ranking_signals?: RankingSignal[];
65
+ }
66
+
67
+ export interface RankingSignal {
68
+ code: string;
69
+ weight: number;
70
+ detail?: string;
58
71
  }
59
72
 
60
73
  export interface TrapActionCard {
@@ -67,6 +80,7 @@ export interface TrapActionCard {
67
80
  severity: string;
68
81
  score: number | null;
69
82
  sources: ("fts" | "semantic")[];
83
+ ranking_signals?: RankingSignal[];
70
84
  next_action: {
71
85
  details_tool: "get_trap";
72
86
  details_args: {
@@ -101,7 +115,8 @@ export interface TrapExportEvidence extends Omit<TrapEvidence, "related_files">
101
115
  related_files: string[];
102
116
  }
103
117
 
104
- export interface TrapExportRecord extends Trap {
118
+ export interface TrapExportRecord extends Omit<Trap, "path_globs"> {
119
+ path_globs: string[];
105
120
  evidence: TrapExportEvidence[];
106
121
  }
107
122
 
@@ -110,11 +125,12 @@ export type TrapImportEvidence = Partial<Omit<TrapEvidence, "related_files">> &
110
125
  related_files?: string[] | string | null;
111
126
  };
112
127
 
113
- export type TrapImportRecord = Omit<TrapInput, "tags" | "before_code" | "after_code" | "project_path"> & {
128
+ export type TrapImportRecord = Omit<TrapInput, "tags" | "before_code" | "after_code" | "project_path" | "path_globs"> & {
114
129
  tags?: string[] | string | null;
115
130
  before_code?: string | null;
116
131
  after_code?: string | null;
117
132
  project_path?: string | null;
133
+ path_globs?: string[] | string | null;
118
134
  evidence?: TrapImportEvidence[];
119
135
  };
120
136
 
@@ -168,6 +184,9 @@ export const TRAP_UPDATE_FIELDS = [
168
184
  "before_code",
169
185
  "after_code",
170
186
  "severity",
187
+ "path_globs",
188
+ "module",
189
+ "owner",
171
190
  ] as const;
172
191
 
173
192
  export const TRAP_INPUT_SCHEMA_PROPERTIES = {
@@ -185,6 +204,13 @@ export const TRAP_INPUT_SCHEMA_PROPERTIES = {
185
204
  before_code: { type: "string", description: "Example of wrong code (optional)" },
186
205
  after_code: { type: "string", description: "Example of correct code (optional)" },
187
206
  severity: { type: "string", enum: [...SEVERITIES] as string[], description: "How severe is this pitfall?" },
207
+ path_globs: {
208
+ type: "array",
209
+ items: { type: "string" },
210
+ description: "Optional file globs where this trap applies, for example src/db/**",
211
+ },
212
+ module: { type: "string", description: "Optional module or subsystem where this trap applies" },
213
+ owner: { type: "string", description: "Optional team or owner for this trap" },
188
214
  } satisfies Record<keyof Omit<TrapInput, "project_path">, JsonSchemaProperty>;
189
215
 
190
216
  export function trapInputSchema() {
@@ -270,6 +296,9 @@ export function buildTrapInput(args: Record<string, any>): TrapInput {
270
296
  before_code: args.before_code,
271
297
  after_code: args.after_code,
272
298
  severity: args.severity ?? DEFAULT_SEVERITY,
299
+ path_globs: Array.isArray(args.path_globs) ? args.path_globs.map(String) : args.path_globs,
300
+ module: args.module,
301
+ owner: args.owner,
273
302
  };
274
303
  }
275
304
 
package/src/index.ts CHANGED
@@ -46,6 +46,9 @@ function showHelp(): void {
46
46
  console.log(" export Export traps as JSON");
47
47
  console.log(" import <file.json> Import traps from JSON");
48
48
  console.log(" stats Show statistics");
49
+ console.log(" doctor Diagnose scope, database, and embedding health");
50
+ console.log(" repair-scope Move mis-scoped project traps into the current project");
51
+ console.log(" migrate-project Move project traps between initialized projects");
49
52
  console.log(" serve Start MCP server (for Claude Code)");
50
53
  console.log("");
51
54
  console.log("Flags:");
@@ -53,6 +56,15 @@ function showHelp(): void {
53
56
  console.log(" --category <name> Filter by category");
54
57
  console.log(" --mode fts|semantic|hybrid Search mode");
55
58
  console.log(" --status active|superseded|archived|all Lifecycle filter");
59
+ console.log(" --path <file> Filter/boost traps scoped to a file path");
60
+ console.log(" --module <name> Filter/boost traps scoped to a module");
61
+ console.log(" --owner <name> Filter/boost traps scoped to an owner/team");
62
+ console.log(" --no-rerank Disable query-aware search reranking");
63
+ console.log(" --ranking-signals Include search ranking diagnostics in JSON cards");
56
64
  console.log(" --batch-size <n> Embedding generation batch size");
57
- console.log(" --json '{}' JSON input for add/edit");
65
+ console.log(" --json JSON output for search/show/list/stats/doctor; JSON input for add/edit");
66
+ console.log(" --output-json JSON output for add/edit when --json is used as input");
67
+ console.log(" --from-project-path <path> Source project path for scope repair/migration");
68
+ console.log(" --to-project-path <path> Destination project path for scope repair/migration");
69
+ console.log(" --dry-run|--apply Preview or apply scope repair/migration");
58
70
  }
@@ -0,0 +1,102 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { CODETRAP_DIR, SCOPES, SEARCH_MODES, type Scope, type SearchMode } from "./constants";
5
+
6
+ export type CodetrapConfig = {
7
+ search?: {
8
+ mode?: SearchMode;
9
+ limit?: number;
10
+ scope?: Scope;
11
+ rerank?: boolean;
12
+ };
13
+ };
14
+
15
+ export type SearchDefaults = {
16
+ mode: SearchMode;
17
+ limit: number;
18
+ scope?: Scope;
19
+ rerank: boolean;
20
+ };
21
+
22
+ const BUILT_IN_SEARCH_DEFAULTS: SearchDefaults = {
23
+ mode: "hybrid",
24
+ limit: 20,
25
+ rerank: true,
26
+ };
27
+
28
+ export function loadCodetrapConfig(home = homedir()): CodetrapConfig {
29
+ const path = join(home, CODETRAP_DIR, "config.json");
30
+ if (!existsSync(path)) return {};
31
+
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
34
+ return normalizeConfig(parsed);
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ throw new Error(`Invalid codetrap config at ${path}: ${message}`);
38
+ }
39
+ }
40
+
41
+ export function searchDefaultsFromConfig(config = loadCodetrapConfig(), env = process.env): SearchDefaults {
42
+ return {
43
+ mode: config.search?.mode ?? parseSearchModeEnv(env.CODETRAP_SEARCH_MODE) ?? BUILT_IN_SEARCH_DEFAULTS.mode,
44
+ limit: config.search?.limit ?? parsePositiveIntEnv(env.CODETRAP_SEARCH_LIMIT) ?? BUILT_IN_SEARCH_DEFAULTS.limit,
45
+ scope: config.search?.scope ?? parseScopeEnv(env.CODETRAP_SEARCH_SCOPE),
46
+ rerank: config.search?.rerank ?? parseBooleanEnv(env.CODETRAP_RERANK) ?? BUILT_IN_SEARCH_DEFAULTS.rerank,
47
+ };
48
+ }
49
+
50
+ function normalizeConfig(value: unknown): CodetrapConfig {
51
+ if (!isRecord(value)) return {};
52
+ const search = isRecord(value.search) ? normalizeSearchConfig(value.search) : undefined;
53
+ return search ? { search } : {};
54
+ }
55
+
56
+ function normalizeSearchConfig(value: Record<string, unknown>): CodetrapConfig["search"] {
57
+ const out: NonNullable<CodetrapConfig["search"]> = {};
58
+ if (typeof value.mode === "string") out.mode = parseSearchMode(value.mode);
59
+ if (typeof value.limit === "number") out.limit = parsePositiveInt(value.limit, "search.limit");
60
+ if (typeof value.scope === "string") out.scope = parseScope(value.scope);
61
+ if (typeof value.rerank === "boolean") out.rerank = value.rerank;
62
+ return out;
63
+ }
64
+
65
+ function parseSearchModeEnv(value?: string): SearchMode | undefined {
66
+ return value ? parseSearchMode(value) : undefined;
67
+ }
68
+
69
+ function parseScopeEnv(value?: string): Scope | undefined {
70
+ return value ? parseScope(value) : undefined;
71
+ }
72
+
73
+ function parsePositiveIntEnv(value?: string): number | undefined {
74
+ if (!value) return undefined;
75
+ return parsePositiveInt(Number.parseInt(value, 10), "CODETRAP_SEARCH_LIMIT");
76
+ }
77
+
78
+ function parseBooleanEnv(value?: string): boolean | undefined {
79
+ if (!value) return undefined;
80
+ if (["1", "true", "yes", "on"].includes(value.toLowerCase())) return true;
81
+ if (["0", "false", "no", "off"].includes(value.toLowerCase())) return false;
82
+ throw new Error(`Invalid CODETRAP_RERANK: ${value}. Expected true or false.`);
83
+ }
84
+
85
+ function parseSearchMode(value: string): SearchMode {
86
+ if ((SEARCH_MODES as readonly string[]).includes(value)) return value as SearchMode;
87
+ throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
88
+ }
89
+
90
+ function parseScope(value: string): Scope {
91
+ if ((SCOPES as readonly string[]).includes(value)) return value as Scope;
92
+ throw new Error(`Invalid scope: ${value}. Expected one of: ${SCOPES.join(", ")}`);
93
+ }
94
+
95
+ function parsePositiveInt(value: number, label: string): number {
96
+ if (Number.isInteger(value) && value > 0) return value;
97
+ throw new Error(`Invalid ${label}: expected a positive integer.`);
98
+ }
99
+
100
+ function isRecord(value: unknown): value is Record<string, unknown> {
101
+ return typeof value === "object" && value !== null && !Array.isArray(value);
102
+ }
@@ -49,7 +49,7 @@ export const DEFAULT_TRAP_STATUS: TrapStatus = "active";
49
49
 
50
50
  // Increment this when schema changes in a breaking way.
51
51
  // Migrations are stored in src/db/migrations.ts
52
- export const SCHEMA_VERSION = 4;
52
+ export const SCHEMA_VERSION = 5;
53
53
 
54
54
  // Directory and file names
55
55
  export const CODETRAP_DIR = ".codetrap";
@@ -0,0 +1,76 @@
1
+ import type { TrapStore } from "./store";
2
+ import type { TrapOperations } from "./trap-operations";
3
+ import type { EmbeddingStatsResult, HybridFallbackReason } from "./embedding-health";
4
+ import { createScopeContext } from "./scope-context";
5
+ import { hybridFallbackReason } from "./embedding-health";
6
+
7
+ export type DoctorReport = {
8
+ cwd: string;
9
+ project_root: string | null;
10
+ project_db: string | null;
11
+ global_db: string;
12
+ traps: {
13
+ project: number | null;
14
+ global: number;
15
+ };
16
+ embeddings: EmbeddingStatsResult;
17
+ hybrid_search: {
18
+ semantic_available: boolean;
19
+ fallback_reason: HybridFallbackReason | null;
20
+ };
21
+ mcp_hint: string;
22
+ };
23
+
24
+ export function buildDoctorReport(
25
+ store: TrapStore,
26
+ operations: TrapOperations,
27
+ cwd = process.cwd()
28
+ ): DoctorReport {
29
+ const scope = createScopeContext(cwd);
30
+ const stats = operations.getStats();
31
+ const embeddings = operations.getEmbeddingStats();
32
+ const semanticAvailable = store.hasEmbeddingProvider();
33
+
34
+ return {
35
+ ...scope,
36
+ traps: {
37
+ project: stats.project?.total ?? null,
38
+ global: stats.global.total,
39
+ },
40
+ embeddings,
41
+ hybrid_search: {
42
+ semantic_available: semanticAvailable,
43
+ fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
44
+ },
45
+ mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
46
+ };
47
+ }
48
+
49
+ export function formatDoctorText(report: DoctorReport): string {
50
+ return [
51
+ `cwd: ${report.cwd}`,
52
+ `project_root: ${report.project_root ?? "(none)"}`,
53
+ `project_db: ${report.project_db ?? "(none)"}`,
54
+ `global_db: ${report.global_db}`,
55
+ `project_traps: ${report.traps.project ?? "(none)"}`,
56
+ `global_traps: ${report.traps.global}`,
57
+ "Embeddings:",
58
+ formatEmbeddingStats("project", report.embeddings.project),
59
+ formatEmbeddingStats("global", report.embeddings.global),
60
+ "Hybrid search:",
61
+ ` semantic_available: ${report.hybrid_search.semantic_available ? "yes" : "no"}`,
62
+ ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
63
+ `mcp_hint: ${report.mcp_hint}`,
64
+ ].join("\n");
65
+ }
66
+
67
+ function formatEmbeddingStats(
68
+ label: string,
69
+ stats: EmbeddingStatsResult["global"] | null
70
+ ): string {
71
+ if (!stats) return ` ${label}: unavailable`;
72
+ const provider = stats.provider_available
73
+ ? `${stats.provider}/${stats.model}`
74
+ : "unavailable";
75
+ return ` ${label}: fresh=${stats.fresh}, stale=${stats.stale}, missing=${stats.missing}, total=${stats.total}, provider=${provider}`;
76
+ }