@lmzhen/dsh-evolution-review 0.3.82 → 0.4.0

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
@@ -1,50 +1,54 @@
1
- # @deepseek-ai/dsh-evolution-review
1
+ # @lmzhen/dsh-evolution-review
2
2
 
3
- Background review orchestration
3
+ Background review orchestration: it latches when a conversation deserves a review (the cadence
4
+ and completion channels), builds the review request, and executes the writes the validated
5
+ plan calls for at conversation end. It registers no prompt section and no tool schema of its
6
+ own, and the delivery mode lives on the `evolution-policy` row, not here.
4
7
 
5
- ## Model Experience
8
+ ## Model surface
6
9
 
7
- ### Indirect model surface
8
-
9
- #### What the model sees
10
-
11
- `@deepseek-ai/dsh-evolution-review` registers no direct prompt or tool schema itself. Model-visible effects are owned by the packages that consume this service.
12
-
13
- #### Token effect
14
-
15
- Zero direct token effect from this package; consumers add any model-visible tokens.
16
-
17
- #### KV Cache effect
18
-
19
- Independent of request-prefix construction. This package does not alter the assembled prompt or tool list.
20
-
21
- ## Known Limitations and Deferred Work
10
+ - **Model-visible:** no prompt section and no tool schema of its own: what a session gains is the delivered review prompt and its result notices (the default `'inject'` mode); any other model-visible effect belongs to the packages that consume this service.
11
+ - **Prompt prefix / KV cache:** independent of request-prefix construction — it does not alter the assembled prompt or tool list; family-level rules: `packages/README.md` §"Model-visible prompt prefix and the KV cache".
12
+ - **Mount it?** yes — the `evolution-review` row, carried by the `evolution-host`, `evolution-all` and one-click `evolution-preset` bundles (the agent-preset delta carries only the four model rows).
22
13
 
14
+ ## Known limitations
23
15
 
24
16
  - 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.
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).
17
+ - 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).
26
18
  - 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.
27
19
  - 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.
