dsh-continual-evolve 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-600%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-665%20passing-brightgreen)]()
11
11
 
12
12
  Continual self-evolution for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): a versioned, auditable, rollback-safe harness state layer — prompt notes, memories, skills, subagent specs — refined from session trajectories.
13
13
 
@@ -61,11 +61,15 @@ Commands (in-session):
61
61
  | `/evolve mount · unmount <skillId>` | hot-mount an executable skill as a live plugin |
62
62
  | `/evolve goal [objective · done · block]` | round-driven auto-review goal |
63
63
  | `/evolve benchmark …` | case lifecycle, runs, acceptance |
64
+ | `/evolve pause · resume · status` | pause/resume the auto-review gate (manual tools keep working), gate state |
65
+ | `/evolve usage` | per-entry injection counts + exact provider-reported tokens for direct review/planner/wrapup/fate calls (benchmark host subagents excluded) |
64
66
 
65
- Model tools: `evolve_list / add / update / delete / rollback`.
67
+ Model tools: `evolve_list / add / update / delete / rollback` (`evolve_delete` takes `id` or a batch `ids` array — one refinement, one approval).
66
68
 
67
69
  For third-party consumers: every applied evolution (gate or manual) appends a structured `evolve_complete` event to `reviews.jsonl` (`src/evolve-event.ts` defines the shape) alongside the human-readable audit records.
68
70
 
71
+ `/evolve usage` also reads `evolve/token-usage.jsonl`: exact provider-reported input/cache/output/total tokens for the plugin's direct review, planner, manual-wrapup, and automatic-fate calls. The report covers a retained tail rather than lifetime usage, distinguishes missing provider samples, and explicitly excludes host benchmark subagents, their agent-loop calls, and per-entry injection attribution.
72
+
69
73
  Injection shape: prompt notes and delegation specs inject with content (≤6/kind × 180 chars, relevance-ranked). Memories and skills appear as a relevance-ordered directory index (`[memory:type:id] title` hooks, capped at 15 lines with a fold counter) — full text via `evolve_list`. Empty store = zero injected tokens.
70
74
 
71
75
  ## Configuration
