codetrap 0.1.7 → 0.1.9
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 +132 -98
- package/docs/installation.md +61 -63
- package/package.json +4 -3
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +2 -3
- package/plugins/codetrap-agent/hooks/post-flight-capture.example.md +19 -17
- package/plugins/codetrap-agent/hooks.json +2 -2
- package/{skills → plugins/codetrap-agent/skills}/codetrap-add/SKILL.md +10 -4
- package/plugins/codetrap-agent/skills/codetrap-capture/SKILL.md +14 -3
- package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +52 -9
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +74 -6
- package/{skills → plugins/codetrap-agent/skills}/codetrap-search/SKILL.md +6 -5
- package/plugins/codetrap-agent/templates/AGENTS.codetrap-maintainer.md +15 -0
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +16 -5
- package/scripts/release-preflight.ts +15 -0
- package/scripts/search-policy-sweep.ts +131 -0
- package/src/commands/workflow.ts +172 -68
- package/src/db/embedding-queries.ts +230 -48
- package/src/db/queries.ts +0 -25
- package/src/db/repository.ts +32 -21
- package/src/db/schema.ts +80 -0
- package/src/index.ts +34 -4
- package/src/lib/codex-setup.ts +247 -0
- package/src/lib/command-requests.ts +112 -1
- package/src/lib/config.ts +57 -7
- package/src/lib/constants.ts +1 -1
- package/src/lib/doctor.ts +42 -12
- package/src/lib/embedder.ts +118 -3
- package/src/lib/embedding-health.ts +3 -1
- package/src/lib/embedding-job.ts +3 -0
- package/src/lib/embedding-management.ts +65 -0
- package/src/lib/embedding-runtime.ts +177 -0
- package/src/lib/output-json.ts +0 -2
- package/src/lib/scope-context.ts +12 -6
- package/src/lib/scope-migration.ts +2 -1
- package/src/lib/scope.ts +0 -2
- package/src/lib/search-eval.ts +38 -18
- package/src/lib/search-policy-sweep.ts +563 -0
- package/src/lib/search-policy.ts +0 -4
- package/src/lib/search-service.ts +14 -15
- package/src/lib/session-candidate-document.ts +175 -0
- package/src/lib/session-candidate-scope.ts +6 -0
- package/src/lib/session-capture.ts +298 -32
- package/src/lib/session-codec.ts +1 -8
- package/src/lib/session-operations.ts +83 -60
- package/src/lib/session-review.ts +327 -0
- package/src/lib/session-store.ts +87 -73
- package/src/lib/store.ts +74 -10
- package/src/lib/string-list.ts +3 -0
- package/src/lib/text-lines.ts +7 -0
- package/src/lib/trap-search-document.ts +2 -1
- package/src/lib/value-types.ts +3 -0
- package/src/web/client-review.ts +171 -0
- package/src/web/client-script.ts +426 -51
- package/src/web/client-shell.ts +414 -0
- package/src/web/client-text.ts +112 -0
- package/src/web/project-registry.ts +3 -5
- package/src/web/server.ts +117 -103
- package/src/web/static.ts +364 -19
- package/skills/codetrap-capture-external/SKILL.md +0 -62
- package/skills/codetrap-check/SKILL.md +0 -69
- package/src/lib/embedding-index.ts +0 -53
|
@@ -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 {
|
|
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 =
|
|
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:
|
|
37
|
-
scope:
|
|
181
|
+
category: fields.category,
|
|
182
|
+
scope: fields.scope,
|
|
38
183
|
context,
|
|
39
184
|
mistake,
|
|
40
185
|
fix,
|
|
41
|
-
severity:
|
|
42
|
-
tags:
|
|
43
|
-
path_globs:
|
|
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
|
|
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
|
|
64
|
-
if (
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
return "other";
|
|
272
|
+
function trimFieldLines(lines: string[]): string {
|
|
273
|
+
return trimOuterBlankLines(lines).join("\n").trim();
|
|
86
274
|
}
|
|
87
275
|
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
}
|
package/src/lib/session-codec.ts
CHANGED
|
@@ -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");
|