dsh-continual-evolve 0.2.0 → 0.3.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 (62) hide show
  1. package/README.md +45 -7
  2. package/README.zh.md +44 -7
  3. package/lib/apply.js +1 -1
  4. package/lib/approval.d.ts +6 -0
  5. package/lib/approval.js +9 -1
  6. package/lib/auto.d.ts +38 -4
  7. package/lib/auto.js +58 -5
  8. package/lib/benchmark-command.d.ts +9 -0
  9. package/lib/benchmark-command.js +331 -0
  10. package/lib/benchmark.d.ts +70 -0
  11. package/lib/benchmark.js +107 -1
  12. package/lib/command.js +25 -442
  13. package/lib/evaluate.d.ts +7 -0
  14. package/lib/evaluate.js +22 -7
  15. package/lib/evolve-event.d.ts +38 -0
  16. package/lib/evolve-event.js +49 -0
  17. package/lib/failures.d.ts +39 -0
  18. package/lib/failures.js +170 -0
  19. package/lib/fate.d.ts +3 -1
  20. package/lib/fate.js +8 -4
  21. package/lib/goal-command.d.ts +7 -0
  22. package/lib/goal-command.js +37 -0
  23. package/lib/index.d.ts +29 -25
  24. package/lib/index.js +14 -0
  25. package/lib/inject.d.ts +8 -0
  26. package/lib/inject.js +51 -4
  27. package/lib/llm-text.d.ts +30 -0
  28. package/lib/llm-text.js +49 -0
  29. package/lib/mount-command.d.ts +10 -0
  30. package/lib/mount-command.js +48 -0
  31. package/lib/plan.js +5 -0
  32. package/lib/planner.d.ts +1 -1
  33. package/lib/planner.js +13 -39
  34. package/lib/render.d.ts +1 -3
  35. package/lib/render.js +0 -4
  36. package/lib/review.d.ts +4 -1
  37. package/lib/review.js +10 -38
  38. package/lib/rollback.d.ts +1 -3
  39. package/lib/rollback.js +0 -8
  40. package/lib/score.d.ts +15 -0
  41. package/lib/score.js +74 -5
  42. package/lib/service.d.ts +2 -2
  43. package/lib/service.js +5 -2
  44. package/lib/skill-render.d.ts +15 -0
  45. package/lib/skill-render.js +30 -0
  46. package/lib/skill.d.ts +2 -5
  47. package/lib/skill.js +2 -29
  48. package/lib/skillquality.d.ts +1 -2
  49. package/lib/skillquality.js +2 -2
  50. package/lib/store.d.ts +1 -3
  51. package/lib/store.js +0 -7
  52. package/lib/tool.js +22 -1
  53. package/lib/types.d.ts +8 -0
  54. package/lib/usage.d.ts +32 -0
  55. package/lib/usage.js +84 -0
  56. package/lib/validate.d.ts +12 -2
  57. package/lib/validate.js +26 -1
  58. package/lib/wrapup-command.d.ts +8 -0
  59. package/lib/wrapup-command.js +211 -0
  60. package/lib/wrapup.d.ts +14 -9
  61. package/lib/wrapup.js +24 -36
  62. package/package.json +8 -8
