dsh-continual-evolve 0.2.0 → 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.
Files changed (66) hide show
  1. package/README.md +83 -371
  2. package/README.zh.md +84 -235
  3. package/lib/apply.js +8 -2
  4. package/lib/approval.d.ts +6 -0
  5. package/lib/approval.js +9 -1
  6. package/lib/auto.d.ts +55 -4
  7. package/lib/auto.js +61 -5
  8. package/lib/benchmark-command.d.ts +9 -0
  9. package/lib/benchmark-command.js +333 -0
  10. package/lib/benchmark.d.ts +70 -0
  11. package/lib/benchmark.js +107 -1
  12. package/lib/command.d.ts +3 -0
  13. package/lib/command.js +62 -441
  14. package/lib/evaluate.d.ts +7 -0
  15. package/lib/evaluate.js +22 -7
  16. package/lib/evolve-event.d.ts +38 -0
  17. package/lib/evolve-event.js +49 -0
  18. package/lib/failures.d.ts +39 -0
  19. package/lib/failures.js +170 -0
  20. package/lib/fate.d.ts +5 -2
  21. package/lib/fate.js +13 -8
  22. package/lib/goal-command.d.ts +7 -0
  23. package/lib/goal-command.js +37 -0
  24. package/lib/index.d.ts +51 -25
  25. package/lib/index.js +33 -1
  26. package/lib/inject.d.ts +24 -1
  27. package/lib/inject.js +93 -5
  28. package/lib/llm-text.d.ts +30 -0
  29. package/lib/llm-text.js +49 -0
  30. package/lib/mount-command.d.ts +10 -0
  31. package/lib/mount-command.js +48 -0
  32. package/lib/plan.js +5 -0
  33. package/lib/planner.d.ts +1 -1
  34. package/lib/planner.js +13 -39
  35. package/lib/promotion.d.ts +62 -0
  36. package/lib/promotion.js +102 -0
  37. package/lib/render.d.ts +1 -3
  38. package/lib/render.js +0 -4
  39. package/lib/review.d.ts +4 -1
  40. package/lib/review.js +10 -38
  41. package/lib/rollback.d.ts +1 -3
  42. package/lib/rollback.js +0 -8
  43. package/lib/score.d.ts +15 -0
  44. package/lib/score.js +74 -5
  45. package/lib/service.d.ts +2 -2
  46. package/lib/service.js +7 -3
  47. package/lib/skill-render.d.ts +23 -0
  48. package/lib/skill-render.js +68 -0
  49. package/lib/skill.d.ts +2 -5
  50. package/lib/skill.js +2 -29
  51. package/lib/skillquality.d.ts +1 -2
  52. package/lib/skillquality.js +2 -2
  53. package/lib/state.js +6 -1
  54. package/lib/store.d.ts +1 -3
  55. package/lib/store.js +0 -7
  56. package/lib/tool.js +22 -1
  57. package/lib/types.d.ts +8 -0
  58. package/lib/usage.d.ts +45 -0
  59. package/lib/usage.js +115 -0
  60. package/lib/validate.d.ts +12 -2
  61. package/lib/validate.js +26 -1
  62. package/lib/wrapup-command.d.ts +9 -0
  63. package/lib/wrapup-command.js +212 -0
  64. package/lib/wrapup.d.ts +29 -15
  65. package/lib/wrapup.js +69 -42
  66. package/package.json +10 -8
package/lib/inject.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isArchived } from "./types.js";
2
2
  import { mergeHarnessStates } from "./state.js";
3
3
  import { entryLine } from "./render.js";
4
+ import { recordInjection } from "./usage.js";
4
5
  /** Prompt sections render at most this many entries per kind. */
5
6
  export const MAX_INJECTED_ENTRIES_PER_KIND = 6;
6
7
  /** Per-entry content budget inside the injected block (matches render.ts). */
@@ -183,6 +184,53 @@ export function formatSubagentSpecsSection(entries, query) {
183
184
  }
184
185
  return lines.join("\n");
185
186
  }
