codetrap 0.1.3 → 0.1.4

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
@@ -13,7 +13,11 @@ AI coding agents make the same mistakes repeatedly across sessions and projects.
13
13
  For detailed setup options, see [Installation](docs/installation.md). Maintainers can use the Chinese [Release Playbook](docs/release-playbook.zh-CN.md) when publishing updates.
14
14
 
15
15
  ```bash
16
- # Prerequisites: Bun >= 1.x (https://bun.sh)
16
+ # Prerequisites: Bun >= 1.x (https://bun.sh) for npm/source installs
17
+
18
+ # npm global install (recommended)
19
+ npm install -g codetrap
20
+ codetrap --help
17
21
 
18
22
  # Source install
19
23
  git clone <repo-url> && cd codetrap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codetrap",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Capture and retrieve coding pitfalls so AI doesn't repeat mistakes",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,6 @@ 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 { SEARCH_MODES, type SearchMode } from "../lib/constants";
6
5
  import {
7
6
  formatScopeMigrationText,
8
7
  runScopeMigration,
@@ -24,6 +23,13 @@ import {
24
23
  type CommandResult,
25
24
  } from "./command-result";
26
25
  import { mutationJsonPayload } from "../lib/trap-mutation-result";
26
+ import {
27
+ embedRequestFromArgs,
28
+ evidenceRequestFromArgs,
29
+ listRequestFromArgs,
30
+ searchRequestFromArgs,
31
+ statsRequestFromArgs,
32
+ } from "../lib/command-requests";
27
33
 
28
34
  type ParsedArgs = {
29
35
  opts: Record<string, string>;
@@ -141,21 +147,8 @@ async function cmdSearch(args: string[], operations: TrapOperations): Promise<Co
141
147
  }
142
148
 
143
149
  try {
144
- const mode = opts.mode ? parseSearchMode(opts.mode) : undefined;
145
150
  const defaults = searchDefaultsFromConfig();
146
- const cards = await operations.searchTrapCards({
147
- query,
148
- category: opts.category,
149
- scope: opts.scope ?? defaults.scope,
150
- limit: opts.limit ? parseOptionalInt(opts.limit) : defaults.limit,
151
- mode: mode ?? defaults.mode,
152
- status: opts.status,
153
- path: opts.path,
154
- module: opts.module,
155
- owner: opts.owner,
156
- rerank: opts["no-rerank"] !== undefined ? false : defaults.rerank,
157
- includeRankingSignals: opts["ranking-signals"] !== undefined,
158
- });
151
+ const cards = await operations.searchTrapCards(searchRequestFromArgs(query, opts, defaults));
159
152
  if (opts.json !== undefined) return jsonResult(toCliSearchJson(cards));
160
153
  return textResult(cards.length > 0 ? cards.map(formatTrapActionCard).join("\n\n") : "No traps found.");
161
154
  } catch (error) {
@@ -166,15 +159,7 @@ async function cmdSearch(args: string[], operations: TrapOperations): Promise<Co
166
159
  function cmdList(args: string[], operations: TrapOperations): CommandResult {
167
160
  const { opts } = parseArgs(args);
168
161
  try {
169
- const groups = operations.listTraps({
170
- category: opts.category,
171
- scope: opts.scope,
172
- status: opts.status,
173
- path: opts.path,
174
- module: opts.module,
175
- owner: opts.owner,
176
- limit: parseOptionalInt(opts.limit, 50),
177
- });
162
+ const groups = operations.listTraps(listRequestFromArgs(opts));
178
163
  if (opts.json !== undefined) return jsonResult(toListJson(groups));
179
164
 
180
165
  const lines = groups.flatMap((group) =>
@@ -246,13 +231,7 @@ function cmdAddTrapEvidence(args: string[], operations: TrapOperations): Command
246
231
  if (typeof id !== "number") return id;
247
232
 
248
233
  try {
249
- const input = opts.json ? JSON.parse(opts.json) : {
250
- source_type: opts.source_type ?? opts["source-type"],
251
- source_ref: opts.source_ref ?? opts["source-ref"],
252
- observed_at: opts.observed_at ?? opts["observed-at"],
253
- related_files: parseCsv(opts.related_files ?? opts["related-files"]),
254
- note: opts.note,
255
- };
234
+ const input = opts.json ? JSON.parse(opts.json) : evidenceRequestFromArgs(opts);
256
235
  const result = operations.addTrapEvidence(id, input, opts.scope);
257
236
  if (opts["output-json"] !== undefined) {
258
237
  return mutationJsonResult({ id, ...result }, `Trap #${id} not found.`);
