@lmzhen/dsh-evolution-review 0.17.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@ own, and the delivery mode lives on the `evolution-policy` row, not here.
13
13
 
14
14
  ## Known limitations
15
15
 
16
+ - **The autonomous write leg does not pass `tools.guard`.** `executePlan` calls the service seam directly (`memory.applyBatch`, `library.*`), so the guard `evolution-threat` installs on the TOOL channel never sees these writes (`grep "tools.execute(" src` = 0 by design). The scan still happens one layer down: `MemoryStore` and `SkillLibrary` refuse threat-shaped payloads at the write point through core's own pattern table, which is the same table the guard consumes. A deployment that wants the tool channel's guard wording on a plan write should route it through a tool — nothing in the plan path claims that coverage.
16
17
  - 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.
17
18
  - 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).
18
19
  - 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.
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import z from "@deepseek-ai/schemastery";
3
3
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
4
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, filterUnreadSkillOps, filterUnreadSkillOps as filterUnreadSkillOps$1, markReviewChannel, newSkillLibrary, paramRowId, policyStageLimits, readDispatchSignal, redactSecrets, resolveOrigins, resolveRootConfig, reviewPrompt, sessionAudited, sessionEvidenceIndex, sessionReadNames, sessionTurnSignals, sweepReviewChannelSessions, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
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, clampOnce, clearReviewChannel, contentHash, evolutionIoAdapter, filterUnreadSkillOps, filterUnreadSkillOps as filterUnreadSkillOps$1, foldUntrustedLines, markReviewChannel, newSkillLibrary, paramRowId, payloadText, pickWithPolicy, policyStageLimits, readDispatchSignal, redactSecrets, resolveOrigins, resolveRootConfig, reviewPrompt, sessionAudited, sessionEvidenceIndex, sessionReadNames, sessionTurnSignals, sweepReviewChannelSessions, userSetKeys, verifyPromptBundle } from "@lmzhen/dsh-evolution-core";
6
6
  import { validateEvolutionPlan } from "@lmzhen/dsh-evolution-plan-validator";
7
7
  /**
8
8
  * Whether a message is a notice this plugin delivered, judged only by durable source fields.
@@ -15,7 +15,8 @@ import { validateEvolutionPlan } from "@lmzhen/dsh-evolution-plan-validator";
15
15
  function isReviewNotice(message) {
16
16
  const source = message.source;
17
17
  if (source?.form !== "notice") return false;
18
- return source.kind === "evolution-review" || source.kind === "plugin:dsh-evolution-review";
18
+ if (source.kind !== "evolution-review" && source.kind !== "plugin:dsh-evolution-review") return false;
19
+ return source.summary === void 0 || !source.summary.startsWith("self-improvement review");
19
20
  }
20
21
  /**
21
22
  * Fold one inbox event into the session's outstanding notice.
@@ -115,6 +116,23 @@ var SessionScopedState = class {
115
116
  * Background review orchestration: signal gate → one-shot subagent → trusted plan execution.
116
117
  * @module @lmzhen/dsh-evolution-review
117
118
  */
119
+ /** Emit one `evolution/review-scheduled` inside its OWN protection domain (V8-03, V5-19③/F-334).
120
+ *
121
+ * Every delivery path must enter that domain, and five hand-copied try/emit/catch blocks had
122
+ * already drifted twice: V24-15 had to add the emit to two injection paths that never had it, and
123
+ * V25-02 fixed a cross-session attribution bug that lived in one of the copies. The fields, the
124
+ * channel and the domain now travel together.
125
+ * @param ctx - the plugin context the event is emitted on.
126
+ * @param event - the payload. `sessionId` is the session the review was scheduled FOR — on the
127
+ * deferred drain that is the entry's session, never the in-flight review's.
128
+ */
129
+ function emitReviewScheduled(ctx, event) {
130
+ try {
131
+ ctx.emit("evolution/review-scheduled", event);
132
+ } catch (emitError) {
133
+ ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
134
+ }
135
+ }
118
136
  /** S2-6 (FLOW1-1): the settle-grace margin a subagent handle gets beyond its
119
137
  * own deadline before the review abandons it. As the dispose watchdog's WHOLE
120
138
  * budget it is capped by the review timeout so a short (test) budget stays
@@ -176,37 +194,6 @@ const REVIEW_OUTPUT_SCHEMA = {
176
194
  */