28
- - Read-before-write tracks only reads through the `skill` tool. `skill_manage` has no per-skill read action (`list`/`review` are whole-library, not targeted at one name), so a skill that was only listed via `skill_manage` is not marked as read a background review may still reject a patch to it until it is actually loaded.
29
- - The completion-channel counters (`cumulativeToolCalls` / `completionInjected`) are in-memory only. A process restart resets them, which is accepted behavior: the completion review is a one-per-session post-task adaptation and a restart is treated as a fresh conversation boundary. The cadence state (`turnsSinceMemory` / `turnsSinceSkill`) is persisted via `ReviewState` and survives restart bounded by `REVIEW_STATE_SESSION_CAP` (500, seam constant): the least-recently-active sessions are pruned on save, so a very old session restarting resumes from a fresh cadence baseline rather than an unbounded store.
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
- - 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
- - 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)). A prompt delivered while a HUMAN message is already queued is not marked at all: the platform claims that human turn first, so a session-level window would attribute the user's own writes to `background_review` (S2-8, FLOW1-3) the plugin reads the pre-claim queue (`agent.inbox`) because the platform exposes no claim identity, and warns once per mount when it withholds the mark.
20
+ - Read-before-write tracks only reads through the `skill` tool. `skill_manage` has no per-skill read action (`list`/`review` are whole-library, not targeted at one name), so a skill that was only listed via `skill_manage` is not marked as read: a background review may still reject a patch to it until it is actually loaded.
21
+ - The completion-channel counters (`cumulativeToolCalls` / `completionInjected`) are in-memory only. A process restart resets them, which is accepted behavior: the completion review is a one-per-session post-task adaptation and a restart is treated as a fresh conversation boundary. The cadence state (`turnsSinceMemory` / `turnsSinceSkill`) is persisted via `ReviewState` and survives restart: bounded by `REVIEW_STATE_SESSION_CAP` (500, seam constant): the least-recently-active sessions are pruned on save, so a very old session restarting resumes from a fresh cadence baseline rather than an unbounded store.
22
+ - `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`.
23
+ - 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.
24
+ - 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.
25
+ - **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)).
26
+ - **`.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). A prompt delivered while a HUMAN message is already queued is not marked at all: the platform claims that human turn first, so a session-level window would attribute the user's own writes to `background_review` (S2-8, FLOW1-3): the plugin reads the pre-claim queue (`agent.inbox`) because the platform exposes no claim identity, and warns once per mount when it withholds the mark.
35
27
 
36
28
  ## Configuration
37
29
 
38
- `reviewProvider` selects the LLM provider for review subagents. When omitted, the subagent inherits the deployment default route instead of a hardcoded provider name. Model selection stays on the policy (`memoryReviewModel` / `skillReviewModel`).
39
-
40
- `reviewTimeoutMs` bounds each review subagent run (an `AbortSignal.timeout`; `0` aborts immediately) and the write leg that executes the plan. The deadline does not stop an abandoned write leg: an op that lands after it is reported to the model as its own notice (S2-10, FLOW1-6), while the `evolution/plan-applied` record stays the deadline snapshot, so late writes still have no plan-applied entry. The former `executionTimeoutMs` declaration was removed in v14 (nothing read it, so it was configuration that did nothing); use `reviewTimeoutMs`.
30
+ - `reviewProvider`: selects the LLM provider for review subagents; omitted by default, so the child inherits the deployment's default route. Model selection stays on the policy (`memoryReviewModel` / `skillReviewModel`).
31
+ - `reviewTimeoutMs`: bounds each review subagent run (an `AbortSignal.timeout`; `0` aborts immediately) and the write leg that executes the plan; it replaces the removed `executionTimeoutMs`. The deadline does not stop an abandoned write leg: an op that lands after it is reported to the model as its own notice (S2-10, FLOW1-6), while the `evolution/plan-applied` record stays the deadline snapshot, so late writes still have no plan-applied entry.
32
+ - `reviewMode` (`'inject'` is the default, `'subagent'` an explicit opt-in): see the delivery contract below; the subagent-only knobs (`reviewProvider`, `reviewTimeoutMs`, `reviewMaxDepth`, `reviewToolAllow`, the review models) are inert in inject mode.
33
+ - `reviewToolAllow`: the allow-list review subagents are spawned with (default `[skill]`).
34
+ - `reviewWakeInject` (default `true`): waking (`agent.followup`) delivery instead of the non-waking `agent.inject`.
35
+ - `skillReviewTrigger` (`'cadence'` default, or `'completion'` / `'both'`): gates only the completion channel.
41
36
 
42
- ### Review delivery contract (0.3.38-0.3.42)
37
+ ### Review delivery contract
43
38
 
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.
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).
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`); the suppression is bound to the turn the delivery WOKE a busy-period turn that started before the delivery keeps its own cadence and cannot consume it (S2-9, FLOW1-5: the platform's `turn/start` carries only `{ turn }`, so ordering is the binding identity); a restart clears the queue, so the loop cannot survive it.
47
- - **In-flight triggers coalesce, and the drain settles at delivery**: a trigger that arrives while a review is in flight is queued as ONE entry per (session, kind) last trigger wins so one window can never deliver two prompts for the same segment, and the next completed boundary starts a fresh run once the window closes (the settle wait is bounded; a handle the platform never settles after its abort is abandoned and reported as `evolution/review-error`). A drain delivery that fails restores that session's cadence latch (and, on the completion channel, its `completionInjected` flag), so the segment's review retries at the next completed boundary instead of vanishing with no trace (S2-7, FLOW1-2/1-4).
48
- - **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).
39
+ - **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.
40
+ - 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.
41
+ - **`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).
42
+ - **`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 (an injected review prompt alone must not re-trigger a review under `interval=1`): a delivery that appends suppresses ONE turn, and an inbox-replace re-armed wake suppresses the TWO turns it can produce (the refreshed-prompt turn, then the wake-stub turn queued behind it) because the platform claims one next-turn per driver round (PLAN-R2 P1-1, 2026-09-16); the suppression is bound to the turn(s) the delivery WOKE: a busy-period turn that started before the delivery keeps its own cadence and cannot consume it (S2-9, FLOW1-5: the platform's `turn/start` carries only `{ turn }`, so ordering is the binding identity); a restart clears the queue, so the loop cannot survive it.
43
+ - **In-flight triggers coalesce, and the drain settles at delivery**: a trigger that arrives while a review is in flight is queued as ONE entry per (session, kind): last trigger wins, so one window can never deliver two prompts for the same segment, and the next completed boundary starts a fresh run once the window closes (the settle wait is bounded: the RESULT watchdog's budget runs from the abort deadline (the full `reviewTimeoutMs` PLUS the 5 s settle margin, the sum capped at the platform timer ceiling), while the `run.dispose` arm point counts that margin alone, from the moment the result settled (PLAN S1.1 / P2-1 / P2-2, 0.3.83); a handle the platform never settles after its abort is abandoned and reported as `evolution/review-error`). A drain delivery that fails restores that session's cadence latch (and, on the completion channel, its `completionInjected` flag), so the segment's review retries at the next completed boundary instead of vanishing with no trace (S2-7, FLOW1-2/1-4). **Cost of a re-armed wake (review D2, 2026-09-16):** coalescing one wake-channel delivery adds ONE model-visible parent turn: the stub is queued behind the refreshed prompt as a second next-turn and the platform claims exactly one next-turn per driver round, so `turns: 2` suppresses the cadence fire of both rounds but cannot remove the round itself.
44
+ - **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).
49
45
 
50
46
  **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).