@@ -331,8 +310,9 @@ function cmdImport(args: string[], operations: TrapOperations): CommandResult {
331
310
 
332
311
  function cmdStats(args: string[], operations: TrapOperations): CommandResult {
333
312
  const { opts } = parseArgs(args);
334
- const stats = operations.getStats();
335
- const embeddingStats = operations.getEmbeddingStats();
313
+ const request = statsRequestFromArgs(opts);
314
+ const stats = operations.getStats(request.scope);
315
+ const embeddingStats = operations.getEmbeddingStats(request.scope);
336
316
  return opts.json !== undefined
337
317
  ? jsonResult(toStatsJson(stats, embeddingStats))
338
318
  : textResult(formatStatsText(stats));
@@ -378,13 +358,7 @@ function cmdScopeMigration(
378
358
  async function cmdEmbed(args: string[], store: TrapStore): Promise<CommandResult> {
379
359
  const { opts } = parseArgs(args);
380
360
  try {
381
- const result = await store.ensureEmbeddings({
382
- scope: opts.scope,
383
- category: opts.category,
384
- limit: opts.limit ? parseOptionalInt(opts.limit) : undefined,
385
- force: opts.force === "true",
386
- batchSize: opts["batch-size"] ? parseOptionalInt(opts["batch-size"]) : undefined,
387
- });
361
+ const result = await store.ensureEmbeddings(embedRequestFromArgs(opts));
388
362
  return textResult([
389
363
  ...result.scopes.map((scoped) =>
390
364
  `[${scoped.scope}] embeddings generated: ${scoped.generated}, skipped: ${scoped.skipped}, batches: ${scoped.batches}`
@@ -401,7 +375,9 @@ function formatStatsText(stats: ReturnType<TrapOperations["getStats"]>): string
401
375
  if (stats.project) {
402
376
  sections.push("── Project ──", formatStatsBlock(stats.project));
403
377
  }
404
- sections.push("── Global ──", formatStatsBlock(stats.global));
378
+ if (stats.global) {
379
+ sections.push("── Global ──", formatStatsBlock(stats.global));
380
+ }
405
381
  return sections.join("\n");
406
382
  }
407
383
 
@@ -415,41 +391,18 @@ function formatStatsBlock(stats: { total: number; byCategory: Record<string, num
415
391
  ].join("\n");
416
392
  }
417
393
 
418
- function parseSearchMode(mode: string): SearchMode {
419
- if ((SEARCH_MODES as readonly string[]).includes(mode)) return mode as SearchMode;
420
- throw new Error(`Invalid search mode: ${mode}. Expected one of: ${SEARCH_MODES.join(", ")}`);
421
- }
422
-
423
394
  function parseId(value: string | undefined, usage: string): number | CommandResult {
424
395
  if (value === undefined) return errorResult(usage);
425
396
  const id = Number.parseInt(value, 10);
426
397
  return Number.isNaN(id) ? errorResult("Error: id must be a number") : id;
427
398
  }
428
399
 
429
- function parseOptionalInt(value: string | undefined, fallback?: number): number {
430
- if (value === undefined) {
431
- if (fallback === undefined) throw new Error("Missing numeric value.");
432
- return fallback;
433
- }
434
- const parsed = Number.parseInt(value, 10);
435
- if (Number.isNaN(parsed)) throw new Error(`Invalid number: ${value}`);
436
- return parsed;
437
- }
438
-
439
400
  function readQuery(positionals: string[]): string {
440
401
  if (positionals.length > 0) return positionals.join(" ").trim();
441
402
  if (process.stdin.isTTY) return "";
442
403
  return readFileSync(0, "utf-8").trim();
443
404
  }
444
405
 
445
- function parseCsv(value?: string): string[] | undefined {
446
- if (!value) return undefined;
447
- return value
448
- .split(",")
449
- .map((item) => item.trim())
450
- .filter(Boolean);
451
- }
452
-
453
406
  function mutationJsonResult<T extends Record<string, unknown> & { success: boolean }>(
454
407
  value: T,
455
408
  error: string
package/src/db/queries.ts CHANGED
@@ -180,6 +180,10 @@ export function listTrapEvidence(db: Database, trapId: number): TrapEvidence[] {
180
180
  }
181
181
 
182
182
  export function archiveTrap(db: Database, id: number): boolean {
183
+ return markTrapArchived(db, id);
184
+ }
185
+
186
+ export function markTrapArchived(db: Database, id: number): boolean {
183
187
  const result = db
184
188
  .prepare(
185
189
  `
@@ -208,33 +212,43 @@ export function supersedeTrap(
208
212
 
209
213
  const key = stateKey ?? oldTrap.state_key ?? newTrap.state_key ?? `trap:${id}`;
210
214
  const tx = db.transaction(() => {
211
- db.prepare(
212
- `
213
- UPDATE traps
214
- SET status = 'superseded',
215
- state_key = ?,
216
- valid_until = COALESCE(valid_until, datetime('now')),
217
- updated_at = datetime('now')
218
- WHERE id = ?
219
- `
220
- ).run(key, id);
221
- db.prepare(
222
- `
223
- UPDATE traps
224
- SET status = 'active',
225
- state_key = ?,
226
- supersedes_id = ?,
227
- valid_from = COALESCE(valid_from, datetime('now')),
228
- valid_until = NULL,
229
- updated_at = datetime('now')
230
- WHERE id = ?
231
- `
232
- ).run(key, id, supersededById);
215
+ markTrapSuperseded(db, id, key);
216
+ markTrapSuperseding(db, supersededById, id, key);
233
217
  });
234
218
  tx();
235
219
  return true;
236
220
  }
237
221
 
222
+ export function markTrapSuperseded(db: Database, id: number, stateKey: string): boolean {
223
+ const result = db.prepare(supersedeTrapSql).run(stateKey, id);
224
+ return result.changes > 0;
225
+ }
226
+
227
+ export function markTrapSuperseding(db: Database, id: number, supersedesId: number, stateKey: string): boolean {
228
+ const result = db.prepare(supersedingTrapSql).run(stateKey, supersedesId, id);
229
+ return result.changes > 0;
230
+ }
231
+
232
+ const supersedeTrapSql = `
233
+ UPDATE traps
234
+ SET status = 'superseded',
235
+ state_key = ?,
236
+ valid_until = COALESCE(valid_until, datetime('now')),
237
+ updated_at = datetime('now')
238
+ WHERE id = ?
239
+ `;
240
+
241
+ const supersedingTrapSql = `
242
+ UPDATE traps
243
+ SET status = 'active',
244
+ state_key = ?,
245
+ supersedes_id = ?,
246
+ valid_from = COALESCE(valid_from, datetime('now')),
247
+ valid_until = NULL,
248
+ updated_at = datetime('now')
249
+ WHERE id = ?
250
+ `;
251
+
238
252
  export function incrementHitCount(db: Database, id: number): void {
239
253
  db.prepare("UPDATE traps SET hit_count = hit_count + 1, updated_at = datetime('now') WHERE id = ?").run(id);
240
254
  }
@@ -245,18 +259,22 @@ export function getTopTraps(db: Database, scope: string, limit = 20): Trap[] {
245
259
  .all(scope, limit) as Trap[];
246
260
  }
247
261
 
248
- export function getStats(db: Database): {
262
+ export function getStats(db: Database, opts: { scope?: string; status?: TrapStatusFilter } = {}): {
249
263
  total: number;
250
264
  byCategory: Record<string, number>;
251
265
  bySeverity: Record<string, number>;
252
266
  } {
253
- const total = (db.query("SELECT COUNT(*) as c FROM traps").get() as { c: number }).c;
267
+ const conditions: string[] = [];
268
+ const params: SQLQueryBindings[] = [];
269
+ addTrapFilters(conditions, params, opts);
270
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
271
+ const total = (db.query(`SELECT COUNT(*) as c FROM traps ${where}`).get(...params) as { c: number }).c;
254
272
  const byCategory = db
255
- .query("SELECT category, COUNT(*) as c FROM traps GROUP BY category")
256
- .all() as { category: string; c: number }[];
273
+ .query(`SELECT category, COUNT(*) as c FROM traps ${where} GROUP BY category`)
274
+ .all(...params) as { category: string; c: number }[];
257
275
  const bySeverity = db
258
- .query("SELECT severity, COUNT(*) as c FROM traps GROUP BY severity")
259
- .all() as { severity: string; c: number }[];
276
+ .query(`SELECT severity, COUNT(*) as c FROM traps ${where} GROUP BY severity`)
277
+ .all(...params) as { severity: string; c: number }[];
260
278
 
261
279
  return {
262
280
  total,
@@ -8,7 +8,6 @@ import type {
8
8
  TrapSearchResult,
9
9
  TrapUpdate,
10
10
  } from "../domain/trap";
11
- import * as embeddingQueries from "./embedding-queries";
12
11
  import { SearchService, type SearchOptions } from "../lib/search-service";
13
12
  import {
14
13
  type EmbeddingConfig,
@@ -20,20 +19,24 @@ import { passageFieldsChanged } from "../lib/trap-search-document";
20
19
  import * as queries from "./queries";
21
20
  import type { TrapStatus } from "../lib/constants";
22
21
  import { TrapSearchPolicy } from "../lib/search-policy";
22
+ import { DatabaseEmbeddingIndex } from "../lib/embedding-index";
23
+ import { archiveTrapLifecycle, supersedeTrapLifecycle } from "../lib/trap-lifecycle";
23
24
 
24
25
  export type TrapStats = ReturnType<typeof queries.getStats>;
25
- export type EmbeddingStateCounts = ReturnType<typeof embeddingQueries.getEmbeddingStateCounts>;
26
+ export type EmbeddingStateCounts = ReturnType<DatabaseEmbeddingIndex["stateCounts"]>;
26
27
  export type TrapRecordInsert = queries.TrapRecordInsert;
27
28
 
28
29
  export class TrapRepository {
29
30
  private readonly searchService: SearchService;
30
31
  private readonly searchPolicy = new TrapSearchPolicy();
32
+ private readonly embeddingIndex: DatabaseEmbeddingIndex;
31
33
 
32
34
  constructor(
33
35
  private readonly db: Database,
34
36
  private readonly embedder?: EmbeddingProvider
35
37
  ) {
36
38
  this.searchService = new SearchService(db, embedder);
39
+ this.embeddingIndex = new DatabaseEmbeddingIndex(db);
37
40
  }
38
41
 
39
42
  add(input: TrapInput): number {
@@ -67,10 +70,16 @@ export class TrapRepository {
67
70
  .slice(0, limit);
68
71
  }
69
72
 
73
+ listMisScoped(expectedScope: string): Trap[] {
74
+ return queries
75
+ .listTraps(this.db, { status: "all", limit: 100000 })
76
+ .filter((trap) => trap.scope !== expectedScope);
77
+ }
78
+
70
79
  update(id: number, input: TrapUpdate): boolean {
71
80
  const success = queries.updateTrap(this.db, id, input);
72
81
  if (success && passageFieldsChanged(input)) {
73
- embeddingQueries.deleteEmbedding(this.db, id);
82
+ this.embeddingIndex.delete(id);
74
83
  }
75
84
  return success;
76
85
  }
@@ -85,11 +94,11 @@ export class TrapRepository {
85
94
  }
86
95
 
87
96
  archive(id: number): boolean {
88
- return queries.archiveTrap(this.db, id);
97
+ return archiveTrapLifecycle(this.lifecycleAdapter(), id);
89
98
  }
90
99
 
91
100
  supersede(id: number, supersededById: number, stateKey?: string): boolean {
92
- return queries.supersedeTrap(this.db, id, supersededById, stateKey);
101
+ return supersedeTrapLifecycle(this.lifecycleAdapter(), id, supersededById, stateKey);
93
102
  }
94
103
 
95
104
  hit(id: number): void {
@@ -100,12 +109,12 @@ export class TrapRepository {
100
109
  return queries.getTopTraps(this.db, scope, limit);
101
110
  }
102
111
 
103
- stats(): TrapStats {
104
- return queries.getStats(this.db);
112
+ stats(opts: { scope?: string; status?: TrapStatus | "all" } = {}): TrapStats {
113
+ return queries.getStats(this.db, opts);
105
114
  }
106
115
 
107
- embeddingStats(config: EmbeddingConfig | null): EmbeddingStateCounts {
108
- return embeddingQueries.getEmbeddingStateCounts(this.db, config);
116
+ embeddingStats(config: EmbeddingConfig | null, opts: { scope?: string; status?: TrapStatus | "all" } = {}): EmbeddingStateCounts {
117
+ return this.embeddingIndex.stateCounts(config, opts);
109
118
  }
110
119
 
111
120
  exportAll(): TrapExportRecord[] {
@@ -137,22 +146,22 @@ export class TrapRepository {
137
146
  }
138
147
 
139
148
  getEmbedding(trapId: number): StoredEmbedding | null {
140
- return embeddingQueries.getEmbedding(this.db, trapId);
149
+ return this.embeddingIndex.get(trapId);
141
150
  }
142
151
 
143
152
  upsertEmbedding(record: StoredEmbedding): void {
144
- embeddingQueries.upsertEmbedding(this.db, record);
153
+ this.embeddingIndex.save(record);
145
154
  }
146
155
 
147
156
  deleteEmbedding(trapId: number): void {
148
- embeddingQueries.deleteEmbedding(this.db, trapId);
157
+ this.embeddingIndex.delete(trapId);
149
158
  }
150
159
 
151
160
  getTrapsNeedingEmbeddings(
152
161
  config: EmbeddingConfig,
153
162
  opts: { scope?: string; category?: string; status?: TrapStatus | "all"; force?: boolean; limit?: number } = {}
154
163
  ): Trap[] {
155
- return embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, opts);
164
+ return this.embeddingIndex.trapsNeedingEmbeddings(config, opts);
156
165
  }
157
166
 
158
167
  async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
@@ -166,13 +175,24 @@ export class TrapRepository {
166
175
 
167
176
  return runEmbeddingJob(
168
177
  {
169
- countEmbeddable: (countOpts) => embeddingQueries.countEmbeddableTraps(this.db, countOpts),
178
+ countEmbeddable: (countOpts) => this.embeddingIndex.countEmbeddable(countOpts),
170
179
  trapsNeedingEmbeddings: (config, jobOpts) =>
171
- embeddingQueries.getTrapsNeedingEmbeddings(this.db, config, jobOpts),
172
- saveEmbedding: (record) => embeddingQueries.upsertEmbedding(this.db, record),
180
+ this.embeddingIndex.trapsNeedingEmbeddings(config, jobOpts),
181
+ saveEmbedding: (record) => this.embeddingIndex.save(record),
173
182
  },
174
183
  this.embedder,
175
184
  opts
176
185
  );
177
186
  }
187
+
188
+ private lifecycleAdapter() {
189
+ return {
190
+ get: (id: number) => queries.getTrap(this.db, id),
191
+ transaction: <T>(callback: () => T) => this.transaction(callback),
192
+ markArchived: (id: number) => queries.markTrapArchived(this.db, id),
193
+ markSuperseded: (id: number, stateKey: string) => queries.markTrapSuperseded(this.db, id, stateKey),
194
+ markSuperseding: (id: number, supersedesId: number, stateKey: string) =>
195
+ queries.markTrapSuperseding(this.db, id, supersedesId, stateKey),
196
+ };
197
+ }
178
198
  }
@@ -60,10 +60,15 @@ export interface TrapSearchResult {
60
60
  rank: number;
61
61
  sources?: ("fts" | "semantic")[];
62
62
  score?: number;
63
- diagnostics?: { code: string; message: string }[];
63
+ diagnostics?: TrapSearchDiagnostic[];
64
64
  ranking_signals?: RankingSignal[];
65
65
  }
66
66
 
67
+ export interface TrapSearchDiagnostic {
68
+ code: string;
69
+ message: string;
70
+ }
71
+
67
72
  export interface RankingSignal {
68
73
  code: string;
69
74
  weight: number;
@@ -80,14 +85,8 @@ export interface TrapActionCard {
80
85
  severity: string;
81
86
  score: number | null;
82
87
  sources: ("fts" | "semantic")[];
88
+ diagnostics?: TrapSearchDiagnostic[];
83
89
  ranking_signals?: RankingSignal[];
84
- next_action: {
85
- details_tool: "get_trap";
86
- details_args: {
87
- id: number;
88
- scope: Scope;
89
- };
90
- };
91
90
  }
92
91
 
93
92
  export type TrapUpdate = Partial<Omit<TrapInput, "scope" | "project_path">>;
@@ -0,0 +1,133 @@
1
+ import { SEARCH_MODES, type SearchMode } from "./constants";
2
+ import type { SearchDefaults } from "./config";
3
+ import type { SearchTrapsArgs, ListTrapsArgs } from "./trap-operations";
4
+
5
+ type RawArgs = Record<string, unknown>;
6
+
7
+ export type EmbedRequest = {
8
+ scope?: string;
9
+ category?: string;
10
+ limit?: number;
11
+ force?: boolean;
12
+ batchSize?: number;
13
+ };
14
+
15
+ export type StatsRequest = {
16
+ scope?: string;
17
+ };
18
+
19
+ export function searchRequestFromArgs(query: string, args: RawArgs, defaults: SearchDefaults): SearchTrapsArgs {
20
+ return {
21
+ query,
22
+ category: stringOption(args, "category"),
23
+ scope: stringOption(args, "scope") ?? defaults.scope,
24
+ limit: intOption(args, "limit", defaults.limit),
25
+ mode: searchModeOption(args, "mode") ?? defaults.mode,
26
+ status: stringOption(args, "status"),
27
+ path: stringOption(args, "path"),
28
+ module: stringOption(args, "module"),
29
+ owner: stringOption(args, "owner"),
30
+ rerank: flagPresent(args, "no-rerank") ? false : booleanOption(args, "rerank") ?? defaults.rerank,
31
+ includeRankingSignals: booleanOption(args, "ranking_signals", "ranking-signals") ?? false,
32
+ };
33
+ }
34
+
35
+ export function listRequestFromArgs(args: RawArgs): ListTrapsArgs {
36
+ return {
37
+ category: stringOption(args, "category"),
38
+ scope: stringOption(args, "scope"),
39
+ status: stringOption(args, "status"),
40
+ path: stringOption(args, "path"),
41
+ module: stringOption(args, "module"),
42
+ owner: stringOption(args, "owner"),
43
+ limit: intOption(args, "limit", 50),
44
+ };
45
+ }
46
+
47
+ export function statsRequestFromArgs(args: RawArgs): StatsRequest {
48
+ return {
49
+ scope: stringOption(args, "scope"),
50
+ };
51
+ }
52
+
53
+ export function embedRequestFromArgs(args: RawArgs): EmbedRequest {
54
+ return {
55
+ scope: stringOption(args, "scope"),
56
+ category: stringOption(args, "category"),
57
+ limit: optionalIntOption(args, "limit"),
58
+ force: booleanOption(args, "force") === true,
59
+ batchSize: optionalIntOption(args, "batch_size", "batch-size"),
60
+ };
61
+ }
62
+
63
+ export function evidenceRequestFromArgs(args: RawArgs): RawArgs {
64
+ return {
65
+ source_type: stringOption(args, "source_type", "source-type"),
66
+ source_ref: stringOption(args, "source_ref", "source-ref"),
67
+ observed_at: stringOption(args, "observed_at", "observed-at"),
68
+ related_files: csvOrArrayOption(args, "related_files", "related-files"),
69
+ note: stringOption(args, "note"),
70
+ };
71
+ }
72
+
73
+ function stringOption(args: RawArgs, ...keys: string[]): string | undefined {
74
+ for (const key of keys) {
75
+ const value = args[key];
76
+ if (typeof value === "string" && value.trim() !== "") return value;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function intOption(args: RawArgs, key: string, fallback: number): number {
82
+ return optionalIntOption(args, key) ?? fallback;
83
+ }
84
+
85
+ function optionalIntOption(args: RawArgs, ...keys: string[]): number | undefined {
86
+ for (const key of keys) {
87
+ const value = args[key];
88
+ if (value === undefined) continue;
89
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
90
+ if (Number.isInteger(parsed) && parsed > 0) return parsed;
91
+ throw new Error(`Invalid number: ${String(value)}`);
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function searchModeOption(args: RawArgs, key: string): SearchMode | undefined {
97
+ const value = stringOption(args, key);
98
+ if (!value) return undefined;
99
+ if ((SEARCH_MODES as readonly string[]).includes(value)) return value as SearchMode;
100
+ throw new Error(`Invalid search mode: ${value}. Expected one of: ${SEARCH_MODES.join(", ")}`);
101
+ }
102
+
103
+ function booleanOption(args: RawArgs, ...keys: string[]): boolean | undefined {
104
+ for (const key of keys) {
105
+ const value = args[key];
106
+ if (value === undefined) continue;
107
+ if (typeof value === "boolean") return value;
108
+ if (typeof value === "string") {
109
+ if (["true", "1", "yes", "on"].includes(value.toLowerCase())) return true;
110
+ if (["false", "0", "no", "off"].includes(value.toLowerCase())) return false;
111
+ }
112
+ throw new Error(`Invalid boolean: ${String(value)}`);
113
+ }
114
+ return undefined;
115
+ }
116
+
117
+ function flagPresent(args: RawArgs, key: string): boolean {
118
+ return args[key] !== undefined;
119
+ }
120
+
121
+ function csvOrArrayOption(args: RawArgs, ...keys: string[]): string[] | undefined {
122
+ for (const key of keys) {
123
+ const value = args[key];
124
+ if (Array.isArray(value)) return value.map(String);
125
+ if (typeof value === "string" && value.trim() !== "") {
126
+ return value
127
+ .split(",")
128
+ .map((item) => item.trim())
129
+ .filter(Boolean);
130
+ }
131
+ }
132
+ return undefined;
133
+ }
package/src/lib/doctor.ts CHANGED
@@ -18,6 +18,11 @@ export type DoctorReport = {
18
18
  semantic_available: boolean;
19
19
  fallback_reason: HybridFallbackReason | null;
20
20
  };
21
+ diagnostics: {
22
+ mis_scoped_traps: {
23
+ global_db_project_traps: ReturnType<TrapStore["diagnostics"]>["mis_scoped_traps"]["global_db_project_traps"];
24
+ };
25
+ };
21
26
  mcp_hint: string;
22
27
  };
23
28
 
@@ -35,13 +40,16 @@ export function buildDoctorReport(
35
40
  ...scope,
36
41
  traps: {
37
42
  project: stats.project?.total ?? null,
38
- global: stats.global.total,
43
+ global: stats.global?.total ?? 0,
39
44
  },
40
45
  embeddings,
41
46
  hybrid_search: {
42
47
  semantic_available: semanticAvailable,
43
48
  fallback_reason: hybridFallbackReason(semanticAvailable, embeddings),
44
49
  },
50
+ diagnostics: {
51
+ mis_scoped_traps: store.diagnostics().mis_scoped_traps,
52
+ },
45
53
  mcp_hint: "Pass cwd in MCP tool calls, or restart codetrap serve after changing projects.",
46
54
  };
47
55
  }
@@ -60,6 +68,8 @@ export function formatDoctorText(report: DoctorReport): string {
60
68
  "Hybrid search:",
61
69
  ` semantic_available: ${report.hybrid_search.semantic_available ? "yes" : "no"}`,
62
70
  ` fallback_reason: ${report.hybrid_search.fallback_reason ?? "(none)"}`,
71
+ "Diagnostics:",
72
+ ` global_db_project_traps: ${report.diagnostics.mis_scoped_traps.global_db_project_traps.length}`,
63
73
  `mcp_hint: ${report.mcp_hint}`,
64
74
  ].join("\n");
65
75
  }
@@ -17,7 +17,7 @@ export type EmbeddingStateSummary = EmbeddingStateCounts & {
17
17
 
18
18
  export type EmbeddingStatsResult = {
19
19
  project: EmbeddingStateSummary | null;
20
- global: EmbeddingStateSummary;
20
+ global: EmbeddingStateSummary | null;
21
21
  };
22
22
 
23
23
  export type HybridFallbackReason = "semantic_unavailable" | "semantic_no_candidates";
@@ -42,8 +42,8 @@ export function hybridFallbackReason(
42
42
  ): HybridFallbackReason | null {
43
43
  if (!providerAvailable) return "semantic_unavailable";
44
44
 
45
- const fresh = (embeddings.project?.fresh ?? 0) + embeddings.global.fresh;
46
- const total = (embeddings.project?.total ?? 0) + embeddings.global.total;
45
+ const fresh = (embeddings.project?.fresh ?? 0) + (embeddings.global?.fresh ?? 0);
46
+ const total = (embeddings.project?.total ?? 0) + (embeddings.global?.total ?? 0);
47
47
  if (total > 0 && fresh === 0) return "semantic_no_candidates";
48
48
  return null;
49
49
  }