@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,745 @@
1
+ /**
2
+ * Skill content history: content-addressed blobs plus a per-skill version index.
3
+ *
4
+ * Why it exists: `.mutations.json` records before/after HASHES and calls every automated edit
5
+ * "reviewable and replayable" — but a hash cannot be replayed. With no content store, the only way
6
+ * back was a whole-tree snapshot (`.backups`, keep=5) or `.archive` (removed skills only), so
7
+ * reverting one skill cost the collateral of every other change in that window. This module keeps
8
+ * the content, keyed by skill and version.
9
+ *
10
+ * Invariants (design `dsh-evolution-skill-history-design.md` §1):
11
+ * - HISTORY IS HISTORY: the skill tree stays the single truth for "current content"; nothing here
12
+ * feeds a judgment — callers read back content and version NUMBERS only.
13
+ * - BEST-EFFORT, like the mutation audit: a failed history write warns and never fails the write
14
+ * that already landed.
15
+ * - Blobs are content-addressed, so identical bodies are stored once (across skills too).
16
+ * - Versions are numbered by `v` (max+1), NOT by the clock: a moved system clock must not reorder
17
+ * history (KiroCrew's `v<N>` choice, independently arrived at). Numbering and the index read-modify-write run
18
+ * under the INDEX's own io lock, so two writers of one skill cannot mint the same number.
19
+ * - The array's ORDER is append order, and the index is appended after the content lock is released, so two
20
+ * writers of ONE skill can interleave their appends. Each entry therefore carries `beforeHash` — the content
21
+ * it replaced, read under that writer's lock — and that link, not the position, is what `undo` follows
22
+ * ({@link orderVersions} rebuilds the content order for display when the chain is complete).
23
+ *
24
+ * NOT here: blob garbage collection. Trimming drops INDEX entries; orphan blobs stay until a
25
+ * sweeper collects them, because a reference count would have to span every skill's index and that
26
+ * belongs to the curator's whole-library pass, not to the write path.
27
+ * @module @lmzhen/dsh-evolution-core/skill-history
28
+ */
29
+ import { join } from 'node:path';
30
+ import { transactIo } from "./io.js";
31
+ import { isPresent, isUnknown, probeText } from "./probe.js";
32
+ import { contentHash } from "./mutations.js";
33
+ /** Root-level history directory. Dot-prefixed so skill discovery skips it. */
34
+ export const HISTORY_DIR = '.history';
35
+ /** Versioned index shape, so a future field can migrate without guessing. */
36
+ export const HISTORY_INDEX_VERSION = 1;
37
+ /** The action label for a predecessor the index had never seen (content found on disk before this
38
+ * feature existed, or a hand-edited tree): it says "this is what was there", not what wrote it. */
39
+ export const BASELINE_ACTION = 'baseline';
40
+ /** The root-level history directory. */
41
+ export function historyRoot(root) {
42
+ return join(root, HISTORY_DIR);
43
+ }
44
+ /** Content-addressed blob path: identical bodies share one file, across skills too. */
45
+ export function blobPath(root, hash) {
46
+ return join(historyRoot(root), 'blobs', hash);
47
+ }
48
+ /** One skill's index file. */
49
+ export function historyIndexFile(root, name) {
50
+ return join(historyRoot(root), 'skills', name, 'index.json');
51
+ }
52
+ /**
53
+ * The versions in CONTENT order (oldest first), rebuilt from the chain links, for display.
54
+ *
55
+ * The stored array is append order, which interleaves when two writers of one skill audit out of
56
+ * order; the links say what actually replaced what. The rebuild runs only when it can account for
57
+ * EVERY entry (exactly one start and a forward link for each step) — otherwise the stored order is
58
+ * returned unchanged, because a partial reconstruction would be a worse answer than the honest one.
59
+ * @param versions - the stored index, oldest first as recorded.
60
+ * @returns the same entries, in content order when the chain is complete.
61
+ */
62
+ export function orderVersions(versions) {
63
+ if (versions.length < 2)
64
+ return [...versions];
65
+ const replaced = new Set();
66
+ for (const entry of versions)
67
+ if (entry.beforeHash !== undefined)
68
+ replaced.add(entry.beforeHash);
69
+ // The NEWEST entry is the one no write replaced; every other entry is named as someone's predecessor.
70
+ const ends = versions.filter(entry => !replaced.has(entry.hash));
71
+ if (ends.length !== 1)
72
+ return [...versions];
73
+ const used = new Set();
74
+ const backward = [];
75
+ let cursor = ends[0];
76
+ while (cursor !== undefined && !used.has(cursor)) {
77
+ used.add(cursor);
78
+ backward.push(cursor);
79
+ const before = cursor.beforeHash;
80
+ // A hash may repeat (an undo restores earlier content): take the newest entry not yet emitted.
81
+ cursor = before === undefined ? undefined : [...versions].reverse().find(entry => !used.has(entry) && entry.hash === before);
82
+ }
83
+ return backward.length === versions.length ? backward.reverse() : [...versions];
84
+ }
85
+ /** The actions that record a SUPPORT FILE's bytes (they share the skill's version index). */
86
+ const SUPPORT_ACTIONS = ['write_file', 'remove_file'];
87
+ /** The actions that record the skill's own SKILL.md body. */
88
+ const CONTENT_ACTIONS = ['create', 'update', 'patch', 'consolidate', 'restructure', 'restore'];
89
+ /**
90
+ * Which artifact one recorded version holds, from the action that produced it.
91
+ * @param action - the entry's action label.
92
+ * @returns the artifact class; `other` when the label does not say.
93
+ */
94
+ export function versionTarget(action) {
95
+ if (SUPPORT_ACTIONS.includes(action))
96
+ return 'support';
97
+ if (CONTENT_ACTIONS.includes(action))
98
+ return 'content';
99
+ return 'other';
100
+ }
101
+ /**
102
+ * Which artifact one INDEX ENTRY holds.
103
+ *
104
+ * {@link versionTarget} reads the ACTION, which is enough for the entries a write mints under its own
105
+ * label and is NOT enough for a {@link BASELINE_ACTION}: a baseline minted for a support write holds
106
+ * that FILE's previous bytes while its action says only "this is what was there". Two facts recover
107
+ * the artifact:
108
+ *
109
+ * - `path` is written exactly when the bytes come from a support file, on BOTH entries one support
110
+ * write can mint, so it decides on its own;
111
+ * - an entry recorded before `path` existed has none, and the link is the evidence left: a support
112
+ * write names the content it replaced through `beforeHash`, so a baseline that a support write
113
+ * names as its predecessor holds that file's bytes.
114
+ *
115
+ * The inference runs only for a baseline. Every other entry keeps the action's verdict, which is also
116
+ * the fail-closed one for an action this module does not know.
117
+ * @param entry - one entry of a skill's index.
118
+ * @param index - the index the entry came from: the legacy link is only readable across the whole
119
+ * index. Omitting it answers from `path` and the action alone.
120
+ * @returns the artifact class; `other` when nothing attributes the bytes.
121
+ */
122
+ export function entryTarget(entry, index = []) {
123
+ if (entry.path !== undefined)
124
+ return 'support';
125
+ if (entry.action !== BASELINE_ACTION)
126
+ return versionTarget(entry.action);
127
+ const namedBySupportWrite = index.some(candidate => SUPPORT_ACTIONS.includes(candidate.action) && candidate.beforeHash === entry.hash);
128
+ return namedBySupportWrite ? 'support' : 'other';
129
+ }
130
+ /**
131
+ * Split one index into the skill body's chain and the support files' versions.
132
+ *
133
+ * Both share ONE index, so a skill that ever wrote a support file has two chains and
134
+ * {@link orderVersions} cannot account for every entry (it falls back to the stored order). Splitting
135
+ * first lets each group rebuild its own order, and keeps the body's chain intact by leaving every
136
+ * entry whose artifact nothing attributes in it — a `baseline` predecessor is usually the body's
137
+ * first link, and only the support-file baselines {@link entryTarget} recovers leave it.
138
+ * @param versions - the stored index, oldest first as recorded.
139
+ * @returns the two groups, each in content order when its chain is complete.
140
+ */
141
+ export function partitionVersions(versions) {
142
+ const content = [];
143
+ const support = [];
144
+ for (const entry of versions) {
145
+ if (entryTarget(entry, versions) === 'support')
146
+ support.push(entry);
147
+ else
148
+ content.push(entry);
149
+ }
150
+ return { content: orderVersions(content), support: orderVersions(support) };
151
+ }
152
+ /**
153
+ * When this skill's content last changed: the newest `at` across BOTH chains, or null when the index
154
+ * holds nothing.
155
+ *
156
+ * The stored array is APPEND order and two writers of one skill can interleave, so the answer is the
157
+ * maximum timestamp rather than the tail. Every `at` this family writes is the same ISO-8601 UTC
158
+ * form, so comparing the strings is comparing the instants.
159
+ * @param versions - every entry the index holds, in any order.
160
+ * @returns the newest timestamp, or null when there is none to report.
161
+ */
162
+ export function latestVersionAt(versions) {
163
+ let newest = null;
164
+ for (const entry of versions) {
165
+ if (newest === null || entry.at > newest)
166
+ newest = entry.at;
167
+ }
168
+ return newest;
169
+ }
170
+ /** Keep nothing less than one version, whatever the deployment asks for. */
171
+ function clampKeep(keep) {
172
+ return Number.isFinite(keep) ? Math.max(1, Math.floor(keep)) : 1;
173
+ }
174
+ /** The next version number: max+1, never a timestamp. */
175
+ function nextVersion(versions) {
176
+ let max = 0;
177
+ for (const entry of versions)
178
+ if (entry.v > max)
179
+ max = entry.v;
180
+ return max + 1;
181
+ }
182
+ /** The last entry carrying this hash, or undefined (a hash may repeat after an undo). */
183
+ function lastVersionOf(versions, hash) {
184
+ for (let index = versions.length - 1; index >= 0; index -= 1) {
185
+ const entry = versions[index];
186
+ if (entry !== undefined && entry.hash === hash)
187
+ return entry.v;
188
+ }
189
+ return undefined;
190
+ }
191
+ /** One entry as this reader accepts it, or null when the entry cannot be understood. */
192
+ function readVersionEntry(entry) {
193
+ if (typeof entry !== 'object' || entry === null)
194
+ return null;
195
+ const candidate = entry;
196
+ if (typeof candidate.v !== 'number' || !Number.isInteger(candidate.v) || candidate.v <= 0)
197
+ return null;
198
+ if (typeof candidate.hash !== 'string' || candidate.hash === '')
199
+ return null;
200
+ if (typeof candidate.at !== 'string')
201
+ return null;
202
+ if (typeof candidate.action !== 'string')
203
+ return null;
204
+ if (typeof candidate.chars !== 'number')
205
+ return null;
206
+ // The chain link is metadata: a garbled value drops the link, not the version it belongs to.
207
+ // `summary` is metadata too, and BOTH branches must carry it: the explicit literal below is the
208
+ // one a create (which has no beforeHash) takes, and recordVersions re-serializes whatever this
209
+ // reader returned — a field dropped here is dropped for good on the next write.
210
+ // EVERY optional field is folded into this one object on purpose: the two returns below then differ
211
+ // only by the chain link, so a field added here cannot be forgotten by one of them (a field this
212
+ // reader drops is dropped for good on the next write).
213
+ const summary = typeof candidate.summary === 'string' && candidate.summary !== '' ? candidate.summary : undefined;
214
+ const path = typeof candidate.path === 'string' && candidate.path !== '' ? candidate.path : undefined;
215
+ const unitId = typeof candidate.unitId === 'string' && candidate.unitId !== '' ? candidate.unitId : undefined;
216
+ // The fold must carry unitHead or the next write drops it for good (the G3a lesson).
217
+ const unitHead = candidate.unitHead;
218
+ const base = {
219
+ v: candidate.v,
220
+ at: candidate.at,
221
+ action: candidate.action,
222
+ hash: candidate.hash,
223
+ chars: candidate.chars,
224
+ ...summary === undefined ? {} : { summary },
225
+ ...path === undefined ? {} : { path },
226
+ ...unitId === undefined ? {} : { unitId },
227
+ ...unitHead === undefined ? {} : { unitHead },
228
+ };
229
+ return typeof candidate.beforeHash === 'string' && candidate.beforeHash !== ''
230
+ ? { ...base, beforeHash: candidate.beforeHash }
231
+ : base;
232
+ }
233
+ /**
234
+ * Read one index body for a RECORDER: "understood", "no file", or "cannot be understood".
235
+ * A file whose shape is right but which carries an entry this reader would have to drop counts as
236
+ * unreadable — dropping it silently would be the overwrite the audit posture forbids.
237
+ * @param raw - the index file's bytes, or null when absent.
238
+ * @returns the three-state answer.
239
+ */
240
+ export function readHistoryIndex(raw) {
241
+ if (raw === null)
242
+ return { kind: 'absent' };
243
+ if (raw.trim() === '')
244
+ return { kind: 'unreadable' };
245
+ let parsed;
246
+ try {
247
+ parsed = JSON.parse(raw);
248
+ }
249
+ catch {
250
+ return { kind: 'unreadable' };
251
+ }
252
+ if (typeof parsed !== 'object' || parsed === null)
253
+ return { kind: 'unreadable' };
254
+ const holder = parsed;
255
+ if (typeof holder.version === 'number' && holder.version > HISTORY_INDEX_VERSION)
256
+ return { kind: 'unreadable' };
257
+ if (!Array.isArray(holder.versions))
258
+ return { kind: 'unreadable' };
259
+ const versions = [];
260
+ for (const entry of holder.versions) {
261
+ const version = readVersionEntry(entry);
262
+ if (version === null)
263
+ return { kind: 'unreadable' };
264
+ versions.push(version);
265
+ }
266
+ return { kind: 'ok', versions };
267
+ }
268
+ /**
269
+ * Parse one index body for a READER. Malformed content, a foreign shape, or a version NEWER than this
270
+ * reader all read as empty — the audit posture: never guess. A recorder must use
271
+ * {@link readHistoryIndex} instead, because it has to PRESERVE bytes it cannot understand.
272
+ * @param raw - the index file's bytes, or null when absent.
273
+ * @returns the versions, oldest first.
274
+ */
275
+ export function parseHistoryIndex(raw) {
276
+ const state = readHistoryIndex(raw);
277
+ return state.kind === 'ok' ? state.versions : [];
278
+ }
279
+ /**
280
+ * The index after this write, and the version numbers it produced. Pure, so the versioning rules
281
+ * are testable without io.
282
+ *
283
+ * Two rules matter here: a predecessor the index has never seen gets a BASELINE entry (so the state
284
+ * before this mutation is recoverable even for a skill that predates the feature), and re-writing
285
+ * identical content mints NO new version (the library's no-op updates must not grow history).
286
+ * @param versions - the current index, oldest first.
287
+ * @param input - the write being recorded.
288
+ * @param keep - how many versions to retain per skill.
289
+ * @returns the trimmed index and the recorded numbers.
290
+ */
291
+ export function nextHistoryIndex(versions, input, keep) {
292
+ const beforeHash = input.before === null ? null : contentHash(input.before);
293
+ const afterHash = input.after === null ? null : contentHash(input.after);
294
+ const grown = [...versions];
295
+ // A support write names its file once and BOTH entries it can mint carry it: the baseline is that
296
+ // same file's previous bytes and the after-entry is this write's. A content write passes none.
297
+ const pathField = input.path === undefined ? {} : { path: input.path };
298
+ // "Is the predecessor already recorded?" is asked of the WHOLE index, not of its tail: two writers
299
+ // of one skill append after releasing the content lock, so their entries can interleave — a tail
300
+ // test would mint a bogus baseline for a predecessor that sits two entries back.
301
+ if (beforeHash !== null && !grown.some(entry => entry.hash === beforeHash)) {
302
+ grown.push({
303
+ v: nextVersion(grown),
304
+ at: input.at,
305
+ action: BASELINE_ACTION,
306
+ hash: beforeHash,
307
+ chars: input.before?.length ?? 0,
308
+ ...pathField,
309
+ // G3a: a baseline is part of THIS unit too -- an unlabelled baseline splits the unit into
310
+ // adjacent segments and the prune (by unit) then keeps only one of them.
311
+ // F-G3b-1 (independent review, 2026-10-14): the head rides ONLY the last entry this landing
312
+ // mints (the after entry, or the removal when there is no after). Putting it here as well left
313
+ // TWO heads in one unit whenever a landing minted a baseline AND an after -- member resolution
314
+ // then double-counted every path into `duplicated` and refused a resolvable unit.
315
+ ...input.unitId === undefined ? {} : { unitId: input.unitId },
316
+ });
317
+ }
318
+ // A REMOVAL has no "after" bytes, so nothing below would mint an entry — and when the removed bytes
319
+ // were already indexed by the file's own write, the whole mutation left NO trace: the reader could not
320
+ // tell the file was deleted, and the `support-remove` vocabulary stayed unreachable (E2, 2026-09-30).
321
+ // The entry carries the bytes that were removed, labeled with the write that removed them, so a face
322
+ // can say "these bytes are gone" and undoing it puts them back.
323
+ const removalHash = afterHash === null ? beforeHash : null;
324
+ if (afterHash !== null) {
325
+ const tail = grown[grown.length - 1];
326
+ if (tail?.hash !== afterHash) {
327
+ grown.push({ ...input.unitId === undefined ? {} : { unitId: input.unitId },
328
+ ...input.unitHead === undefined ? {} : { unitHead: input.unitHead },
329
+ v: nextVersion(grown),
330
+ at: input.at,
331
+ action: input.action,
332
+ hash: afterHash,
333
+ chars: input.after?.length ?? 0,
334
+ ...pathField,
335
+ // The summary describes THIS write, so it rides the after-entry only: the baseline minted
336
+ // above is the state BEFORE it and has nothing to summarize.
337
+ ...input.summary === undefined ? {} : { summary: input.summary },
338
+ // The authoritative predecessor link: what THIS write replaced, read under the write lock.
339
+ ...beforeHash === null ? {} : { beforeHash },
340
+ });
341
+ }
342
+ }
343
+ if (removalHash !== null) {
344
+ const tail = grown[grown.length - 1];
345
+ // A baseline of the same bytes may sit right above (a removal of a file the index never saw): that
346
+ // entry says "we first saw these bytes", this one says "and then they were removed", and the second
347
+ // is what the face needs. Self-reference is deliberately omitted: the entry's own hash IS the
348
+ // removed content, so a `beforeHash` link would point at itself.
349
+ if (tail?.hash !== removalHash || tail.action !== input.action) {
350
+ grown.push({
351
+ v: nextVersion(grown),
352
+ at: input.at,
353
+ action: input.action,
354
+ hash: removalHash,
355
+ chars: input.before?.length ?? 0,
356
+ ...pathField,
357
+ ...input.unitId === undefined ? {} : { unitId: input.unitId },
358
+ // F-G3b-1: a removal-only landing IS the last entry of its unit, so it carries the head;
359
+ // otherwise a pure-deletion representative would leave the unit headless.
360
+ ...input.unitHead === undefined ? {} : { unitHead: input.unitHead },
361
+ ...input.summary === undefined ? {} : { summary: input.summary },
362
+ });
363
+ }
364
+ }
365
+ // G3a (R-G3a-5): retention cuts by UNIT and never through one. A unit is the run of consecutive
366
+ // entries sharing a `unitId`; entries recorded before unit ids are singletons (their own group).
367
+ // Walk newest to oldest, take whole groups while the budget allows, and stop at the first group
368
+ // that does not fit -- the newest group is ALWAYS kept whole, even when it alone exceeds `keep`
369
+ // (half a unit is an unrecoverable state: undo could neither restore nor refuse it honestly).
370
+ const budget = clampKeep(keep);
371
+ // Group by unit, not by ADJACENCY: a unit whose entries ended up apart in the index (a later write
372
+ // can mint a baseline whose hash repeats an older unit's) must still be ONE group, otherwise the
373
+ // `break` below preserves a whole GROUP but half a UNIT -- the half-cut R120 forbids.
374
+ const groups = [];
375
+ const byUnit = new Map();
376
+ for (const entry of grown) {
377
+ const unit = entry.unitId;
378
+ if (unit === undefined) {
379
+ groups.push([entry]);
380
+ continue;
381
+ }
382
+ const found = byUnit.get(unit);
383
+ if (found !== undefined) {
384
+ found.push(entry);
385
+ continue;
386
+ }
387
+ const made = [entry];
388
+ byUnit.set(unit, made);
389
+ groups.push(made);
390
+ }
391
+ // Decide by membership, then filter the ORIGINAL array: prepending groups would re-order the
392
+ // retained list (a merged, non-adjacent unit's older member would jump next to its newer one),
393
+ // and the index's order is append order — readers rely on it.
394
+ const keptIndexes = new Set();
395
+ let keptCount = 0;
396
+ for (let i = groups.length - 1; i >= 0; i -= 1) {
397
+ const group = groups[i];
398
+ if (group === undefined)
399
+ continue;
400
+ if (keptCount > 0 && keptCount + group.length > budget)
401
+ break;
402
+ for (const entry of group)
403
+ keptIndexes.add(grown.indexOf(entry));
404
+ keptCount += group.length;
405
+ }
406
+ const trimmed = grown.filter((_, index) => keptIndexes.has(index));
407
+ const recorded = {};
408
+ if (beforeHash !== null) {
409
+ const v = lastVersionOf(trimmed, beforeHash);
410
+ if (v !== undefined)
411
+ recorded.beforeVersion = v;
412
+ }
413
+ if (afterHash !== null) {
414
+ const v = lastVersionOf(trimmed, afterHash);
415
+ if (v !== undefined)
416
+ recorded.afterVersion = v;
417
+ }
418
+ return { versions: trimmed, recorded };
419
+ }
420
+ /** Shrink level at which the retention line speaks: a body that keeps less than this share of the
421
+ * previous one is a rewrite, not an edit. */
422
+ export const RETENTION_FEEDBACK_KEEP_RATIO = 0.5;
423
+ /** Floor on the REPLACED body: below this a ratio is noise (a 40-character skill cut in half is not
424
+ * news), so the line stays off for small skills. */
425
+ export const RETENTION_FEEDBACK_MIN_CHARS = 200;
426
+ /**
427
+ * The anti-overwrite feedback line (design §4, item 3): says how much of the previous body a
428
+ * replacement kept, so a destructive rewrite is visible in the same message that reports the write.
429
+ *
430
+ * Computed where the replaced bytes are still in hand — the write path's own in-lock read — instead of
431
+ * re-reading the file from a caller (that second read would race the writers this library serializes).
432
+ * It is FEEDBACK, never a gate: no ratio refuses a write, and the function owns no configuration
433
+ * (a toggle nobody reads would be configuration that does nothing).
434
+ *
435
+ * @param input - the write being described.
436
+ * @returns the line, or null when the write is not a significant whole-body replacement.
437
+ */
438
+ export function contentRetentionFeedback(input) {
439
+ // Only a whole-body replacement has a retention ratio: a patch touches an anchor, a support-file
440
+ // write replaces a different artifact, and a create/archive has no pair of bodies to compare.
441
+ if (input.action !== 'update')
442
+ return null;
443
+ const before = input.before;
444
+ const after = input.after;
445
+ if (before === null || after === null)
446
+ return null;
447
+ if (before.length < RETENTION_FEEDBACK_MIN_CHARS)
448
+ return null;
449
+ if (after.length >= before.length * RETENTION_FEEDBACK_KEEP_RATIO)
450
+ return null;
451
+ const kept = Math.round(after.length / before.length * 100);
452
+ return `新内容保留了上一版正文的 ${kept}%(${after.length}/${before.length} 字符);被替换的版本已保留在该技能的历史里。`;
453
+ }
454
+ /**
455
+ * Read one skill's versions, oldest first.
456
+ * @param root - the skills root.
457
+ * @param io - the io seam.
458
+ * @param name - the skill name.
459
+ * @returns the versions; an absent or unreadable index reads as empty (never a throw).
460
+ */
461
+ export async function loadVersions(root, io, name) {
462
+ const file = historyIndexFile(root, name);
463
+ // N36 (group-5 sweep): three-state. A failed read is not "this skill has no history" — the bytes may
464
+ // be there and unreadable, and serving that as empty is what made a broken store look empty. This read
465
+ // surface has no unknown state (its consumers are the panel and the diff), so the degradation is NAMED
466
+ // and the answer stays empty; the WRITE path (recordVersions below) refuses outright.
467
+ const probe = await probeText(io, file);
468
+ if (isUnknown(probe)) {
469
+ console.warn(`skill-store: ${file} could not be read (${probe.reason}); reporting no versions for "${name}" — the bytes are untouched`);
470
+ return [];
471
+ }
472
+ return parseHistoryIndex(isPresent(probe) ? probe.value : null);
473
+ }
474
+ /**
475
+ * One version's content.
476
+ * @param root - the skills root.
477
+ * @param io - the io seam.
478
+ * @param name - the skill name.
479
+ * @param v - the version number.
480
+ * @returns the content, or null when the version or its blob cannot be read (never '').
481
+ */
482
+ export async function loadVersionContent(root, io, name, v) {
483
+ const versions = await loadVersions(root, io, name);
484
+ const entry = versions.find(candidate => candidate.v === v);
485
+ if (entry === undefined)
486
+ return null;
487
+ // N36: a blob that cannot be READ is not a blob that is not there — the answer is `null` either way
488
+ // (this face has no unknown state), but the reason is named instead of swallowed.
489
+ const probe = await probeText(io, blobPath(root, entry.hash));
490
+ if (isUnknown(probe)) {
491
+ console.warn(`skill-store: version ${v} of "${name}" could not be read (${probe.reason}); reporting no content`);
492
+ return null;
493
+ }
494
+ return isPresent(probe) ? probe.value : null;
495
+ }
496
+ /**
497
+ * Record the content one mutation produced, best-effort.
498
+ *
499
+ * Blobs are written FIRST: the index must never reference content that is not on disk, and a failed
500
+ * blob write aborts the whole record (the caller warns; the mutation itself stands).
501
+ * @param root - the skills root.
502
+ * @param io - the io seam.
503
+ * @param input - the write being recorded.
504
+ * @param keep - how many versions to retain per skill.
505
+ * @returns the version numbers, or null when there was no content to record.
506
+ */
507
+ export async function recordVersions(root, io, input, keep) {
508
+ if (input.before === null && input.after === null)
509
+ return null;
510
+ for (const side of [input.before, input.after]) {
511
+ if (side === null)
512
+ continue;
513
+ const hash = contentHash(side);
514
+ const path = blobPath(root, hash);
515
+ // Immutable + content-addressed: a second write of the same bytes is the same file, so the
516
+ // existence probe is an optimisation, not a correctness requirement. N36: a probe that FAILS is not
517
+ // "not there" — both answers lead to the same write here, so the degradation is named rather than
518
+ // swallowed (a backend whose stat fails will fail the write too, which the caller reports).
519
+ let blobPresent;
520
+ try {
521
+ blobPresent = await io.exists(path);
522
+ }
523
+ catch (error) {
524
+ blobPresent = false;
525
+ console.warn(`skill-store: could not check whether the history blob ${path} exists (${error instanceof Error ? error.message : String(error)}); writing it — content-addressed bytes are the same file`);
526
+ }
527
+ if (!blobPresent)
528
+ await io.writeText(path, side);
529
+ }
530
+ const file = historyIndexFile(root, input.skillName);
531
+ // Bytes this reader cannot understand are PRESERVED before a fresh index replaces them (the same
532
+ // posture as the activity sidecar's quarantine): deriving an index from "nothing" over a corrupt or
533
+ // newer-reader file would drop every version it lists and orphan their blobs. If the copy itself
534
+ // fails, the record is abandoned — the mutation stands, the old bytes stay recoverable.
535
+ // PLAN S3.2/S3.3 (audit 1-2, P1): the probe is a THREE-state read. "Could not read" is not "no
536
+ // index": serving a failed read as absent skipped the rescue copy below and then let the
537
+ // in-lock read derive a fresh index from nothing over bytes this reader never saw. On a failed
538
+ // read the record is ABANDONED (the mutation stands, the bytes stay, a rescue copy is attempted
539
+ // out of band because copying needs no parsing).
540
+ const fileProbe = await probeText(io, file);
541
+ if (isUnknown(fileProbe)) {
542
+ const quarantine = `${file}.corrupt`;
543
+ try {
544
+ await io.copy(file, quarantine);
545
+ }
546
+ catch {
547
+ // Best-effort: the copy is a convenience, the abandoned record is the guarantee.
548
+ }
549
+ console.warn(`skill-store: ${file} could not be read (${fileProbe.reason}); its bytes were copied to ${quarantine} where possible and the version record for this write was abandoned — the mutation stands and the file is untouched`);
550
+ return null;
551
+ }
552
+ const probe = readHistoryIndex(isPresent(fileProbe) ? fileProbe.value : null);
553
+ if (probe.kind === 'unreadable') {
554
+ const quarantine = `${file}.corrupt`;
555
+ await io.copy(file, quarantine);
556
+ console.warn(`skill-store: ${file} could not be understood (malformed, foreign, or written by a newer reader); its bytes were copied to ${quarantine} and a fresh index starts from this write`);
557
+ }
558
+ const recorded = {};
559
+ let raceRefused = false;
560
+ await transactIo(io, file, (current) => {
561
+ const state = readHistoryIndex(current);
562
+ // The old comment here claimed the probe's copy covered a race. It only did when the probe had
563
+ // ALREADY seen the bytes as unreadable; a file that turned unreadable in between had no copy, and
564
+ // deriving the index from `[]` dropped every version it listed (and orphaned their blobs).
565
+ // Returning `current` unchanged is a byte-identical no-op, so the bytes survive and this record
566
+ // is abandoned below. Only an UNREADABLE state refuses: an ABSENT file is a skill with no
567
+ // history yet, and refusing there would abandon every first record (the index must be created).
568
+ if (state.kind === 'unreadable' && probe.kind !== 'unreadable') {
569
+ raceRefused = true;
570
+ return current;
571
+ }
572
+ const { versions, recorded: numbers } = nextHistoryIndex(state.kind === 'ok' ? state.versions : [], input, keep);
573
+ recorded.beforeVersion = numbers.beforeVersion;
574
+ recorded.afterVersion = numbers.afterVersion;
575
+ return JSON.stringify({ version: HISTORY_INDEX_VERSION, versions }, null, 2);
576
+ });
577
+ // v46 lint: `raceRefused` is set inside the transact task (the analyzer sees only the literal
578
+ // initializer), and the refusal is the S3.1 case this branch exists for.
579
+ // oxlint-disable-next-line typescript/no-unnecessary-condition -- set inside the transact task
580
+ if (raceRefused) {
581
+ const quarantine = `${file}.corrupt`;
582
+ try {
583
+ await io.copy(file, quarantine);
584
+ }
585
+ catch {
586
+ // Best-effort rescue copy; the refusal above is what protects the bytes.
587
+ }
588
+ console.warn(`skill-store: ${file} became unreadable between the probe and the locked read; its bytes were copied to ${quarantine} where possible and the version record for this write was abandoned — nothing was overwritten`);
589
+ return null;
590
+ }
591
+ return recorded;
592
+ }
593
+ /**
594
+ * Classify one version action for display. Fail-closed like {@link versionTarget}: an action this
595
+ * module does not know reads as `other` rather than being guessed, and `tests/skill-history.spec.ts`
596
+ * pins every action the library can write, so a new one cannot land unclassified.
597
+ * @param action - the entry’s action label.
598
+ * @returns the kind the faces switch on.
599
+ */
600
+ export function versionActionKind(action) {
601
+ switch (action) {
602
+ case BASELINE_ACTION: return 'baseline';
603
+ case 'create': return 'create';
604
+ case 'patch':
605
+ case 'edit': return 'patch';
606
+ case 'update': return 'update';
607
+ case 'restore': return 'restore';
608
+ // The body a delete or an archive REMOVED is what the entry holds, so it labels itself.
609
+ case 'delete': return 'delete';
610
+ case 'archive': return 'archive';
611
+ case 'consolidate': return 'consolidate';
612
+ case 'restructure': return 'restructure';
613
+ case 'write_file': return 'support-write';
614
+ case 'remove_file': return 'support-remove';
615
+ default: return 'other';
616
+ }
617
+ }
618
+ /** Lines one changed region may carry before it is windowed and flagged. */
619
+ export const TEXT_DIFF_MAX_LINES = 40;
620
+ /** Characters one windowed side may carry, so a single enormous line cannot travel whole. */
621
+ export const TEXT_DIFF_MAX_CHARS = 4_000;
622
+ /**
623
+ * The facts of one whole-body replacement: how many lines the changed region gained and lost, and
624
+ * that region itself as one hunk.
625
+ *
626
+ * The algorithm is the honest one for "what did this edit touch": common leading and trailing lines
627
+ * are trimmed away and what remains is ONE contiguous region. It is not a minimal edit script — a
628
+ * body edited in two distant places reports their span as one region — and that limitation is the
629
+ * reason the faces show the region as context rather than as a line-by-line proof.
630
+ * @param before - the body the write replaced.
631
+ * @param after - the body the write stored.
632
+ * @param path - the path the hunk names (the platform’s diff card requires one).
633
+ * @param maxLines - the window a region’s text is capped to.
634
+ * @returns the counts, the windowed hunk, and whether the window cut anything.
635
+ */
636
+ export function textDiffFacts(before, after, path, maxLines = TEXT_DIFF_MAX_LINES) {
637
+ // An EMPTY body is zero lines, not one blank line: otherwise every first version reports a removal
638
+ // that never happened (`before: ''` was being counted as one line).
639
+ const oldLines = before === '' ? [] : before.split('\n');
640
+ const newLines = after === '' ? [] : after.split('\n');
641
+ let head = 0;
642
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head])
643
+ head += 1;
644
+ let tail = 0;
645
+ while (tail < oldLines.length - head && tail < newLines.length - head
646
+ && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail])
647
+ tail += 1;
648
+ const oldRegion = oldLines.slice(head, oldLines.length - tail);
649
+ const newRegion = newLines.slice(head, newLines.length - tail);
650
+ if (oldRegion.length === 0 && newRegion.length === 0) {
651
+ return { linesAdded: 0, linesRemoved: 0, hunks: [], truncated: false };
652
+ }
653
+ // The window is bounded by BOTH lines and characters: a single 100 KB line is one line and would
654
+ // otherwise travel whole, which is exactly what the request-side body cap exists to prevent.
655
+ const window = (lines) => {
656
+ const byLines = lines.slice(0, maxLines);
657
+ const joined = byLines.join('\n');
658
+ if (joined.length <= TEXT_DIFF_MAX_CHARS)
659
+ return { text: joined, cut: lines.length > maxLines };
660
+ return { text: joined.slice(0, TEXT_DIFF_MAX_CHARS), cut: true };
661
+ };
662
+ const oldWindow = window(oldRegion);
663
+ const newWindow = window(newRegion);
664
+ return {
665
+ linesAdded: newRegion.length,
666
+ linesRemoved: oldRegion.length,
667
+ hunks: [{ path, oldText: oldWindow.text, newText: newWindow.text }],
668
+ truncated: oldWindow.cut || newWindow.cut,
669
+ };
670
+ }
671
+ /** Characters one generated summary may carry. */
672
+ export const VERSION_SUMMARY_MAX_CHARS = 80;
673
+ /**
674
+ * The fixed prompt the optional per-write summarizer runs (registry `skillVersionSummary`).
675
+ *
676
+ * Model-visible, so it is written from the model’s perspective and pinned: the tests assert that it
677
+ * carries the skill name, the character cap and both bodies, because a silent change to a prompt is a
678
+ * silent change to what every recorded version says.
679
+ * @param input - the skill, the action, and the two bodies (null when absent).
680
+ * @returns the prompt text.
681
+ */
682
+ export function versionSummaryPrompt(input) {
683
+ return [
684
+ 'You are writing ONE line that tells a reader what this single edit to the agent skill',
685
+ '"' + input.name + '" changed. The edit action was "' + input.action + '".',
686
+ 'Rules: at most ' + String(VERSION_SUMMARY_MAX_CHARS) + ' characters; the same language as the body;',
687
+ 'name the decision, number or section that moved (for example "keeps 30 days instead of 90 days");',
688
+ 'never say merely that something changed; no preamble, quotes, markdown or trailing period.',
689
+ '',
690
+ 'Body BEFORE the edit:',
691
+ input.before ?? '(the skill did not exist before this edit)',
692
+ '',
693
+ 'Body AFTER the edit:',
694
+ input.after ?? '(the skill does not exist after this edit)',
695
+ ].join('\n');
696
+ }
697
+ /**
698
+ * Reduce a model answer to the one line the index stores.
699
+ * @param raw - the model’s answer, whatever shape it came in.
700
+ * @param maxChars - the character cap.
701
+ * @returns the one line, or undefined when there is nothing usable (callers store no summary).
702
+ */
703
+ export function normalizeVersionSummary(raw, maxChars = VERSION_SUMMARY_MAX_CHARS) {
704
+ const firstLine = raw.split('\n').map(line => line.trim()).find(line => line !== '');
705
+ if (firstLine === undefined)
706
+ return undefined;
707
+ const stripped = firstLine.replace(/^["'`*•s]+/u, '').replace(/["'`*s]+$/u, '').replace(/[。.]$/u, '').trim();
708
+ if (stripped === '')
709
+ return undefined;
710
+ return stripped.length > maxChars ? stripped.slice(0, maxChars - 1) + '…' : stripped;
711
+ }
712
+ /**
713
+ * How long ago an ISO timestamp was, as a bucket plus a count — never as a sentence.
714
+ *
715
+ * The shape deliberately mirrors the platform’s `relativeTime` (`ui-primitives`), so a face that later
716
+ * may import the platform helper swaps one call and keeps its dictionary; the WORDS live in the
717
+ * faces (the panel’s locale dictionary, the command surface’s English), which is why this module
718
+ * returns numbers.
719
+ * @param at - an ISO timestamp, or any string `Date.parse` understands.
720
+ * @param nowMs - the reference instant.
721
+ * @returns the bucket and its count; an unparsable timestamp reads as `now` with 0.
722
+ */
723
+ export function elapsedSince(at, nowMs) {
724
+ const then = Date.parse(at);
725
+ if (Number.isNaN(then))
726
+ return { unit: 'now', n: 0 };
727
+ const minutes = Math.floor(Math.max(0, nowMs - then) / 60_000);
728
+ if (minutes < 1)
729
+ return { unit: 'now', n: 0 };
730
+ if (minutes < 60)
731
+ return { unit: 'minutes', n: minutes };
732
+ const hours = Math.floor(minutes / 60);
733
+ if (hours < 24)
734
+ return { unit: 'hours', n: hours };
735
+ const days = Math.floor(hours / 24);
736
+ if (days < 30)
737
+ return { unit: 'days', n: days };
738
+ const months = Math.floor(days / 30);
739
+ if (months < 12)
740
+ return { unit: 'months', n: months };
741
+ // A year is never "0 years ago": 360 days reads as months by the platform's own helper, and here as
742
+ // the first year, which is the honest bucket for "older than eleven months".
743
+ return { unit: 'years', n: Math.max(1, Math.floor(days / 365)) };
744
+ }
745
+ //# sourceMappingURL=skill-history.js.map