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.
- package/README.md +64 -2
- package/docs/installation.md +25 -3
- package/package.json +3 -1
- package/plugins/codetrap-agent/.codex-plugin/plugin.json +3 -2
- package/plugins/codetrap-agent/skills/codetrap-capture-external/SKILL.md +19 -0
- package/plugins/codetrap-agent/skills/codetrap-check/SKILL.md +2 -0
- package/plugins/codetrap-agent/templates/AGENTS.codetrap.md +4 -0
- package/scripts/dogfood-eval.ts +53 -0
- package/skills/codetrap-add/SKILL.md +4 -0
- package/skills/codetrap-capture-external/SKILL.md +62 -0
- package/skills/codetrap-check/SKILL.md +3 -1
- package/skills/codetrap-search/SKILL.md +3 -1
- package/src/commands/workflow.ts +261 -2
- package/src/db/connection.ts +1 -1
- package/src/domain/session.ts +119 -0
- package/src/domain/trap.ts +1 -1
- package/src/index.ts +9 -0
- package/src/lib/command-requests.ts +156 -0
- package/src/lib/constants.ts +1 -1
- package/src/lib/search-eval.ts +412 -0
- package/src/lib/session-capture.ts +96 -0
- package/src/lib/session-codec.ts +261 -0
- package/src/lib/session-conflicts.ts +104 -0
- package/src/lib/session-operations.ts +214 -0
- package/src/lib/session-store.ts +503 -0
- package/src/lib/trap-quality.ts +111 -0
- package/src/lib/trap-scope-match.ts +1 -1
- package/src/web/project-registry.ts +106 -0
- package/src/web/server.ts +441 -0
- package/src/web/static.ts +776 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import type { CandidateTrap, CandidateTrapDocument, SessionIndexDocument, SessionMetadata, SessionNote } from "../domain/session";
|
|
10
|
+
import { parseSessionNoteKind, SESSION_VERSION } from "../domain/session";
|
|
11
|
+
import { CODETRAP_DIR } from "./constants";
|
|
12
|
+
import {
|
|
13
|
+
ACTIVE_SESSION_FILE,
|
|
14
|
+
CANDIDATES_FILE,
|
|
15
|
+
createSessionId,
|
|
16
|
+
formatImplementationNotesHeader,
|
|
17
|
+
formatRecap,
|
|
18
|
+
formatSessionNote,
|
|
19
|
+
NOTES_FILE,
|
|
20
|
+
noteCounts,
|
|
21
|
+
parseSessionNotes,
|
|
22
|
+
recapSummary,
|
|
23
|
+
RECAP_FILE,
|
|
24
|
+
sessionIndexEntry,
|
|
25
|
+
SESSION_FILE,
|
|
26
|
+
SESSION_INDEX_FILE,
|
|
27
|
+
sessionRelativeFile,
|
|
28
|
+
SESSIONS_DIR,
|
|
29
|
+
} from "./session-codec";
|
|
30
|
+
import { proposeCandidateTraps } from "./session-capture";
|
|
31
|
+
import { scoreCandidateTrap } from "./trap-quality";
|
|
32
|
+
|
|
33
|
+
export interface StartSessionArgs {
|
|
34
|
+
goal: string;
|
|
35
|
+
specRef?: string | null;
|
|
36
|
+
module?: string | null;
|
|
37
|
+
owner?: string | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AddSessionNoteArgs {
|
|
41
|
+
kind?: string;
|
|
42
|
+
text: string;
|
|
43
|
+
relatedFiles?: string[];
|
|
44
|
+
sourceRef?: string | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CloseSessionResult {
|
|
48
|
+
session: SessionMetadata;
|
|
49
|
+
recap_path: string;
|
|
50
|
+
candidate_count: number;
|
|
51
|
+
traps_written: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AcceptCandidateResult {
|
|
55
|
+
session: SessionMetadata;
|
|
56
|
+
candidate: CandidateTrap;
|
|
57
|
+
trap_id: number;
|
|
58
|
+
scope: string;
|
|
59
|
+
evidence_id: number | null;
|
|
60
|
+
superseded_id: number | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class SessionStore {
|
|
64
|
+
constructor(private readonly projectRoot: string) {}
|
|
65
|
+
|
|
66
|
+
startSession(args: StartSessionArgs, now = new Date()): SessionMetadata {
|
|
67
|
+
const active = this.activeSession();
|
|
68
|
+
if (active) {
|
|
69
|
+
throw new Error(`Session ${active.id} is already active. Close it before starting another session.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const goal = args.goal.trim();
|
|
73
|
+
if (!goal) throw new Error("Session goal is required.");
|
|
74
|
+
|
|
75
|
+
this.ensureSessionsDir();
|
|
76
|
+
const id = createSessionId(goal, now, (candidate) => existsSync(this.sessionDir(candidate)));
|
|
77
|
+
const createdAt = now.toISOString();
|
|
78
|
+
const session: SessionMetadata = {
|
|
79
|
+
version: SESSION_VERSION,
|
|
80
|
+
id,
|
|
81
|
+
goal,
|
|
82
|
+
status: "active",
|
|
83
|
+
created_at: createdAt,
|
|
84
|
+
updated_at: createdAt,
|
|
85
|
+
closed_at: null,
|
|
86
|
+
scope: "project",
|
|
87
|
+
project_path: this.projectRoot,
|
|
88
|
+
module: args.module ?? null,
|
|
89
|
+
owner: args.owner ?? null,
|
|
90
|
+
spec_ref: args.specRef ?? null,
|
|
91
|
+
notes_path: NOTES_FILE,
|
|
92
|
+
recap_path: RECAP_FILE,
|
|
93
|
+
candidate_traps_path: CANDIDATES_FILE,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
mkdirSync(this.sessionDir(id), { recursive: true });
|
|
97
|
+
this.writeSession(session);
|
|
98
|
+
writeFileSync(this.notesPath(id), formatImplementationNotesHeader(session));
|
|
99
|
+
this.writeIndexEntry(session, [], [], null);
|
|
100
|
+
this.writeActive(id);
|
|
101
|
+
return session;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
addNote(args: AddSessionNoteArgs, now = new Date()): { session: SessionMetadata; note: SessionNote; notes_path: string } {
|
|
105
|
+
const session = this.requireActiveSession();
|
|
106
|
+
const text = args.text.trim();
|
|
107
|
+
if (!text) throw new Error("Session note text is required.");
|
|
108
|
+
|
|
109
|
+
const note: SessionNote = {
|
|
110
|
+
created_at: now.toISOString(),
|
|
111
|
+
kind: parseSessionNoteKind(args.kind),
|
|
112
|
+
text,
|
|
113
|
+
related_files: uniqueStrings(args.relatedFiles ?? []),
|
|
114
|
+
source_ref: args.sourceRef ?? null,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
appendFileSync(this.notesPath(session.id), formatSessionNote(note));
|
|
118
|
+
const updated = { ...session, updated_at: note.created_at };
|
|
119
|
+
this.writeSession(updated);
|
|
120
|
+
this.writeIndexEntry(updated, this.readNotes(updated.id), this.readCandidateDocument(updated.id).candidates, null);
|
|
121
|
+
return {
|
|
122
|
+
session: updated,
|
|
123
|
+
note,
|
|
124
|
+
notes_path: sessionRelativeFile(updated.id, NOTES_FILE),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
status(): { active_session_id: string | null; session: SessionMetadata | null } {
|
|
129
|
+
const active = this.activeSession();
|
|
130
|
+
return {
|
|
131
|
+
active_session_id: active?.id ?? null,
|
|
132
|
+
session: active,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listSessions(args: { status?: string; limit?: number } = {}) {
|
|
137
|
+
const status = args.status ?? "all";
|
|
138
|
+
if (!["active", "closed", "all"].includes(status)) {
|
|
139
|
+
throw new Error("Invalid session status. Expected active, closed, or all.");
|
|
140
|
+
}
|
|
141
|
+
const sessions = this.readIndex().sessions
|
|
142
|
+
.filter((entry) => status === "all" || entry.status === status)
|
|
143
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
144
|
+
return typeof args.limit === "number" ? sessions.slice(0, args.limit) : sessions;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getSession(id: string): SessionMetadata {
|
|
148
|
+
return this.requireSession(id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
showSession(id: string): { session: SessionMetadata; recap: string | null; notes_path: string; session_dir: string } {
|
|
152
|
+
const session = this.requireSession(id);
|
|
153
|
+
return {
|
|
154
|
+
session,
|
|
155
|
+
recap: this.readOptionalText(this.recapPath(id)),
|
|
156
|
+
notes_path: sessionRelativeFile(id, NOTES_FILE),
|
|
157
|
+
session_dir: join(".codetrap", SESSIONS_DIR, id),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
summarizeNotes(id?: string): {
|
|
162
|
+
session: SessionMetadata;
|
|
163
|
+
notes_path: string;
|
|
164
|
+
content: string;
|
|
165
|
+
notes: SessionNote[];
|
|
166
|
+
note_counts: ReturnType<typeof noteCounts>;
|
|
167
|
+
latest_notes: { created_at: string; kind: string; text: string; related_files: string[] }[];
|
|
168
|
+
} {
|
|
169
|
+
const session = this.requireSession(this.resolveSessionId(id));
|
|
170
|
+
const content = this.readOptionalText(this.notesPath(session.id)) ?? "";
|
|
171
|
+
const notes = parseSessionNotes(content);
|
|
172
|
+
return {
|
|
173
|
+
session,
|
|
174
|
+
notes_path: sessionRelativeFile(session.id, NOTES_FILE),
|
|
175
|
+
content,
|
|
176
|
+
notes,
|
|
177
|
+
note_counts: noteCounts(notes),
|
|
178
|
+
latest_notes: notes.slice(-3).reverse().map((note) => ({
|
|
179
|
+
created_at: note.created_at,
|
|
180
|
+
kind: note.kind,
|
|
181
|
+
text: note.text,
|
|
182
|
+
related_files: note.related_files,
|
|
183
|
+
})),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
closeSession(id: string | undefined, proposeTraps: boolean, now = new Date()): CloseSessionResult {
|
|
188
|
+
const session = this.requireSession(this.resolveSessionId(id, { requireActive: id === undefined }));
|
|
189
|
+
if (session.status === "closed") throw new Error(`Session ${session.id} is already closed.`);
|
|
190
|
+
|
|
191
|
+
const closedAt = now.toISOString();
|
|
192
|
+
const notes = this.readNotes(session.id);
|
|
193
|
+
const candidates = proposeTraps ? proposeCandidateTraps(session, notes) : this.readCandidateDocument(session.id).candidates;
|
|
194
|
+
if (proposeTraps) this.writeCandidateDocument(session.id, candidates);
|
|
195
|
+
|
|
196
|
+
const updated = {
|
|
197
|
+
...session,
|
|
198
|
+
status: "closed" as const,
|
|
199
|
+
closed_at: closedAt,
|
|
200
|
+
updated_at: closedAt,
|
|
201
|
+
};
|
|
202
|
+
this.writeSession(updated);
|
|
203
|
+
writeFileSync(this.recapPath(updated.id), formatRecap(updated, notes, candidates));
|
|
204
|
+
this.writeIndexEntry(updated, notes, candidates, recapSummary(updated, notes, candidates));
|
|
205
|
+
if (this.readActive()?.active_session_id === updated.id) this.clearActive();
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
session: updated,
|
|
209
|
+
recap_path: sessionRelativeFile(updated.id, RECAP_FILE),
|
|
210
|
+
candidate_count: candidates.length,
|
|
211
|
+
traps_written: 0,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
candidateDocument(id?: string): CandidateTrapDocument {
|
|
216
|
+
const sessionId = this.resolveSessionId(id);
|
|
217
|
+
return this.readCandidateDocument(sessionId);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
getCandidate(candidateId: string, sessionId?: string): { session: SessionMetadata; candidate: CandidateTrap } {
|
|
221
|
+
const resolvedSessionId = this.resolveSessionId(sessionId);
|
|
222
|
+
const session = this.requireSession(resolvedSessionId);
|
|
223
|
+
const candidate = this.findCandidate(resolvedSessionId, candidateId);
|
|
224
|
+
return { session, candidate };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
recordCandidateConflictCheck(
|
|
228
|
+
candidateId: string,
|
|
229
|
+
args: {
|
|
230
|
+
sessionId?: string;
|
|
231
|
+
trap?: CandidateTrap["trap"];
|
|
232
|
+
conflictStatus: CandidateTrap["quality"]["conflict_status"];
|
|
233
|
+
suggestedAction: CandidateTrap["quality"]["suggested_action"];
|
|
234
|
+
}
|
|
235
|
+
): { session: SessionMetadata; candidate: CandidateTrap } {
|
|
236
|
+
const sessionId = this.resolveSessionId(args.sessionId);
|
|
237
|
+
const session = this.requireSession(sessionId);
|
|
238
|
+
const document = this.readCandidateDocument(sessionId);
|
|
239
|
+
const candidate = document.candidates.find((item) => item.id === candidateId);
|
|
240
|
+
if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
|
|
241
|
+
if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
|
|
242
|
+
|
|
243
|
+
if (args.trap) candidate.trap = args.trap;
|
|
244
|
+
candidate.quality.conflict_checked = true;
|
|
245
|
+
candidate.quality.conflict_status = args.conflictStatus;
|
|
246
|
+
candidate.quality.suggested_action = args.suggestedAction;
|
|
247
|
+
this.writeCandidateDocument(session.id, document.candidates);
|
|
248
|
+
this.refreshSessionSummaries(session.id);
|
|
249
|
+
return { session, candidate };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
saveCandidateTrap(
|
|
253
|
+
candidateId: string,
|
|
254
|
+
args: {
|
|
255
|
+
sessionId?: string;
|
|
256
|
+
trap: CandidateTrap["trap"];
|
|
257
|
+
}
|
|
258
|
+
): { session: SessionMetadata; candidate: CandidateTrap } {
|
|
259
|
+
const sessionId = this.resolveSessionId(args.sessionId);
|
|
260
|
+
const session = this.requireSession(sessionId);
|
|
261
|
+
const document = this.readCandidateDocument(sessionId);
|
|
262
|
+
const candidate = document.candidates.find((item) => item.id === candidateId);
|
|
263
|
+
if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
|
|
264
|
+
if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
|
|
265
|
+
|
|
266
|
+
const scored = scoreCandidateTrap({ trap: args.trap, evidence: candidate.evidence });
|
|
267
|
+
candidate.trap = args.trap;
|
|
268
|
+
candidate.quality_score = scored.score;
|
|
269
|
+
candidate.quality = scored.quality;
|
|
270
|
+
this.writeCandidateDocument(session.id, document.candidates);
|
|
271
|
+
this.refreshSessionSummaries(session.id);
|
|
272
|
+
return { session, candidate };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
acceptCandidate(
|
|
276
|
+
candidateId: string,
|
|
277
|
+
args: {
|
|
278
|
+
sessionId?: string;
|
|
279
|
+
trap?: CandidateTrap["trap"];
|
|
280
|
+
trapId: number;
|
|
281
|
+
scope: string;
|
|
282
|
+
evidenceId: number | null;
|
|
283
|
+
supersededId?: number | null;
|
|
284
|
+
conflictChecked?: boolean;
|
|
285
|
+
conflictStatus?: CandidateTrap["quality"]["conflict_status"];
|
|
286
|
+
suggestedAction?: CandidateTrap["quality"]["suggested_action"];
|
|
287
|
+
},
|
|
288
|
+
now = new Date()
|
|
289
|
+
): AcceptCandidateResult {
|
|
290
|
+
const sessionId = this.resolveSessionId(args.sessionId);
|
|
291
|
+
const session = this.requireSession(sessionId);
|
|
292
|
+
const document = this.readCandidateDocument(sessionId);
|
|
293
|
+
const candidate = document.candidates.find((item) => item.id === candidateId);
|
|
294
|
+
if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
|
|
295
|
+
if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
|
|
296
|
+
|
|
297
|
+
if (args.trap) candidate.trap = args.trap;
|
|
298
|
+
candidate.status = "accepted";
|
|
299
|
+
candidate.accepted_trap_id = args.trapId;
|
|
300
|
+
candidate.accepted_scope = args.scope === "project" ? "project" : "global";
|
|
301
|
+
candidate.accepted_at = now.toISOString();
|
|
302
|
+
candidate.quality.conflict_checked = args.conflictChecked ?? candidate.quality.conflict_checked;
|
|
303
|
+
candidate.quality.conflict_status = args.conflictStatus ?? candidate.quality.conflict_status;
|
|
304
|
+
candidate.quality.suggested_action = args.suggestedAction ?? candidate.quality.suggested_action;
|
|
305
|
+
this.writeCandidateDocument(session.id, document.candidates);
|
|
306
|
+
this.refreshSessionSummaries(session.id);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
session,
|
|
310
|
+
candidate,
|
|
311
|
+
trap_id: args.trapId,
|
|
312
|
+
scope: args.scope,
|
|
313
|
+
evidence_id: args.evidenceId,
|
|
314
|
+
superseded_id: args.supersededId ?? null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
rejectCandidate(
|
|
319
|
+
candidateId: string,
|
|
320
|
+
args: { sessionId?: string; reason?: string | null },
|
|
321
|
+
now = new Date()
|
|
322
|
+
): { session: SessionMetadata; candidate: CandidateTrap } {
|
|
323
|
+
const sessionId = this.resolveSessionId(args.sessionId);
|
|
324
|
+
const session = this.requireSession(sessionId);
|
|
325
|
+
const document = this.readCandidateDocument(sessionId);
|
|
326
|
+
const candidate = document.candidates.find((item) => item.id === candidateId);
|
|
327
|
+
if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
|
|
328
|
+
if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
|
|
329
|
+
|
|
330
|
+
candidate.status = "rejected";
|
|
331
|
+
candidate.rejected_at = now.toISOString();
|
|
332
|
+
if (args.reason) candidate.rejection_reason = args.reason;
|
|
333
|
+
this.writeCandidateDocument(session.id, document.candidates);
|
|
334
|
+
this.refreshSessionSummaries(session.id);
|
|
335
|
+
return { session, candidate };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
readNotes(id: string): SessionNote[] {
|
|
339
|
+
return parseSessionNotes(this.readOptionalText(this.notesPath(id)) ?? "");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private activeSession(): SessionMetadata | null {
|
|
343
|
+
const active = this.readActive();
|
|
344
|
+
if (active?.active_session_id) {
|
|
345
|
+
const session = this.getSessionOrNull(active.active_session_id);
|
|
346
|
+
if (session?.status === "active") return session;
|
|
347
|
+
this.clearActive();
|
|
348
|
+
}
|
|
349
|
+
return this.readIndex().sessions
|
|
350
|
+
.filter((entry) => entry.status === "active")
|
|
351
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
352
|
+
.map((entry) => this.getSessionOrNull(entry.id))
|
|
353
|
+
.find((session): session is SessionMetadata => session !== null) ?? null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private resolveSessionId(id?: string, opts: { requireActive?: boolean } = {}): string {
|
|
357
|
+
if (id) return id;
|
|
358
|
+
const active = this.activeSession();
|
|
359
|
+
if (active) return active.id;
|
|
360
|
+
if (opts.requireActive) throw new Error("No active session. Start one with `codetrap session start <goal>`.");
|
|
361
|
+
const latest = this.listSessions({ status: "all", limit: 1 })[0];
|
|
362
|
+
if (!latest) throw new Error("No sessions found.");
|
|
363
|
+
return latest.id;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private requireActiveSession(): SessionMetadata {
|
|
367
|
+
const active = this.activeSession();
|
|
368
|
+
if (!active) throw new Error("No active session. Start one with `codetrap session start <goal>`.");
|
|
369
|
+
return active;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private requireSession(id: string): SessionMetadata {
|
|
373
|
+
const session = this.getSessionOrNull(id);
|
|
374
|
+
if (!session) throw new Error(`Session ${id} not found.`);
|
|
375
|
+
return session;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private getSessionOrNull(id: string): SessionMetadata | null {
|
|
379
|
+
const path = this.sessionJsonPath(id);
|
|
380
|
+
if (!existsSync(path)) return null;
|
|
381
|
+
return JSON.parse(readFileSync(path, "utf-8")) as SessionMetadata;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private findCandidate(sessionId: string, candidateId: string): CandidateTrap {
|
|
385
|
+
const candidate = this.readCandidateDocument(sessionId).candidates.find((item) => item.id === candidateId);
|
|
386
|
+
if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
|
|
387
|
+
return candidate;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private readCandidateDocument(id: string): CandidateTrapDocument {
|
|
391
|
+
const path = this.candidatesPath(id);
|
|
392
|
+
if (!existsSync(path)) {
|
|
393
|
+
return { version: SESSION_VERSION, session_id: id, candidates: [] };
|
|
394
|
+
}
|
|
395
|
+
return JSON.parse(readFileSync(path, "utf-8")) as CandidateTrapDocument;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private writeCandidateDocument(id: string, candidates: CandidateTrap[]): void {
|
|
399
|
+
writeFileSync(this.candidatesPath(id), `${JSON.stringify({
|
|
400
|
+
version: SESSION_VERSION,
|
|
401
|
+
session_id: id,
|
|
402
|
+
candidates,
|
|
403
|
+
} satisfies CandidateTrapDocument, null, 2)}\n`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
private refreshSessionSummaries(id: string): void {
|
|
407
|
+
const session = this.requireSession(id);
|
|
408
|
+
const notes = this.readNotes(id);
|
|
409
|
+
const candidates = this.readCandidateDocument(id).candidates;
|
|
410
|
+
writeFileSync(this.recapPath(id), formatRecap(session, notes, candidates));
|
|
411
|
+
this.writeIndexEntry(session, notes, candidates, recapSummary(session, notes, candidates));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private writeSession(session: SessionMetadata): void {
|
|
415
|
+
writeFileSync(this.sessionJsonPath(session.id), `${JSON.stringify(session, null, 2)}\n`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private readIndex(): SessionIndexDocument {
|
|
419
|
+
const path = this.indexPath();
|
|
420
|
+
if (!existsSync(path)) return { version: SESSION_VERSION, sessions: [] };
|
|
421
|
+
return JSON.parse(readFileSync(path, "utf-8")) as SessionIndexDocument;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private writeIndexEntry(
|
|
425
|
+
session: SessionMetadata,
|
|
426
|
+
notes: SessionNote[],
|
|
427
|
+
candidates: CandidateTrap[],
|
|
428
|
+
summary: string | null
|
|
429
|
+
): void {
|
|
430
|
+
this.ensureSessionsDir();
|
|
431
|
+
const index = this.readIndex();
|
|
432
|
+
const existing = index.sessions.find((entry) => entry.id === session.id);
|
|
433
|
+
const entry = sessionIndexEntry(session, notes, candidates, summary ?? existing?.summary ?? null);
|
|
434
|
+
const sessions = [entry, ...index.sessions.filter((item) => item.id !== session.id)]
|
|
435
|
+
.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
436
|
+
writeFileSync(this.indexPath(), `${JSON.stringify({ version: SESSION_VERSION, sessions } satisfies SessionIndexDocument, null, 2)}\n`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private readActive(): { active_session_id: string | null; updated_at: string } | null {
|
|
440
|
+
const path = this.activePath();
|
|
441
|
+
if (!existsSync(path)) return null;
|
|
442
|
+
return JSON.parse(readFileSync(path, "utf-8")) as { active_session_id: string | null; updated_at: string };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
private writeActive(id: string): void {
|
|
446
|
+
writeFileSync(this.activePath(), `${JSON.stringify({
|
|
447
|
+
active_session_id: id,
|
|
448
|
+
updated_at: new Date().toISOString(),
|
|
449
|
+
}, null, 2)}\n`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private clearActive(): void {
|
|
453
|
+
this.ensureSessionsDir();
|
|
454
|
+
writeFileSync(this.activePath(), `${JSON.stringify({
|
|
455
|
+
active_session_id: null,
|
|
456
|
+
updated_at: new Date().toISOString(),
|
|
457
|
+
}, null, 2)}\n`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
private readOptionalText(path: string): string | null {
|
|
461
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private ensureSessionsDir(): void {
|
|
465
|
+
mkdirSync(this.sessionsDir(), { recursive: true });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private sessionsDir(): string {
|
|
469
|
+
return join(this.projectRoot, CODETRAP_DIR, SESSIONS_DIR);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
private sessionDir(id: string): string {
|
|
473
|
+
return join(this.sessionsDir(), id);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private activePath(): string {
|
|
477
|
+
return join(this.sessionsDir(), ACTIVE_SESSION_FILE);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private indexPath(): string {
|
|
481
|
+
return join(this.sessionsDir(), SESSION_INDEX_FILE);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
private sessionJsonPath(id: string): string {
|
|
485
|
+
return join(this.sessionDir(id), SESSION_FILE);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private notesPath(id: string): string {
|
|
489
|
+
return join(this.sessionDir(id), NOTES_FILE);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private recapPath(id: string): string {
|
|
493
|
+
return join(this.sessionDir(id), RECAP_FILE);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private candidatesPath(id: string): string {
|
|
497
|
+
return join(this.sessionDir(id), CANDIDATES_FILE);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function uniqueStrings(values: string[]): string[] {
|
|
502
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
503
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { CandidateQuality, CandidateTrap } from "../domain/session";
|
|
2
|
+
|
|
3
|
+
type CandidateDraft = Pick<CandidateTrap, "trap" | "evidence">;
|
|
4
|
+
|
|
5
|
+
export function scoreCandidateTrap(candidate: CandidateDraft): { score: number; quality: CandidateQuality } {
|
|
6
|
+
const trap = candidate.trap;
|
|
7
|
+
const evidenceCount = candidate.evidence.length;
|
|
8
|
+
const hasClearTrigger = hasMeaningfulText(trap.context) && hasTriggerLanguage(trap.context);
|
|
9
|
+
const hasClearMistake = hasMeaningfulText(trap.mistake);
|
|
10
|
+
const hasActionableFix = hasMeaningfulText(trap.fix) && hasActionLanguage(trap.fix);
|
|
11
|
+
const futureReuseLikely = hasFutureReuseSignal(candidate);
|
|
12
|
+
const properScope = trap.scope === "global" || hasSpecificScope(candidate);
|
|
13
|
+
const notTooBroad = !isTooBroad(candidate);
|
|
14
|
+
|
|
15
|
+
const warnings: string[] = [];
|
|
16
|
+
if (!hasClearTrigger) warnings.push("context does not clearly describe when the trap applies");
|
|
17
|
+
if (!hasClearMistake) warnings.push("mistake is not specific enough");
|
|
18
|
+
if (!hasActionableFix) warnings.push("fix is not actionable enough");
|
|
19
|
+
if (!futureReuseLikely) warnings.push("future reuse is unclear");
|
|
20
|
+
if (!properScope) warnings.push("scope is too loose for a project trap");
|
|
21
|
+
if (!notTooBroad) warnings.push("candidate reads like a broad reminder rather than a durable trap");
|
|
22
|
+
if (evidenceCount === 0) warnings.push("candidate has no evidence");
|
|
23
|
+
|
|
24
|
+
const score =
|
|
25
|
+
(hasClearTrigger ? 0.2 : 0) +
|
|
26
|
+
(hasClearMistake ? 0.2 : 0) +
|
|
27
|
+
(hasActionableFix ? 0.2 : 0) +
|
|
28
|
+
(futureReuseLikely ? 0.15 : 0) +
|
|
29
|
+
(properScope ? 0.1 : 0) +
|
|
30
|
+
(evidenceCount > 0 ? 0.1 : 0) +
|
|
31
|
+
(notTooBroad ? 0.05 : 0);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
score: roundScore(score),
|
|
35
|
+
quality: {
|
|
36
|
+
has_clear_trigger: hasClearTrigger,
|
|
37
|
+
has_clear_mistake: hasClearMistake,
|
|
38
|
+
has_actionable_fix: hasActionableFix,
|
|
39
|
+
not_too_broad: notTooBroad,
|
|
40
|
+
future_reuse_likely: futureReuseLikely,
|
|
41
|
+
proper_scope: properScope,
|
|
42
|
+
evidence_count: evidenceCount,
|
|
43
|
+
conflict_checked: false,
|
|
44
|
+
conflict_status: "none",
|
|
45
|
+
staleness_risk: "low",
|
|
46
|
+
suggested_action: suggestedAction(score),
|
|
47
|
+
warnings,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasMeaningfulText(value: string | undefined): boolean {
|
|
53
|
+
return typeof value === "string" && value.replace(/\s+/g, " ").trim().length >= 24;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasTriggerLanguage(value: string): boolean {
|
|
57
|
+
const normalized = value.toLowerCase();
|
|
58
|
+
return /\bwhen\b|\bwhile\b|\bduring\b|\bif\b/.test(normalized) || /当|如果|处理|实现|修改/.test(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hasActionLanguage(value: string): boolean {
|
|
62
|
+
const normalized = value.toLowerCase();
|
|
63
|
+
return /\buse\b|\bavoid\b|\bprefer\b|\bkeep\b|\bcheck\b|\bcall\b|\bwrite\b|\badd\b|\bverify\b/.test(normalized)
|
|
64
|
+
|| /使用|避免|优先|检查|验证|改用|保留|调用/.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasFutureReuseSignal(candidate: CandidateDraft): boolean {
|
|
68
|
+
const tags = candidate.trap.tags ?? [];
|
|
69
|
+
return tags.length > 0
|
|
70
|
+
|| (candidate.trap.path_globs?.length ?? 0) > 0
|
|
71
|
+
|| Boolean(candidate.trap.module)
|
|
72
|
+
|| Boolean(candidate.trap.owner);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasSpecificScope(candidate: CandidateDraft): boolean {
|
|
76
|
+
return (candidate.trap.path_globs?.length ?? 0) > 0
|
|
77
|
+
|| Boolean(candidate.trap.module)
|
|
78
|
+
|| Boolean(candidate.trap.owner)
|
|
79
|
+
|| candidate.evidence.some((evidence) => (evidence.related_files?.length ?? 0) > 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTooBroad(candidate: CandidateDraft): boolean {
|
|
83
|
+
const combined = [
|
|
84
|
+
candidate.trap.title,
|
|
85
|
+
candidate.trap.context,
|
|
86
|
+
candidate.trap.mistake,
|
|
87
|
+
candidate.trap.fix,
|
|
88
|
+
].join(" ").toLowerCase();
|
|
89
|
+
|
|
90
|
+
const broadPhrases = [
|
|
91
|
+
"read the docs",
|
|
92
|
+
"write better code",
|
|
93
|
+
"be careful",
|
|
94
|
+
"check everything",
|
|
95
|
+
"always test",
|
|
96
|
+
"先看文档",
|
|
97
|
+
"小心",
|
|
98
|
+
"写好代码",
|
|
99
|
+
];
|
|
100
|
+
return broadPhrases.some((phrase) => combined.includes(phrase));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function roundScore(value: number): number {
|
|
104
|
+
return Math.round(value * 100) / 100;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function suggestedAction(score: number): CandidateQuality["suggested_action"] {
|
|
108
|
+
if (score >= 0.8) return "accept";
|
|
109
|
+
if (score >= 0.6) return "edit";
|
|
110
|
+
return "reject";
|
|
111
|
+
}
|
|
@@ -46,7 +46,7 @@ export function normalizePath(path: string): string {
|
|
|
46
46
|
return path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function globMatchesPath(glob: string, path: string): boolean {
|
|
49
|
+
export function globMatchesPath(glob: string, path: string): boolean {
|
|
50
50
|
const normalizedGlob = normalizePath(glob);
|
|
51
51
|
return new RegExp(`^${globToRegExp(normalizedGlob)}$`).test(path);
|
|
52
52
|
}
|