@lmzhen/dsh-evolution-review 0.3.74 → 0.3.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,6 +30,8 @@ Independent of request-prefix construction. This package does not alter the asse
30
30
  - `evolution/review-scheduled` and `evolution/review-error` are emitted for platform/user wiring only — this family has no in-repo production `ctx.on` consumer for them. They are declared externally owned (the platform side wires consumption), which matches the `EXEMPT_ORPHANS` set in `scripts/verify-event-pairing.mjs`.
31
31
  - When the `evolution-state` service is not mounted, the memory/skill cadence state is not persisted and every turn restarts from a clean `{ turnsSinceMemory: 0, turnsSinceSkill: 0 }` baseline — the review schedule is stateless and re-decided each turn rather than accumulating across the conversation. The loss is surfaced once per process as a logger warning at the first turn/end.
32
32
  - Read-before-write can see the review subagent's own `skill` reads only when the subagent backend exposes `localAgent` (the in-process driver does; out-of-process backends such as ACP and the CLI providers set `localAgent: undefined`). With a remote backend the subagent's reads are invisible, so a plan item patching a skill the subagent itself loaded is dropped as "unread" — the review then falls back to the parent session's reads only. Documented rather than worked around: recovering the child read set needs a `SubagentLike` contract change (v14 P2-6).
33
+ - **The default `reviewMode: 'inject'` produces no plan ledger.** The review runs in the parent session and emits no `evolution/plan-applied` event, so `evolution-activity`'s `activity.json` and the `evolution-replay` leaderboard never grow in this mode — the only production emit point sits inside the subagent path. Set `reviewMode: 'subagent'` on the `evolution-policy` row when the plan audit trail is required; the plugin states this once at load instead of leaving an empty ledger to be read as "no reviews happened" (v37 S2.2, plan decision (c)).
34
+ - **`.pinned` protection in the `'inject'` channel rides a family-internal session mark.** The review prompt marks the parent session, `tool-skill-manage` reads that mark and resolves both origin surfaces (approval + library) to `background_review`, and the next REAL user message (`source.kind === 'user'`) clears it — plugin notices, including the review prompt itself, do not. Two bounds follow from the platform's inject contract (no driver wake; a prompt is dropped on cancel/dispose and may be missed with an already-claimed batch): with `reviewWakeInject: false`, or on a host without `followup`, (a) a pending prompt may never execute while the session stays idle, and (b) when the user's next message wakes the session, the mark is cleared before that pending prompt reaches the model — its writes are then attributed `foreground` and the pinned guard does not cover them. The mark is in-memory, so a restart drops the window (v37 S2.2, plan decision (b)).
33
35
 
34
36
  ## Configuration
35
37
 
@@ -41,5 +43,7 @@ Independent of request-prefix construction. This package does not alter the asse
41
43
 
