@aprimediet/codewalker 1.2.0 → 1.4.0

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/src/db.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import Database, { type Database as DatabaseType } from "better-sqlite3";
15
- import type { LibSymbol } from "./types.ts";
15
+ import type { LibSymbol, NoteKind } from "./types.ts";
16
16
 
17
17
  export { Database };
18
18
  export type { DatabaseType };
@@ -28,7 +28,7 @@ export function openDb(dbPath: string): DatabaseType {
28
28
  /** Bootstrap DDL — idempotent (all CREATE use IF NOT EXISTS). */
29
29
  export function bootstrapDb(db: DatabaseType): void {
30
30
  db.exec(`
31
- PRAGMA user_version = 2;
31
+ PRAGMA user_version = 4;
32
32
 
33
33
  CREATE TABLE IF NOT EXISTS files (
34
34
  path TEXT PRIMARY KEY,
@@ -120,9 +120,99 @@ export function bootstrapDb(db: DatabaseType): void {
120
120
  INSERT INTO lib_symbols_fts(rowid, name, signature, doc, summary)
121
121
  VALUES (new.id, new.name, new.signature, new.doc, new.summary);
122
122
  END;
123
+
124
+ -- v1.3: Notes table for glossary/decision bridge cards
125
+ CREATE TABLE IF NOT EXISTS notes (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ note_kind TEXT NOT NULL,
128
+ title TEXT NOT NULL,
129
+ body TEXT,
130
+ tags TEXT,
131
+ related TEXT,
132
+ card_path TEXT,
133
+ created_at TEXT
134
+ );
135
+
136
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
137
+ title, body, tags,
138
+ content='notes', content_rowid='id',
139
+ tokenize='unicode61 remove_diacritics 2'
140
+ );
141
+
142
+ CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
143
+ INSERT INTO notes_fts(rowid, title, body, tags)
144
+ VALUES (new.id, new.title, new.body, new.tags);
145
+ END;
146
+
147
+ CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
148
+ INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
149
+ VALUES ('delete', old.id, old.title, old.body, old.tags);
150
+ END;
151
+
152
+ CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
153
+ INSERT INTO notes_fts(notes_fts, rowid, title, body, tags)
154
+ VALUES ('delete', old.id, old.title, old.body, old.tags);
155
+ INSERT INTO notes_fts(rowid, title, body, tags)
156
+ VALUES (new.id, new.title, new.body, new.tags);
157
+ END;
158
+
159
+ -- v1.4: Analysis table for coverage/debt/practice findings
160
+ CREATE TABLE IF NOT EXISTS analysis (
161
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
162
+ finding_kind TEXT NOT NULL,
163
+ title TEXT NOT NULL,
164
+ severity TEXT,
165
+ file_path TEXT,
166
+ line_start INTEGER,
167
+ line_end INTEGER,
168
+ metric TEXT,
169
+ body TEXT,
170
+ related TEXT,
171
+ card_path TEXT,
172
+ created_at TEXT
173
+ );
174
+
175
+ CREATE VIRTUAL TABLE IF NOT EXISTS analysis_fts USING fts5(
176
+ title, body, metric,
177
+ content='analysis', content_rowid='id',
178
+ tokenize='unicode61 remove_diacritics 2'
179
+ );
180
+
181
+ CREATE TRIGGER IF NOT EXISTS analysis_ai AFTER INSERT ON analysis BEGIN
182
+ INSERT INTO analysis_fts(rowid, title, body, metric)
183
+ VALUES (new.id, new.title, new.body, new.metric);
184
+ END;
185
+
186
+ CREATE TRIGGER IF NOT EXISTS analysis_ad AFTER DELETE ON analysis BEGIN
187
+ INSERT INTO analysis_fts(analysis_fts, rowid, title, body, metric)
188
+ VALUES ('delete', old.id, old.title, old.body, old.metric);
189
+ END;
190
+
191
+ CREATE TRIGGER IF NOT EXISTS analysis_au AFTER UPDATE ON analysis BEGIN
192
+ INSERT INTO analysis_fts(analysis_fts, rowid, title, body, metric)
193
+ VALUES ('delete', old.id, old.title, old.body, old.metric);
194
+ INSERT INTO analysis_fts(rowid, title, body, metric)
195
+ VALUES (new.id, new.title, new.body, new.metric);
196
+ END;
123
197
  `);
124
198
  }
125
199
 
200
+ /**
201
+ * Re-derive the external-content FTS indexes from their content tables (the FTS5 'rebuild'
202
+ * command). This heals a stale/legacy index: a DB written by an older (pre-trigger, manual-sync)
203
+ * build can have a `*_fts` shadow that is silently out of sync with its base table. The
204
+ * `*_ad`/`*_au` triggers issue FTS5 'delete' commands using `old.*` values; if those don't match
205
+ * the stale index, the delete decrements counts that aren't there and corrupts the index
206
+ * ("database disk image is malformed"). Running 'rebuild' first makes subsequent trigger-driven
207
+ * deletes safe. Cheap and idempotent — it only re-tokenizes existing rows (no filesystem work).
208
+ */
209
+ export function rebuildFtsIndexes(db: DatabaseType): void {
210
+ db.exec("INSERT INTO symbols_fts(symbols_fts) VALUES('rebuild')");
211
+ db.exec("INSERT INTO lib_symbols_fts(lib_symbols_fts) VALUES('rebuild')");
212
+ db.exec("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')");
213
+ db.exec("INSERT INTO analysis_fts(analysis_fts) VALUES('rebuild')");
214
+ }
215
+
126
216
  /** Upsert a file record. */
127
217
  export function upsertFile(
128
218
  db: DatabaseType,
@@ -223,6 +313,312 @@ export function searchSymbols(
223
313
  return db.prepare(sql).all(...params) as typeof results;
224
314
  }
225
315
 
316
+ // ── Notes CRUD ─────────────────────────────────────────────────
317
+
318
+ /**
319
+ * Upsert a note keyed on (note_kind, title).
320
+ * Returns the row id.
321
+ */
322
+ export function upsertNote(
323
+ db: DatabaseType,
324
+ note: { note_kind: string; title: string; body: string; tags: string; related: string; card_path: string },
325
+ ): number {
326
+ const existing = db.prepare(
327
+ "SELECT id FROM notes WHERE note_kind = ? AND title = ?",
328
+ ).get(note.note_kind, note.title) as { id: number } | undefined;
329
+
330
+ if (existing) {
331
+ db.prepare(
332
+ `UPDATE notes SET body=?, tags=?, related=?, card_path=?, created_at=COALESCE(created_at, datetime('now'))
333
+ WHERE id = ?`,
334
+ ).run(note.body, note.tags, note.related, note.card_path, existing.id);
335
+ return existing.id;
336
+ }
337
+
338
+ const result = db.prepare(
339
+ `INSERT INTO notes (note_kind, title, body, tags, related, card_path, created_at)
340
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
341
+ ).run(note.note_kind, note.title, note.body, note.tags, note.related, note.card_path);
342
+ return Number(result.lastInsertRowid);
343
+ }
344
+
345
+ /** Delete a note by (note_kind, title). */
346
+ export function deleteNote(db: DatabaseType, noteKind: string, title: string): void {
347
+ db.prepare("DELETE FROM notes WHERE note_kind = ? AND title = ?").run(noteKind, title);
348
+ }
349
+
350
+ /**
351
+ * Search notes via FTS5 MATCH, ranked by bm25.
352
+ * Empty query returns all notes ordered by title.
353
+ * Each result is shaped as a QueryResultRow with source:'note'.
354
+ */
355
+ export function searchNotes(
356
+ db: DatabaseType,
357
+ query: string,
358
+ kindFilter?: NoteKind,
359
+ limit = 10,
360
+ ): Array<{
361
+ id: number;
362
+ name: string;
363
+ kind: string;
364
+ summary: string;
365
+ score: number;
366
+ source: "note";
367
+ note_kind: NoteKind;
368
+ tags: string;
369
+ file_path: string;
370
+ line_start: number;
371
+ line_end: number;
372
+ }> {
373
+ if (!query.trim()) {
374
+ let sql = "SELECT n.id, n.title as name, n.note_kind as kind, n.body as summary, n.tags, 0.0 as score FROM notes n";
375
+ const params: unknown[] = [];
376
+ if (kindFilter) {
377
+ sql += " WHERE n.note_kind = ?";
378
+ params.push(kindFilter);
379
+ }
380
+ sql += " ORDER BY n.title LIMIT ?";
381
+ params.push(limit);
382
+ const rows = db.prepare(sql).all(...params) as any[];
383
+ return rows.map((r) => ({
384
+ ...r,
385
+ source: "note" as const,
386
+ note_kind: r.kind as NoteKind,
387
+ file_path: "",
388
+ line_start: 0,
389
+ line_end: 0,
390
+ }));
391
+ }
392
+
393
+ let sql = `
394
+ SELECT n.id, n.title as name, n.note_kind as kind, n.body as summary, n.tags,
395
+ bm25(notes_fts, 10.0, 5.0, 3.0) as score
396
+ FROM notes_fts
397
+ JOIN notes n ON n.id = notes_fts.rowid
398
+ WHERE notes_fts MATCH ?
399
+ `;
400
+ const params: unknown[] = [query];
401
+
402
+ if (kindFilter) {
403
+ sql += " AND n.note_kind = ?";
404
+ params.push(kindFilter);
405
+ }
406
+
407
+ sql += " ORDER BY score LIMIT ?";
408
+ params.push(limit);
409
+
410
+ const rows = db.prepare(sql).all(...params) as any[];
411
+ return rows.map((r) => ({
412
+ ...r,
413
+ source: "note" as const,
414
+ note_kind: r.kind as NoteKind,
415
+ file_path: "",
416
+ line_start: 0,
417
+ line_end: 0,
418
+ }));
419
+ }
420
+
421
+ // ── Analysis CRUD ────────────────────────────────────────────
422
+
423
+ /**
424
+ * Allowed finding kinds.
425
+ */
426
+ const VALID_FINDING_KINDS = new Set(["coverage", "debt", "practice"]);
427
+
428
+ /**
429
+ * Upsert a finding keyed on (finding_kind, file_path, title).
430
+ * Returns the row id.
431
+ */
432
+ export function upsertFinding(
433
+ db: DatabaseType,
434
+ finding: {
435
+ finding_kind: string;
436
+ title: string;
437
+ severity?: string;
438
+ file_path?: string;
439
+ line_start?: number;
440
+ line_end?: number;
441
+ metric?: string;
442
+ body?: string;
443
+ related?: string;
444
+ card_path?: string;
445
+ },
446
+ ): number {
447
+ if (!VALID_FINDING_KINDS.has(finding.finding_kind)) {
448
+ throw new Error(`Invalid finding_kind "${finding.finding_kind}". Must be coverage, debt, or practice.`);
449
+ }
450
+
451
+ const existing = db.prepare(
452
+ "SELECT id FROM analysis WHERE finding_kind = ? AND file_path = ? AND title = ?",
453
+ ).get(finding.finding_kind, finding.file_path ?? "", finding.title) as { id: number } | undefined;
454
+
455
+ if (existing) {
456
+ db.prepare(
457
+ `UPDATE analysis SET severity=?, line_start=?, line_end=?, metric=?, body=?, related=?, card_path=?, created_at=COALESCE(created_at, datetime('now'))
458
+ WHERE id = ?`,
459
+ ).run(
460
+ finding.severity ?? null, finding.line_start ?? null, finding.line_end ?? null,
461
+ finding.metric ?? null, finding.body ?? null, finding.related ?? null,
462
+ finding.card_path ?? null, existing.id,
463
+ );
464
+ return existing.id;
465
+ }
466
+
467
+ const result = db.prepare(
468
+ `INSERT INTO analysis (finding_kind, title, severity, file_path, line_start, line_end, metric, body, related, card_path, created_at)
469
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
470
+ ).run(
471
+ finding.finding_kind, finding.title, finding.severity ?? null, finding.file_path ?? "",
472
+ finding.line_start ?? null, finding.line_end ?? null, finding.metric ?? null,
473
+ finding.body ?? null, finding.related ?? null, finding.card_path ?? null,
474
+ );
475
+ return Number(result.lastInsertRowid);
476
+ }
477
+
478
+ /**
479
+ * Delete all findings for a given file of a given kind.
480
+ * The analysis_ad trigger removes the FTS rows.
481
+ */
482
+ export function deleteFindingsForFile(
483
+ db: DatabaseType,
484
+ findingKind: string,
485
+ filePath: string,
486
+ ): void {
487
+ db.prepare("DELETE FROM analysis WHERE finding_kind = ? AND file_path = ?").run(findingKind, filePath);
488
+ }
489
+
490
+ /**
491
+ * Search findings via FTS5 MATCH, ranked by bm25.
492
+ * Empty query returns all findings ordered by severity, title.
493
+ * Each result is shaped as a QueryResultRow with source:'analysis'.
494
+ */
495
+ export function searchFindings(
496
+ db: DatabaseType,
497
+ query: string,
498
+ kindFilter?: string,
499
+ limit = 10,
500
+ ): Array<{
501
+ id: number;
502
+ name: string;
503
+ kind: string;
504
+ summary: string;
505
+ score: number;
506
+ source: "analysis";
507
+ finding_kind: string;
508
+ severity: string | null;
509
+ metric: string | null;
510
+ file_path: string;
511
+ line_start: number;
512
+ line_end: number;
513
+ }> {
514
+ if (!query.trim()) {
515
+ let sql = `
516
+ SELECT a.id, a.title as name, a.finding_kind as kind, a.body as summary,
517
+ a.severity, a.metric, a.file_path, a.line_start, a.line_end, 0.0 as score
518
+ FROM analysis a
519
+ `;
520
+ const params: unknown[] = [];
521
+ if (kindFilter) {
522
+ sql += " WHERE a.finding_kind = ?";
523
+ params.push(kindFilter);
524
+ }
525
+ sql += " ORDER BY CASE a.severity WHEN 'high' THEN 1 WHEN 'warn' THEN 2 WHEN 'info' THEN 3 ELSE 4 END, a.title LIMIT ?";
526
+ params.push(limit);
527
+ const rows = db.prepare(sql).all(...params) as any[];
528
+ return rows.map((r) => ({
529
+ ...r,
530
+ source: "analysis" as const,
531
+ finding_kind: r.kind as string,
532
+ file_path: r.file_path ?? "",
533
+ line_start: r.line_start ?? 0,
534
+ line_end: r.line_end ?? 0,
535
+ severity: r.severity ?? null,
536
+ metric: r.metric ?? null,
537
+ }));
538
+ }
539
+
540
+ let sql = `
541
+ SELECT a.id, a.title as name, a.finding_kind as kind, a.body as summary,
542
+ a.severity, a.metric, a.file_path, a.line_start, a.line_end,
543
+ bm25(analysis_fts, 10.0, 5.0, 3.0) as score
544
+ FROM analysis_fts
545
+ JOIN analysis a ON a.id = analysis_fts.rowid
546
+ WHERE analysis_fts MATCH ?
547
+ `;
548
+ const params: unknown[] = [query];
549
+
550
+ if (kindFilter) {
551
+ sql += " AND a.finding_kind = ?";
552
+ params.push(kindFilter);
553
+ }
554
+
555
+ sql += " ORDER BY CASE a.severity WHEN 'high' THEN 1 WHEN 'warn' THEN 2 WHEN 'info' THEN 3 ELSE 4 END, score LIMIT ?";
556
+ params.push(limit);
557
+
558
+ const rows = db.prepare(sql).all(...params) as any[];
559
+ return rows.map((r) => ({
560
+ ...r,
561
+ source: "analysis" as const,
562
+ finding_kind: r.kind as string,
563
+ file_path: r.file_path ?? "",
564
+ line_start: r.line_start ?? 0,
565
+ line_end: r.line_end ?? 0,
566
+ severity: r.severity ?? null,
567
+ metric: r.metric ?? null,
568
+ }));
569
+ }
570
+
571
+ // ── Enrichment helpers ──────────────────────────────────────────
572
+
573
+ /**
574
+ * Update symbols.summary for a given card_path.
575
+ * Returns true if a row was updated, false if no symbol matched.
576
+ * The existing symbols_au trigger reindexes FTS automatically.
577
+ */
578
+ export function updateSymbolSummary(
579
+ db: DatabaseType,
580
+ cardPath: string,
581
+ summary: string,
582
+ ): boolean {
583
+ const result = db.prepare(
584
+ "UPDATE symbols SET summary = ? WHERE card_path = ?",
585
+ ).run(summary, cardPath);
586
+ return result.changes > 0;
587
+ }
588
+
589
+ /**
590
+ * Select unenriched symbols (summary IS NULL or empty) under a path prefix.
591
+ * Results ordered by file_path then line_start.
592
+ */
593
+ export function selectUnenrichedSymbols(
594
+ db: DatabaseType,
595
+ pathPrefix: string,
596
+ limit: number,
597
+ ): Array<{
598
+ name: string;
599
+ kind: string;
600
+ file_path: string;
601
+ line_start: number;
602
+ line_end: number;
603
+ card_path: string;
604
+ }> {
605
+ return db.prepare(
606
+ `SELECT name, kind, file_path, line_start, line_end, card_path
607
+ FROM symbols
608
+ WHERE (summary IS NULL OR summary = '')
609
+ AND file_path LIKE ?
610
+ ORDER BY file_path, line_start
611
+ LIMIT ?`,
612
+ ).all(pathPrefix + "%", limit) as Array<{
613
+ name: string;
614
+ kind: string;
615
+ file_path: string;
616
+ line_start: number;
617
+ line_end: number;
618
+ card_path: string;
619
+ }>;
620
+ }
621
+
226
622
  /** Upsert a library record. */
227
623
  export function upsertLibrary(
228
624
  db: DatabaseType,
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatEnrichWorklist, validateEnrichPath, checkEnrichCap } from './enrich.ts';
3
+ import type { UnenrichedSymbol } from './enrich.ts';
4
+
5
+ function makeSym(overrides: Partial<UnenrichedSymbol> = {}): UnenrichedSymbol {
6
+ return {
7
+ name: 'myFunc',
8
+ kind: 'function',
9
+ file_path: 'src/auth/token.ts',
10
+ line_start: 10,
11
+ line_end: 30,
12
+ card_path: '/entries/symbols/auth-token/myFunc.md',
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe('validateEnrichPath', () => {
18
+ it('accepts a non-empty path', () => {
19
+ const result = validateEnrichPath('src/auth');
20
+ expect(result.valid).toBe(true);
21
+ expect(result.error).toBeUndefined();
22
+ });
23
+
24
+ it('rejects empty string', () => {
25
+ const result = validateEnrichPath('');
26
+ expect(result.valid).toBe(false);
27
+ expect(result.error).toContain('specify a path');
28
+ });
29
+
30
+ it('rejects whitespace-only', () => {
31
+ const result = validateEnrichPath(' ');
32
+ expect(result.valid).toBe(false);
33
+ expect(result.error).toContain('specify a path');
34
+ });
35
+
36
+ it('rejects undefined/null', () => {
37
+ const result = validateEnrichPath(undefined as any);
38
+ expect(result.valid).toBe(false);
39
+ expect(result.error).toContain('specify a path');
40
+ });
41
+ });
42
+
43
+ describe('checkEnrichCap', () => {
44
+ it('returns ok when count within cap', () => {
45
+ const result = checkEnrichCap(5, 40);
46
+ expect(result.ok).toBe(true);
47
+ expect(result.count).toBe(5);
48
+ expect(result.skipped).toBe(0);
49
+ expect(result.error).toBeUndefined();
50
+ });
51
+
52
+ it('rejects when count exceeds cap', () => {
53
+ const result = checkEnrichCap(100, 40);
54
+ expect(result.ok).toBe(false);
55
+ expect(result.count).toBe(100);
56
+ expect(result.skipped).toBe(60);
57
+ expect(result.error).toContain('100 symbols');
58
+ expect(result.error).toContain('--max=100');
59
+ });
60
+
61
+ it('uses default cap when not specified', () => {
62
+ const result = checkEnrichCap(50);
63
+ expect(result.ok).toBe(false);
64
+ expect(result.error).toContain('--max=50');
65
+ });
66
+
67
+ it('accepts edge case at exactly cap', () => {
68
+ const result = checkEnrichCap(40, 40);
69
+ expect(result.ok).toBe(true);
70
+ expect(result.count).toBe(40);
71
+ });
72
+ });
73
+
74
+ describe('formatEnrichWorklist', () => {
75
+ it('formats symbols into compact lines', () => {
76
+ const syms = [
77
+ makeSym({ name: 'refreshToken', file_path: 'src/auth/token.ts', line_start: 42, line_end: 71 }),
78
+ makeSym({ name: 'validateJwt', file_path: 'src/auth/jwt.ts', line_start: 10, line_end: 30 }),
79
+ ];
80
+
81
+ const result = formatEnrichWorklist(syms, 'src/auth');
82
+ expect(result).toContain('refreshToken');
83
+ expect(result).toContain('validateJwt');
84
+ expect(result).toContain('src/auth');
85
+ expect(result).toContain('42-71');
86
+ expect(result).toContain('10-30');
87
+ expect(result).toMatch(/function/g);
88
+ });
89
+
90
+ it('includes instructions for the agent', () => {
91
+ const syms = [makeSym()];
92
+ const result = formatEnrichWorklist(syms, 'src/');
93
+ expect(result).toContain('codewalker_enrich');
94
+ expect(result).toContain('summary');
95
+ expect(result).toContain('120');
96
+ });
97
+
98
+ it('says "No unenriched symbols" for empty array', () => {
99
+ const result = formatEnrichWorklist([], 'src/');
100
+ expect(result).toContain('No unenriched symbols');
101
+ });
102
+ });
package/src/enrich.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Enrichment core for codewalker v1.3.
3
+ *
4
+ * Pure/lightweight helpers for the enrichment workflow:
5
+ * - `validateEnrichPath()` — ensures path is provided
6
+ * - `checkEnrichCap()` — enforces the enrichment cap guardrail
7
+ * - `formatEnrichWorklist()` — formats worklist items for agent display
8
+ *
9
+ * The heavy lifting (selecting unenriched symbols) lives in db.ts.
10
+ * The write-back path (updating card + DB) is in cards.ts + db.ts.
11
+ */
12
+
13
+ /** Default maximum symbols per enrich command. */
14
+ export const DEFAULT_ENRICH_CAP = 40;
15
+
16
+ /** A single unenriched symbol from the selection query. */
17
+ export interface UnenrichedSymbol {
18
+ name: string;
19
+ kind: string;
20
+ file_path: string;
21
+ line_start: number;
22
+ line_end: number;
23
+ card_path: string;
24
+ }
25
+
26
+ /** Result of validateEnrichPath. */
27
+ export interface PathValidation {
28
+ valid: boolean;
29
+ error?: string;
30
+ }
31
+
32
+ /** Result of checkEnrichCap. */
33
+ export interface CapCheck {
34
+ ok: boolean;
35
+ count: number;
36
+ /** Number of symbols over the cap (only when !ok). */
37
+ skipped: number;
38
+ error?: string;
39
+ }
40
+
41
+ /**
42
+ * Validate that an enrichment path was provided.
43
+ */
44
+ export function validateEnrichPath(path: string | undefined | null): PathValidation {
45
+ const trimmed = (path ?? "").trim();
46
+ if (!trimmed) {
47
+ return {
48
+ valid: false,
49
+ error: 'specify a path, e.g. src/auth. Bare /codewalker enrich with no path is not allowed.',
50
+ };
51
+ }
52
+ return { valid: true };
53
+ }
54
+
55
+ /**
56
+ * Check whether a count of unenriched symbols exceeds the cap.
57
+ * If ok is false, the caller should refuse and tell the user to narrow the path.
58
+ *
59
+ * @param count - Number of unenriched symbols found.
60
+ * @param cap - Maximum allowed (default: DEFAULT_ENRICH_CAP = 40).
61
+ */
62
+ export function checkEnrichCap(count: number, cap: number = DEFAULT_ENRICH_CAP): CapCheck {
63
+ if (count > cap) {
64
+ const skipped = count - cap;
65
+ return {
66
+ ok: false,
67
+ count,
68
+ skipped,
69
+ error: `Refusing to enrich ${count} symbols (cap ${cap}). ` +
70
+ `Narrow your path or raise the cap with --max=${count}.`,
71
+ };
72
+ }
73
+ return { ok: true, count, skipped: 0 };
74
+ }
75
+
76
+ /**
77
+ * Format an enrichment worklist for the agent to process.
78
+ * Each line shows one unenriched symbol. Includes a header with instructions.
79
+ */
80
+ export function formatEnrichWorklist(
81
+ symbols: UnenrichedSymbol[],
82
+ pathPrefix: string,
83
+ ): string {
84
+ if (symbols.length === 0) {
85
+ return `No unenriched symbols found under "${pathPrefix}". All symbols in this path already have summaries.`;
86
+ }
87
+
88
+ const lines: string[] = [
89
+ `Found ${symbols.length} unenriched symbol(s) under "${pathPrefix}":`,
90
+ "",
91
+ ];
92
+
93
+ for (const sym of symbols) {
94
+ const loc = `${sym.file_path}:${sym.line_start}-${sym.line_end}`;
95
+ lines.push(` ${sym.name} · ${sym.kind} · ${loc} · card: ${sym.card_path}`);
96
+ }
97
+
98
+ lines.push(
99
+ "",
100
+ "Instructions:",
101
+ ` For each symbol above, read the source span and call \`codewalker_enrich\``,
102
+ " with a ≤120 character plain-English summary of what the symbol does.",
103
+ ` Example: \`codewalker_enrich { card: "${symbols[0]?.card_path ?? '<card_path>'}", summary: "..." }\``,
104
+ );
105
+
106
+ return lines.join("\n");
107
+ }