187
+ /**
188
+ * Gap B3: a lightweight directory of ALL non-archived entries across all
189
+ * kinds — one line per entry (`- [kind:id] title`), no content. This gives
190
+ * the model a zero-cost overview of what exists so it can ask for full text
191
+ * via `evolve_list` or `/evolve list`. The directory is appended after the
192
+ * curated top-N injection sections and adds minimal tokens.
193
+ *
194
+ * 2026-08-22 throttle: the directory is CAPPED at {@link DEFAULT_DIRECTORY_LINES}
195
+ * lines (oldest-sorted stable order) with the remainder folded into a single
196
+ * counter line — an uncapped directory across a polluted global store was
197
+ * measured at ~2K chars of every build in every project.
198
+ */
199
+ export const DEFAULT_DIRECTORY_LINES = 15;
200
+ /** How many of the variadic arrays are content-section kinds (prompt, subagent). */
201
+ const CONTENT_SECTION_KINDS = 2;
202
+ export function formatEntriesDirectory(...kindEntries) {
203
+ return formatEntriesDirectoryCapped(DEFAULT_DIRECTORY_LINES, ...kindEntries);
204
+ }
205
+ /** {@link formatEntriesDirectory} with an explicit cap (configurable). */
206
+ export function formatEntriesDirectoryCapped(maxLines, ...kindEntries) {
207
+ const allEntries = kindEntries.flat().filter((e) => !isArchived(e));
208
+ if (allEntries.length === 0) {
209
+ return "";
210
+ }
211
+ // Skip the directory only when EVERY entry is already content-visible.
212
+ // Only the first two arrays (prompt, subagent) have curated sections —
213
+ // memories and skills have NO content injection, so they are invisible
214
+ // unless the directory lists them (pre-2026-08-22 the redundancy check
215
+ // wrongly counted them as "already shown", hiding small stores entirely).
216
+ const contentVisible = kindEntries
217
+ .slice(0, CONTENT_SECTION_KINDS)
218
+ .reduce((sum, entries) => sum + Math.min(entries.filter((e) => !isArchived(e)).length, MAX_INJECTED_ENTRIES_PER_KIND), 0);
219
+ if (allEntries.length <= contentVisible) {
220
+ return "";
221
+ }
222
+ const sorted = [...allEntries].sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
223
+ const lines = ["# Continual Harness — Entry Directory", "All entries (use evolve_list for full text of any entry):"];
224
+ const shown = sorted.slice(0, Math.max(maxLines, 1));
225
+ for (const entry of shown) {
226
+ lines.push(`- [${entry.kind}:${entry.id}] ${entry.title}`);
227
+ }
228
+ const hidden = sorted.length - shown.length;
229
+ if (hidden > 0) {
230
+ lines.push(`- …and ${hidden} more entries (evolve_list for the full index)`);
231
+ }
232
+ return lines.join("\n");
233
+ }
186
234
  /**
187
235
  * Walk the parent-session chain from `agent` upward and return the nearest
188
236
  * session whose local store is non-empty, if any. Children inherit their
@@ -211,8 +259,13 @@ export function nearestLocalStateWithEntries(engine, agent) {
211
259
  * (relevance first, then recency; see {@link rankEntries}). Returns "" when
212
260
  * nothing is injectable — the prompt renderer then drops the section, so an
213
261
  * empty store adds zero tokens to every assembly.
262
+ *
263
+ * `opts.directoryLines` caps the entry-directory index (2026-08-22 throttle).
264
+ * Usage recording covers ALL kinds — memories and skills appear as directory
265
+ * lines, prompts/subagents as content — and is deduped per session so the
266
+ * counts read "how many sessions saw this", not "how many prompt builds".
214
267
  */