42
44
  - **Both channels execute at conversation end only** (a `turn/end` with `reason.kind === 'completed'`): a cadence threshold fire mid-task merely latches the kind — no subagent spawn, no inject. The flush runs BEFORE the latch block (the completing turn may itself be a threshold-firing turn). `reviewMode` selects how the flush delivers: **`'inject'` is the default since 0.3.74**, `'subagent'` is an explicit opt-in. Rationale: the parent session already holds a warm prefix cache, so an injected prompt costs the new tokens only, while a spawned child re-prefills its own system prompt plus a redacted, re-serialized conversation digest (`buildReviewRequest`) under a **different model** (`skillReviewModel`/`memoryReviewModel`) — no prefix is shared with the parent, so the whole child input is paid at full price. `'subagent'` remains the choice for deployments that want the parent context kept clean (a review's skill reads and plan do not join the parent thread) or a dedicated review model; the subagent-only knobs (`reviewProvider`, `reviewTimeoutMs`, `reviewMaxDepth`, `reviewToolAllow`, the review models) are inert in inject mode. The explicit `'inject'` mode's historical "immediate on threshold" contract was superseded in 0.3.39 — both modes are end-of-conversation.
43
45
  - **`skillReviewTrigger`** (default `'cadence'`): the cadence channel is **always on** (one end-of-conversation review from the cadence latch per task segment); the flag gates **only the completion channel** — `'cadence'` disables it, `'completion'` enables it (cadence still fires), `'both'` enables it on top of the always-on cadence. At one boundary a turn is served by exactly one review: the cadence flush runs first and returns, so `'both'` never double-sends a second task-complete prompt at the same boundary (V10-13).
44
- - **`reviewWakeInject`** (default `true`): deliveries use `agent.followup` (next-turn + wake — the model starts processing immediately) instead of the non-waking `agent.inject` (which waits for the next driver wake). The host falls back to `inject` when it has no followup or the option is `false`. **The wake primitive is always called ON the agent instance** — the platform's `Agent.followup`/`inject` are prototype methods that call `this.send(...)`, so extracting one into a local and calling the detached reference throws (0.3.73: that throw was caught and logged while the cadence reset still ran, silently consuming every segment's review from 2026-09-07). A refused delivery now returns `false` and the caller keeps its latch and counters, so the review retries at the next completed boundary instead of vanishing; `evolution-host/tests/wake-delivery-guard.spec.ts` pins the call form mechanically. The woken turn's own cadence fire is suppressed once (an injected review prompt alone must not re-trigger a review under `interval=1`); a restart clears the queue, so the loop cannot survive it.
46
+ - **`reviewWakeInject`** (default `true`): deliveries use `agent.followup` (next-turn + wake — the model starts processing immediately) instead of the non-waking `agent.inject` (which waits for the next driver wake). The host falls back to `inject` when it has no followup or the option is `false`. **The wake primitive is always called ON the agent instance** — the platform's `Agent.followup`/`inject` are prototype methods that call `this.send(...)`, so extracting one into a local and calling the detached reference throws (0.3.73: that throw was caught and logged while the cadence reset still ran, silently consuming every segment's review from 2026-09-07). A refused delivery now returns `false` and the caller keeps its latch and counters, so the review retries at the next completed boundary instead of vanishing; rule N13b in `packages/scripts/verify-arch-guards.mjs` pins the call form mechanically (comments and string literals are masked, and the detector self-tests at startup). The woken turn's own cadence fire is suppressed once (an injected review prompt alone must not re-trigger a review under `interval=1`); a restart clears the queue, so the loop cannot survive it.
45
47
  - **Counting window = injection-to-injection**: the `turnsSinceMemory`/`turnsSinceSkill` counters are monotonic across threshold fires (`resetOnFire: false`) and are zeroed at the flush delivery — a continued conversation starts a fresh segment from the injection. A threshold fire on the completing turn is caught by the flush (`pendingKind = latch ?? kind`). All deliveries (review prompt AND result notices) share the same waking channel; a failed counter-reset persist warns once per session (a stateful reload may re-deliver).
