codetrap 0.1.7 → 0.1.8

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 (58) hide show
  1. package/README.md +151 -52
  2. package/docs/installation.md +113 -29
  3. package/package.json +4 -3
  4. package/plugins/codetrap-agent/.codex-plugin/plugin.json +1 -2
  5. package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +19 -17
  6. package/plugins/codetrap-agent/hooks.json +2 -2
  7. package/{skills → plugins/codetrap-agent/skills}/codetrap-add/SKILL.md +10 -4
  8. package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +14 -3
  9. package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +52 -9
  10. package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +74 -6
  11. package/{skills → plugins/codetrap-agent/skills}/codetrap-search/SKILL.md +6 -5
  12. package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +31 -5
  13. package/scripts/search-policy-sweep.ts +131 -0
  14. package/src/commands/workflow.ts +144 -68
  15. package/src/db/embedding-queries.ts +230 -48
  16. package/src/db/queries.ts +0 -25
  17. package/src/db/repository.ts +32 -21
  18. package/src/db/schema.ts +80 -0
  19. package/src/index.ts +28 -3
  20. package/src/lib/command-requests.ts +112 -1
  21. package/src/lib/config.ts +57 -7
  22. package/src/lib/constants.ts +1 -1
  23. package/src/lib/doctor.ts +42 -12
  24. package/src/lib/embedder.ts +118 -3
  25. package/src/lib/embedding-health.ts +3 -1
  26. package/src/lib/embedding-job.ts +3 -0
  27. package/src/lib/embedding-management.ts +65 -0
  28. package/src/lib/embedding-runtime.ts +177 -0
  29. package/src/lib/output-json.ts +0 -2
  30. package/src/lib/scope-context.ts +12 -6
  31. package/src/lib/scope-migration.ts +2 -1
  32. package/src/lib/scope.ts +0 -2
  33. package/src/lib/search-eval.ts +38 -18
  34. package/src/lib/search-policy-sweep.ts +563 -0
  35. package/src/lib/search-policy.ts +0 -4
  36. package/src/lib/search-service.ts +14 -15
  37. package/src/lib/session-candidate-document.ts +175 -0
  38. package/src/lib/session-candidate-scope.ts +6 -0
  39. package/src/lib/session-capture.ts +298 -32
  40. package/src/lib/session-codec.ts +1 -8
  41. package/src/lib/session-operations.ts +83 -60
  42. package/src/lib/session-review.ts +327 -0
  43. package/src/lib/session-store.ts +87 -73
  44. package/src/lib/store.ts +74 -10
  45. package/src/lib/string-list.ts +3 -0
  46. package/src/lib/text-lines.ts +7 -0
  47. package/src/lib/trap-search-document.ts +2 -1
  48. package/src/lib/value-types.ts +3 -0
  49. package/src/web/client-review.ts +171 -0
  50. package/src/web/client-script.ts +426 -51
  51. package/src/web/client-shell.ts +414 -0
  52. package/src/web/client-text.ts +112 -0
  53. package/src/web/project-registry.ts +3 -5
  54. package/src/web/server.ts +117 -103
  55. package/src/web/static.ts +364 -19
  56. package/skills/codetrap-capture-external/SKILL.md +0 -62
  57. package/skills/codetrap-check/SKILL.md +0 -69
  58. package/src/lib/embedding-index.ts +0 -53
@@ -120,10 +120,6 @@ export class TrapSearchPolicy {
120
120
  return trapMatchesApplicability(trap, filter);
121
121
  }
122
122
 
