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.
- package/.agents/plugins/marketplace.json +20 -0
- package/README.md +121 -38
- package/docs/installation.md +18 -10
- package/package.json +4 -1
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +34 -0
- package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +25 -0
- package/plugins/codetrap-agent/hooks/pre-edit.example.sh +10 -0
- package/plugins/codetrap-agent/hooks.json +11 -0
- package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +19 -0
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +14 -0
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +25 -0
- package/scripts/release-preflight.ts +55 -0
- package/skills/codetrap-add/SKILL.md +4 -1
- package/skills/codetrap-check/SKILL.md +24 -4
- package/skills/codetrap-search/SKILL.md +32 -12
- package/src/commands/command-result.ts +29 -0
- package/src/commands/router.ts +6 -400
- package/src/commands/workflow.ts +466 -0
- package/src/db/embedding-queries.ts +33 -0
- package/src/db/queries.ts +119 -20
- package/src/db/repository.ts +39 -2
- package/src/db/schema.ts +35 -0
- package/src/domain/trap.ts +31 -2
- package/src/index.ts +13 -1
- package/src/lib/config.ts +102 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +76 -0
- package/src/lib/embedding-health.ts +49 -0
- package/src/lib/format.ts +5 -1
- package/src/lib/output-json.ts +116 -0
- package/src/lib/scope-context.ts +116 -0
- package/src/lib/scope-migration.ts +360 -0
- package/src/lib/scope.ts +6 -4
- package/src/lib/search-normalizer.ts +6 -0
- package/src/lib/search-policy.ts +276 -0
- package/src/lib/search-result-card.ts +1 -0
- package/src/lib/search-service.ts +36 -98
- package/src/lib/store.ts +96 -107
- package/src/lib/trap-archive.ts +9 -42
- package/src/lib/trap-codec.ts +113 -0
- package/src/lib/trap-json-fields.ts +12 -0
- package/src/lib/trap-mutation-result.ts +36 -0
- package/src/lib/trap-operations.ts +27 -6
- package/src/lib/trap-scope-match.ts +112 -0
- package/src/lib/trap-search-document.ts +8 -1
- package/src/lib/trap-transfer.ts +88 -0
- package/src/mcp/server.ts +75 -57
- 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 {
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/db/repository.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/domain/trap.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
}
|