48
+
49
+ **Runtime invariant:** No companion is published. The platform auto-assembles nothing and the family mounts no `<pkg>/invariant` cordis row, so a companion here would never execute (v37 S2.1 / I-3).
package/lib/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
4
- import { COMPLETION_SKILL_REVIEW_PROMPT, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, MAX_TIMER_DELAY_MS, PROMPT_BUNDLE, SkillLibrary, advanceReview, assertSkillsRootAliasRetired, clampedNumber, contentHash, evolutionIoAdapter, foldTurn, redactSecrets, resolveOrigins, resolveRootConfig, resolveSkillsRoot, reviewPrompt, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
4
+ import { SessionId } from "@deepseek-ai/dsh-session";
5
+ import { COMPLETION_SKILL_REVIEW_PROMPT, DEFAULT_MAX_OPS_PER_PLAN, DEFAULT_MEMORY_CHAR_LIMIT, DEFAULT_MEMORY_REVIEW_MODEL, DEFAULT_REVIEW_CONTEXT_MESSAGES, DEFAULT_REVIEW_MEMORY_INTERVAL, DEFAULT_REVIEW_MESSAGE_CHARS, DEFAULT_REVIEW_SKILL_INTERVAL, DEFAULT_REVIEW_TIMEOUT_MS, DEFAULT_SKILL_CONTENT_CHARS, DEFAULT_SKILL_LIMITS, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, DEFAULT_SKILL_REVIEW_MODEL, DEFAULT_SKILL_REVIEW_TRIGGER, DEFAULT_SUBSTANTIVE_MIN_AGENT_CHARS, DEFAULT_SUBSTANTIVE_MIN_TOOL_CALLS, DEFAULT_SUBSTANTIVE_MIN_USER_CHARS, DEFAULT_USER_CHAR_LIMIT, MAX_TIMER_DELAY_MS, PROMPT_BUNDLE, advanceReview, assertSkillsRootAliasRetired, clampedNumber, clearReviewChannel, contentHash, evolutionIoAdapter, foldToolDispatches, foldTurn, markReviewChannel, newSkillLibrary, readDispatchSignal, redactSecrets, resolveOrigins, resolveRootConfig, reviewPrompt, sessionAudited, skillReadNameOf, sweepReviewChannelSessions, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
5
6
  import { validateEvolutionPlan } from "@lmzhen/dsh-evolution-plan-validator";
6
7
  //#region lib/types/index.js
7
8
  /**
@@ -27,6 +28,7 @@ const Config = z.object({
27
28
  z.const("both")
28
29
  ]).default(DEFAULT_SKILL_REVIEW_TRIGGER),
29
30
  reviewWakeInject: z.boolean().default(true),
31
+ sessionScoped: z.boolean().default(false),
30
32
  skillReviewCompletionMinToolCalls: z.number().min(1).default(DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS),
31
33
  root: z.string().default(""),
32
34
  skillsRoot: z.string().default("")
@@ -92,6 +94,25 @@ function apply(ctx, rawConfig = {}) {
92
94
  const cadenceResetWarned = /* @__PURE__ */ new Set();
93
95
  let reviewInFlight = false;
94
96
  const policy = () => ctx.get("evolutionPolicy")?.get();
97
+ const schemaDefaults = Config["~standard"].validate({}).value;
98
+ const shadowedRowFields = [
99
+ "reviewMode",
100
+ "memoryInterval",
101
+ "skillInterval"
102
+ ].filter((field) => rawConfig[field] !== void 0 && rawConfig[field] !== schemaDefaults[field]);
103
+ if (shadowedRowFields.length > 0) {
104
+ let shadowWarned = false;
105
+ const warnShadowed = () => {
106
+ if (shadowWarned) return;
107
+ shadowWarned = true;
108
+ ctx.logger.warn(`dsh-evolution-review: this row sets ${shadowedRowFields.join(", ")}, but the mounted evolution-policy service overrides all three — these row values have no effect. Set them on the evolution-policy row instead.`);
109
+ };
110
+ if (policy() !== void 0) warnShadowed();
111
+ else ctx.inject(["evolutionPolicy"], () => {
112
+ warnShadowed();
113
+ });
114
+ }
115
+ if ((policy()?.reviewMode ?? config.reviewMode) === "inject") ctx.logger.warn("dsh-evolution-review: reviewMode \"inject\" (default) runs the review in the parent session and emits NO evolution/plan-applied ledger entry — evolution-activity and evolution-replay stay empty in this mode (see packages/docs/known-limitations.md). Set reviewMode: \"subagent\" on the evolution-policy row to keep the audited plan path.");
95
116
  const reviewStateLocks = /* @__PURE__ */ new Map();
96
117
  async function withReviewStateLock(id, task) {
97
118
  const next = (reviewStateLocks.get(id) ?? Promise.resolve()).catch(() => {}).then(task);
@@ -103,7 +124,12 @@ function apply(ctx, rawConfig = {}) {
103
124
  }
104
125
  }
105
126
  ctx.on("session/event", (session, event) => {
127
+ if (!sessionAudited(ctx, session.id, config.sessionScoped)) return;
106
128
  if (event.type === "turn/start" && session.header.origin !== "subagent") turnStarts.set(session.id, session.seq - 1);
129
+ if (event.type === "user/message") {
130
+ if ((event.data?.source)?.kind === "user") clearReviewChannel(session.id);
131
+ return;
132
+ }
107
133
  if (event.type !== "turn/end") return;
108
134
  if (turnStarts.size >= COUNTER_SWEEP_THRESHOLD || cumulativeToolCalls.size >= COUNTER_SWEEP_THRESHOLD || completionInjected.size >= COUNTER_SWEEP_THRESHOLD || pendingCadenceReviews.size >= COUNTER_SWEEP_THRESHOLD || skipNextCadenceFire.size >= COUNTER_SWEEP_THRESHOLD || cadenceResetWarned.size >= COUNTER_SWEEP_THRESHOLD) {
109
135
  const isAlive = (id) => ctx.agents.get(id) !== void 0;
@@ -114,6 +140,7 @@ function apply(ctx, rawConfig = {}) {
114
140
  sweepDeadSessionEntries(pendingCadenceWarned, isAlive);
115
141
  sweepDeadSessionEntries(skipNextCadenceFire, isAlive);
116
142
  sweepDeadSessionEntries(cadenceResetWarned, isAlive);
143
+ sweepReviewChannelSessions((id) => ctx.agents.get(SessionId(id)) !== void 0);
117
144
  }
118
145
  onTurnEnd(session, event);
119
146
  });
@@ -174,7 +201,7 @@ function apply(ctx, rawConfig = {}) {
174
201
  if (pendingKind !== void 0) {
175
202
  pendingCadenceReviews.delete(session.id);
176
203
  if ((policy()?.reviewMode ?? config.reviewMode) === "inject") {
177
- if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review")) {
204
+ if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review", true)) {
178
205
  pendingCadenceReviews.set(session.id, pendingKind);
179
206
  return;
180
207
  }
@@ -208,7 +235,7 @@ function apply(ctx, rawConfig = {}) {
208
235
  pendingCadenceReviews.set(session.id, pendingKind);
209
236
  return;
210
237
  } else if (reviewOutcome !== "deferred") {
211
- if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review")) {
238
+ if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review", true)) {
212
239
  pendingCadenceReviews.set(session.id, pendingKind);
213
240
  return;
214
241
  }
@@ -272,7 +299,7 @@ function apply(ctx, rawConfig = {}) {
272
299
  }
273
300
  return;
274
301
  }
275
- if (!deliverMessage(agent, COMPLETION_SKILL_REVIEW_PROMPT, "completion review")) {
302
+ if (!deliverMessage(agent, COMPLETION_SKILL_REVIEW_PROMPT, "completion review", true)) {
276
303
  completionInjected.delete(session.id);
277
304
  return;
278
305
  }
@@ -302,7 +329,7 @@ function apply(ctx, rawConfig = {}) {
302
329
  * prototype method), the caller's catch demoted that to a console warning,
303
330
  * and the cadence reset ran anyway — the segment's review was consumed with
304
331
  * nothing queued (silent no-delivery window: 2026-09-07 → 0.3.73). */
305
- const deliverMessage = (agent, text, summary) => {
332
+ const deliverMessage = (agent, text, summary, reviewPrompt = false) => {
306
333
  const message = createUserMessage({
307
334
  content: [{
308
335
  type: "text",
@@ -321,6 +348,7 @@ function apply(ctx, rawConfig = {}) {
321
348
  wake.followup(message);
322
349
  skipNextCadenceFire.set(agent.session.id, true);
323
350
  } else agent.inject(message);
351
+ if (reviewPrompt) markReviewChannel(agent.session.id);
324
352
  return true;
325
353
  } catch (error) {
326
354
  ctx.logger.warn(`dsh-evolution-review: review delivery failed (${error instanceof Error ? error.message : String(error)}) — nothing was queued; the review is NOT consumed and retries at the next completed boundary`);
@@ -354,7 +382,10 @@ function apply(ctx, rawConfig = {}) {
354
382
  const hashes = /* @__PURE__ */ new Map();
355
383
  const io = ctx.get("evolutionIo");
356
384
  if (!io) return hashes;
357
- const library = new SkillLibrary(resolveSkillsRoot({ root: rootConfig.root }), evolutionIoAdapter(() => io.provider()));
385
+ const library = newSkillLibrary({
386
+ config: rootConfig,
387
+ io: evolutionIoAdapter(() => io.provider())
388
+ });
358
389
  for (const summary of await library.list({ withContent: true }).catch((error) => {
359
390
  ctx.logger.warn(`dsh-evolution-review: skill tree scan failed (${error instanceof Error ? error.message : String(error)}) — pre-run staleness hashes are unavailable; full-content updates will not be drift-checked this review`);
360
391
  return [];
@@ -388,6 +419,13 @@ function apply(ctx, rawConfig = {}) {
388
419
  const reviewText = redactSecrets(buildReviewRequest(session, kind, signal, config.reviewContextMessages, config.reviewMessageChars));
389
420
  const agentOptions = { model };
390
421
  if (config.reviewProvider) agentOptions.provider = config.reviewProvider;
422
+ const registry = ctx.get("tools");
423
+ const requestedReviewTools = config.reviewToolAllow ?? [];
424
+ if (requestedReviewTools.length > 0) if (registry === void 0) ctx.logger.warn("dsh-evolution-review: tools service not mounted — the review subagent tool allow-list was not probed; passing it through unchanged");
425
+ else {
426
+ const notInGlobalLayer = requestedReviewTools.filter((name) => registry.get(name) == null);
427
+ if (notInGlobalLayer.length > 0) ctx.logger.warn(`dsh-evolution-review: reviewToolAllow name(s) ${notInGlobalLayer.map((name) => `"${name}"`).join(", ")} are absent from the GLOBAL tool layer — passing them through unchanged (a preset-mounted tool is invisible to a scope-less lookup)`);
428
+ }
391
429
  const preRunHashes = await treeSkillHashes();
392
430
  const sessionSeqAtPlanTime = session.seq - 1;
393
431
  const run = await subagents.start("spawn", {
@@ -401,7 +439,7 @@ function apply(ctx, rawConfig = {}) {
401
439
  maxDepth: config.reviewMaxDepth,
402
440
  agentOptions,
403
441
  persona: reviewPrompt(kind, "plan"),
404
- toolFilter: { allow: [...config.reviewToolAllow ?? []] },
442
+ toolFilter: { allow: requestedReviewTools },
405
443
  outputSchema: REVIEW_OUTPUT_SCHEMA
406
444
  });
407
445
  try {
@@ -412,9 +450,8 @@ function apply(ctx, rawConfig = {}) {
412
450
  } catch (emitError) {
413
451
  ctx.logger.warn(`dsh-evolution-review: review-error emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
414
452
  }
415
- const failureShape = result;
416
- const stopDetail = typeof failureShape.stopReason === "string" ? failureShape.stopReason : void 0;
417
- const diagDetail = typeof failureShape.diagnostic === "string" ? failureShape.diagnostic : void 0;
453
+ const stopDetail = typeof result.stopReason === "string" ? result.stopReason : void 0;
454
+ const diagDetail = typeof result.diagnostic === "string" ? result.diagnostic : void 0;
418
455
  ctx.logger.warn(`dsh-evolution-review: review subagent returned no structured plan${stopDetail !== void 0 ? ` (stopReason=${stopDetail}${diagDetail !== void 0 ? `; diagnostic=${diagDetail}` : ""})` : ""}`);
419
456
  return false;
420
457
  }
@@ -499,7 +536,7 @@ function apply(ctx, rawConfig = {}) {
499
536
  reviewInFlight = false;
500
537
  const deferred = deferredFallbackReviews.splice(0);
501
538
  for (const { agent: waitingAgent, sessionId: entrySession, kind: waitingKind, prompt, label, channel, counts: entryCounts } of deferred) {
502
- if (!deliverMessage(waitingAgent, prompt, label)) {
539
+ if (!deliverMessage(waitingAgent, prompt, label, true)) {
503
540
  if (channel === "completion") completionInjected.delete(entrySession);
504
541
  continue;
505
542
  }
@@ -525,7 +562,10 @@ function apply(ctx, rawConfig = {}) {
525
562
  const hashLibrary = (() => {
526
563
  const io = ctx.get("evolutionIo");
527
564
  if (!io) return null;
528
- return new SkillLibrary(resolveSkillsRoot({ root: rootConfig.root }), evolutionIoAdapter(() => io.provider()));
565
+ return newSkillLibrary({
566
+ config: rootConfig,
567
+ io: evolutionIoAdapter(() => io.provider())
568
+ });
529
569
  })();
530
570
  const origins = resolveOrigins(void 0, true);
531
571
  const actions = [];
@@ -563,19 +603,17 @@ function apply(ctx, rawConfig = {}) {
563
603
  ...op,
564
604
  evidence: op.evidence
565
605
  };
566
- let stageCurrent = null;
567
- if ((args.action === "update" || args.action === "edit") && hashLibrary && typeof args.name === "string" && args.name !== "") {
568
- stageCurrent = await hashLibrary.read(args.name).catch(() => null);
569
- if (stageCurrent !== null) args.staged_from_sha256 = contentHash(stageCurrent);
570
- }
606
+ let stagedBytes;
607
+ if ((args.action === "update" || args.action === "edit") && hashLibrary && typeof args.name === "string" && args.name !== "") stagedBytes = await hashLibrary.read(args.name).catch(() => void 0);
571
608
  if ((args.action === "write_file" || args.action === "remove_file") && hashLibrary && typeof args.name === "string" && args.name !== "" && typeof args.file_path === "string" && args.file_path !== "") {
572
609
  const stageFile = await hashLibrary.readSupportFile(args.name, args.file_path).catch(() => void 0);
573
610
  if (stageFile !== void 0) args.staged_from_sha256 = stageFile === null ? "absent" : contentHash(stageFile);
574
611
  }
575
612
  const opName = typeof args.name === "string" ? args.name : "";
576
613
  const hashChecked = (args.action === "update" || args.action === "edit" || args.action === "patch") && opName !== "" && preRunHashes?.has(opName) === true;
614
+ const anchored = hashChecked && typeof stagedBytes === "string" ? stagedBytes : null;
577
615
  if (hashChecked) {
578
- const live = args.action === "patch" ? hashLibrary ? await hashLibrary.read(opName).catch(() => null) : null : stageCurrent;
616
+ const live = anchored !== null ? anchored : hashLibrary ? await hashLibrary.read(opName).catch(() => null) : null;
579
617
  const preRun = preRunHashes.get(opName);
580
618
  if (live === null || preRun !== void 0 && contentHash(live) !== preRun) {
581
619
  ok = false;
@@ -583,6 +621,8 @@ function apply(ctx, rawConfig = {}) {
583
621
  continue;
584
622
  }
585
623
  }
624
+ const anchorBytes = anchored ?? stagedBytes;
625
+ if ((args.action === "update" || args.action === "edit") && anchorBytes !== void 0) args.staged_from_sha256 = anchorBytes === null ? "absent" : contentHash(anchorBytes);
586
626
  const runnerArgs = {
587
627
  operation: args,
588
628
  origin: origins.library
@@ -670,11 +710,14 @@ function apply(ctx, rawConfig = {}) {
670
710
  message: "evolution-io service not mounted"
671
711
  };
672
712
  const policySnapshot = policySnapshotOf(ctx.get("evolutionPolicy"));
673
- const library = new SkillLibrary(resolveSkillsRoot({ root: rootConfig.root }), evolutionIoAdapter(() => io.provider()), {
674
- ...DEFAULT_SKILL_LIMITS,
675
- maxSkillContentChars: policySnapshot?.skillContentChars ?? DEFAULT_SKILL_LIMITS.maxSkillContentChars
676
- }, (event) => {
677
- ctx.emit("evolution/skill-mutated", event);
713
+ const library = newSkillLibrary({
714
+ config: rootConfig,
715
+ io: evolutionIoAdapter(() => io.provider()),
716
+ limits: {
717
+ ...DEFAULT_SKILL_LIMITS,
718
+ maxSkillContentChars: policySnapshot?.skillContentChars ?? DEFAULT_SKILL_LIMITS.maxSkillContentChars
719
+ },
720
+ ctx
678
721
  });
679
722
  const op = skillArgs;
680
723
  const name = op.name ?? "";
@@ -792,32 +835,27 @@ function staleRefusal(result, name, filePath) {
792
835
  message: filePath === void 0 ? `Skill "${name}" changed since this plan was produced — the full-content update was refused as stale. Re-read the skill and produce a fresh plan.` : `Support file "${filePath}" of "${name}" changed since this plan was produced — the staged file operation was refused as stale. Re-read the skill tree and produce a fresh plan.`
793
836
  };
794
837
  }
838
+ /**
839
+ * v37 P7a: the read-before-write credit now comes from evolution-core's
840
+ * `tool-dispatch` module — the ONE reader of the platform's dispatch event
841
+ * types, and the ONE authority on which tool reads a skill.
842
+ *
843
+ * v32 REV-06(a) is preserved by the normalizer: a skill counts as READ only
844
+ * when it did not fail, so a failed/timeout read still cannot pass the
845
+ * read-before-write gate and let the review blind-overwrite content the model
846
+ * never saw. What changed is the vocabulary the gate listens to: matching
847
+ * `tool/call` here meant every PTC session (`tool/ptc-dispatch*`) collected an
848
+ * EMPTY set, so `filterUnreadSkillOps` dropped every mutating op the model had
849
+ * legitimately read first — and nothing reported the loss.
850
+ * @param session - the session whose log is folded.
851
+ * @returns the skill names this session read through a non-failed dispatch.
852
+ */
795
853
  function collectReadSkillNames(session) {
796
- const callNames = /* @__PURE__ */ new Map();
797
- const okCallIds = /* @__PURE__ */ new Set();
798
- for (const event of session.snapshotEvents()) if (event.type === "tool/call") {
799
- if (event.data.name !== "skill") continue;
800
- const raw = event.data.arguments;
801
- let parsed = {};
802
- if (typeof raw === "string") try {
803
- parsed = JSON.parse(raw);
804
- } catch {
805
- continue;
806
- }
807
- else parsed = raw;
808
- const parsedObj = typeof parsed === "object" && parsed !== null ? parsed : {};
809
- const name = typeof parsedObj.name === "string" ? parsedObj.name : typeof parsedObj.skill === "string" ? parsedObj.skill : "";
810
- if (name) callNames.set(event.data.callId, name);
811
- } else if (event.type === "tool/result") {
812
- const blocks = event.data?.message?.content;
813
- if (!Array.isArray(blocks)) continue;
814
- for (const block of blocks) {
815
- const typed = block;
816
- if (typed.type === "tool-result" && typed.isError !== true && typeof typed.toolCallId === "string") okCallIds.add(typed.toolCallId);
817
- }
818
- }
819
854
  const names = /* @__PURE__ */ new Set();
820
- for (const [callId, name] of callNames) if (okCallIds.has(callId)) names.add(name);
855
+ for (const dispatch of foldToolDispatches(session.snapshotEvents())) {
856
+ const name = skillReadNameOf(dispatch);
857
+ if (name !== void 0) names.add(name);
858
+ }
821
859
  return names;
822
860
  }
823
861
  /** Map/set size that triggers a dead-session counter sweep (bounded, not a hard cap). */
@@ -874,7 +912,26 @@ function fingerprintPolicy(snapshot) {
874
912
  }
875
913
  }
876
914
  /**
877
- * V10-10 (P2-11): render one `[result]` evidence line from a 'tool/result'
915
+ * The call one log event ANSWERS, in either platform vocabulary.
916
+ *
917
+ * A native tool-result event names its call inside the message (`message.source`,
918
+ * the shape `createToolResultMessage` produces — llm/src/message.ts:258), and a
919
+ * PTC settle event carries `isError` plus the outcome `content` on its own
920
+ * payload. Classified structurally, from the payload, so this file never
921
+ * discriminates on a dispatch event type (arch guard N11).
922
+ * @param event - one persisted session event.
923
+ * @returns the answered call id, or `null` when the event carries no outcome.
924
+ */
925
+ function resultCallIdOf(event) {
926
+ const data = event?.data;
927
+ if (data === void 0 || data === null) return null;
928
+ const native = data.message?.source?.callId;
929
+ if (typeof native === "string") return native;
930
+ if (typeof data.subCallId === "string" && typeof data.isError === "boolean") return data.subCallId;
931
+ return null;
932
+ }
933
+ /**
934
+ * V10-10 (P2-11): render one `[result]` evidence line from a tool-result
878
935
  * event payload. The former read (`data.output`) targeted a field that does
879
936
  * not exist on the upstream rc.2 payload, so EVERY result line rendered an
880
937
  * empty payload and the review subagent never saw tool output — the evidence
@@ -902,13 +959,21 @@ function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars)
902
959
  }
903
960
  const toolLines = [];
904
961
  const events = session.snapshotEvents();
962
+ const openedCallIds = /* @__PURE__ */ new Set();
905
963
  for (let index = events.length - 1; index >= 0 && toolLines.length < 12; index -= 1) {
906
964
  const event = events[index];
907
- if (event?.type === "tool/call") {
908
- const data = event.data;
909
- const argsRaw = typeof data?.arguments === "string" ? data.arguments : JSON.stringify(data?.arguments ?? {});
910
- toolLines.push(`[call] ${data?.name ?? "?"} ${argsRaw.slice(0, 500)}`);
911
- } else if (event?.type === "tool/result") toolLines.push(renderToolResultLine(event.data));
965
+ const answeredCallId = resultCallIdOf(event);
966
+ if (answeredCallId !== null) {
967
+ if (openedCallIds.has(answeredCallId)) continue;
968
+ openedCallIds.add(answeredCallId);
969
+ toolLines.push(renderToolResultLine(event?.data));
970
+ continue;
971
+ }
972
+ const opened = readDispatchSignal(event);
973
+ if (opened === null || openedCallIds.has(opened.callId)) continue;
974
+ openedCallIds.add(opened.callId);
975
+ const argsRaw = typeof opened.arguments === "string" ? opened.arguments : JSON.stringify(opened.arguments ?? {});
976
+ toolLines.push(`[call] ${opened.name} ${argsRaw.slice(0, 500)}`);
912
977
  }
913
978
  toolLines.reverse();
914
979
  return [
@@ -17,9 +17,12 @@ export interface Config {
17
17
  * system prompt plus a re-serialized conversation digest
18
18
  * (`buildReviewRequest`, redacted, header-prepended) on a DIFFERENT model —
19
19
  * so no prefix cache is shared with the parent and the input tokens are paid
20
- * at full price. Deployments that prefer the clean-context split opt back in
21
- * per plugin row. */
20
+ * at full price. Deployments that prefer the clean-context split switch the
21
+ * POLICY row (`evolution-policy`): the mounted policy snapshot shadows this
22
+ * row's value, which the plugin reports once at load (v37 P2-24). */
22
23
  reviewMode?: 'subagent' | 'inject';
24
+ /** Shadowed by the policy snapshot in every shipped composition — configure
25
+ * `reviewMemoryInterval` on the `evolution-policy` row instead (v37 P2-24). */
23
26
  memoryInterval?: number;
24
27
  skillInterval?: number;
25
28
  /**
@@ -62,6 +65,14 @@ export interface Config {
62
65
  * config that still sets it reaches {@link assertSkillsRootAliasRetired} and
63
66
  * fails the load instead of being silently dropped. Never read. */
64
67
  skillsRoot?: string;
68
+ /**
69
+ * Act only on sessions that carry the family's model tools (evolution-core's
70
+ * per-session probe). The shipped bundles set it: at profile root every
71
+ * session carries them, inside a variant preset only the sessions that
72
+ * selected one do. False — a bare library mount, the host-only infrastructure
73
+ * mode, and every deployment that predates this field — acts on every session.
74
+ */
75
+ sessionScoped?: boolean;
65
76
  }
66
77
  export declare const Config: z<Config>;
67
78
  /** V8-01 (0.3.45): the review subagent's structured-output contract. The
@@ -138,7 +149,7 @@ export declare function filterUnreadSkillOps(ops: Array<{
138
149
  name?: string;
139
150
  }>, readNames: ReadonlySet<string>): number;
140
151
  /**
141
- * V10-10 (P2-11): render one `[result]` evidence line from a 'tool/result'
152
+ * V10-10 (P2-11): render one `[result]` evidence line from a tool-result
142
153
  * event payload. The former read (`data.output`) targeted a field that does
143
154
  * not exist on the upstream rc.2 payload, so EVERY result line rendered an
144
155
  * empty payload and the review subagent never saw tool output — the evidence
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-review",
3
3
  "description": "Background review orchestration (community build)",
4
- "version": "0.3.74",
4
+ "version": "0.3.77",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -18,10 +18,6 @@
18
18
  "types": "./lib/types/index.d.ts",
19
19
  "default": "./lib/index.js"
20
20
  },
21
- "./invariant": {
22
- "types": "./lib/types/invariant.d.ts",
23
- "default": "./lib/invariant.js"
24
- },
25
21
  "./package.json": "./package.json"
26
22
  },
27
23
  "files": [
@@ -31,33 +27,31 @@
31
27
  "license": "MIT",
32
28
  "dependencies": {
33
29
  "@deepseek-ai/schemastery": "^3.18.1",
34
- "@lmzhen/dsh-evolution-approval": "^0.3.74",
35
- "@lmzhen/dsh-evolution-core": "^0.3.74",
36
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.74"
30
+ "@lmzhen/dsh-evolution-approval": "^0.3.77",
31
+ "@lmzhen/dsh-evolution-core": "^0.3.77",
32
+ "@lmzhen/dsh-evolution-plan-validator": "^0.3.77"
37
33
  },
38
34
  "peerDependencies": {
39
35
  "@deepseek-ai/cordis": "^4.0.1",
40
36
  "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
41
- "@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
42
37
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
43
38
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
44
39
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
45
- "@lmzhen/dsh-evolution-state": "^0.3.74",
46
- "@lmzhen/dsh-evolution-policy": "^0.3.74"
40
+ "@lmzhen/dsh-evolution-state": "^0.3.77",
41
+ "@lmzhen/dsh-evolution-policy": "^0.3.77"
47
42
  },
48
43
  "devDependencies": {
49
44
  "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
50
45
  "@deepseek-ai/dsh-agent-loop-testkit": "^0.1.5-rc.2",
51
- "@deepseek-ai/dsh-invariants": "^0.1.5-rc.2",
52
46
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
53
47
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
54
48
  "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.2",
55
49
  "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.5-rc.2",
56
50
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
57
- "@lmzhen/dsh-evolution-approval": "^0.3.74",
58
- "@lmzhen/dsh-evolution-core": "^0.3.74",
59
- "@lmzhen/dsh-evolution-curator": "^0.3.74",
60
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.74",
61
- "@lmzhen/dsh-evolution-state": "^0.3.74"
51
+ "@lmzhen/dsh-evolution-approval": "^0.3.77",
52
+ "@lmzhen/dsh-evolution-core": "^0.3.77",
53
+ "@lmzhen/dsh-evolution-curator": "^0.3.77",
54
+ "@lmzhen/dsh-evolution-plan-validator": "^0.3.77",
55
+ "@lmzhen/dsh-evolution-state": "^0.3.77"
62
56
  }
63
57
  }
package/lib/invariant.js DELETED
@@ -1,8 +0,0 @@
1
- //#region lib/types/invariant.js
2
- const PACKAGE_NAME = "@lmzhen/dsh-evolution-review";
3
- const name = "evolution-review-invariant";
4
- const inject = ["invariants"];
5
- const install = () => {};
6
- const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
7
- //#endregion
8
- export { apply, inject, name };
@@ -1,5 +0,0 @@
1
- import type { Context } from '@deepseek-ai/cordis';
2
- export declare const name = "evolution-review-invariant";
3
- export declare const inject: string[];
4
- export declare const apply: (ctx: Context) => Promise<() => void>;
5
- //# sourceMappingURL=invariant.d.ts.map