synergyspec-selfevolving 1.3.0 → 2.0.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 (113) hide show
  1. package/README.md +50 -19
  2. package/dist/commands/learn.d.ts +12 -1
  3. package/dist/commands/learn.js +373 -31
  4. package/dist/commands/self-evolution-episode.d.ts +177 -0
  5. package/dist/commands/self-evolution-episode.js +423 -0
  6. package/dist/commands/self-evolution.d.ts +12 -190
  7. package/dist/commands/self-evolution.js +179 -786
  8. package/dist/commands/workflow/status.js +3 -1
  9. package/dist/core/archive.d.ts +0 -1
  10. package/dist/core/archive.js +0 -58
  11. package/dist/core/artifact-graph/instruction-loader.d.ts +2 -4
  12. package/dist/core/artifact-graph/instruction-loader.js +3 -31
  13. package/dist/core/config-prompts.js +4 -0
  14. package/dist/core/fitness/health/health-metrics.d.ts +26 -56
  15. package/dist/core/fitness/health/health-metrics.js +19 -58
  16. package/dist/core/fitness/health/index.d.ts +15 -2
  17. package/dist/core/fitness/health/index.js +25 -1
  18. package/dist/core/fitness/health/local-source.d.ts +43 -4
  19. package/dist/core/fitness/health/local-source.js +181 -25
  20. package/dist/core/fitness/health/metric-source.d.ts +48 -19
  21. package/dist/core/fitness/health/metric-source.js +8 -18
  22. package/dist/core/fitness/health/resolve-source.js +4 -1
  23. package/dist/core/fitness/loss.d.ts +7 -7
  24. package/dist/core/fitness/loss.js +6 -6
  25. package/dist/core/fitness/sample.d.ts +10 -0
  26. package/dist/core/fitness/test-failures.d.ts +30 -0
  27. package/dist/core/fitness/test-failures.js +123 -0
  28. package/dist/core/learn/credit-path.d.ts +36 -0
  29. package/dist/core/learn/credit-path.js +198 -0
  30. package/dist/core/learn/trajectory-discovery.d.ts +39 -0
  31. package/dist/core/learn/trajectory-discovery.js +140 -0
  32. package/dist/core/learn.d.ts +39 -5
  33. package/dist/core/learn.js +131 -14
  34. package/dist/core/project-config.d.ts +4 -0
  35. package/dist/core/project-config.js +52 -1
  36. package/dist/core/self-evolution/candidate-fitness.d.ts +23 -1
  37. package/dist/core/self-evolution/candidate-fitness.js +31 -5
  38. package/dist/core/self-evolution/candidates.d.ts +0 -9
  39. package/dist/core/self-evolution/canonical-targets.d.ts +8 -4
  40. package/dist/core/self-evolution/canonical-targets.js +8 -4
  41. package/dist/core/self-evolution/critic-agent.d.ts +150 -0
  42. package/dist/core/self-evolution/critic-agent.js +487 -0
  43. package/dist/core/self-evolution/edits-contract.d.ts +53 -0
  44. package/dist/core/self-evolution/edits-contract.js +89 -0
  45. package/dist/core/self-evolution/episode-orchestrator.d.ts +197 -0
  46. package/dist/core/self-evolution/episode-orchestrator.js +534 -0
  47. package/dist/core/self-evolution/episode-store.d.ts +266 -0
  48. package/dist/core/self-evolution/episode-store.js +573 -0
  49. package/dist/core/self-evolution/evolution-switches.d.ts +1 -1
  50. package/dist/core/self-evolution/evolution-switches.js +5 -10
  51. package/dist/core/self-evolution/evolving-agent.d.ts +162 -0
  52. package/dist/core/self-evolution/evolving-agent.js +449 -0
  53. package/dist/core/self-evolution/health-baseline.d.ts +25 -6
  54. package/dist/core/self-evolution/health-baseline.js +30 -6
  55. package/dist/core/self-evolution/host-harness.d.ts +1 -2
  56. package/dist/core/self-evolution/host-harness.js +1 -2
  57. package/dist/core/self-evolution/index.d.ts +10 -6
  58. package/dist/core/self-evolution/index.js +19 -6
  59. package/dist/core/self-evolution/learn-hints.d.ts +31 -0
  60. package/dist/core/self-evolution/learn-hints.js +16 -0
  61. package/dist/core/self-evolution/learn-observation-adapter.d.ts +35 -0
  62. package/dist/core/self-evolution/learn-observation-adapter.js +285 -10
  63. package/dist/core/self-evolution/line-diff.d.ts +60 -0
  64. package/dist/core/self-evolution/line-diff.js +130 -0
  65. package/dist/core/self-evolution/policy/fs-safe.d.ts +19 -0
  66. package/dist/core/self-evolution/policy/fs-safe.js +89 -0
  67. package/dist/core/self-evolution/policy/index.d.ts +13 -0
  68. package/dist/core/self-evolution/policy/index.js +13 -0
  69. package/dist/core/self-evolution/policy/policy-store.d.ts +217 -0
  70. package/dist/core/self-evolution/policy/policy-store.js +774 -0
  71. package/dist/core/self-evolution/policy/reject-buffer.d.ts +48 -0
  72. package/dist/core/self-evolution/policy/reject-buffer.js +168 -0
  73. package/dist/core/self-evolution/promote.d.ts +1 -1
  74. package/dist/core/self-evolution/promote.js +6 -33
  75. package/dist/core/self-evolution/promotion.js +1 -2
  76. package/dist/core/self-evolution/proposer-agent.d.ts +41 -0
  77. package/dist/core/self-evolution/proposer-agent.js +94 -13
  78. package/dist/core/self-evolution/proposer-slice.d.ts +26 -0
  79. package/dist/core/self-evolution/proposer-slice.js +54 -0
  80. package/dist/core/self-evolution/reward-agent.d.ts +234 -0
  81. package/dist/core/self-evolution/reward-agent.js +564 -0
  82. package/dist/core/self-evolution/scope-gate.d.ts +66 -0
  83. package/dist/core/self-evolution/scope-gate.js +107 -0
  84. package/dist/core/self-evolution/success-channel.d.ts +79 -0
  85. package/dist/core/self-evolution/success-channel.js +361 -0
  86. package/dist/core/self-evolution/target-evolution.d.ts +11 -0
  87. package/dist/core/self-evolution/target-evolution.js +2 -0
  88. package/dist/core/self-evolution/tool-evolution.js +2 -13
  89. package/dist/core/self-evolution/verdict.d.ts +8 -5
  90. package/dist/core/self-evolution/verdict.js +4 -7
  91. package/dist/core/templates/skill-templates.d.ts +1 -0
  92. package/dist/core/templates/skill-templates.js +1 -0
  93. package/dist/core/templates/workflow-manifest.js +2 -0
  94. package/dist/core/templates/workflows/learn.d.ts +4 -2
  95. package/dist/core/templates/workflows/learn.js +25 -166
  96. package/dist/core/templates/workflows/self-evolving.d.ts +13 -0
  97. package/dist/core/templates/workflows/self-evolving.js +127 -0
  98. package/dist/core/trajectory/facts.d.ts +16 -0
  99. package/dist/core/trajectory/facts.js +12 -4
  100. package/dist/core/trajectory/skeleton.d.ts +43 -0
  101. package/dist/core/trajectory/skeleton.js +239 -0
  102. package/dist/dashboard/data.d.ts +25 -51
  103. package/dist/dashboard/data.js +68 -180
  104. package/dist/dashboard/react-client.js +458 -503
  105. package/dist/dashboard/react-styles.js +3 -3
  106. package/dist/dashboard/server.js +23 -17
  107. package/dist/ui/ascii-patterns.d.ts +7 -15
  108. package/dist/ui/ascii-patterns.js +123 -54
  109. package/dist/ui/welcome-screen.d.ts +0 -14
  110. package/dist/ui/welcome-screen.js +16 -35
  111. package/package.json +3 -1
  112. package/scripts/code-health.py +1066 -638
  113. package/scripts/slop_rules.yaml +2151 -0