123
- filterTraps(traps: Trap[], filter: ApplicabilityFilter): Trap[] {
124
- return traps.filter((trap) => this.matchesTrap(trap, filter));
125
- }
126
-
127
123
  prepareRetrievedResults(
128
124
  results: TrapSearchResult[],
129
125
  source: SearchRetrievalSource,
@@ -1,20 +1,20 @@
1
1
  import type { Database } from "bun:sqlite";
2
+ import * as embeddingQueries from "../db/embedding-queries";
2
3
  import * as queries from "../db/queries";
3
4
  import type { TrapSearchResult } from "../domain/trap";
4
5
  import type { SearchMode, TrapStatus } from "./constants";
6
+ import { cosineSimilarity } from "./embedder";
5
7
  import {
6
- cosineSimilarity,
7
- EmbeddingProviderUnavailableError,
8
- embeddingConfig,
9
- type EmbeddingProvider,
10
- } from "./embedder";
8
+ embeddingRuntimeFrom,
9
+ type EmbeddingRuntime,
10
+ type EmbeddingRuntimeInput,
11
+ } from "./embedding-runtime";
11
12
  import {
12
13
  DEFAULT_RANKING_CONFIG,
13
14
  TrapSearchPolicy,
14
15
  type RankingConfig,
15
16
  type SearchRetrievalPlan,
16
17
  } from "./search-policy";
17
- import { DatabaseEmbeddingIndex } from "./embedding-index";
18
18
 
