@lmzhen/dsh-evolution-core 0.26.26 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -1
- package/lib/composition/index.js +3 -0
- package/lib/composition-D8Y3-ROd.js +604 -0
- package/lib/ensure-MEYMj9jn.js +222 -0
- package/lib/errors-Br2-uUSK.js +57 -0
- package/lib/http-routes-BP-S3KV-.js +149 -0
- package/lib/http-routes.js +2 -0
- package/lib/index.js +66 -850
- package/lib/restrictor.js +37 -0
- package/lib/subagent/index.js +186 -0
- package/lib/types/citations.js +192 -0
- package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
- package/lib/types/composition/composer.js +681 -0
- package/lib/types/composition/ensure.d.ts +10 -0
- package/lib/types/composition/ensure.js +95 -0
- package/lib/types/composition/index.d.ts +5 -0
- package/lib/types/composition/index.js +7 -0
- package/lib/types/composition/preset-ids.d.ts +17 -0
- package/lib/types/composition/preset-ids.js +21 -0
- package/lib/types/composition/restricted-preset.d.ts +36 -0
- package/lib/types/composition/restricted-preset.js +42 -0
- package/lib/types/constants.js +223 -0
- package/lib/types/cost.js +67 -0
- package/lib/types/curator.js +346 -0
- package/lib/types/diagnostics.d.ts +26 -0
- package/lib/types/diagnostics.js +23 -0
- package/lib/types/document-body.js +91 -0
- package/lib/types/drift-signals.js +319 -0
- package/lib/types/env.js +25 -0
- package/lib/types/errors.d.ts +4 -1
- package/lib/types/errors.js +59 -0
- package/lib/types/events.js +16 -0
- package/lib/types/evidence.js +65 -0
- package/lib/types/evolution-events.js +479 -0
- package/lib/types/fold-untrusted.js +44 -0
- package/lib/types/frontmatter.js +623 -0
- package/lib/types/fuzzy-match.js +135 -0
- package/lib/types/gates.js +51 -0
- package/lib/types/guards.js +27 -0
- package/lib/types/health-baseline.js +63 -0
- package/lib/types/http-routes.d.ts +67 -35
- package/lib/types/http-routes.js +135 -0
- package/lib/types/index.d.ts +7 -2
- package/lib/types/index.js +101 -0
- package/lib/types/instance-scope.js +69 -0
- package/lib/types/io.js +675 -0
- package/lib/types/learn-prompt.js +42 -0
- package/lib/types/limits.js +63 -0
- package/lib/types/live.js +107 -0
- package/lib/types/long-runs.js +96 -0
- package/lib/types/memory-store.d.ts +0 -16
- package/lib/types/memory-store.js +606 -0
- package/lib/types/mutations.js +95 -0
- package/lib/types/namespace-migration.js +156 -0
- package/lib/types/numeric.js +47 -0
- package/lib/types/opt-in.js +101 -0
- package/lib/types/package-identity.js +47 -0
- package/lib/types/params.js +328 -0
- package/lib/types/payload.js +36 -0
- package/lib/types/platform-line.generated.d.ts +10 -0
- package/lib/types/platform-line.generated.js +12 -0
- package/lib/types/platform-services.d.ts +2 -2
- package/lib/types/platform-services.js +52 -0
- package/lib/types/probe.js +122 -0
- package/lib/types/prompts.js +409 -0
- package/lib/types/quality.js +308 -0
- package/lib/types/reachability.js +122 -0
- package/lib/types/redact.js +257 -0
- package/lib/types/reference-rewrite.js +144 -0
- package/lib/types/reports.js +110 -0
- package/lib/types/restrictor.d.ts +11 -0
- package/lib/types/restrictor.js +48 -0
- package/lib/types/review-channel.js +59 -0
- package/lib/types/run-registry.js +345 -0
- package/lib/types/scope.js +7 -0
- package/lib/types/serial.js +17 -0
- package/lib/types/session-fold.d.ts +15 -0
- package/lib/types/session-fold.js +28 -0
- package/lib/types/session-projection.js +295 -0
- package/lib/types/signals.js +209 -0
- package/lib/types/skill-discovery.js +66 -0
- package/lib/types/skill-health.js +134 -0
- package/lib/types/skill-history.js +745 -0
- package/lib/types/skill-lint.js +112 -0
- package/lib/types/skill-reads.js +67 -0
- package/lib/types/skill-row-cells.js +123 -0
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.js +117 -0
- package/lib/types/subagent/channel.d.ts +4 -0
- package/lib/types/subagent/channel.js +131 -0
- package/lib/types/subagent/index.d.ts +4 -0
- package/lib/types/subagent/index.js +5 -0
- package/lib/types/subagent/readiness.d.ts +6 -0
- package/lib/types/subagent/readiness.js +35 -0
- package/lib/types/subagent/types.d.ts +58 -0
- package/lib/types/subagent/types.js +2 -0
- package/lib/types/threats.js +338 -0
- package/lib/types/tool-dispatch.d.ts +5 -0
- package/lib/types/tool-dispatch.js +535 -0
- package/lib/types/tree-write.js +40 -0
- package/lib/types/unit-members.js +121 -0
- package/lib/types/usage.js +474 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.js +344 -0
- package/lib/types/write-inventory.js +158 -0
- package/package.json +39 -11
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quality scoring and near-duplicate detection for the curated skill library.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions over data inputs so the scoring policy is unit-testable and
|
|
5
|
+
* the same math feeds the usage sidecar, the `skill_manage review` surface and
|
|
6
|
+
* the learning graph. Weights follow the Hermes/hermes-claw six-factor model;
|
|
7
|
+
* mutation maturity is a documented DSH approximation (single per-month patch
|
|
8
|
+
* trend ratio replaces the claw timestamp-trend formula, since DSH usage
|
|
9
|
+
* records only carry the last patched timestamp).
|
|
10
|
+
*
|
|
11
|
+
* Batch C (2026-09-27): the similarity half of this module is now TWO NAMED
|
|
12
|
+
* QUESTIONS plus a declared projection — {@link identity} ("is it the same
|
|
13
|
+
* text?") and {@link affinity} ("how much vocabulary do these two share?") asked
|
|
14
|
+
* over {@link projectionText}. Callers name the question and the projection
|
|
15
|
+
* instead of growing a fourth unnamed ruler (design §3C / §1 I2).
|
|
16
|
+
* @module @lmzhen/dsh-evolution-core
|
|
17
|
+
*/
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { latestActivityAt } from "./usage.js";
|
|
20
|
+
export const QUALITY_WEIGHTS = {
|
|
21
|
+
usageFrequency: 0.25,
|
|
22
|
+
stability: 0.20,
|
|
23
|
+
recency: 0.20,
|
|
24
|
+
references: 0.10,
|
|
25
|
+
mutationMaturity: 0.20,
|
|
26
|
+
richness: 0.05,
|
|
27
|
+
};
|
|
28
|
+
/** Score below which a skill is flagged for review. */
|
|
29
|
+
export const LOW_QUALITY_THRESHOLD = 0.3;
|
|
30
|
+
function clamp01(value) {
|
|
31
|
+
return Math.max(0, Math.min(1, value));
|
|
32
|
+
}
|
|
33
|
+
function daysBetween(from, now) {
|
|
34
|
+
// A2-9 (v18): an unparseable timestamp must not poison the score with NaN.
|
|
35
|
+
const t = Date.parse(from);
|
|
36
|
+
if (!Number.isFinite(t))
|
|
37
|
+
return 0;
|
|
38
|
+
return Math.max(0, (now.getTime() - t) / 86_400_000);
|
|
39
|
+
}
|
|
40
|
+
export function computeQualityScores(input) {
|
|
41
|
+
const now = input.now ?? new Date();
|
|
42
|
+
const scores = new Map();
|
|
43
|
+
for (const [name, record] of input.usage) {
|
|
44
|
+
const ageDays = Math.max(1, daysBetween(record.created_at, now));
|
|
45
|
+
const idleDays = daysBetween(latestActivityAt(record) ?? record.created_at, now);
|
|
46
|
+
const patchCount = record.patch_count;
|
|
47
|
+
// E-2 / G-1 (v18): a skill is "used" when it is LOADED. The in-tree
|
|
48
|
+
// producer is the platform `skill` tool's `view` bump (skill-usage
|
|
49
|
+
// READ_SKILL_TOOL_KIND); `use_count` has no in-tree producer and stays an
|
|
50
|
+
// EXTERNAL host signal. Summing both makes these two factors real today
|
|
51
|
+
// while still counting a host that writes `use` — no durable-format change
|
|
52
|
+
// and no silent refactor of the published field.
|
|
53
|
+
const loadCount = record.use_count + record.view_count;
|
|
54
|
+
const usageFrequency = clamp01(loadCount / ageDays);
|
|
55
|
+
const stability = loadCount === 0 ? 1 : clamp01(1 - patchCount / loadCount);
|
|
56
|
+
const recency = idleDays < 30 ? 1 : clamp01(1 - (idleDays - 30) / 150);
|
|
57
|
+
const references = clamp01((input.referenceCounts?.get(name) ?? 0) / 3);
|
|
58
|
+
const mutationMaturity = patchCount === 0 ? 0.3 : patchCount === 1 ? 0.4 : clamp01((patchCount - 1) / Math.max(1, ageDays / 30));
|
|
59
|
+
const richness = clamp01((input.supportDirs?.get(name) ?? 0) * 0.175);
|
|
60
|
+
const factors = { usageFrequency, stability, recency, references, mutationMaturity, richness };
|
|
61
|
+
const score = usageFrequency * QUALITY_WEIGHTS.usageFrequency
|
|
62
|
+
+ stability * QUALITY_WEIGHTS.stability
|
|
63
|
+
+ recency * QUALITY_WEIGHTS.recency
|
|
64
|
+
+ references * QUALITY_WEIGHTS.references
|
|
65
|
+
+ mutationMaturity * QUALITY_WEIGHTS.mutationMaturity
|
|
66
|
+
+ richness * QUALITY_WEIGHTS.richness;
|
|
67
|
+
scores.set(name, { score, factors, warn: score < LOW_QUALITY_THRESHOLD });
|
|
68
|
+
}
|
|
69
|
+
return scores;
|
|
70
|
+
}
|
|
71
|
+
function normalize(content) {
|
|
72
|
+
return content.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
73
|
+
}
|
|
74
|
+
/* ------------------------------------------------------------------------- *
|
|
75
|
+
* Similarity: two NAMED questions, and the projection a caller declares.
|
|
76
|
+
*
|
|
77
|
+
* Batch C (2026-09-27, design §3C) — the module used to carry three unnamed
|
|
78
|
+
* rulers (a normalized hash, a token Jaccard, a first-word name index) whose
|
|
79
|
+
* questions overlapped without saying so. The questions are now named, and a
|
|
80
|
+
* caller that wants a comparison must name the one it is asking:
|
|
81
|
+
*
|
|
82
|
+
* 1. {@link identity} — "is this the SAME TEXT?" (normalized-content hash)
|
|
83
|
+
* 2. {@link affinity} — "how much VOCABULARY do these two share?" (Jaccard
|
|
84
|
+
* over two {@link vocabulary} sets)
|
|
85
|
+
*
|
|
86
|
+
* {@link computePrefixClusters} stays what it always was — a name-side
|
|
87
|
+
* STRUCTURAL index (which names share a first word), not a similarity score.
|
|
88
|
+
* What a caller declares instead of inventing a fourth ruler is the PROJECTION
|
|
89
|
+
* ({@link projectionText}): `body` = the whole SKILL.md, `summary` = name +
|
|
90
|
+
* description. Adding a projection is one entry in that table, and the two
|
|
91
|
+
* callers stop disagreeing about what they compared (design §6.4).
|
|
92
|
+
* ------------------------------------------------------------------------- */
|
|
93
|
+
/** Named similarity question 1 — "is it the same text?" — as a normalized
|
|
94
|
+
* content hash: case- and whitespace-insensitive, so two texts that differ only
|
|
95
|
+
* in formatting are the SAME text here. Invariant under which projection the
|
|
96
|
+
* caller read the text from.
|
|
97
|
+
*
|
|
98
|
+
* C-26 (v10 audit): NOT named `contentHash` — mutations.ts exports a
|
|
99
|
+
* contentHash of RAW bytes, this one hashes the NORMALIZED text for dedup
|
|
100
|
+
* grouping. Same helper as that rename, now a named question. */
|
|
101
|
+
export function identity(text) {
|
|
102
|
+
return createHash('sha256').update(normalize(text)).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The declared projection: WHICH text a comparison weighs.
|
|
106
|
+
*
|
|
107
|
+
* `body` is the library-wide scan's projection (whole SKILL.md, read by the
|
|
108
|
+
* caller). `summary` is name + description in one string — the projection a
|
|
109
|
+
* listing already publishes (`SkillSummary` carries both fields, and a body is
|
|
110
|
+
* attached only when the caller asks for it) — so a summary comparison never
|
|
111
|
+
* reads a body OF ITS OWN: the create-time hint is fed by the listing pass the
|
|
112
|
+
* tool already pays for its frontmatter, and calls no read of its own.
|
|
113
|
+
*/
|
|
114
|
+
export function projectionText(projection, input) {
|
|
115
|
+
switch (projection) {
|
|
116
|
+
case 'body': return input.content ?? '';
|
|
117
|
+
case 'summary': return `${input.name ?? ''} ${input.description ?? ''}`;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** The comparable vocabulary of one text: lowercase alphanumeric runs, plus
|
|
121
|
+
* whole CJK runs (a Chinese description tokenizes by punctuation, not by word —
|
|
122
|
+
* deliberately: the ruler is one home, and a real tokenizer is a different
|
|
123
|
+
* question than "do these two share vocabulary?"). */
|
|
124
|
+
export function vocabulary(text) {
|
|
125
|
+
return new Set(normalize(text).split(/[^a-z0-9\u4e00-\u9fff]+/).filter(Boolean));
|
|
126
|
+
}
|
|
127
|
+
/** Named similarity question 2 — "how much vocabulary do these two share?" —
|
|
128
|
+
* Jaccard over two {@link vocabulary} sets, 0 (nothing in common, or either side
|
|
129
|
+
* empty) to 1 (identical vocabulary). Set-based on purpose: the bulk scan
|
|
130
|
+
* memoizes each name's set once and only the comparisons are budgeted. */
|
|
131
|
+
export function affinity(a, b) {
|
|
132
|
+
if (a.size === 0 || b.size === 0)
|
|
133
|
+
return 0;
|
|
134
|
+
let intersection = 0;
|
|
135
|
+
for (const token of a)
|
|
136
|
+
if (b.has(token))
|
|
137
|
+
intersection += 1;
|
|
138
|
+
return intersection / (a.size + b.size - intersection);
|
|
139
|
+
}
|
|
140
|
+
/** The `affinity` level at which two bodies are treated as the same skill
|
|
141
|
+
* (design §3C: the default is unchanged at 0.95 — it is now a NAME, because the
|
|
142
|
+
* docblock below has linked it since PLAN-R2 while the code carried a bare
|
|
143
|
+
* literal). Callers may still pass their own level. */
|
|
144
|
+
export const DEDUP_SIMILARITY_THRESHOLD = 0.95;
|
|
145
|
+
/** PLAN-R2 P2-8 (2026-09-16): default cap on two-name comparisons in
|
|
146
|
+
* {@link computeDedupGroups}. A 2000-skill library is ~2M pairs; the old
|
|
147
|
+
* unbounded two-two Jaccard ran seconds to tens of seconds per
|
|
148
|
+
* review/`dedup_group` probe. 250k comparisons bounds that to well under a
|
|
149
|
+
* second while staying far above every real library's pair count. Only
|
|
150
|
+
* pairwise comparisons are budgeted — materializing a name's token set
|
|
151
|
+
* (memoized, once per name) and the exact-hash pre-union phase are not. */
|
|
152
|
+
export const DEDUP_MAX_PAIR_COMPARISONS = 250_000;
|
|
153
|
+
/**
|
|
154
|
+
* Two-phase near-duplicate clustering: exact normalized-hash groups first,
|
|
155
|
+
* then token-Jaccard edges at {@link DEDUP_SIMILARITY_THRESHOLD} with a token
|
|
156
|
+
* ratio guard, union-find across the whole set.
|
|
157
|
+
*
|
|
158
|
+
* PLAN-R2 P2-8 (2026-09-16): each name's token set is materialized once
|
|
159
|
+
* (memoized map), each pair takes an O(1) size-ratio short-circuit before the
|
|
160
|
+
* intersection, and the pairwise loop is bounded by
|
|
161
|
+
* `maxPairComparisons` (default {@link DEDUP_MAX_PAIR_COMPARISONS}); hitting
|
|
162
|
+
* the budget stops the scan and `truncated: true` says so. Small libraries
|
|
163
|
+
* (below the budget) behave exactly as the unbounded scan did.
|
|
164
|
+
*/
|
|
165
|
+
export function computeDedupGroups(input) {
|
|
166
|
+
const threshold = input.threshold ?? DEDUP_SIMILARITY_THRESHOLD;
|
|
167
|
+
const maxPairComparisons = input.maxPairComparisons ?? DEDUP_MAX_PAIR_COMPARISONS;
|
|
168
|
+
const names = [...input.contents.keys()];
|
|
169
|
+
const hashes = new Map();
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
// Question 1 over the declared projection: the body is what this scan weighs
|
|
172
|
+
// (it was handed bodies by its caller — see the tool path's exclusion notes).
|
|
173
|
+
const hash = identity(projectionText('body', { content: input.contents.get(name) ?? '' }));
|
|
174
|
+
const bucket = hashes.get(hash);
|
|
175
|
+
if (bucket)
|
|
176
|
+
bucket.push(name);
|
|
177
|
+
else
|
|
178
|
+
hashes.set(hash, [name]);
|
|
179
|
+
}
|
|
180
|
+
// Union-find over names; exact-hash peers are pre-united.
|
|
181
|
+
const parent = new Map();
|
|
182
|
+
const find = (x) => {
|
|
183
|
+
const root = parent.get(x) ?? x;
|
|
184
|
+
if (root !== x)
|
|
185
|
+
parent.set(x, find(root));
|
|
186
|
+
return parent.get(x) ?? x;
|
|
187
|
+
};
|
|
188
|
+
const union = (a, b) => {
|
|
189
|
+
const [ra, rb] = [find(a), find(b)];
|
|
190
|
+
if (ra !== rb)
|
|
191
|
+
parent.set(rb, ra);
|
|
192
|
+
};
|
|
193
|
+
for (const [, bucketNames] of hashes) {
|
|
194
|
+
const first = bucketNames[0];
|
|
195
|
+
if (first === undefined || bucketNames.length === 1)
|
|
196
|
+
continue;
|
|
197
|
+
for (let index = 1; index < bucketNames.length; index += 1) {
|
|
198
|
+
const peer = bucketNames[index];
|
|
199
|
+
if (peer)
|
|
200
|
+
union(first, peer);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const tokens = new Map();
|
|
204
|
+
const tokenSet = (name) => {
|
|
205
|
+
let set = tokens.get(name);
|
|
206
|
+
if (!set) {
|
|
207
|
+
set = vocabulary(projectionText('body', { content: input.contents.get(name) ?? '' }));
|
|
208
|
+
tokens.set(name, set);
|
|
209
|
+
}
|
|
210
|
+
return set;
|
|
211
|
+
};
|
|
212
|
+
// PLAN-R2 P2-8: the memoized tokenSet materializes each name's set at most
|
|
213
|
+
// once; only the two-name comparisons below count against the budget.
|
|
214
|
+
let compared = 0;
|
|
215
|
+
let truncated = false;
|
|
216
|
+
for (let index = 0; index < names.length && !truncated; index += 1) {
|
|
217
|
+
const a = names[index];
|
|
218
|
+
if (a === undefined)
|
|
219
|
+
continue;
|
|
220
|
+
for (let other = index + 1; other < names.length; other += 1) {
|
|
221
|
+
const b = names[other];
|
|
222
|
+
if (b === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
if (compared >= maxPairComparisons) {
|
|
225
|
+
truncated = true;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
compared += 1;
|
|
229
|
+
const [ta, tb] = [tokenSet(a), tokenSet(b)];
|
|
230
|
+
if (Math.max(ta.size, tb.size) / Math.max(1, Math.min(ta.size, tb.size)) > 5)
|
|
231
|
+
continue;
|
|
232
|
+
if (affinity(ta, tb) >= threshold)
|
|
233
|
+
union(a, b);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const groups = new Map();
|
|
237
|
+
for (const name of names) {
|
|
238
|
+
const root = find(name);
|
|
239
|
+
const group = groups.get(root);
|
|
240
|
+
if (group)
|
|
241
|
+
group.push(name);
|
|
242
|
+
else
|
|
243
|
+
groups.set(root, [name]);
|
|
244
|
+
}
|
|
245
|
+
return { groups: [...groups.values()].filter(group => group.length > 1), truncated };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* The create-time near-duplicate question (batch C, design §3C-2): WHICH
|
|
249
|
+
* existing skills is this authoring candidate about to near-copy?
|
|
250
|
+
*
|
|
251
|
+
* It is the SAME {@link affinity} question as the library-wide scan, asked over
|
|
252
|
+
* the `summary` projection — the one a listing already publishes
|
|
253
|
+
* (`SkillSummary` carries `name` and `description`; nothing here can see a
|
|
254
|
+
* body, which is the point: a create must not pay a SECOND whole-tree pass — the
|
|
255
|
+
* listing's own frontmatter read is what feeds this comparison). Deliberately hint-only: the caller writes regardless, and the
|
|
256
|
+
* matches are news, not a gate (G4 — the create-time foreground behavior is
|
|
257
|
+
* unchanged).
|
|
258
|
+
*
|
|
259
|
+
* The default level is far below the scan's {@link DEDUP_SIMILARITY_THRESHOLD}
|
|
260
|
+
* because the projection is thinner: a name plus one sentence shares far fewer
|
|
261
|
+
* tokens than two bodies, and a hint that fires on every new skill is noise.
|
|
262
|
+
* Exact same-name candidates are skipped — the library's own create refuses
|
|
263
|
+
* those, and this line must not repeat that refusal.
|
|
264
|
+
*/
|
|
265
|
+
export const SUMMARY_DUPLICATE_HINT_THRESHOLD = 0.5;
|
|
266
|
+
export function nearDuplicateSummaries(input) {
|
|
267
|
+
const threshold = input.threshold ?? SUMMARY_DUPLICATE_HINT_THRESHOLD;
|
|
268
|
+
const limit = input.limit ?? 3;
|
|
269
|
+
const candidateTokens = vocabulary(projectionText('summary', input.candidate));
|
|
270
|
+
const matches = [];
|
|
271
|
+
for (const existing of input.existing) {
|
|
272
|
+
if (existing.name === input.candidate.name)
|
|
273
|
+
continue;
|
|
274
|
+
const score = affinity(candidateTokens, vocabulary(projectionText('summary', existing)));
|
|
275
|
+
if (score >= threshold)
|
|
276
|
+
matches.push({ name: existing.name, score });
|
|
277
|
+
}
|
|
278
|
+
return matches
|
|
279
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
280
|
+
.slice(0, limit);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Prefix-cluster index over a name set (rc.67 merge heuristic, input side):
|
|
284
|
+
* the curator prompt asks the model to identify "prefix clusters — skills
|
|
285
|
+
* sharing a first word or domain keyword"; the deterministic index supplies
|
|
286
|
+
* stable ground truth instead of letting the model infer clusters from the
|
|
287
|
+
* raw list. Key = first alphanumeric run of the lowercased name; groups with
|
|
288
|
+
* at least two members, largest first then alphabetical. Orientation-only:
|
|
289
|
+
* nomination authority stays with the LLM and the candidate-pool gates.
|
|
290
|
+
*/
|
|
291
|
+
export function computePrefixClusters(names) {
|
|
292
|
+
const groups = new Map();
|
|
293
|
+
for (const name of names) {
|
|
294
|
+
const key = name.toLowerCase().split(/[^a-z0-9]+/)[0];
|
|
295
|
+
if (!key)
|
|
296
|
+
continue;
|
|
297
|
+
const bucket = groups.get(key);
|
|
298
|
+
if (bucket)
|
|
299
|
+
bucket.push(name);
|
|
300
|
+
else
|
|
301
|
+
groups.set(key, [name]);
|
|
302
|
+
}
|
|
303
|
+
return [...groups.entries()]
|
|
304
|
+
.filter(([, members]) => members.length >= 2)
|
|
305
|
+
.map(([key, members]) => ({ key, members }))
|
|
306
|
+
.sort((a, b) => b.members.length - a.members.length || a.key.localeCompare(b.key));
|
|
307
|
+
}
|
|
308
|
+
//# sourceMappingURL=quality.js.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G3/3.4: the READ-ONLY reachability reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* Three resource domains have grown past their owners' view: content blobs whose index entries were
|
|
5
|
+
* trimmed away (skill-history: "Trimming drops INDEX entries; orphan blobs stay"), report files no run
|
|
6
|
+
* record points at, and snapshot directories whose manifest holds almost nothing. The audit measured
|
|
7
|
+
* them once (233 blobs / 11.40 MiB / 75.4% unreachable; 52 orphan report .md; 4/5 empty snapshot trees,
|
|
8
|
+
* the newest holding 9 of 35 skills); this module answers the same three questions, from the tree in
|
|
9
|
+
* front of it, and NOTHING ELSE: it deletes nothing, repairs nothing, and adds no timer.
|
|
10
|
+
*
|
|
11
|
+
* Every path is a parameter. The reconciliation must not grow its own idea of where a domain lives -
|
|
12
|
+
* the callers already know (historyRoot / the reports directory / the snapshot root), and a second
|
|
13
|
+
* guess here would be a second owner of that fact.
|
|
14
|
+
*/
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { isPresent, isUnknown, probeList, probeText } from "./probe.js";
|
|
17
|
+
const HISTORY_BASELINE = '233 blobs / 11.40 MiB unreachable (75.4%)';
|
|
18
|
+
const REPORTS_BASELINE = '52 orphan report .md';
|
|
19
|
+
const BACKUPS_BASELINE = '4/5 empty snapshot trees; the newest held 9/35 skills';
|
|
20
|
+
/** Every sha256 any skill index references, across the whole history root. */
|
|
21
|
+
export async function referencedBlobs(io, historyRoot) {
|
|
22
|
+
const referenced = new Set();
|
|
23
|
+
const skillsDir = join(historyRoot, 'skills');
|
|
24
|
+
const namesProbe = await probeList(io, skillsDir);
|
|
25
|
+
// N36: an unreadable skills directory is not "no indexes"; the domain is unmeasurable.
|
|
26
|
+
if (isUnknown(namesProbe))
|
|
27
|
+
return undefined;
|
|
28
|
+
const names = isPresent(namesProbe) ? namesProbe.value : [];
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const probe = await probeText(io, join(skillsDir, name, 'index.json'));
|
|
31
|
+
// N36: a read FAILURE is not "no references" - it makes the domain unmeasurable, and the caller says so.
|
|
32
|
+
if (isUnknown(probe))
|
|
33
|
+
return undefined;
|
|
34
|
+
// N36: `probe === null` was never true - `Probe<T>` is a wrapper, not `T | null`. The absent case is the
|
|
35
|
+
// wrapper state, so an ABSENT index is skipped while an UNKNOWN one already returned above.
|
|
36
|
+
if (!isPresent(probe))
|
|
37
|
+
continue;
|
|
38
|
+
for (const match of probe.value.matchAll(/[a-f0-9]{64}/g))
|
|
39
|
+
referenced.add(match[0]);
|
|
40
|
+
}
|
|
41
|
+
return referenced;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The history domain: blobs on disk minus the blobs the indexes still reference.
|
|
45
|
+
* @param io - the tree reader.
|
|
46
|
+
* @param historyRoot - the history root (`.history`), passed in, never guessed.
|
|
47
|
+
* @returns the reading, with the audit baseline quoted for comparison.
|
|
48
|
+
*/
|
|
49
|
+
export async function historyReachability(io, historyRoot) {
|
|
50
|
+
const blobsDir = join(historyRoot, 'blobs');
|
|
51
|
+
const blobsProbe = await probeList(io, blobsDir);
|
|
52
|
+
const referenced = await referencedBlobs(io, historyRoot);
|
|
53
|
+
// N36: neither half may turn a read failure into a number. An unreadable blob directory or an
|
|
54
|
+
// unreadable index makes the domain unmeasured - "not read" is not "nothing unreachable".
|
|
55
|
+
if (isUnknown(blobsProbe) || referenced === undefined) {
|
|
56
|
+
return { domain: 'history.blobs', unit: 'bytes', total: 0, reachable: 0, unreachable: 0, baseline: HISTORY_BASELINE, unmeasured: 'a blob listing or an index read failed; this domain was not measured' };
|
|
57
|
+
}
|
|
58
|
+
const blobs = isPresent(blobsProbe) ? blobsProbe.value : [];
|
|
59
|
+
const reachable = blobs.filter(blob => referenced.has(blob)).length;
|
|
60
|
+
return {
|
|
61
|
+
domain: 'history.blobs',
|
|
62
|
+
unit: 'bytes',
|
|
63
|
+
total: blobs.length,
|
|
64
|
+
reachable,
|
|
65
|
+
unreachable: blobs.length - reachable,
|
|
66
|
+
baseline: HISTORY_BASELINE,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The reports domain: files no run record points at. A record WITHOUT a `resultRef` still owns the
|
|
71
|
+
* conventional path (`reports/maintain-<id>.json`), so that path counts as reachable - counting only
|
|
72
|
+
* explicit refs would report compliant files as orphans and the number could never match the baseline.
|
|
73
|
+
* @param io - the tree reader.
|
|
74
|
+
* @param reportsDir - the reports directory, passed in.
|
|
75
|
+
* @param runIds - every run id the run records carry.
|
|
76
|
+
* @param resultRefs - every explicit resultRef those records carry.
|
|
77
|
+
*/
|
|
78
|
+
export async function reportsReachability(io, reportsDir, runIds, resultRefs) {
|
|
79
|
+
const listed = await probeList(io, reportsDir);
|
|
80
|
+
if (isUnknown(listed)) {
|
|
81
|
+
return { domain: 'reports.files', unit: 'files', total: 0, reachable: 0, unreachable: 0, baseline: REPORTS_BASELINE, unmeasured: 'the reports directory could not be listed; this domain was not measured' };
|
|
82
|
+
}
|
|
83
|
+
const files = isPresent(listed) ? listed.value : [];
|
|
84
|
+
const reachableNames = new Set();
|
|
85
|
+
for (const ref of resultRefs)
|
|
86
|
+
reachableNames.add(ref.slice(ref.lastIndexOf('/') + 1));
|
|
87
|
+
for (const id of runIds)
|
|
88
|
+
reachableNames.add('maintain-' + id + '.json');
|
|
89
|
+
const reachable = files.filter(file => reachableNames.has(file)).length;
|
|
90
|
+
return {
|
|
91
|
+
domain: 'reports.files',
|
|
92
|
+
unit: 'files',
|
|
93
|
+
total: files.length,
|
|
94
|
+
reachable,
|
|
95
|
+
unreachable: files.length - reachable,
|
|
96
|
+
baseline: REPORTS_BASELINE,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The backups domain: snapshot trees and how much each holds. `skillsPerSnapshot` is measured by the
|
|
101
|
+
* caller (it already reads manifests); an empty tree is one whose manifest lists no skill.
|
|
102
|
+
* @param io - the tree reader.
|
|
103
|
+
* @param backupsRoot - the snapshot root, passed in.
|
|
104
|
+
* @param skillsPerSnapshot - the caller's manifest reading, one entry per snapshot directory.
|
|
105
|
+
*/
|
|
106
|
+
export async function backupsReachability(io, backupsRoot, skillsPerSnapshot) {
|
|
107
|
+
const listed = await probeList(io, backupsRoot);
|
|
108
|
+
if (isUnknown(listed)) {
|
|
109
|
+
return { domain: 'backups.trees', unit: 'files', total: 0, reachable: 0, unreachable: 0, baseline: BACKUPS_BASELINE, unmeasured: 'the snapshot root could not be listed; this domain was not measured' };
|
|
110
|
+
}
|
|
111
|
+
const dirs = isPresent(listed) ? listed.value : [];
|
|
112
|
+
const empty = skillsPerSnapshot.filter(count => count === 0).length;
|
|
113
|
+
return {
|
|
114
|
+
domain: 'backups.trees',
|
|
115
|
+
unit: 'files',
|
|
116
|
+
total: dirs.length,
|
|
117
|
+
reachable: dirs.length - empty,
|
|
118
|
+
unreachable: empty,
|
|
119
|
+
baseline: BACKUPS_BASELINE,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=reachability.js.map
|