@@ -0,0 +1,774 @@
1
+ /**
2
+ * 策略 POLICY = design template(主智能体的「权重」)· CLI 持有版本.
3
+ *
4
+ * In-context-RL framing (loop v2): the 主智能体 MAIN AGENT runs the current
5
+ * policy vN+1 as a frozen actor; the CRITIC AGENT(基线智能体 baseline agent)
6
+ * reruns LAST episode's policy vN on the SAME change; the 奖励智能体 REWARD
7
+ * AGENT calculates 算分 reward(主臂)&reward(基线臂), advantage =
8
+ * reward(主臂) − reward(基线臂), and the 文本梯度 textual gradient — it never
9
+ * edits, and it 弃权 abstains when there is no nameable gap; the 演进智能体
10
+ * EVOLVING AGENT is optimizer.step — ONE bounded edit ≤L, never scoring.
11
+ *
12
+ * The policy itself lives in the USER's repo (the design template file(s));
13
+ * this module is the CLI side that 持有版本 (holds the VERSION): the
14
+ * 版本账本 ledger, byte-for-byte snapshots, and the one-in-flight lock.
15
+ *
16
+ * On-disk layout, rooted at
17
+ * `<repoRoot>/.synergyspec-selfevolving/self-evolution/policy/`:
18
+ * - `ledger.ndjson` — 版本账本, append-only, 单一血统
19
+ * single lineage per target.
20
+ * - `snapshots/<target-id-slug>/v<N>/` — `manifest.json` + `files/<relPath>`
21
+ * (byte-for-byte) + `delta.patch`
22
+ * (unified diff vs v<N-1>; v0 has no
23
+ * delta).
24
+ * - `in-flight.json` — one in-flight episode per target
25
+ * (stale after 60 minutes).
26
+ *
27
+ * Versioning rules (what each ledger `action` means for the version axis):
28
+ * - 'init' → v0: snapshot of the target's CURRENT resolved local files.
29
+ * - 'evolve' → vN+1: the 演进智能体 EVOLVING AGENT's ONE bounded edit.
30
+ * - 'refused' → version does NOT bump (vN+1 ≡ vN). This is what the
31
+ * CRITIC AGENT(基线智能体 baseline agent)'s skip condition
32
+ * reads: the policy did not change, so there is no new arm
33
+ * to compare against the baseline.
34
+ * - 'rollback' → vN+1 whose files are byte-identical to snapshot
35
+ * v<toVersion>. Rolling FORWARD to old content (git-revert
36
+ * style) keeps the 单一血统 single lineage monotonic: the
37
+ * version axis never rewinds, so no snapshot dir is ever
38
+ * overwritten.
39
+ *
40
+ * Snapshots are the durable record; any per-episode worktree is 产物即弃
41
+ * (worktree artifacts discarded). All writes are fail-closed: snapshot dirs
42
+ * are committed via tmp dir + rename, live files via sibling tmp + rename,
43
+ * the order is snapshot-then-write (the NEW version dir exists before any
44
+ * live file changes), and a mid-write failure restores every live file from
45
+ * the head snapshot before rethrowing.
46
+ */
47
+ import { promises as fs } from 'node:fs';
48
+ import * as path from 'node:path';
49
+ import * as crypto from 'node:crypto';
50
+ import { resolveTargetLocalFiles } from '../local-targets.js';
51
+ import { assertWithinRepo, writeFileAtomic, appendFileDurable } from './fs-safe.js';
52
+ export const POLICY_LEDGER_FILE = 'ledger.ndjson';
53
+ export const POLICY_IN_FLIGHT_FILE = 'in-flight.json';
54
+ export const POLICY_REJECT_BUFFER_FILE = 'reject-buffer.ndjson';
55
+ export const POLICY_SNAPSHOT_MANIFEST_FILE = 'manifest.json';
56
+ export const POLICY_SNAPSHOT_FILES_DIR = 'files';
57
+ export const POLICY_SNAPSHOT_DELTA_FILE = 'delta.patch';
58
+ /** An in-flight episode older than this is stale and its slot reclaimable. */
59
+ export const IN_FLIGHT_STALE_MS = 60 * 60 * 1000;
60
+ const POLICY_SUBDIR = path.join('.synergyspec-selfevolving', 'self-evolution', 'policy');
61
+ /** Compute the policy dir layout for a repo. */
62
+ export function resolvePolicyLayout(repoRoot) {
63
+ const baseDir = path.join(repoRoot, POLICY_SUBDIR);
64
+ return {
65
+ repoRoot,
66
+ baseDir,
67
+ ledgerPath: path.join(baseDir, POLICY_LEDGER_FILE),
68
+ snapshotsDir: path.join(baseDir, 'snapshots'),
69
+ inFlightPath: path.join(baseDir, POLICY_IN_FLIGHT_FILE),
70
+ rejectBufferPath: path.join(baseDir, POLICY_REJECT_BUFFER_FILE),
71
+ };
72
+ }
73
+ /** Kebab-case a target id for use as a snapshot directory name. */
74
+ export function targetIdSlug(targetId) {
75
+ return targetId
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9]+/g, '-')
78
+ .replace(/^-|-$/g, '');
79
+ }
80
+ /** Absolute path of one version's snapshot dir. No I/O is performed. */
81
+ export function policySnapshotDir(layout, targetId, version) {
82
+ return path.join(layout.snapshotsDir, targetIdSlug(targetId), `v${version}`);
83
+ }
84
+ function sha256Of(content) {
85
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
86
+ }
87
+ function toPosix(p) {
88
+ return p.replace(/\\/g, '/');
89
+ }
90
+ function assertNonEmptyString(value, name) {
91
+ if (typeof value !== 'string' || value.length === 0) {
92
+ throw new Error(`${name} must be a non-empty string`);
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // 版本账本 ledger
97
+ // ---------------------------------------------------------------------------
98
+ function isValidLedgerEntry(value) {
99
+ if (!value || typeof value !== 'object')
100
+ return false;
101
+ const e = value;
102
+ if (e.schemaVersion !== 1)
103
+ return false;
104
+ if (typeof e.version !== 'number' || !Number.isInteger(e.version) || e.version < 0) {
105
+ return false;
106
+ }
107
+ if (typeof e.targetId !== 'string' || e.targetId.length === 0)
108
+ return false;
109
+ if (typeof e.at !== 'string')
110
+ return false;
111
+ if (e.action !== 'init' &&
112
+ e.action !== 'evolve' &&
113
+ e.action !== 'rollback' &&
114
+ e.action !== 'refused') {
115
+ return false;
116
+ }
117
+ if (e.episodeId !== null && typeof e.episodeId !== 'string')
118
+ return false;
119
+ if (!Array.isArray(e.files))
120
+ return false;
121
+ for (const f of e.files) {
122
+ const ff = f;
123
+ if (!ff || typeof ff !== 'object')
124
+ return false;
125
+ if (typeof ff.relPath !== 'string' || typeof ff.sha256 !== 'string')
126
+ return false;
127
+ }
128
+ return true;
129
+ }
130
+ async function appendLedgerEntry(layout, entry) {
131
+ await fs.mkdir(layout.baseDir, { recursive: true });
132
+ // Durable append: fsync the fd so a host crash cannot lose a rollback ledger
133
+ // entry a later separate process (resumeEpisode) reads to decide head version.
134
+ await appendFileDurable(layout.ledgerPath, `${JSON.stringify(entry)}\n`);
135
+ }
136
+ /**
137
+ * Read one target's slice of the 版本账本 ledger, in append order. Returns
138
+ * `[]` when the ledger does not exist. Malformed/blank lines are skipped
139
+ * best-effort (forward-compatible, matching the repo's other ndjson readers).
140
+ */
141
+ export async function readPolicyLedger(repoRoot, targetId) {
142
+ const layout = resolvePolicyLayout(path.resolve(repoRoot));
143
+ let raw;
144
+ try {
145
+ raw = await fs.readFile(layout.ledgerPath, 'utf8');
146
+ }
147
+ catch (err) {
148
+ if (err.code === 'ENOENT')
149
+ return [];
150
+ throw err;
151
+ }
152
+ const entries = [];
153
+ for (const line of raw.split(/\r?\n/)) {
154
+ const trimmed = line.trim();
155
+ if (trimmed.length === 0)
156
+ continue;
157
+ let parsed;
158
+ try {
159
+ parsed = JSON.parse(trimmed);
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (isValidLedgerEntry(parsed) && parsed.targetId === targetId)
165
+ entries.push(parsed);
166
+ }
167
+ return entries;
168
+ }
169
+ /**
170
+ * Read the ENTIRE 版本账本 ledger across all targets, in append order. Returns
171
+ * `[]` when the ledger does not exist. Malformed/blank lines are skipped
172
+ * best-effort (forward-compatible, matching the repo's other ndjson readers).
173
+ */
174
+ export async function readPolicyLedgerAll(repoRoot) {
175
+ const layout = resolvePolicyLayout(path.resolve(repoRoot));
176
+ let raw;
177
+ try {
178
+ raw = await fs.readFile(layout.ledgerPath, 'utf8');
179
+ }
180
+ catch (err) {
181
+ if (err.code === 'ENOENT')
182
+ return [];
183
+ throw err;
184
+ }
185
+ const entries = [];
186
+ for (const line of raw.split(/\r?\n/)) {
187
+ const trimmed = line.trim();
188
+ if (trimmed.length === 0)
189
+ continue;
190
+ let parsed;
191
+ try {
192
+ parsed = JSON.parse(trimmed);
193
+ }
194
+ catch {
195
+ continue;
196
+ }
197
+ if (isValidLedgerEntry(parsed))
198
+ entries.push(parsed);
199
+ }
200
+ return entries;
201
+ }
202
+ /**
203
+ * The lineage head version for a target, or `null` when the lineage has not
204
+ * been initialized. The 单一血统 single lineage is append-only, so the head is
205
+ * simply the LAST ledger entry's version ('refused' entries carry the
206
+ * unchanged head; 'evolve'/'rollback' carry the new one).
207
+ */
208
+ export async function currentPolicyVersion(repoRoot, targetId) {
209
+ const entries = await readPolicyLedger(repoRoot, targetId);
210
+ if (entries.length === 0)
211
+ return null;
212
+ return entries[entries.length - 1].version;
213
+ }
214
+ /**
215
+ * Write one version's snapshot dir atomically (tmp dir + rename, mirroring
216
+ * `writeCandidatePackage`). Refuses to overwrite an existing version dir —
217
+ * snapshots, like the ledger, are append-only history. Returns the
218
+ * content-addressed manifest file list.
219
+ */
220
+ async function writeSnapshot(layout, opts) {
221
+ const slugDir = path.join(layout.snapshotsDir, targetIdSlug(opts.targetId));
222
+ const finalDir = path.join(slugDir, `v${opts.version}`);
223
+ // Refuse to overwrite. Detect via stat; ENOENT means safe to proceed.
224
+ try {
225
+ await fs.stat(finalDir);
226
+ throw new Error(`Refusing to overwrite existing policy snapshot: ${finalDir}`);
227
+ }
228
+ catch (err) {
229
+ if (err.code !== 'ENOENT')
230
+ throw err;
231
+ }
232
+ await fs.mkdir(slugDir, { recursive: true });
233
+ const tmpDir = await fs.mkdtemp(path.join(slugDir, `v${opts.version}.tmp-`));
234
+ try {
235
+ const manifestFiles = opts.files.map((f) => ({
236
+ relPath: toPosix(f.relPath),
237
+ sha256: sha256Of(f.content),
238
+ }));
239
+ const manifest = {
240
+ version: opts.version,
241
+ targetId: opts.targetId,
242
+ at: opts.at,
243
+ files: manifestFiles,
244
+ };
245
+ await fs.writeFile(path.join(tmpDir, POLICY_SNAPSHOT_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
246
+ const filesRoot = path.join(tmpDir, POLICY_SNAPSHOT_FILES_DIR);
247
+ for (const f of opts.files) {
248
+ const abs = path.join(filesRoot, ...toPosix(f.relPath).split('/'));
249
+ assertWithinRepo(filesRoot, abs);
250
+ await fs.mkdir(path.dirname(abs), { recursive: true });
251
+ await fs.writeFile(abs, f.content, 'utf8');
252
+ }
253
+ if (opts.deltaPatch !== null) {
254
+ await fs.writeFile(path.join(tmpDir, POLICY_SNAPSHOT_DELTA_FILE), opts.deltaPatch, 'utf8');
255
+ }
256
+ await fs.rename(tmpDir, finalDir);
257
+ return manifestFiles;
258
+ }
259
+ catch (err) {
260
+ // Best-effort cleanup; surface the original error.
261
+ try {
262
+ await fs.rm(tmpDir, { recursive: true, force: true });
263
+ }
264
+ catch {
265
+ // ignore
266
+ }
267
+ throw err;
268
+ }
269
+ }
270
+ /**
271
+ * Read one version's snapshotted files (byte-for-byte). Verifies every file
272
+ * against the manifest's sha256 and throws on a mismatch — a snapshot is the
273
+ * restore source of truth, so corruption is a hard error, never silent.
274
+ */
275
+ export async function readPolicySnapshotFiles(repoRoot, targetId, version) {
276
+ const layout = resolvePolicyLayout(path.resolve(repoRoot));
277
+ const dir = policySnapshotDir(layout, targetId, version);
278
+ let manifestRaw;
279
+ try {
280
+ manifestRaw = await fs.readFile(path.join(dir, POLICY_SNAPSHOT_MANIFEST_FILE), 'utf8');
281
+ }
282
+ catch (err) {
283
+ if (err.code === 'ENOENT') {
284
+ throw new Error(`No policy snapshot v${version} for ${targetId} (looked under ${dir}).`);
285
+ }
286
+ throw err;
287
+ }
288
+ let manifest;
289
+ try {
290
+ manifest = JSON.parse(manifestRaw);
291
+ }
292
+ catch (err) {
293
+ throw new Error(`Policy snapshot v${version} for ${targetId} has an unreadable manifest: ${err.message}`);
294
+ }
295
+ if (!Array.isArray(manifest.files)) {
296
+ throw new Error(`Policy snapshot v${version} for ${targetId} has a malformed manifest (no files[]).`);
297
+ }
298
+ const filesRoot = path.join(dir, POLICY_SNAPSHOT_FILES_DIR);
299
+ const out = [];
300
+ for (const f of manifest.files) {
301
+ if (typeof f?.relPath !== 'string' || typeof f?.sha256 !== 'string') {
302
+ throw new Error(`Policy snapshot v${version} for ${targetId} has a malformed manifest file entry.`);
303
+ }
304
+ const abs = path.join(filesRoot, ...toPosix(f.relPath).split('/'));
305
+ assertWithinRepo(filesRoot, abs);
306
+ const content = await fs.readFile(abs, 'utf8');
307
+ if (sha256Of(content) !== f.sha256) {
308
+ throw new Error(`Policy snapshot corrupted: sha256 mismatch for ${f.relPath} in v${version} of ${targetId}.`);
309
+ }
310
+ out.push({ relPath: toPosix(f.relPath), content });
311
+ }
312
+ return out;
313
+ }
314
+ // ---------------------------------------------------------------------------
315
+ // Delta rendering
316
+ // ---------------------------------------------------------------------------
317
+ /**
318
+ * Whole-file-replacement unified diff for one policy file. Same format as
319
+ * `edits-contract.ts`'s `renderUnifiedDiff`, duplicated locally on purpose so
320
+ * the policy module stays free of the shared edits-contract module graph.
321
+ */
322
+ function renderFileDelta(relPath, oldContent, newContent) {
323
+ const oldLines = oldContent.length === 0 ? [] : oldContent.replace(/\n$/, '').split('\n');
324
+ const newLines = newContent.replace(/\n$/, '').split('\n');
325
+ const oldStart = oldLines.length === 0 ? 0 : 1;
326
+ const header = `--- a/${relPath}\n+++ b/${relPath}\n` +
327
+ `@@ -${oldStart},${oldLines.length} +1,${newLines.length} @@`;
328
+ const body = [...oldLines.map((l) => `-${l}`), ...newLines.map((l) => `+${l}`)].join('\n');
329
+ return `${header}\n${body}`;
330
+ }
331
+ /** Derive ledger `deltaStats` from a rendered `delta.patch` body. */
332
+ function countDeltaStats(deltaPatch, filesChanged) {
333
+ let linesAdded = 0;
334
+ let linesRemoved = 0;
335
+ for (const line of deltaPatch.split('\n')) {
336
+ if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@'))
337
+ continue;
338
+ if (line.startsWith('+'))
339
+ linesAdded += 1;
340
+ else if (line.startsWith('-'))
341
+ linesRemoved += 1;
342
+ }
343
+ return { filesChanged, linesAdded, linesRemoved };
344
+ }
345
+ /**
346
+ * Start a target's 单一血统 single lineage: snapshot v0 of the target's
347
+ * CURRENT resolved local files and append the 'init' ledger entry. Refuses to
348
+ * re-init an existing lineage (the version axis never restarts).
349
+ */
350
+ export async function initPolicyLineage(opts) {
351
+ assertNonEmptyString(opts.targetId, 'targetId');
352
+ const repoRoot = path.resolve(opts.repoRoot);
353
+ const existing = await currentPolicyVersion(repoRoot, opts.targetId);
354
+ if (existing !== null) {
355
+ throw new Error(`Policy lineage for ${opts.targetId} is already initialized (head v${existing}); ` +
356
+ `单一血统 single lineage — refusing to re-init.`);
357
+ }
358
+ const resolveFiles = opts.resolveFiles ?? resolveTargetLocalFiles;
359
+ const resolved = await resolveFiles(opts.targetId, repoRoot);
360
+ const files = resolved.files.map((f) => ({ relPath: toPosix(f.relPath), content: f.content }));
361
+ if (files.length === 0) {
362
+ throw new Error(`Cannot init policy lineage for ${opts.targetId}: the target resolves to no local files in this repo.`);
363
+ }
364
+ const layout = resolvePolicyLayout(repoRoot);
365
+ const at = new Date().toISOString();
366
+ const manifestFiles = await writeSnapshot(layout, {
367
+ targetId: opts.targetId,
368
+ version: 0,
369
+ at,
370
+ files,
371
+ deltaPatch: null, // v0 has no predecessor
372
+ });
373
+ const entry = {
374
+ schemaVersion: 1,
375
+ version: 0,
376
+ targetId: opts.targetId,
377
+ at,
378
+ action: 'init',
379
+ episodeId: null,
380
+ files: manifestFiles,
381
+ };
382
+ await appendLedgerEntry(layout, entry);
383
+ return entry;
384
+ }
385
+ const PREDICTION_METRICS = new Set([
386
+ 'loss',
387
+ 'passRate',
388
+ 'healthPenalty',
389
+ ]);
390
+ function assertValidPrediction(prediction) {
391
+ if (!prediction || typeof prediction !== 'object') {
392
+ throw new Error('advancePolicyVersion requires a prediction ({metric, direction, checkBy}) — every evolve step must be falsifiable.');
393
+ }
394
+ if (!PREDICTION_METRICS.has(prediction.metric)) {
395
+ throw new Error(`prediction.metric must be 'loss' | 'passRate' | 'healthPenalty', got ${JSON.stringify(prediction.metric)}`);
396
+ }
397
+ if (prediction.direction !== 'down' && prediction.direction !== 'up') {
398
+ throw new Error(`prediction.direction must be 'down' | 'up', got ${JSON.stringify(prediction.direction)}`);
399
+ }
400
+ assertNonEmptyString(prediction.checkBy, 'prediction.checkBy');
401
+ }
402
+ /**
403
+ * Apply the 演进智能体 EVOLVING AGENT's ONE bounded edit as the next policy
404
+ * version. 单一血统 single-lineage enforcement: the live files must still be
405
+ * byte-identical to the lineage head snapshot — if anything advanced or edited
406
+ * them out-of-band, the head is not the expected base and this throws (roll
407
+ * back, or re-init, before evolving again).
408
+ *
409
+ * Order is snapshot-then-write: the NEW version's snapshot dir is committed
410
+ * first, then the live files are written atomically; a mid-write failure
411
+ * restores every live file from the head content and discards the new
412
+ * snapshot before rethrowing. The 'evolve' ledger entry (with `prediction`
413
+ * and `deltaStats`) is appended only after every live write succeeded.
414
+ */
415
+ export async function advancePolicyVersion(opts) {
416
+ assertNonEmptyString(opts.targetId, 'targetId');
417
+ assertNonEmptyString(opts.episodeId, 'episodeId');
418
+ assertValidPrediction(opts.prediction);
419
+ if (!Array.isArray(opts.edits) || opts.edits.length === 0) {
420
+ throw new Error('advancePolicyVersion requires at least one edit (the 演进智能体 EVOLVING AGENT makes ONE bounded edit ≤L, never zero).');
421
+ }
422
+ const repoRoot = path.resolve(opts.repoRoot);
423
+ const layout = resolvePolicyLayout(repoRoot);
424
+ const entries = await readPolicyLedger(repoRoot, opts.targetId);
425
+ if (entries.length === 0) {
426
+ throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
427
+ }
428
+ const headVersion = entries[entries.length - 1].version;
429
+ // The head snapshot is the expected base.
430
+ const headFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, headVersion);
431
+ const headByPath = new Map(headFiles.map((f) => [f.relPath, f.content]));
432
+ // 单一血统 single-lineage enforcement: every live file must match the head
433
+ // snapshot byte-for-byte before we stack a new version on top of it.
434
+ for (const f of headFiles) {
435
+ const abs = path.resolve(repoRoot, ...f.relPath.split('/'));
436
+ assertWithinRepo(repoRoot, abs);
437
+ let live;
438
+ try {
439
+ live = await fs.readFile(abs, 'utf8');
440
+ }
441
+ catch (err) {
442
+ if (err.code === 'ENOENT') {
443
+ throw new Error(`Refusing to advance ${opts.targetId}: live file ${f.relPath} is missing — ` +
444
+ `lineage head v${headVersion} is not the expected base.`);
445
+ }
446
+ throw err;
447
+ }
448
+ if (live !== f.content) {
449
+ throw new Error(`Refusing to advance ${opts.targetId}: live file ${f.relPath} diverged from lineage head ` +
450
+ `v${headVersion} — the head is not the expected base (单一血统 single lineage; roll back ` +
451
+ `or re-init before evolving again).`);
452
+ }
453
+ }
454
+ // Validate the edits BEFORE writing anything: lineage files only, and at
455
+ // least one byte must actually change (a no-op evolve is a bug upstream).
456
+ const editByPath = new Map();
457
+ for (const edit of opts.edits) {
458
+ const rel = toPosix(edit.relPath);
459
+ if (!headByPath.has(rel)) {
460
+ throw new Error(`Refusing to advance ${opts.targetId}: edit targets ${rel}, which is not part of the ` +
461
+ `policy lineage (evolve existing policy files only).`);
462
+ }
463
+ if (typeof edit.content !== 'string') {
464
+ throw new Error(`Refusing to advance ${opts.targetId}: edit for ${rel} has no string content.`);
465
+ }
466
+ editByPath.set(rel, edit.content);
467
+ }
468
+ const changed = [...editByPath].filter(([rel, content]) => content !== headByPath.get(rel));
469
+ if (changed.length === 0) {
470
+ throw new Error(`Refusing to advance ${opts.targetId}: every edit is byte-identical to head v${headVersion} (no-op evolve).`);
471
+ }
472
+ const deltaPatch = changed
473
+ .map(([rel, content]) => renderFileDelta(rel, headByPath.get(rel), content))
474
+ .join('\n');
475
+ const deltaStats = countDeltaStats(deltaPatch, changed.length);
476
+ const newVersion = headVersion + 1;
477
+ const at = new Date().toISOString();
478
+ const newFiles = headFiles.map((f) => ({
479
+ relPath: f.relPath,
480
+ content: editByPath.get(f.relPath) ?? f.content,
481
+ }));
482
+ // 1) Snapshot the NEW version dir first (durable before any live change).
483
+ const manifestFiles = await writeSnapshot(layout, {
484
+ targetId: opts.targetId,
485
+ version: newVersion,
486
+ at,
487
+ files: newFiles,
488
+ deltaPatch,
489
+ });
490
+ // 2) Write the live files, then append the ledger entry, under ONE
491
+ // fail-closed guard: any failure restores the written live files from the
492
+ // head content and discards the new snapshot, so the lineage either fully
493
+ // advances or stays byte-unchanged at the head.
494
+ const written = [];
495
+ try {
496
+ for (const [rel, content] of changed) {
497
+ const abs = path.resolve(repoRoot, ...rel.split('/'));
498
+ const before = headByPath.get(rel);
499
+ await writeFileAtomic(abs, content);
500
+ written.push({ abs, before });
501
+ }
502
+ const entry = {
503
+ schemaVersion: 1,
504
+ version: newVersion,
505
+ targetId: opts.targetId,
506
+ at,
507
+ action: 'evolve',
508
+ episodeId: opts.episodeId,
509
+ files: manifestFiles,
510
+ prediction: opts.prediction,
511
+ deltaStats,
512
+ };
513
+ await appendLedgerEntry(layout, entry);
514
+ return entry;
515
+ }
516
+ catch (err) {
517
+ for (const w of written) {
518
+ try {
519
+ await writeFileAtomic(w.abs, w.before);
520
+ }
521
+ catch {
522
+ // best-effort restore; surface the original error
523
+ }
524
+ }
525
+ try {
526
+ await fs.rm(policySnapshotDir(layout, opts.targetId, newVersion), {
527
+ recursive: true,
528
+ force: true,
529
+ });
530
+ }
531
+ catch {
532
+ // ignore
533
+ }
534
+ throw err;
535
+ }
536
+ }
537
+ /**
538
+ * Restore the live policy files byte-for-byte from snapshot v<toVersion> and
539
+ * append a 'rollback' ledger entry. The restore is recorded as a NEW head
540
+ * version whose files equal the old snapshot (git-revert style) so the
541
+ * 单一血统 single lineage stays monotonic — the version axis never rewinds.
542
+ * Unlike advance, rollback does NOT require the live files to match the head:
543
+ * it is the recovery path, including for out-of-band live-file divergence.
544
+ */
545
+ export async function rollbackPolicyVersion(opts) {
546
+ assertNonEmptyString(opts.targetId, 'targetId');
547
+ assertNonEmptyString(opts.episodeId, 'episodeId');
548
+ const repoRoot = path.resolve(opts.repoRoot);
549
+ const layout = resolvePolicyLayout(repoRoot);
550
+ const entries = await readPolicyLedger(repoRoot, opts.targetId);
551
+ if (entries.length === 0) {
552
+ throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
553
+ }
554
+ const headVersion = entries[entries.length - 1].version;
555
+ if (!Number.isInteger(opts.toVersion) || opts.toVersion < 0 || opts.toVersion >= headVersion) {
556
+ throw new Error(`Cannot roll back ${opts.targetId} to v${opts.toVersion}: head is v${headVersion} ` +
557
+ `(toVersion must be an existing earlier version).`);
558
+ }
559
+ const restoreFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, opts.toVersion);
560
+ const headFiles = await readPolicySnapshotFiles(repoRoot, opts.targetId, headVersion);
561
+ const headByPath = new Map(headFiles.map((f) => [f.relPath, f.content]));
562
+ const changedVsHead = restoreFiles.filter((f) => headByPath.get(f.relPath) !== f.content);
563
+ const deltaPatch = changedVsHead
564
+ .map((f) => renderFileDelta(f.relPath, headByPath.get(f.relPath) ?? '', f.content))
565
+ .join('\n');
566
+ const deltaStats = countDeltaStats(deltaPatch, changedVsHead.length);
567
+ const newVersion = headVersion + 1;
568
+ const at = new Date().toISOString();
569
+ // Snapshot-then-write, same order and fail-closed guard as advance. The
570
+ // pre-images captured here are whatever is live right now (possibly
571
+ // diverged), so a mid-restore failure puts back exactly what it found.
572
+ const manifestFiles = await writeSnapshot(layout, {
573
+ targetId: opts.targetId,
574
+ version: newVersion,
575
+ at,
576
+ files: restoreFiles,
577
+ deltaPatch,
578
+ });
579
+ const written = [];
580
+ try {
581
+ for (const f of restoreFiles) {
582
+ const abs = path.resolve(repoRoot, ...f.relPath.split('/'));
583
+ assertWithinRepo(repoRoot, abs);
584
+ let before;
585
+ try {
586
+ before = await fs.readFile(abs, 'utf8');
587
+ }
588
+ catch (err) {
589
+ if (err.code !== 'ENOENT')
590
+ throw err;
591
+ before = null;
592
+ }
593
+ await fs.mkdir(path.dirname(abs), { recursive: true });
594
+ await writeFileAtomic(abs, f.content);
595
+ written.push({ abs, before });
596
+ }
597
+ const entry = {
598
+ schemaVersion: 1,
599
+ version: newVersion,
600
+ targetId: opts.targetId,
601
+ at,
602
+ action: 'rollback',
603
+ episodeId: opts.episodeId,
604
+ files: manifestFiles,
605
+ ...(opts.advantage !== undefined ? { advantage: opts.advantage } : {}),
606
+ deltaStats,
607
+ };
608
+ await appendLedgerEntry(layout, entry);
609
+ return entry;
610
+ }
611
+ catch (err) {
612
+ for (const w of written) {
613
+ try {
614
+ if (w.before !== null)
615
+ await writeFileAtomic(w.abs, w.before);
616
+ }
617
+ catch {
618
+ // best-effort restore; surface the original error
619
+ }
620
+ }
621
+ try {
622
+ await fs.rm(policySnapshotDir(layout, opts.targetId, newVersion), {
623
+ recursive: true,
624
+ force: true,
625
+ });
626
+ }
627
+ catch {
628
+ // ignore
629
+ }
630
+ throw err;
631
+ }
632
+ }
633
+ /**
634
+ * Record an episode where the 演进智能体 EVOLVING AGENT refused to edit. The
635
+ * version does NOT bump (vN+1 ≡ vN) — this entry is exactly what the
636
+ * CRITIC AGENT(基线智能体 baseline agent)'s skip condition reads: the policy
637
+ * is unchanged, so rerunning the baseline arm would compare a policy against
638
+ * itself. No files are touched.
639
+ */
640
+ export async function recordEvolutionRefused(opts) {
641
+ assertNonEmptyString(opts.targetId, 'targetId');
642
+ assertNonEmptyString(opts.episodeId, 'episodeId');
643
+ assertNonEmptyString(opts.reason, 'reason');
644
+ const repoRoot = path.resolve(opts.repoRoot);
645
+ const layout = resolvePolicyLayout(repoRoot);
646
+ const entries = await readPolicyLedger(repoRoot, opts.targetId);
647
+ if (entries.length === 0) {
648
+ throw new Error(`Policy lineage for ${opts.targetId} is not initialized; run initPolicyLineage first.`);
649
+ }
650
+ const head = entries[entries.length - 1];
651
+ const entry = {
652
+ schemaVersion: 1,
653
+ version: head.version, // unchanged: vN+1 ≡ vN
654
+ targetId: opts.targetId,
655
+ at: new Date().toISOString(),
656
+ action: 'refused',
657
+ episodeId: opts.episodeId,
658
+ files: head.files,
659
+ reason: opts.reason,
660
+ };
661
+ await appendLedgerEntry(layout, entry);
662
+ return entry;
663
+ }
664
+ // ---------------------------------------------------------------------------
665
+ // One in-flight episode per target
666
+ // ---------------------------------------------------------------------------
667
+ function isValidInFlightEntry(value) {
668
+ if (!value || typeof value !== 'object')
669
+ return false;
670
+ const e = value;
671
+ return (typeof e.targetId === 'string' &&
672
+ e.targetId.length > 0 &&
673
+ typeof e.episodeId === 'string' &&
674
+ e.episodeId.length > 0 &&
675
+ typeof e.sinceVersion === 'number' &&
676
+ Number.isInteger(e.sinceVersion) &&
677
+ e.sinceVersion >= 0 &&
678
+ typeof e.startedAt === 'string');
679
+ }
680
+ /**
681
+ * Read the in-flight map (targetId → entry). A missing or unparseable file
682
+ * reads as empty: the staleness window — not the file's integrity — is the
683
+ * real liveness guarantee, so a corrupt lock file must not wedge the loop.
684
+ */
685
+ async function readInFlightMap(layout) {
686
+ let raw;
687
+ try {
688
+ raw = await fs.readFile(layout.inFlightPath, 'utf8');
689
+ }
690
+ catch (err) {
691
+ if (err.code === 'ENOENT')
692
+ return {};
693
+ throw err;
694
+ }
695
+ let parsed;
696
+ try {
697
+ parsed = JSON.parse(raw);
698
+ }
699
+ catch {
700
+ return {};
701
+ }
702
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
703
+ return {};
704
+ const out = {};
705
+ for (const [key, value] of Object.entries(parsed)) {
706
+ if (isValidInFlightEntry(value))
707
+ out[key] = value;
708
+ }
709
+ return out;
710
+ }
711
+ async function writeInFlightMap(layout, map) {
712
+ await fs.mkdir(layout.baseDir, { recursive: true });
713
+ await writeFileAtomic(layout.inFlightPath, `${JSON.stringify(map, null, 2)}\n`);
714
+ }
715
+ /**
716
+ * Claim the one in-flight slot for a target. Throws while a NON-stale entry
717
+ * exists for the target (one in-flight per target); an entry whose
718
+ * `startedAt` is ≥ {@link IN_FLIGHT_STALE_MS} old is stale and its slot is
719
+ * reclaimed (the abandoned episode's worktree is 产物即弃 — worktree
720
+ * artifacts discarded). Records `sinceVersion` = the lineage head at acquire
721
+ * time, so the episode knows which policy version it started from.
722
+ */
723
+ export async function acquireInFlight(opts) {
724
+ assertNonEmptyString(opts.targetId, 'targetId');
725
+ assertNonEmptyString(opts.episodeId, 'episodeId');
726
+ const repoRoot = path.resolve(opts.repoRoot);
727
+ const layout = resolvePolicyLayout(repoRoot);
728
+ const now = opts.now ?? new Date();
729
+ const map = await readInFlightMap(layout);
730
+ const existing = map[opts.targetId];
731
+ if (existing) {
732
+ const startedMs = Date.parse(existing.startedAt);
733
+ const stale = !Number.isFinite(startedMs) || now.getTime() - startedMs >= IN_FLIGHT_STALE_MS;
734
+ if (!stale) {
735
+ throw new Error(`An episode is already in flight for ${opts.targetId} ` +
736
+ `(episode ${existing.episodeId}, started ${existing.startedAt}); one in-flight per target.`);
737
+ }
738
+ // Stale: reclaim the slot below.
739
+ }
740
+ const sinceVersion = await currentPolicyVersion(repoRoot, opts.targetId);
741
+ if (sinceVersion === null) {
742
+ throw new Error(`Cannot acquire in-flight for ${opts.targetId}: policy lineage not initialized (run initPolicyLineage first).`);
743
+ }
744
+ const entry = {
745
+ targetId: opts.targetId,
746
+ episodeId: opts.episodeId,
747
+ sinceVersion,
748
+ startedAt: now.toISOString(),
749
+ };
750
+ map[opts.targetId] = entry;
751
+ await writeInFlightMap(layout, map);
752
+ return entry;
753
+ }
754
+ /**
755
+ * Release a target's in-flight slot. Idempotent when no entry exists; throws
756
+ * when the slot is held by a DIFFERENT episode (an episode may only release
757
+ * its own claim).
758
+ */
759
+ export async function releaseInFlight(opts) {
760
+ assertNonEmptyString(opts.targetId, 'targetId');
761
+ assertNonEmptyString(opts.episodeId, 'episodeId');
762
+ const repoRoot = path.resolve(opts.repoRoot);
763
+ const layout = resolvePolicyLayout(repoRoot);
764
+ const map = await readInFlightMap(layout);
765
+ const existing = map[opts.targetId];
766
+ if (!existing)
767
+ return; // idempotent: nothing to release
768
+ if (existing.episodeId !== opts.episodeId) {
769
+ throw new Error(`Refusing to release in-flight for ${opts.targetId}: held by episode ${existing.episodeId}, not ${opts.episodeId}.`);
770
+ }
771
+ delete map[opts.targetId];
772
+ await writeInFlightMap(layout, map);
773
+ }
774
+ //# sourceMappingURL=policy-store.js.map