47
+
48
+ ## Notes and history
49
+
50
+ - Documented rather than worked around: recovering the child read set needs a `SubagentLike` contract change (v14 P2-6).
51
+ - The mark is in-memory, so a restart drops the window (v37 S2.2, plan decision (b)).
52
+ - The former `executionTimeoutMs` declaration was removed in v14 (nothing read it, so it was configuration that did nothing); use `reviewTimeoutMs`.
53
+ - The explicit `'inject'` mode's historical "immediate on threshold" contract was superseded in 0.3.39: both modes are end-of-conversation.
54
+ - Review delivery contract (0.3.38-0.3.42)
package/lib/index.js CHANGED
@@ -55,9 +55,13 @@ var SessionScopedState = class {
55
55
  * Background review orchestration: signal gate → one-shot subagent → trusted plan execution.
56
56
  * @module @lmzhen/dsh-evolution-review
57
57
  */
58
- /** S2-6 (FLOW1-1): how long a subagent handle may keep the in-flight window
59
- * open AFTER its own review timeout, before the review abandons it. Caps the
60
- * wait at the review timeout itself so a short (test) budget stays short. */
58
+ /** S2-6 (FLOW1-1): the settle-grace margin a subagent handle gets beyond its
59
+ * own deadline before the review abandons it. As the dispose watchdog's WHOLE
60
+ * budget it is capped by the review timeout so a short (test) budget stays
61
+ * short; the result watchdog adds it AFTER the full timeout (PLAN S1.1,
62
+ * 2026-09-16) and caps the sum at the timer ceiling (PLAN-R2 P2-1). The
63
+ * `run.dispose` arm point counts this margin ALONE — its clock starts after
64
+ * the result settled, not at the review timeout (PLAN-R2 P2-2, 2026-09-16). */
61
65
  const REVIEW_SETTLE_MARGIN_MS = 5e3;
62
66
  /** Error name marking the S2-6 watchdog expiry (see the catch in trySubagentReview). */
63
67
  const REVIEW_SETTLE_TIMEOUT = "ReviewSettleTimeout";
@@ -209,9 +213,10 @@ function apply(ctx, rawConfig = {}) {
209
213
  return;
210
214
  }
211
215
  if (event.type !== "turn/end") return;
212
- 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) {
216
+ 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 || lastTurnStart.size >= COUNTER_SWEEP_THRESHOLD) {
213
217
  const isAlive = (id) => ctx.agents.get(id) !== void 0;
214
218
  sweepDeadSessionEntries(turnStarts, isAlive);
219
+ sweepDeadSessionEntries(lastTurnStart, isAlive);
215
220
  sweepDeadSessionEntries(cumulativeToolCalls, isAlive);
216
221
  sweepDeadSessionEntries(completionInjected, isAlive);
217
222
  sweepDeadSessionEntries(pendingCadenceReviews, isAlive);
@@ -249,7 +254,11 @@ function apply(ctx, rawConfig = {}) {
249
254
  const snapshot = policy();
250
255
  const suppression = skipNextCadenceFire.get(session.id);
251
256
  const skipFire = suppression !== void 0 && event.data.turn > suppression.afterTurn;
252
- if (skipFire) skipNextCadenceFire.delete(session.id);
257
+ if (skipFire) if (suppression.turns === void 0 || suppression.turns <= 1) skipNextCadenceFire.delete(session.id);
258
+ else skipNextCadenceFire.set(session.id, {
259
+ afterTurn: suppression.afterTurn,
260
+ turns: suppression.turns - 1
261
+ });
253
262
  let state = {
254
263
  turnsSinceMemory: 0,
255
264
  turnsSinceSkill: 0,
@@ -446,17 +455,36 @@ function apply(ctx, rawConfig = {}) {
446
455
  summary
447
456
  }
448
457
  });
458
+ const wake = agent;
449
459
  const inbox = agent.inbox;
450
460
  if (inbox !== void 0 && typeof inbox.replace === "function") try {
451
- const superseded = [...inbox.nextTurn ?? [], ...inbox.nextStep ?? []].find((row) => isSameKindPending(row, summary));
461
+ const supersededTurnRow = (inbox.nextTurn ?? []).find((row) => isSameKindPending(row, summary));
462
+ const superseded = supersededTurnRow ?? (inbox.nextStep ?? []).find((row) => isSameKindPending(row, summary));
452
463
  if (superseded !== void 0 && inbox.replace(superseded.id, message)) {
453
464
  if (reviewPrompt) markReviewChannelForDelivery(agent, inbox);
465
+ if (supersededTurnRow !== void 0 && typeof wake.followup === "function") {
466
+ wake.followup(createUserMessage({
467
+ content: [{
468
+ type: "text",
469
+ text: `[${summary}] the queued prompt ahead of this notice was refreshed in place; that copy is the current request. If this notice reaches a turn on its own, the review turn already ran — no action is needed.`
470
+ }],
471
+ source: {
472
+ kind: "plugin",
473
+ plugin: "dsh-evolution-review",
474
+ form: "notice",
475
+ summary: `${summary} (wake)`
476
+ }
477
+ }));
478
+ skipNextCadenceFire.set(agent.session.id, {
479
+ afterTurn: lastTurnStart.get(agent.session.id) ?? -1,
480
+ turns: 2
481
+ });
482
+ }
454
483
  return true;
455
484
  }
456
485
  } catch (error) {
457
486
  ctx.logger.warn(`dsh-evolution-review: inbox coalescing failed (${error instanceof Error ? error.message : String(error)}) — delivering a fresh message instead`);
458
487
  }
459
- const wake = agent;
460
488
  try {
461
489
  if (config.reviewWakeInject && typeof wake.followup === "function") {
462
490
  wake.followup(message);
@@ -469,14 +497,23 @@ function apply(ctx, rawConfig = {}) {
469
497
  return false;
470
498
  }
471
499
  };
472
- const settleBudgetMs = () => Math.min(config.reviewTimeoutMs, REVIEW_SETTLE_MARGIN_MS);
473
- const withSettleWatchdog = (promise, label) => new Promise((resolve, reject) => {
474
- const ms = settleBudgetMs();
500
+ let settleBudgetCapWarned = false;
501
+ const resultSettleBudgetMs = () => {
502
+ const budget = config.reviewTimeoutMs + Math.min(REVIEW_SETTLE_MARGIN_MS, config.reviewTimeoutMs);
503
+ if (budget <= MAX_TIMER_DELAY_MS) return budget;
504
+ if (!settleBudgetCapWarned) {
505
+ settleBudgetCapWarned = true;
506
+ ctx.logger.warn(`dsh-evolution-review: reviewTimeoutMs ${config.reviewTimeoutMs}ms plus the settle margin exceeds the 32-bit timer delay ceiling (${MAX_TIMER_DELAY_MS}ms) — the settle watchdog arms at the ceiling`);
507
+ }
508
+ return MAX_TIMER_DELAY_MS;
509
+ };
510
+ const disposeSettleBudgetMs = () => Math.min(REVIEW_SETTLE_MARGIN_MS, config.reviewTimeoutMs);
511
+ const withSettleWatchdog = (promise, label, budgetMs, overdue) => new Promise((resolve, reject) => {
475
512
  const timer = setTimeout(() => {
476
- const error = /* @__PURE__ */ new Error(`dsh-evolution-review: ${label} did not settle within ${ms}ms of the review timeout — abandoning the handle`);
513
+ const error = /* @__PURE__ */ new Error(`dsh-evolution-review: ${label} ${overdue(budgetMs)} — abandoning the handle`);
477
514
  error.name = REVIEW_SETTLE_TIMEOUT;
478
515
  reject(error);
479
- }, ms);
516
+ }, budgetMs);
480
517
  promise.then((value) => {
481
518
  clearTimeout(timer);
482
519
  resolve(value);
@@ -576,7 +613,7 @@ function apply(ctx, rawConfig = {}) {
576
613
  outputSchema: REVIEW_OUTPUT_SCHEMA
577
614
  });
578
615
  try {
579
- const result = await withSettleWatchdog(run.result, "subagent review result");
616
+ const result = await withSettleWatchdog(run.result, "subagent review result", resultSettleBudgetMs(), (total) => `did not settle within ${Math.max(0, total - config.reviewTimeoutMs)}ms after the review timeout (watchdog total ${total}ms)`);
580
617
  if (!result.structured) {
581
618
  try {
582
619
  ctx.emit("evolution/review-error", { sessionId: session.id });
@@ -667,7 +704,7 @@ function apply(ctx, rawConfig = {}) {
667
704
  return true;
668
705
  } finally {
669
706
  try {
670
- await withSettleWatchdog(run.dispose(), "subagent dispose");
707
+ await withSettleWatchdog(run.dispose(), "subagent dispose", disposeSettleBudgetMs(), (total) => `did not settle within ${total}ms`);
671
708
  } catch (disposeError) {
672
709
  ctx.logger.warn(`dsh-evolution-review: subagent dispose failed: ${disposeError instanceof Error ? disposeError.message : String(disposeError)}`);
673
710
  }
@@ -1095,11 +1132,27 @@ function renderToolResultLine(data) {
1095
1132
  const output = resultBlocks.map((block) => Array.isArray(block.content) ? block.content.map((inner) => inner.type === "text" && typeof inner.text === "string" ? inner.text : "").join(" ") : typeof block.text === "string" ? block.text : "").join(" ").trim();
1096
1133
  return `[result]${shape?.error || resultBlocks.some((block) => block.isError === true) ? " [ERROR]" : ""} ${output.slice(0, 500)}`;
1097
1134
  }
1135
+ /**
1136
+ * PLAN S4.1 (2026-09-16, audit P2-12): text of one persisted content block,
1137
+ * or `''` for any other shape. Content blocks cross the durable session-log
1138
+ * boundary, so their runtime shape is `unknown` even where the static type
1139
+ * promises `{ type, text }` — a persisted `content: [null]` (the A2-7 shape)
1140
+ * used to TypeError in buildReviewRequest and the caller's catch dropped the
1141
+ * whole subagent review leg. This mirrors evolution-core signals.ts's private
1142
+ * `textOfBlock` (same guard, same rationale); it is not imported because core
1143
+ * keeps that helper module-private, and this file's other block renderer
1144
+ * (renderToolResultLine) guards its own inner-block shapes inline.
1145
+ */
1146
+ function textOfPersistedBlock(block) {
1147
+ if (block === null || typeof block !== "object") return "";
1148
+ const candidate = block;
1149
+ return candidate.type === "text" && typeof candidate.text === "string" ? candidate.text : "";
1150
+ }
1098
1151
  function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars) {
1099
1152
  const messages = [];
1100
1153
  const surface = session.deriveMessages();
1101
1154
  for (const message of surface.slice(-maxMessages)) if (message.role === "user" || message.role === "assistant") {
1102
- const text = message.content.map((block) => block.type === "text" ? block.text : "").join(" ").trim();
1155
+ const text = message.content.map(textOfPersistedBlock).join(" ").trim();
1103
1156
  if (text) messages.push(`${message.role.toUpperCase()}: ${text.slice(0, maxMessageChars)}`);
1104
1157
  }
1105
1158
  const toolLines = [];
@@ -1132,4 +1185,4 @@ function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars)
1132
1185
  ].join("\n");
1133
1186
  }
1134
1187
  //#endregion
1135
- export { Config, REVIEW_OUTPUT_SCHEMA, apply, clampReviewConfig, filterUnreadSkillOps, inject, name, renderToolResultLine, shouldCompletionReview, sweepDeadSessionEntries };
1188
+ export { Config, REVIEW_OUTPUT_SCHEMA, apply, buildReviewRequest, clampReviewConfig, filterUnreadSkillOps, inject, name, renderToolResultLine, shouldCompletionReview, sweepDeadSessionEntries };
@@ -4,6 +4,8 @@
4
4
  */
5
5
  import type { Context } from '@deepseek-ai/cordis';
6
6
  import z from '@deepseek-ai/schemastery';
7
+ import type { Session } from '@deepseek-ai/dsh-session';
8
+ import { type ReviewKind } from '@lmzhen/dsh-evolution-core';
7
9
  export declare const name = "evolution-review";
8
10
  export declare const inject: string[];
9
11
  export interface Config {
@@ -162,5 +164,10 @@ export declare function filterUnreadSkillOps(ops: Array<{
162
164
  * buildReviewRequest and is unchanged).
163
165
  */
164
166
  export declare function renderToolResultLine(data: unknown): string;
167
+ export declare function buildReviewRequest(session: Session, kind: ReviewKind, signal: {
168
+ toolCalls: number;
169
+ userChars: number;
170
+ assistantChars: number;
171
+ }, maxMessages: number, maxMessageChars: number): string;
165
172
  export {};
166
173
  //# sourceMappingURL=index.d.ts.map
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.82",
4
+ "version": "0.4.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -27,9 +27,9 @@
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
29
  "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-approval": "^0.3.82",
31
- "@lmzhen/dsh-evolution-core": "^0.3.82",
32
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.82"
30
+ "@lmzhen/dsh-evolution-approval": "^0.4.0",
31
+ "@lmzhen/dsh-evolution-core": "^0.4.0",
32
+ "@lmzhen/dsh-evolution-plan-validator": "^0.4.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@deepseek-ai/cordis": "^4.0.1",
@@ -37,8 +37,8 @@
37
37
  "@deepseek-ai/dsh-llm": "^0.1.5-rc.2",
38
38
  "@deepseek-ai/dsh-session": "^0.1.5-rc.2",
39
39
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
40
- "@lmzhen/dsh-evolution-state": "^0.3.82",
41
- "@lmzhen/dsh-evolution-policy": "^0.3.82"
40
+ "@lmzhen/dsh-evolution-state": "^0.4.0",
41
+ "@lmzhen/dsh-evolution-policy": "^0.4.0"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-agent": "^0.1.5-rc.2",
@@ -48,10 +48,10 @@
48
48
  "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.2",
49
49
  "@deepseek-ai/dsh-session-persistence-jsonl": "^0.1.5-rc.2",
50
50
  "@deepseek-ai/dsh-tools": "^0.1.5-rc.2",
51
- "@lmzhen/dsh-evolution-approval": "^0.3.82",
52
- "@lmzhen/dsh-evolution-core": "^0.3.82",
53
- "@lmzhen/dsh-evolution-curator": "^0.3.82",
54
- "@lmzhen/dsh-evolution-plan-validator": "^0.3.82",
55
- "@lmzhen/dsh-evolution-state": "^0.3.82"
51
+ "@lmzhen/dsh-evolution-approval": "^0.4.0",
52
+ "@lmzhen/dsh-evolution-core": "^0.4.0",
53
+ "@lmzhen/dsh-evolution-curator": "^0.4.0",
54
+ "@lmzhen/dsh-evolution-plan-validator": "^0.4.0",
55
+ "@lmzhen/dsh-evolution-state": "^0.4.0"
56
56
  }
57
57
  }