dsh-continual-evolve 0.7.1 → 0.7.3

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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Serial latest-boundary scheduler for automatic memory+review snapshots.
3
+ *
4
+ * One runner exists per session. A burst is coalesced by durable cursor rather
5
+ * than promise arrival: once multiple pending captures settle, the highest
6
+ * comparable boundary runs first. A cursor advances only after success/no-op;
7
+ * errors, aborts, and stale boundaries leave it unchanged.
8
+ */
9
+ /** Compare two scheduler cursors; undefined means the cursor families differ. */
10
+ export function compareReviewCursors(left, right) {
11
+ if (left === right)
12
+ return 0;
13
+ const leftMatch = /^(seq|index):(\d+)$/.exec(left);
14
+ const rightMatch = /^(seq|index):(\d+)$/.exec(right);
15
+ if (!leftMatch || !rightMatch || leftMatch[1] !== rightMatch[1])
16
+ return undefined;
17
+ return Number(leftMatch[2]) - Number(rightMatch[2]);
18
+ }
19
+ /** Bound unresolved acquisitions; actual auto capture is serialized per session. */
20
+ const MAX_PENDING_ACQUISITIONS = 16;
21
+ /**
22
+ * Create a scheduler with ZCode-compatible serial/coalescing semantics.
23
+ *
24
+ * @param execute - one serial extraction/review operation
25
+ * @param cursorOf - extracts the durable boundary from a snapshot
26
+ */
27
+ export function createReviewScheduler(execute, cursorOf) {
28
+ let cursor;
29
+ let running;
30
+ let shuttingDown = false;
31
+ let wakePending;
32
+ const pending = [];
33
+ const shutdownController = new AbortController();
34
+ const signal = shutdownController.signal;
35
+ const trackPending = (acquisition) => {
36
+ const entry = {};
37
+ entry.promise = acquisition.then((result) => {
38
+ entry.result = result;
39
+ wakePending?.();
40
+ return result;
41
+ });
42
+ pending.push(entry);
43
+ while (pending.length > MAX_PENDING_ACQUISITIONS)
44
+ pending.shift();
45
+ };
46
+ const takeHighestPending = async () => {
47
+ while (!signal.aborted) {
48
+ for (let index = pending.length - 1; index >= 0; index -= 1) {
49
+ if (pending[index]?.result?.status === "error")
50
+ pending.splice(index, 1);
51
+ }
52
+ if (pending.length === 0)
53
+ return undefined;
54
+ const settled = pending.filter((entry) => entry.result !== undefined);
55
+ if (settled.length > 0) {
56
+ let bestIndex = -1;
57
+ let bestCursor;
58
+ for (let index = 0; index < settled.length; index += 1) {
59
+ const entry = settled[index];
60
+ if (!entry || entry.result?.status !== "acquired")
61
+ continue;
62
+ const candidate = cursorOf(entry.result.snapshot);
63
+ const order = bestCursor === undefined || candidate === undefined
64
+ ? 1
65
+ : compareReviewCursors(candidate, bestCursor);
66
+ if (bestIndex < 0 || order === undefined || order > 0) {
67
+ bestIndex = pending.indexOf(entry);
68
+ bestCursor = candidate;
69
+ }
70
+ }
71
+ if (bestIndex < 0) {
72
+ pending.length = 0;
73
+ return undefined;
74
+ }
75
+ const chosen = pending[bestIndex];
76
+ pending.splice(bestIndex, 1);
77
+ for (let index = pending.length - 1; index >= 0; index -= 1) {
78
+ if (pending[index]?.result !== undefined)
79
+ pending.splice(index, 1);
80
+ }
81
+ return chosen?.result;
82
+ }
83
+ await new Promise((resolve) => {
84
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
85
+ const onAbort = () => {
86
+ cleanup();
87
+ resolve();
88
+ };
89
+ wakePending = () => {
90
+ cleanup();
91
+ wakePending = undefined;
92
+ resolve();
93
+ };
94
+ signal.addEventListener("abort", onAbort, { once: true });
95
+ });
96
+ }
97
+ return undefined;
98
+ };
99
+ const processSnapshot = async (snapshot) => {
100
+ if (shuttingDown || signal.aborted)
101
+ return;
102
+ const nextCursor = cursorOf(snapshot);
103
+ if (cursor !== undefined && nextCursor !== undefined) {
104
+ const order = compareReviewCursors(nextCursor, cursor);
105
+ if (order !== undefined && order <= 0)
106
+ return;
107
+ }
108
+ let status;
109
+ try {
110
+ status = await execute({ snapshot, signal });
111
+ }
112
+ catch {
113
+ return;
114
+ }
115
+ if (!shuttingDown && (status === "success" || status === "no-op") && nextCursor !== undefined) {
116
+ const order = cursor === undefined ? 1 : compareReviewCursors(nextCursor, cursor);
117
+ if (order === undefined || order > 0)
118
+ cursor = nextCursor;
119
+ }
120
+ };
121
+ const run = async (first) => {
122
+ try {
123
+ let acquisition = await waitForSnapshotAcquisitionOrShutdown(first, signal);
124
+ while (acquisition.status !== "shutdown" && !shuttingDown) {
125
+ if (acquisition.status === "acquired")
126
+ await processSnapshot(acquisition.snapshot);
127
+ const next = await takeHighestPending();
128
+ if (!next)
129
+ break;
130
+ acquisition = next;
131
+ }
132
+ }
133
+ finally {
134
+ if (shuttingDown) {
135
+ pending.length = 0;
136
+ wakePending?.();
137
+ wakePending = undefined;
138
+ }
139
+ running = undefined;
140
+ }
141
+ };
142
+ return {
143
+ async drain() {
144
+ while (running)
145
+ await running;
146
+ },
147
+ getCursor() {
148
+ return cursor;
149
+ },
150
+ hasPendingWork() {
151
+ return running !== undefined || pending.length > 0;
152
+ },
153
+ schedule(snapshot) {
154
+ if (shuttingDown)
155
+ return;
156
+ const acquisition = acquireSnapshot(snapshot);
157
+ if (running)
158
+ trackPending(acquisition);
159
+ else
160
+ running = run(acquisition);
161
+ },
162
+ shutdown() {
163
+ if (shuttingDown)
164
+ return;
165
+ shuttingDown = true;
166
+ pending.length = 0;
167
+ shutdownController.abort();
168
+ wakePending?.();
169
+ wakePending = undefined;
170
+ },
171
+ };
172
+ }
173
+ function acquireSnapshot(snapshot) {
174
+ return Promise.resolve(snapshot).then((value) => ({ status: "acquired", snapshot: value }), () => ({ status: "error" }));
175
+ }
176
+ function waitForSnapshotAcquisitionOrShutdown(acquisition, signal) {
177
+ if (signal.aborted)
178
+ return Promise.resolve({ status: "shutdown" });
179
+ return new Promise((resolve) => {
180
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
181
+ const onAbort = () => {
182
+ cleanup();
183
+ resolve({ status: "shutdown" });
184
+ };
185
+ signal.addEventListener("abort", onAbort, { once: true });
186
+ void acquisition.then((result) => {
187
+ cleanup();
188
+ resolve(result);
189
+ });
190
+ });
191
+ }
192
+ //# sourceMappingURL=review-scheduler.js.map
package/lib/review.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface AutoRefineReview {
15
15
  rationale: string;
16
16
  instructions?: string;
17
17
  }