19
19
  export interface SearchOptions {
20
20
  category?: string;
@@ -33,15 +33,15 @@ const DEFAULT_LIMIT = 20;
33
33
 
34
34
  export class SearchService {
35
35
  private readonly policy: TrapSearchPolicy;
36
- private readonly embeddingIndex: DatabaseEmbeddingIndex;
36
+ private readonly embeddings: EmbeddingRuntime;
37
37
 
38
38
  constructor(
39
39
  private readonly db: Database,
40
- private readonly embedder?: EmbeddingProvider,
40
+ embeddings?: EmbeddingRuntimeInput,
41
41
  ranking: RankingConfig = DEFAULT_RANKING_CONFIG
42
42
  ) {
43
+ this.embeddings = embeddingRuntimeFrom(embeddings);
43
44
  this.policy = new TrapSearchPolicy(ranking);
44
- this.embeddingIndex = new DatabaseEmbeddingIndex(db);
45
45
  }
46
46
 
47
47
  async search(query: string, opts: SearchOptions = {}): Promise<TrapSearchResult[]> {
@@ -106,15 +106,14 @@ export class SearchService {
106
106
  query: string,
107
107
  plan: SearchRetrievalPlan
108
108
  ): Promise<TrapSearchResult[]> {
109
- if (!this.embedder) {
110
- throw new EmbeddingProviderUnavailableError();
111
- }
109
+ const provider = this.embeddings.requireProvider();
112
110
 
113
- const [queryEmbedding] = await this.embedder.embed([query], "retrieval.query");
111
+ const [queryEmbedding] = await provider.embed([query], "retrieval.query");
114
112
  if (!queryEmbedding) return [];
115
113
 
116
- const config = embeddingConfig(this.embedder);
117
- const candidates = this.embeddingIndex.freshEmbeddings(config, plan.semanticStorageFilter);
114
+ const config = this.embeddings.config();
115
+ if (!config) throw this.embeddings.unavailableError();
116
+ const candidates = embeddingQueries.getAllFreshEmbeddings(this.db, config, plan.semanticStorageFilter);
118
117
 
119
118
  const results = candidates
120
119
  .map(({ trap, embedding }) => {
@@ -0,0 +1,175 @@
1
+ import type { CandidateTrap } from "../domain/session";
2
+ import type { Scope } from "./constants";
3
+ import { uniqueStrings } from "./string-list";
4
+ import {
5
+ candidateTrapKey,
6
+ createCandidateTrap,
7
+ nextCandidateId,
8
+ type CandidateDraft,
9
+ } from "./session-capture";
10
+ import { scoreCandidateTrap } from "./trap-quality";
11
+
12
+ export type CandidateDocumentAddResult = {
13
+ candidates: CandidateTrap[];
14
+ candidate: CandidateTrap;
15
+ duplicate: boolean;
16
+ };
17
+
18
+ export type CandidateDocumentUpdateResult = {
19
+ candidates: CandidateTrap[];
20
+ candidate: CandidateTrap;
21
+ };
22
+
23
+ export type CandidateDocumentRemoveResult = {
24
+ candidates: CandidateTrap[];
25
+ removed: CandidateTrap[];
26
+ };
27
+
28
+ export function addCandidateToDocument(
29
+ candidates: CandidateTrap[],
30
+ draft: CandidateDraft
31
+ ): CandidateDocumentAddResult {
32
+ const candidate = createCandidateTrap(draft, nextCandidateId(candidates));
33
+ const duplicate = findDuplicateCandidate(candidates, candidate);
34
+ if (duplicate) {
35
+ return {
36
+ candidates,
37
+ candidate: duplicate,
38
+ duplicate: true,
39
+ };
40
+ }
41
+
42
+ return {
43
+ candidates: [...candidates, candidate],
44
+ candidate,
45
+ duplicate: false,
46
+ };
47
+ }
48
+
49
+ export function recordCandidateConflictCheckInDocument(
50
+ candidates: CandidateTrap[],
51
+ candidateId: string,
52
+ args: {
53
+ sessionId: string;
54
+ trap?: CandidateTrap["trap"];
55
+ conflictStatus: CandidateTrap["quality"]["conflict_status"];
56
+ suggestedAction: CandidateTrap["quality"]["suggested_action"];
57
+ }
58
+ ): CandidateDocumentUpdateResult {
59
+ return updateProposedCandidate(candidates, candidateId, args.sessionId, (candidate) => ({
60
+ ...candidate,
61
+ trap: args.trap ?? candidate.trap,
62
+ quality: {
63
+ ...candidate.quality,
64
+ conflict_checked: true,
65
+ conflict_status: args.conflictStatus,
66
+ suggested_action: args.suggestedAction,
67
+ },
68
+ }));
69
+ }
70
+
71
+ export function saveCandidateTrapInDocument(
72
+ candidates: CandidateTrap[],
73
+ candidateId: string,
74
+ args: {
75
+ sessionId: string;
76
+ trap: CandidateTrap["trap"];
77
+ }
78
+ ): CandidateDocumentUpdateResult {
79
+ return updateProposedCandidate(candidates, candidateId, args.sessionId, (candidate) => {
80
+ const scored = scoreCandidateTrap({ trap: args.trap, evidence: candidate.evidence });
81
+ return {
82
+ ...candidate,
83
+ trap: args.trap,
84
+ quality_score: scored.score,
85
+ quality: scored.quality,
86
+ };
87
+ });
88
+ }
89
+
90
+ export function acceptCandidateInDocument(
91
+ candidates: CandidateTrap[],
92
+ candidateId: string,
93
+ args: {
94
+ sessionId: string;
95
+ trap?: CandidateTrap["trap"];
96
+ trapId: number;
97
+ scope: string;
98
+ conflictChecked?: boolean;
99
+ conflictStatus?: CandidateTrap["quality"]["conflict_status"];
100
+ suggestedAction?: CandidateTrap["quality"]["suggested_action"];
101
+ },
102
+ now: Date
103
+ ): CandidateDocumentUpdateResult {
104
+ return updateProposedCandidate(candidates, candidateId, args.sessionId, (candidate) => ({
105
+ ...candidate,
106
+ trap: args.trap ?? candidate.trap,
107
+ status: "accepted",
108
+ accepted_trap_id: args.trapId,
109
+ accepted_scope: acceptedScope(args.scope),
110
+ accepted_at: now.toISOString(),
111
+ quality: {
112
+ ...candidate.quality,
113
+ conflict_checked: args.conflictChecked ?? candidate.quality.conflict_checked,
114
+ conflict_status: args.conflictStatus ?? candidate.quality.conflict_status,
115
+ suggested_action: args.suggestedAction ?? candidate.quality.suggested_action,
116
+ },
117
+ }));
118
+ }
119
+
120
+ export function rejectCandidateInDocument(
121
+ candidates: CandidateTrap[],
122
+ candidateId: string,
123
+ args: {
124
+ sessionId: string;
125
+ reason?: string | null;
126
+ },
127
+ now: Date
128
+ ): CandidateDocumentUpdateResult {
129
+ return updateProposedCandidate(candidates, candidateId, args.sessionId, (candidate) => ({
130
+ ...candidate,
131
+ status: "rejected",
132
+ rejected_at: now.toISOString(),
133
+ rejection_reason: args.reason || undefined,
134
+ }));
135
+ }
136
+
137
+ export function removeCandidatesFromDocument(
138
+ candidates: CandidateTrap[],
139
+ candidateIds: string[]
140
+ ): CandidateDocumentRemoveResult {
141
+ const removeIds = new Set(uniqueStrings(candidateIds));
142
+ if (removeIds.size === 0) return { candidates, removed: [] };
143
+
144
+ const removed = candidates.filter((candidate) => removeIds.has(candidate.id));
145
+ return {
146
+ candidates: candidates.filter((candidate) => !removeIds.has(candidate.id)),
147
+ removed,
148
+ };
149
+ }
150
+
151
+ function updateProposedCandidate(
152
+ candidates: CandidateTrap[],
153
+ candidateId: string,
154
+ sessionId: string,
155
+ update: (candidate: CandidateTrap) => CandidateTrap
156
+ ): CandidateDocumentUpdateResult {
157
+ const candidate = candidates.find((item) => item.id === candidateId);
158
+ if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
159
+ if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
160
+
161
+ const updated = update(candidate);
162
+ return {
163
+ candidates: candidates.map((item) => item.id === candidateId ? updated : item),
164
+ candidate: updated,
165
+ };
166
+ }
167
+
168
+ function findDuplicateCandidate(candidates: CandidateTrap[], candidate: CandidateTrap): CandidateTrap | null {
169
+ const key = candidateTrapKey(candidate);
170
+ return candidates.find((item) => candidateTrapKey(item) === key) ?? null;
171
+ }
172
+
173
+ function acceptedScope(scope: string): Scope {
174
+ return scope === "project" ? "project" : "global";
175
+ }
@@ -0,0 +1,6 @@
1
+ import type { CandidateTrap } from "../domain/session";
2
+ import type { Scope } from "./constants";
3
+
4
+ export function candidateAcceptedScope(candidate: CandidateTrap): Scope {
5
+ return candidate.accepted_scope ?? (candidate.trap.scope === "global" ? "global" : "project");
6
+ }
@@ -1,8 +1,56 @@
1
1
  import type { CandidateTrap, SessionMetadata, SessionNote } from "../domain/session";
2
- import { CATEGORIES, SCOPES, SEVERITIES, type Category, type Scope, type Severity } from "./constants";
2
+ import { parseSessionNoteKind } from "../domain/session";
3
+ import { buildTrapInput } from "../domain/trap";
4
+ import {
5
+ CATEGORIES,
6
+ DEFAULT_CATEGORY,
7
+ DEFAULT_SEVERITY,
8
+ SCOPES,
9
+ SEVERITIES,
10
+ } from "./constants";
11
+ import { uniqueStrings } from "./string-list";
12
+ import { trimOuterBlankLines } from "./text-lines";
3
13
  import { scoreCandidateTrap } from "./trap-quality";
14
+ import { isRecord } from "./value-types";
4
15
 
5
- type CandidateDraft = Pick<CandidateTrap, "trap" | "evidence">;
16
+ export type CandidateDraft = Pick<CandidateTrap, "trap" | "evidence">;
17
+
18
+ export type CapturedCandidateDraftArgs = {
19
+ trap: CandidateTrap["trap"];
20
+ kind?: string;
21
+ relatedFiles?: string[];
22
+ sourceRef?: string | null;
23
+ evidenceNote?: string | null;
24
+ };
25
+
26
+ export type ParsedTrapMarkdownInput = {
27
+ trap: CandidateTrap["trap"];
28
+ evidenceNote?: string;
29
+ relatedFiles?: string[];
30
+ };
31
+
32
+ const TRAP_MARKDOWN_FIELDS = new Set([
33
+ "title",
34
+ "trap_title",
35
+ "context",
36
+ "trigger",
37
+ "mistake",
38
+ "avoid",
39
+ "fix",
40
+ "do_instead",
41
+ "category",
42
+ "scope",
43
+ "severity",
44
+ "tags",
45
+ "path_globs",
46
+ "paths",
47
+ "module",
48
+ "owner",
49
+ "before_code",
50
+ "after_code",
51
+ "evidence",
52
+ "related_files",
53
+ ]);
6
54
 
7
55
  export function proposeCandidateTraps(session: SessionMetadata, notes: SessionNote[]): CandidateTrap[] {
8
56
  const candidates: CandidateTrap[] = [];
@@ -22,8 +70,105 @@ export function proposeCandidateTraps(session: SessionMetadata, notes: SessionNo
22
70
  return candidates;
23
71
  }
24
72
 
73
+ export function createCandidateTrap(draft: CandidateDraft, id: string): CandidateTrap {
74
+ const scored = scoreCandidateTrap(draft);
75
+ return {
76
+ id,
77
+ status: "proposed",
78
+ quality_score: scored.score,
79
+ quality: scored.quality,
80
+ ...draft,
81
+ };
82
+ }
83
+
84
+ export function mergeCandidateTraps(existing: CandidateTrap[], proposed: CandidateTrap[]): CandidateTrap[] {
85
+ const merged = [...existing];
86
+ const seen = new Set(merged.map(candidateTrapKey));
87
+ for (const candidate of proposed) {
88
+ const key = candidateTrapKey(candidate);
89
+ if (seen.has(key)) continue;
90
+ seen.add(key);
91
+ merged.push({ ...candidate, id: nextCandidateId(merged) });
92
+ }
93
+ return merged;
94
+ }
95
+
96
+ export function candidateTrapKey(candidate: CandidateTrap): string {
97
+ return [
98
+ candidate.trap.title,
99
+ candidate.trap.context,
100
+ candidate.trap.mistake,
101
+ candidate.trap.fix,
102
+ candidate.trap.scope,
103
+ ].map(normalizeKeyPart).join("\u0000");
104
+ }
105
+
106
+ export function nextCandidateId(candidates: CandidateTrap[]): string {
107
+ const max = candidates
108
+ .map((candidate) => candidate.id.match(/^cand-(\d+)$/)?.[1])
109
+ .filter((value): value is string => value !== undefined)
110
+ .map((value) => Number.parseInt(value, 10))
111
+ .filter(Number.isFinite)
112
+ .reduce((highest, value) => Math.max(highest, value), 0);
113
+ return `cand-${String(max + 1).padStart(3, "0")}`;
114
+ }
115
+
116
+ export function capturedTrapInput(args: Record<string, unknown>): CandidateTrap["trap"] {
117
+ return normalizeCandidateTrap({
118
+ ...args,
119
+ category: args.category ?? DEFAULT_CATEGORY,
120
+ scope: args.scope ?? "project",
121
+ severity: args.severity ?? DEFAULT_SEVERITY,
122
+ }) as CandidateTrap["trap"];
123
+ }
124
+
125
+ export function capturedTrapMarkdownInput(markdown: string): ParsedTrapMarkdownInput {
126
+ const fields = parseTrapMarkdownFields(markdown);
127
+ const trap = trapFromParsedFields(fields);
128
+ const evidenceNote = optionalText(fields.evidence);
129
+ return {
130
+ trap,
131
+ evidenceNote: evidenceNote ?? undefined,
132
+ relatedFiles: stringArray(fields.related_files),
133
+ };
134
+ }
135
+
136
+ export function capturedCandidateDraft(
137
+ session: SessionMetadata,
138
+ args: CapturedCandidateDraftArgs
139
+ ): CandidateDraft {
140
+ const kind = parseSessionNoteKind(args.kind);
141
+ return {
142
+ trap: args.trap,
143
+ evidence: [{
144
+ source_type: kind === "test_failure" ? "test_failure" : "conversation",
145
+ source_ref: args.sourceRef ?? `session:${session.id}`,
146
+ related_files: uniqueStrings(args.relatedFiles ?? []),
147
+ note: optionalText(args.evidenceNote) ?? `Captured through session capture (${kind}).`,
148
+ }],
149
+ };
150
+ }
151
+
152
+ export function captureGoal(goal: string | undefined, title: string): string {
153
+ const trimmed = goal?.trim();
154
+ return trimmed ? trimmed : `capture: ${title}`;
155
+ }
156
+
157
+ export function candidateWithTrapEdits(
158
+ candidate: CandidateTrap,
159
+ edit: Record<string, unknown> | undefined
160
+ ): CandidateTrap {
161
+ return {
162
+ ...candidate,
163
+ trap: normalizeCandidateTrap({
164
+ ...candidate.trap,
165
+ ...trapEdits(edit),
166
+ }) as CandidateTrap["trap"],
167
+ };
168
+ }
169
+
25
170
  function explicitTrapDraft(session: SessionMetadata, note: SessionNote): CandidateDraft | null {
26
- const fields = parseLabeledFields(note.text);
171
+ const fields = parseTrapMarkdownFields(note.text);
27
172
  const title = fields.title ?? fields.trap_title;
28
173
  const context = fields.context ?? fields.trigger;
29
174
  const mistake = fields.mistake ?? fields.avoid;
@@ -31,23 +176,50 @@ function explicitTrapDraft(session: SessionMetadata, note: SessionNote): Candida
31
176
  if (!title || !context || !mistake || !fix) return null;
32
177
 
33
178
  return {
34
- trap: {
179
+ trap: normalizeCandidateTrap({
35
180
  title,
36
- category: parseCategory(fields.category),
37
- scope: parseScope(fields.scope),
181
+ category: fields.category,
182
+ scope: fields.scope,
38
183
  context,
39
184
  mistake,
40
185
  fix,
41
- severity: parseSeverity(fields.severity),
42
- tags: splitList(fields.tags),
43
- path_globs: splitList(fields.path_globs ?? fields.paths ?? fields.related_files) || note.related_files,
186
+ severity: fields.severity,
187
+ tags: fields.tags,
188
+ path_globs: fields.path_globs ?? fields.paths ?? fields.related_files ?? note.related_files,
44
189
  module: fields.module ?? session.module,
45
190
  owner: fields.owner ?? session.owner,
46
- },
191
+ before_code: fields.before_code,
192
+ after_code: fields.after_code,
193
+ }, { strictEnums: false }) as CandidateTrap["trap"],
47
194
  evidence: [evidenceFromNote(session, note, "Captured from explicit session candidate fields.")],
48
195
  };
49
196
  }
50
197
 
198
+ function trapFromParsedFields(
199
+ fields: Record<string, unknown>,
200
+ options: { strictEnums?: boolean } = {}
201
+ ): CandidateTrap["trap"] {
202
+ return normalizeCandidateTrap({
203
+ title: fields.title ?? fields.trap_title,
204
+ category: fields.category,
205
+ scope: fields.scope,
206
+ context: fields.context ?? fields.trigger,
207
+ mistake: fields.mistake ?? fields.avoid,
208
+ fix: fields.fix ?? fields.do_instead,
209
+ severity: fields.severity,
210
+ tags: fields.tags,
211
+ path_globs: fields.path_globs ?? fields.paths,
212
+ module: fields.module,
213
+ owner: fields.owner,
214
+ before_code: stripOuterFence(optionalText(fields.before_code)),
215
+ after_code: stripOuterFence(optionalText(fields.after_code)),
216
+ }, options) as CandidateTrap["trap"];
217
+ }
218
+
219
+ function normalizeKeyPart(value: unknown): string {
220
+ return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
221
+ }
222
+
51
223
  function evidenceFromNote(session: SessionMetadata, note: SessionNote, noteText: string) {
52
224
  return {
53
225
  source_type: note.kind === "test_failure" ? "test_failure" : "conversation",
@@ -57,40 +229,134 @@ function evidenceFromNote(session: SessionMetadata, note: SessionNote, noteText:
57
229
  };
58
230
  }
59
231
 
60
- function parseLabeledFields(text: string): Record<string, string> {
61
- const fields: Record<string, string> = {};
232
+ function parseTrapMarkdownFields(text: string): Record<string, string> {
233
+ const fields: Record<string, string[]> = {};
234
+ let currentField: string | null = null;
235
+ let fenceMarker: string | null = null;
236
+
62
237
  for (const line of text.split(/\r?\n/)) {
63
- const match = line.match(/^([A-Za-z][A-Za-z0-9 _-]{1,40}):\s*(.+)$/);
64
- if (!match) continue;
65
- fields[normalizeFieldName(match[1])] = match[2].trim();
238
+ const fence = line.match(/^\s*(```|~~~)/)?.[1] ?? null;
239
+ if (fence !== null) {
240
+ if (currentField) fields[currentField].push(line);
241
+ fenceMarker = fenceMarker === null ? fence : fenceMarker === fence ? null : fenceMarker;
242
+ continue;
243
+ }
244
+
245
+ if (fenceMarker === null) {
246
+ const match = line.match(/^([A-Za-z][A-Za-z0-9 _-]{0,40}):\s*(.*)$/);
247
+ if (match && isTrapMarkdownField(match[1])) {
248
+ currentField = normalizeFieldName(match[1]);
249
+ fields[currentField] = fields[currentField] ?? [];
250
+ if (match[2].trim() !== "") fields[currentField].push(match[2].trimEnd());
251
+ continue;
252
+ }
253
+ }
254
+
255
+ if (currentField) fields[currentField].push(line);
66
256
  }
67
- return fields;
257
+
258
+ return Object.fromEntries(
259
+ Object.entries(fields)
260
+ .map(([field, lines]) => [field, trimFieldLines(lines)])
261
+ );
68
262
  }
69
263
 
70
264
  function normalizeFieldName(value: string): string {
71
265
  return value.trim().toLowerCase().replace(/[\s-]+/g, "_");
72
266
  }
73
267
 
74
- function splitList(value: string | undefined): string[] | undefined {
75
- if (!value) return undefined;
76
- const values = value
77
- .split(",")
78
- .map((item) => item.trim())
79
- .filter(Boolean);
80
- return values.length > 0 ? values : undefined;
268
+ function isTrapMarkdownField(value: string): boolean {
269
+ return TRAP_MARKDOWN_FIELDS.has(normalizeFieldName(value));
81
270
  }
82
271
 
83
- function parseCategory(value: string | undefined): Category {
84
- if (value && (CATEGORIES as readonly string[]).includes(value)) return value as Category;
85
- return "other";
272
+ function trimFieldLines(lines: string[]): string {
273
+ return trimOuterBlankLines(lines).join("\n").trim();
86
274
  }
87
275
 
88
- function parseSeverity(value: string | undefined): Severity {
89
- if (value && (SEVERITIES as readonly string[]).includes(value)) return value as Severity;
90
- return "warning";
276
+ function normalizeCandidateTrap(
277
+ args: Record<string, unknown>,
278
+ options: { strictEnums?: boolean } = {}
279
+ ) {
280
+ const strictEnums = options.strictEnums ?? true;
281
+ return buildTrapInput({
282
+ ...args,
283
+ title: requiredText(args.title, "title"),
284
+ category: enumText(args.category, CATEGORIES, DEFAULT_CATEGORY, "category", strictEnums),
285
+ scope: enumText(args.scope, SCOPES, "project", "scope", strictEnums),
286
+ context: requiredText(args.context, "context"),
287
+ mistake: requiredText(args.mistake, "mistake"),
288
+ fix: requiredText(args.fix, "fix"),
289
+ tags: stringArray(args.tags),
290
+ path_globs: stringArray(args.path_globs),
291
+ module: optionalText(args.module),
292
+ owner: optionalText(args.owner),
293
+ before_code: optionalText(args.before_code),
294
+ after_code: optionalText(args.after_code),
295
+ severity: enumText(args.severity, SEVERITIES, DEFAULT_SEVERITY, "severity", strictEnums),
296
+ });
297
+ }
298
+
299
+ function requiredText(value: unknown, field: string): string {
300
+ const text = typeof value === "string" ? value.trim() : "";
301
+ if (!text) throw new Error(`trap ${field} is required.`);
302
+ return text;
303
+ }
304
+
305
+ function enumText<T extends readonly string[]>(
306
+ value: unknown,
307
+ allowed: T,
308
+ fallback: T[number],
309
+ field: string,
310
+ strict: boolean
311
+ ): T[number] {
312
+ if (value === undefined || value === null || String(value).trim() === "") return fallback;
313
+ const text = String(value).trim();
314
+ if ((allowed as readonly string[]).includes(text)) return text as T[number];
315
+ if (!strict) return fallback;
316
+ throw new Error(`Invalid trap ${field}: ${text}. Expected one of: ${allowed.join(", ")}`);
317
+ }
318
+
319
+ function stringArray(value: unknown): string[] | undefined {
320
+ if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
321
+ if (typeof value === "string") {
322
+ const values = value
323
+ .split(/\r?\n/)
324
+ .flatMap((line) => {
325
+ const trimmed = line.trim();
326
+ if (!trimmed) return [];
327
+ const bullet = trimmed.match(/^[-*+]\s+(.+)$/);
328
+ return (bullet?.[1] ?? trimmed).split(",");
329
+ })
330
+ .map((item) => item.trim())
331
+ .filter(Boolean);
332
+ return values.length > 0 ? values : undefined;
333
+ }
334
+ return undefined;
335
+ }
336
+
337
+ function optionalText(value: unknown): string | null | undefined {
338
+ if (value === undefined) return undefined;
339
+ if (value === null) return null;
340
+ const text = String(value).trim();
341
+ return text ? text : null;
342
+ }
343
+
344
+ function stripOuterFence(value: string | null | undefined): string | null | undefined {
345
+ if (value === undefined || value === null) return value;
346
+ const lines = value.split(/\r?\n/);
347
+ const first = lines[0]?.trim();
348
+ const last = lines[lines.length - 1]?.trim();
349
+ if (lines.length >= 2 && first?.startsWith("```") && last === "```") {
350
+ return lines.slice(1, -1).join("\n").trim();
351
+ }
352
+ if (lines.length >= 2 && first?.startsWith("~~~") && last === "~~~") {
353
+ return lines.slice(1, -1).join("\n").trim();
354
+ }
355
+ return value;
91
356
  }
92
357
 
93
- function parseScope(value: string | undefined): Scope {
94
- if (value && (SCOPES as readonly string[]).includes(value)) return value as Scope;
95
- return "project";
358
+ function trapEdits(edit: Record<string, unknown> | undefined): Record<string, unknown> {
359
+ if (!edit) return {};
360
+ const nested = edit.trap;
361
+ return isRecord(nested) ? nested : edit;
96
362
  }
@@ -7,6 +7,7 @@ import type {
7
7
  SessionNoteCounts,
8
8
  } from "../domain/session";
9
9
  import { SESSION_NOTE_KINDS } from "../domain/session";
10
+ import { trimOuterBlankLines } from "./text-lines";
10
11
 
11
12
  export const SESSIONS_DIR = "sessions";
12
13
  export const ACTIVE_SESSION_FILE = "active.json";
@@ -229,14 +230,6 @@ function parseNoteBlock(created_at: string, kind: SessionNote["kind"], lines: st
229
230
  };
230
231
  }
231
232
 
232
- function trimOuterBlankLines(lines: string[]): string[] {
233
- let start = 0;
234
- let end = lines.length;
235
- while (start < end && lines[start].trim() === "") start++;
236
- while (end > start && lines[end - 1].trim() === "") end--;
237
- return lines.slice(start, end);
238
- }
239
-
240
233
  function formatNoteList(notes: SessionNote[], empty: string): string {
241
234
  if (notes.length === 0) return empty;
242
235
  return notes.map((note) => `- ${summarizeNoteText(note.text)}${formatRelatedFiles(note.related_files)}`).join("\n");