@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.
Files changed (107) hide show
  1. package/README.md +16 -1
  2. package/lib/composition/index.js +3 -0
  3. package/lib/composition-D8Y3-ROd.js +604 -0
  4. package/lib/ensure-MEYMj9jn.js +222 -0
  5. package/lib/errors-Br2-uUSK.js +57 -0
  6. package/lib/http-routes-BP-S3KV-.js +149 -0
  7. package/lib/http-routes.js +2 -0
  8. package/lib/index.js +66 -850
  9. package/lib/restrictor.js +37 -0
  10. package/lib/subagent/index.js +186 -0
  11. package/lib/types/citations.js +192 -0
  12. package/lib/types/{preset-composition.d.ts → composition/composer.d.ts} +1 -1
  13. package/lib/types/composition/composer.js +681 -0
  14. package/lib/types/composition/ensure.d.ts +10 -0
  15. package/lib/types/composition/ensure.js +95 -0
  16. package/lib/types/composition/index.d.ts +5 -0
  17. package/lib/types/composition/index.js +7 -0
  18. package/lib/types/composition/preset-ids.d.ts +17 -0
  19. package/lib/types/composition/preset-ids.js +21 -0
  20. package/lib/types/composition/restricted-preset.d.ts +36 -0
  21. package/lib/types/composition/restricted-preset.js +42 -0
  22. package/lib/types/constants.js +223 -0
  23. package/lib/types/cost.js +67 -0
  24. package/lib/types/curator.js +346 -0
  25. package/lib/types/diagnostics.d.ts +26 -0
  26. package/lib/types/diagnostics.js +23 -0
  27. package/lib/types/document-body.js +91 -0
  28. package/lib/types/drift-signals.js +319 -0
  29. package/lib/types/env.js +25 -0
  30. package/lib/types/errors.d.ts +4 -1
  31. package/lib/types/errors.js +59 -0
  32. package/lib/types/events.js +16 -0
  33. package/lib/types/evidence.js +65 -0
  34. package/lib/types/evolution-events.js +479 -0
  35. package/lib/types/fold-untrusted.js +44 -0
  36. package/lib/types/frontmatter.js +623 -0
  37. package/lib/types/fuzzy-match.js +135 -0
  38. package/lib/types/gates.js +51 -0
  39. package/lib/types/guards.js +27 -0
  40. package/lib/types/health-baseline.js +63 -0
  41. package/lib/types/http-routes.d.ts +67 -35
  42. package/lib/types/http-routes.js +135 -0
  43. package/lib/types/index.d.ts +7 -2
  44. package/lib/types/index.js +101 -0
  45. package/lib/types/instance-scope.js +69 -0
  46. package/lib/types/io.js +675 -0
  47. package/lib/types/learn-prompt.js +42 -0
  48. package/lib/types/limits.js +63 -0
  49. package/lib/types/live.js +107 -0
  50. package/lib/types/long-runs.js +96 -0
  51. package/lib/types/memory-store.d.ts +0 -16
  52. package/lib/types/memory-store.js +606 -0
  53. package/lib/types/mutations.js +95 -0
  54. package/lib/types/namespace-migration.js +156 -0
  55. package/lib/types/numeric.js +47 -0
  56. package/lib/types/opt-in.js +101 -0
  57. package/lib/types/package-identity.js +47 -0
  58. package/lib/types/params.js +328 -0
  59. package/lib/types/payload.js +36 -0
  60. package/lib/types/platform-line.generated.d.ts +10 -0
  61. package/lib/types/platform-line.generated.js +12 -0
  62. package/lib/types/platform-services.d.ts +2 -2
  63. package/lib/types/platform-services.js +52 -0
  64. package/lib/types/probe.js +122 -0
  65. package/lib/types/prompts.js +409 -0
  66. package/lib/types/quality.js +308 -0
  67. package/lib/types/reachability.js +122 -0
  68. package/lib/types/redact.js +257 -0
  69. package/lib/types/reference-rewrite.js +144 -0
  70. package/lib/types/reports.js +110 -0
  71. package/lib/types/restrictor.d.ts +11 -0
  72. package/lib/types/restrictor.js +48 -0
  73. package/lib/types/review-channel.js +59 -0
  74. package/lib/types/run-registry.js +345 -0
  75. package/lib/types/scope.js +7 -0
  76. package/lib/types/serial.js +17 -0
  77. package/lib/types/session-fold.d.ts +15 -0
  78. package/lib/types/session-fold.js +28 -0
  79. package/lib/types/session-projection.js +295 -0
  80. package/lib/types/signals.js +209 -0
  81. package/lib/types/skill-discovery.js +66 -0
  82. package/lib/types/skill-health.js +134 -0
  83. package/lib/types/skill-history.js +745 -0
  84. package/lib/types/skill-lint.js +112 -0
  85. package/lib/types/skill-reads.js +67 -0
  86. package/lib/types/skill-row-cells.js +123 -0
  87. package/lib/types/skill-store.js +3528 -0
  88. package/lib/types/state-store.js +60 -0
  89. package/lib/types/store-ledger.js +117 -0
  90. package/lib/types/subagent/channel.d.ts +4 -0
  91. package/lib/types/subagent/channel.js +131 -0
  92. package/lib/types/subagent/index.d.ts +4 -0
  93. package/lib/types/subagent/index.js +5 -0
  94. package/lib/types/subagent/readiness.d.ts +6 -0
  95. package/lib/types/subagent/readiness.js +35 -0
  96. package/lib/types/subagent/types.d.ts +58 -0
  97. package/lib/types/subagent/types.js +2 -0
  98. package/lib/types/threats.js +338 -0
  99. package/lib/types/tool-dispatch.d.ts +5 -0
  100. package/lib/types/tool-dispatch.js +535 -0
  101. package/lib/types/tree-write.js +40 -0
  102. package/lib/types/unit-members.js +121 -0
  103. package/lib/types/usage.js +474 -0
  104. package/lib/types/write-authority.js +23 -0
  105. package/lib/types/write-gates.js +344 -0
  106. package/lib/types/write-inventory.js +158 -0
  107. 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