@@ -92,6 +96,9 @@ Injection shape: prompt notes and delegation specs inject with content (≤6/kin
92
96
  | `autoRollbackOnReject` | `true` | deterministic rollback after a benchmark rejection |
93
97
  | `autoCase` | `true` | failed evolution attempts are captured as draft regression cases (`auto_regression` benchmark) |
94
98
  | `reviewModel` | agent's own | optional cheaper model for the gate (`"provider/model"`) |
99
+ | `plannerPrefixCache` | `auto` | Route A session-prefix input when cache evidence exists (`session` always, `off` legacy flat text) |
100
+ | `plannerPrefixMaxChars` | `12000` | session-prefix budget for Route A planning inputs (chars) |
101
+ | `historyRetain` | `{snapshots: 20, refinements: 500, reviews: 500, tokenUsage: 500}` | storage hygiene: snapshots per store, tail lines per store history, shared `reviews.jsonl` tail, and direct-call `token-usage.jsonl` tail |
95
102
 
96
103
  Example profile patch:
97
104
 
@@ -106,7 +113,7 @@ Example profile patch:
106
113
 
107
114
  ```bash
108
115
  pnpm install && pnpm build # deps + tsc -> lib/
109
- pnpm test # vitest (600 tests)
116
+ pnpm test # vitest (665 tests)
110
117
  pnpm test:coverage # v8 coverage, thresholds enforced in CI
111
118
  pnpm lint # oxlint src test
112
119
  ```
@@ -114,8 +121,8 @@ pnpm lint # oxlint src test
114
121
  Project layout:
115
122
 
116
123
  ```
117
- ├── src/ # engine, tools, commands, gate, fate, benchmark, usage…
118
- ├── test/ # vitest suites (37 files)
124
+ ├── src/ # engine, tools, commands, gate, fate, benchmark, injection + token usage…
125
+ ├── test/ # vitest suites (41 files)
119
126
  ├── lib/ # build output (tsc)
120
127
  ├── docs/
121
128
  │ ├── design.md # full design doc (hardening matrix)
package/README.zh.md CHANGED
@@ -7,7 +7,7 @@
7
7
  [![CI](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml/badge.svg)](https://github.com/ZK-Andy/dsh-continual-evolve/actions/workflows/ci.yml)
8
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
9
  [![Node](https://img.shields.io/badge/node-%5E22.19%20%7C%7C%20%3E%3D24-339933)](package.json)
10
- [![Tests](https://img.shields.io/badge/tests-600%20passing-brightgreen)]()
10
+ [![Tests](https://img.shields.io/badge/tests-665%20passing-brightgreen)]()
11
11
 
12
12
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(`dsh`)的持续自进化插件:一套**版本化、可审计、可回滚**的 harness 状态层——提示词补充、记忆、技能、子代理规格——从会话轨迹中沉淀而来。
13
13
 
@@ -61,11 +61,15 @@ dsh plugin add ZK-Andy/dsh-continual-evolve
61
61
  | `/evolve mount · unmount <skillId>` | 把可执行技能热挂载为 live 插件 |
62
62
  | `/evolve goal [objective · done · block]` | 回合驱动的自进化目标 |
63
63
  | `/evolve benchmark …` | 用例生命周期、运行、接受决策 |
64
+ | `/evolve pause · resume · status` | 暂停/恢复自动门禁(手动工具不受影响)、门禁状态 |
65
+ | `/evolve usage` | 每条目注入次数 + review/planner/wrapup/fate 直属调用的 provider 精确 token(不含 benchmark 宿主子代理) |
64
66
 
65
- 模型工具:`evolve_list / add / update / delete / rollback`。
67
+ 模型工具:`evolve_list / add / update / delete / rollback`(`evolve_delete` 支持 `id` 或批量 `ids` 数组——一次 refinement、一次审批)。
66
68
 
67
69
  第三方消费:每次进化落地(门禁或手动)都会向 `reviews.jsonl` 追加结构化 `evolve_complete` 事件(shape 见 `src/evolve-event.ts`),与人类可读的审计记录并存。
68
70
 
71
+ `/evolve usage` 还会读取 `evolve/token-usage.jsonl`:插件直属 review、planner、手动 wrapup 与自动 fate 调用的 provider 精确 input/cache/output/total token。报告只覆盖保留尾部而非终身累计,单独显示 provider 未返回 usage 的调用,并明确排除宿主 benchmark 子代理、其 agent-loop 调用与逐条 memory 注入归因。
72
+
69
73
  注入形态:prompt 补充与委派规格带内容注入(每 kind ≤6 条 × 180 字符,按相关性排序)。memory/skill 以按相关性排序的目录索引出现(`[memory:type:id] 标题`钩子,15 行封顶 + 折叠计数行)——全文经 `evolve_list` 获取。空 store = 零注入 token。
70
74
 
71
75
  ## 配置
@@ -92,6 +96,9 @@ dsh plugin add ZK-Andy/dsh-continual-evolve
92
96
  | `autoRollbackOnReject` | `true` | benchmark 拒绝后自动确定性回滚 |
93
97
  | `autoCase` | `true` | 失败的进化尝试自动沉淀为 draft 回归用例(`auto_regression` 基准) |
94
98
  | `reviewModel` | agent 自身 | 门禁可选更便宜的模型(`"provider/model"`) |
99
+ | `plannerPrefixCache` | `auto` | 有缓存证据时用会话前缀输入(`session` 总是前缀,`off` 保持旧扁平文本) |
100
+ | `plannerPrefixMaxChars` | `12000` | Route A 会话前缀预算(字符) |
101
+ | `historyRetain` | `{snapshots: 20, refinements: 500, reviews: 500, tokenUsage: 500}` | 存储卫生:每 store 快照数、每 store 历史尾行、共享 `reviews.jsonl` 尾行、直属调用 `token-usage.jsonl` 尾行 |
95
102
 
96
103
  profile patch 示例:
97
104
 
@@ -106,7 +113,7 @@ profile patch 示例:
106
113
 
107
114
  ```bash
108
115
  pnpm install && pnpm build # 依赖 + tsc -> lib/
109
- pnpm test # vitest(600 例)
116
+ pnpm test # vitest(665 例)
110
117
  pnpm test:coverage # v8 覆盖率,CI 强制阈值
111
118
  pnpm lint # oxlint src test
112
119
  ```
@@ -114,8 +121,8 @@ pnpm lint # oxlint src test
114
121
  目录结构:
115
122
 
116
123
  ```
117
- ├── src/ # 引擎、工具、命令、门禁、fate、benchmark、usage…
118
- ├── test/ # vitest 测试套件(37 个文件)
124
+ ├── src/ # 引擎、工具、命令、门禁、fate、benchmark、注入 + token 用量…
125
+ ├── test/ # vitest 测试套件(41 个文件)
119
126
  ├── lib/ # 构建产物(tsc)
120
127
  ├── docs/
121
128
  │ ├── design.md # 完整设计文档(硬化矩阵)
package/lib/apply.js CHANGED
@@ -7,15 +7,23 @@ export function applyRefinementProposal(state, proposal, options) {
7
7
  const now = new Date().toISOString();
8
8
  for (const edit of proposal.edits) {
9
9
  // Store-prefix hygiene: planner edits are written against the MERGED
10
- // view, where colliding local ids carry a `local:` / `global:` prefix.
11
- // A CREATE must never bake that view prefix into a permanent id
12
- // (observed: global entries literally named "local:handoff_todo_…").
13
- // Updates/deletes keep the raw id — they address existing entries.
14
- // (Review audit 2026-08-28 S7: planner update/delete edits against
15
- // the merged view carry the same prefix — strip it there too, they
16
- // address the same underlying entry.)
17
- const prefixlessId = edit.id?.replace(/^(?:local|global):/, "");
18
- const requestedId = edit.action === "create" ? prefixlessId : edit.id && prefixlessId;
10
+ // view, where colliding project/local ids carry a `project:` /
11
+ // `local:` / `global:` prefix. A CREATE must never bake that view
12
+ // prefix into a permanent id (observed: global entries literally
13
+ // named "local:handoff_todo_…").
14
+ // Non-create edits address existing entries: the literal id wins —
15
+ // legacy stores contain entries whose literal id carries a colon
16
+ // prefix, and stripping unconditionally would resolve them to a
17
+ // sibling that does not exist ("entry not found", forcing file-level
18
+ // surgery). Only when the literal id is absent does the stripped
19
+ // form serve as the merged-view fallback.
20
+ // (Review audit 2026-08-28 S7 origin; literal-first fix 2026-09-24.)
21
+ const strippedId = edit.id?.replace(/^(?:local|project|global):/, "");
22
+ const requestedId = edit.action === "create"
23
+ ? strippedId
24
+ : edit.id !== undefined && state.entries[edit.kind]?.[edit.id] !== undefined
25
+ ? edit.id
26
+ : edit.id && strippedId;
19
27
  const computedId = requestedId ?? (edit.action === "create" ? slug(edit.title ?? edit.kind, edit.kind) : undefined);
20
28
  const id = computedId ?? "";
21
29
  // Unknown kinds fail per-edit — a malformed proposal must never crash
package/lib/auto.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { HarnessState, RefinementEdit, RefinementProposal } from "./types.j
4
4
  import type { EvolutionEngine } from "./service.js";
5
5
  import { type AutoRefineReason } from "./review.js";
6
6
  import type { PromotionPolicy } from "./promotion.js";
7
+ import type { PlannerPrefixCacheMode } from "./prefix-cache.js";
7
8
  export interface AutoReviewConfig {
8
9
  intervalTurns: number;
9
10
  maxInputChars: number;
@@ -29,6 +30,12 @@ export interface AutoReviewConfig {
29
30
  * When absent, the review gate uses the agent's own provider/model.
30
31
  */
31
32
  reviewModel?: string;
33
+ /**
34
+ * Prefix-cache routing for the gate input (see reviewAutoRefine).
35
+ * Absent mode → auto-detect; absent budget → the default prefix budget.
36
+ */
37
+ prefixCacheMode?: PlannerPrefixCacheMode;
38
+ prefixMaxChars?: number;
32
39
  /**
33
40
  * Goal-blocked trigger (D3): after this many CONSECUTIVE gate runs that
34
41
  * observe the session goal in phase "blocked", run one local-fate
@@ -52,6 +59,12 @@ export interface AutoReviewConfig {
52
59
  autoCase: boolean;
53
60
  /** Resolved rubric key for the capture's encrypted scaffold rubric. */
54
61
  rubricKey?: Buffer;
62
+ /**
63
+ * Storage hygiene (#20): tail lines kept in the shared reviews.jsonl
64
+ * audit trail. Absent → the store default (readers need a recent
65
+ * window, not the full past).
66
+ */
67
+ reviewsRetain?: number;
55
68
  }
56
69
  export interface GateState {
57
70
  turns: number;
package/lib/auto.js CHANGED
@@ -28,6 +28,8 @@ import { mergeHarnessStates } from "./state.js";
28
28
  import { projectKeyOf } from "./project.js";
29
29
  import { questionServiceOf } from "./approval.js";
30
30
  import { buildEvolveCompleteEvent, emitEvolveComplete } from "./evolve-event.js";
31
+ import { DEFAULT_REVIEWS_RETAIN, pruneJsonlFile } from "./store.js";
32
+ import { isGatePaused } from "./runtime.js";
31
33
  import { captureAutoCase } from "./autocase.js";
32
34
  /** Turns a rejected skill candidate stays silent before being offered again. */
33
35
  export const SKILL_CONSULT_COOLDOWN_TURNS = 10;
@@ -42,6 +44,14 @@ export function registerAutoReview(ctx, engine, config) {
42
44
  }
43
45
  catch (cause) {
44
46
  logger.warn(`failed to record auto-review: ${cause instanceof Error ? cause.message : String(cause)}`);
47
+ return;
48
+ }
49
+ // Storage hygiene (#20): bound the audit trail at write time.
50
+ try {
51
+ pruneJsonlFile(reviewsPath, config.reviewsRetain ?? DEFAULT_REVIEWS_RETAIN);
52
+ }
53
+ catch {
54
+ // ignored — the next record retries
45
55
  }
46
56
  };
47
57
  // Turn counting uses `agent/turn-stopping` — empirically the only event
@@ -67,6 +77,12 @@ export function registerAutoReview(ctx, engine, config) {
67
77
  const goalDriven = goalDrivesRounds(goalServiceOf(ctx)?.get(agent));
68
78
  if (!goalDriven && state.turns - state.lastReviewAt < config.intervalTurns)
69
79
  return;
80
+ // #21 P2 runtime switch: a paused gate stays fully dormant — no LLM
81
+ // calls, no fate assessments, no audit records. Checked after the
82
+ // cheap in-memory interval check so the paused path costs one file
83
+ // read at most. Manual tools/commands are unaffected (separate path).
84
+ if (isGatePaused(engine.baseDir))
85
+ return;
70
86
  // Run the gate outside the listener turn: agent is idle, work is auxiliary.
71
87
  // Every failure is durably recorded — nothing fails silently.
72
88
  void runGate(ctx, engine, agent, config, state, "turn_interval", record).catch((cause) => {
@@ -99,6 +115,12 @@ export function registerAutoReview(ctx, engine, config) {
99
115
  catch (cause) {
100
116
  logger.warn(`failed to write armed marker: ${cause instanceof Error ? cause.message : String(cause)}`);
101
117
  }
118
+ try {
119
+ pruneJsonlFile(reviewsPath, config.reviewsRetain ?? DEFAULT_REVIEWS_RETAIN);
120
+ }
121
+ catch {
122
+ // ignored — the next record retries
123
+ }
102
124
  ctx.on("session/event", (session, event) => {
103
125
  if (event.type !== "compaction/start")
104
126
  return;
@@ -107,7 +129,11 @@ export function registerAutoReview(ctx, engine, config) {
107
129
  if (!agent)
108
130
  return; // no live agent for that session (e.g. cold read)
109
131
  const state = stateFor(perSession, agent.id);
110
- // Compaction is unconditional: persist what is about to be summarized away.
132
+ // Compaction is unconditional — unless the human paused the gate
133
+ // (#21): a paused gate skips even the compaction run, so "pause"
134
+ // is a true zero-LLM-call state.
135
+ if (isGatePaused(engine.baseDir))
136
+ return;
111
137
  void runGate(ctx, engine, agent, config, state, "compact", record).catch((cause) => {
112
138
  logger.warn(`auto-review failed at compaction for ${agent.id}: ${cause instanceof Error ? cause.message : String(cause)}`);
113
139
  record({
@@ -228,6 +254,12 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
228
254
  const sessionId = agent.id;
229
255
  const turnsSinceLastReview = state.turns - state.lastReviewAt;
230
256
  const logger = ctx.logger("continual-evolve");
257
+ const tokenUsage = {
258
+ baseDir: engine.baseDir,
259
+ sessionId,
260
+ retain: engine.retention.tokenUsage,
261
+ onError: (cause) => logger.warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
262
+ };
231
263
  const trajectory = await readTrajectory(ctx, agent, config.maxInputChars).catch((cause) => {
232
264
  logger.warn(`auto-review skipped for ${sessionId}: trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}`);
233
265
  record({ sessionId, reason, turnsSinceLastReview, outcome: "failed", rationale: `trajectory unavailable: ${cause instanceof Error ? cause.message : String(cause)}` });
@@ -253,7 +285,16 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
253
285
  trajectory,
254
286
  context: { reason, turnsSinceLastReview },
255
287
  budgetTokens: config.budgetTokens,
288
+ tokenUsage,
256
289
  ...(reviewRoute ? { overrideProvider: reviewRoute.provider, overrideModel: reviewRoute.model } : {}),
290
+ ...((config.prefixCacheMode !== undefined || config.prefixMaxChars !== undefined
291
+ ? {
292
+ prefixCache: {
293
+ ...(config.prefixCacheMode !== undefined ? { mode: config.prefixCacheMode } : {}),
294
+ ...(config.prefixMaxChars !== undefined ? { maxChars: config.prefixMaxChars } : {}),
295
+ },
296
+ }
297
+ : {})),
257
298
  });
258
299
  state.lastReviewAt = state.turns;
259
300
  if (!review.shouldRefine) {
@@ -270,6 +311,15 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
270
311
  // Read the skill-creator template facts (fallback: builtin distilled
271
312
  // guide) so skill proposals follow the standard.
272
313
  skillsRoot: join(engine.baseDir, "skills"),
314
+ tokenUsage,
315
+ ...((config.prefixCacheMode !== undefined || config.prefixMaxChars !== undefined
316
+ ? {
317
+ prefixCache: {
318
+ ...(config.prefixCacheMode !== undefined ? { mode: config.prefixCacheMode } : {}),
319
+ ...(config.prefixMaxChars !== undefined ? { maxChars: config.prefixMaxChars } : {}),
320
+ },
321
+ }
322
+ : {})),
273
323
  });
274
324
  // Skills are governed resources: an auto-created skill is OFFERED to the
275
325
  // user for a decision (固化/不固化) before it lands — the gate never
@@ -317,7 +367,7 @@ async function runReviewPhase(ctx, engine, agent, config, state, reason, record)
317
367
  logger.info(`auto-review approved (${reason}) [${sessionId}] after ${turnsSinceLastReview} turns; auto-refine ${result.id}: ${result.appliedEdits.filter((e) => e.applied).length} applied, ${result.appliedEdits.filter((e) => !e.applied).length} failed — ${review.rationale}`);
318
368
  record({ sessionId, reason, turnsSinceLastReview, outcome: "approved", rationale: review.rationale, refinementId: result.id });
319
369
  // Gap C4: emit structured evolve_complete event for third-party consumers.
320
- emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, `auto_review:${reason}`, sessionId));
370
+ emitEvolveComplete(engine.baseDir, buildEvolveCompleteEvent(result, `auto_review:${reason}`, sessionId), config.reviewsRetain ?? DEFAULT_REVIEWS_RETAIN);
321
371
  // Visibility: tell the user what the gate just persisted. Only the
322
372
  // turn-interval path notifies — a compaction-triggered gate must not wake
323
373
  // the agent mid-compaction — and only when something was actually applied
package/lib/command.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface CommandRuntimeOptions {
17
17
  autoCase: boolean;
18
18
  /** Mechanical promotion guards for wrapup/fate (2026-08-22 policy). */
19
19
  promotionPolicy: PromotionPolicy;
20
+ /**
21
+ * Static patch flag for the auto-review gate (#21 status display).
22
+ * Absent (older wiring/tests) renders as unknown — the runtime pause
23
+ * switch still works.
24
+ */
25
+ autoReview?: boolean;
20
26
  }
21
27
  export declare function registerEvolveCommand(ctx: Context, engine: EvolutionEngine, opts: CommandGateOptions, runtime: CommandRuntimeOptions): void;
22
28
  /**
package/lib/command.js CHANGED
@@ -5,7 +5,8 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { requireGlobalApproval } from "./approval.js";
7
7
  import { saveHarnessState } from "./state.js";
8
- import { appendResult, storePaths } from "./store.js";
8
+ import { appendResult, pruneJsonlFile, storePaths } from "./store.js";
9
+ import { DEFAULT_REFINEMENTS_RETAIN } from "./store.js";
9
10
  import { entrySourceOf } from "./source.js";
10
11
  import { filterLogBySession, formatLogLine, pluginLogFilePath } from "./logfile.js";
11
12
  import { collectFailureSummary, formatFailureSummary } from "./failures.js";
@@ -14,8 +15,10 @@ import { executeMountCommand, executeUnmountCommand } from "./mount-command.js";
14
15
  import { executeBenchmarkCommand } from "./benchmark-command.js";
15
16
  import { executeWrapupCommand } from "./wrapup-command.js";
16
17
  import { projectKeyOf } from "./project.js";
17
- import { loadUsage } from "./usage.js";
18
+ import { loadUsage, getUsageCount } from "./usage.js";
19
+ import { loadGateRuntime, saveGateRuntime } from "./runtime.js";
18
20
  import { planConsolidation } from "./consolidate.js";
21
+ import { loadTokenUsage, renderTokenUsageReport } from "./token-usage.js";
19
22
  const USAGE = `Usage:
20
23
  /evolve show this help and the current local store
21
24
  /evolve list [project|global] list entries (default local staging; "project" = this project's cross-session store, "global" = cross-project store)
@@ -40,7 +43,10 @@ const USAGE = `Usage:
40
43
  /evolve unmount <id> remove a hot-mounted plugin
41
44
  /evolve goal show the evolution goal (round-driven auto-review)
42
45
  /evolve goal <objective> create/update the evolution goal
43
- /evolve goal done complete the evolution goal`;
46
+ /evolve goal done complete the evolution goal
47
+ /evolve pause | resume pause/resume the auto-review gate (manual tools and commands keep working)
48
+ /evolve status gate state (patch flag + runtime switch) plus store entry counts
49
+ /evolve usage injection counts + exact direct-call token usage (benchmark subagents excluded)`;
44
50
  export function registerEvolveCommand(ctx, engine, opts, runtime) {
45
51
  ctx.commands.register({
46
52
  name: "evolve",
@@ -323,6 +329,14 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
323
329
  appendResult(paths, result);
324
330
  }
325
331
  }
332
+ // Storage hygiene (#20): an imported history obeys the
333
+ // same tail budget as a live one.
334
+ try {
335
+ pruneJsonlFile(paths.resultsPath, engine.retention?.refinements ?? DEFAULT_REFINEMENTS_RETAIN);
336
+ }
337
+ catch {
338
+ // ignored — the next apply retries
339
+ }
326
340
  }
327
341
  return success(`imported ${scope} store from ${path}`);
328
342
  }
@@ -338,6 +352,12 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
338
352
  ...(instructions ? { instructions } : {}),
339
353
  global: scope === "global",
340
354
  signal: invocation.signal,
355
+ tokenUsage: {
356
+ baseDir: engine.baseDir,
357
+ sessionId,
358
+ retain: engine.retention.tokenUsage,
359
+ onError: (cause) => ctx.logger("continual-evolve").warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
360
+ },
341
361
  // skill-creator template facts (fallback: builtin guide).
342
362
  skillsRoot: join(engine.baseDir, "skills"),
343
363
  });
@@ -354,6 +374,27 @@ async function executeEvolveCommand(ctx, engine, invocation, opts, runtime) {
354
374
  case "wrapup": {
355
375
  return await executeWrapupCommand(ctx, engine, invocation, runtime.promotionPolicy);
356
376
  }
377
+ case "pause":
378
+ case "resume": {
379
+ // #21 P2 runtime switch: pause the AUTOMATIC gate only. Manual
380
+ // evolve_* tools and /evolve commands keep working — the human
381
+ // is acting explicitly there, so no gate is being bypassed.
382
+ const pausing = sub === "pause";
383
+ const current = loadGateRuntime(engine.baseDir);
384
+ if (current.paused === pausing) {
385
+ return success(`auto-review gate is already ${pausing ? "paused" : "running"} (no change).`);
386
+ }
387
+ saveGateRuntime(engine.baseDir, pausing);
388
+ return success(pausing
389
+ ? "auto-review gate paused: no automatic reviews, fate assessments, or gate LLM calls until /evolve resume. Manual evolve_* tools and /evolve commands keep working."
390
+ : "auto-review gate resumed: automatic reviews run again on their configured cadence.");
391
+ }
392
+ case "status": {
393
+ return success(renderGateStatus(engine, sessionId, projectKeyOf(invocation.agent), runtime));
394
+ }
395
+ case "usage": {
396
+ return success(renderUsageReport(engine, sessionId, projectKeyOf(invocation.agent)));
397
+ }
357
398
  case "goal": {
358
399
  return executeGoalCommand(ctx, invocation, rest);
359
400
  }
@@ -412,6 +453,101 @@ function demoteEntry(engine, id, sessionId, projectKey) {
412
453
  }
413
454
  return error(`entry ${id} not found in the global, project, or local store`);
414
455
  }
456
+ /**
457
+ * #21 status: one screen answering "is the gate on, and what is in the
458
+ * stores". The patch flag says whether the gate was registered at boot;
459
+ * the runtime switch says whether the human paused it since.
460
+ */
461
+ function renderGateStatus(engine, sessionId, projectKey, runtime) {
462
+ const paused = loadGateRuntime(engine.baseDir).paused;
463
+ const patch = runtime.autoReview === undefined ? "unknown" : runtime.autoReview ? "on" : "off";
464
+ const gateLine = patch === "off"
465
+ ? "gate: disabled in patch config (autoReview off — /evolve pause has nothing to pause)"
466
+ : `gate: ${patch === "unknown" ? "patch flag unknown" : "enabled in patch config"} · ${paused ? "PAUSED by /evolve pause (resume with /evolve resume)" : "running"}`;
467
+ const countEntries = (state) => Object.values(state.entries).reduce((n, byKind) => n + Object.keys(byKind).length, 0);
468
+ const lines = [gateLine];
469
+ lines.push(`stores: global ${countEntries(engine.load("global", undefined))} entries · local(${sessionId}) ${countEntries(engine.load("local", sessionId))} entries`);
470
+ if (projectKey) {
471
+ try {
472
+ lines[lines.length - 1] += ` · project ${countEntries(engine.load("project", projectKey))} entries`;
473
+ }
474
+ catch {
475
+ lines[lines.length - 1] += " · project (unavailable)";
476
+ }
477
+ }
478
+ const retention = engine.retention;
479
+ if (retention) {
480
+ lines.push(`retention: snapshots ${retention.snapshots} · refinements ${retention.refinements} · reviews ${retention.reviews} · token usage ${retention.tokenUsage} (historyRetain)`);
481
+ }
482
+ return lines.join("\n");
483
+ }
484
+ /**
485
+ * #21 P0 usage ledger: injection counts per entry across the stores the
486
+ * human can see (global + this session + this project). Counts are
487
+ * per-session since the v2 usage shape — "in how many sessions did this
488
+ * entry surface" — the exposure half of the #18a归零实验 verdict.
489
+ */
490
+ function renderUsageReport(engine, sessionId, projectKey) {
491
+ const store = loadUsage(engine.baseDir);
492
+ const rows = [];
493
+ const seen = new Set();
494
+ const collect = (state) => {
495
+ for (const kind of Object.keys(state.entries)) {
496
+ for (const entry of Object.values(state.entries[kind])) {
497
+ const key = `${kind}:${entry.id}`;
498
+ if (seen.has(key))
499
+ continue;
500
+ seen.add(key);
501
+ const count = getUsageCount(store, kind, entry.id);
502
+ rows.push({
503
+ key,
504
+ title: entry.title,
505
+ count,
506
+ ...(store.lastSession?.[`${kind}:${entry.id}`] ? { lastSession: store.lastSession[`${kind}:${entry.id}`] } : {}),
507
+ });
508
+ }
509
+ }
510
+ };
511
+ collect(engine.load("global", undefined));
512
+ collect(engine.load("local", sessionId));
513
+ if (projectKey) {
514
+ try {
515
+ collect(engine.load("project", projectKey));
516
+ }
517
+ catch {
518
+ // project store unavailable here — global+local still report
519
+ }
520
+ }
521
+ const liveKeys = new Set(rows.map((r) => r.key));
522
+ const orphaned = Object.keys(store.counts).filter((k) => !liveKeys.has(k)).length;
523
+ const total = rows.reduce((n, r) => n + r.count, 0);
524
+ const injected = rows.filter((r) => r.count > 0).sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1));
525
+ const stale = rows.filter((r) => r.count === 0).sort((a, b) => (a.key < b.key ? -1 : 1));
526
+ const lines = [`injected entries: ${total} injections across ${injected.length} of ${rows.length} stored entries${orphaned > 0 ? ` (+${orphaned} historical key(s) for deleted entries)` : ""}`];
527
+ if (injected.length > 0) {
528
+ lines.push("injected (top 15):");
529
+ for (const row of injected.slice(0, 15)) {
530
+ lines.push(` ${row.key} — ${row.count}× · ${row.title}${row.lastSession ? ` (last in ${row.lastSession})` : ""}`);
531
+ }
532
+ if (injected.length > 15) {
533
+ lines.push(` … and ${injected.length - 15} more`);
534
+ }
535
+ }
536
+ else {
537
+ lines.push("injected: (none yet — nothing has surfaced into a session prompt)");
538
+ }
539
+ if (stale.length > 0) {
540
+ lines.push(`never injected (${stale.length}):`);
541
+ for (const row of stale.slice(0, 20)) {
542
+ lines.push(` ${row.key} · ${row.title}`);
543
+ }
544
+ if (stale.length > 20) {
545
+ lines.push(` … and ${stale.length - 20} more`);
546
+ }
547
+ }
548
+ lines.push("", ...renderTokenUsageReport(loadTokenUsage(engine.baseDir), engine.retention.tokenUsage));
549
+ return lines.join("\n");
550
+ }
415
551
  function renderResult(result) {
416
552
  const applied = result.appliedEdits.filter((e) => e.applied);
417
553
  const failed = result.appliedEdits.filter((e) => !e.applied);
@@ -32,7 +32,9 @@ export declare function buildEvolveCompleteEvent(result: RefinementResult, trigg
32
32
  /**
33
33
  * Emit an evolve_complete event to the reviews.jsonl audit trail. The event
34
34
  * is JSONL-formatted (one line) so consumers can tail and parse it. This is
35
- * a best-effort write — failure never blocks the refinement path.
35
+ * a best-effort write — failure never blocks the refinement path. Storage
36
+ * hygiene (#20): the trail is truncated to its tail after the append so the
37
+ * audit file cannot grow without bound; readers only need a recent window.
36
38
  */
37
- export declare function emitEvolveComplete(baseDir: string, event: EvolveCompleteEvent): void;
39
+ export declare function emitEvolveComplete(baseDir: string, event: EvolveCompleteEvent, reviewsRetain?: number): void;
38
40
  //# sourceMappingURL=evolve-event.d.ts.map
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { appendFileSync, mkdirSync } from "node:fs";
13
13
  import { join } from "node:path";
14
+ import { DEFAULT_REVIEWS_RETAIN, pruneJsonlFile, reviewsPath } from "./store.js";
14
15
  /** Build a structured evolve-complete event from a refinement result. */
15
16
  export function buildEvolveCompleteEvent(result, trigger, sessionId) {
16
17
  return {
@@ -34,9 +35,11 @@ export function buildEvolveCompleteEvent(result, trigger, sessionId) {
34
35
  /**
35
36
  * Emit an evolve_complete event to the reviews.jsonl audit trail. The event
36
37
  * is JSONL-formatted (one line) so consumers can tail and parse it. This is
37
- * a best-effort write — failure never blocks the refinement path.
38
+ * a best-effort write — failure never blocks the refinement path. Storage
39
+ * hygiene (#20): the trail is truncated to its tail after the append so the
40
+ * audit file cannot grow without bound; readers only need a recent window.
38
41
  */
39
- export function emitEvolveComplete(baseDir, event) {
42
+ export function emitEvolveComplete(baseDir, event, reviewsRetain = DEFAULT_REVIEWS_RETAIN) {
40
43
  try {
41
44
  const dir = join(baseDir, "evolve");
42
45
  mkdirSync(dir, { recursive: true });
@@ -44,6 +47,13 @@ export function emitEvolveComplete(baseDir, event) {
44
47
  }
45
48
  catch {
46
49
  // Event emission is diagnostic; never interrupt the refinement path.
50
+ return;
51
+ }
52
+ try {
53
+ pruneJsonlFile(reviewsPath(baseDir), reviewsRetain);
54
+ }
55
+ catch {
56
+ // ignored — the next emission retries
47
57
  }
48
58
  }
49
59
  //# sourceMappingURL=evolve-event.js.map
package/lib/fate.js CHANGED
@@ -201,6 +201,12 @@ export async function runLocalFatePhase(ctx, engine, agent, config, state, reaso
201
201
  return;
202
202
  const logger = ctx.logger("continual-evolve");
203
203
  const sessionId = agent.id;
204
+ const tokenUsage = {
205
+ baseDir: engine.baseDir,
206
+ sessionId,
207
+ retain: engine.retention.tokenUsage,
208
+ onError: (cause) => logger.warn(`token-usage ledger failed for ${sessionId}: ${cause instanceof Error ? cause.message : String(cause)}`),
209
+ };
204
210
  const localState = engine.load("local", sessionId);
205
211
  const globalState = engine.load("global", undefined);
206
212
  const candidates = listLocalCandidates(localState, globalState, engine.baseDir);
@@ -218,7 +224,7 @@ export async function runLocalFatePhase(ctx, engine, agent, config, state, reaso
218
224
  state.lastFateAt = state.turns;
219
225
  let assessment;
220
226
  try {
221
- assessment = await assessLocalEntries(ctx, agent, candidates);
227
+ assessment = await assessLocalEntries(ctx, agent, candidates, { tokenUsage, tokenUsagePhase: "fate" });
222
228
  }
223
229
  catch (cause) {
224
230
  const message = cause instanceof Error ? cause.message : String(cause);