177
195
  const cadenceSummary = (kind) => `auto-review:${kind}`;
178
196
  /**
179
- * G3.1 (0.3.23): clamp one numeric review knob so a 0/negative/NaN/±Infinity value
180
- * falls back to the package default instead of folding as a "disabled" special value
181
- * (a 0 interval would fire a review every turn; NaN folds as NaN into the
182
- * cadence/timeout). The schema `.min(1)` guards the loader path; this clamp also
183
- * covers NaN/±Infinity (which schemastery lets a bare number schema through) and
184
- * programmatic assembly. `reviewMaxDepth` clamps to at least 1 because 0 is the
185
- * historical 0.3.1 maximum-depth defect (a 0 rejects the spawn outright). The warning
186
- * fires once per key: a live field is read at every use, so an assembly-time list
187
- * would repeat on each read.
188
- * @param warned - the keys this caller already warned about.
189
- * @param warn - the warning sink.
190
- * @param name - the config key, for the warning.
191
- * @param value - the value as supplied, or its volatile reference.
192
- * @param fallback - the package default a corrected value falls back to.
193
- * @param min - the smallest value the engine can act on.
194
- * @param max - the largest value the carrier can hold, when bounded.
195
- * @returns the value the engine may use.
196
- */
197
- function clampField(warned, warn, name, value, fallback, min, max) {
198
- const current = typeof value === "object" ? value.get() : value;
199
- const result = clampedNumber(current, fallback, max === void 0 ? { min } : {
200
- min,
201
- max
202
- });
203
- if (current !== void 0 && result !== current && !warned.has(name)) {
204
- warned.add(name);
205
- warn(`${name} provided an invalid value; falling back to the default`);
206
- }
207
- return result;
208
- }
209
- /**
210
197
  * The DEPLOYMENT layer of this row, clamped once at assembly: the restart-only
211
198
  * numerics (registry tier E2) whose consumers read them from this snapshot. The
212
199
  * seven E3 keys are NOT resolved here — they are live (G1 §8.3), and
@@ -220,7 +207,7 @@ function clampReviewConfig(rawConfig, ctx) {
220
207
  const warn = (message) => {
221
208
  ctx.logger.warn("dsh-evolution-review: " + message);
222
209
  };
223
- const field = (name, value, fallback, min, max) => clampField(warned, warn, name, value, fallback, min, max);
210
+ const field = (name, value, fallback, min, max) => clampOnce(warned, warn, name, value, fallback, min, max);
224
211
  return Object.assign({}, rawConfig, {
225
212
  reviewTimeoutMs: field("reviewTimeoutMs", rawConfig.reviewTimeoutMs, DEFAULT_REVIEW_TIMEOUT_MS, 1, MAX_TIMER_DELAY_MS),
226
213
  reviewContextMessages: field("reviewContextMessages", rawConfig.reviewContextMessages, DEFAULT_REVIEW_CONTEXT_MESSAGES, 1),
@@ -244,10 +231,10 @@ function policySnapshotOf(source) {
244
231
  function resolveReviewRowSettings(rawConfig, warn) {
245
232
  const warned = /* @__PURE__ */ new Set();
