@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,479 @@
1
+ /**
2
+ * Self-evolution event log (rc.68): an append-only sidecar under
3
+ * `$DSH_HOME/evolution/events.json` that is the single source of truth for
4
+ * the self-improvement loop. Feedback increments and learn actions share one
5
+ * ordered timeline (`seq` is the ordering key), so "feedback before/after a
6
+ * learn on target X" is answerable. The aggregate `feedback.json` is a
7
+ * rebuildable boot cache, never the truth.
8
+ *
9
+ * Usage events (C semantics, rc.73+): `type:'usage'` records are the
10
+ * OBSERVATION WINDOW ANCHOR — written once, when the library's first observed
11
+ * read (`view_count` 0 -> 1) happens. The anchor is the durable timeline record
12
+ * of that moment: the churn-suppression gate itself (`usageObserved()`) reads
13
+ * the usage SIDECAR's own first-view evidence, since reads were invisible to it
14
+ * pre-A2 and no sidecar record can reach `view_count > 0` without the same
15
+ * 0 -> 1 transition. `counts` on the event is a cumulative library-wide
16
+ * snapshot (skills/views/use/patches) at that moment, and `window.opened` pins
17
+ * the window start for the timeline. (V27 G3.3: `verify-event-pairing` requires
18
+ * every persisted type to have a production reader or a declared external
19
+ * contract — this one is the latter.)
20
+ *
21
+ * Rotation (rc.71, 007 design): when the active log reaches
22
+ * `EVENT_LOG_ROTATE_AT` the older half is split into an archive
23
+ * (`events-<lastArchivedSeq>.json`); the boot timeline merges active +
24
+ * archives and dedupes by seq (active copy wins), so the rotation crash window
25
+ * yields the identical timeline. Archival naming is STRICTLY numeric
26
+ * (`/^events-\d+\.json$/`) — user files under the same directory are never
27
+ * read as archives and never pruned (rc.72 G-2).
28
+ */
29
+ import { transactIo } from "./io.js";
30
+ import { dirname, join } from 'node:path';
31
+ export const EVENT_LOG_VERSION = 1;
32
+ /** Active-log split point (rc.71): when the active log reaches this many events
33
+ * the older half is rotated into an archive; the active stays bounded so a
34
+ * single append stays O(active) instead of O(total-history). Tunable default —
35
+ * callers may override per append (the tests use small values). */
36
+ export const EVENT_LOG_ROTATE_AT = 4000;
37
+ /** Number of archives retained (rc.71): older archives are pruned at rotation,
38
+ * mirroring retainReports. The horizon covers the loop-analysis window. */
39
+ export const EVENT_LOG_RETAIN_ARCHIVES = 10;
40
+ /** Archive file prefix: `events-<lastArchivedSeq>.json`. The active file is
41
+ * `events.json` and never matches this glob. */
42
+ export const EVENT_ARCHIVE_PREFIX = 'events-';
43
+ /** Archive naming is strictly numeric: a user file such as `events-backup.json`
44
+ * under the same directory is neither read into the timeline nor pruned. */
45
+ const EVENT_ARCHIVE_RE = /^events-(\d+)\.json$/;
46
+ export function eventsFile(home) {
47
+ return join(home, 'evolution', 'events.json');
48
+ }
49
+ /** I-5 (v18): one lightweight description of the durable event payload
50
+ * contract. The log is a FILE boundary (a host, a script or an older version
51
+ * can write it), so `appendEvolutionEvent` refuses a record no consumer can
52
+ * fold instead of persisting it and failing silently later. The process event
53
+ * bus stays unvalidated — that is a typed same-process boundary.
54
+ * @param event - the candidate event record.
55
+ * @returns a human-readable issue, or null when the record is well-formed.
56
+ */
57
+ export function evolutionEventPayloadIssue(event) {
58
+ const type = event.type;
59
+ // `type` is `unknown` on purpose: at this FILE boundary the static union is
60
+ // not a guarantee, so a non-member (or non-string) is reported.
61
+ if (typeof type !== 'string')
62
+ return `unknown event type "${String(type)}"`;
63
+ switch (type) {
64
+ case 'feedback':
65
+ if (event.kind !== 'skill' && event.kind !== 'session')
66
+ return 'feedback event requires kind skill|session';
67
+ if (event.rating !== 'positive' && event.rating !== 'negative')
68
+ return 'feedback event requires rating positive|negative';
69
+ // P2-10 (v37): target is the fold key — the only folder skips a
70
+ // target-less feedback silently and `target: ''` folds a phantom key, so
71
+ // neither may pass this durable boundary.
72
+ if (typeof event.target !== 'string' || event.target.trim() === '')
73
+ return 'feedback event requires a non-empty target';
74
+ return null;
75
+ case 'maintain':
76
+ return typeof event.runId === 'string' ? null : 'maintain event requires runId';
77
+ case 'learn':
78
+ case 'usage':
79
+ return null;
80
+ default:
81
+ // Unreachable for the declared union; reachable when a JS host writes
82
+ // the file. The cast restores the runtime value the union erased.
83
+ return `unknown event type "${type}"`;
84
+ }
85
+ }
86
+ function isEventRecord(event) {
87
+ // C-05: NaN passed the bare typeof check — a NaN seq became a Map
88
+ // key that never matches and a sort comparator that never orders. Only a
89
+ // finite number is a seq.
90
+ const seq = event?.seq;
91
+ return typeof event === 'object' && event !== null && typeof seq === 'number' && Number.isFinite(seq);
92
+ }
93
+ /**
94
+ * Parse an event log body. A missing file, a whitespace-only file (rc.69:
95
+ * rebuildable, NOT malformed) or a corrupt one reads as empty; corrupt content
96
+ * is still refused on append, never overwritten.
97
+ *
98
+ * This reader is **v1-only** (F-338): a body carrying a `version` other than
99
+ * `EVENT_LOG_VERSION` is a future-format log this reader cannot interpret, so
100
+ * it reads as an EMPTY timeline rather than being mis-parsed as v1. The read
101
+ * side never overwrites it on its own — `appendEvolutionEvent` rejects a
102
+ * version mismatch up front and preserves the original bytes, so a newer log
103
+ * is never silently downgraded here.
104
+ *
105
+ * Per-entry normalization (rc.70 F-1): entries without a numeric `seq` are
106
+ * skipped here and dropped at the next append — valid entries survive, the
107
+ * damaged record is the only loss (self-heal semantics, matching the usage
108
+ * sidecar's per-field normalization on read).
109
+ */
110
+ /**
111
+ * Post-parse v1 gate shared by parseEvolutionEvents and appendEvolutionEvent
112
+ * (C-06, v10 audit: the append used to JSON.parse the same body a second time
113
+ * through parseEvolutionEvents — double parse cost per append). A null parse
114
+ * (missing/whitespace/unparsable body) reads as an empty timeline.
115
+ */
116
+ function v1EventRecords(parsed) {
117
+ if (parsed === null)
118
+ return [];
119
+ // v1-only reader: an explicit non-current version is a future format (or a
120
+ // corrupt version field) and must not be shaped as v1. A missing `version`
121
+ // is tolerated as legacy v1.
122
+ if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
123
+ return [];
124
+ if (!Array.isArray(parsed.events))
125
+ return [];
126
+ return parsed.events.filter(isEventRecord);
127
+ }
128
+ export function parseEvolutionEvents(raw) {
129
+ if (raw === null || raw.trim() === '')
130
+ return [];
131
+ try {
132
+ return v1EventRecords(JSON.parse(raw));
133
+ }
134
+ catch {
135
+ return [];
136
+ }
137
+ }
138
+ /**
139
+ * List the numeric archives under the log's directory, sorted ascending by
140
+ * their last-archived seq. Single glob predicate for the timeline, the
141
+ * retention pass and the feedback migration check (rc.72 H-3).
142
+ */
143
+ export async function listEventArchives(io, path) {
144
+ const dir = dirname(path);
145
+ const names = await io.list(dir);
146
+ return names
147
+ .filter(name => EVENT_ARCHIVE_RE.test(name))
148
+ .sort((a, b) => {
149
+ const sa = Number.parseInt(a.slice(EVENT_ARCHIVE_PREFIX.length, a.length - 5), 10);
150
+ const sb = Number.parseInt(b.slice(EVENT_ARCHIVE_PREFIX.length, b.length - 5), 10);
151
+ return sa - sb;
152
+ });
153
+ }
154
+ /**
155
+ * Append one event under the write lock (rc.68): `seq` = current max + 1
156
+ * computed inside the transact, so two processes appending concurrently never
157
+ * collide. A malformed log is refused (bytes preserved) and the append fails.
158
+ * Returns the assigned seq.
159
+ *
160
+ * Rotation (rc.71, 007 design): when the active log reaches `rotateAt`, the
161
+ * older half is copied into an archive inside the SAME transact (the archive
162
+ * path has its own lock, so no recursion) and the active is replaced with the
163
+ * newer half + the new event. seqs stay globally monotonic; a crash between
164
+ * archive write and active write leaves both copies, which the timeline merge
165
+ * dedupes by seq. An archive-write failure aborts the append (active keeps the
166
+ * full old content — no loss) and the caller's best-effort handling applies.
167
+ *
168
+ * rc.72 G-1: when the ACTIVE is missing/whitespace but archives exist (a
169
+ * deleted active, or B-2 self-heal), seq derivation consults the archive names
170
+ * — the active restarts AFTER the highest archived seq, never at 1, so a new
171
+ * event can never shadow an archived one in the seq-deduped timeline.
172
+ */
173
+ export async function appendEvolutionEvent(io, path, event, rotateAt = EVENT_LOG_ROTATE_AT) {
174
+ // I-5 (v18): refuse an unfoldable record at the durable write boundary.
175
+ const issue = evolutionEventPayloadIssue(event);
176
+ if (issue !== null)
177
+ throw new Error(`evolution event refused: ${issue}`);
178
+ let assigned = 0;
179
+ // Empty when the append was refused as malformed; set when it was refused
180
+ // because the log carries a future `version` (F-338). Kept as a prebuilt
181
+ // message so a closure-side assignment is never narrowed to `never` by the
182
+ // outer control flow.
183
+ let refuseMessage = '';
184
+ // C-06 (v10 audit): the shape parse below is REUSED for the event list via
185
+ // v1EventRecords — the body used to be JSON.parsed twice per append.
186
+ let parsedBody = null;
187
+ await transactIo(io, path, async (current) => {
188
+ // A10 (audit P2-14): the lock layer is contractually allowed to re-invoke
189
+ // this task whole (a LostWriteLock converts into a full read-modify-write
190
+ // retry). Closure state captured during a PREVIOUS attempt must reset
191
+ // first, or a log that vanished/was emptied between attempts would skip
192
+ // the re-parse below and fold rotation from the stale previous parse.
193
+ // `assigned` belongs to the same discipline (R2 follow-up): without the
194
+ // reset, attempt 1's seq survives into a refusing attempt 2 and the
195
+ // caller reads a phantom success for an event that never landed.
196
+ parsedBody = null;
197
+ refuseMessage = '';
198
+ assigned = 0;
199
+ // rc.69: a whitespace-only log (crash residue) is rebuildable — treat it
200
+ // as missing; a genuinely corrupt body is still refused.
201
+ if (current !== null && current.trim() !== '') {
202
+ let shape;
203
+ try {
204
+ shape = JSON.parse(current);
205
+ }
206
+ catch {
207
+ return current;
208
+ }
209
+ parsedBody = shape;
210
+ // F-338: a non-v1 body is a FUTURE format. This v1 writer must never
211
+ // rewrite it back down to v1 — refuse and keep the original bytes. (The
212
+ // reader treats it as an empty timeline; only the append refuses.)
213
+ if (shape.version !== undefined && shape.version !== EVENT_LOG_VERSION) {
214
+ const found = typeof shape.version === 'number' || typeof shape.version === 'string' ? String(shape.version) : 'unknown';
215
+ const kind = typeof shape.version === 'number' || typeof shape.version === 'string' ? typeof shape.version : 'unknown';
216
+ // V4-48: a version written as the STRING "1" is statically unequal to
217
+ // the number 1 and is refused, but the old message rendered both as
218
+ // "found 1, expected 1" — type-ambiguous and self-contradictory. Report
219
+ // the type so a manual-editer/heterogeneous writer sees the real cause.
220
+ const rendered = kind === 'string' ? `"${found}"` : found;
221
+ refuseMessage = `evolution event log version mismatch (expected version ${EVENT_LOG_VERSION}, got ${rendered} (${kind})) and was not touched`;
222
+ return current;
223
+ }
224
+ }
225
+ const rotated = await rotateIfDue(io, path, v1EventRecords(parsedBody), rotateAt);
226
+ if (!rotated.ok) {
227
+ // P2-11: an unmergeable archive collision writes NOTHING — the active
228
+ // keeps the full band and the append reports the refusal.
229
+ refuseMessage = rotated.reason;
230
+ return current;
231
+ }
232
+ const events = rotated.events;
233
+ // P2-1 (v11): a single rotate pass — the second call was either a no-op
234
+ // (production threshold) or a double rotation (small test thresholds).
235
+ // v23 (ML-3): seq continues from the max of BOTH the active file and the
236
+ // archive names. The old `if (maxSeq === 0)` gate only consulted the
237
+ // archives when the active was empty — an active ROLLED BACK to an older
238
+ // backup (manual restore) kept a maxSeq below the archived range, so new
239
+ // events reused archived seqs and the timeline merge's "active wins" rule
240
+ // silently shadowed the archived records.
241
+ let maxSeq = events.reduce((max, entry) => Math.max(max, entry.seq), 0);
242
+ for (const name of await listEventArchives(io, path)) {
243
+ maxSeq = Math.max(maxSeq, Number.parseInt(name.slice(EVENT_ARCHIVE_PREFIX.length, name.length - 5), 10));
244
+ }
245
+ const record = { ...event, seq: maxSeq + 1, at: new Date().toISOString() };
246
+ assigned = record.seq;
247
+ return JSON.stringify({ version: EVENT_LOG_VERSION, events: [...events, record] }, null, 2);
248
+ });
249
+ if (assigned === 0) {
250
+ throw new Error(`${refuseMessage || 'evolution event log is malformed and was not touched'}: ${path}`);
251
+ }
252
+ return assigned;
253
+ }
254
+ /** The collision archive is mergeable only when this v1 writer can read it
255
+ * (P2-11): a foreign `version` (F-338) or a body without an `events` array is
256
+ * refused, so a merge can never downgrade it or empty it out.
257
+ * @param parsed - the parsed collision archive body.
258
+ * @returns the usable events, or null when the archive must not be rewritten. */
259
+ function mergeableCollisionEvents(parsed) {
260
+ if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
261
+ return null;
262
+ if (!Array.isArray(parsed.events))
263
+ return null;
264
+ return parsed.events.filter(isEventRecord);
265
+ }
266
+ /**
267
+ * Split the active log at its midpoint when due: the older half is written to
268
+ * `events-<lastArchivedSeq>.json` (await — a failed archive write aborts the
269
+ * append so the active is never truncated without its copy), old archives are
270
+ * pruned, and the newer half is returned as the next active body. No-op when
271
+ * under the threshold; `rotateAt < 2` is a guarded no-op (rc.72 G-1: a
272
+ * one-event rotate would archive everything and restart seqs at 1).
273
+ */
274
+ async function rotateIfDue(io, path, events, rotateAt) {
275
+ // A2-10 (v18): a NaN rotateAt made both comparisons false, so the log
276
+ // rotated on EVERY append. Treat non-finite as "no rotation".
277
+ if (!Number.isFinite(rotateAt) || rotateAt < 2 || events.length < rotateAt)
278
+ return { ok: true, events };
279
+ const mid = Math.ceil(events.length / 2);
280
+ const head = events.slice(0, mid);
281
+ const tail = events.slice(mid);
282
+ if (tail.length === 0)
283
+ return { ok: true, events };
284
+ const anchor = tail[0]?.seq ?? 0;
285
+ const archivePath = join(dirname(path), `${EVENT_ARCHIVE_PREFIX}${anchor - 1}.json`);
286
+ // v31 EVENTS-01: after a manual active-file rollback the recomputed archive
287
+ // name can COLLIDE with an existing archive holding a DIFFERENT seq band —
288
+ // the old in-place write destroyed that band silently (feedback/learn/usage
289
+ // history gone while every reader reported a coherent timeline). Merge the
290
+ // two generations instead: both bands survive, overlapping seqs dedupe.
291
+ let archived = head;
292
+ // C-events-dispatch-2 (v43 audit): only a readText of `null` means "that slot
293
+ // is free" — the node backend maps just ENOENT/ENOTDIR to null and lets every
294
+ // other failure (EACCES/EIO/EBUSY) propagate (io.ts readText contract). The
295
+ // old `.catch(() => null)` folded such a failure into "nothing to collide
296
+ // with", so the archive write below destroyed a band that WAS on disk. An
297
+ // unreadable collision archive now refuses the rotation exactly like an
298
+ // unmergeable one (P2-11): the active keeps its full band, the archive keeps
299
+ // its bytes, and no band leaves the logical timeline.
300
+ let existing;
301
+ try {
302
+ existing = await io.readText(archivePath);
303
+ }
304
+ catch (error) {
305
+ const cause = error instanceof Error ? error.message : String(error);
306
+ return { ok: false, reason: `evolution event archive collision at ${archivePath} could not be read (${cause}) and was not touched` };
307
+ }
308
+ if (existing !== null) {
309
+ let parsed = null;
310
+ try {
311
+ parsed = JSON.parse(existing);
312
+ }
313
+ catch {
314
+ parsed = null;
315
+ }
316
+ if (parsed === null) {
317
+ // An unparsable collision file must not be destroyed: shift this
318
+ // rotation's head aside under a distinct name instead. v31 EVENTS-02:
319
+ // the shift-aside is OBSERVABLE (the band leaves the logical timeline —
320
+ // the collide name matches no archive reader) and PRUNABLE (the old
321
+ // silent form accumulated without bound).
322
+ const shiftPath = `${archivePath}.${Date.now()}.collide`;
323
+ await io.writeText(shiftPath, JSON.stringify({ version: EVENT_LOG_VERSION, events: head }, null, 2));
324
+ console.warn(`evolution-events: rotation hit an unparsable archive collision — the rotated head band was preserved at ${shiftPath} but is OUTSIDE the logical timeline; inspect and merge it manually`);
325
+ await retainEventArchives(io, path);
326
+ await pruneCollideArchives(io, path);
327
+ return { ok: true, events: tail };
328
+ }
329
+ const prior = mergeableCollisionEvents(parsed);
330
+ if (prior === null) {
331
+ // P2-11 (v37): merging REWRITES the collision archive, so an archive this
332
+ // v1 writer cannot read must never be merged — a future version would be
333
+ // downgraded (F-338) and a body without `events` was emptied and
334
+ // destroyed. Refuse the rotation: the active keeps its full band, the
335
+ // archive keeps its bytes, and no band leaves the logical timeline.
336
+ return { ok: false, reason: `evolution event archive collision at ${archivePath} is not a readable version-${EVENT_LOG_VERSION} archive (future version, or no "events" array) and was not touched` };
337
+ }
338
+ // v33 F-3: the merge validates like every reader does - a collision
339
+ // archive with damaged entries must not re-persist junk into the
340
+ // canonical band (isEventRecord is the same filter the timeline uses).
341
+ const bySeq = new Map(prior.map(event => [event.seq, event]));
342
+ for (const event of head)
343
+ bySeq.set(event.seq, event);
344
+ archived = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
345
+ }
346
+ await io.writeText(archivePath, JSON.stringify({ version: EVENT_LOG_VERSION, events: archived }, null, 2));
347
+ await retainEventArchives(io, path);
348
+ return { ok: true, events: tail };
349
+ }
350
+ /**
351
+ * Prune old event archives (rc.71): keep the newest `EVENT_LOG_RETAIN_ARCHIVES`.
352
+ * The name's numeric part is the last archived seq, so ordering is NUMERIC —
353
+ * lexicographic would rank `events-10` before `events-2`. Only strictly
354
+ * numeric names participate (rc.72 G-2: user files are never deleted).
355
+ * Best-effort per removal; exported for the retention test.
356
+ */
357
+ export async function retainEventArchives(io, path) {
358
+ const dir = dirname(path);
359
+ const names = await listEventArchives(io, path);
360
+ const excess = names.slice(0, Math.max(0, names.length - EVENT_LOG_RETAIN_ARCHIVES));
361
+ for (const name of excess) {
362
+ await io.remove(join(dir, name)).catch(() => { });
363
+ }
364
+ }
365
+ /** v31 EVENTS-02: prune shift-aside collision files (`events-*.json.<ts>.collide`)
366
+ * after the same 7-day window the `.corrupt` sweep uses. They are write-once
367
+ * recovery artifacts no reader accepts; without a sweep they accumulated
368
+ * without bound across rollback episodes. No mtime probe → keep (fail-safe). */
369
+ const COLLIDE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
370
+ async function pruneCollideArchives(io, path) {
371
+ const dir = dirname(path);
372
+ let names;
373
+ try {
374
+ names = await io.list(dir);
375
+ }
376
+ catch {
377
+ return;
378
+ }
379
+ const now = Date.now();
380
+ for (const name of names) {
381
+ if (!name.endsWith('.collide') || !name.startsWith('events-'))
382
+ continue;
383
+ const full = join(dir, name);
384
+ const stamp = name.match(/\.(\d{13})\.collide$/);
385
+ if (stamp && now - Number(stamp[1]) < COLLIDE_AGE_MS)
386
+ continue;
387
+ if (!stamp) {
388
+ // v33 F-2: the no-stamp branch is FAIL-SAFE BY CONTRACT - an unknown
389
+ // collision artifact (no readable mtime, absent mtime probe) is KEPT,
390
+ // never destroyed. A rejecting mtime is contained here so housekeeping
391
+ // cannot abort the event append.
392
+ try {
393
+ const mtime = await io.mtime?.(full);
394
+ // C-events-dispatch-3 (v43 audit): only a NUMBER that is old enough may
395
+ // be deleted. `undefined` (a backend that omits the probe), `null` (a
396
+ // probe that cannot tell, io.ts) and a rejecting probe all mean "age
397
+ // unknown" and KEEP the artifact, exactly as the contract above states —
398
+ // the old branch fell through to `remove` on all three.
399
+ if (typeof mtime !== 'number' || now - mtime < COLLIDE_AGE_MS)
400
+ continue;
401
+ }
402
+ catch {
403
+ continue;
404
+ }
405
+ }
406
+ await io.remove(full).catch(() => { });
407
+ }
408
+ }
409
+ /** Read the event log; a missing/whitespace-only file reads as empty, corrupt
410
+ * content is flagged (and refused on append). A well-formed future-version body
411
+ * is v1-incompatible: it reads as EMPTY and is now flagged malformed as well
412
+ * (C-events-dispatch-1, v43). F-338's own guarantees are untouched — the reader
413
+ * never mis-shapes a newer format and the append path refuses it up front, so
414
+ * the original bytes survive — while the flag reports what the old reader hid:
415
+ * every record that body holds is dropped from this read. */
416
+ export async function readEvolutionEvents(io, path) {
417
+ let raw;
418
+ try {
419
+ raw = await io.readText(path);
420
+ }
421
+ catch {
422
+ return { events: [], malformed: true };
423
+ }
424
+ if (raw === null || raw.trim() === '')
425
+ return { events: [], malformed: false };
426
+ try {
427
+ const parsed = JSON.parse(raw);
428
+ // v1-only reader (F-338): a non-current `version` is a future format that
429
+ // must not be shaped as v1. It still reads as empty (the append path
430
+ // rejects it before writing, so nothing is overwritten) but it is FLAGGED:
431
+ // the band that body holds leaves the timeline HERE, and a consumer that
432
+ // cannot see the drop folds a truncated history as if it were the truth
433
+ // (C-events-dispatch-1, v43 audit).
434
+ if (parsed.version !== undefined && parsed.version !== EVENT_LOG_VERSION)
435
+ return { events: [], malformed: true };
436
+ // Shape damage is replaceable garbage (read as empty, rebuilt on append);
437
+ // only syntax-level damage is "malformed" (never overwritten).
438
+ if (!Array.isArray(parsed.events))
439
+ return { events: [], malformed: false };
440
+ return {
441
+ events: parsed.events.filter(isEventRecord),
442
+ malformed: false,
443
+ };
444
+ }
445
+ catch {
446
+ return { events: [], malformed: true };
447
+ }
448
+ }
449
+ /**
450
+ * Read the full timeline (rc.71): active log + all archives, merged by seq
451
+ * (active copy wins, duplicates only arise from the rotation crash window),
452
+ * sorted ascending. Per-file malformed flag as in `readEvolutionEvents`; a
453
+ * flagged ARCHIVE (unreadable, damaged, or a future-version body this reader
454
+ * cannot interpret) is SKIPPED — it never bricks the boot, the returned events
455
+ * simply LACK that seq band, and `malformed` is the only signal that they do
456
+ * (C-events-dispatch-1, v43: the flag is the consumer's contract; a truncated
457
+ * timeline must never be folded back as if it were complete).
458
+ */
459
+ export async function readEvolutionTimeline(io, path, archives) {
460
+ const dir = dirname(path);
461
+ let malformed = false;
462
+ const bySeq = new Map();
463
+ // C2 (v35): a caller that already listed the archives (the boot restore does)
464
+ // passes them in instead of paying a second directory scan.
465
+ for (const name of archives ?? await listEventArchives(io, path)) {
466
+ const read = await readEvolutionEvents(io, join(dir, name));
467
+ if (read.malformed)
468
+ malformed = true;
469
+ for (const event of read.events)
470
+ bySeq.set(event.seq, event);
471
+ }
472
+ const active = await readEvolutionEvents(io, path);
473
+ if (active.malformed)
474
+ malformed = true;
475
+ for (const event of active.events)
476
+ bySeq.set(event.seq, event);
477
+ return { events: [...bySeq.values()].sort((a, b) => a.seq - b.seq), malformed };
478
+ }
479
+ //# sourceMappingURL=evolution-events.js.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Untrusted text rendered as prompt lines (T3-03/A31) — ONE folding rule.
3
+ *
4
+ * The background-review digest embeds conversation text and tool output into a
5
+ * plain-text prompt whose PRODUCT is a plan with write permission. Anything the
6
+ * session ingested (a fetched page, a file body, command output) can therefore
7
+ * carry a newline plus a line shaped like the prompt's own structure — a
8
+ * `SYSTEM:`/`Review kind:` header, a `[result]` evidence line, a second copy
9
+ * of the closing instruction — and forge structure or inject instructions.
10
+ *
11
+ * FOLDING, not deleting: the reader (the model) must still SEE what the data
12
+ * said, it just must never become structure. Line separators become a visible
13
+ * `⏎`, and a line that would read as a role/evidence header gets a visible
14
+ * marker in front of it. Every original character stays in place.
15
+ *
16
+ * The boundary is the separator itself, not only a real newline: a payload can
17
+ * paste the separator verbatim, so neutralization matches after `⏎` whether it
18
+ * came from the fold or from the data. Both edits are idempotent (a second pass
19
+ * cannot add a second marker).
20
+ *
21
+ * Scope: text rendered as its OWN line(s) of a prompt. `render-facts`'
22
+ * sanitizer (evolution-maintenance) is the same posture for `key=value` fields
23
+ * and additionally escapes its block delimiter; it is not superseded here.
24
+ * @module @lmzhen/dsh-evolution-core
25
+ */
26
+ /** Visible stand-in for a folded line separator: one character that cannot be read as line structure. */
27
+ export const UNTRUSTED_LINE_BREAK = ' ⏎ ';
28
+ /** Visible marker prefixed to a line that would otherwise read as a role/structure header. */
29
+ export const UNTRUSTED_LINE_MARK = '· ';
30
+ const NEWLINES = /\r\n|\r|\n/g;
31
+ /** A line that reads as structure: a `Label:` head (SYSTEM:, Review kind:, Signals:) or a digest evidence tag.
32
+ * The head is case-TOLERANT on purpose: this family's own prompt labels are mixed case, so an
33
+ * uppercase-only rule neutralized SYSTEM: while leaving a forged "Review kind:" untouched. */
34
+ const STRUCTURAL_HEAD = /(^|⏎)([ \t]*)((?:[A-Za-z][A-Za-z0-9 _]{2,}:|\[result\]|\[call\]))/g;
35
+ /**
36
+ * Fold untrusted `text` so it cannot become structure, then bound it to `limit`
37
+ * visible characters. A non-positive `limit` means "no truncation".
38
+ */
39
+ export function foldUntrustedLines(text, limit) {
40
+ const folded = text.replace(NEWLINES, UNTRUSTED_LINE_BREAK);
41
+ const marked = folded.replace(STRUCTURAL_HEAD, (_match, boundary, spaces, head) => `${boundary}${spaces}${UNTRUSTED_LINE_MARK}${head}`);
42
+ return limit > 0 ? marked.slice(0, limit) : marked;
43
+ }
44
+ //# sourceMappingURL=fold-untrusted.js.map