moflo 4.12.11 → 4.13.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.
Files changed (43) hide show
  1. package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
  2. package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
  3. package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
  4. package/.claude/skills/fl/phases.md +51 -17
  5. package/.claude/skills/optimize-learnings/SKILL.md +220 -0
  6. package/README.md +95 -1
  7. package/bin/lib/get-backend.mjs +150 -12
  8. package/bin/lib/skill-categories.mjs +1 -0
  9. package/bin/session-start-launcher.mjs +13 -5
  10. package/dist/src/cli/commands/daemon.js +5 -2
  11. package/dist/src/cli/commands/epic.js +5 -1
  12. package/dist/src/cli/commands/hive-mind.js +6 -4
  13. package/dist/src/cli/commands/hooks.js +8 -8
  14. package/dist/src/cli/commands/index.js +5 -0
  15. package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
  16. package/dist/src/cli/commands/memory.js +71 -10
  17. package/dist/src/cli/commands/spell-schedule.js +5 -3
  18. package/dist/src/cli/commands/worktree.js +408 -0
  19. package/dist/src/cli/config/moflo-config.js +57 -0
  20. package/dist/src/cli/index.js +4 -2
  21. package/dist/src/cli/init/executor.js +1 -0
  22. package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
  23. package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
  24. package/dist/src/cli/memory/bridge-entries.js +157 -9
  25. package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
  26. package/dist/src/cli/memory/daemon-backend.js +152 -11
  27. package/dist/src/cli/memory/entries-read.js +47 -2
  28. package/dist/src/cli/memory/entries-write.js +73 -10
  29. package/dist/src/cli/memory/hnsw-singleton.js +112 -9
  30. package/dist/src/cli/memory/learnings-audit.js +420 -0
  31. package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
  32. package/dist/src/cli/memory/learnings-tree.js +187 -0
  33. package/dist/src/cli/memory/memory-bridge.js +37 -27
  34. package/dist/src/cli/memory/tool-call-markup.js +218 -0
  35. package/dist/src/cli/parser.js +7 -3
  36. package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
  37. package/dist/src/cli/services/durable-reconcile.js +161 -0
  38. package/dist/src/cli/services/durable-store-io.js +291 -0
  39. package/dist/src/cli/services/durable-sync.js +159 -24
  40. package/dist/src/cli/services/team-artifact-sync.js +462 -163
  41. package/dist/src/cli/services/worktree-provision.js +400 -0
  42. package/dist/src/cli/version.js +1 -1
  43. package/package.json +2 -2
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Curation pass over the `learnings` namespace (#1466).
3
+ *
4
+ * `flo memory cleanup` purges by age, which is the wrong instrument for durable
5
+ * rows — a three-year-old lesson about a footgun that still exists is worth more
6
+ * than last week's note about a migration that finished. #1464 made that
7
+ * explicit by exempting durable namespaces from age-based cleanup, which left
8
+ * `learnings` with no evaluation surface at all: a consumer store reached 1,582
9
+ * entries with no way to tell which of them were still true.
10
+ *
11
+ * The shape that makes this affordable at that size is **mechanical filters
12
+ * first, model judgement last**. The filters here do not decide anything — they
13
+ * nominate. Four cheap passes (near-duplicate clustering over the embeddings
14
+ * already stored on the row, least-used-and-old ranking, retired-vocabulary
15
+ * matching, and dead-path resolution) narrow ~1,500 entries to a few dozen, and
16
+ * only those go to a model. A full-store LLM sweep is the design this exists to
17
+ * avoid.
18
+ *
19
+ * The dead-path pass (#1479) is the only one grounded in ground truth rather
20
+ * than prose shape — a repo-relative path either resolves in the tree or it does
21
+ * not. It is carved into `memory/learnings-dead-paths.ts` and re-exported from
22
+ * here; its filesystem half is `memory/learnings-tree.ts`.
23
+ *
24
+ * This module is pure by construction: no filesystem, no database, no spawning.
25
+ * Rows come in, a plan comes out. The command layer
26
+ * (`commands/memory-audit-learnings.ts`) owns every side effect, which is what
27
+ * makes the ranking and clustering testable without a store.
28
+ *
29
+ * @module memory/learnings-audit
30
+ */
31
+ // The dead-path pass lives in its own module so this one stays the place the
32
+ // passes are ASSEMBLED. Re-exported here because `learnings-audit.js` is the
33
+ // audit's public surface — a caller configuring the pass should not have to
34
+ // know which file the detector was carved into.
35
+ import { findDeadPaths } from './learnings-dead-paths.js';
36
+ export { DEFAULT_DEAD_PATHS_PER_ENTRY, extractCandidatePaths, findDeadPaths, resolvesInTree, } from './learnings-dead-paths.js';
37
+ /** The namespace this audit is scoped to. */
38
+ export const LEARNINGS_NAMESPACE = 'learnings';
39
+ export const AUDIT_VERDICTS = ['KEEP', 'RETIRE', 'COMPRESS', 'MERGE'];
40
+ /**
41
+ * Retired vocabulary. **Ships empty, and a guard test keeps it that way.**
42
+ *
43
+ * A rename is always local to one project: the consumer that renamed `foo` to
44
+ * `bar` is the only project where an entry saying `foo` is stale, and shipping
45
+ * their row would flag innocent entries in every other consumer's store — while
46
+ * also publishing that consumer's internal vocabulary to everyone who installs
47
+ * moflo (Rule #3). The row shape is documented rather than demonstrated for the
48
+ * same reason.
49
+ *
50
+ * A project that wants entries flagged fills this in downstream:
51
+ *
52
+ * { from: 'old-term', to: 'new-term', note: 'renamed in <their ticket>' }
53
+ */
54
+ export const SUPERSEDED_VOCABULARY = [];
55
+ export const DEFAULT_DUPLICATE_THRESHOLD = 0.9;
56
+ export const DEFAULT_UNUSED_MIN_AGE_MS = 90 * 24 * 60 * 60 * 1000;
57
+ export const DEFAULT_UNUSED_LIMIT = 25;
58
+ export const DEFAULT_JUDGE_LIMIT = 60;
59
+ /**
60
+ * Group near-duplicate rows, returning the non-representative members.
61
+ *
62
+ * Greedy single-pass clustering: rows are walked newest-first, so the entry that
63
+ * survives a cluster is the most recently updated statement of the rule and the
64
+ * older restatements are the ones nominated. That ordering is the whole point —
65
+ * a duplicate pass that kept an arbitrary member would sometimes retire the
66
+ * corrected version and keep the one it replaced.
67
+ *
68
+ * Pass the FULL row set, not a filtered one: representatives are chosen from
69
+ * whatever is handed in, so clustering over a subset can promote an older
70
+ * restatement to representative and nominate the newer entry instead — exactly
71
+ * the inversion the newest-first sort exists to prevent. `buildAuditPlan`
72
+ * therefore clusters over every row and filters the nominations afterwards.
73
+ *
74
+ * O(n²) in rows that carry a vector. At the ~1,500 entries this exists for that
75
+ * is roughly a million comparisons — well under a second, and it costs no
76
+ * embedding calls at all, which is the trade the ticket asks for. Vectors are
77
+ * normalised once up front rather than through `cosineSim`, which would
78
+ * recompute both magnitudes on every one of those comparisons.
79
+ */
80
+ export function findDuplicates(rows, threshold = DEFAULT_DUPLICATE_THRESHOLD) {
81
+ const embedded = rows
82
+ .filter((r) => Array.isArray(r.embedding) && r.embedding.length > 0)
83
+ .sort((a, b) => b.updatedAt - a.updatedAt)
84
+ .map((row) => {
85
+ let sumSquares = 0;
86
+ for (const component of row.embedding)
87
+ sumSquares += component * component;
88
+ const magnitude = Math.sqrt(sumSquares);
89
+ // A zero vector has no direction, so it can neither match nor be matched.
90
+ // `unit: null` keeps it in the walk without ever passing the threshold.
91
+ const unit = magnitude === 0 ? null : row.embedding.map((component) => component / magnitude);
92
+ return { row, unit };
93
+ });
94
+ const claimed = new Set();
95
+ const found = [];
96
+ for (let i = 0; i < embedded.length; i++) {
97
+ const representative = embedded[i];
98
+ if (!representative.unit || claimed.has(representative.row.key))
99
+ continue;
100
+ for (let j = i + 1; j < embedded.length; j++) {
101
+ const other = embedded[j];
102
+ if (!other.unit || claimed.has(other.row.key))
103
+ continue;
104
+ // Both vectors are unit length, so the dot product IS the cosine.
105
+ const length = Math.min(representative.unit.length, other.unit.length);
106
+ let similarity = 0;
107
+ for (let k = 0; k < length; k++)
108
+ similarity += representative.unit[k] * other.unit[k];
109
+ if (similarity < threshold)
110
+ continue;
111
+ // Claim the member, not the representative: a representative that stayed
112
+ // claimable could be absorbed into a later cluster and nominated itself,
113
+ // which would leave the rule with no surviving statement at all.
114
+ claimed.add(other.row.key);
115
+ found.push({ row: other.row, duplicateOf: representative.row.key, similarity });
116
+ }
117
+ }
118
+ return found;
119
+ }
120
+ /**
121
+ * Rank never-used entries older than `minAgeMs`, least-recently-updated first,
122
+ * and return the top `limit`.
123
+ *
124
+ * Nomination only. Zero recorded usage is weak evidence — usage recording is
125
+ * newer than most of the rows it is being read against — which is exactly why
126
+ * this feeds a model rather than a DELETE.
127
+ */
128
+ export function findUnused(rows, options) {
129
+ const minAgeMs = options.minAgeMs ?? DEFAULT_UNUSED_MIN_AGE_MS;
130
+ const limit = options.limit ?? DEFAULT_UNUSED_LIMIT;
131
+ const cutoff = options.now - minAgeMs;
132
+ return rows
133
+ .filter((r) => r.accessCount <= 0 && r.updatedAt <= cutoff)
134
+ .sort((a, b) => a.updatedAt - b.updatedAt)
135
+ .slice(0, Math.max(0, limit));
136
+ }
137
+ /** Escape a vocabulary term for use inside a RegExp. */
138
+ function escapeRegExp(term) {
139
+ return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
140
+ }
141
+ /**
142
+ * Find entries still using retired vocabulary.
143
+ *
144
+ * Matched on word boundaries so a retired `db` does not fire on `dbPath`, and
145
+ * case-insensitively so a rename survives the entry's own capitalisation. With
146
+ * the shipped table empty this returns nothing, which is the intended default.
147
+ */
148
+ export function findSuperseded(rows, vocabulary = SUPERSEDED_VOCABULARY) {
149
+ if (vocabulary.length === 0)
150
+ return [];
151
+ const matchers = vocabulary.map((term) => ({
152
+ term,
153
+ re: new RegExp(`\\b${escapeRegExp(term.from)}\\b`, 'i'),
154
+ }));
155
+ const found = [];
156
+ for (const row of rows) {
157
+ const terms = matchers.filter((m) => m.re.test(row.content)).map((m) => m.term);
158
+ if (terms.length > 0)
159
+ found.push({ row, terms });
160
+ }
161
+ return found;
162
+ }
163
+ /**
164
+ * Run the mechanical passes and assemble the candidate set.
165
+ *
166
+ * Entries with a recorded verdict whose content has not changed since are
167
+ * dropped before any pass runs — that is what makes a second run immediately
168
+ * after `--apply` report nothing (the audit is idempotent), and it is also why
169
+ * the hash is part of the record: a rewritten entry is a new claim and gets
170
+ * judged again.
171
+ */
172
+ export function buildAuditPlan(rows, options = {}) {
173
+ const now = options.now ?? Date.now();
174
+ const decided = options.decided ?? new Map();
175
+ const hash = options.hashContent;
176
+ const judgeLimit = options.judgeLimit ?? DEFAULT_JUDGE_LIMIT;
177
+ const pending = [];
178
+ let alreadyDecided = 0;
179
+ for (const row of rows) {
180
+ const record = decided.get(row.key);
181
+ // No hash function means we cannot tell a rewrite from the entry we judged;
182
+ // re-judging is the safe side of that, so the record is ignored.
183
+ if (record && hash && record.hash === hash(row.content)) {
184
+ alreadyDecided++;
185
+ continue;
186
+ }
187
+ pending.push(row);
188
+ }
189
+ const byKey = new Map();
190
+ const nominate = (row, bucket) => {
191
+ let candidate = byKey.get(row.key);
192
+ if (!candidate) {
193
+ candidate = {
194
+ key: row.key,
195
+ id: row.id,
196
+ content: row.content,
197
+ createdAt: row.createdAt,
198
+ updatedAt: row.updatedAt,
199
+ accessCount: row.accessCount,
200
+ buckets: [],
201
+ };
202
+ byKey.set(row.key, candidate);
203
+ }
204
+ if (!candidate.buckets.includes(bucket))
205
+ candidate.buckets.push(bucket);
206
+ return candidate;
207
+ };
208
+ const pendingKeys = new Set(pending.map((r) => r.key));
209
+ // Cluster over EVERY row, then keep only the pending nominations. Clustering
210
+ // over `pending` alone would let a previously-kept newest entry drop out of
211
+ // the candidate pool and promote an older restatement to representative,
212
+ // nominating the newer entry instead.
213
+ const duplicates = findDuplicates(rows, options.duplicateThreshold)
214
+ .filter((hit) => pendingKeys.has(hit.row.key));
215
+ for (const hit of duplicates) {
216
+ const candidate = nominate(hit.row, 'duplicate');
217
+ candidate.duplicateOf = hit.duplicateOf;
218
+ candidate.similarity = hit.similarity;
219
+ }
220
+ const unusedMatched = findUnused(pending, {
221
+ now,
222
+ minAgeMs: options.unusedMinAgeMs,
223
+ limit: Number.POSITIVE_INFINITY,
224
+ });
225
+ const unused = unusedMatched.slice(0, Math.max(0, options.unusedLimit ?? DEFAULT_UNUSED_LIMIT));
226
+ for (const row of unused)
227
+ nominate(row, 'unused');
228
+ const superseded = findSuperseded(pending, options.vocabulary);
229
+ for (const hit of superseded) {
230
+ const candidate = nominate(hit.row, 'superseded');
231
+ candidate.supersededTerms = hit.terms;
232
+ }
233
+ // Runs over `pending` rather than every row: unlike clustering, this pass
234
+ // reads one entry at a time and has no representative to protect, so a
235
+ // previously-judged entry contributes nothing to another entry's nomination.
236
+ const deadPaths = options.deadPaths ? findDeadPaths(pending, options.deadPaths) : [];
237
+ for (const hit of deadPaths) {
238
+ const candidate = nominate(hit.row, 'dead-path');
239
+ candidate.deadPaths = hit.deadPaths;
240
+ }
241
+ const counts = {
242
+ duplicate: duplicates.length,
243
+ unused: unused.length,
244
+ superseded: superseded.length,
245
+ deadPath: deadPaths.length,
246
+ };
247
+ // Most-nominated first, then oldest — an entry three passes agree on is the
248
+ // one a bounded prompt should spend its budget on.
249
+ const ranked = [...byKey.values()].sort((a, b) => b.buckets.length - a.buckets.length || a.updatedAt - b.updatedAt);
250
+ const candidates = ranked.slice(0, Math.max(0, judgeLimit));
251
+ return {
252
+ examined: rows.length,
253
+ alreadyDecided,
254
+ // Counted over every examined row, matching `examined`'s denominator.
255
+ withoutEmbedding: rows.filter((r) => !r.embedding || r.embedding.length === 0).length,
256
+ counts,
257
+ unusedCoverage: { matched: unusedMatched.length, nominated: unused.length },
258
+ candidates,
259
+ overflow: ranked.length - candidates.length,
260
+ };
261
+ }
262
+ /** Per-candidate body cap in the judge prompt — bounds cost, keeps the claim readable. */
263
+ export const JUDGE_CONTENT_CAP = 400;
264
+ function describeBuckets(candidate) {
265
+ const parts = [];
266
+ for (const bucket of candidate.buckets) {
267
+ if (bucket === 'duplicate') {
268
+ parts.push(`near-duplicate of "${candidate.duplicateOf}" (similarity ${(candidate.similarity ?? 0).toFixed(3)})`);
269
+ }
270
+ else if (bucket === 'unused') {
271
+ parts.push('never returned by a search since usage recording began');
272
+ }
273
+ else if (bucket === 'dead-path') {
274
+ parts.push(`cites path(s) that resolve nowhere in the tree: ${(candidate.deadPaths ?? []).join(', ')}`);
275
+ }
276
+ else {
277
+ const terms = (candidate.supersededTerms ?? [])
278
+ .map((t) => `"${t.from}" → "${t.to}"`)
279
+ .join(', ');
280
+ parts.push(`uses retired vocabulary: ${terms}`);
281
+ }
282
+ }
283
+ return parts.join('; ');
284
+ }
285
+ /**
286
+ * Build the judgement prompt for the nominated entries.
287
+ *
288
+ * The decision table is restated inline rather than referenced by path: the
289
+ * model answering this runs headless in a consumer's project, where
290
+ * `.claude/guidance/internal/memory-hygiene.md` does not exist.
291
+ */
292
+ export function buildJudgePrompt(candidates, now = Date.now()) {
293
+ const dayMs = 24 * 60 * 60 * 1000;
294
+ const entries = candidates
295
+ .map((candidate, index) => {
296
+ const ageDays = Math.max(0, Math.round((now - candidate.createdAt) / dayMs));
297
+ const body = candidate.content.length > JUDGE_CONTENT_CAP
298
+ ? `${candidate.content.slice(0, JUDGE_CONTENT_CAP)}…`
299
+ : candidate.content;
300
+ return [
301
+ `### ${index + 1}. ${candidate.key}`,
302
+ `- flagged because: ${describeBuckets(candidate)}`,
303
+ `- age: ${ageDays} day(s); recorded uses: ${candidate.accessCount}`,
304
+ `- content: ${body.replace(/\s+/g, ' ').trim()}`,
305
+ ].join('\n');
306
+ })
307
+ .join('\n\n');
308
+ return [
309
+ 'You are auditing durable engineering learnings stored in a project memory.',
310
+ 'Each entry below was flagged by a mechanical filter. The filters nominate; you decide.',
311
+ '',
312
+ 'Classify every entry into exactly one bucket:',
313
+ '',
314
+ '| Verdict | When to apply |',
315
+ '|---|---|',
316
+ '| KEEP | The entry still drives a decision someone might make today. |',
317
+ '| RETIRE | Resolved incident, completed migration, or a rule now enforced by a lint/test/CI gate. |',
318
+ '| COMPRESS | Load-bearing but verbose; the durable signal fits in 1-3 sentences. |',
319
+ '| MERGE | Restates another entry listed here; the two should become one. |',
320
+ '',
321
+ 'Only RETIRE removes an entry. MERGE and COMPRESS are reported to a human to act on,',
322
+ 'so use them when the content still needs to survive in some form somewhere.',
323
+ '',
324
+ 'The test: "if this entry were removed today, would any future decision be wrong?"',
325
+ 'If no — because the situation no longer arises, or a machine gate already prevents the',
326
+ 'failure — it is RETIRE. When genuinely unsure, answer KEEP: a wrongly kept entry costs',
327
+ 'a little search budget, a wrongly retired one costs the lesson.',
328
+ '',
329
+ 'A near-duplicate flag is evidence, not a verdict: two entries can restate one rule',
330
+ '(MERGE) or cover genuinely different cases that merely read alike (KEEP).',
331
+ '',
332
+ 'A dead-path flag is evidence with FOUR possible causes, and only reading the entry tells',
333
+ 'them apart. Never read one as RETIRE on sight — the moved-file case is the common one:',
334
+ '',
335
+ '| Why the cited path does not resolve | Verdict |',
336
+ '|---|---|',
337
+ '| The file MOVED; the lesson still holds | COMPRESS — keep the rule, correct or drop the path |',
338
+ '| Deleted, and the lesson was about that code | RETIRE |',
339
+ '| Deleted, but the lesson generalises past it | COMPRESS — drop the path, keep the rule |',
340
+ '| The entry is a historical record, correct as written | KEEP |',
341
+ '',
342
+ `Answer with exactly ${candidates.length} line(s), nothing else. One line per entry:`,
343
+ '',
344
+ '<key><TAB><VERDICT><TAB><reason in under 15 words>',
345
+ '',
346
+ '---',
347
+ '',
348
+ entries,
349
+ ].join('\n');
350
+ }
351
+ /**
352
+ * Parse the model's verdict lines, keyed by entry key.
353
+ *
354
+ * Deliberately lenient about separators and surrounding prose — the cost of a
355
+ * strict parser here is discarding a whole batch of correct verdicts over a
356
+ * stray preamble. Keys not present in `expected` are ignored, so a hallucinated
357
+ * entry cannot cause an archive.
358
+ */
359
+ export function parseVerdicts(text, expected) {
360
+ const known = new Set(expected);
361
+ const out = new Map();
362
+ for (const rawLine of String(text ?? '').split(/\r?\n/)) {
363
+ // Strip a list marker only — a bare `[-*\d.\s]+` class also eats the front
364
+ // of a key like `1466-lesson`, which then fails the `known` check and
365
+ // silently loses its verdict (and re-nominates on every future run).
366
+ const line = rawLine.trim().replace(/^(?:[-*+]|\d+[.)])\s+/, '');
367
+ if (!line)
368
+ continue;
369
+ const parts = line.split(/\t|\s*\|\s*|\s{2,}|\s+-\s+/).map((p) => p.trim()).filter(Boolean);
370
+ if (parts.length < 2)
371
+ continue;
372
+ const key = parts[0].replace(/^["'`]|["'`]$/g, '');
373
+ if (!known.has(key) || out.has(key))
374
+ continue;
375
+ const verdict = parts[1].toUpperCase().replace(/[^A-Z]/g, '');
376
+ if (!AUDIT_VERDICTS.includes(verdict))
377
+ continue;
378
+ out.set(key, { verdict, reason: parts.slice(2).join(' ') });
379
+ }
380
+ return out;
381
+ }
382
+ /**
383
+ * The entries `--apply` archives.
384
+ *
385
+ * **RETIRE only.** MERGE and COMPRESS both describe work that preserves content
386
+ * — fold this into that one, rewrite this shorter — and nothing here performs
387
+ * either. Archiving on those verdicts would delete the very signal the verdict
388
+ * said to keep, and MERGE is the more dangerous of the two: "restates another
389
+ * entry listed here" is a true statement about BOTH members of a pair, so a
390
+ * model answering MERGE twice would erase the rule entirely. They are reported
391
+ * for an author to act on instead.
392
+ *
393
+ * A cluster's representative is excluded even when it is itself nominated. The
394
+ * duplicate pass protects the newest statement of a rule from its own bucket,
395
+ * but `findUnused` and `findSuperseded` walk the same rows and can nominate that
396
+ * representative independently — and a RETIRE on it alongside RETIREs on its
397
+ * duplicates takes every statement of the rule at once.
398
+ */
399
+ export function selectArchivable(candidates, verdicts) {
400
+ const survivors = new Set(candidates.map((candidate) => candidate.duplicateOf).filter((key) => Boolean(key)));
401
+ return candidates.filter((candidate) => {
402
+ if (survivors.has(candidate.key))
403
+ return false;
404
+ return verdicts.get(candidate.key)?.verdict === 'RETIRE';
405
+ });
406
+ }
407
+ /**
408
+ * Entries whose verdict asks for an author's hand rather than an archive —
409
+ * MERGE and COMPRESS. Reported so a verdict never silently evaporates.
410
+ */
411
+ export function selectManualActions(candidates, verdicts) {
412
+ const out = [];
413
+ for (const candidate of candidates) {
414
+ const verdict = verdicts.get(candidate.key)?.verdict;
415
+ if (verdict === 'MERGE' || verdict === 'COMPRESS')
416
+ out.push({ candidate, verdict });
417
+ }
418
+ return out;
419
+ }
420
+ //# sourceMappingURL=learnings-audit.js.map
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Dead-path nomination for the learnings audit (#1479).
3
+ *
4
+ * The audit's other three passes read prose shape — how similar two entries
5
+ * are, how long one has gone unused, whether it speaks a retired word. This one
6
+ * reads ground truth: a repo-relative path an entry cites either resolves in the
7
+ * tree or it does not. That also makes it the only pass that is portable across
8
+ * consumers — it resolves against the consumer's own tree and carries no
9
+ * project-specific data, unlike a vocabulary list.
10
+ *
11
+ * **It nominates, it never decides.** A dead path has four causes and only a
12
+ * reader can tell them apart. See {@link findDeadPaths}.
13
+ *
14
+ * Pure by construction, like `learnings-audit.ts`: resolution is injected as a
15
+ * predicate, so nothing here touches a disk. The filesystem half lives in
16
+ * `memory/learnings-tree.ts`.
17
+ *
18
+ * @module memory/learnings-dead-paths
19
+ */
20
+ // Pure string composition, no disk: `path.posix.join` puts a workspace prefix in
21
+ // front of a cited path. `posix` specifically, because the separator inside a
22
+ // learning is whatever its author typed — `/` on every platform in practice — so
23
+ // it is a wire format, not a host path. The host separator is applied once, at
24
+ // the filesystem boundary in `memory/learnings-tree.ts`, with `path.join` (Rule #1).
25
+ import { posix as posixPath } from 'path';
26
+ /**
27
+ * How many unresolved paths are recorded per entry.
28
+ *
29
+ * Evidence, not an inventory: an entry citing thirty dead paths is nominated by
30
+ * the first few just as decisively, and the rest would only inflate the judge
31
+ * prompt this whole design exists to keep bounded.
32
+ */
33
+ export const DEFAULT_DEAD_PATHS_PER_ENTRY = 5;
34
+ /** A URL is not a tree path, and plenty of them end in `.json` or `.md`. */
35
+ const URL_LIKE = /\S+:\/\/\S+/g;
36
+ /** A glob is a pattern; there is nothing to look up. */
37
+ const GLOB_LIKE = /\S*\*\S*/g;
38
+ /**
39
+ * A path-shaped token: two or more segments joined by a separator.
40
+ *
41
+ * Group 1 pins the character BEFORE the token and rejects the ones that mean
42
+ * "not repo-relative" — a leading separator (`/tmp/x.log`), a home marker
43
+ * (`~/.claude/settings.json`), a drive colon (`C:\Users\x\y.ts`), or a longer
44
+ * path this token is merely the tail of. Backslashes are accepted on input and
45
+ * normalised away, because a learning written on Windows says `src\cli\x.ts`.
46
+ */
47
+ const PATH_TOKEN = /(^|[^A-Za-z0-9._~@+\-/\\:])([A-Za-z0-9._~@+-]+(?:[/\\][A-Za-z0-9._~@+-]+)+[/\\]?)/g;
48
+ /** Sentence punctuation that rides along on a path at the end of a clause. */
49
+ const TRAILING_PUNCTUATION = /[.,;:!?)\]}'"`]+$/;
50
+ /** A file extension, which is what separates `src/foo.ts` from `and/or`. */
51
+ const FILE_EXTENSION = /\.[A-Za-z0-9]{1,10}$/;
52
+ /**
53
+ * An environment variable standing in for a path root —
54
+ * `CLAUDE_PROJECT_DIR/.claude/helpers/gate.cjs`. The underscore is required, so
55
+ * an ordinary shouted directory (`API/v1.json`, `README/notes.md`) is untouched.
56
+ */
57
+ const ENV_VAR_SEGMENT = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/;
58
+ /**
59
+ * Normalise one matched token, or reject it as unscoreable.
60
+ *
61
+ * Rejections are the load-bearing half. A detector whose output is mostly false
62
+ * positives gets ignored wholesale, so anything whose resolution would say
63
+ * something about the environment rather than about the entry is dropped here:
64
+ *
65
+ * - `node_modules/...` resolves or not depending on whether anyone has run an
66
+ * install in this checkout. That is a fact about the checkout, not the entry.
67
+ * - An absolute, home-relative, or parent-relative path is not repo-relative,
68
+ * so resolving it against the project root would be meaningless.
69
+ * - A bare filename (`package.json`) and an extensionless pair (`and/or`,
70
+ * `KEEP/RETIRE`) are too ambiguous to score at all.
71
+ * - A path rooted at an environment variable resolves to wherever that variable
72
+ * points, which is not this tree.
73
+ */
74
+ function normalizeCandidate(raw) {
75
+ let candidate = raw.replace(TRAILING_PUNCTUATION, '').replace(/\\/g, '/');
76
+ if (candidate.startsWith('./'))
77
+ candidate = candidate.slice(2);
78
+ if (!candidate || candidate.startsWith('/') || candidate.startsWith('~') || candidate.startsWith('../')) {
79
+ return null;
80
+ }
81
+ if (candidate.includes('//'))
82
+ return null;
83
+ const lower = candidate.toLowerCase();
84
+ if (lower.startsWith('node_modules/') || lower.includes('/node_modules/'))
85
+ return null;
86
+ if (ENV_VAR_SEGMENT.test(candidate.split('/')[0]))
87
+ return null;
88
+ const isDirectory = candidate.endsWith('/');
89
+ const body = isDirectory ? candidate.slice(0, -1) : candidate;
90
+ if (!body.includes('/'))
91
+ return null;
92
+ if (!isDirectory && !FILE_EXTENSION.test(body))
93
+ return null;
94
+ return candidate;
95
+ }
96
+ /**
97
+ * Pull the repo-relative paths an entry cites, deduplicated, in first-seen
98
+ * order.
99
+ *
100
+ * Exported because it is where every false positive would come from: the rules
101
+ * above are only checkable by driving prose at them directly.
102
+ */
103
+ export function extractCandidatePaths(content) {
104
+ // Blank URLs and globs out of the text BEFORE tokenising. Filtering them
105
+ // afterwards does not work — a URL's own path tail tokenises cleanly on its
106
+ // own and would survive as `docs/spec.md`.
107
+ const text = String(content ?? '').replace(URL_LIKE, ' ').replace(GLOB_LIKE, ' ');
108
+ const out = [];
109
+ const seen = new Set();
110
+ for (const match of text.matchAll(PATH_TOKEN)) {
111
+ const candidate = normalizeCandidate(match[2]);
112
+ if (!candidate || seen.has(candidate))
113
+ continue;
114
+ seen.add(candidate);
115
+ out.push(candidate);
116
+ }
117
+ return out;
118
+ }
119
+ /**
120
+ * Resolve a cited path as written, then under each workspace prefix.
121
+ *
122
+ * The second pass is what makes the detector usable rather than noise. A
123
+ * learning is authored from inside a workspace and routinely cites
124
+ * `src/routes/foo.ts` meaning `packages/api/src/routes/foo.ts`; without the
125
+ * retry every such citation reads as dead. The reference implementation this
126
+ * pass is ported from measured that single omission taking its findings from
127
+ * 103 entries to 261 — an auditor that is wrong more often than right gets
128
+ * ignored wholesale, which costs the other three buckets their audience too.
129
+ *
130
+ * A prefix the path already carries is skipped: retrying `packages/api/x.ts`
131
+ * under `packages/api` only ever asks about `packages/api/packages/api/x.ts`.
132
+ */
133
+ export function resolvesInTree(relativePath, resolves, workspacePrefixes = []) {
134
+ return resolvesUnder(relativePath, resolves, normalizePrefixes(workspacePrefixes));
135
+ }
136
+ /**
137
+ * Put a prefix list into the one form {@link resolvesUnder} can compose with.
138
+ *
139
+ * Hoisted out of the resolution loop deliberately: the prefix list is the same
140
+ * for every candidate in the store, so normalising inside the loop would repeat
141
+ * this work once per candidate per prefix for no answer that changes.
142
+ */
143
+ function normalizePrefixes(workspacePrefixes) {
144
+ const out = [];
145
+ for (const raw of workspacePrefixes) {
146
+ const prefix = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
147
+ if (prefix)
148
+ out.push(prefix);
149
+ }
150
+ return out;
151
+ }
152
+ /** {@link resolvesInTree} over an already-normalised prefix list. */
153
+ function resolvesUnder(relativePath, resolves, prefixes) {
154
+ if (resolves(relativePath))
155
+ return true;
156
+ for (const prefix of prefixes) {
157
+ if (relativePath === prefix || relativePath.startsWith(`${prefix}/`))
158
+ continue;
159
+ if (resolves(posixPath.join(prefix, relativePath)))
160
+ return true;
161
+ }
162
+ return false;
163
+ }
164
+ /**
165
+ * Nominate entries citing paths that resolve nowhere.
166
+ *
167
+ * **This nominates, it never decides.** A dead path has four causes and only a
168
+ * reader can tell them apart: the file moved and the lesson still holds, the
169
+ * file was deleted and the lesson was about that code, the file was deleted but
170
+ * the lesson generalises, or the entry is a historical record that is correct as
171
+ * written. The move case is the common one, which is why reading "dead path" as
172
+ * "retire" throws away lessons that are still entirely true. The verdict table
173
+ * goes to the model in {@link buildJudgePrompt}.
174
+ *
175
+ * Resolution is memoised across the whole store, not per entry: the same file is
176
+ * cited by many learnings, and every miss costs one lookup per workspace prefix.
177
+ */
178
+ export function findDeadPaths(rows, options) {
179
+ const prefixes = normalizePrefixes(options.workspacePrefixes ?? []);
180
+ const maxPerEntry = Math.max(1, options.maxPathsPerEntry ?? DEFAULT_DEAD_PATHS_PER_ENTRY);
181
+ const resolved = new Map();
182
+ const found = [];
183
+ for (const row of rows) {
184
+ const deadPaths = [];
185
+ for (const candidate of extractCandidatePaths(row.content)) {
186
+ let alive = resolved.get(candidate);
187
+ if (alive === undefined) {
188
+ alive = resolvesUnder(candidate, options.resolves, prefixes);
189
+ resolved.set(candidate, alive);
190
+ }
191
+ if (alive)
192
+ continue;
193
+ deadPaths.push(candidate);
194
+ if (deadPaths.length >= maxPerEntry)
195
+ break;
196
+ }
197
+ if (deadPaths.length > 0)
198
+ found.push({ row, deadPaths });
199
+ }
200
+ return found;
201
+ }
202
+ //# sourceMappingURL=learnings-dead-paths.js.map