@lmzhen/dsh-evolution-core 0.32.2 → 0.32.4

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 (36) hide show
  1. package/README.md +5 -5
  2. package/lib/composition/index.js +2 -2
  3. package/lib/{composition-DSesmTvf.js → composition-3QFfEe56.js} +1 -1
  4. package/lib/{ensure-BuWsjPGD.js → ensure-C2tUoF5U.js} +1 -1
  5. package/lib/{errors-Br2-uUSK.js → errors-DRZ9A187.js} +1 -0
  6. package/lib/index.js +475 -113
  7. package/lib/restrictor.js +1 -1
  8. package/lib/subagent/index.js +1 -1
  9. package/lib/types/errors.d.ts +1 -0
  10. package/lib/types/errors.js +1 -0
  11. package/lib/types/index.d.ts +1 -0
  12. package/lib/types/index.js +1 -0
  13. package/lib/types/mutation-storage-adapter.d.ts +5 -2
  14. package/lib/types/mutation-storage-adapter.js +59 -22
  15. package/lib/types/mutation-unit.d.ts +2 -2
  16. package/lib/types/mutation-unit.js +18 -45
  17. package/lib/types/params.js +1 -1
  18. package/lib/types/platform-services.js +0 -1
  19. package/lib/types/session-projection.d.ts +32 -0
  20. package/lib/types/session-projection.js +51 -3
  21. package/lib/types/skill-reads.d.ts +19 -0
  22. package/lib/types/skill-reads.js +42 -2
  23. package/lib/types/skill-store.js +4 -2
  24. package/lib/types/tool-dispatch.d.ts +16 -0
  25. package/lib/types/tool-dispatch.js +42 -11
  26. package/lib/types/unit-journal.d.ts +5 -3
  27. package/lib/types/unit-journal.js +7 -6
  28. package/lib/types/unit-storage.d.ts +5 -4
  29. package/lib/types/unit-storage.js +8 -8
  30. package/lib/types/unit-transaction.js +5 -7
  31. package/lib/types/write-gates.d.ts +4 -0
  32. package/lib/types/write-gates.js +16 -1
  33. package/lib/types/write-kernel.d.ts +81 -0
  34. package/lib/types/write-kernel.js +155 -0
  35. package/package.json +2 -2
  36. package/persisted-write-inventory.json +1 -10
package/lib/restrictor.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as errorText } from "./errors-Br2-uUSK.js";
1
+ import { n as errorText } from "./errors-DRZ9A187.js";
2
2
  //#region lib/types/restrictor.js
3
3
  const name = "evolution-restrictor";
4
4
  const inject = ["agentPresets"];
@@ -1,4 +1,4 @@
1
- import { h as restrictedPresetId, n as ensureRestrictedPreset, r as restrictedPresetState } from "../ensure-BuWsjPGD.js";
1
+ import { h as restrictedPresetId, n as ensureRestrictedPreset, r as restrictedPresetState } from "../ensure-C2tUoF5U.js";
2
2
  //#region lib/types/subagent/readiness.js