215
- export function entriesSectionText(engine, agent, query) {
268
+ export function entriesSectionText(engine, agent, query, opts) {
216
269
  if (!agent) {
217
270
  return "";
218
271
  }
@@ -222,10 +275,45 @@ export function entriesSectionText(engine, agent, query) {
222
275
  const promptEntries = Object.values(merged.entries.prompt);
223
276
  const subagentEntries = Object.values(merged.entries.subagent);
224
277
  const relevanceQuery = (query ?? recentUserText(agent)).trim();
225
- const parts = [
226
- formatPromptEntriesSection(promptEntries, relevanceQuery),
227
- formatSubagentSpecsSection(subagentEntries, relevanceQuery),
228
- ].filter((part) => part.length > 0);
278
+ // Build injected text and collect which entries were included (gap B1).
279
+ const promptText = formatPromptEntriesSection(promptEntries, relevanceQuery);
280
+ const subagentText = formatSubagentSpecsSection(subagentEntries, relevanceQuery);
281
+ const injectedKeys = new Set();
282
+ // Collect keys from the visible (ranked, capped) entries that actually appear.
283
+ const visiblePrompt = promptEntries.filter((e) => !isArchived(e));
284
+ const visibleSubagent = subagentEntries.filter((e) => !isArchived(e));
285
+ for (const entry of rankEntries(visiblePrompt, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
286
+ injectedKeys.add(`prompt:${entry.id}`);
287
+ }
288
+ for (const entry of rankEntries(visibleSubagent, relevanceQuery).slice(0, MAX_INJECTED_ENTRIES_PER_KIND)) {
289
+ injectedKeys.add(`subagent:${entry.id}`);
290
+ }
291
+ // Gap B3: lightweight directory of ALL entries (id+title, one line each).
292
+ // Zero-cost index so the model knows what exists and can ask for full text.
293
+ const directoryText = formatEntriesDirectoryCapped(opts?.directoryLines ?? DEFAULT_DIRECTORY_LINES, Object.values(merged.entries.prompt), Object.values(merged.entries.memory), Object.values(merged.entries.skill), Object.values(merged.entries.subagent));
294
+ // Directory-visible keys count too: a memory's injection IS its directory
295
+ // line. Set semantics keep content-injected entries single-counted.
296
+ for (const kind of ["prompt", "memory", "skill", "subagent"]) {
297
+ for (const entry of Object.values(merged.entries[kind])) {
298
+ if (isArchived(entry))
299
+ continue;
300
+ const key = `${kind}:${entry.id}`;
301
+ if (injectedKeys.has(key) || directoryText.includes(`[${key}]`)) {
302
+ injectedKeys.add(key);
303
+ }
304
+ }
305
+ }
306
+ // Record usage durably (best-effort: failure never blocks injection).
307
+ // Deduped per session — see recordInjection.
308
+ if (injectedKeys.size > 0) {
309
+ try {
310
+ recordInjection(engine.baseDir, [...injectedKeys], agent.id);
311
+ }
312
+ catch {
313
+ // Usage recording is diagnostic; never interrupt the injection path.
314
+ }
315
+ }
316
+ const parts = [promptText, subagentText, directoryText].filter((part) => part.length > 0);
229
317
  return parts.join("\n\n");
230
318
  }
231
319
  //# sourceMappingURL=inject.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Unified LLM text call: a shared streaming-text helper used by the review
3
+ * gate, planner, and wrap-up assessor. Eliminates ~120 lines of duplicated
4
+ * BlockAssembler + finish-state-check + text-extraction boilerplate.
5
+ *
6
+ * Every caller needs the same sequence:
7
+ * provider/model validation → stream → assemble → check finish → extract text
8
+ * This module owns that sequence; callers keep only their prompt construction
9
+ * and JSON parsing.
10
+ */
11
+ import type { Context } from "@deepseek-ai/cordis";
12
+ export interface StreamTextOptions {
13
+ provider: string;
14
+ model: string;
15
+ system: string;
16
+ prompt: string;
17
+ maxTokens?: number;
18
+ signal?: AbortSignal | undefined;
19
+ }
20
+ /**
21
+ * Stream a single-turn text completion through `ctx.llm`. Forces
22
+ * `reasoningEffort: off` so the model spends its budget on the answer,
23
+ * not visible thinking (reasoning models otherwise produce zero text
24
+ * blocks — the exact failure recorded in FAQ #7).
25
+ *
26
+ * @returns The concatenated text blocks from the response.
27
+ * @throws On provider error, abort, max-token truncation, or empty output.
28
+ */
29
+ export declare function streamText(ctx: Context, opts: StreamTextOptions): Promise<string>;
30
+ //# sourceMappingURL=llm-text.d.ts.map
@@ -0,0 +1,49 @@
1
+ import { BlockAssembler, createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
2
+ /**
3
+ * Stream a single-turn text completion through `ctx.llm`. Forces
4
+ * `reasoningEffort: off` so the model spends its budget on the answer,
5
+ * not visible thinking (reasoning models otherwise produce zero text
6
+ * blocks — the exact failure recorded in FAQ #7).
7
+ *
8
+ * @returns The concatenated text blocks from the response.
9
+ * @throws On provider error, abort, max-token truncation, or empty output.
10
+ */
11
+ export async function streamText(ctx, opts) {
12
+ const assembler = new BlockAssembler();
13
+ for await (const chunk of ctx.llm.stream({
14
+ provider: opts.provider,
15
+ model: opts.model,
16
+ system: opts.system,
17
+ messages: [
18
+ createUserMessage({
19
+ content: [{ type: "text", text: opts.prompt }],
20
+ source: { kind: "plugin", plugin: "dsh-continual-evolve" },
21
+ }),
22
+ ],
23
+ reasoningEffort: ReasoningEffortId("off"),
24
+ maxTokens: opts.maxTokens ?? 8000,
25
+ ...(opts.signal ? { signal: opts.signal } : {}),
26
+ })) {
27
+ assembler.push(chunk);
28
+ }
29
+ const finish = assembler.finish;
30
+ if (finish.kind === "error") {
31
+ throw new Error(`evolve: LLM call failed: ${finish.failure?.message ?? "unknown"}`);
32
+ }
33
+ if (finish.kind === "aborted") {
34
+ throw new Error("evolve: LLM call aborted");
35
+ }
36
+ if (finish.kind === "max-tokens") {
37
+ throw new Error("evolve: LLM output budget exhausted (max-tokens)");
38
+ }
39
+ const text = assembler
40
+ .blocks()
41
+ .filter((block) => block.type === "text")
42
+ .map((block) => block.text)
43
+ .join("\n");
44
+ if (text.length === 0) {
45
+ throw new Error("evolve: LLM produced no text output");
46
+ }
47
+ return text;
48
+ }
49
+ //# sourceMappingURL=llm-text.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The `/evolve mount` and `/evolve unmount` subcommand handlers.
3
+ * Extracted from command.ts (P2-2).
4
+ */
5
+ import type { Context } from "@deepseek-ai/cordis";
6
+ import type { CommandInvocation, CommandResult } from "@deepseek-ai/dsh-commands";
7
+ import type { EvolutionEngine } from "./service.js";
8
+ export declare function executeMountCommand(ctx: Context, engine: EvolutionEngine, invocation: CommandInvocation, rest: string[]): Promise<CommandResult>;
9
+ export declare function executeUnmountCommand(ctx: Context, engine: EvolutionEngine, rest: string[]): Promise<CommandResult>;
10
+ //# sourceMappingURL=mount-command.d.ts.map
@@ -0,0 +1,48 @@
1
+ import { loadLedger, mountSkill, unmountSkill } from "./mount.js";
2
+ import { stripAngleBrackets } from "./command.js";
3
+ function success(text) {
4
+ return { kind: "success", text };
5
+ }
6
+ function error(text) {
7
+ return { kind: "error", text };
8
+ }
9
+ export async function executeMountCommand(ctx, engine, invocation, rest) {
10
+ const sub = rest[0] ?? "";
11
+ if (sub === "list") {
12
+ const ledger = loadLedger(engine.baseDir);
13
+ if (ledger.mounted.length === 0) {
14
+ return success("(no hot-mounted plugins — /evolve mount <skillId>)");
15
+ }
16
+ return success(ledger.mounted.map((m) => `- ${m.id} (${m.entryId}, v${m.version}, ${m.mountedAt})`).join("\n"));
17
+ }
18
+ const skillId = stripAngleBrackets(sub);
19
+ if (!skillId) {
20
+ return error(`mount requires a skill entry id.\nUsage: /evolve mount <skillId> | /evolve mount list`);
21
+ }
22
+ const sessionId = invocation.agent.id;
23
+ const local = engine.load("local", sessionId);
24
+ const globalState = engine.load("global", undefined);
25
+ const entry = local.entries.skill[skillId] ??
26
+ globalState.entries.skill[skillId] ??
27
+ Object.values(local.entries.skill).find((e) => e.id === skillId) ??
28
+ Object.values(globalState.entries.skill).find((e) => e.id === skillId);
29
+ if (!entry) {
30
+ return error(`skill entry ${skillId} not found in local or global store`);
31
+ }
32
+ try {
33
+ const record = await mountSkill(ctx, engine.baseDir, entry);
34
+ return success(`mounted ${record.id} as ${record.entryId} (v${record.version}) — tool: skill_${record.id.replace(/_/g, "-")}`);
35
+ }
36
+ catch (cause) {
37
+ return error(cause instanceof Error ? cause.message : String(cause));
38
+ }
39
+ }
40
+ export async function executeUnmountCommand(ctx, engine, rest) {
41
+ const id = stripAngleBrackets(rest[0] ?? "");
42
+ if (!id) {
43
+ return error(`unmount requires a mount id (see /evolve mount list).`);
44
+ }
45
+ const record = await unmountSkill(ctx, engine.baseDir, id);
46
+ return record ? success(`unmounted ${record.id} (${record.entryId})`) : error(`no mount found for ${id}`);
47
+ }
48
+ //# sourceMappingURL=mount-command.js.map
package/lib/plan.js CHANGED
@@ -99,6 +99,11 @@ export function parseProposal(text) {
99
99
  assignIfString(built, "content", edit["content"]);
100
100
  assignIfString(built, "path", edit["path"]);
101
101
  assignIfString(built, "reason", edit["reason"]);
102
+ // Gap C2: validate blastRadius values.
103
+ const br = asString(edit["blastRadius"]);
104
+ if (br === "general" || br === "project" || br === "session") {
105
+ built.blastRadius = br;
106
+ }
102
107
  const reference = asRecord(edit["reference"]);
103
108
  if (reference)
104
109
  built.reference = reference;
package/lib/planner.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  import type { Context } from "@deepseek-ai/cordis";
10
10
  import type { Agent } from "@deepseek-ai/dsh-agent";
11
11
  import type { HarnessState, RefinementProposal, RefinementResult } from "./types.js";
12
- export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = durable facts/preferences/failures;\n skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\"\n }\n ]\n}";
12
+ export declare const PLANNER_SYSTEM_PROMPT = "You are the /evolve continual harness subsystem.\n\nYour job is to improve the editable continual harness state. Instead of\nsummarizing the conversation you emit precise Create, Update, or Delete edits\nto reusable state: prompt notes, memories, skills, and subagent specs.\n\nRules:\n- The base system prompt is immutable and MUST NOT be rewritten (never edit id \"base_system_prompt\").\n- Prefer small evidence-backed edits. If no useful edit is justified, return an empty edits array.\n- prompt = narrow behavioral policy addendums; memory = durable facts/preferences/failures;\n skill = repeatable procedures (must carry a python reference {type:\"python\", import, callable}\n and an arguments object); subagent = reusable delegation roles.\n- Skill entries are authored to the DSH skill quality standard\n (skill-creator, distilled from the official deepseek-harness 11 skills;\n the full facts are provided in the <skill_quality_standard> block below):\n only for a REAL trigger scenario grounded in the trajectory\n (who, in what real task, what signal) \u2014 never invent one to pad the store;\n never duplicate the official 11 skills or existing entries; content is a\n SKILL.md document (frontmatter routing with \"use when / do not use when\"\n description, boundary declaration, prerequisites and exclusions, layered\n information, verifiable completion criteria). Self-check every proposed\n skill against the 7 structural features and state the result in its\n reason field.\n- Repeated multi-step workflows (session start/end routines, recurring\n wrap-up or handoff procedures) may be proposed as guidance skills:\n kind=skill, skill_kind=\"guidance\", content = a SKILL.md document (no\n python reference \u2014 executable skills keep requiring reference +\n arguments). Only propose with repeated evidence in the trajectory, never\n for one-off flows. Guidance skills materialize as discoverable SKILL.md\n files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to\n the user for a decision before they land.\n- Local edits are session-scoped; global edits persist across sessions.\n- Gap C2: EVERY edit MUST include a \"blastRadius\" field indicating how broadly\n the edit applies: \"general\" (cross-project tactical rule, applies everywhere),\n \"project\" (valid for the current project/repo), or \"session\" (one-off,\n specific to this session's context). The review gate validates that\n local-scope edits use \"session\" or \"project\", and global-scope edits use\n \"general\" or \"project\". When in doubt, prefer narrower blast radius.\n- Ground every edit in evidence: the session trajectory (recent direct user\n messages) is provided when available; prefer edits backed by it over\n speculation, and never invent preferences the user did not express.\n- Stale entries (superseded by newer ones, never referenced in recent\n trajectories, obsolete facts): propose action \"archive\" instead of\n \"delete\" \u2014 archive hides the entry from injection while keeping its data\n restorable; it requires only kind + id.\n- Output JSON only, exactly this shape:\n{\n \"summary\": \"one sentence\",\n \"rationale\": \"why these edits are justified by the evidence\",\n \"expectedOutcome\": \"what should improve and how to validate it\",\n \"edits\": [\n {\n \"action\": \"create|update|delete\",\n \"kind\": \"prompt|memory|skill|subagent\",\n \"id\": \"stable id for update/delete, optional for create\",\n \"title\": \"required for create/update except delete\",\n \"content\": \"required for create/update except delete\",\n \"path\": \"optional grouping path\",\n \"reference\": {\"type\":\"python\",\"import\":\"pkg.mod\",\"callable\":\"fn\"} ,\n \"arguments\": {\"name\": {\"type\":\"string\",\"required\":true,\"description\":\"...\"}},\n \"skill_kind\": \"executable|guidance (optional; skill kind only \u2014 guidance = SKILL.md document without reference)\",\n \"metadata\": {},\n \"reason\": \"why this edit is useful\",\n \"blastRadius\": \"general|project|session\"\n }\n ]\n}";
13
13
  export interface PlanOptions {
14
14
  agent: Agent;
15
15
  state: HarnessState;
package/lib/planner.js CHANGED
@@ -1,8 +1,8 @@
1
- import { BlockAssembler, createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
2
1
  import { parseProposal } from "./plan.js";
3
2
  import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
4
3
  import { recentUserText } from "./inject.js";
5
4
  import { skillQualityGuide } from "./skillquality.js";
5
+ import { streamText } from "./llm-text.js";
6
6
  export const PLANNER_SYSTEM_PROMPT = `You are the /evolve continual harness subsystem.
7
7
 
8
8
  Your job is to improve the editable continual harness state. Instead of
@@ -35,6 +35,12 @@ Rules:
35
35
  files under <skillsRoot>/<kebab-name>/SKILL.md and are always offered to
36
36
  the user for a decision before they land.
37
37
  - Local edits are session-scoped; global edits persist across sessions.
38
+ - Gap C2: EVERY edit MUST include a "blastRadius" field indicating how broadly
39
+ the edit applies: "general" (cross-project tactical rule, applies everywhere),
40
+ "project" (valid for the current project/repo), or "session" (one-off,
41
+ specific to this session's context). The review gate validates that
42
+ local-scope edits use "session" or "project", and global-scope edits use
43
+ "general" or "project". When in doubt, prefer narrower blast radius.
38
44
  - Ground every edit in evidence: the session trajectory (recent direct user
39
45
  messages) is provided when available; prefer edits backed by it over
40
46
  speculation, and never invent preferences the user did not express.
@@ -59,7 +65,8 @@ Rules:
59
65
  "arguments": {"name": {"type":"string","required":true,"description":"..."}},
60
66
  "skill_kind": "executable|guidance (optional; skill kind only — guidance = SKILL.md document without reference)",
61
67
  "metadata": {},
62
- "reason": "why this edit is useful"
68
+ "reason": "why this edit is useful",
69
+ "blastRadius": "general|project|session"
63
70
  }
64
71
  ]
65
72
  }`;
@@ -91,47 +98,14 @@ export async function planWithLlm(ctx, options) {
91
98
  ]
92
99
  .filter(Boolean)
93
100
  .join("\n\n");
94
- const assembler = new BlockAssembler();
95
- for await (const chunk of ctx.llm.stream({
101
+ const text = await streamText(ctx, {
96
102
  provider: agent.options.provider,
97
103
  model: agent.options.model,
98
104
  system: PLANNER_SYSTEM_PROMPT,
99
- messages: [
100
- createUserMessage({
101
- content: [{ type: "text", text: userPrompt }],
102
- source: { kind: "plugin", plugin: "dsh-continual-evolve" },
103
- }),
104
- ],
105
- // Force non-reasoning output: the proposal must be pure JSON text.
106
- reasoningEffort: ReasoningEffortId("off"),
105
+ prompt: userPrompt,
107
106
  maxTokens: options.maxOutputTokens ?? 8000,
108
- ...(options.signal ? { signal: options.signal } : {}),
109
- })) {
110
- assembler.push(chunk);
111
- }
112
- throwOnFinishError(assembler.finish);
113
- const blocks = assembler.blocks();
114
- const text = blocks
115
- .filter((block) => block.type === "text")
116
- .map((block) => block.text)
117
- .join("\n");
118
- if (text.length === 0) {
119
- throw new Error("evolve: planner produced no text output");
120
- }
107
+ signal: options.signal,
108
+ });
121
109
  return parseProposal(text);
122
110
  }
123
- /** Surface terminal stream states as errors so the caller never sees a silent partial plan. */
124
- function throwOnFinishError(finish) {
125
- switch (finish.kind) {
126
- case "stop":
127
- case "tool-calls":
128
- return;
129
- case "max-tokens":
130
- throw new Error("evolve: planner output budget exhausted (max-tokens)");
131
- case "aborted":
132
- throw new Error("evolve: planner call aborted");
133
- case "error":
134
- throw new Error(`evolve: planner call failed: ${finish.failure?.message ?? "unknown error"}`);
135
- }
136
- }
137
111
  //# sourceMappingURL=planner.js.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Promotion policy: mechanical, code-enforced gates that decide whether a
3
+ * local entry MAY be promoted to the cross-session global store ("模型提议,
4
+ * 代码保证"). The LLM classification proposes; these guards dispose.
5
+ *
6
+ * Rationale (2026-08-22 store audit): the global store is shared by every
7
+ * project on one profile, so promoting project-scoped knowledge taxes every
8
+ * future session in every project. Measured failure modes:
9
+ * - absolute paths / session ids in promoted content (project-scoped),
10
+ * - near-duplicates of existing global entries re-promoted from later
11
+ * sessions (title matching alone missed them),
12
+ * - one-line facts whose framing costs more than their content.
13
+ *
14
+ * Pure functions only — the callers (wrapup command, gate local-fate phase)
15
+ * supply the resolved {@link PromotionPolicy}.
16
+ */
17
+ import type { HarnessState, RefinementKind } from "./types.js";
18
+ export interface PromotionPolicy {
19
+ /** Content matching any pattern is project-scoped and stays local. */
20
+ blockPatterns: RegExp[];
21
+ /** Whole promotions below this content length stay local (chars). */
22
+ minPromoteChars: number;
23
+ /** Content overlap above this against a global entry = duplicate. */
24
+ maxContentOverlap: number;
25
+ }
26
+ export declare const DEFAULT_PROMOTION_POLICY: PromotionPolicy;
27
+ /**
28
+ * Build a policy from config values (schemastery strings compiled here so
29
+ * the config layer never touches RegExp). Invalid patterns are skipped —
30
+ * a broken user pattern must not disable the remaining guards.
31
+ */
32
+ export declare function resolvePromotionPolicy(options: {
33
+ blockPatterns?: readonly string[] | undefined;
34
+ minPromoteChars?: number | undefined;
35
+ maxContentOverlap?: number | undefined;
36
+ }): PromotionPolicy;
37
+ /**
38
+ * First reason the content reads as project-scoped, or undefined when it
39
+ * looks portable. Returns the matched pattern source so skip reports stay
40
+ * explainable in reviews.jsonl rationales.
41
+ */
42
+ export declare function projectScopedReason(content: string, policy: PromotionPolicy): string | undefined;
43
+ /**
44
+ * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
45
+ * (single CJK chars are too ambiguous; bigrams survive segmentation-free
46
+ * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
47
+ */
48
+ export declare function normalizedTokens(text: string): Set<string>;
49
+ /** Jaccard similarity of two texts' normalized token sets (0..1). */
50
+ export declare function contentOverlap(a: string, b: string): number;
51
+ export interface SimilarEntryHit {
52
+ id: string;
53
+ title: string;
54
+ score: number;
55
+ }
56
+ /**
57
+ * The most similar non-archived global entry of the same kind, above the
58
+ * policy threshold. Title and content both feed the comparison (titles are
59
+ * short; content carries the real signal).
60
+ */
61
+ export declare function mostSimilarGlobalEntry(globalState: HarnessState, kind: RefinementKind, title: string, content: string, policy: PromotionPolicy): SimilarEntryHit | undefined;
62
+ //# sourceMappingURL=promotion.d.ts.map
@@ -0,0 +1,102 @@
1
+ import { isArchived } from "./types.js";
2
+ /** Regex sources that mark content as project-scoped (never global). */
3
+ const DEFAULT_BLOCK_PATTERNS = [
4
+ String.raw `\/(?:mnt|home|Users)\/`, // absolute POSIX paths: "/home/…", "/mnt/…", "/Users/…"
5
+ String.raw `\bsession-[0-9a-f]{8}\b`, // session-scoped identifiers
6
+ String.raw `~/\.dsh\b`, // user harness home references
7
+ ];
8
+ export const DEFAULT_PROMOTION_POLICY = {
9
+ blockPatterns: DEFAULT_BLOCK_PATTERNS.map((source) => new RegExp(source, "i")),
10
+ minPromoteChars: 100,
11
+ maxContentOverlap: 0.6,
12
+ };
13
+ /**
14
+ * Build a policy from config values (schemastery strings compiled here so
15
+ * the config layer never touches RegExp). Invalid patterns are skipped —
16
+ * a broken user pattern must not disable the remaining guards.
17
+ */
18
+ export function resolvePromotionPolicy(options) {
19
+ const patterns = [...(options.blockPatterns ?? [])]
20
+ .map((source) => {
21
+ try {
22
+ return new RegExp(source, "i");
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ })
28
+ .filter((pattern) => pattern !== undefined);
29
+ return {
30
+ blockPatterns: patterns.length > 0 ? patterns : DEFAULT_PROMOTION_POLICY.blockPatterns,
31
+ minPromoteChars: options.minPromoteChars ?? DEFAULT_PROMOTION_POLICY.minPromoteChars,
32
+ maxContentOverlap: options.maxContentOverlap ?? DEFAULT_PROMOTION_POLICY.maxContentOverlap,
33
+ };
34
+ }
35
+ /**
36
+ * First reason the content reads as project-scoped, or undefined when it
37
+ * looks portable. Returns the matched pattern source so skip reports stay
38
+ * explainable in reviews.jsonl rationales.
39
+ */
40
+ export function projectScopedReason(content, policy) {
41
+ for (const pattern of policy.blockPatterns) {
42
+ if (pattern.test(content)) {
43
+ return `project-scoped content (matches /${pattern.source}/)`;
44
+ }
45
+ }
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Tokenize for cheap similarity: ASCII words plus CJK character bigrams
50
+ * (single CJK chars are too ambiguous; bigrams survive segmentation-free
51
+ * Chinese text). Lowercased; single-char ASCII tokens dropped as noise.
52
+ */
53
+ export function normalizedTokens(text) {
54
+ const lowered = text.toLowerCase();
55
+ const tokens = new Set();
56
+ for (const match of lowered.matchAll(/[a-z0-9_]{2,}/g)) {
57
+ tokens.add(match[0] ?? "");
58
+ }
59
+ let previous;
60
+ for (const match of lowered.matchAll(/[\u3400-\u9fff]/g)) {
61
+ const char = match[0] ?? "";
62
+ if (previous !== undefined) {
63
+ tokens.add(`${previous}${char}`);
64
+ }
65
+ previous = char;
66
+ }
67
+ tokens.delete("");
68
+ return tokens;
69
+ }
70
+ /** Jaccard similarity of two texts' normalized token sets (0..1). */
71
+ export function contentOverlap(a, b) {
72
+ const left = normalizedTokens(a);
73
+ const right = normalizedTokens(b);
74
+ if (left.size === 0 || right.size === 0) {
75
+ return 0;
76
+ }
77
+ let intersection = 0;
78
+ for (const token of left) {
79
+ if (right.has(token)) {
80
+ intersection += 1;
81
+ }
82
+ }
83
+ return intersection / (left.size + right.size - intersection);
84
+ }
85
+ /**
86
+ * The most similar non-archived global entry of the same kind, above the
87
+ * policy threshold. Title and content both feed the comparison (titles are
88
+ * short; content carries the real signal).
89
+ */
90
+ export function mostSimilarGlobalEntry(globalState, kind, title, content, policy) {
91
+ let best;
92
+ for (const other of Object.values(globalState.entries[kind])) {
93
+ if (isArchived(other))
94
+ continue;
95
+ const score = Math.max(contentOverlap(title, other.title), contentOverlap(content, other.content));
96
+ if (score >= policy.maxContentOverlap && (best === undefined || score > best.score)) {
97
+ best = { id: other.id, title: other.title, score };
98
+ }
99
+ }
100
+ return best;
101
+ }
102
+ //# sourceMappingURL=promotion.js.map
package/lib/render.d.ts CHANGED
@@ -3,13 +3,11 @@
3
3
  * refinement history. Every cap exists to keep token cost predictable no
4
4
  * matter how large the store grows.
5
5
  */
6
- import type { HarnessEntry, HarnessRefinementEvent, HarnessState, RefinementResult } from "./types.js";
6
+ import type { HarnessEntry, HarnessState, RefinementResult } from "./types.js";
7
7
  export declare function compactText(text: string, maxLength: number): string;
8
8
  export declare function entryLine(entry: HarnessEntry, maxContentLength: number): string;
9
9
  /** Render the full merged state as a bounded overview for the system prompt. */
10
10
  export declare function formatHarnessStateForPrompt(state: HarnessState): string;
11
11
  /** Render recent refinement results for the planner. */
12
12
  export declare function historyForPrompt(history: readonly RefinementResult[]): string;
13
- /** Serialize a refinement event for persistence (lightweight). */
14
- export declare function eventToLine(event: HarnessRefinementEvent): string;
15
13
  //# sourceMappingURL=render.d.ts.map
package/lib/render.js CHANGED
@@ -77,8 +77,4 @@ export function historyForPrompt(history) {
77
77
  })
78
78
  .join("\n\n");
79
79
  }
80
- /** Serialize a refinement event for persistence (lightweight). */
81
- export function eventToLine(event) {
82
- return JSON.stringify(event);
83
- }
84
80
  //# sourceMappingURL=render.js.map
package/lib/review.d.ts CHANGED
@@ -13,7 +13,7 @@ export interface AutoRefineReview {
13
13
  rationale: string;
14
14
  instructions?: string;
15
15
  }
16
- export type AutoRefineReason = "turn_interval" | "compact";
16
+ export type AutoRefineReason = "turn_interval" | "compact" | "goal_blocked";
17
17
  export interface AutoRefineReviewContext {
18
18
  reason: AutoRefineReason;
19
19
  turnsSinceLastReview: number;
@@ -27,6 +27,9 @@ export interface ReviewOptions {
27
27
  trajectory?: string;
28
28
  signal?: AbortSignal;
29
29
  budgetTokens?: number;
30
+ /** Gap C1: optional provider/model override for the review gate (cheaper model). */
31
+ overrideProvider?: string;
32
+ overrideModel?: string;
30
33
  }
31
34
  export declare const AUTO_REVIEW_SYSTEM_PROMPT = "You are the automatic /evolve review gate.\n\nDecide whether this checkpoint should run /evolve. Auto /evolve writes local\nharness state by default, so approve when the trajectory contains evidence\nuseful to this session's future turns: a repeated failure, a reusable tactic,\na repeated delegation role, a durable fact or preference, a user correction\nthat should persist, or a narrow behavioral policy.\n\nThe current harness state below includes GLOBAL entries (scope=global) plus\nthis session's local entries (scope=local). When a topic is already covered\nby a global entry, do NOT approve a local duplicate of it \u2014 decline and say\nin the rationale that the topic is already covered globally.\n\nReject one-off noise, unsupported hypotheses, transient tool outputs, and\nrequests that carry no reusable content.\n\nStale local entries (superseded, long-unused, obsolete facts) are a valid\nrefine target: approve with instructions naming the entry ids, and tell the\nplanner to archive them (archive hides from injection, data stays restorable)\nrather than delete.\n\nSkill-related trajectories (the evidence concerns creating or improving a\nskill entry) are judged against the DSH skill quality standard (skill-audit\ndimensions: frontmatter routing, the 7 structural features, paragraph\nskeleton, no duplication of the official 11 skills or covered skills).\nApprove only when the trajectory shows a REAL trigger scenario and the\nresulting skill would meet the standard; otherwise decline and say in the\nrationale what must improve \u2014 drafting follows skill-creator, and the\nplanner receives the standard as its <skill_quality_standard> block.\n\nRepeated multi-step workflows (session start/end routines, recurring\nwrap-up or handoff procedures) are a valid refine target: approve with\ninstructions telling the planner to propose a guidance skill (kind=skill,\nskill_kind=guidance \u2014 a SKILL.md document, no python reference). Only\npropose when the same workflow recurs in the trajectory \u2014 never for\none-off flows. Auto-created skills are always offered to the user for a\ndecision before they land; the gate never writes a skill silently.\n\nReturn JSON only:\n{\n \"shouldRefine\": true|false,\n \"rationale\": \"short reason\",\n \"instructions\": \"optional concise instructions for /evolve if shouldRefine is true\"\n}";
32
35
  /** Parse the gate's JSON reply. */