@lmzhen/dsh-evolution-review 0.3.73 → 0.3.76

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
@@ -21,6 +21,7 @@ Independent of request-prefix construction. This package does not alter the asse
21
21
  ## Known Limitations and Deferred Work
22
22
 
23
23
 
24
+ - The review's model/context routing below applies to `reviewMode: 'subagent'` (opt-in since 0.3.74); in the default `'inject'` mode the review runs on the parent agent's model inside the parent thread.
24
25
  - Review subagents are spawned with the plain `skill` tool only (`reviewToolAllow` default and the host/preset config both = `[skill]` — the DSH tool catalog has no `skill_search`/`skill_load` discovery pair, so the Hermes-lineage Anchored Standard `skill_search`/`skill_load` allow-list does not exist here).
25
26
  - Review subagents run as `spawn` children on the deployment default preset rather than inheriting the parent agent's composition (`fork`): a fork child is always promoted by the Anchored Standard bootstrap and its narrowed resident catalog would drop the plain `skill` tool from the review allow-list.
26
27
  - The review request text is redacted for credential-shaped patterns before it reaches the subagent, but redaction is pattern-based and best-effort, not a security boundary.
@@ -29,6 +30,8 @@ Independent of request-prefix construction. This package does not alter the asse
29
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`.
30
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.
31
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)).
32
35
 
33
36
  ## Configuration
34
37
 
@@ -38,7 +41,9 @@ Independent of request-prefix construction. This package does not alter the asse
38
41
 
39
42
  ### Review delivery contract (0.3.38-0.3.42)
40
43
 
41
- - **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` (`'subagent'` default / `'inject'`) selects how the flush delivers; the explicit `'inject'` mode's historical "immediate on threshold" contract was superseded in 0.3.39 — both modes are end-of-conversation.
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.
42
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).
43
- - **`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.
44
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, 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
  /**
@@ -12,7 +13,7 @@ const name = "evolution-review";
12
13
  const inject = ["agents"];
13
14
  const Config = z.object({
14
15
  reviewEnabled: z.boolean().default(true),
15
- reviewMode: z.union([z.const("subagent"), z.const("inject")]).default("subagent"),
16
+ reviewMode: z.union([z.const("subagent"), z.const("inject")]).default("inject"),
16
17
  memoryInterval: z.number().min(1).default(DEFAULT_REVIEW_MEMORY_INTERVAL),
17
18
  skillInterval: z.number().min(1).default(DEFAULT_REVIEW_SKILL_INTERVAL),
18
19
  reviewToolAllow: z.array(z.string()).default(["skill"]),
@@ -92,6 +93,25 @@ function apply(ctx, rawConfig = {}) {
92
93
  const cadenceResetWarned = /* @__PURE__ */ new Set();
93
94
  let reviewInFlight = false;
94
95
  const policy = () => ctx.get("evolutionPolicy")?.get();
96
+ const schemaDefaults = Config["~standard"].validate({}).value;
97
+ const shadowedRowFields = [
98
+ "reviewMode",
99
+ "memoryInterval",
100
+ "skillInterval"
101
+ ].filter((field) => rawConfig[field] !== void 0 && rawConfig[field] !== schemaDefaults[field]);
102
+ if (shadowedRowFields.length > 0) {
103
+ let shadowWarned = false;
104
+ const warnShadowed = () => {
105
+ if (shadowWarned) return;
106
+ shadowWarned = true;
107
+ 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.`);
108
+ };
109
+ if (policy() !== void 0) warnShadowed();
110
+ else ctx.inject(["evolutionPolicy"], () => {
111
+ warnShadowed();
112
+ });
113
+ }
114
+ 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
115
  const reviewStateLocks = /* @__PURE__ */ new Map();