@@ -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
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. */
package/lib/review.js CHANGED
@@ -1,6 +1,6 @@
1
- import { BlockAssembler, createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
2
1
  import { extractJsonObject } from "./plan.js";
3
2
  import { formatHarnessStateForPrompt, historyForPrompt } from "./render.js";
3
+ import { streamText } from "./llm-text.js";
4
4
  export const AUTO_REVIEW_SYSTEM_PROMPT = `You are the automatic /evolve review gate.
5
5
 
6
6
  Decide whether this checkpoint should run /evolve. Auto /evolve writes local
@@ -88,7 +88,9 @@ export function serializeSurface(events, maxChars) {
88
88
  }
89
89
  export async function reviewAutoRefine(ctx, options) {
90
90
  const { agent, state, history } = options;
91
- if (!agent.options.provider || !agent.options.model) {
91
+ const provider = options.overrideProvider ?? agent.options.provider;
92
+ const model = options.overrideModel ?? agent.options.model;
93
+ if (!provider || !model) {
92
94
  throw new Error("evolve: no provider/model route for the review gate");
93
95
  }
94
96
  if (!options.trajectory || options.trajectory.length === 0) {
@@ -101,44 +103,14 @@ export async function reviewAutoRefine(ctx, options) {
101
103
  `<conversation>\n${options.trajectory}\n</conversation>`,
102
104
  "Return shouldRefine=true when the trajectory contains evidence useful to this session's future turns. Prefer local edits; do not ask for global refinement here.",
103
105
  ].join("\n\n");
104
- const assembler = new BlockAssembler();
105
- for await (const chunk of ctx.llm.stream({
106
- provider: agent.options.provider,
107
- model: agent.options.model,
106
+ const text = await streamText(ctx, {
107
+ provider,
108
+ model,
108
109
  system: AUTO_REVIEW_SYSTEM_PROMPT,
109
- messages: [
110
- createUserMessage({
111
- content: [{ type: "text", text: userPrompt }],
112
- source: { kind: "plugin", plugin: "dsh-continual-evolve" },
113
- }),
114
- ],
115
- // Force non-reasoning output so the model spends its budget on the JSON
116
- // answer, not on visible thinking (reasoning models otherwise produce
117
- // zero text blocks — the exact failure recorded in reviews.jsonl).
118
- reasoningEffort: ReasoningEffortId("off"),
110
+ prompt: userPrompt,
119
111
  maxTokens: options.budgetTokens ?? 8000,
120
- ...(options.signal ? { signal: options.signal } : {}),
121
- })) {
122
- assembler.push(chunk);
123
- }
124
- const finish = assembler.finish;
125
- if (finish.kind === "error") {
126
- throw new Error(`evolve: review gate call failed: ${finish.failure?.message ?? "unknown"}`);
127
- }
128
- if (finish.kind === "aborted") {
129
- throw new Error("evolve: review gate call aborted");
130
- }
131
- if (finish.kind === "max-tokens") {
132
- throw new Error("evolve: review gate output budget exhausted (max-tokens)");
133
- }
134
- const text = assembler
135
- .blocks()
136
- .filter((block) => block.type === "text")
137
- .map((block) => block.text)
138
- .join("\n");
139
- if (text.length === 0) {
140
- throw new Error("evolve: review gate produced no text");
141
- }
112
+ signal: options.signal,
113
+ });
142
114
  return parseAutoRefineReview(text);
143
115
  }
144
116
  //# sourceMappingURL=review.js.map
package/lib/rollback.d.ts CHANGED
@@ -3,9 +3,7 @@
3
3
  * result, in reverse order. Rollback is pure data transformation — no LLM
4
4
  * is asked to "guess" the previous state.
5
5
  */
6
- import type { HarnessEntry, RefinementProposal, RefinementResult } from "./types.js";
6
+ import type { RefinementProposal, RefinementResult } from "./types.js";
7
7
  /** Build the inverse proposal for an applied refinement. */
8
8
  export declare function rollbackProposal(target: RefinementResult): RefinementProposal;
9
- /** Recreate an entry from a prior snapshot (used when an inverse edit is an update). */
10
- export declare function restoreEntry(prior: HarnessEntry): HarnessEntry;
11
9
  //# sourceMappingURL=rollback.d.ts.map
package/lib/rollback.js CHANGED
@@ -58,12 +58,4 @@ function inverseEdit(edit, refinementId) {
58
58
  }
59
59
  return undefined;
60
60
  }
61
- /** Recreate an entry from a prior snapshot (used when an inverse edit is an update). */
62
- export function restoreEntry(prior) {
63
- return {
64
- ...prior,
65
- updated_at: new Date().toISOString(),
66
- version: prior.version + 1,
67
- };
68
- }
69
61
  //# sourceMappingURL=rollback.js.map
package/lib/score.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface AggregateResult extends Record<string, number | null> {
23
23
  failed: number;
24
24
  /** Total cells considered (ok + failed). */
25
25
  total: number;
26
+ /** Gap C3: total wall-clock duration of all cells in milliseconds. */
27
+ totalDurationMs: number;
26
28
  }
27
29
  /**
28
30
  * Aggregate raw cells into code-owned per-case means + overall mean.
@@ -38,6 +40,19 @@ export interface Decision {
38
40
  }
39
41
  /** Human-readable decision report with per-case before → after deltas. */
40
42
  export declare function decisionReport(reference: EvaluationEntry, candidate: EvaluationEntry, decision: Decision): string[];
43
+ /**
44
+ * Gap A3 (version_changed semantics): detect cells whose case material
45
+ * changed between the reference and candidate evaluation runs. A candidate
46
+ * cell whose `caseHash` differs from the reference cell of the SAME case
47
+ * means the statement/rubric was edited between the two runs — its score is
48
+ * not comparable to the baseline and must not count toward the decision.
49
+ *
50
+ * The check is conservative: cells without a hash on either side (pre-A3
51
+ * data) and cells already failed are left untouched. Mismatched cells are
52
+ * returned re-marked as `failed` with a reason in notes, so aggregation
53
+ * excludes them and the acceptance rule can reject the round.
54
+ */
55
+ export declare function flagMaterialDrift(reference: EvaluationEntry, candidateCells: readonly CellScore[]): CellScore[];
41
56
  /**
42
57
  * Non-regressive acceptance rule (Self-Harness style):
43
58
  * the candidate is accepted iff its overall mean is STRICTLY higher than the
package/lib/score.js CHANGED
@@ -26,11 +26,18 @@ export function aggregate(cells) {
26
26
  perCase[caseId] = mean(scores);
27
27
  }
28
28
  const all = [...byCase.values()].flat();
29
+ let totalDurationMs = 0;
30
+ for (const cell of cells) {
31
+ if (cell.durationMs !== undefined && cell.durationMs >= 0) {
32
+ totalDurationMs += cell.durationMs;
33
+ }
34
+ }
29
35
  return {
30
36
  ...perCase,
31
37
  overall: all.length > 0 ? mean(all) : null,
32
38
  failed,
33
39
  total: cells.length,
40
+ totalDurationMs,
34
41
  };
35
42
  }
36
43
  export function entryFromCells(label, cells, refinementId) {
@@ -47,8 +54,12 @@ export function entryFromCells(label, cells, refinementId) {
47
54
  /** Human-readable decision report with per-case before → after deltas. */
48
55
  export function decisionReport(reference, candidate, decision) {
49
56
  const lines = [`overall: ${reference.overall ?? "?"} → ${candidate.overall ?? "?"}`];
50
- for (const [caseId, refScore] of Object.entries(reference.aggregate)) {
51
- if (caseId === "overall" || caseId === "failed" || caseId === "total" || refScore === null)
57
+ // Per-case deltas over the actual evaluated cases only (aggregate() mixes
58
+ // case means with metadata keys like totalDurationMs never render those
59
+ // as cases).
60
+ for (const caseId of new Set(reference.cells.map((cell) => cell.caseId))) {
61
+ const refScore = reference.aggregate[caseId];
62
+ if (refScore === null || refScore === undefined)
52
63
  continue;
53
64
  const candScore = candidate.aggregate[caseId];
54
65
  const failedMark = isCaseFailed(reference, caseId) || isCaseFailed(candidate, caseId) ? " (failed)" : "";
@@ -59,11 +70,55 @@ export function decisionReport(reference, candidate, decision) {
59
70
  if (refFailed > 0 || candFailed > 0) {
60
71
  lines.push(`failed cells: reference ${refFailed}/${reference.aggregate.total ?? 0} · candidate ${candFailed}/${candidate.aggregate.total ?? 0}`);
61
72
  }
73
+ // Gap C3: show duration summary when available.
74
+ const refDuration = reference.aggregate.totalDurationMs ?? 0;
75
+ const candDuration = candidate.aggregate.totalDurationMs ?? 0;
76
+ if (refDuration > 0 || candDuration > 0) {
77
+ lines.push(`duration: ${formatDuration(refDuration)} → ${formatDuration(candDuration)}`);
78
+ }
62
79
  lines.push(decision.accepted
63
80
  ? "DECISION: ACCEPTED — overall improved, no regression"
64
81
  : `DECISION: REJECTED — ${decision.reasons.join("; ")}`);
65
82
  return lines;
66
83
  }
84
+ /**
85
+ * Gap A3 (version_changed semantics): detect cells whose case material
86
+ * changed between the reference and candidate evaluation runs. A candidate
87
+ * cell whose `caseHash` differs from the reference cell of the SAME case
88
+ * means the statement/rubric was edited between the two runs — its score is
89
+ * not comparable to the baseline and must not count toward the decision.
90
+ *
91
+ * The check is conservative: cells without a hash on either side (pre-A3
92
+ * data) and cells already failed are left untouched. Mismatched cells are
93
+ * returned re-marked as `failed` with a reason in notes, so aggregation
94
+ * excludes them and the acceptance rule can reject the round.
95
+ */
96
+ export function flagMaterialDrift(reference, candidateCells) {
97
+ const referenceHashes = new Map();
98
+ for (const cell of reference.cells) {
99
+ if (cell.status !== "failed" && cell.caseHash !== undefined && !referenceHashes.has(cell.caseId)) {
100
+ referenceHashes.set(cell.caseId, cell.caseHash);
101
+ }
102
+ }
103
+ if (referenceHashes.size === 0) {
104
+ return [...candidateCells];
105
+ }
106
+ return candidateCells.map((cell) => {
107
+ if (cell.status === "failed" || cell.caseHash === undefined) {
108
+ return cell;
109
+ }
110
+ const refHash = referenceHashes.get(cell.caseId);
111
+ if (refHash !== undefined && refHash !== cell.caseHash) {
112
+ return {
113
+ ...cell,
114
+ status: "failed",
115
+ passed: false,
116
+ notes: `materials changed: case ${cell.caseId} hash ${cell.caseHash} ≠ reference ${refHash} (re-run the reference or fix the material)`,
117
+ };
118
+ }
119
+ return cell;
120
+ });
121
+ }
67
122
  function isCaseFailed(entry, caseId) {
68
123
  return entry.cells.some((cell) => cell.caseId === caseId && cell.status === "failed");
69
124
  }
@@ -93,9 +148,17 @@ export function decide(reference, candidate, opts) {
93
148
  if (candidate.overall <= reference.overall) {
94
149
  reasons.push(`overall not improved: ${candidate.overall} <= ${reference.overall}`);
95
150
  }
96
- for (const [caseId, refScore] of Object.entries(reference.aggregate)) {
97
- if (caseId === "overall" || caseId === "failed" || caseId === "total" || refScore === null)
98
- continue;
151
+ // Per-case regression is judged ONLY over the cases that actually exist in
152
+ // the evaluation aggregate() mixes per-case means with metadata keys
153
+ // (failed/total/totalDurationMs), and iterating raw keys would (and did)
154
+ // treat totalDurationMs as a case score, rejecting a candidate whose run
155
+ // merely took longer. Derived from reference.cells, never from the
156
+ // aggregate key set.
157
+ for (const caseId of new Set(reference.cells.map((cell) => cell.caseId))) {
158
+ const refScore = reference.aggregate[caseId];
159
+ if (refScore === null || refScore === undefined) {
160
+ continue; // case had no comparable mean (all cells failed) — nothing to regress
161
+ }
99
162
  const candScore = candidate.aggregate[caseId];
100
163
  if (candScore === null || candScore === undefined) {
101
164
  reasons.push(`candidate missing case ${caseId}`);
@@ -119,4 +182,10 @@ function clampScore(score) {
119
182
  function round2(value) {
120
183
  return Math.round(value * 100) / 100;
121
184
  }
185
+ /** Gap C3: human-readable duration (ms → "1.2s" or "340ms"). */
186
+ function formatDuration(ms) {
187
+ if (ms < 1000)
188
+ return `${Math.round(ms)}ms`;
189
+ return `${(ms / 1000).toFixed(1)}s`;
190
+ }
122
191
  //# sourceMappingURL=score.js.map
package/lib/service.d.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import type { EntrySource, HarnessScope, RefinementProposal, RefinementResult } from "./types.js";
7
7
  import { applyRefinementProposal } from "./apply.js";
8
- import { storePaths } from "./store.js";
9
8
  export interface ApplyContext {
10
9
  scope: HarnessScope;
11
10
  sessionId?: string;
@@ -13,6 +12,8 @@ export interface ApplyContext {
13
12
  baselineState?: Parameters<typeof applyRefinementProposal>[0];
14
13
  /** Trajectory citation stamped into newly created entries (see apply.ts). */
15
14
  source?: EntrySource | undefined;
15
+ /** Marks the resulting refinement as the deterministic rollback of another (audit chain). */
16
+ rollbackOf?: string | undefined;
16
17
  }
17
18
  export interface EvolutionHooks {
18
19
  /** Called after every applied refinement (side-effect boundary: skills sync, etc.). */
@@ -26,5 +27,4 @@ export declare function createEvolutionEngine(baseDir: string, hooks?: Evolution
26
27
  baseDir: string;
27
28
  };
28
29
  export type EvolutionEngine = ReturnType<typeof createEvolutionEngine>;
29
- export { storePaths };
30
30
  //# sourceMappingURL=service.d.ts.map
package/lib/service.js CHANGED
@@ -17,6 +17,7 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
17
17
  scope,
18
18
  ...(context?.source ? { source: context.source } : {}),
19
19
  ...(context?.baselineState ? { baselineState: context.baselineState } : {}),
20
+ ...(context?.rollbackOf ? { rollbackOf: context.rollbackOf } : {}),
20
21
  });
21
22
  saveHarnessState(paths.stateDir, state);
22
23
  appendResult(paths, result);
@@ -31,12 +32,14 @@ export function createEvolutionEngine(baseDir, hooks = {}) {
31
32
  throw new Error(`Refinement ${refinementId} not found in ${scope} history`);
32
33
  }
33
34
  const proposal = rollbackProposal(target);
34
- return apply(scope, sessionId, proposal);
35
+ // The rollback refinement carries rollbackOf so the audit chain links
36
+ // the inverse operation back to its origin (previously the rollback
37
+ // record only echoed "Rollback refinement <id>" in its summary text).
38
+ return apply(scope, sessionId, proposal, { scope, rollbackOf: refinementId });
35
39
  }
36
40
  function history(scope, sessionId) {
37
41
  return loadResults(storePaths(baseDir, scope, sessionId));
38
42
  }
39
43
  return { load, apply, rollback, history, baseDir };
40
44
  }
41
- export { storePaths };
42
45
  //# sourceMappingURL=service.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Skill rendering: pure functions that convert harness entries into
3
+ * SKILL.md documents. Extracted from skill.ts to break the circular
4
+ * dependency between skill.ts ↔ skillquality.ts.
5
+ *
6
+ * Both skill.ts (materializer) and skillquality.ts (validator) need
7
+ * these rendering functions; importing from this shared leaf module
8
+ * keeps the dependency graph acyclic.
9
+ */
10
+ import type { HarnessEntry } from "./types.js";
11
+ /** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
12
+ export declare function skillNameOf(id: string): string;
13
+ /** Render a harness skill entry as a discoverable SKILL.md document. */
14
+ export declare function renderSkillMarkdown(entry: HarnessEntry): string;
15
+ //# sourceMappingURL=skill-render.d.ts.map
@@ -0,0 +1,30 @@
1
+ /** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
2
+ export function skillNameOf(id) {
3
+ return id.toLowerCase().replace(/_/g, "-");
4
+ }
5
+ /** Render a harness skill entry as a discoverable SKILL.md document. */
6
+ export function renderSkillMarkdown(entry) {
7
+ const lines = [
8
+ "---",
9
+ `name: ${skillNameOf(entry.id)}`,
10
+ `description: ${oneLine(entry.title)}`,
11
+ "---",
12
+ "",
13
+ entry.content.trim(),
14
+ ];
15
+ const reference = entry.reference;
16
+ if (reference && typeof reference === "object" && Object.keys(reference).length > 0) {
17
+ lines.push("", "## Invocation");
18
+ for (const [key, value] of Object.entries(reference)) {
19
+ lines.push(`- ${key}: ${JSON.stringify(value)}`);
20
+ }
21
+ }
22
+ if (Object.keys(entry.arguments).length > 0) {
23
+ lines.push("", "## Arguments", "```json", JSON.stringify(entry.arguments, null, 2), "```");
24
+ }
25
+ return `${lines.join("\n").trimEnd()}\n`;
26
+ }
27
+ function oneLine(text) {
28
+ return text.replace(/\s+/g, " ").trim();
29
+ }
30
+ //# sourceMappingURL=skill-render.js.map
package/lib/skill.d.ts CHANGED
@@ -1,10 +1,7 @@
1
- import type { HarnessEntry, RefinementResult } from "./types.js";
2
- /** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
3
- export declare function skillNameOf(id: string): string;
1
+ import type { RefinementResult } from "./types.js";
2
+ export { renderSkillMarkdown, skillNameOf } from "./skill-render.js";
4
3
  /** Resolve and defend the skill directory for an entry id. */
5
4
  export declare function skillDir(skillsRoot: string, id: string): string;
6
- /** Render a harness skill entry as a discoverable SKILL.md document. */
7
- export declare function renderSkillMarkdown(entry: HarnessEntry): string;
8
5
  /**
9
6
  * Apply the skill-kind edits of an applied refinement to the skills root.
10
7
  * Returns materialization warnings (rendered-SKILL.md mechanical problems
package/lib/skill.js CHANGED
@@ -9,11 +9,9 @@
9
9
  */
10
10
  import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { join, resolve, sep } from "node:path";
12
+ import { renderSkillMarkdown, skillNameOf } from "./skill-render.js";
12
13
  import { skillResourceRefs, validateRenderedSkill } from "./skillquality.js";
13
- /** Convert a harness entry id (underscore slug) to a kebab-case skill name. */
14
- export function skillNameOf(id) {
15
- return id.toLowerCase().replace(/_/g, "-");
16
- }
14
+ export { renderSkillMarkdown, skillNameOf } from "./skill-render.js";
17
15
  /** Resolve and defend the skill directory for an entry id. */
18
16
  export function skillDir(skillsRoot, id) {
19
17
  const root = resolve(skillsRoot);
@@ -23,28 +21,6 @@ export function skillDir(skillsRoot, id) {
23
21
  }
24
22
  return dir;
25
23
  }
26
- /** Render a harness skill entry as a discoverable SKILL.md document. */
27
- export function renderSkillMarkdown(entry) {
28
- const lines = [
29
- "---",
30
- `name: ${skillNameOf(entry.id)}`,
31
- `description: ${oneLine(entry.title)}`,
32
- "---",
33
- "",
34
- entry.content.trim(),
35
- ];
36
- const reference = entry.reference;
37
- if (reference && typeof reference === "object" && Object.keys(reference).length > 0) {
38
- lines.push("", "## Invocation");
39
- for (const [key, value] of Object.entries(reference)) {
40
- lines.push(`- ${key}: ${JSON.stringify(value)}`);
41
- }
42
- }
43
- if (Object.keys(entry.arguments).length > 0) {
44
- lines.push("", "## Arguments", "```json", JSON.stringify(entry.arguments, null, 2), "```");
45
- }
46
- return `${lines.join("\n").trimEnd()}\n`;
47
- }
48
24
  /**
49
25
  * Apply the skill-kind edits of an applied refinement to the skills root.
50
26
  * Returns materialization warnings (rendered-SKILL.md mechanical problems
@@ -101,7 +77,4 @@ function removeSkill(skillsRoot, id) {
101
77
  rmSync(dir, { recursive: true, force: true });
102
78
  }
103
79
  }
104
- function oneLine(text) {
105
- return text.replace(/\s+/g, " ").trim();
106
- }
107
80
  //# sourceMappingURL=skill.js.map
@@ -1,5 +1,4 @@
1
1
  import type { HarnessEntry } from "./types.js";
2
- import { skillNameOf } from "./skill.js";
3
2
  /** Relative location of the skill-creator template facts. */
4
3
  export declare const SKILL_CREATOR_TEMPLATE_REL: string;
5
4
  /**
@@ -77,5 +76,5 @@ export declare function validateRenderedSkill(entry: HarnessEntry): string[];
77
76
  */
78
77
  export declare function skillResourceRefs(content: string): string[];
79
78
  /** Kebab-case name under which the entry materializes (exported for diagnostics). */
80
- export { skillNameOf };
79
+ export { skillNameOf } from "./skill-render.js";
81
80
  //# sourceMappingURL=skillquality.d.ts.map