246
233
  return {
247
- reviewSkillInterval: clampField(warned, warn, "reviewSkillInterval", rawConfig.reviewSkillInterval?.get() ?? rawConfig.skillInterval, DEFAULT_REVIEW_SKILL_INTERVAL, 1),
248
- reviewMemoryInterval: clampField(warned, warn, "reviewMemoryInterval", rawConfig.reviewMemoryInterval?.get() ?? rawConfig.memoryInterval, DEFAULT_REVIEW_MEMORY_INTERVAL, 1),
234
+ reviewSkillInterval: clampOnce(warned, warn, "reviewSkillInterval", rawConfig.reviewSkillInterval?.get() ?? rawConfig.skillInterval, DEFAULT_REVIEW_SKILL_INTERVAL, 1),
235
+ reviewMemoryInterval: clampOnce(warned, warn, "reviewMemoryInterval", rawConfig.reviewMemoryInterval?.get() ?? rawConfig.memoryInterval, DEFAULT_REVIEW_MEMORY_INTERVAL, 1),
249
236
  skillReviewTrigger: rawConfig.skillReviewTrigger?.get() ?? DEFAULT_SKILL_REVIEW_TRIGGER,
250
- skillReviewCompletionMinToolCalls: clampField(warned, warn, "skillReviewCompletionMinToolCalls", rawConfig.skillReviewCompletionMinToolCalls, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, 1),
237
+ skillReviewCompletionMinToolCalls: clampOnce(warned, warn, "skillReviewCompletionMinToolCalls", rawConfig.skillReviewCompletionMinToolCalls, DEFAULT_SKILL_REVIEW_COMPLETION_MIN_TOOL_CALLS, 1),
251
238
  reviewEnabled: rawConfig.reviewEnabled?.get() ?? true,
252
239
  reviewMode: rawConfig.reviewMode?.get() ?? "inject",
253
240
  reviewWakeInject: rawConfig.reviewWakeInject?.get() ?? true
@@ -257,7 +244,7 @@ function apply(ctx, rawConfig = {}) {
257
244
  ctx.inject(["settings"], (injected) => {
258
245
  const settings = injected.settings;
259
246
  if (typeof settings?.configure !== "function") return;
260
- const disposer = settings.configure({ auto: false });
247
+ const disposer = settings.configure({ auto: false }, ctx.fiber);
261
248
  if (typeof disposer === "function") ctx.effect(() => disposer, "dsh-evolution-review: settings presentation");
262
249
  });
263
250
  if (!verifyPromptBundle(PROMPT_BUNDLE)) throw new Error("dsh-evolution prompt bundle integrity check failed; refusing to schedule review work");
@@ -285,25 +272,11 @@ function apply(ctx, rawConfig = {}) {
285
272
  const rowSettings = () => resolveReviewRowSettings(rawConfig, (message) => {
286
273
  ctx.logger.warn("dsh-evolution-review: " + message);
287
274
  });
288
- /**
289
- * The keys the user set in this row's namespace (G1 §8.4-A). This is the ONLY
290
- * remaining read of the settings user layer, and it reads KEY NAMES, never values:
291
- * the platform resolves the user layer into the row's live fields, so the value
292
- * comes from `rowSettings()`.
293
- * @returns the keys present in this row's user layer.
294
- */
295
- const userSetKeys = () => {
296
- const entry = ctx.get("settings")?.describe?.({ redactSecrets: false }).find((item) => item.ns === paramRowId("evolution-review"));
297
- return new Set(Object.keys(entry?.user ?? {}));
298
- };
299
275
  const params = () => {
300
276
  const row = rowSettings();
301
- const userSet = userSetKeys();
277
+ const userSet = userSetKeys(ctx, paramRowId("evolution-review"));
302
278
  const snapshot = policy();
303
- const pick = (key) => {
304
- if (userSet.has(key)) return row[key];
305
- return snapshot?.[key] ?? row[key];
306
- };
279
+ const pick = (key) => pickWithPolicy(userSet, row, snapshot, key);
307
280
  const mode = () => {
308
281
  if (userSet.has("reviewMode")) return row.reviewMode;
309
282
  return snapshot?.reviewMode ?? row.reviewMode;
@@ -335,7 +308,7 @@ function apply(ctx, rawConfig = {}) {
335
308
  ["reviewMode", void 0],
336
309
  ["reviewMemoryInterval", "memoryInterval"],
337
310
  ["reviewSkillInterval", "skillInterval"]
338
- ].filter(([id, alias]) => !userSetKeys().has(id) && rowSupplied(id, alias)).map(([id]) => id);
311
+ ].filter(([id, alias]) => !userSetKeys(ctx, paramRowId("evolution-review")).has(id) && rowSupplied(id, alias)).map(([id]) => id);
339
312
  if (shadowedRowFields.length > 0) {
340
313
  let shadowWarned = false;
341
314
  const warnShadowed = () => {
@@ -367,7 +340,7 @@ function apply(ctx, rawConfig = {}) {
367
340
  return;
368
341
  }
369
342
  if (event.type !== "turn/end") return;
370
- if (cumulativeToolCalls.size >= COUNTER_SWEEP_THRESHOLD || dispatchDigest.size >= COUNTER_SWEEP_THRESHOLD || pendingCadenceWarned.size >= COUNTER_SWEEP_THRESHOLD || cumulativeToolCalls.size >= COUNTER_SWEEP_THRESHOLD || completionInjected.size >= COUNTER_SWEEP_THRESHOLD || pendingCadenceReviews.size >= COUNTER_SWEEP_THRESHOLD || cadenceResetWarned.size >= COUNTER_SWEEP_THRESHOLD || pendingReviewNotices.size >= COUNTER_SWEEP_THRESHOLD) {
343
+ if (cumulativeToolCalls.size >= COUNTER_SWEEP_THRESHOLD || dispatchDigest.size >= COUNTER_SWEEP_THRESHOLD || completionInjected.size >= COUNTER_SWEEP_THRESHOLD || pendingCadenceReviews.size >= COUNTER_SWEEP_THRESHOLD || pendingCadenceWarned.size >= COUNTER_SWEEP_THRESHOLD || cadenceResetWarned.size >= COUNTER_SWEEP_THRESHOLD || pendingReviewNotices.size >= COUNTER_SWEEP_THRESHOLD) {
371
344
  const isAlive = (id) => ctx.agents.get(id) !== void 0;
372
345
  sweepDeadSessionEntries(cumulativeToolCalls, isAlive);
373
346
  sweepDeadSessionEntries(dispatchDigest, isAlive);
@@ -503,33 +476,25 @@ function apply(ctx, rawConfig = {}) {
503
476
  pendingCadenceReviews.set(session.id, pendingKind);
504
477
  return;
505
478
  }
506
- try {
507
- ctx.emit("evolution/review-scheduled", {
479
+ emitReviewScheduled(ctx, {
480
+ sessionId: session.id,
481
+ kind: pendingKind,
482
+ toolCalls: signal.toolCalls,
483
+ userChars: signal.userChars,
484
+ assistantChars: signal.assistantChars,
485
+ channel: "inject"
486
+ });
487
+ } else {
488
+ const reviewOutcome = await trySubagentReview(session, agent, pendingKind, signal);
489
+ if (reviewOutcome === true) {
490
+ emitReviewScheduled(ctx, {
508
491
  sessionId: session.id,
509
492
  kind: pendingKind,
510
493
  toolCalls: signal.toolCalls,
511
494
  userChars: signal.userChars,
512
495
  assistantChars: signal.assistantChars,
513
- channel: "inject"
496
+ channel: "subagent"
514
497
  });
515
- } catch (emitError) {
516
- ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
517
- }
518
- } else {
519
- const reviewOutcome = await trySubagentReview(session, agent, pendingKind, signal);
520
- if (reviewOutcome === true) {
521
- try {
522
- ctx.emit("evolution/review-scheduled", {
523
- sessionId: session.id,
524
- kind: pendingKind,
525
- toolCalls: signal.toolCalls,
526
- userChars: signal.userChars,
527
- assistantChars: signal.assistantChars,
528
- channel: "subagent"
529
- });
530
- } catch (emitError) {
531
- ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
532
- }
533
498
  await settleNotice(session.id, "subagent-flush");
534
499
  } else if (reviewOutcome === "dropped") {
535
500
  pendingCadenceReviews.set(session.id, pendingKind);
@@ -539,18 +504,14 @@ function apply(ctx, rawConfig = {}) {
539
504
  pendingCadenceReviews.set(session.id, pendingKind);
540
505
  return;
541
506
  }
542
- try {
543
- ctx.emit("evolution/review-scheduled", {
544
- sessionId: session.id,
545
- kind: pendingKind,
546
- toolCalls: signal.toolCalls,
547
- userChars: signal.userChars,
548
- assistantChars: signal.assistantChars,
549
- channel: "inject"
550
- });
551
- } catch (emitError) {
552
- ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
553
- }
507
+ emitReviewScheduled(ctx, {
508
+ sessionId: session.id,
509
+ kind: pendingKind,
510
+ toolCalls: signal.toolCalls,
511
+ userChars: signal.userChars,
512
+ assistantChars: signal.assistantChars,
513
+ channel: "inject"
514
+ });
554
515
  }
555
516
  }
556
517
  return;
@@ -590,18 +551,14 @@ function apply(ctx, rawConfig = {}) {
590
551
  completionInjected.delete(session.id);
591
552
  return;
592
553
  }
593
- try {
594
- ctx.emit("evolution/review-scheduled", {
595
- sessionId: session.id,
596
- kind: "skill",
597
- toolCalls: signal.toolCalls,
598
- userChars: signal.userChars,
599
- assistantChars: signal.assistantChars,
600
- channel: "completion"
601
- });
602
- } catch (emitError) {
603
- ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
604
- }
554
+ emitReviewScheduled(ctx, {
555
+ sessionId: session.id,
556
+ kind: "skill",
557
+ toolCalls: signal.toolCalls,
558
+ userChars: signal.userChars,
559
+ assistantChars: signal.assistantChars,
560
+ channel: "completion"
561
+ });
605
562
  }
606
563
  /** V7-03 (0.3.42): shared waking delivery — review prompts AND result
607
564
  * notices go through the same followup-first channel (skip the woken turn's
@@ -734,6 +691,18 @@ function apply(ctx, rawConfig = {}) {
734
691
  reject(error instanceof Error ? error : new Error(String(error)));
735
692
  });
736
693
  });
694
+ /**
695
+ * T3-09/A37: the ONE abort channel for this fiber's long work.
696
+ *
697
+ * The review's two legs (the review subagent, and the write leg that executes its plan) were
698
+ * bounded only by their own timeouts, so an unloaded or HMR-replaced fiber kept running — and the
699
+ * write leg kept WRITING — while the replacement instance knew nothing about it (its single-flight
700
+ * flag and min-idle window do not cover a run that no longer belongs to any live fiber). The
701
+ * disposer below aborts this controller; the subagent leg merges it with its own deadline through
702
+ * `AbortSignal.any` (the shape evolution-maintenance's scan already uses) and the write leg checks
703
+ * it at every op boundary — before the first op, so an aborted fiber lands nothing.
704
+ */
705
+ const fiberAbort = new AbortController();
737
706
  const withTimeout = (promise, ms, label) => new Promise((resolve, reject) => {
738
707
  const timer = setTimeout(() => {
739
708
  reject(/* @__PURE__ */ new Error(`dsh-evolution-review: ${label} timed out after ${ms}ms`));
@@ -818,7 +787,7 @@ function apply(ctx, rawConfig = {}) {
818
787
  text: reviewText
819
788
  }],
820
789
  parent: agent,
821
- signal: AbortSignal.timeout(config.reviewTimeoutMs),
790
+ signal: AbortSignal.any([AbortSignal.timeout(config.reviewTimeoutMs), fiberAbort.signal]),
822
791
  maxDepth: config.reviewMaxDepth,
823
792
  agentOptions,
824
793
  persona: reviewPrompt(kind, "plan"),
@@ -887,7 +856,7 @@ function apply(ctx, rawConfig = {}) {
887
856
  };
888
857
  let executed;
889
858
  try {
890
- executed = await withTimeout(executePlan(validation.accepted, session, noteLanded, preRunHashes), config.reviewTimeoutMs, "review plan execution");
859
+ executed = await withTimeout(executePlan(validation.accepted, session, noteLanded, preRunHashes, fiberAbort.signal), config.reviewTimeoutMs, "review plan execution");
891
860
  } catch (error) {
892
861
  deadlineMissed = true;
893
862
  emitApplied({
@@ -943,22 +912,18 @@ function apply(ctx, rawConfig = {}) {
943
912
  else pendingCadenceReviews.set(entrySession, waitingKind);
944
913
  continue;
945
914
  }
946
- try {
947
- ctx.emit("evolution/review-scheduled", {
948
- sessionId: entrySession,
949
- kind: waitingKind,
950
- toolCalls: entryCounts.toolCalls,
951
- userChars: entryCounts.userChars,
952
- assistantChars: entryCounts.assistantChars,
953
- channel
954
- });
955
- } catch (emitError) {
956
- ctx.logger.warn(`dsh-evolution-review: review-scheduled emit failed: ${emitError instanceof Error ? emitError.message : String(emitError)}`);
957
- }
915
+ emitReviewScheduled(ctx, {
916
+ sessionId: entrySession,
917
+ kind: waitingKind,
918
+ toolCalls: entryCounts.toolCalls,
919
+ userChars: entryCounts.userChars,
920
+ assistantChars: entryCounts.assistantChars,
921
+ channel
922
+ });
958
923
  }
959
924
  }
960
925
  }
961
- async function executePlan(plan, session, onLanded, preRunHashes) {
926
+ async function executePlan(plan, session, onLanded, preRunHashes, signal) {
962
927
  const sessionId = session?.id;
963
928
  const memory = ctx.get("memory");
964
929
  const approval = ctx.get("evolutionApproval");
@@ -976,6 +941,16 @@ function apply(ctx, rawConfig = {}) {
976
941
  let ok = true;
977
942
  try {
978
943
  for (const op of plan.memoryOps ?? []) {
944
+ if (signal?.aborted === true) {
945
+ const reason = "the review was unloaded mid-plan";
946
+ ctx.logger.warn(`dsh-evolution-review: executePlan stopped at an op boundary after ${actions.length} op(s): ${reason}`);
947
+ return {
948
+ actions,
949
+ ok: false,
950
+ failedOps,
951
+ aborted: reason
952
+ };
953
+ }
979
954
  if (!Array.isArray(op.evidence) || op.evidence.length === 0) {
980
955
  ok = false;
981
956
  failedOps.push(`memory ${op.action ?? "add"} ${op.target}: missing evidence (defense-in-depth rejection)`);
@@ -984,7 +959,7 @@ function apply(ctx, rawConfig = {}) {
984
959
  const normalized = {
985
960
  target: op.target === "user" ? "user" : "memory",
986
961
  action: op.action ?? "add",
987
- facts: op.facts ?? op.content,
962
+ facts: payloadText(op),
988
963
  old_text: op.old_text
989
964
  };
990
965
  const result = approval ? await runApproved("memory", `memory ${normalized.target} ${normalized.action}`, normalized, normalized, session) : await memory?.applyBatch(normalized.target, [normalized]);
@@ -997,6 +972,16 @@ function apply(ctx, rawConfig = {}) {
997
972
  }
998
973
  }
999
974
  for (const op of plan.skillOps ?? []) {
975
+ if (signal?.aborted === true) {
976
+ const reason = "the review was unloaded mid-plan";
977
+ ctx.logger.warn(`dsh-evolution-review: executePlan stopped at an op boundary after ${actions.length} op(s): ${reason}`);
978
+ return {
979
+ actions,
980
+ ok: false,
981
+ failedOps,
982
+ aborted: reason
983
+ };
984
+ }
1000
985
  if (!Array.isArray(op.evidence) || op.evidence.length === 0 || !op.name) {
1001
986
  ok = false;
1002
987
  failedOps.push(`skill ${op.action ?? "patch"} ${op.name ?? "<unnamed>"}: missing evidence or name (defense-in-depth rejection)`);
@@ -1093,7 +1078,7 @@ function apply(ctx, rawConfig = {}) {
1093
1078
  if (!memory?.applyBatch) return void 0;
1094
1079
  return await memory.applyBatch(op.target === "user" ? "user" : "memory", [{
1095
1080
  action: op.action ?? "add",
1096
- facts: op.facts ?? op.content,
1081
+ facts: payloadText(op),
1097
1082
  old_text: op.old_text
1098
1083
  }]);
1099
1084
  }
@@ -1185,6 +1170,7 @@ function apply(ctx, rawConfig = {}) {
1185
1170
  }
1186
1171
  }
1187
1172
  ctx.effect(() => () => {
1173
+ fiberAbort.abort();
1188
1174
  sessionState.dispose();
1189
1175
  }, "dsh-evolution-review.cleanup");
1190
1176
  }
@@ -1340,8 +1326,8 @@ function renderToolResultLine(data, identity) {
1340
1326
  output = "";
1341
1327
  failed = false;
1342
1328
  }
1343
- const head = identity === void 0 ? "" : `${identity.name} ${identity.argsRaw.slice(0, 200)} → `;
1344
- return `[result]${failed ? " [ERROR]" : ""} ${head}${output.slice(0, 500)}`;
1329
+ const head = identity === void 0 ? "" : `${foldUntrustedLines(identity.name, 0)} ${foldUntrustedLines(identity.argsRaw, 200)} → `;
1330
+ return `[result]${failed ? " [ERROR]" : ""} ${head}${foldUntrustedLines(output, 500)}`;
1345
1331
  }
1346
1332
  /** Text of a PTC settle `content` payload (the logged ContentBlock list):
1347
1333
  * text blocks and plain strings joined, anything else skipped. */
@@ -1375,10 +1361,10 @@ function textOfPersistedBlock(block) {
1375
1361
  }
1376
1362
  function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars, dispatchTail = []) {
1377
1363
  const messages = [];
1378
- const surface = session.deriveMessages();
1379
- for (const message of surface.slice(-maxMessages)) if (message.role === "user" || message.role === "assistant") {
1364
+ const eligible = session.deriveMessages().filter((message) => message.role === "user" || message.role === "assistant").slice(-maxMessages);
1365
+ for (const message of eligible) {
1380
1366
  const text = message.content.map(textOfPersistedBlock).join(" ").trim();
1381
- if (text) messages.push(`${message.role.toUpperCase()}: ${text.slice(0, maxMessageChars)}`);
1367
+ if (text) messages.push(`${message.role.toUpperCase()}: ${foldUntrustedLines(text, maxMessageChars)}`);
1382
1368
  }
1383
1369
  const toolLines = [];
1384
1370
  const events = dispatchTail;
@@ -1412,7 +1398,7 @@ function buildReviewRequest(session, kind, signal, maxMessages, maxMessageChars,
1412
1398
  if (opened === null || openedCallIds.has(opened.callId)) continue;
1413
1399
  openedCallIds.add(opened.callId);
1414
1400
  const argsRaw = typeof opened.arguments === "string" ? opened.arguments : JSON.stringify(opened.arguments ?? {});
1415
- toolLines.push(`[call] ${opened.name} ${argsRaw.slice(0, 500)}`);
1401
+ toolLines.push(`[call] ${foldUntrustedLines(opened.name, 0)} ${foldUntrustedLines(argsRaw, 500)}`);
1416
1402
  }
1417
1403
  toolLines.reverse();
1418
1404
  return [
@@ -42,10 +42,10 @@ export interface Config {
42
42
  * the policy snapshot like the memory cadence. */
43
43
  reviewSkillInterval?: Volatile<number | undefined>;
44
44
  /** Deprecated alias of `reviewMemoryInterval` (G0/S0.3): still readable,
45
- * refused by writes; removed 0.7.0. */
45
+ * refused by writes; removal was planned for 0.7.0 and is deferred (the alias still ships — see the package README). */
46
46
  memoryInterval?: number;
47
47
  /** Deprecated alias of `reviewSkillInterval` (G0/S0.3): still readable,
48
- * refused by writes; removed 0.7.0. */
48
+ * refused by writes; removal was planned for 0.7.0 and is deferred (the alias still ships — see the package README). */
49
49
  skillInterval?: number;
50
50
  /**
51
51
  * Tools the one-shot review subagent may use. Only actually-existing tools
@@ -31,12 +31,20 @@ export declare const REVIEW_NOTICE_KIND = "evolution-review";
31
31
  * belonged to would be delivered a second time.
32
32
  */
33
33
  export declare const REVIEW_NOTICE_LEGACY_KIND = "plugin:dsh-evolution-review";
34
+ /** T3-01/A29: the summary every RESULT notification carries ("what the review did"). A result notice
35
+ * is NOT a review prompt: it occupies no window and must never refuse the next cadence flush — but it
36
+ * shares the kind AND the form (the platform types `form` as the literal 'notice'), so this summary is
37
+ * the only field that tells the two apart, for new notices and for the ones an older build persisted. */
38
+ export declare const REVIEW_RESULT_SUMMARY = "self-improvement review";
34
39
  /** The subset of a platform message this module reads. */
35
40
  export interface NoticeMessage {
36
41
  readonly id: string;
37
42
  readonly source?: {
38
43
  readonly kind?: string;
39
44
  readonly form?: string;
45
+ /** Present on both shapes this plugin delivers (a prompt and a result notice); the result
46
+ * summary is what identifies a LEGACY result notice, see {@link REVIEW_RESULT_SUMMARY}. */
47
+ readonly summary?: string;
40
48
  } | undefined;
41
49
  }
42
50
  /** One delivered notice that has not settled yet. */
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.17.1",
4
+ "version": "0.18.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
@@ -26,10 +26,10 @@
26
26
  ],
27
27
  "license": "MIT",
28
28
  "dependencies": {
29
- "@deepseek-ai/schemastery": "^3.18.1",
30
- "@lmzhen/dsh-evolution-approval": "^0.17.1",
31
- "@lmzhen/dsh-evolution-core": "^0.17.1",
32
- "@lmzhen/dsh-evolution-plan-validator": "^0.17.1"
29
+ "@deepseek-ai/schemastery": "^3.18.4",
30
+ "@lmzhen/dsh-evolution-approval": "^0.18.1",
31
+ "@lmzhen/dsh-evolution-core": "^0.18.1",
32
+ "@lmzhen/dsh-evolution-plan-validator": "^0.18.1"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@deepseek-ai/cordis": "^4.0.1",
@@ -37,8 +37,8 @@
37
37
  "@deepseek-ai/dsh-llm": "^0.2.0-rc.1",
38
38
  "@deepseek-ai/dsh-session": "^0.2.0-rc.1",
39
39
  "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
40
- "@lmzhen/dsh-evolution-state": "^0.17.1",
41
- "@lmzhen/dsh-evolution-policy": "^0.17.1"
40
+ "@lmzhen/dsh-evolution-state": "^0.18.1",
41
+ "@lmzhen/dsh-evolution-policy": "^0.18.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@deepseek-ai/dsh-agent": "^0.2.0-rc.1",
@@ -48,10 +48,10 @@
48
48
  "@deepseek-ai/dsh-session-persistence": "^0.2.0-rc.1",
49
49
  "@deepseek-ai/dsh-session-persistence-jsonl": "^0.2.0-rc.1",
50
50
  "@deepseek-ai/dsh-tools": "^0.2.0-rc.1",
51
- "@lmzhen/dsh-evolution-approval": "^0.17.1",
52
- "@lmzhen/dsh-evolution-core": "^0.17.1",
53
- "@lmzhen/dsh-evolution-curator": "^0.17.1",
54
- "@lmzhen/dsh-evolution-plan-validator": "^0.17.1",
55
- "@lmzhen/dsh-evolution-state": "^0.17.1"
51
+ "@lmzhen/dsh-evolution-approval": "^0.18.1",
52
+ "@lmzhen/dsh-evolution-core": "^0.18.1",
53
+ "@lmzhen/dsh-evolution-curator": "^0.18.1",
54
+ "@lmzhen/dsh-evolution-plan-validator": "^0.18.1",
55
+ "@lmzhen/dsh-evolution-state": "^0.18.1"
56
56
  }
57
57
  }