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.
- package/.claude/guidance/shipped/moflo-cli-reference.md +45 -1
- package/.claude/guidance/shipped/moflo-cross-install-memory-sharing.md +7 -2
- package/.claude/guidance/shipped/moflo-skills-reference.md +2 -0
- package/.claude/skills/fl/phases.md +51 -17
- package/.claude/skills/optimize-learnings/SKILL.md +220 -0
- package/README.md +95 -1
- package/bin/lib/get-backend.mjs +150 -12
- package/bin/lib/skill-categories.mjs +1 -0
- package/bin/session-start-launcher.mjs +13 -5
- package/dist/src/cli/commands/daemon.js +5 -2
- package/dist/src/cli/commands/epic.js +5 -1
- package/dist/src/cli/commands/hive-mind.js +6 -4
- package/dist/src/cli/commands/hooks.js +8 -8
- package/dist/src/cli/commands/index.js +5 -0
- package/dist/src/cli/commands/memory-audit-learnings.js +587 -0
- package/dist/src/cli/commands/memory.js +71 -10
- package/dist/src/cli/commands/spell-schedule.js +5 -3
- package/dist/src/cli/commands/worktree.js +408 -0
- package/dist/src/cli/config/moflo-config.js +57 -0
- package/dist/src/cli/index.js +4 -2
- package/dist/src/cli/init/executor.js +1 -0
- package/dist/src/cli/mcp-tools/memory-admin-tools.js +46 -8
- package/dist/src/cli/mcp-tools/moflodb-tools.js +30 -6
- package/dist/src/cli/memory/bridge-entries.js +157 -9
- package/dist/src/cli/memory/controllers/batch-operations.js +7 -2
- package/dist/src/cli/memory/daemon-backend.js +152 -11
- package/dist/src/cli/memory/entries-read.js +47 -2
- package/dist/src/cli/memory/entries-write.js +73 -10
- package/dist/src/cli/memory/hnsw-singleton.js +112 -9
- package/dist/src/cli/memory/learnings-audit.js +420 -0
- package/dist/src/cli/memory/learnings-dead-paths.js +202 -0
- package/dist/src/cli/memory/learnings-tree.js +187 -0
- package/dist/src/cli/memory/memory-bridge.js +37 -27
- package/dist/src/cli/memory/tool-call-markup.js +218 -0
- package/dist/src/cli/parser.js +7 -3
- package/dist/src/cli/services/cherry-pick-learnings.js +9 -3
- package/dist/src/cli/services/durable-reconcile.js +161 -0
- package/dist/src/cli/services/durable-store-io.js +291 -0
- package/dist/src/cli/services/durable-sync.js +159 -24
- package/dist/src/cli/services/team-artifact-sync.js +462 -163
- package/dist/src/cli/services/worktree-provision.js +400 -0
- package/dist/src/cli/version.js +1 -1
- 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
|