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
@@ -10,6 +10,15 @@ import { join } from "node:path";
10
10
  import type { CandidateTrap, CandidateTrapDocument, SessionIndexDocument, SessionMetadata, SessionNote } from "../domain/session";
11
11
  import { parseSessionNoteKind, SESSION_VERSION } from "../domain/session";
12
12
  import { CODETRAP_DIR } from "./constants";
13
+ import {
14
+ acceptCandidateInDocument,
15
+ addCandidateToDocument,
16
+ recordCandidateConflictCheckInDocument,
17
+ rejectCandidateInDocument,
18
+ removeCandidatesFromDocument,
19
+ saveCandidateTrapInDocument,
20
+ type CandidateDocumentUpdateResult,
21
+ } from "./session-candidate-document";
13
22
  import {
14
23
  ACTIVE_SESSION_FILE,
15
24
  CANDIDATES_FILE,
@@ -29,8 +38,8 @@ import {
29
38
  sessionRelativeFile,
30
39
  SESSIONS_DIR,
31
40
  } from "./session-codec";
32
- import { proposeCandidateTraps } from "./session-capture";
33
- import { scoreCandidateTrap } from "./trap-quality";
41
+ import { uniqueStrings } from "./string-list";
42
+ import { mergeCandidateTraps, proposeCandidateTraps, type CandidateDraft } from "./session-capture";
34
43
 
35
44
  export interface StartSessionArgs {
36
45
  goal: string;
@@ -50,7 +59,6 @@ export interface CloseSessionResult {
50
59
  session: SessionMetadata;
51
60
  recap_path: string;
52
61
  candidate_count: number;
53
- traps_written: number;
54
62
  }
55
63
 
56
64
  export interface AcceptCandidateResult {
@@ -89,6 +97,11 @@ export interface RemoveSessionCandidatesResult {
89
97
  candidates: CandidateTrap[];
90
98
  }
91
99
 
100
+ export interface AddSessionCandidateArgs {
101
+ sessionId: string;
102
+ draft: CandidateDraft;
103
+ }
104
+
92
105
  export class SessionStore {
93
106
  constructor(private readonly projectRoot: string) {}
94
107
 
@@ -219,7 +232,10 @@ export class SessionStore {
219
232
 
220
233
  const closedAt = now.toISOString();
221
234
  const notes = this.readNotes(session.id);
222
- const candidates = proposeTraps ? proposeCandidateTraps(session, notes) : this.readCandidateDocument(session.id).candidates;
235
+ const existingCandidates = this.readCandidateDocument(session.id).candidates;
236
+ const candidates = proposeTraps
237
+ ? mergeCandidateTraps(existingCandidates, proposeCandidateTraps(session, notes))
238
+ : existingCandidates;
223
239
  if (proposeTraps) this.writeCandidateDocument(session.id, candidates);
224
240
 
225
241
  const updated = {
@@ -237,7 +253,6 @@ export class SessionStore {
237
253
  session: updated,
238
254
  recap_path: sessionRelativeFile(updated.id, RECAP_FILE),
239
255
  candidate_count: candidates.length,
240
- traps_written: 0,
241
256
  };
242
257
  }
243
258
 
@@ -253,6 +268,22 @@ export class SessionStore {
253
268
  return { session, candidate };
254
269
  }
255
270
 
271
+ addCandidate(args: AddSessionCandidateArgs): { session: SessionMetadata; candidate: CandidateTrap; candidates_path: string; duplicate: boolean } {
272
+ const session = this.requireSession(args.sessionId);
273
+ const document = this.readCandidateDocument(session.id);
274
+ const added = addCandidateToDocument(document.candidates, args.draft);
275
+ if (!added.duplicate) {
276
+ this.writeCandidateDocument(session.id, added.candidates);
277
+ this.refreshSessionSummaries(session.id);
278
+ }
279
+ return {
280
+ session: this.requireSession(session.id),
281
+ candidate: added.candidate,
282
+ candidates_path: sessionRelativeFile(session.id, CANDIDATES_FILE),
283
+ duplicate: added.duplicate,
284
+ };
285
+ }
286
+
256
287
  recordCandidateConflictCheck(
257
288
  candidateId: string,
258
289
  args: {
@@ -265,17 +296,16 @@ export class SessionStore {
265
296
  const sessionId = this.resolveSessionId(args.sessionId);
266
297
  const session = this.requireSession(sessionId);
267
298
  const document = this.readCandidateDocument(sessionId);
268
- const candidate = document.candidates.find((item) => item.id === candidateId);
269
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
270
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
271
-
272
- if (args.trap) candidate.trap = args.trap;
273
- candidate.quality.conflict_checked = true;
274
- candidate.quality.conflict_status = args.conflictStatus;
275
- candidate.quality.suggested_action = args.suggestedAction;
276
- this.writeCandidateDocument(session.id, document.candidates);
277
- this.refreshSessionSummaries(session.id);
278
- return { session, candidate };
299
+ return this.saveCandidateDocumentMutation(session, recordCandidateConflictCheckInDocument(
300
+ document.candidates,
301
+ candidateId,
302
+ {
303
+ sessionId,
304
+ trap: args.trap,
305
+ conflictStatus: args.conflictStatus,
306
+ suggestedAction: args.suggestedAction,
307
+ }
308
+ ));
279
309
  }
280
310
 
281
311
  saveCandidateTrap(
@@ -288,17 +318,11 @@ export class SessionStore {
288
318
  const sessionId = this.resolveSessionId(args.sessionId);
289
319
  const session = this.requireSession(sessionId);
290
320
  const document = this.readCandidateDocument(sessionId);
291
- const candidate = document.candidates.find((item) => item.id === candidateId);
292
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
293
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
294
-
295
- const scored = scoreCandidateTrap({ trap: args.trap, evidence: candidate.evidence });
296
- candidate.trap = args.trap;
297
- candidate.quality_score = scored.score;
298
- candidate.quality = scored.quality;
299
- this.writeCandidateDocument(session.id, document.candidates);
300
- this.refreshSessionSummaries(session.id);
301
- return { session, candidate };
321
+ return this.saveCandidateDocumentMutation(session, saveCandidateTrapInDocument(
322
+ document.candidates,
323
+ candidateId,
324
+ { sessionId, trap: args.trap }
325
+ ));
302
326
  }
303
327
 
304
328
  acceptCandidate(
@@ -319,24 +343,24 @@ export class SessionStore {
319
343
  const sessionId = this.resolveSessionId(args.sessionId);
320
344
  const session = this.requireSession(sessionId);
321
345
  const document = this.readCandidateDocument(sessionId);
322
- const candidate = document.candidates.find((item) => item.id === candidateId);
323
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
324
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
325
-
326
- if (args.trap) candidate.trap = args.trap;
327
- candidate.status = "accepted";
328
- candidate.accepted_trap_id = args.trapId;
329
- candidate.accepted_scope = args.scope === "project" ? "project" : "global";
330
- candidate.accepted_at = now.toISOString();
331
- candidate.quality.conflict_checked = args.conflictChecked ?? candidate.quality.conflict_checked;
332
- candidate.quality.conflict_status = args.conflictStatus ?? candidate.quality.conflict_status;
333
- candidate.quality.suggested_action = args.suggestedAction ?? candidate.quality.suggested_action;
334
- this.writeCandidateDocument(session.id, document.candidates);
335
- this.refreshSessionSummaries(session.id);
346
+ const accepted = this.saveCandidateDocumentMutation(session, acceptCandidateInDocument(
347
+ document.candidates,
348
+ candidateId,
349
+ {
350
+ sessionId,
351
+ trap: args.trap,
352
+ trapId: args.trapId,
353
+ scope: args.scope,
354
+ conflictChecked: args.conflictChecked,
355
+ conflictStatus: args.conflictStatus,
356
+ suggestedAction: args.suggestedAction,
357
+ },
358
+ now
359
+ ));
336
360
 
337
361
  return {
338
362
  session,
339
- candidate,
363
+ candidate: accepted.candidate,
340
364
  trap_id: args.trapId,
341
365
  scope: args.scope,
342
366
  evidence_id: args.evidenceId,
@@ -352,44 +376,29 @@ export class SessionStore {
352
376
  const sessionId = this.resolveSessionId(args.sessionId);
353
377
  const session = this.requireSession(sessionId);
354
378
  const document = this.readCandidateDocument(sessionId);
355
- const candidate = document.candidates.find((item) => item.id === candidateId);
356
- if (!candidate) throw new Error(`Candidate ${candidateId} not found in session ${sessionId}.`);
357
- if (candidate.status !== "proposed") throw new Error(`Candidate ${candidateId} is already ${candidate.status}.`);
358
-
359
- candidate.status = "rejected";
360
- candidate.rejected_at = now.toISOString();
361
- if (args.reason) candidate.rejection_reason = args.reason;
362
- this.writeCandidateDocument(session.id, document.candidates);
363
- this.refreshSessionSummaries(session.id);
364
- return { session, candidate };
379
+ return this.saveCandidateDocumentMutation(session, rejectCandidateInDocument(
380
+ document.candidates,
381
+ candidateId,
382
+ { sessionId, reason: args.reason },
383
+ now
384
+ ));
365
385
  }
366
386
 
367
387
  removeCandidates(sessionId: string | undefined, candidateIds: string[]): RemoveSessionCandidatesResult {
368
388
  const resolvedSessionId = this.resolveSessionId(sessionId);
369
389
  const session = this.requireSession(resolvedSessionId);
370
- const removeIds = new Set(uniqueStrings(candidateIds));
371
- if (removeIds.size === 0) {
372
- return {
373
- session,
374
- removed_count: 0,
375
- removed_candidate_ids: [],
376
- candidates: this.readCandidateDocument(session.id).candidates,
377
- };
378
- }
379
-
380
390
  const document = this.readCandidateDocument(session.id);
381
- const removed = document.candidates.filter((candidate) => removeIds.has(candidate.id));
382
- const candidates = document.candidates.filter((candidate) => !removeIds.has(candidate.id));
383
- if (removed.length > 0) {
384
- this.writeCandidateDocument(session.id, candidates);
391
+ const removed = removeCandidatesFromDocument(document.candidates, candidateIds);
392
+ if (removed.removed.length > 0) {
393
+ this.writeCandidateDocument(session.id, removed.candidates);
385
394
  this.refreshSessionSummaries(session.id);
386
395
  }
387
396
 
388
397
  return {
389
398
  session,
390
- removed_count: removed.length,
391
- removed_candidate_ids: removed.map((candidate) => candidate.id),
392
- candidates,
399
+ removed_count: removed.removed.length,
400
+ removed_candidate_ids: removed.removed.map((candidate) => candidate.id),
401
+ candidates: removed.candidates,
393
402
  };
394
403
  }
395
404
 
@@ -488,6 +497,15 @@ export class SessionStore {
488
497
  return candidate;
489
498
  }
490
499
 
500
+ private saveCandidateDocumentMutation(
501
+ session: SessionMetadata,
502
+ mutation: CandidateDocumentUpdateResult
503
+ ): { session: SessionMetadata; candidate: CandidateTrap } {
504
+ this.writeCandidateDocument(session.id, mutation.candidates);
505
+ this.refreshSessionSummaries(session.id);
506
+ return { session, candidate: mutation.candidate };
507
+ }
508
+
491
509
  private readCandidateDocument(id: string): CandidateTrapDocument {
492
510
  const path = this.candidatesPath(id);
493
511
  if (!existsSync(path)) {
@@ -605,7 +623,3 @@ export class SessionStore {
605
623
  return join(this.sessionDir(id), CANDIDATES_FILE);
606
624
  }
607
625
  }
608
-
609
- function uniqueStrings(values: string[]): string[] {
610
- return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
611
- }
package/src/lib/store.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EmbeddingProfileSummary } from "../db/embedding-queries";
1
2
  import type { TrapStats } from "../db/repository";
2
3
  import {
3
4
  type Trap,
@@ -10,7 +11,20 @@ import {
10
11
  type TrapUpdate,
11
12
  } from "../domain/trap";
12
13
  import type { SearchMode, TrapStatus } from "./constants";
13
- import { createDefaultEmbeddingProvider, embeddingConfig, type EmbeddingConfig, type EmbeddingProvider } from "./embedder";
14
+ import type { EmbeddingConfig } from "./embedder";
15
+ import {
16
+ defaultEmbeddingRuntime,
17
+ embeddingRuntimeFrom,
18
+ type EmbeddingRuntime,
19
+ type EmbeddingRuntimeInput,
20
+ type EmbeddingRuntimeStatus,
21
+ } from "./embedding-runtime";
22
+ import {
23
+ type ConfigWriteResult,
24
+ type EmbeddingSettings,
25
+ loadCodetrapConfig,
26
+ setCodetrapEmbeddingSettings,
27
+ } from "./config";
14
28
  import { summarizeEmbeddingState, type EmbeddingStateSummary, type EmbeddingStatsResult } from "./embedding-health";
15
29
  import { normalizeScope, ScopedRepositoryContext, type ScopedRepository } from "./scope-context";
16
30
  import { importTrapArchive } from "./trap-archive";
@@ -33,18 +47,32 @@ export {
33
47
 
34
48
  export type { EmbeddingStateSummary };
35
49
  export type TrapEmbeddingStats = EmbeddingStatsResult;
50
+ export type TrapEmbeddingProfiles = {
51
+ project: EmbeddingProfileSummary[] | null;
52
+ global: EmbeddingProfileSummary[] | null;
53
+ };
54
+ export type EmbeddingScopeStatus = EmbeddingStateSummary & {
55
+ profiles: EmbeddingProfileSummary[];
56
+ };
57
+ export type TrapEmbeddingStatus = {
58
+ runtime: EmbeddingRuntimeStatus;
59
+ project: EmbeddingScopeStatus | null;
60
+ global: EmbeddingScopeStatus | null;
61
+ };
36
62
 
37
63
  export class TrapStore {
38
64
  private readonly scopes: ScopedRepositoryContext;
39
- private readonly embedder?: EmbeddingProvider;
65
+ private readonly embeddings: EmbeddingRuntime;
40
66
 
41
67
  constructor(
42
68
  cwd: string,
43
- embedder: EmbeddingProvider | undefined = createDefaultEmbeddingProvider(),
69
+ embeddings?: EmbeddingRuntimeInput,
44
70
  private readonly home?: string
45
71
  ) {
46
- this.embedder = embedder;
47
- this.scopes = new ScopedRepositoryContext(cwd, embedder, home);
72
+ this.embeddings = embeddings === undefined
73
+ ? defaultEmbeddingRuntime(process.env, loadCodetrapConfig(home))
74
+ : embeddingRuntimeFrom(embeddings);
75
+ this.scopes = new ScopedRepositoryContext(cwd, this.embeddings, home);
48
76
  }
49
77
 
50
78
  add(input: TrapInput): { id: number; scope: string } {
@@ -199,6 +227,34 @@ export class TrapStore {
199
227
  };
200
228
  }
201
229
 
230
+ embeddingProfiles(opts: { scope?: string } = {}): TrapEmbeddingProfiles {
231
+ const scope = opts.scope ? normalizeScope(opts.scope) : null;
232
+ const project = scope === "global"
233
+ ? null
234
+ : this.scopes.repositoryEntry("project")
235
+ ? this.scopes.repositoryFor("project").embeddingProfiles({ scope: "project" })
236
+ : null;
237
+ const global = scope === "project"
238
+ ? null
239
+ : this.scopes.repositoryFor("global").embeddingProfiles({ scope: "global" });
240
+ return { project, global };
241
+ }
242
+
243
+ async embeddingStatus(opts: { scope?: string } = {}): Promise<TrapEmbeddingStatus> {
244
+ const runtime = await this.embeddingRuntimeHealth();
245
+ const stats = this.embeddingStats(opts);
246
+ const profiles = this.embeddingProfiles(opts);
247
+ return {
248
+ runtime,
249
+ project: stats.project
250
+ ? { ...stats.project, profiles: profiles.project ?? [] }
251
+ : null,
252
+ global: stats.global
253
+ ? { ...stats.global, profiles: profiles.global ?? [] }
254
+ : null,
255
+ };
256
+ }
257
+
202
258
  diagnostics(): {
203
259
  mis_scoped_traps: {
204
260
  global_db_project_traps: Pick<Trap, "id" | "title" | "scope" | "project_path" | "status">[];
@@ -242,16 +298,24 @@ export class TrapStore {
242
298
  return this.scopes.projectRoot();
243
299
  }
244
300
 
245
- hasEmbeddingProvider(): boolean {
246
- return this.embedder !== undefined;
301
+ embeddingConfig(): EmbeddingConfig | null {
302
+ return this.embeddings.config();
247
303
  }
248
304
 
249
- embeddingConfig(): EmbeddingConfig | null {
250
- return this.embedder ? embeddingConfig(this.embedder) : null;
305
+ embeddingRuntimeStatus(): EmbeddingRuntimeStatus {
306
+ return this.embeddings.status();
307
+ }
308
+
309
+ embeddingRuntimeHealth(): Promise<EmbeddingRuntimeStatus> {
310
+ return this.embeddings.health();
311
+ }
312
+
313
+ configureEmbeddings(settings: EmbeddingSettings): ConfigWriteResult {
314
+ return setCodetrapEmbeddingSettings(settings, this.home);
251
315
  }
252
316
 
253
317
  forCwd(cwd: string): TrapStore {
254
- return new TrapStore(cwd, this.embedder, this.home);
318
+ return new TrapStore(cwd, this.embeddings, this.home);
255
319
  }
256
320
 
257
321
  async ensureEmbeddings(opts: { scope?: string; category?: string; limit?: number; force?: boolean; batchSize?: number } = {}): Promise<{
@@ -0,0 +1,3 @@
1
+ export function uniqueStrings(values: string[]): string[] {
2
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
3
+ }
@@ -0,0 +1,7 @@
1
+ export function trimOuterBlankLines(lines: string[]): string[] {
2
+ let start = 0;
3
+ let end = lines.length;
4
+ while (start < end && lines[start].trim() === "") start++;
5
+ while (end > start && lines[end - 1].trim() === "") end--;
6
+ return lines.slice(start, end);
7
+ }
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { Trap, TrapInput, TrapUpdate } from "../domain/trap";
3
- import type { EmbeddingConfig, StoredEmbedding } from "./embedder";
3
+ import { embeddingProfileId, type EmbeddingConfig, type StoredEmbedding } from "./embedder";
4
4
  import { buildSearchText, SEARCH_TEXT_FIELD_NAMES, type SearchTextFields } from "./search-normalizer";
5
5
  import { parseTrapPathGlobs, parseTrapTags } from "./trap-json-fields";
6
6
 
@@ -74,6 +74,7 @@ export function embeddingIsFresh(trap: Trap, embedding: StoredEmbedding | null,
74
74
  embedding.provider === config.provider &&
75
75
  embedding.model === config.model &&
76
76
  embedding.dimensions === config.dimensions &&
77
+ embedding.profile_id === embeddingProfileId(config) &&
77
78
  embedding.passage_version === config.passageVersion &&
78
79
  embedding.passage_hash === passageHashForTrap(trap)
79
80
  );
@@ -0,0 +1,3 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
@@ -0,0 +1,171 @@
1
+ export type ReviewSessionLike = {
2
+ id: string;
3
+ pending_count?: number | null;
4
+ };
5
+
6
+ export type ReviewCandidateView = "inbox" | "reviewed";
7
+
8
+ export type ReviewCandidateLike = {
9
+ id: string;
10
+ status: string;
11
+ quality_score: number;
12
+ };
13
+
14
+ export type ReviewSummaryLike = {
15
+ pending_count?: number | null;
16
+ pending_session_count?: number | null;
17
+ high_quality_pending_count?: number | null;
18
+ needs_edit_count?: number | null;
19
+ };
20
+
21
+ export type ReviewCandidateDraftFields = Record<string, unknown>;
22
+
23
+ export type ReviewCandidateTrapDraft = {
24
+ title: string;
25
+ category: string;
26
+ scope: string;
27
+ severity: string;
28
+ tags: string[];
29
+ path_globs: string[];
30
+ module: string | null;
31
+ owner: string | null;
32
+ context: string;
33
+ mistake: string;
34
+ fix: string;
35
+ };
36
+
37
+ export type ReviewCandidateMutationPayloadArgs = {
38
+ projectRoot: string | null;
39
+ sessionId: string | null;
40
+ candidateId: string | null;
41
+ trap: ReviewCandidateTrapDraft;
42
+ extra?: Record<string, unknown>;
43
+ };
44
+
45
+ export type ReviewCandidateMutationPayload = {
46
+ projectRoot: string | null;
47
+ sessionId: string | null;
48
+ candidateId: string | null;
49
+ trap: ReviewCandidateTrapDraft;
50
+ } & Record<string, unknown>;
51
+
52
+ export type ReviewQueueModelArgs<TCandidate extends ReviewCandidateLike> = {
53
+ candidates: TCandidate[];
54
+ candidateView: ReviewCandidateView;
55
+ candidateId?: string | null;
56
+ candidateReview?: ReviewSummaryLike | null;
57
+ };
58
+
59
+ export function selectedReviewSessionId(
60
+ sessions: ReviewSessionLike[],
61
+ currentSessionId: string | null | undefined
62
+ ): string | null {
63
+ if (currentSessionId && sessions.some((session) => session.id === currentSessionId)) {
64
+ return currentSessionId;
65
+ }
66
+ return sessions.find((session) => (session.pending_count || 0) > 0)?.id || sessions[0]?.id || null;
67
+ }
68
+
69
+ export function reviewQueueModel<TCandidate extends ReviewCandidateLike>(
70
+ args: ReviewQueueModelArgs<TCandidate>
71
+ ) {
72
+ const pendingCount = args.candidates.filter((candidate) => candidate.status === "proposed").length;
73
+ const reviewedCount = args.candidates.length - pendingCount;
74
+ const visibleCandidates = sortedReviewCandidates(args.candidates, args.candidateView);
75
+ return {
76
+ pendingCount,
77
+ reviewedCount,
78
+ visibleCandidates,
79
+ selectedCandidateId: selectedReviewCandidateId(visibleCandidates, args.candidateId),
80
+ summary: visibleReviewSummary(args.candidateReview),
81
+ };
82
+ }
83
+
84
+ export function sortedReviewCandidates<TCandidate extends ReviewCandidateLike>(
85
+ candidates: TCandidate[],
86
+ candidateView: ReviewCandidateView
87
+ ): TCandidate[] {
88
+ return [...candidates]
89
+ .filter((candidate) => candidateVisibleInReviewView(candidate, candidateView))
90
+ .sort((a, b) => reviewStatusRank(a.status) - reviewStatusRank(b.status) || b.quality_score - a.quality_score);
91
+ }
92
+
93
+ export function selectedReviewCandidateId(
94
+ candidates: ReviewCandidateLike[],
95
+ currentCandidateId: string | null | undefined
96
+ ): string | null {
97
+ if (currentCandidateId && candidates.some((candidate) => candidate.id === currentCandidateId)) {
98
+ return currentCandidateId;
99
+ }
100
+ return candidates[0]?.id || null;
101
+ }
102
+
103
+ export function visibleReviewSummary(summary: ReviewSummaryLike | null | undefined): ReviewSummaryLike | null {
104
+ if (!summary || (summary.pending_count || 0) === 0) return null;
105
+ return summary;
106
+ }
107
+
108
+ export function candidateVisibleInReviewView(
109
+ candidate: ReviewCandidateLike,
110
+ candidateView: ReviewCandidateView
111
+ ): boolean {
112
+ return candidateView === "inbox" ? candidate.status === "proposed" : candidate.status !== "proposed";
113
+ }
114
+
115
+ export function reviewStatusRank(status: string): number {
116
+ return status === "proposed" ? 0 : status === "accepted" ? 1 : 2;
117
+ }
118
+
119
+ export function reviewCandidateTrapDraft(fields: ReviewCandidateDraftFields): ReviewCandidateTrapDraft {
120
+ return {
121
+ title: String(fields.title || ""),
122
+ category: String(fields.category || ""),
123
+ scope: String(fields.scope || ""),
124
+ severity: String(fields.severity || ""),
125
+ tags: reviewSplitList(fields.tags),
126
+ path_globs: reviewSplitList(fields.path_globs),
127
+ module: reviewBlankToNull(fields.module),
128
+ owner: reviewBlankToNull(fields.owner),
129
+ context: String(fields.context || ""),
130
+ mistake: String(fields.mistake || ""),
131
+ fix: String(fields.fix || ""),
132
+ };
133
+ }
134
+
135
+ export function reviewCandidateMutationPayload(args: ReviewCandidateMutationPayloadArgs): ReviewCandidateMutationPayload {
136
+ return {
137
+ projectRoot: args.projectRoot,
138
+ sessionId: args.sessionId,
139
+ candidateId: args.candidateId,
140
+ trap: args.trap,
141
+ ...(args.extra || {}),
142
+ };
143
+ }
144
+
145
+ function reviewSplitList(value: unknown): string[] {
146
+ const values = Array.isArray(value) ? value : String(value || "").split(",");
147
+ return [...new Set(values.map((item) => String(item).trim()).filter(Boolean))];
148
+ }
149
+
150
+ function reviewBlankToNull(value: unknown): string | null {
151
+ const text = String(value || "").trim();
152
+ return text ? text : null;
153
+ }
154
+
155
+ const CLIENT_REVIEW_FUNCTIONS = [
156
+ selectedReviewSessionId,
157
+ reviewQueueModel,
158
+ sortedReviewCandidates,
159
+ selectedReviewCandidateId,
160
+ visibleReviewSummary,
161
+ candidateVisibleInReviewView,
162
+ reviewStatusRank,
163
+ reviewCandidateTrapDraft,
164
+ reviewCandidateMutationPayload,
165
+ reviewSplitList,
166
+ reviewBlankToNull,
167
+ ];
168
+
169
+ export const WEB_REVIEW_CLIENT_SCRIPT = CLIENT_REVIEW_FUNCTIONS
170
+ .map((fn) => ` ${fn.toString().replace(/\n/g, "\n ")}`)
171
+ .join("\n\n");