3
3
  function serviceOf$1(ctx, name) {
4
4
  return ctx.get(name);
@@ -35,6 +35,7 @@ export declare const EVOLUTION_ERRORS: Readonly<{
35
35
  readonly 'e-310-the-settings-service-refused': "E-310: 设置服务拒绝了这次写入:%a1% —— 归属插件的规则优先(跨字段成对约束,或只能收紧的上限)。/evolution params 会显示当前值。";
36
36
  readonly 'e-317-skill-write-not-confirmed': "E-317: 技能 \"%a1%\" 未被%a2% —— 确认提示被拒绝或关闭,因此没有写入任何内容。每次创建与裸删除都会出现该提示;只有操作者要求时才重复调用。";
37
37
  readonly 'e-318-skill-write-without-a-read': "E-318: 本会话没有读取技能 \"%a1%\",因此拒绝这次写入。请先用 `skill` 工具读取它(失败的读取不算),然后重复该写入。";
38
+ readonly 'e-326-support-write-without-a-read': "E-326: 本会话没有读取技能 \"%a1%\" 的支撑文件 \"%a2%\",因此拒绝这次写入。只读技能正文不算读过支撑文件;请先读取该文件(失败的读取不算),然后重复该写入。";
38
39
  readonly 'e-320-the-profile-home-is-not': "E-320: 本宿主没有报告 profile home,因此无法定位旧设置文档。/evolution migrate 读取 `<profile home>/settings.yaml.imported`;请在由 profile 启动的会话里运行。";
39
40
  readonly 'e-322-a-parameter-would-break-a': "E-322: %a1% 至少应为 %a2%,但这次写入把它设成 %a3%。未写入任何内容。";
40
41
  readonly 'e-321-no-io-provider-is': "E-321: 没有挂载 evolution-io 提供方,因此无法读取旧设置文档。请挂载 evolution-io 行(evolution-host/evolution-all)后重试。";
@@ -36,6 +36,7 @@ export const EVOLUTION_ERRORS = Object.freeze({
36
36
  // 0.9.0: the write-admission sequence's own refusals (tool-skill-manage/src/write-gates.ts).
37
37
  'e-317-skill-write-not-confirmed': 'E-317: 技能 "%a1%" 未被%a2% —— 确认提示被拒绝或关闭,因此没有写入任何内容。每次创建与裸删除都会出现该提示;只有操作者要求时才重复调用。',
38
38
  'e-318-skill-write-without-a-read': 'E-318: 本会话没有读取技能 "%a1%",因此拒绝这次写入。请先用 `skill` 工具读取它(失败的读取不算),然后重复该写入。',
39
+ 'e-326-support-write-without-a-read': 'E-326: 本会话没有读取技能 "%a1%" 的支撑文件 "%a2%",因此拒绝这次写入。只读技能正文不算读过支撑文件;请先读取该文件(失败的读取不算),然后重复该写入。',
39
40
  // 0.12.0: the confirmation's own deadline (`skillWriteConfirm: 'timeout'`).
40
41
  'e-320-the-profile-home-is-not': 'E-320: 本宿主没有报告 profile home,因此无法定位旧设置文档。/evolution migrate 读取 `<profile home>/settings.yaml.imported`;请在由 profile 启动的会话里运行。',
41
42
  // G5.12/U-4: the cross-field constraint the registry declares (params.ts) has one enforcement
@@ -100,6 +100,7 @@ export * from './skill-revision.ts';
100
100
  export * from './store-ledger.ts';
101
101
  export * from './reachability.ts';
102
102
  export * from './state-store.ts';
103
+ export * from './write-kernel.ts';
103
104
  export * from './threats.ts';
104
105
  export * from './tool-dispatch.ts';
105
106
  export * from './usage.ts';
@@ -101,6 +101,7 @@ export * from "./skill-revision.js";
101
101
  export * from "./store-ledger.js";
102
102
  export * from "./reachability.js";
103
103
  export * from "./state-store.js";
104
+ export * from "./write-kernel.js";
104
105
  export * from "./threats.js";
105
106
  export * from "./tool-dispatch.js";
106
107
  export * from "./usage.js";
@@ -6,9 +6,12 @@ import type { MutationJournalPort, MutationSnapshotPort } from './mutation-unit.
6
6
  * snapshots do not carry that kind and are ignored by the reader.
7
7
  */
8
8
  export declare function unitSnapshotPort(io: EvolutionIoLike, home: string): MutationSnapshotPort;
9
+ /** Keep a bounded number of terminal tree-change snapshots; prepared units are never pruned. */
10
+ export declare function pruneTreeChangeUnits(io: EvolutionIoLike, home: string, keep?: number): Promise<number>;
9
11
  /**
10
- * Production adapter onto the existing unit journal. It never writes a second
11
- * journal file; merge and tree-change units share the one journal directory.
12
+ * Production adapter onto the skill-owned journal directory. Merge recovery
13
+ * reads merge/unit-journals only; legacy tree-change journals carry kind and
14
+ * are skipped by the recovery coordinator.
12
15
  */
13
16
  export declare function unitJournalPort(io: EvolutionIoLike, home: string): MutationJournalPort;
14
17
  //# sourceMappingURL=mutation-storage-adapter.d.ts.map
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { contentHash } from "./mutations.js";
3
3
  import { readUnitSnapshot, unitSnapshotsDir, unitStorageKey, writeUnitSnapshot } from "./unit-storage.js";
4
- import { writeUnitJournal } from "./unit-journal.js";
4
+ import { unitJournalPath, writeUnitJournal } from "./unit-journal.js";
5
5
  /**
6
6
  * Production adapter onto the existing unit snapshot store. A tree-change
7
7
  * snapshot is encoded as a UnitSnapshot with kind='tree-change'; merge
@@ -36,10 +36,10 @@ export function unitSnapshotPort(io, home) {
36
36
  ...(op.skill === undefined ? {} : { skill: op.skill }),
37
37
  })),
38
38
  };
39
- await writeUnitSnapshot(io, home, body);
39
+ await writeUnitSnapshot(io, home, body, 'skill');
40
40
  },
41
41
  async read(unitId) {
42
- const snapshot = await readUnitSnapshot(io, home, unitId);
42
+ const snapshot = await readUnitSnapshot(io, home, unitId, 'skill') ?? await readUnitSnapshot(io, home, unitId);
43
43
  if (snapshot === null || snapshot.kind !== 'tree-change' || snapshot.ops === undefined)
44
44
  return null;
45
45
  return {
@@ -51,29 +51,64 @@ export function unitSnapshotPort(io, home) {
51
51
  };
52
52
  },
53
53
  async listPrepared() {
54
- const names = await io.list(unitSnapshotsDir(home));
55
54
  const out = [];
56
- for (const name of names) {
57
- if (!name.endsWith('.json'))
58
- continue;
59
- const raw = await io.readText(join(unitSnapshotsDir(home), name));
60
- if (raw === null)
61
- continue;
62
- let snapshot;
63
- try {
64
- snapshot = JSON.parse(raw);
55
+ const seen = new Set();
56
+ for (const dir of [unitSnapshotsDir(home, 'skill'), unitSnapshotsDir(home)]) {
57
+ const names = await io.list(dir);
58
+ for (const name of names) {
59
+ if (!name.endsWith('.json'))
60
+ continue;
61
+ const raw = await io.readText(join(dir, name));
62
+ if (raw === null)
63
+ continue;
64
+ let snapshot;
65
+ try {
66
+ snapshot = JSON.parse(raw);
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (snapshot.kind !== 'tree-change' || snapshot.mutationState !== 'prepared' || snapshot.ops === undefined)
72
+ continue;
73
+ if (seen.has(snapshot.unitId))
74
+ continue;
75
+ seen.add(snapshot.unitId);
76
+ out.push({ unitId: snapshot.unitId, state: 'prepared', ops: snapshot.ops, ...(snapshot.cleanup === undefined ? {} : { cleanup: snapshot.cleanup }), createdAt: snapshot.createdAt });
65
77
  }
66
- catch {
67
- continue;
68
- }
69
- if (snapshot.kind !== 'tree-change' || snapshot.mutationState !== 'prepared' || snapshot.ops === undefined)
70
- continue;
71
- out.push({ unitId: snapshot.unitId, state: 'prepared', ops: snapshot.ops, ...(snapshot.cleanup === undefined ? {} : { cleanup: snapshot.cleanup }), createdAt: snapshot.createdAt });
72
78
  }
73
79
  return out;
74
80
  },
75
81
  };
76
82
  }
83
+ /** Keep a bounded number of terminal tree-change snapshots; prepared units are never pruned. */
84
+ export async function pruneTreeChangeUnits(io, home, keep = 500) {
85
+ if (!Number.isInteger(keep) || keep < 1)
86
+ return 0;
87
+ const dir = unitSnapshotsDir(home, 'skill');
88
+ const names = await io.list(dir);
89
+ const terminal = [];
90
+ for (const name of names) {
91
+ if (!name.endsWith('.json'))
92
+ continue;
93
+ try {
94
+ const snapshot = JSON.parse(await io.readText(join(dir, name)) ?? '');
95
+ if (snapshot.kind !== 'tree-change' || snapshot.mutationState === 'prepared')
96
+ continue;
97
+ terminal.push({ name, unitId: snapshot.unitId, createdAt: snapshot.createdAt });
98
+ }
99
+ catch {
100
+ // unreadable files are left for the explicit recovery/audit path
101
+ }
102
+ }
103
+ terminal.sort((a, b) => b.createdAt - a.createdAt);
104
+ let removed = 0;
105
+ for (const entry of terminal.slice(keep)) {
106
+ await io.remove(join(dir, entry.name));
107
+ await io.remove(unitJournalPath(home, entry.unitId, 'skill')).catch(() => { });
108
+ removed += 1;
109
+ }
110
+ return removed;
111
+ }
77
112
  const PHASE = {
78
113
  prepared: 'prepared',
79
114
  'unit-written': 'unit-written',
@@ -82,8 +117,9 @@ const PHASE = {
82
117
  undone: 'rolled-back',
83
118
  };
84
119
  /**
85
- * Production adapter onto the existing unit journal. It never writes a second
86
- * journal file; merge and tree-change units share the one journal directory.
120
+ * Production adapter onto the skill-owned journal directory. Merge recovery
121
+ * reads merge/unit-journals only; legacy tree-change journals carry kind and
122
+ * are skipped by the recovery coordinator.
87
123
  */
88
124
  export function unitJournalPort(io, home) {
89
125
  return {
@@ -98,9 +134,10 @@ export function unitJournalPort(io, home) {
98
134
  phase: PHASE[record.phase],
99
135
  storageKey: unitStorageKey(record.unitId),
100
136
  snapshotSha256: contentHash(JSON.stringify(record)),
137
+ kind: 'tree-change',
101
138
  updatedAt: Date.now(),
102
139
  };
103
- await writeUnitJournal(io, home, journal);
140
+ await writeUnitJournal(io, home, journal, 'skill');
104
141
  },
105
142
  };
106
143
  }
@@ -107,7 +107,7 @@ export interface MutationUnitOptions {
107
107
  hooks?: MutationUnitHooks;
108
108
  validateWrite?: (op: TreeChangeOp) => string | null;
109
109
  transact?: typeof transactIo;
110
- /** Whether the io.transact callback re-checks the baseline. Default true; create sets false under its outer root lock. */
110
+ /** @deprecated the kernel always re-checks the baseline; kept for source compatibility. */
111
111
  transactGuard?: boolean;
112
112
  now?: () => number;
113
113
  }
@@ -135,7 +135,7 @@ export declare class MutationUnit {
135
135
  private readonly snapshots;
136
136
  private readonly hooks;
137
137
  private readonly validateWrite;
138
- private readonly transact;
138
+ private readonly kernel;
139
139
  private readonly transactGuard;
140
140
  private readonly now;
141
141
  constructor(options: MutationUnitOptions);
@@ -1,6 +1,7 @@
1
1
  import { basename, dirname, isAbsolute, join, relative } from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { isCommittedWarning, isProcessAlive } from "./io.js";
4
+ import { WriteKernel } from "./write-kernel.js";
4
5
  function underRoot(rootDir, target) {
5
6
  if (rootDir === undefined || rootDir === '')
6
7
  return false;
@@ -101,7 +102,7 @@ export class MutationUnit {
101
102
  snapshots;
102
103
  hooks;
103
104
  validateWrite;
104
- transact;
105
+ kernel;
105
106
  transactGuard;
106
107
  now;
107
108
  constructor(options) {
@@ -113,9 +114,10 @@ export class MutationUnit {
113
114
  this.snapshots = options.snapshots;
114
115
  this.hooks = options.hooks;
115
116
  this.validateWrite = options.validateWrite;
116
- this.transact = options.transact;
117
- this.transactGuard = options.transactGuard ?? true;
117
+ this.transactGuard = options.transactGuard !== false;
118
118
  this.now = options.now ?? Date.now;
119
+ const transact = options.transact;
120
+ this.kernel = new WriteKernel({ io: this.io, ...(transact === undefined ? {} : { transact }), now: this.now });
119
121
  }
120
122
  preview(request) {
121
123
  const validation = validateMutationRequest(request, this.limits);
@@ -163,7 +165,7 @@ export class MutationUnit {
163
165
  if (current !== baseline) {
164
166
  return { ok: false, unitId, dryRun: false, changed, errors: [{ code: 'cas-drift', message: 'target changed after plan', target: op.target }] };
165
167
  }
166
- snapshots.push({ target: op.target, before: current, baseline });
168
+ snapshots.push({ target: op.target, before: current, after: op.kind === 'write' ? op.content : null, baseline });
167
169
  }
168
170
  for (const op of validation.ops) {
169
171
  const refusal = this.validateWrite?.(op) ?? null;
@@ -188,7 +190,8 @@ export class MutationUnit {
188
190
  if (snapshot === undefined)
189
191
  throw new Error('snapshot missing for ' + op.target);
190
192
  try {
191
- await this.applyOp(op, snapshot.baseline);
193
+ const opWarnings = await this.applyOp(op, snapshot.baseline);
194
+ warnings.push(...opWarnings);
192
195
  changed.push(op.target);
193
196
  }
194
197
  catch (error) {
@@ -200,9 +203,11 @@ export class MutationUnit {
200
203
  }
201
204
  }
202
205
  await bestEffort(async () => this.journal?.write({ unitId, phase: 'unit-written', changed, target: primary, roots: validation.roots.map(root => root.name) }));
203
- await bestEffort(async () => this.journal?.write({ unitId, phase: 'committed', changed, target: primary, roots: validation.roots.map(root => root.name) }));
206
+ // P2-1: the committed snapshot is the recovery authority. Write it BEFORE the journal's
207
+ // committed phase so a crash in this window recovers as committed, never rolled back.
204
208
  if (this.snapshots !== undefined)
205
209
  await bestEffort(() => this.snapshots.write({ unitId, state: 'committed', ops: eventOps, ...cleanup, createdAt: this.now() }));
210
+ await bestEffort(async () => this.journal?.write({ unitId, phase: 'committed', changed, target: primary, roots: validation.roots.map(root => root.name) }));
206
211
  try {
207
212
  await this.hooks?.committed?.({ unitId, changed, ops: eventOps, roots: validation.roots.map(root => root.name) });
208
213
  }
@@ -378,48 +383,16 @@ export class MutationUnit {
378
383
  });
379
384
  }
380
385
  async applyOp(op, baseline) {
381
- if (this.transact !== undefined && op.kind === 'write') {
382
- let ran = false;
383
- await this.transact(this.io, op.target, () => {
384
- ran = true;
385
- return op.content;
386
- });
387
- if (!ran)
388
- throw new Error('internal error: the write transaction did not invoke the task; no write was performed');
389
- return;
390
- }
391
- const next = op.kind === 'delete' ? null : op.content;
392
- if (this.io.transact !== undefined) {
393
- let conflict = false;
394
- let wrote = false;
395
- await this.io.transact(op.target, current => {
396
- if (current !== baseline) {
397
- if (this.transactGuard)
398
- throw new Error('concurrent modification detected: ' + op.target);
399
- conflict = true;
400
- return current;
401
- }
402
- wrote = true;
403
- return next;
404
- });
405
- if (conflict && !wrote)
406
- throw Object.assign(new Error('concurrent modification detected: ' + op.target), { noWrite: true });
407
- return;
408
- }
409
- const current = await this.io.readText(op.target);
410
- if (current !== baseline)
411
- throw new Error('concurrent modification detected: ' + op.target);
412
- if (next === null)
413
- await this.io.remove(op.target);
414
- else
415
- await this.io.writeText(op.target, next);
386
+ const result = await this.kernel.writeOne({ path: op.target, expected: baseline, next: op.kind === 'delete' ? null : op.content, ...op.skill === undefined ? {} : { skill: op.skill } }, { guardBaseline: this.transactGuard });
387
+ if (!result.ok)
388
+ throw Object.assign(new Error(result.errors[0]?.message ?? 'write failed: ' + op.target), { noWrite: true });
389
+ return [...(result.warnings ?? [])];
416
390
  }
417
391
  async rollback(snapshots) {
418
392
  for (const snapshot of [...snapshots].reverse()) {
419
- if (snapshot.before === null)
420
- await this.io.remove(snapshot.target);
421
- else
422
- await this.io.writeText(snapshot.target, snapshot.before);
393
+ const result = await this.kernel.restore({ path: snapshot.target, before: snapshot.before, after: snapshot.after });
394
+ if (!result.ok)
395
+ throw new Error(result.errors[0]?.message ?? 'rollback failed: ' + snapshot.target);
423
396
  }
424
397
  }
425
398
  }
@@ -187,7 +187,7 @@ export const PARAM_EXPOSURE = Object.freeze([
187
187
  { id: 'evolution-approval.enabled', group: 'deployment', tier: 'E3', authority: 'cordis', owner: 'evolution-approval', applies: 'live', docAnchor: 'PARAMETERS.md#deployment', summary: 'Master approval switch: run the approval pipeline for foreground writes.', label: '启用批准流水线', hint: '关闭后前台写入不再经批准。', control: 'switch', unit: '', values: '', card: 'evolution-approval' },
188
188
  { id: 'evolution-approval.stageForeground', group: 'deployment', tier: 'E2', authority: 'cordis', owner: 'evolution-approval', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Stage foreground agent writes for approval too.', why: 'security' },
189
189
  { id: 'evolution-approval.pendingTtlMs', group: 'deployment', tier: 'E3', authority: 'cordis', owner: 'evolution-approval', applies: 'live', docAnchor: 'PARAMETERS.md#deployment', summary: 'How long a staged write may wait before it expires: expired writes are closed as rejected and are NEVER executed; 0 (the default) means the window never expires.', label: '待批过期时间', hint: '一条待批写入最多等多久:超时就按「拒绝」关掉(不会执行,写效果不会落地);0 = 永不过期(默认)。', control: 'number', unit: '毫秒', values: '', card: 'evolution-approval' },
190
- { id: 'approvalConfirm', group: 'write-caps', tier: 'E3', authority: 'cordis', owner: 'evolution-approval', applies: 'live', docAnchor: 'PARAMETERS.md#write-caps', summary: 'What the confirmation before an APPROVE does: write straight through, ask first, or treat an unanswered prompt as a cancel. The same three words skillWriteConfirm uses for a skill write.', label: '批准前先确认', hint: '点「批准」时要不要再确认一次:直接执行 / 先确认 / 等待超时即取消。默认直接执行(不打断批量批准)。', control: 'select', unit: '', values: 'auto|ask|timeout', valueLabels: '直接执行|先确认|等待超时即取消', card: 'evolution-approval' },
190
+ { id: 'approvalConfirm', group: 'write-caps', tier: 'E3', authority: 'cordis', owner: 'evolution-approval', applies: 'live', docAnchor: 'PARAMETERS.md#write-caps', summary: 'What the confirmation before an APPROVE does: execute immediately, or ask first.', label: '批准前先确认', hint: '点「批准」时要不要再确认一次:直接执行 / 先确认。默认直接执行(不打断批量批准)。', control: 'select', unit: '', values: 'auto|ask', valueLabels: '直接执行|先确认', card: 'evolution-approval' },
191
191
  { id: 'qualityWarnThreshold', group: 'deployment', tier: 'E2', authority: 'cordis', owner: 'evolution-feedback', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Feedback score below which a skill carries a warning.', why: 'internal' },
192
192
  { id: 'replay.maxPlans', group: 'deployment', tier: 'E2', authority: 'cordis', owner: 'evolution-replay', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Plans compared in one replay report.', why: 'internal' },
193
193
  { id: 'replay.weights', group: 'deployment', tier: 'E2', authority: 'cordis', owner: 'evolution-replay', applies: 'none', docAnchor: 'PARAMETERS.md#deployment', summary: 'Scoring weights of the replay comparison.', why: 'internal' },
@@ -29,7 +29,6 @@ export const PLATFORM_SERVICE_PROBES = Object.freeze([
29
29
  { service: 'evolutionPolicy', feature: 'the policy snapshot that shadows row knobs', side: 'host' },
30
30
  { service: 'evolutionReplay', feature: '/evolution replay', side: 'host' },
31
31
  { service: 'evolutionState', feature: 'the state facade behind activity and replay', side: 'host' },
32
- { service: 'evolutionStateStore', feature: 'generic durable state store for assignment queue and future collections', side: 'host' },
33
32
  { service: 'llm', feature: 'the curator one-shot review model call', side: 'host' },
34
33
  { service: 'memory', feature: 'the cross-session memory store (review and learning-graph reads)', side: 'host' },
35
34
  { service: 'pluginInventory', feature: 'the plugin inventory view', side: 'host' },
@@ -40,6 +40,18 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
40
40
  export declare const evolutionActivityProjection: ProjectionDefinition<'evolutionActivity'>;
41
41
  /** The `evolutionEvidence` unit (host-only: no client view is registered for it). */
42
42
  export declare const evolutionEvidenceProjection: ProjectionDefinition<'evolutionEvidence'>;
43
+ /**
44
+ * Whether one session object can be projected at all.
45
+ *
46
+ * The family's readers also take STRUCTURAL session views — `skill-reads.ts` documents the tool
47
+ * path's bare stub — while the registry folds a session's own log (`snapshotEvents(from, to)`,
48
+ * `eventAt(seq)`, `seq`, `inheritedEventCount`) whenever it has to build or advance a cell. A
49
+ * view without those members has no projection state to read, and asking for one would fold a
50
+ * log the unit cannot index.
51
+ * @param session - the value a caller holds as its session.
52
+ * @returns true when the registry can fold this object.
53
+ */
54
+ export declare function isProjectableSession(session: unknown): session is Session;
43
55
  /**
44
56
  * The skill names one session read through a non-failed dispatch, as a fold state.
45
57
  *
@@ -54,6 +66,8 @@ export interface EvolutionReadsState {
54
66
  readonly key: string;
55
67
  readonly skill: string | null;
56
68
  readonly failed: boolean;
69
+ readonly file?: string | null | undefined;
70
+ readonly tool?: string | undefined;
57
71
  }[];
58
72
  }
59
73
  /**
@@ -74,14 +88,32 @@ export interface EvolutionSignalsState {
74
88
  /** The open turn's window fold. */
75
89
  readonly turn: TurnFoldState;
76
90
  }
91
+ /** One captured skill-catalog message, bounded for the projection-restore reader. */
92
+ export interface EvolutionCatalogFrame {
93
+ readonly text: string;
94
+ readonly entries: readonly {
95
+ readonly name: string;
96
+ readonly description: string;
97
+ }[];
98
+ readonly update: boolean;
99
+ }
100
+ /** The bounded catalog/projection history the discovery runtime reads for restore and dedupe. */
101
+ export interface EvolutionProjectionHistoryState {
102
+ readonly catalogs: readonly EvolutionCatalogFrame[];
103
+ readonly digests: readonly string[];
104
+ }
77
105
  declare module '@deepseek-ai/dsh-session-projection/types' {
78
106
  interface SessionProjectionStateMap {
79
107
  /** The skill names one session read, for the read-before-write gate and the review plan path. */
80
108
  evolutionReads: EvolutionReadsState;
109
+ /** Catalog/projection frames and digests, newest last; bounded so the state cannot grow with the session. */
110
+ evolutionProjectionHistory: EvolutionProjectionHistoryState;
81
111
  /** The open turn's window signals, for the review cadence. */
82
112
  evolutionSignals: EvolutionSignalsState;
83
113
  }
84
114
  }
115
+ /** The `evolutionProjectionHistory` unit (host-only). */
116
+ export declare const evolutionProjectionHistoryProjection: ProjectionDefinition<'evolutionProjectionHistory'>;
85
117
  /** The `evolutionReads` unit (host-only: no client view is registered for it). */
86
118
  export declare const evolutionReadsProjection: ProjectionDefinition<'evolutionReads'>;
87
119
  /** The `evolutionSignals` unit (host-only: no client view is registered for it). */
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { z } from 'zod';
24
24
  import { isBookkeepingFrame } from "./evidence.js";
25
- import { dispatchStepOf, isSkillReadToolName, skillReadNameOf } from "./tool-dispatch.js";
25
+ import { dispatchPathOf, dispatchStepOf, isSkillReadToolName, skillReadNameOf } from "./tool-dispatch.js";
26
26
  import { foldTurnEvent, initialTurnFold } from "./signals.js";
27
27
  // Bounds are the persisted-row validation bounds (a millisecond timestamp): the clamp guard
28
28
  // requires every `z.number()` field to carry one on the same line.
@@ -77,7 +77,7 @@ export const evolutionEvidenceProjection = {
77
77
  * @param session - the value a caller holds as its session.
78
78
  * @returns true when the registry can fold this object.
79
79
  */
80
- function isProjectableSession(session) {
80
+ export function isProjectableSession(session) {
81
81
  if (session === null || typeof session !== 'object')
82
82
  return false;
83
83
  const candidate = session;
@@ -89,11 +89,21 @@ function isProjectableSession(session) {
89
89
  const logs = typeof candidate.snapshotEvents === 'function';
90
90
  return folds && logs && typeof candidate.seq === 'number';
91
91
  }
92
+ const projectionHistorySchema = z.object({
93
+ catalogs: z.array(z.object({
94
+ text: z.string(),
95
+ entries: z.array(z.object({ name: z.string(), description: z.string() }).strict()).max(512),
96
+ update: z.boolean(),
97
+ }).strict()).max(8),
98
+ digests: z.array(z.string()).max(64),
99
+ }).strict();
92
100
  const readsStateSchema = z.object({
93
101
  reads: z.array(z.object({
94
102
  key: z.string(),
95
103
  skill: z.string().nullable(),
96
104
  failed: z.boolean(),
105
+ file: z.string().nullable().optional(),
106
+ tool: z.string().optional(),
97
107
  }).strict()),
98
108
  }).strict();
99
109
  const signalsStateSchema = z.object({
@@ -110,6 +120,36 @@ const signalsStateSchema = z.object({
110
120
  opened: z.array(z.string()),
111
121
  }).strict(),
112
122
  }).strict();
123
+ /** The `evolutionProjectionHistory` unit (host-only). */
124
+ export const evolutionProjectionHistoryProjection = {
125
+ key: 'evolutionProjectionHistory',
126
+ stateVersion: 1,
127
+ stateSchema: projectionHistorySchema,
128
+ init: () => ({ catalogs: [], digests: [] }),
129
+ apply: (state, event) => {
130
+ const frame = event;
131
+ if (frame.type !== 'user/message' || typeof frame.data !== 'object' || frame.data === null)
132
+ return state;
133
+ const message = frame.data;
134
+ const source = message.source;
135
+ if (source === undefined || typeof source !== 'object')
136
+ return state;
137
+ if (source.kind === 'evolution-skill-projection' && typeof source.digest === 'string') {
138
+ if (state.digests.includes(source.digest))
139
+ return state;
140
+ return { catalogs: state.catalogs, digests: [...state.digests, source.digest].slice(-64) };
141
+ }
142
+ if (source.kind !== 'skill-catalog' || source.form !== 'catalog' || !Array.isArray(source.entries))
143
+ return state;
144
+ if (!Array.isArray(message.content))
145
+ return state;
146
+ const text = message.content.map(block => block !== null && typeof block === 'object' && block.type === 'text' ? String(block.text ?? '') : '').join('');
147
+ if (!text.includes('<available_skills>') || !text.includes('</available_skills>'))
148
+ return state;
149
+ const entries = source.entries.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.name === 'string' && typeof entry.description === 'string').map(entry => ({ name: entry.name, description: entry.description }));
150
+ return { catalogs: [...state.catalogs, { text, entries, update: source.update === true }].slice(-8), digests: state.digests };
151
+ },
152
+ };
113
153
  /** The `evolutionReads` unit (host-only: no client view is registered for it). */
114
154
  export const evolutionReadsProjection = {
115
155
  key: 'evolutionReads',
@@ -134,7 +174,7 @@ export const evolutionReadsProjection = {
134
174
  if (entry.failed === failed)
135
175
  return state;
136
176
  const reads = [...state.reads];
137
- reads[at] = { key: entry.key, skill: entry.skill, failed };
177
+ reads[at] = { key: entry.key, skill: entry.skill, failed, ...(entry.file === undefined ? {} : { file: entry.file }), ...(entry.tool === undefined ? {} : { tool: entry.tool }) };
138
178
  return { reads };
139
179
  };
140
180
  // An event with no name only SETTLES a dispatch: it can flip the failure flag of one already
@@ -144,6 +184,13 @@ export const evolutionReadsProjection = {
144
184
  // A replayed open of a tracked dispatch: keep the first entry, still honour its outcome.
145
185
  if (at >= 0)
146
186
  return settled();
187
+ // A dispatch naming a path is a candidate SUPPORT-FILE read (P2-27). The
188
+ // projection stores the raw path; the gate maps it to a skill later, when it
189
+ // knows the deployment's root and file-tool names.
190
+ const file = dispatchPathOf(step);
191
+ if (file !== null) {
192
+ return { reads: [...state.reads.slice(-511), { key: step.key, skill: null, failed: step.outcome?.ok === false, file, tool: step.name }] };
193
+ }
147
194
  // Only a read-capable tool is worth a state entry: the reader answers skill names, and a
148
195
  // state that tracked every dispatch would grow with the session.
149
196
  if (!isSkillReadToolName(step.name))
@@ -195,6 +242,7 @@ export function installEvolutionProjections(ctx) {
195
242
  registry.register(evolutionActivityProjection);
196
243
  registry.register(evolutionEvidenceProjection);
197
244
  registry.register(evolutionReadsProjection);
245
+ registry.register(evolutionProjectionHistoryProjection);
198
246
  registry.register(evolutionSignalsProjection);
199
247
  };
200
248
  // Already-mounted registry first: a reader that runs in the same tick as its own mount
@@ -56,6 +56,25 @@ export interface EvolutionSessionLogView {
56
56
  * @returns the names read through a non-failed `skill` dispatch, or `undefined` when unreadable.
57
57
  */
58
58
  export declare function sessionReadSkillNames(ctx: Context, session: EvolutionSessionLogView | undefined): ReadonlySet<string> | undefined;
59
+ /** The deployment's file-read evidence inputs: its skills root and file-tool names. */
60
+ export interface SupportReadOptions {
61
+ readonly root: string;
62
+ readonly toolNames?: readonly string[] | undefined;
63
+ }
64
+ /**
65
+ * Skill support files ONE session read through a non-failed file tool (P2-27).
66
+ *
67
+ * platform-declaration anchor: `sessionProjections` is declared by
68
+ * `@deepseek-ai/dsh-session-projection` (its registry; capability-probed here).
69
+ * Same two readers as {@link sessionReadSkillNames}: the family projection first,
70
+ * then the structural log view. `undefined` means "no evidence", not "nothing was
71
+ * read" - the write gate then keeps its fail-open posture.
72
+ * @param ctx - a context of the runtime.
73
+ * @param session - the session whose reads are wanted.
74
+ * @param options - the skills root and the configured file-tool names.
75
+ * @returns skill -> set of skill-root-relative support paths.
76
+ */
77
+ export declare function sessionReadSupportFiles(ctx: Context, session: EvolutionSessionLogView | undefined, options: SupportReadOptions): ReadonlyMap<string, ReadonlySet<string>> | undefined;
59
78
  /** Actions whose target must have been read first (create is authorship, so it is exempt). */
60
79
  export declare const READ_REQUIRED_ACTIONS: readonly string[];
61
80
  /**
@@ -1,5 +1,5 @@
1
- import { collectReadSkillNames } from "./tool-dispatch.js";
2
- import { sessionReadNames } from "./session-projection.js";
1
+ import { collectReadSkillNames, collectReadSupportFiles, DEFAULT_SUPPORT_READ_TOOL_NAMES, supportFileReadFromPath } from "./tool-dispatch.js";
2
+ import { installEvolutionProjections, isProjectableSession, sessionReadNames } from "./session-projection.js";
3
3
  /**
4
4
  * The skill names ONE session read, or `undefined` when its log is not readable.
5
5
  *
@@ -24,6 +24,46 @@ export function sessionReadSkillNames(ctx, session) {
24
24
  const events = session.readEvents?.();
25
25
  return events === undefined ? undefined : collectReadSkillNames(events);
26
26
  }
27
+ /**
28
+ * Skill support files ONE session read through a non-failed file tool (P2-27).
29
+ *
30
+ * platform-declaration anchor: `sessionProjections` is declared by
31
+ * `@deepseek-ai/dsh-session-projection` (its registry; capability-probed here).
32
+ * Same two readers as {@link sessionReadSkillNames}: the family projection first,
33
+ * then the structural log view. `undefined` means "no evidence", not "nothing was
34
+ * read" - the write gate then keeps its fail-open posture.
35
+ * @param ctx - a context of the runtime.
36
+ * @param session - the session whose reads are wanted.
37
+ * @param options - the skills root and the configured file-tool names.
38
+ * @returns skill -> set of skill-root-relative support paths.
39
+ */
40
+ export function sessionReadSupportFiles(ctx, session, options) {
41
+ if (session === undefined)
42
+ return undefined;
43
+ if (isProjectableSession(session)) {
44
+ installEvolutionProjections(ctx);
45
+ const state = ctx.get('sessionProjections')?.stateOf(session, 'evolutionReads');
46
+ if (state !== undefined) {
47
+ const toolNames = options.toolNames ?? DEFAULT_SUPPORT_READ_TOOL_NAMES;
48
+ const reads = new Map();
49
+ for (const entry of state.reads) {
50
+ if (entry.failed || typeof entry.file !== 'string')
51
+ continue;
52
+ if (entry.tool !== undefined && !toolNames.includes(entry.tool))
53
+ continue;
54
+ const hit = supportFileReadFromPath(entry.file, { root: options.root });
55
+ if (hit === null)
56
+ continue;
57
+ const set = reads.get(hit.skill) ?? new Set();
58
+ set.add(hit.rel);
59
+ reads.set(hit.skill, set);
60
+ }
61
+ return reads;
62
+ }
63
+ }
64
+ const events = session.readEvents?.();
65
+ return events === undefined ? undefined : collectReadSupportFiles(events, { toolNames: options.toolNames ?? DEFAULT_SUPPORT_READ_TOOL_NAMES, root: options.root });
66
+ }
27
67
  /** Actions whose target must have been read first (create is authorship, so it is exempt). */
28
68
  export const READ_REQUIRED_ACTIONS = Object.freeze([
29
69
  'edit', 'update', 'patch', 'delete', 'write_file', 'remove_file', 'restructure', 'extract',
@@ -50,7 +50,7 @@ import { supportRefs } from "./support-refs.js";
50
50
  import { selectSections } from "./section-selection.js";
51
51
  import { MutationUnit, fileMutationLock } from "./mutation-unit.js";
52
52
  import { SkillModel } from "./skill-model.js";
53
- import { unitJournalPort, unitSnapshotPort } from "./mutation-storage-adapter.js";
53
+ import { pruneTreeChangeUnits, unitJournalPort, unitSnapshotPort } from "./mutation-storage-adapter.js";
54
54
  import { projectExtractBodies } from "./extract-plan.js";
55
55
  import { diffFindings } from "./skill-lint.js";
56
56
  import { contentHash, loadMutations, recordMutation } from "./mutations.js";
@@ -583,7 +583,9 @@ export class SkillLibrary {
583
583
  journal: unitJournalPort(this.io, home),
584
584
  snapshots: unitSnapshotPort(this.io, home),
585
585
  });
586
- return await unit.recoverPrepared();
586
+ const results = await unit.recoverPrepared();
587
+ await pruneTreeChangeUnits(this.io, home).catch(() => { });
588
+ return results;
587
589
  }
588
590
  /**
589
591
  * Run one single-file read-modify-write for a mutator. When `transact` was