@lmzhen/dsh-evolution-core 0.26.15 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) 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 +777 -840
  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} +2 -2
  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.d.ts +2 -0
  25. package/lib/types/curator.js +346 -0
  26. package/lib/types/diagnostics.d.ts +26 -0
  27. package/lib/types/diagnostics.js +23 -0
  28. package/lib/types/document-body.js +91 -0
  29. package/lib/types/drift-signals.js +319 -0
  30. package/lib/types/env.js +25 -0
  31. package/lib/types/errors.d.ts +4 -1
  32. package/lib/types/errors.js +59 -0
  33. package/lib/types/events.js +16 -0
  34. package/lib/types/evidence.js +65 -0
  35. package/lib/types/evolution-events.js +479 -0
  36. package/lib/types/fold-untrusted.js +44 -0
  37. package/lib/types/frontmatter.js +623 -0
  38. package/lib/types/fuzzy-match.js +135 -0
  39. package/lib/types/gates.js +51 -0
  40. package/lib/types/guards.js +27 -0
  41. package/lib/types/health-baseline.js +63 -0
  42. package/lib/types/http-routes.d.ts +67 -35
  43. package/lib/types/http-routes.js +135 -0
  44. package/lib/types/index.d.ts +13 -2
  45. package/lib/types/index.js +101 -0
  46. package/lib/types/instance-scope.js +69 -0
  47. package/lib/types/io.js +675 -0
  48. package/lib/types/learn-prompt.js +42 -0
  49. package/lib/types/limits.js +63 -0
  50. package/lib/types/live.js +107 -0
  51. package/lib/types/long-runs.js +96 -0
  52. package/lib/types/memory-store.d.ts +0 -16
  53. package/lib/types/memory-store.js +606 -0
  54. package/lib/types/mutations.js +95 -0
  55. package/lib/types/namespace-migration.js +156 -0
  56. package/lib/types/numeric.js +47 -0
  57. package/lib/types/opt-in.js +101 -0
  58. package/lib/types/package-identity.d.ts +14 -0
  59. package/lib/types/package-identity.js +47 -0
  60. package/lib/types/params.js +328 -0
  61. package/lib/types/payload.js +36 -0
  62. package/lib/types/platform-line.generated.d.ts +10 -0
  63. package/lib/types/platform-line.generated.js +12 -0
  64. package/lib/types/platform-services.d.ts +2 -2
  65. package/lib/types/platform-services.js +52 -0
  66. package/lib/types/probe.d.ts +9 -3
  67. package/lib/types/probe.js +122 -0
  68. package/lib/types/prompts.js +409 -0
  69. package/lib/types/quality.js +308 -0
  70. package/lib/types/reachability.d.ts +46 -0
  71. package/lib/types/reachability.js +122 -0
  72. package/lib/types/redact.js +257 -0
  73. package/lib/types/reference-rewrite.js +144 -0
  74. package/lib/types/reports.js +110 -0
  75. package/lib/types/restrictor.d.ts +11 -0
  76. package/lib/types/restrictor.js +48 -0
  77. package/lib/types/review-channel.js +59 -0
  78. package/lib/types/run-registry.js +345 -0
  79. package/lib/types/scope.js +7 -0
  80. package/lib/types/serial.js +17 -0
  81. package/lib/types/session-fold.d.ts +15 -0
  82. package/lib/types/session-fold.js +28 -0
  83. package/lib/types/session-projection.js +295 -0
  84. package/lib/types/signals.js +209 -0
  85. package/lib/types/skill-discovery.js +66 -0
  86. package/lib/types/skill-health.js +134 -0
  87. package/lib/types/skill-history.js +745 -0
  88. package/lib/types/skill-lint.js +112 -0
  89. package/lib/types/skill-reads.js +67 -0
  90. package/lib/types/skill-row-cells.js +123 -0
  91. package/lib/types/skill-store.d.ts +34 -3
  92. package/lib/types/skill-store.js +3528 -0
  93. package/lib/types/state-store.js +60 -0
  94. package/lib/types/store-ledger.d.ts +44 -0
  95. package/lib/types/store-ledger.js +117 -0
  96. package/lib/types/subagent/channel.d.ts +4 -0
  97. package/lib/types/subagent/channel.js +131 -0
  98. package/lib/types/subagent/index.d.ts +4 -0
  99. package/lib/types/subagent/index.js +5 -0
  100. package/lib/types/subagent/readiness.d.ts +6 -0
  101. package/lib/types/subagent/readiness.js +35 -0
  102. package/lib/types/subagent/types.d.ts +58 -0
  103. package/lib/types/subagent/types.js +2 -0
  104. package/lib/types/threats.js +338 -0
  105. package/lib/types/tool-dispatch.d.ts +5 -0
  106. package/lib/types/tool-dispatch.js +535 -0
  107. package/lib/types/tree-write.d.ts +53 -0
  108. package/lib/types/tree-write.js +40 -0
  109. package/lib/types/unit-members.js +121 -0
  110. package/lib/types/usage.js +474 -0
  111. package/lib/types/write-authority.d.ts +87 -0
  112. package/lib/types/write-authority.js +23 -0
  113. package/lib/types/write-gates.d.ts +111 -0
  114. package/lib/types/write-gates.js +344 -0
  115. package/lib/types/write-inventory.js +158 -0
  116. package/package.json +39 -11
