codetrap 0.1.4 → 0.1.6

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.
@@ -0,0 +1,261 @@
1
+ import { basename, join } from "node:path";
2
+ import type {
3
+ CandidateTrap,
4
+ SessionIndexEntry,
5
+ SessionMetadata,
6
+ SessionNote,
7
+ SessionNoteCounts,
8
+ } from "../domain/session";
9
+ import { SESSION_NOTE_KINDS } from "../domain/session";
10
+
11
+ export const SESSIONS_DIR = "sessions";
12
+ export const ACTIVE_SESSION_FILE = "active.json";
13
+ export const SESSION_INDEX_FILE = "index.json";
14
+ export const SESSION_FILE = "session.json";
15
+ export const NOTES_FILE = "implementation-notes.md";
16
+ export const RECAP_FILE = "recap.md";
17
+ export const CANDIDATES_FILE = "candidate-traps.json";
18
+
19
+ export function createSessionId(goal: string, now: Date, exists: (id: string) => boolean): string {
20
+ const date = now.toISOString().slice(0, 10);
21
+ const base = `${date}-${slugify(goal)}`;
22
+ let candidate = base;
23
+ let suffix = 2;
24
+ while (exists(candidate)) {
25
+ candidate = `${base}-${suffix++}`;
26
+ }
27
+ return candidate;
28
+ }
29
+
30
+ export function slugify(value: string): string {
31
+ const slug = value
32
+ .normalize("NFKD")
33
+ .replace(/[^\w\s-]/g, "")
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[_\s]+/g, "-")
37
+ .replace(/-+/g, "-")
38
+ .slice(0, 64)
39
+ .replace(/^-|-$/g, "");
40
+ return slug || "session";
41
+ }
42
+
43
+ export function sessionRelativeDir(id: string): string {
44
+ return join(".codetrap", SESSIONS_DIR, id);
45
+ }
46
+
47
+ export function sessionRelativeFile(id: string, file: string): string {
48
+ return join(sessionRelativeDir(id), file);
49
+ }
50
+
51
+ export function formatImplementationNotesHeader(session: SessionMetadata): string {
52
+ return [
53
+ `# Implementation Notes: ${session.goal}`,
54
+ "",
55
+ `Session: ${session.id}`,
56
+ `Status: ${session.status}`,
57
+ "",
58
+ "## Timeline",
59
+ "",
60
+ ].join("\n");
61
+ }
62
+
63
+ export function formatSessionNote(note: SessionNote): string {
64
+ const lines = [
65
+ `### ${note.created_at} ${note.kind}`,
66
+ "",
67
+ note.text.trim(),
68
+ ];
69
+ if (note.source_ref) {
70
+ lines.push("", `Source ref: ${note.source_ref}`);
71
+ }
72
+ if (note.related_files.length > 0) {
73
+ lines.push("", "Related files:", ...note.related_files.map((file) => `- ${file}`));
74
+ }
75
+ lines.push("");
76
+ return `${lines.join("\n")}\n`;
77
+ }
78
+
79
+ export function parseSessionNotes(markdown: string): SessionNote[] {
80
+ const lines = markdown.split(/\r?\n/);
81
+ const notes: SessionNote[] = [];
82
+ let current: { created_at: string; kind: SessionNote["kind"]; lines: string[] } | null = null;
83
+
84
+ for (const line of lines) {
85
+ const match = line.match(/^###\s+(\S+)\s+(\S+)\s*$/);
86
+ if (match && isSessionNoteKind(match[2])) {
87
+ if (current) notes.push(parseNoteBlock(current.created_at, current.kind, current.lines));
88
+ current = { created_at: match[1], kind: match[2], lines: [] };
89
+ continue;
90
+ }
91
+ if (current) current.lines.push(line);
92
+ }
93
+
94
+ if (current) notes.push(parseNoteBlock(current.created_at, current.kind, current.lines));
95
+ return notes;
96
+ }
97
+
98
+ export function noteCounts(notes: SessionNote[]): SessionNoteCounts {
99
+ const counts: SessionNoteCounts = {};
100
+ for (const note of notes) {
101
+ counts[note.kind] = (counts[note.kind] ?? 0) + 1;
102
+ }
103
+ return counts;
104
+ }
105
+
106
+ export function formatRecap(
107
+ session: SessionMetadata,
108
+ notes: SessionNote[],
109
+ candidates: CandidateTrap[] = []
110
+ ): string {
111
+ const decisions = notes.filter((note) => note.kind === "decision");
112
+ const deviations = notes.filter((note) => note.kind === "deviation");
113
+ const tradeoffs = notes.filter((note) => note.kind === "tradeoff");
114
+ const failures = notes.filter((note) => note.kind === "failure" || note.kind === "test_failure");
115
+ const questions = notes.filter((note) => note.kind === "open_question");
116
+ const observations = notes.filter((note) => note.kind === "observation" || note.kind === "correction" || note.kind === "review");
117
+
118
+ return [
119
+ `# Session Recap: ${session.goal}`,
120
+ "",
121
+ "## Goal",
122
+ "",
123
+ session.goal,
124
+ "",
125
+ "## What Changed",
126
+ "",
127
+ formatNoteList(observations, "No implementation observations were captured."),
128
+ "",
129
+ "## Decisions",
130
+ "",
131
+ formatNoteList(decisions, "No decisions were captured."),
132
+ "",
133
+ "## Deviations From Spec",
134
+ "",
135
+ formatNoteList(deviations, "No deviations were captured."),
136
+ "",
137
+ "## Tradeoffs",
138
+ "",
139
+ formatNoteList(tradeoffs, "No tradeoffs were captured."),
140
+ "",
141
+ "## Failures And Fixes",
142
+ "",
143
+ formatNoteList(failures, "No failures were captured."),
144
+ "",
145
+ "## Open Questions",
146
+ "",
147
+ formatNoteList(questions, "No open questions were captured."),
148
+ "",
149
+ "## Candidate Traps",
150
+ "",
151
+ candidates.length > 0
152
+ ? candidates.map((candidate) => `- ${candidate.id}: ${candidate.trap.title} (${candidate.quality_score.toFixed(2)})`).join("\n")
153
+ : "No candidate traps were proposed.",
154
+ "",
155
+ "## Accepted Traps",
156
+ "",
157
+ formatAcceptedCandidates(candidates),
158
+ "",
159
+ ].join("\n");
160
+ }
161
+
162
+ export function recapSummary(session: SessionMetadata, notes: SessionNote[], candidates: CandidateTrap[]): string {
163
+ const noteCount = notes.length;
164
+ const candidateCount = candidates.length;
165
+ return `Captured ${noteCount} note${noteCount === 1 ? "" : "s"} for ${session.goal}; proposed ${candidateCount} candidate trap${candidateCount === 1 ? "" : "s"}.`;
166
+ }
167
+
168
+ export function sessionIndexEntry(
169
+ session: SessionMetadata,
170
+ notes: SessionNote[],
171
+ candidates: CandidateTrap[],
172
+ summary: string | null
173
+ ): SessionIndexEntry {
174
+ return {
175
+ id: session.id,
176
+ goal: session.goal,
177
+ status: session.status,
178
+ created_at: session.created_at,
179
+ closed_at: session.closed_at,
180
+ module: session.module,
181
+ owner: session.owner,
182
+ note_counts: noteCounts(notes),
183
+ candidate_count: candidates.length,
184
+ accepted_count: candidates.filter((candidate) => candidate.status === "accepted").length,
185
+ summary,
186
+ };
187
+ }
188
+
189
+ export function summarizeNoteText(text: string, maxLength = 96): string {
190
+ const normalized = text.replace(/\s+/g, " ").trim();
191
+ if (normalized.length <= maxLength) return normalized;
192
+ return `${normalized.slice(0, maxLength - 1).trimEnd()}...`;
193
+ }
194
+
195
+ function parseNoteBlock(created_at: string, kind: SessionNote["kind"], lines: string[]): SessionNote {
196
+ const textLines: string[] = [];
197
+ const relatedFiles: string[] = [];
198
+ let sourceRef: string | null = null;
199
+ let inRelatedFiles = false;
200
+
201
+ for (const line of trimOuterBlankLines(lines)) {
202
+ const sourceMatch = line.match(/^Source ref:\s*(.+)$/);
203
+ if (sourceMatch) {
204
+ sourceRef = sourceMatch[1].trim() || null;
205
+ inRelatedFiles = false;
206
+ continue;
207
+ }
208
+ if (line.trim() === "Related files:") {
209
+ inRelatedFiles = true;
210
+ continue;
211
+ }
212
+ if (inRelatedFiles) {
213
+ const relatedFileMatch = line.match(/^-\s+(.+)$/);
214
+ if (relatedFileMatch) {
215
+ relatedFiles.push(relatedFileMatch[1].trim());
216
+ continue;
217
+ }
218
+ inRelatedFiles = false;
219
+ }
220
+ textLines.push(line);
221
+ }
222
+
223
+ return {
224
+ created_at,
225
+ kind,
226
+ text: trimOuterBlankLines(textLines).join("\n").trim(),
227
+ related_files: relatedFiles,
228
+ source_ref: sourceRef,
229
+ };
230
+ }
231
+
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
+ function formatNoteList(notes: SessionNote[], empty: string): string {
241
+ if (notes.length === 0) return empty;
242
+ return notes.map((note) => `- ${summarizeNoteText(note.text)}${formatRelatedFiles(note.related_files)}`).join("\n");
243
+ }
244
+
245
+ function formatRelatedFiles(relatedFiles: string[]): string {
246
+ if (relatedFiles.length === 0) return "";
247
+ const displayed = relatedFiles.slice(0, 3).map((file) => basename(file)).join(", ");
248
+ return ` [${displayed}]`;
249
+ }
250
+
251
+ function formatAcceptedCandidates(candidates: CandidateTrap[]): string {
252
+ const accepted = candidates.filter((candidate) => candidate.status === "accepted");
253
+ if (accepted.length === 0) return "No candidate traps have been accepted yet.";
254
+ return accepted
255
+ .map((candidate) => `- ${candidate.id}: Trap #${candidate.accepted_trap_id} (${candidate.accepted_scope})`)
256
+ .join("\n");
257
+ }
258
+
259
+ function isSessionNoteKind(value: string): value is SessionNote["kind"] {
260
+ return (SESSION_NOTE_KINDS as readonly string[]).includes(value);
261
+ }
@@ -0,0 +1,104 @@
1
+ import type { CandidateTrap } from "../domain/session";
2
+ import type { Trap } from "../domain/trap";
3
+ import type { Scope } from "./constants";
4
+ import type { TrapOperations } from "./trap-operations";
5
+ import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
6
+ import { globMatchesPath, normalizePath, trapAppliesToPath } from "./trap-scope-match";
7
+
8
+ export interface CandidateConflict {
9
+ trap_id: number;
10
+ scope: Scope;
11
+ title: string;
12
+ context: string;
13
+ fix: string;
14
+ reason: string;
15
+ }
16
+
17
+ export async function findCandidateConflicts(
18
+ candidate: CandidateTrap,
19
+ operations: TrapOperations
20
+ ): Promise<CandidateConflict[]> {
21
+ const query = conflictQuery(candidate);
22
+ if (!query) return [];
23
+
24
+ const cards = await operations.searchTrapCards({
25
+ query,
26
+ scope: candidate.trap.scope,
27
+ status: "active",
28
+ limit: 3,
29
+ mode: "fts",
30
+ });
31
+
32
+ const conflicts: CandidateConflict[] = [];
33
+ for (const card of cards) {
34
+ const details = operations.getTrapDetails(card.trap_id, card.scope);
35
+ if (!details || details.trap.status !== "active") continue;
36
+ const reason = conflictReason(candidate, details.trap);
37
+ if (!reason) continue;
38
+ conflicts.push({
39
+ trap_id: details.trap.id,
40
+ scope: details.scope,
41
+ title: details.trap.title,
42
+ context: details.trap.context,
43
+ fix: details.trap.fix,
44
+ reason,
45
+ });
46
+ }
47
+ return conflicts;
48
+ }
49
+
50
+ function conflictQuery(candidate: CandidateTrap): string {
51
+ return [
52
+ candidate.trap.title,
53
+ ...(candidate.trap.tags ?? []),
54
+ candidate.trap.module,
55
+ ].filter(Boolean).join(" ").trim();
56
+ }
57
+
58
+ function conflictReason(candidate: CandidateTrap, existing: Trap): string | null {
59
+ const sameModule = Boolean(candidate.trap.module && existing.module === candidate.trap.module);
60
+ if (sameModule) return "same module";
61
+
62
+ if (pathScopesOverlap(candidate, existing)) return "overlapping path scope";
63
+
64
+ const candidateTags = candidate.trap.tags ?? [];
65
+ const existingTags = parseTrapTags(existing.tags);
66
+ if (intersects(candidateTags, existingTags) && titleOverlap(candidate.trap.title, existing.title) >= 2) {
67
+ return "similar title and tags";
68
+ }
69
+
70
+ return null;
71
+ }
72
+
73
+ function intersects(left: string[], right: string[]): boolean {
74
+ const rightSet = new Set(right.map((value) => value.toLowerCase()));
75
+ return left.some((value) => rightSet.has(value.toLowerCase()));
76
+ }
77
+
78
+ function pathScopesOverlap(candidate: CandidateTrap, existing: Trap): boolean {
79
+ const candidatePaths = candidate.trap.path_globs ?? [];
80
+ const existingPaths = parseTrapPathGlobs(existing.path_globs);
81
+ if (candidatePaths.length === 0 || existingPaths.length === 0) return false;
82
+
83
+ return candidatePaths.some((candidatePath) =>
84
+ trapAppliesToPath(existing, candidatePath)
85
+ || existingPaths.some((existingPath) => pathGlobsMayOverlap(candidatePath, existingPath))
86
+ );
87
+ }
88
+
89
+ function pathGlobsMayOverlap(left: string, right: string): boolean {
90
+ const normalizedLeft = normalizePath(left);
91
+ const normalizedRight = normalizePath(right);
92
+ return normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
93
+ || globMatchesPath(normalizedLeft, normalizedRight)
94
+ || globMatchesPath(normalizedRight, normalizedLeft);
95
+ }
96
+
97
+ function titleOverlap(left: string, right: string): number {
98
+ const leftTokens = new Set(tokens(left));
99
+ return tokens(right).filter((token) => leftTokens.has(token)).length;
100
+ }
101
+
102
+ function tokens(value: string): string[] {
103
+ return value.toLowerCase().match(/[a-z0-9_]+/g) ?? [];
104
+ }
@@ -0,0 +1,214 @@
1
+ import type { CandidateTrap } from "../domain/session";
2
+ import { buildTrapInput } from "../domain/trap";
3
+ import type { TrapOperations } from "./trap-operations";
4
+ import { findCandidateConflicts, type CandidateConflict } from "./session-conflicts";
5
+ import type {
6
+ AcceptCandidateResult,
7
+ AddSessionNoteArgs,
8
+ CloseSessionResult,
9
+ SessionStore,
10
+ StartSessionArgs,
11
+ } from "./session-store";
12
+
13
+ export type SessionAcceptRequest = {
14
+ candidateId: string;
15
+ sessionId?: string;
16
+ edit?: Record<string, unknown>;
17
+ supersedesId?: number;
18
+ acceptAnyway?: boolean;
19
+ };
20
+
21
+ export type SessionSaveCandidateRequest = {
22
+ candidateId: string;
23
+ sessionId?: string;
24
+ edit: Record<string, unknown>;
25
+ };
26
+
27
+ export type SessionRejectRequest = {
28
+ candidateId: string;
29
+ sessionId?: string;
30
+ reason?: string | null;
31
+ };
32
+
33
+ export type SessionConflictResult = {
34
+ success: false;
35
+ session_id: string;
36
+ candidate_id: string;
37
+ possible_conflicts: CandidateConflict[];
38
+ };
39
+
40
+ export type SessionAcceptSuccess = AcceptCandidateResult & {
41
+ success: true;
42
+ };
43
+
44
+ export type SessionAcceptResult = SessionAcceptSuccess | SessionConflictResult;
45
+
46
+ export class SessionOperations {
47
+ constructor(
48
+ private readonly sessions: SessionStore,
49
+ private readonly traps: TrapOperations
50
+ ) {}
51
+
52
+ startSession(args: StartSessionArgs) {
53
+ return this.sessions.startSession(args);
54
+ }
55
+
56
+ addNote(args: AddSessionNoteArgs) {
57
+ return this.sessions.addNote(args);
58
+ }
59
+
60
+ status() {
61
+ return this.sessions.status();
62
+ }
63
+
64
+ listSessions(args: { status?: string; limit?: number } = {}) {
65
+ return this.sessions.listSessions(args);
66
+ }
67
+
68
+ showSession(id: string) {
69
+ return this.sessions.showSession(id);
70
+ }
71
+
72
+ summarizeNotes(id?: string) {
73
+ return this.sessions.summarizeNotes(id);
74
+ }
75
+
76
+ closeSession(id: string | undefined, proposeTraps: boolean): CloseSessionResult {
77
+ return this.sessions.closeSession(id, proposeTraps);
78
+ }
79
+
80
+ candidateDocument(id?: string) {
81
+ return this.sessions.candidateDocument(id);
82
+ }
83
+
84
+ getCandidate(candidateId: string, sessionId?: string) {
85
+ return this.sessions.getCandidate(candidateId, sessionId);
86
+ }
87
+
88
+ saveCandidate(request: SessionSaveCandidateRequest) {
89
+ const { session, candidate } = this.sessions.getCandidate(request.candidateId, request.sessionId);
90
+ const editedCandidate = candidateWithTrapEdits(candidate, request.edit);
91
+ return this.sessions.saveCandidateTrap(editedCandidate.id, {
92
+ sessionId: session.id,
93
+ trap: editedCandidate.trap,
94
+ });
95
+ }
96
+
97
+ async acceptCandidate(request: SessionAcceptRequest): Promise<SessionAcceptResult> {
98
+ const { session, candidate } = this.sessions.getCandidate(request.candidateId, request.sessionId);
99
+ const editedCandidate = candidateWithTrapEdits(candidate, request.edit);
100
+ const supersedesId = request.supersedesId;
101
+ const conflicts = await findCandidateConflicts(editedCandidate, this.traps);
102
+ if (conflicts.length > 0 && supersedesId === undefined && request.acceptAnyway !== true) {
103
+ const checked = this.sessions.recordCandidateConflictCheck(candidate.id, {
104
+ sessionId: session.id,
105
+ trap: editedCandidate.trap,
106
+ conflictStatus: "possible",
107
+ suggestedAction: "supersede",
108
+ });
109
+ return {
110
+ success: false,
111
+ session_id: checked.session.id,
112
+ candidate_id: checked.candidate.id,
113
+ possible_conflicts: conflicts,
114
+ };
115
+ }
116
+
117
+ const trap = editedCandidate.trap;
118
+ if (supersedesId !== undefined && !this.traps.getTrapDetails(supersedesId, String(trap.scope))) {
119
+ throw new Error(`Trap #${supersedesId} not found in ${String(trap.scope)} scope.`);
120
+ }
121
+
122
+ const added = this.traps.addTrap({ ...trap });
123
+ const evidence = this.traps.addTrapEvidence(added.id, {
124
+ source_type: "conversation",
125
+ source_ref: `session:${session.id}`,
126
+ related_files: candidateRelatedFiles(editedCandidate),
127
+ note: `Accepted from session candidate ${editedCandidate.id}`,
128
+ }, added.scope);
129
+ if (!evidence.success) throw new Error(`Failed to attach evidence to trap #${added.id}.`);
130
+
131
+ if (supersedesId !== undefined) {
132
+ const supersede = this.traps.supersedeTrap(supersedesId, added.id, added.scope);
133
+ if (!supersede.success) throw new Error(`Trap #${supersedesId} could not be superseded by trap #${added.id}.`);
134
+ }
135
+
136
+ return {
137
+ success: true,
138
+ ...this.sessions.acceptCandidate(editedCandidate.id, {
139
+ sessionId: session.id,
140
+ trap,
141
+ trapId: added.id,
142
+ scope: added.scope,
143
+ evidenceId: evidence.evidence_id,
144
+ supersededId: supersedesId ?? null,
145
+ conflictChecked: true,
146
+ conflictStatus: supersedesId !== undefined ? "confirmed" : conflicts.length > 0 ? "possible" : "none",
147
+ suggestedAction: supersedesId !== undefined ? "supersede" : "accept",
148
+ }),
149
+ };
150
+ }
151
+
152
+ rejectCandidate(request: SessionRejectRequest) {
153
+ return this.sessions.rejectCandidate(request.candidateId, {
154
+ sessionId: request.sessionId,
155
+ reason: request.reason,
156
+ });
157
+ }
158
+ }
159
+
160
+ function candidateWithTrapEdits(candidate: CandidateTrap, edit: Record<string, unknown> | undefined): CandidateTrap {
161
+ return {
162
+ ...candidate,
163
+ trap: normalizeCandidateTrap({
164
+ ...candidate.trap,
165
+ ...trapEdits(edit),
166
+ }) as CandidateTrap["trap"],
167
+ };
168
+ }
169
+
170
+ function normalizeCandidateTrap(args: Record<string, unknown>) {
171
+ return buildTrapInput({
172
+ ...args,
173
+ tags: stringArray(args.tags),
174
+ path_globs: stringArray(args.path_globs),
175
+ module: optionalText(args.module),
176
+ owner: optionalText(args.owner),
177
+ before_code: optionalText(args.before_code),
178
+ after_code: optionalText(args.after_code),
179
+ });
180
+ }
181
+
182
+ function stringArray(value: unknown): string[] | undefined {
183
+ if (Array.isArray(value)) return value.map(String).map((item) => item.trim()).filter(Boolean);
184
+ if (typeof value === "string") {
185
+ const values = value.split(",").map((item) => item.trim()).filter(Boolean);
186
+ return values.length > 0 ? values : undefined;
187
+ }
188
+ return undefined;
189
+ }
190
+
191
+ function optionalText(value: unknown): string | null | undefined {
192
+ if (value === undefined) return undefined;
193
+ if (value === null) return null;
194
+ const text = String(value).trim();
195
+ return text ? text : null;
196
+ }
197
+
198
+ function trapEdits(edit: Record<string, unknown> | undefined): Record<string, unknown> {
199
+ if (!edit) return {};
200
+ const nested = edit.trap;
201
+ return isRecord(nested) ? nested : edit;
202
+ }
203
+
204
+ function isRecord(value: unknown): value is Record<string, unknown> {
205
+ return typeof value === "object" && value !== null && !Array.isArray(value);
206
+ }
207
+
208
+ function candidateRelatedFiles(candidate: CandidateTrap): string[] {
209
+ return uniqueStrings(candidate.evidence.flatMap((evidence) => evidence.related_files ?? []));
210
+ }
211
+
212
+ function uniqueStrings(values: string[]): string[] {
213
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
214
+ }