18
- export type AutoRefineReason = "turn_interval" | "compact" | "goal_blocked";
18
+ export type AutoRefineReason = "turn_snapshot" | "turn_interval" | "compact" | "goal_blocked";
19
19
  export interface AutoRefineReviewContext {
20
20
  reason: AutoRefineReason;
21
21
  turnsSinceLastReview: number;
@@ -27,6 +27,8 @@ export interface ReviewOptions {
27
27
  context: AutoRefineReviewContext;
28
28
  /** Serialized trajectory text; when absent the gate is skipped by the caller. */
29
29
  trajectory?: string;
30
+ /** Incremental surface rows captured for this turn snapshot. */
31
+ trajectoryEvents?: readonly unknown[];
30
32
  signal?: AbortSignal;
31
33
  budgetTokens?: number;
32
34
  /** Gap C1: optional provider/model override for the review gate (cheaper model). */
@@ -42,7 +44,7 @@ export interface ReviewOptions {
42
44
  /** Direct-call token ledger destination; the review phase is fixed by this module. */
43
45
  tokenUsage?: TokenUsageTarget;
44
46
  }
45
- 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}";
47
+ export declare const AUTO_REVIEW_SYSTEM_PROMPT = "You are the automatic /evolve review gate.\n\nDecide whether this checkpoint should continue the non-memory /evolve path.\nA dedicated memory extraction agent has already processed the same snapshot\nwith a frozen memory manifest, so do NOT approve solely to create, update,\narchive, or delete a memory entry. Decline with a rationale that the memory\nphase owns that evidence. For the remaining kinds, approve repeated failures,\nreusable tactics, repeated delegation roles, or narrow behavioral policies.\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}";
46
48
  /** Parse the gate's JSON reply. */
47
49
  export declare function parseAutoRefineReview(text: string): AutoRefineReview;
48
50
  /** Serialize surface events to bounded role-prefixed text. */
package/lib/review.js CHANGED
@@ -6,11 +6,12 @@ import { streamText } from "./llm-text.js";
6
6
  import { createTokenUsageObserver } from "./token-usage.js";
7
7
  export const AUTO_REVIEW_SYSTEM_PROMPT = `You are the automatic /evolve review gate.
8
8
 
9
- Decide whether this checkpoint should run /evolve. Auto /evolve writes local
10
- harness state by default, so approve when the trajectory contains evidence
11
- useful to this session's future turns: a repeated failure, a reusable tactic,
12
- a repeated delegation role, a durable fact or preference, a user correction
13
- that should persist, or a narrow behavioral policy.
9
+ Decide whether this checkpoint should continue the non-memory /evolve path.
10
+ A dedicated memory extraction agent has already processed the same snapshot
11
+ with a frozen memory manifest, so do NOT approve solely to create, update,
12
+ archive, or delete a memory entry. Decline with a rationale that the memory
13
+ phase owns that evidence. For the remaining kinds, approve repeated failures,
14
+ reusable tactics, repeated delegation roles, or narrow behavioral policies.
14
15
 
15
16
  The current harness state below includes GLOBAL entries (scope=global) plus
16
17
  this session's local entries (scope=local). When a topic is already covered
@@ -101,7 +102,7 @@ export async function reviewAutoRefine(ctx, options) {
101
102
  }
102
103
  // Route A carries the judged conversation as a session-derived message
103
104
  // prefix and drops the flat block; an empty prefix falls back to B.
104
- const events = sessionEventsOf(agent);
105
+ const events = options.trajectoryEvents ?? sessionEventsOf(agent);
105
106
  const routing = resolvePrefixCache(options.prefixCache);
106
107
  const route = detectPlannerRoute(events, routing.mode);
107
108
  let prefixMessages = [];
package/lib/runtime.d.ts CHANGED
@@ -1,6 +1,9 @@
1
+ /** On-disk runtime gate state. Version 2 adds an explicit enable bit. */
1
2
  export interface GateRuntime {
2
- version: 1;
3
- /** True while the human paused the automatic review gate. */
3
+ version: 2;
4
+ /** Whether the human has enabled automatic review. */
5
+ enabled: boolean;
6
+ /** Whether the human has explicitly paused automatic review. */
4
7
  paused: boolean;
5
8
  /** ISO timestamp of the last pause/resume write. */
6
9
  updatedAt: string;
@@ -8,17 +11,14 @@ export interface GateRuntime {
8
11
  /** Full path of the runtime switch file. */
9
12
  export declare function runtimePath(baseDir: string): string;
10
13
  /**
11
- * Load the runtime switch; fail open to "running" on any unreadable or
12
- * malformed content (called on every gate trigger, so it must never throw
13
- * for a bad file).
14
+ * Load the runtime switch. Missing, malformed, or legacy content uses the
15
+ * supplied configured default and never throws; this is a trigger-path read.
14
16
  */
15
- export declare function loadGateRuntime(baseDir: string): GateRuntime;
16
- /**
17
- * Persist the pause switch atomically (temp file + rename). Never throws
18
- * for a bad file — callers (pause/resume commands) surface real IO errors
19
- * through the command's own error path instead.
20
- */
21
- export declare function saveGateRuntime(baseDir: string, paused: boolean): string;
22
- /** True while the human paused the automatic review gate (fail-open). */
17
+ export declare function loadGateRuntime(baseDir: string, defaultEnabled?: boolean): GateRuntime;
18
+ /** Persist the runtime switch atomically (temp file + rename). */
19
+ export declare function saveGateRuntime(baseDir: string, paused: boolean, enabled?: boolean): string;
20
+ /** True while the human paused the automatic review gate. */
23
21
  export declare function isGatePaused(baseDir: string): boolean;
22
+ /** Effective automatic-review state, including the explicit enable bit. */
23
+ export declare function isGateEnabled(baseDir: string, defaultEnabled?: boolean): boolean;
24
24
  //# sourceMappingURL=runtime.d.ts.map
package/lib/runtime.js CHANGED
@@ -1,18 +1,15 @@
1
1
  /**
2
- * Runtime gate switch (#21 P2): a tiny human-owned file that pauses the
3
- * automatic review gate without touching the plugin config or restarting.
2
+ * Runtime gate switch: a small human-owned file that enables or pauses the
3
+ * automatic review listener without editing the profile or restarting DSH.
4
4
  *
5
- * - file: `<baseDir>/evolve/runtime.json` (`{version, paused, updatedAt}`)
6
- * - `/evolve pause` sets paused, `/evolve resume` clears it, `/evolve
7
- * status` shows it alongside the static patch config.
8
- * - the gate consults it on every trigger (turn-interval AND compaction)
9
- * and stays dormant while paused — manual tools/commands keep working.
10
- * - fail-open: a missing or corrupt file means "running". Pausing is the
11
- * exceptional state; a broken file must never silently wedge the gate
12
- * shut (the next save rewrites it cleanly).
5
+ * - file: `<baseDir>/evolve/runtime.json` (`{version: 2, enabled, paused, updatedAt}`)
6
+ * - the file is consulted at every trigger and before scheduler execution;
7
+ * - `/evolve pause` sets `paused`, `/evolve resume` sets `enabled=true` and
8
+ * clears the pause, while manual tools and commands remain independent;
9
+ * - a missing or malformed file uses the caller's configured default. A
10
+ * legacy v1 `{paused}` file is readable and preserves its pause state.
13
11
  */
14
- import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
15
- import { readFileSync } from "node:fs";
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
16
13
  import { randomUUID } from "node:crypto";
17
14
  import { join } from "node:path";
18
15
  import { EVOLVE_DIR } from "./store.js";
@@ -21,57 +18,54 @@ const RUNTIME_FILE = "runtime.json";
21
18
  export function runtimePath(baseDir) {
22
19
  return join(baseDir, EVOLVE_DIR, RUNTIME_FILE);
23
20
  }
24
- function running() {
25
- return { version: 1, paused: false, updatedAt: new Date(0).toISOString() };
21
+ function state(enabled, paused) {
22
+ return { version: 2, enabled, paused, updatedAt: new Date(0).toISOString() };
26
23
  }
27
24
  /**
28
- * Load the runtime switch; fail open to "running" on any unreadable or
29
- * malformed content (called on every gate trigger, so it must never throw
30
- * for a bad file).
25
+ * Load the runtime switch. Missing, malformed, or legacy content uses the
26
+ * supplied configured default and never throws; this is a trigger-path read.
31
27
  */
32
- export function loadGateRuntime(baseDir) {
28
+ export function loadGateRuntime(baseDir, defaultEnabled = false) {
33
29
  const path = runtimePath(baseDir);
34
- if (!existsSync(path)) {
35
- return running();
36
- }
30
+ if (!existsSync(path))
31
+ return state(defaultEnabled, false);
37
32
  try {
38
33
  const raw = JSON.parse(readFileSync(path, "utf8"));
39
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
40
- return running();
41
- }
34
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
35
+ return state(defaultEnabled, false);
42
36
  const record = raw;
37
+ const paused = record["paused"] === true;
38
+ // v1 had no enable bit. Its only meaningful state was pause, so retain
39
+ // the configured default when it is not paused.
40
+ const enabled = typeof record["enabled"] === "boolean" ? record["enabled"] : defaultEnabled;
43
41
  return {
44
- version: 1,
45
- paused: record["paused"] === true,
42
+ version: 2,
43
+ enabled,
44
+ paused,
46
45
  updatedAt: typeof record["updatedAt"] === "string" ? record["updatedAt"] : new Date(0).toISOString(),
47
46
  };
48
47
  }
49
48
  catch {
50
- return running();
49
+ return state(defaultEnabled, false);
51
50
  }
52
51
  }
53
- /**
54
- * Persist the pause switch atomically (temp file + rename). Never throws
55
- * for a bad file — callers (pause/resume commands) surface real IO errors
56
- * through the command's own error path instead.
57
- */
58
- export function saveGateRuntime(baseDir, paused) {
52
+ /** Persist the runtime switch atomically (temp file + rename). */
53
+ export function saveGateRuntime(baseDir, paused, enabled = true) {
59
54
  const path = runtimePath(baseDir);
60
55
  mkdirSync(join(baseDir, EVOLVE_DIR), { recursive: true });
61
56
  const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
62
- const payload = { version: 1, paused, updatedAt: new Date().toISOString() };
57
+ const payload = { version: 2, enabled, paused, updatedAt: new Date().toISOString() };
63
58
  writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
64
59
  try {
65
60
  renameSync(tempPath, path);
66
61
  }
67
62
  finally {
68
- if (existsSync(tempPath)) {
63
+ if (existsSync(tempPath))
69
64
  unlinkSync(tempPath);
70
- }
71
65
  }
72
66
  return path;
73
67
  }
74
- /** True while the human paused the automatic review gate (fail-open). */
68
+ /** True while the human paused the automatic review gate. */
75
69
  export function isGatePaused(baseDir) {
76
70
  try {
77
71
  return loadGateRuntime(baseDir).paused;
@@ -80,4 +74,14 @@ export function isGatePaused(baseDir) {
80
74
  return false;
81
75
  }
82
76
  }
77
+ /** Effective automatic-review state, including the explicit enable bit. */
78
+ export function isGateEnabled(baseDir, defaultEnabled = false) {
79
+ try {
80
+ const current = loadGateRuntime(baseDir, defaultEnabled);
81
+ return current.enabled && !current.paused;
82
+ }
83
+ catch {
84
+ return defaultEnabled;
85
+ }
86
+ }
83
87
  //# sourceMappingURL=runtime.js.map
package/lib/service.d.ts CHANGED
@@ -6,6 +6,14 @@
6
6
  import type { EntrySource, HarnessScope, RefinementProposal, RefinementResult } from "./types.js";
7
7
  import { applyRefinementProposal } from "./apply.js";
8
8
  import type { HistoryRetention } from "./store.js";
9
+ /** A post-commit apply failure that still carries the durable applied result. */
10
+ export declare class EvolutionApplyPostCommitError extends Error {
11
+ readonly result: RefinementResult;
12
+ readonly scope: HarnessScope;
13
+ readonly storeId: string | undefined;
14
+ readonly cause: unknown;
15
+ constructor(message: string, result: RefinementResult, scope: HarnessScope, storeId: string | undefined, cause: unknown);
16
+ }
9
17
  export interface ApplyContext {
10
18
  scope: HarnessScope;
11
19
  sessionId?: string;
@@ -32,6 +40,7 @@ export declare function createEvolutionEngine(baseDir: string, hooks?: Evolution
32
40
  load: (scope: HarnessScope, sessionId: string | undefined) => import("./types.js").HarnessState;
33
41
  apply: (scope: HarnessScope, sessionId: string | undefined, proposal: RefinementProposal, context?: ApplyContext) => RefinementResult;
34
42
  rollback: (scope: HarnessScope, sessionId: string | undefined, refinementId: string) => RefinementResult;
43
+ rollbackResult: (scope: HarnessScope, sessionId: string | undefined, target: RefinementResult) => RefinementResult;
35
44
  history: (scope: HarnessScope, sessionId: string | undefined) => RefinementResult[];
36
45
  baseDir: string;
37
46
  retention: HistoryRetention;
package/lib/service.js CHANGED
@@ -5,6 +5,20 @@ import { rollbackProposal } from "./rollback.js";
5
5
  import { loadHarnessState, saveHarnessState } from "./state.js";
6
6
  import { appendResult, loadResults, pruneJsonlFile, pruneSnapshots, resolveHistoryRetention, snapshotBefore, storePaths } from "./store.js";
7
7
  import { CONFLICT_BLOCK_SCORE, CONFLICT_WARN_SCORE, buildConflictNotice, mostSimilarEntry, secretLeakReason } from "./promotion.js";
8
+ /** A post-commit apply failure that still carries the durable applied result. */
9
+ export class EvolutionApplyPostCommitError extends Error {
10
+ result;
11
+ scope;
12
+ storeId;
13
+ cause;
14
+ constructor(message, result, scope, storeId, cause) {
15
+ super(message);
16
+ this.result = result;
17
+ this.scope = scope;
18
+ this.storeId = storeId;
19
+ this.cause = cause;
20
+ }
21
+ }
8
22
  export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
9
23
  const retention = resolveHistoryRetention(opts.historyRetain);
10
24
  /**
@@ -26,27 +40,29 @@ export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
26
40
  // 2026-08-28 B1: the two roles were previously folded into one object,
27
41
  // which made the advertised guard unreachable).
28
42
  const state = load(scope, sessionId);
29
- // Write-time conflict guard (R2): global creates are checked against
30
- // the existing same-kind entries BEFORE any side effect — a
31
- // near-duplicate is rejected with an actionable error (evolve_update
32
- // instead), a moderate overlap proceeds stamped with
43
+ // Write-time conflict guard (R2): persistent-scope creates are
44
+ // checked against the existing same-kind entries BEFORE any side
45
+ // effect — a near-duplicate is rejected with an actionable error
46
+ // (evolve_update instead), a moderate overlap proceeds stamped with
33
47
  // CONFLICT_HINT_KEY. Rollbacks bypass the guard: re-creating an entry
34
48
  // that resembles its successor is the point of rollback. Local scope
35
49
  // is never blocked (scratch space); the wrapup/fate promotion path
36
50
  // already enforces its own overlap policy there.
37
51
  //
38
- // Secret-leak guard (P0, same throat): global creates AND updates are
39
- // screened for credential-shaped literals before any side effect — a
40
- // secret reaching the cross-session store is a leak even when the
41
- // entry itself is legitimate. The screen covers every field the edit
42
- // can plant: title, content, and the JSON forms of reference,
43
- // arguments, and metadata (mount embeds reference verbatim into the
44
- // generated plugin file). Fixed patterns, not policy-configurable.
52
+ // Secret-leak guard (P0, same throat): every persistent-scope create
53
+ // and update is screened before any side effect. Project and global
54
+ // stores both outlive the current session, so approval never turns a
55
+ // credential-shaped literal into an allowed write. The screen covers
56
+ // every field the edit can plant: title, content, and the JSON forms
57
+ // of reference, arguments, and metadata. Fixed patterns, not policy-
58
+ // configurable.
45
59
  const warnHits = new Map();
46
- if (scope === "global" && !context?.rollbackOf) {
60
+ if ((scope === "project" || scope === "global") && !context?.rollbackOf) {
47
61
  for (const [index, edit] of proposal.edits.entries()) {
48
62
  if (edit.action === "create" || edit.action === "update") {
49
63
  const screenable = [
64
+ typeof edit.id === "string" ? edit.id : "",
65
+ typeof edit.path === "string" ? edit.path : "",
50
66
  typeof edit.title === "string" ? edit.title : "",
51
67
  typeof edit.content === "string" ? edit.content : "",
52
68
  edit.reference !== undefined ? JSON.stringify(edit.reference) : "",
@@ -100,7 +116,12 @@ export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
100
116
  }
101
117
  }
102
118
  saveHarnessState(paths.stateDir, state);
103
- appendResult(paths, result);
119
+ try {
120
+ appendResult(paths, result);
121
+ }
122
+ catch (cause) {
123
+ throw new EvolutionApplyPostCommitError(`Refinement ${result.id} persisted state but failed to append history: ${cause instanceof Error ? cause.message : String(cause)}`, result, scope, sessionId, cause);
124
+ }
104
125
  // Storage hygiene (#20): bound the append-only past at write time —
105
126
  // snapshots keep the newest N, the per-store history keeps its tail.
106
127
  // Best-effort (never throws): a prune failure must not fail the apply.
@@ -116,9 +137,18 @@ export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
116
137
  catch {
117
138
  // ignored — the next apply retries
118
139
  }
119
- hooks.onApplied?.(result);
140
+ try {
141
+ hooks.onApplied?.(result);
142
+ }
143
+ catch (cause) {
144
+ throw new EvolutionApplyPostCommitError(`Refinement ${result.id} persisted state but a post-commit hook failed: ${cause instanceof Error ? cause.message : String(cause)}`, result, scope, sessionId, cause);
145
+ }
120
146
  return result;
121
147
  }
148
+ /** Roll back an already-observed result without consulting history. */
149
+ function rollbackResult(scope, sessionId, target) {
150
+ return apply(scope, sessionId, rollbackProposal(target), { scope, rollbackOf: target.id });
151
+ }
122
152
  function rollback(scope, sessionId, refinementId) {
123
153
  const paths = storePaths(baseDir, scope, sessionId);
124
154
  const history = loadResults(paths);
@@ -126,15 +156,11 @@ export function createEvolutionEngine(baseDir, hooks = {}, opts = {}) {
126
156
  if (!target) {
127
157
  throw new Error(`Refinement ${refinementId} not found in ${scope} history`);
128
158
  }
129
- const proposal = rollbackProposal(target);
130
- // The rollback refinement carries rollbackOf so the audit chain links
131
- // the inverse operation back to its origin (previously the rollback
132
- // record only echoed "Rollback refinement <id>" in its summary text).
133
- return apply(scope, sessionId, proposal, { scope, rollbackOf: refinementId });
159
+ return rollbackResult(scope, sessionId, target);
134
160
  }
135
161
  function history(scope, sessionId) {
136
162
  return loadResults(storePaths(baseDir, scope, sessionId));
137
163
  }
138
- return { load, apply, rollback, history, baseDir, retention };
164
+ return { load, apply, rollback, rollbackResult, history, baseDir, retention };
139
165
  }
140
166
  //# sourceMappingURL=service.js.map
@@ -2,7 +2,7 @@ import type { TokenUsage } from "@deepseek-ai/dsh-llm";
2
2
  import type { StreamTextObservation, StreamTextUsageObserver } from "./llm-text.js";
3
3
  export declare const TOKEN_USAGE_LEDGER_VERSION = 1;
4
4
  /** Plugin-owned direct call paths represented in the ledger. */
5
- export declare const TOKEN_USAGE_PHASES: readonly ["review", "planner", "wrapup", "fate"];
5
+ export declare const TOKEN_USAGE_PHASES: readonly ["review", "memory", "planner", "wrapup", "fate"];
6
6
  export type TokenUsagePhase = (typeof TOKEN_USAGE_PHASES)[number];
7
7
  /** Whether the provider supplied a usage sample; missing is never treated as zero. */
8
8
  export type TokenUsageStatus = "reported" | "missing";
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Provider-reported token accounting for plugin-owned direct LLM calls.
3
3
  *
4
- * The review gate, planner, wrap-up command, and local-fate classifier all use
5
- * the shared `streamText` helper. These calls do not belong to an Agent turn
6
- * (they pass no sessionId), so the host session token-meter cannot attribute
7
- * them. This ledger records the exact `TokenUsage` emitted by the adapter at
8
- * the one shared stream boundary instead.
4
+ * The review gate, planner, dedicated memory agent, wrap-up command, and
5
+ * local-fate classifier all use the shared model-call boundary. These calls do
6
+ * not belong to an Agent turn (they pass no sessionId), so the host session
7
+ * token-meter cannot attribute them. This ledger records the exact
8
+ * `TokenUsage` emitted by the adapter at the one shared stream boundary.
9
9
  *
10
10
  * Storage: `<baseDir>/evolve/token-usage.jsonl`. The file contains route and
11
11
  * count metadata only — never prompts, responses, or tool arguments — and is
@@ -17,7 +17,7 @@ import { EVOLVE_DIR, pruneJsonlFile } from "./store.js";
17
17
  const TOKEN_USAGE_FILE = "token-usage.jsonl";
18
18
  export const TOKEN_USAGE_LEDGER_VERSION = 1;
19
19
  /** Plugin-owned direct call paths represented in the ledger. */
20
- export const TOKEN_USAGE_PHASES = ["review", "planner", "wrapup", "fate"];
20
+ export const TOKEN_USAGE_PHASES = ["review", "memory", "planner", "wrapup", "fate"];
21
21
  /** Absolute path of the direct-call token ledger. */
22
22
  export function tokenUsagePath(baseDir) {
23
23
  return join(baseDir, EVOLVE_DIR, TOKEN_USAGE_FILE);
@@ -139,7 +139,7 @@ export function renderTokenUsageReport(ledger, retain) {
139
139
  return [
140
140
  `direct LLM token usage: no valid calls in the retained tail.${corrupt}`,
141
141
  `window: last ${retain} call(s), not a lifetime total.`,
142
- "scope: review/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.",
142
+ "scope: review/memory/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.",
143
143
  ];
144
144
  }
145
145
  const totals = aggregateTokenUsage(records);
@@ -166,7 +166,7 @@ export function renderTokenUsageReport(ledger, retain) {
166
166
  const lastTotal = last.usage ? exactTokenTotal(last.usage) : "usage unavailable";
167
167
  lines.push(`last: ${last.phase} ${last.provider}/${last.model} · ${last.outcome} · ${lastTotal} tokens · ${last.timestamp}`);
168
168
  }
169
- lines.push("scope: review/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.");
169
+ lines.push("scope: review/memory/planner/wrapup/fate only; host benchmark subagents, their agent-loop calls, and per-entry injection cost are excluded.");
170
170
  return lines;
171
171
  }
172
172
  function isTokenUsageRecord(value) {