@@ -0,0 +1,121 @@
1
+ /** Candidate skills: head first, then `sources[]` order, DEDUPLICATED (sources may name the head). */
2
+ export function candidateSkills(head) {
3
+ const out = [head.skill];
4
+ for (const source of head.sources)
5
+ if (!out.includes(source.skill))
6
+ out.push(source.skill);
7
+ return out;
8
+ }
9
+ /** @param indexes - skill name -> that skill's index text, or null when the index file is absent. */
10
+ /** The `path` of one manifest move, or the empty string when the record carries no string one.
11
+ * Kept as a function so the read never stringifies an object (the linter refuses
12
+ * `String(unknown)` for exactly that reason) and every call site reads the same way. */
13
+ function movePath(move) {
14
+ const raw = move.path;
15
+ return typeof raw === 'string' ? raw : '';
16
+ }
17
+ export function resolveUnitMembersFromIndexes(head, indexes, readEntries) {
18
+ const members = [];
19
+ const missing = [];
20
+ for (const skill of candidateSkills(head)) {
21
+ const text = indexes.get(skill);
22
+ if (text === undefined || text === null) {
23
+ missing.push({ skill, reason: 'index-absent' });
24
+ continue;
25
+ }
26
+ const entries = readEntries(text).filter(entry => entry.unitId === head.unitId);
27
+ if (entries.length === 0) {
28
+ missing.push({ skill, reason: 'unit-absent' });
29
+ continue;
30
+ }
31
+ members.push({ skill, entries });
32
+ }
33
+ const conflicts = [];
34
+ const manifest = head.unitHead?.moves ?? undefined;
35
+ if (manifest !== undefined) {
36
+ const shardPaths = [];
37
+ for (const slice of members) {
38
+ for (const entry of slice.entries) {
39
+ for (const move of (entry.unitHead?.moves ?? [])) {
40
+ const path = movePath(move);
41
+ if (path !== '')
42
+ shardPaths.push(path);
43
+ }
44
+ }
45
+ }
46
+ const wanted = manifest.map(move => movePath(move)).filter(p => p !== '');
47
+ for (const path of wanted)
48
+ if (!shardPaths.includes(path))
49
+ conflicts.push({ kind: 'missing', path });
50
+ for (const path of shardPaths)
51
+ if (!wanted.includes(path))
52
+ conflicts.push({ kind: 'foreign', path });
53
+ for (const path of wanted)
54
+ if (shardPaths.filter(p => p === path).length > 1)
55
+ conflicts.push({ kind: 'duplicated', path });
56
+ }
57
+ return { members, missing, conflicts };
58
+ }
59
+ /** A unit's members are trustworthy only when nothing is missing and nothing conflicts. */
60
+ export function unitMembersAreComplete(members) {
61
+ return members.missing.length === 0 && members.conflicts.length === 0;
62
+ }
63
+ /**
64
+ * The io shell (A19.2): open ONLY the known keys -- never enumerate a directory. A skill whose index
65
+ * file is unreadable becomes `null` (index-absent), which the core then reports by name.
66
+ */
67
+ export async function resolveUnitMembers(root, io, head, indexPath, readEntries) {
68
+ const indexes = new Map();
69
+ for (const skill of candidateSkills(head)) {
70
+ let text = null;
71
+ try {
72
+ text = await io.readText(indexPath(root, skill));
73
+ }
74
+ catch {
75
+ text = null;
76
+ }
77
+ indexes.set(skill, text);
78
+ }
79
+ return resolveUnitMembersFromIndexes(head, indexes, raw => readEntries(raw));
80
+ }
81
+ /** G3b: a LANDMARK carries the owning skill and no manifest (the two forms never coexist). */
82
+ export function isLandmark(entry) {
83
+ const head = entry.unitHead;
84
+ return head !== undefined && head.moves === undefined && head.skill !== undefined;
85
+ }
86
+ /** @param entry - the entry being judged. @param ownerEntries - the owner skill's index, or null when
87
+ * the owner has no readable index. */
88
+ export function landmarkVerdict(entry, ownerEntries) {
89
+ const head = entry.unitHead;
90
+ const skill = isLandmark(entry) ? head?.skill : undefined;
91
+ if (skill === undefined || entry.unitId === undefined)
92
+ return { jumpable: false, reason: 'not-a-landmark' };
93
+ if (ownerEntries === null)
94
+ return { jumpable: false, reason: 'owner-absent' };
95
+ const held = ownerEntries.some(candidate => candidate.unitId === entry.unitId);
96
+ return held ? { jumpable: true } : { jumpable: false, reason: 'unit-absent' };
97
+ }
98
+ /**
99
+ * Scan a whole corpus (skill name -> index entries, or null when unreadable) for stale route stubs.
100
+ * Sorted by (skill, version, owner): a function of the corpus, not of the caller's map order.
101
+ */
102
+ export function staleRouteStubs(indexes) {
103
+ const out = [];
104
+ for (const [skill, entries] of indexes) {
105
+ if (entries === null)
106
+ continue;
107
+ for (const entry of entries) {
108
+ const head = entry.unitHead;
109
+ const owner = isLandmark(entry) ? head?.skill : undefined;
110
+ if (owner === undefined)
111
+ continue;
112
+ const verdict = landmarkVerdict(entry, indexes.get(owner) ?? null);
113
+ if (!verdict.jumpable && verdict.reason !== 'not-a-landmark')
114
+ out.push({ skill, v: entry.v, owner, reason: verdict.reason });
115
+ }
116
+ }
117
+ // F3 (independent review, 2026-10-14): sort so the reading is a function of the corpus rather than of
118
+ // the caller's Map insertion order (a concurrent index assembly would otherwise reorder the report).
119
+ return out.sort((a, b) => a.skill.localeCompare(b.skill) || a.v - b.v || a.owner.localeCompare(b.owner));
120
+ }
121
+ //# sourceMappingURL=unit-members.js.map
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Skill usage telemetry sidecar: `$DSH_HOME/skills/.usage.json`.
3
+ * Format-compatible with Hermes Agent / hermes-claw core fields.
4
+ */
5
+ import { join } from 'node:path';
6
+ import { isRecord } from "./guards.js";
7
+ import { nodeEvolutionIo, transactIo } from "./io.js";
8
+ export function usageFile(root) {
9
+ return join(root, '.usage.json');
10
+ }
11
+ export function emptyRecord() {
12
+ return {
13
+ created_by: null,
14
+ use_count: 0,
15
+ view_count: 0,
16
+ patch_count: 0,
17
+ last_used_at: null,
18
+ last_viewed_at: null,
19
+ last_patched_at: null,
20
+ created_at: new Date().toISOString(),
21
+ state: 'active',
22
+ pinned: false,
23
+ archived_at: null,
24
+ };
25
+ }
26
+ /** A timestamp passes only when `Date.parse` yields a finite epoch (N-3): a bare
27
+ * string check let garbage like "not-a-date" propagate as Invalid Date → NaN
28
+ * into quality math and lifecycle comparisons. */
29
+ const validTimestamp = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
30
+ const nullableTimestamp = (value) => value === null || validTimestamp(value);
31
+ /**
32
+ * Field-level normalization for one sidecar record (rc.42 audit P2-3): the
33
+ * spread used to copy any junk through verbatim, so a corrupted file could
34
+ * carry `use_count: "3"` into the quality math and lifecycle comparisons as
35
+ * NaN. Every field falls back to its `emptyRecord()` baseline unless it has
36
+ * exactly the declared type; an invalid `created_at` anchors the age clock at
37
+ * now (first-sight defer semantics for a record whose age is unknowable).
38
+ * Timestamps additionally require a parseable date (N-3): `"not-a-date"`
39
+ * would otherwise survive the type check as Invalid Date.
40
+ * Pure — exported for unit tests; `loadUsage` is the production caller.
41
+ */
42
+ export function normalizeUsageRecord(record) {
43
+ const base = emptyRecord();
44
+ if (!record || typeof record !== 'object' || Array.isArray(record))
45
+ return base;
46
+ const raw = record;
47
+ // C-09: counters are cardinalities — a negative value from a
48
+ // corrupted sidecar falls back to the baseline instead of poisoning the
49
+ // quality math and the write-ghost judgment (1e300-class magnitudes still
50
+ // pass; only the sign domain is closed here).
51
+ const num = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback;
52
+ const bool = (value, fallback) => typeof value === 'boolean' ? value : fallback;
53
+ return {
54
+ // v28 G5.2 (USAGE-01): unknown fields pass through UNTOUCHED and land
55
+ // before the known keys, so a newer writer's per-record fields survive
56
+ // this runtime's write-side RMW (mutateUsage persists the rebuilt map).
57
+ // The family's forward-compat discipline (A2-11/L-1/F-338) gates on the
58
+ // top-level file version, but the usage writer emits none — without this
59
+ // spread the first counter bump stripped every unknown field. Known keys
60
+ // are overridden below and stay strictly sanitized; a malformed value
61
+ // falls back exactly as before.
62
+ ...raw,
63
+ created_by: typeof raw.created_by === 'string' ? raw.created_by : null,
64
+ use_count: num(raw.use_count, base.use_count),
65
+ view_count: num(raw.view_count, base.view_count),
66
+ patch_count: num(raw.patch_count, base.patch_count),
67
+ last_used_at: nullableTimestamp(raw.last_used_at) ? raw.last_used_at : base.last_used_at,
68
+ last_viewed_at: nullableTimestamp(raw.last_viewed_at) ? raw.last_viewed_at : base.last_viewed_at,
69
+ last_patched_at: nullableTimestamp(raw.last_patched_at) ? raw.last_patched_at : base.last_patched_at,
70
+ // An unknowable age anchors at now: the record's inactivity clock starts
71
+ // today instead of counting from epoch.
72
+ created_at: validTimestamp(raw.created_at) ? raw.created_at : base.created_at,
73
+ state: raw.state === 'stale' || raw.state === 'archived' ? raw.state : 'active',
74
+ pinned: bool(raw.pinned, base.pinned),
75
+ archived_at: nullableTimestamp(raw.archived_at) ? raw.archived_at : base.archived_at,
76
+ quality_score: typeof raw.quality_score === 'number' && Number.isFinite(raw.quality_score) ? raw.quality_score : undefined,
77
+ quality_warn: typeof raw.quality_warn === 'boolean' ? raw.quality_warn : undefined,
78
+ feedback_score: typeof raw.feedback_score === 'number' && Number.isFinite(raw.feedback_score) ? raw.feedback_score : undefined,
79
+ feedback_warn: typeof raw.feedback_warn === 'boolean' ? raw.feedback_warn : undefined,
80
+ support_reads: normalizeSupportReads(raw.support_reads),
81
+ };
82
+ }
83
+ /** Sidecar bound: a pathological path set must not grow the usage file forever. */
84
+ export const MAX_SUPPORT_READ_PATHS = 200;
85
+ /** Sanitize the per-support-file read counts: non-negative finite numbers under
86
+ * non-empty, bounded, non-traversal path keys. An unusable value drops the whole
87
+ * map (absence over a poisoned count). */
88
+ function normalizeSupportReads(value) {
89
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
90
+ return undefined;
91
+ const out = {};
92
+ let kept = 0;
93
+ for (const [key, count] of Object.entries(value)) {
94
+ if (kept >= MAX_SUPPORT_READ_PATHS)
95
+ break;
96
+ if (key === '' || key.length > 200 || key.split('/').includes('..'))
97
+ continue;
98
+ if (typeof count !== 'number' || !Number.isFinite(count) || count < 0)
99
+ continue;
100
+ out[key] = count;
101
+ kept += 1;
102
+ }
103
+ return kept === 0 ? undefined : out;
104
+ }
105
+ /** Parse a raw usage sidecar; malformed content reads as empty (best-effort telemetry). */
106
+ function parseUsage(raw) {
107
+ const map = new Map();
108
+ if (raw === null)
109
+ return map;
110
+ try {
111
+ const parsed = JSON.parse(raw);
112
+ // V6-20 (0.3.37): a top-level ARRAY must not fold into "0"/"1" phantom
113
+ // skill records and be persisted as an object map (parseSuppressed /
114
+ // normalizeUsageRecord already guard their entry shapes — this is the
115
+ // missing top-level guard).
116
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
117
+ return map;
118
+ for (const [name, record] of Object.entries(parsed)) {
119
+ map.set(name, normalizeUsageRecord(record));
120
+ }
121
+ }
122
+ catch {
123
+ // Malformed sidecar is treated as empty.
124
+ }
125
+ return map;
126
+ }
127
+ export async function loadUsage(root, io = nodeEvolutionIo()) {
128
+ return parseUsage(await io.readText(usageFile(root)));
129
+ }
130
+ export async function mutateUsage(root, io, task, options = {}) {
131
+ await transactIo(io, usageFile(root), async (current) => {
132
+ // P3 (v3 audit): a malformed sidecar is never overwritten by the RMW —
133
+ // JSON.parse swallow→empty then persist would destroy recoverable telemetry.
134
+ // P3 (v17): the same preserve applies to "valid JSON, wrong top-level
135
+ // shape" (array/scalar) — parseUsage folds those to an empty map, and
136
+ // persisting that would DESTROY the original bytes (mutations.ts and the
137
+ // suppression sidecar both keep array compat; usage is the odd one out).
138
+ // v21 (L-1): the WHOLE-FILE branches (unparsable JSON / array / scalar)
139
+ // used to freeze every later RMW as a SILENT no-op — telemetry counts,
140
+ // feedback and the curator's lifecycle fold all stopped landing with no
141
+ // throw and no warn (the exact defect shape the v19 P2-9 note fixed for
142
+ // the malformed-ENTRIES case). Same healing posture as state-json's E-9:
143
+ // quarantine the original bytes, warn via onQuarantine, continue with an
144
+ // empty map. A FAILED quarantine copy keeps the freeze but says so — the
145
+ // bytes on disk stay the only recovery source, so overwriting them is
146
+ // refused (v21 L-2, the P2-17 discipline state-json already follows).
147
+ let shapePreserved = false;
148
+ let recovered = null;
149
+ const quarantineCopy = async () => {
150
+ const corruptPath = `${usageFile(root)}.corrupt`;
151
+ return io.writeText(corruptPath, current).then(() => true, () => false);
152
+ };
153
+ if (current !== null) {
154
+ let parsed = null;
155
+ try {
156
+ const probe = JSON.parse(current);
157
+ if (isRecord(probe))
158
+ parsed = probe;
159
+ }
160
+ catch {
161
+ parsed = null;
162
+ }
163
+ if (parsed === null) {
164
+ // Whole-file corruption (unparsable JSON) or wrong top-level shape
165
+ // (array/scalar): quarantine + warn + restart empty (parseUsage folds
166
+ // the preserved bytes to an empty map below).
167
+ const copied = await quarantineCopy();
168
+ if (!copied) {
169
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable; the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable (telemetry is frozen until the file is recovered manually)`);
170
+ return current;
171
+ }
172
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} is unreadable (unparsable JSON or wrong top-level shape); the original bytes were copied to ${usageFile(root)}.corrupt and the sidecar restarts empty`);
173
+ }
174
+ else {
175
+ const record = parsed;
176
+ const isMalformed = (value) => value === null || typeof value !== 'object' || Array.isArray(value);
177
+ // A2-11 (v18): a newer on-disk version is never downgraded by this
178
+ // writer (a skill literally named `version` holds an object, not a
179
+ // number, so this cannot false-positive on a usage map). v21 (L-1):
180
+ // the freeze now says so — the old silence was indistinguishable from
181
+ // a dead pipeline.
182
+ if (typeof record.version === 'number' && record.version > 1) {
183
+ // S3-5 (J-6): the SAME refusal its siblings report. Handing the message
184
+ // to `onQuarantine` alone made the freeze silent whenever the caller had
185
+ // no callback — the one artifact of the four whose version refusal could
186
+ // go unreported (pinned by tests/version-policy-uniformity.spec.ts).
187
+ const message = `usage sidecar ${usageFile(root)} carries schema version ${String(record.version)} (> this runtime) — writes stay frozen and the bytes preserved until the runtime is upgraded`;
188
+ if (options.onQuarantine !== undefined)
189
+ options.onQuarantine(message);
190
+ else
191
+ console.warn(message);
192
+ shapePreserved = true;
193
+ }
194
+ else if (Object.values(record).some(isMalformed)) {
195
+ // P2-9 (v19): keep the original bytes in a quarantine copy, warn,
196
+ // and continue with the good entries so the facility heals itself.
197
+ // v21 (L-2): when the rescue copy ITSELF fails, refuse the write
198
+ // instead of destroying the only recoverable bytes — and never
199
+ // claim a copy exists when it does not.
200
+ const bad = Object.keys(record).filter(key => isMalformed(record[key]));
201
+ const copied = await quarantineCopy();
202
+ if (!copied) {
203
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? 'y' : 'ies'} (${bad.slice(0, 5).join(', ')}); the .corrupt copy could not be written — refusing this write so the original bytes stay recoverable`);
204
+ return current;
205
+ }
206
+ recovered = JSON.stringify(Object.fromEntries(Object.entries(record).filter(([, value]) => !isMalformed(value))));
207
+ options.onQuarantine?.(`usage sidecar ${usageFile(root)} carried ${bad.length} malformed entr${bad.length === 1 ? 'y' : 'ies'} (${bad.slice(0, 5).join(', ')}); the original bytes were copied to ${usageFile(root)}.corrupt and the remaining entries continue to be served`);
208
+ }
209
+ }
210
+ }
211
+ if (shapePreserved)
212
+ return current;
213
+ const map = parseUsage(recovered ?? current);
214
+ await task(map);
215
+ return JSON.stringify(Object.fromEntries(map.entries()), null, 2);
216
+ });
217
+ }
218
+ /** Curator-owned usage fields (rc.67 K-2): the curator writes ONLY this set —
219
+ * lifecycle state, archive stamp, the six-factor quality pair, and the
220
+ * marker-mirrored pin flag; counters and activity stamps belong to the
221
+ * tool-telemetry side and are never copied by a fold. P2-12 (v39): the
222
+ * combined `applyCuratorFields` wrapper had no production caller (folds go
223
+ * through {@link foldCuratorFields}), so the two field copies below are the
224
+ * whole contract. */
225
+ /** Copy only the lifecycle pair (state/archived_at) — see the ownership split
226
+ * rationale above. */
227
+ export function applyCuratorLifecycleFields(disk, curated) {
228
+ disk.state = curated.state;
229
+ disk.archived_at = curated.archived_at;
230
+ }
231
+ /**
232
+ * Copy the recomputed meta pair (quality_score/quality_warn + the
233
+ * marker-mirrored pin flag) — refreshed tree-wide each run by design, so a
234
+ * concurrent curator run's lifecycle changes are never reverted by them.
235
+ * P1-1 (v15): the feedback pair (`feedback_score`/`feedback_warn`) is
236
+ * deliberately NOT copied — it is feedback-owned (see the field-ownership
237
+ * contract on {@link UsageRecord}) and must survive curator runs untouched.
238
+ */
239
+ export function applyCuratorMetaFields(disk, curated) {
240
+ disk.quality_score = curated.quality_score;
241
+ disk.quality_warn = curated.quality_warn;
242
+ disk.pinned = curated.pinned;
243
+ }
244
+ /**
245
+ * Fold a curator run-start snapshot onto the current on-disk map (rc.67 K-2):
246
+ * each curated record is projected onto its disk peer by copying only the
247
+ * curator-owned fields, so a concurrent tool-side bump between snapshot and
248
+ * save survives. Records absent from the snapshot are left untouched; a
249
+ * curated record with no disk peer is seeded from the snapshot. `stateOwned`
250
+ * (rc.72 H-1) restricts the lifecycle pair to the names this run ACTUALLY
251
+ * transitioned — a concurrent curator run's archive/restore is never reverted
252
+ * by a stale snapshot; without it both pairs apply everywhere.
253
+ */
254
+ export function foldCuratorFields(disk, curated, stateOwned, runStartStates) {
255
+ // A2-4 (v18): names whose lifecycle fold was REFUSED because the on-disk
256
+ // state no longer equals the run-start state (another process moved them).
257
+ const skipped = [];
258
+ for (const [name, record] of curated) {
259
+ const diskRecord = disk.get(name);
260
+ if (!diskRecord) {
261
+ disk.set(name, { ...record });
262
+ continue;
263
+ }
264
+ applyCuratorMetaFields(diskRecord, record);
265
+ if (stateOwned === undefined || stateOwned.has(name)) {
266
+ // A2-4 (v18): compare-and-set on the lifecycle pair. The run-start state
267
+ // is the basis this run decided from; a different on-disk state means a
268
+ // concurrent run/process already moved the skill, and its archive or
269
+ // restore must win rather than be reverted to this run's stale snapshot.
270
+ // An unknown start state keeps the previous behavior — the name is in
271
+ // `stateOwned` precisely because this run transitioned it.
272
+ const expected = runStartStates?.get(name);
273
+ if (expected !== undefined && diskRecord.state !== expected) {
274
+ skipped.push(name);
275
+ continue;
276
+ }
277
+ applyCuratorLifecycleFields(diskRecord, record);
278
+ }
279
+ }
280
+ return skipped;
281
+ }
282
+ /** Whole-file usage write (V6-37, 0.3.37): this is the ONE path that bypasses
283
+ * the malformed-defense and the transact lock — prefer `mutateUsage` for any
284
+ * read-modify-write so a concurrent writer cannot lose its update and a
285
+ * malformed sidecar stays recoverable. Kept for fixture/test seeding.
286
+ * @internal P3-16 (v14): no production caller (verified by grep); exported for
287
+ * the family's tests only. Do not use it to write the sidecar in new code. */
288
+ export async function saveUsage(root, map, io = nodeEvolutionIo()) {
289
+ const obj = Object.fromEntries(map.entries());
290
+ await io.writeText(usageFile(root), JSON.stringify(obj, null, 2));
291
+ }
292
+ export function getRecord(map, name) {
293
+ let record = map.get(name);
294
+ if (!record) {
295
+ record = emptyRecord();
296
+ map.set(name, record);
297
+ }
298
+ return record;
299
+ }
300
+ export function bumpView(map, name, when = new Date()) {
301
+ const record = getRecord(map, name);
302
+ record.view_count += 1;
303
+ record.last_viewed_at = when.toISOString();
304
+ }
305
+ export function bumpUse(map, name, when = new Date()) {
306
+ const record = getRecord(map, name);
307
+ record.use_count += 1;
308
+ record.last_used_at = when.toISOString();
309
+ }
310
+ export function bumpPatch(map, name, when = new Date()) {
311
+ const record = getRecord(map, name);
312
+ record.patch_count += 1;
313
+ record.last_patched_at = when.toISOString();
314
+ }
315
+ /** Count one observed support-file read (design §5.5). The map is created on
316
+ * first evidence only — a skill with no support reads keeps the field absent. */
317
+ export function bumpSupportRead(map, name, rel, when = new Date()) {
318
+ const record = getRecord(map, name);
319
+ const counts = record.support_reads ?? {};
320
+ counts[rel] = (counts[rel] ?? 0) + 1;
321
+ record.support_reads = counts;
322
+ // The newest read is the demand clock; reuse the view timestamp so no extra
323
+ // field has to be introduced (a support read IS a read of the skill).
324
+ record.last_viewed_at = when.toISOString();
325
+ }
326
+ export function markAgentCreated(map, name) {
327
+ getRecord(map, name).created_by = 'agent';
328
+ }
329
+ export function latestActivityAt(record) {
330
+ // 0.3.16 (E-46): lexical ISO sorting misorders numeric offsets (+08:00 vs Z
331
+ // for the same instant) — compare by Date.parse; unparseable values count
332
+ // as absent.
333
+ const values = [record.last_used_at, record.last_viewed_at, record.last_patched_at]
334
+ .filter((value) => typeof value === 'string' && Number.isFinite(Date.parse(value)));
335
+ if (values.length === 0)
336
+ return null;
337
+ return values.reduce((latest, value) => (Date.parse(value) > Date.parse(latest) ? value : latest));
338
+ }
339
+ /** Days between an ISO timestamp (falling back to `created`) and `now`. An
340
+ * unparseable date counts as 0 days: NaN silently froze every age comparison
341
+ * (A2-9). */
342
+ export function daysSinceIso(iso, created, nowMs) {
343
+ const t = Date.parse(iso ?? created);
344
+ if (!Number.isFinite(t))
345
+ return 0;
346
+ return (nowMs - t) / 86_400_000;
347
+ }
348
+ /** Idle days of one skill since its lifecycle age anchor (`last activity ??
349
+ * created_at`) — the SAME anchor the curator's transitions use, so the
350
+ * maintenance view and the lifecycle can never disagree about staleness. */
351
+ export function idleDays(record, now = new Date()) {
352
+ return daysSinceIso(latestActivityAt(record), record.created_at, now.getTime());
353
+ }
354
+ /**
355
+ * Whether the library has ANY observed read evidence (C observation window):
356
+ * reads were invisible to the usage sidecar before A2, so `view_count` zero
357
+ * means "never read" ONLY after the first observed read exists anywhere in
358
+ * the map. Before that, churn-based signals (write-ghost) are untrustworthy
359
+ * and callers must suppress them. Pure and derived — never persisted.
360
+ */
361
+ export function usageObserved(usage) {
362
+ for (const record of usage.values()) {
363
+ if (record.view_count > 0)
364
+ return true;
365
+ }
366
+ return false;
367
+ }
368
+ /**
369
+ * Curator suppression sidecar: built-in skills the curator has archived stay
370
+ * suppressed across re-seeds, so the lifecycle never fights a re-created
371
+ * bundled skill. Best-effort load/save, mirroring the usage sidecar posture.
372
+ * Versioned shape ({ version, names }) with legacy plain-array compat.
373
+ */
374
+ export const SUPPRESSED_FILE_VERSION = 1;
375
+ export function suppressedFile(root) {
376
+ return join(root, '.curator-suppressed.json');
377
+ }
378
+ export async function loadSuppressedNames(root, io = nodeEvolutionIo()) {
379
+ return parseSuppressed(await io.readText(suppressedFile(root)));
380
+ }
381
+ function parseSuppressed(raw) {
382
+ if (raw === null)
383
+ return new Set();
384
+ try {
385
+ const parsed = JSON.parse(raw);
386
+ const names = Array.isArray(parsed)
387
+ ? parsed
388
+ : typeof parsed === 'object' && parsed !== null && Array.isArray(parsed.names)
389
+ ? parsed.names
390
+ : [];
391
+ return new Set(names.filter((entry) => typeof entry === 'string'));
392
+ }
393
+ catch {
394
+ // Malformed sidecar is treated as empty. Suppression is best-effort.
395
+ return new Set();
396
+ }
397
+ }
398
+ /**
399
+ * Plain wholesale write of the suppression sidecar (tests and fixture seeding).
400
+ * @internal S2.4 (PLAN 2026-09-16, audit P2-31): NO production consumer
401
+ * (verified by grep; production suppressions go through
402
+ * {@link updateSuppressedNames}) — exported for the family's tests only. Do
403
+ * not use it to write the sidecar in new code.
404
+ *
405
+ * The write rides the {@link transactIo} channel (the same lock the RMW
406
+ * writer uses), not the former naked read → `io.writeText`: that form was an
407
+ * unserialized read-modify-write — the last `saveSuppressedNames` in this file
408
+ * with no lock, while its sibling `saveUsage` already carried the `@internal`
409
+ * test-only note. Version discipline is unchanged (V24-09 / S2-14): a read
410
+ * failure surfaces (an unreadable sidecar is never written over), a newer
411
+ * on-disk schema is warned about and preserved byte-for-byte (the
412
+ * byte-identical result short-circuits the write), and a malformed sidecar is
413
+ * still overwritten with the v1 shape (the historical plain-writer posture).
414
+ */
415
+ export async function saveSuppressedNames(root, names, io = nodeEvolutionIo()) {
416
+ await transactIo(io, suppressedFile(root), (current) => {
417
+ // V24-09 (v24): same future-version discipline as the RMW path below and
418
+ // the mutations/usage/events writers (A2-11 / L-1 / F-338) — a newer
419
+ // on-disk schema is never downgraded by this writer.
420
+ // S2-14 (root cause B): and neither is one this writer could not READ. The
421
+ // former `.catch(() => null)` folded a read failure (EACCES/EIO/EISDIR —
422
+ // `readText` answers null only for a MISSING path) into "no sidecar", skipped
423
+ // the version guard and overwrote it: the one fold in this file that permitted
424
+ // a downgrade. The failure now surfaces to the caller.
425
+ if (current !== null) {
426
+ try {
427
+ const parsed = JSON.parse(current);
428
+ if (parsed !== null && typeof parsed.version === 'number' && parsed.version > SUPPRESSED_FILE_VERSION) {
429
+ console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than ${SUPPRESSED_FILE_VERSION}); not overwritten`);
430
+ // Byte-identical result → transactIo/io.transact skip the write.
431
+ return current;
432
+ }
433
+ }
434
+ catch {
435
+ // Malformed — the plain write below matches the historical behavior.
436
+ }
437
+ }
438
+ return JSON.stringify({ version: SUPPRESSED_FILE_VERSION, names: [...names].sort() }, null, 2);
439
+ });
440
+ }
441
+ /**
442
+ * Atomic read-modify-write on the suppression sidecar (rc.50 P2-2): `task`
443
+ * receives the set parsed from the current on-disk state and may mutate it;
444
+ * the result is persisted inside the same transact so a second process
445
+ * sharing DSH_HOME cannot interleave its RMW. Best-effort posture unchanged.
446
+ */
447
+ export async function updateSuppressedNames(root, io, task) {
448
+ await transactIo(io, suppressedFile(root), async (current) => {
449
+ // P3: never overwrite a malformed suppression sidecar; V24-09 (v24): a
450
+ // newer on-disk version is never downgraded by this writer — the family
451
+ // discipline the mutations (A2-11), usage (L-1) and evolution-events
452
+ // (F-338) writers already follow. parseSuppressed below deliberately
453
+ // ignores `version`; this guard reads it directly so an unknown future
454
+ // shape (carrying fields this runtime does not know) keeps its bytes
455
+ // instead of being rewritten back to the v1 shape.
456
+ if (current !== null) {
457
+ let parsed = null;
458
+ try {
459
+ parsed = JSON.parse(current);
460
+ }
461
+ catch {
462
+ return current;
463
+ }
464
+ if (parsed !== null && typeof parsed.version === 'number' && parsed.version > SUPPRESSED_FILE_VERSION) {
465
+ console.warn(`suppression sidecar ${suppressedFile(root)} declares version ${String(parsed.version)} (newer than ${SUPPRESSED_FILE_VERSION}); not overwritten`);
466
+ return current;
467
+ }
468
+ }
469
+ const names = parseSuppressed(current);
470
+ await task(names);
471
+ return JSON.stringify({ version: SUPPRESSED_FILE_VERSION, names: [...names].sort() }, null, 2);
472
+ });
473
+ }
474
+ //# sourceMappingURL=usage.js.map