@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.
- 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 +777 -840
- 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} +2 -2
- 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.d.ts +2 -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 +13 -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.d.ts +14 -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.d.ts +9 -3
- 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.d.ts +46 -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.d.ts +34 -3
- package/lib/types/skill-store.js +3528 -0
- package/lib/types/state-store.js +60 -0
- package/lib/types/store-ledger.d.ts +44 -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.d.ts +53 -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.d.ts +87 -0
- package/lib/types/write-authority.js +23 -0
- package/lib/types/write-gates.d.ts +111 -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,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
|