96
116
  async function withReviewStateLock(id, task) {
97
117
  const next = (reviewStateLocks.get(id) ?? Promise.resolve()).catch(() => {}).then(task);
@@ -104,6 +124,10 @@ function apply(ctx, rawConfig = {}) {
104
124
  }
105
125
  ctx.on("session/event", (session, event) => {
106
126
  if (event.type === "turn/start" && session.header.origin !== "subagent") turnStarts.set(session.id, session.seq - 1);
127
+ if (event.type === "user/message") {
128
+ if ((event.data?.source)?.kind === "user") clearReviewChannel(session.id);
129
+ return;
130
+ }
107
131
  if (event.type !== "turn/end") return;
108
132
  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
133
  const isAlive = (id) => ctx.agents.get(id) !== void 0;
@@ -114,6 +138,7 @@ function apply(ctx, rawConfig = {}) {
114
138
  sweepDeadSessionEntries(pendingCadenceWarned, isAlive);
115
139
  sweepDeadSessionEntries(skipNextCadenceFire, isAlive);
116
140
  sweepDeadSessionEntries(cadenceResetWarned, isAlive);
141
+ sweepReviewChannelSessions((id) => ctx.agents.get(SessionId(id)) !== void 0);
117
142
  }
118
143
  onTurnEnd(session, event);
119
144
  });
@@ -174,7 +199,7 @@ function apply(ctx, rawConfig = {}) {
174
199
  if (pendingKind !== void 0) {
175
200
  pendingCadenceReviews.delete(session.id);
176
201
  if ((policy()?.reviewMode ?? config.reviewMode) === "inject") {
177
- if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review")) {
202
+ if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review", true)) {
178
203
  pendingCadenceReviews.set(session.id, pendingKind);
179
204
  return;
180
205
  }
@@ -208,7 +233,7 @@ function apply(ctx, rawConfig = {}) {
208
233
  pendingCadenceReviews.set(session.id, pendingKind);
209
234
  return;
210
235
  } else if (reviewOutcome !== "deferred") {
211
- if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review")) {
236
+ if (!deliverMessage(agent, reviewPrompt(pendingKind), "auto-review", true)) {
212
237
  pendingCadenceReviews.set(session.id, pendingKind);
213
238
  return;
214
239
  }
@@ -272,7 +297,7 @@ function apply(ctx, rawConfig = {}) {
272
297
  }
273
298
  return;
274
299
  }
275
- if (!deliverMessage(agent, COMPLETION_SKILL_REVIEW_PROMPT, "completion review")) {
300
+ if (!deliverMessage(agent, COMPLETION_SKILL_REVIEW_PROMPT, "completion review", true)) {
276
301
  completionInjected.delete(session.id);
277
302
  return;
278
303
  }
@@ -302,7 +327,7 @@ function apply(ctx, rawConfig = {}) {
302
327
  * prototype method), the caller's catch demoted that to a console warning,
303
328
  * and the cadence reset ran anyway — the segment's review was consumed with
304
329
  * nothing queued (silent no-delivery window: 2026-09-07 → 0.3.73). */
305
- const deliverMessage = (agent, text, summary) => {
330
+ const deliverMessage = (agent, text, summary, reviewPrompt = false) => {
306
331
  const message = createUserMessage({
307
332
  content: [{
308
333
  type: "text",
@@ -321,6 +346,7 @@ function apply(ctx, rawConfig = {}) {
321
346
  wake.followup(message);
322
347
  skipNextCadenceFire.set(agent.session.id, true);
323
348
  } else agent.inject(message);
349
+ if (reviewPrompt) markReviewChannel(agent.session.id);
324
350
  return true;
325
351
  } catch (error) {
326
352
  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 +380,10 @@ function apply(ctx, rawConfig = {}) {
354
380
  const hashes = /* @__PURE__ */ new Map();
355
381
  const io = ctx.get("evolutionIo");
356
382
  if (!io) return hashes;
357
- const library = new SkillLibrary(resolveSkillsRoot({ root: rootConfig.root }), evolutionIoAdapter(() => io.provider()));
383
+ const library = newSkillLibrary({
384
+ config: rootConfig,
385
+ io: evolutionIoAdapter(() => io.provider())
386
+ });
358
387
  for (const summary of await library.list({ withContent: true }).catch((error) => {
359
388
  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
389
  return [];
@@ -388,6 +417,13 @@ function apply(ctx, rawConfig = {}) {
388
417
  const reviewText = redactSecrets(buildReviewRequest(session, kind, signal, config.reviewContextMessages, config.reviewMessageChars));
389
418
  const agentOptions = { model };
390
419
  if (config.reviewProvider) agentOptions.provider = config.reviewProvider;
420
+ const registry = ctx.get("tools");
421
+ const requestedReviewTools = config.reviewToolAllow ?? [];
422
+ 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");
423
+ else {
424
+ const notInGlobalLayer = requestedReviewTools.filter((name) => registry.get(name) == null);
425
+ 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)`);
426
+ }
391
427
  const preRunHashes = await treeSkillHashes();
392
428
  const sessionSeqAtPlanTime = session.seq - 1;
393
429
  const run = await subagents.start("spawn", {
@@ -401,7 +437,7 @@ function apply(ctx, rawConfig = {}) {
401
437
  maxDepth: config.reviewMaxDepth,
402
438
  agentOptions,
403
439
  persona: reviewPrompt(kind, "plan"),
404
- toolFilter: { allow: [...config.reviewToolAllow ?? []] },
440
+ toolFilter: { allow: requestedReviewTools },
405
441
  outputSchema: REVIEW_OUTPUT_SCHEMA
406
442
  });
407
443
  try {
@@ -412,9 +448,8 @@ function apply(ctx, rawConfig = {}) {
412
448
  } catch (emitError) {
413
449
  ctx.logger.warn(`dsh-evolution-review: review-error emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
414
450
  }
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;
451
+ const stopDetail = typeof result.stopReason === "string" ? result.stopReason : void 0;
452
+ const diagDetail = typeof result.diagnostic === "string" ? result.diagnostic : void 0;
418
453
  ctx.logger.warn(`dsh-evolution-review: review subagent returned no structured plan${stopDetail !== void 0 ? ` (stopReason=${stopDetail}${diagDetail !== void 0 ? `; diagnostic=${diagDetail}` : ""})` : ""}`);
419
454
  return false;
420
455
  }
@@ -499,7 +534,7 @@ function apply(ctx, rawConfig = {}) {
499
534
  reviewInFlight = false;
500
535
  const deferred = deferredFallbackReviews.splice(0);
501
536
  for (const { agent: waitingAgent, sessionId: entrySession, kind: waitingKind, prompt, label, channel, counts: entryCounts } of deferred) {
502
- if (!deliverMessage(waitingAgent, prompt, label)) {
537
+ if (!deliverMessage(waitingAgent, prompt, label, true)) {
503
538
  if (channel === "completion") completionInjected.delete(entrySession);
504
539
  continue;
505
540
  }
@@ -525,7 +560,10 @@ function apply(ctx, rawConfig = {}) {
525
560
  const hashLibrary = (() => {
526
561
  const io = ctx.get("evolutionIo");
527
562
  if (!io) return null;
528
- return new SkillLibrary(resolveSkillsRoot({ root: rootConfig.root }), evolutionIoAdapter(() => io.provider()));
563
+ return newSkillLibrary({
564
+ config: rootConfig,
565
+ io: evolutionIoAdapter(() => io.provider())
566
+ });
529
567
  })();
530
568
  const origins = resolveOrigins(void 0, true);
531
569
  const actions = [];
@@ -563,19 +601,17 @@ function apply(ctx, rawConfig = {}) {
563
601
  ...op,
564
602
  evidence: op.evidence
565
603
  };
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
- }
604
+ let stagedBytes;
605
+ if ((args.action === "update" || args.action === "edit") && hashLibrary && typeof args.name === "string" && args.name !== "") stagedBytes = await hashLibrary.read(args.name).catch(() => void 0);
571
606
  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
607
  const stageFile = await hashLibrary.readSupportFile(args.name, args.file_path).catch(() => void 0);
573
608
  if (stageFile !== void 0) args.staged_from_sha256 = stageFile === null ? "absent" : contentHash(stageFile);
574
609
  }
575
610
  const opName = typeof args.name === "string" ? args.name : "";
576
611
  const hashChecked = (args.action === "update" || args.action === "edit" || args.action === "patch") && opName !== "" && preRunHashes?.has(opName) === true;
612
+ const anchored = hashChecked && typeof stagedBytes === "string" ? stagedBytes : null;
577
613
  if (hashChecked) {
578
- const live = args.action === "patch" ? hashLibrary ? await hashLibrary.read(opName).catch(() => null) : null : stageCurrent;
614
+ const live = anchored !== null ? anchored : hashLibrary ? await hashLibrary.read(opName).catch(() => null) : null;
579
615
  const preRun = preRunHashes.get(opName);
580
616
  if (live === null || preRun !== void 0 && contentHash(live) !== preRun) {
581
617
  ok = false;
@@ -583,6 +619,8 @@ function apply(ctx, rawConfig = {}) {
583
619
  continue;
584
620
  }
585
621
  }
622
+ const anchorBytes = anchored ?? stagedBytes;
623
+ if ((args.action === "update" || args.action === "edit") && anchorBytes !== void 0) args.staged_from_sha256 = anchorBytes === null ? "absent" : contentHash(anchorBytes);
586
624
  const runnerArgs = {
587
625
  operation: args,
588
626
  origin: origins.library
@@ -670,11 +708,14 @@ function apply(ctx, rawConfig = {}) {
670
708
  message: "evolution-io service not mounted"
671
709
  };
672
710
  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);
711
+ const library = newSkillLibrary({
712
+ config: rootConfig,
713
+ io: evolutionIoAdapter(() => io.provider()),
714
+ limits: {
715
+ ...DEFAULT_SKILL_LIMITS,
716
+ maxSkillContentChars: policySnapshot?.skillContentChars ?? DEFAULT_SKILL_LIMITS.maxSkillContentChars
717
+ },
718
+ ctx
678
719
  });
679
720
  const op = skillArgs;
680
721
  const name = op.name ?? "";
@@ -792,32 +833,27 @@ function staleRefusal(result, name, filePath) {
792
833
  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
834
  };
794
835
  }
836
+ /**
837
+ * v37 P7a: the read-before-write credit now comes from evolution-core's
838
+ * `tool-dispatch` module — the ONE reader of the platform's dispatch event
839
+ * types, and the ONE authority on which tool reads a skill.
840
+ *
841
+ * v32 REV-06(a) is preserved by the normalizer: a skill counts as READ only
842
+ * when it did not fail, so a failed/timeout read still cannot pass the
843
+ * read-before-write gate and let the review blind-overwrite content the model
844
+ * never saw. What changed is the vocabulary the gate listens to: matching
845
+ * `tool/call` here meant every PTC session (`tool/ptc-dispatch*`) collected an
846
+ * EMPTY set, so `filterUnreadSkillOps` dropped every mutating op the model had
847
+ * legitimately read first — and nothing reported the loss.
848
+ * @param session - the session whose log is folded.
849
+ * @returns the skill names this session read through a non-failed dispatch.
850
+ */
795
851
  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
852
  const names = /* @__PURE__ */ new Set();
820
- for (const [callId, name] of callNames) if (okCallIds.has(callId)) names.add(name);
853
+ for (const dispatch of foldToolDispatches(session.snapshotEvents())) {
854
+ const name = skillReadNameOf(dispatch);
855
+ if (name !== void 0) names.add(name);
856
+ }
821
857
  return names;
822
858
  }
823
859
  /** Map/set size that triggers a dead-session counter sweep (bounded, not a hard cap). */
@@ -874,7 +910,26 @@ function fingerprintPolicy(snapshot) {
874
910
  }
875
911
  }
876
912
  /**
877
- * V10-10 (P2-11): render one `[result]` evidence line from a 'tool/result'
913
+ * The call one log event ANSWERS, in either platform vocabulary.
914
+ *
915
+ * A native tool-result event names its call inside the message (`message.source`,
916
+ * the shape `createToolResultMessage` produces — llm/src/message.ts:258), and a
917
+ * PTC settle event carries `isError` plus the outcome `content` on its own
918
+ * payload. Classified structurally, from the payload, so this file never
919
+ * discriminates on a dispatch event type (arch guard N11).
920
+ * @param event - one persisted session event.
921
+ * @returns the answered call id, or `null` when the event carries no outcome.
922
+ */
923
+ function resultCallIdOf(event) {
924
+ const data = event?.data;
925
+ if (data === void 0 || data === null) return null;
926
+ const native = data.message?.source?.callId;
927
+ if (typeof native === "string") return native;
928
+ if (typeof data.subCallId === "string" && typeof data.isError === "boolean") return data.subCallId;
929
+ return null;
930
+ }
931
+ /**
932
+ * V10-10 (P2-11): render one `[result]` evidence line from a tool-result
878
933
  * event payload. The former read (`data.output`) targeted a field that does
879
934
  * not exist on the upstream rc.2 payload, so EVERY result line rendered an
880
935
  * empty payload and the review subagent never saw tool output — the evidence
@@ -902,13 +957,21 @@ function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars)
902
957
  }
903
958
  const toolLines = [];
904
959
  const events = session.snapshotEvents();
960
+ const openedCallIds = /* @__PURE__ */ new Set();
905
961
  for (let index = events.length - 1; index >= 0 && toolLines.length < 12; index -= 1) {
906
962
  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));
963
+ const answeredCallId = resultCallIdOf(event);
964
+ if (answeredCallId !== null) {
965
+ if (openedCallIds.has(answeredCallId)) continue;
966
+ openedCallIds.add(answeredCallId);
967
+ toolLines.push(renderToolResultLine(event?.data));
968
+ continue;
969
+ }
970
+ const opened = readDispatchSignal(event);
971
+ if (opened === null || openedCallIds.has(opened.callId)) continue;
972
+ openedCallIds.add(opened.callId);
973
+ const argsRaw = typeof opened.arguments === "string" ? opened.arguments : JSON.stringify(opened.arguments ?? {});
974
+ toolLines.push(`[call] ${opened.name} ${argsRaw.slice(0, 500)}`);
912
975
  }
913
976
  toolLines.reverse();
914
977
  return [
@@ -8,7 +8,21 @@ export declare const name = "evolution-review";
8
8
  export declare const inject: string[];
9
9
  export interface Config {
10
10
  reviewEnabled?: boolean;
11
+ /** How the flush delivers the review: `'inject'` (default since 0.3.74) hands
12
+ * the review prompt to the PARENT agent — waking it through the same
13
+ * followup-first channel — so the review runs on the parent's model against
14
+ * the parent's already-cached prefix and no separate prefill is paid.
15
+ * `'subagent'` spawns a one-shot child instead: the parent context stays clean
16
+ * and the review can use a dedicated model, but the child re-prefills its own
17
+ * system prompt plus a re-serialized conversation digest
18
+ * (`buildReviewRequest`, redacted, header-prepended) on a DIFFERENT model —
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 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). */
11
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). */
12
26
  memoryInterval?: number;
13
27
  skillInterval?: number;
14
28
  /**
@@ -127,7 +141,7 @@ export declare function filterUnreadSkillOps(ops: Array<{
127
141
  name?: string;
128
142
  }>, readNames: ReadonlySet<string>): number;
129
143
  /**
130
- * V10-10 (P2-11): render one `[result]` evidence line from a 'tool/result'
144
+ * V10-10 (P2-11): render one `[result]` evidence line from a tool-result
131
145
  * event payload. The former read (`data.output`) targeted a field that does
132
146
  * not exist on the upstream rc.2 payload, so EVERY result line rendered an
133
147
  * 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.73",
4
+ "version": "0.3.76",
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.73",
35
- "@lmzhen/dsh-evolution-core": "^0.3.73",
36
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.73"
30
+ "@lmzhen/dsh-evolution-approval": "^0.3.76",
31
+ "@lmzhen/dsh-evolution-core": "^0.3.76",
32
+ "@lmzhen/dsh-evolution-plan-validator": "^0.3.76"
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.73",
46
- "@lmzhen/dsh-evolution-policy": "^0.3.73"
40
+ "@lmzhen/dsh-evolution-state": "^0.3.76",
41
+ "@lmzhen/dsh-evolution-policy": "^0.3.76"
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.73",
58
- "@lmzhen/dsh-evolution-core": "^0.3.73",
59
- "@lmzhen/dsh-evolution-curator": "^0.3.73",
60
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.73",
61
- "@lmzhen/dsh-evolution-state": "^0.3.73"
51
+ "@lmzhen/dsh-evolution-approval": "^0.3.76",
52
+ "@lmzhen/dsh-evolution-core": "^0.3.76",
53
+ "@lmzhen/dsh-evolution-curator": "^0.3.76",
54
+ "@lmzhen/dsh-evolution-plan-validator": "^0.3.76",
55
+ "@lmzhen/dsh-evolution-state": "^0.3.76"
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