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,487 @@
1
+ /**
2
+ * CRITIC AGENT(基线智能体 baseline agent)runner — loop v2 (self-evolution as
3
+ * in-context RL).
4
+ *
5
+ * The CRITIC AGENT is an AGENT with the SAME input/output as the 主智能体 MAIN
6
+ * AGENT (frozen actor; the user's host agent running the current 策略 policy
7
+ * vN+1). It reruns LAST episode's 策略 policy vN on the SAME change in an
8
+ * ISOLATED worktree, so the 奖励智能体 REWARD AGENT can later 算分 calculate
9
+ * reward(主臂)&reward(基线臂) and advantage = reward(主臂) − reward(基线臂).
10
+ * Only its baseline trajectory survives — 产物即弃 (worktree artifacts
11
+ * discarded): the worktree is torn down in `finally`, and the single durable
12
+ * output is the `baseline-arm/` capture in the episode store.
13
+ *
14
+ * This module orchestrates ONE baseline arm:
15
+ * 1. create an isolated worktree OUTSIDE the repo (git worktree, else a
16
+ * recursive file copy fallback),
17
+ * 2. make it runnable (node_modules junction/symlink + the untracked surfaces
18
+ * the rerun reads),
19
+ * 3. INSTALL 策略 policy vN into the worktree from the byte-for-byte version
20
+ * snapshot, so the baseline arm reruns the PRIOR policy and not the live
21
+ * templates,
22
+ * 4. rerun headlessly via the host-aware {@link runHeadlessAgent} with
23
+ * cwd = worktree, measurement only, never editing canonical files,
24
+ * 5. persist the baseline arm (stdout always; the claude session transcript +
25
+ * action skeleton when discoverable; an `objective.json` shaped IDENTICALLY
26
+ * to the main arm's), and
27
+ * 6. ALWAYS tear the worktree down.
28
+ *
29
+ * Honesty contract: a pass rate is parse-or-throw — when the rerun's stdout
30
+ * carries no parseable test summary the objective records `passRate: null`
31
+ * rather than fabricating one. The agent is RUN, never asked to edit; the prompt
32
+ * strips every arm/candidate word.
33
+ */
34
+ import { spawn as nodeSpawn } from 'node:child_process';
35
+ import { promises as fs } from 'node:fs';
36
+ import * as os from 'node:os';
37
+ import * as path from 'node:path';
38
+ import { parseTestMetrics, computePerChangeLoss, measureHealthPenalty, resolveMetricSource, } from '../fitness/index.js';
39
+ import { readProjectConfig } from '../project-config.js';
40
+ import { claudeProjectsDir } from '../learn/trajectory-discovery.js';
41
+ import { claudeSourceFactory } from '../trajectory/adapters/claude.js';
42
+ import { toActionSkeleton } from '../trajectory/skeleton.js';
43
+ import { runHeadlessAgent } from './host-harness.js';
44
+ import { currentPolicyVersion, readPolicyLedger, readPolicySnapshotFiles, } from './policy/index.js';
45
+ import { advanceEpisodeStage, writeArmCapture } from './episode-store.js';
46
+ /** Error thrown when the worktree could not be created (git AND copy fallback failed). */
47
+ export class CriticWorktreeError extends Error {
48
+ constructor(message) {
49
+ super(`critic worktree failed: ${message}`);
50
+ this.name = 'CriticWorktreeError';
51
+ }
52
+ }
53
+ /**
54
+ * Decide whether the CRITIC AGENT(基线智能体 baseline agent)should run for the
55
+ * NEXT episode.
56
+ *
57
+ * Skip (`run: false`) when:
58
+ * - the 单一血统 single lineage has < 2 versions — there is no PRIOR policy to
59
+ * rerun (v0 is the only point; the 主智能体 MAIN AGENT IS v0), OR
60
+ * - the head 版本账本 ledger entry's action is 'refused' — the 演进智能体
61
+ * EVOLVING AGENT refused last episode, so vN+1 ≡ vN and rerunning the
62
+ * baseline would compare a policy against ITSELF (no advantage to measure).
63
+ *
64
+ * Otherwise run, rerunning the head version (vN, the policy the LAST episode
65
+ * settled on, which the current 主智能体 MAIN AGENT also runs as vN+1 unless an
66
+ * evolve happened — the comparison the 奖励智能体 REWARD AGENT scores).
67
+ *
68
+ * Pure read of the ledger via {@link readPolicyLedger}/{@link currentPolicyVersion};
69
+ * this function NEVER writes episode state. The skip path's
70
+ * {@link advanceEpisodeStage} to 'baseline-skipped' is the CALLER's job.
71
+ */
72
+ export async function shouldRunCriticAgent(opts) {
73
+ const repoRoot = path.resolve(opts.repoRoot);
74
+ const ledger = await readPolicyLedger(repoRoot, opts.targetId);
75
+ if (ledger.length === 0) {
76
+ return {
77
+ run: false,
78
+ reason: `policy lineage for ${opts.targetId} is not initialized (no versions to rerun)`,
79
+ baselineVersion: null,
80
+ };
81
+ }
82
+ // A lineage with a single distinct version (only v0) has no PRIOR policy to
83
+ // compare against. The lineage head is monotonic, so "< 2 versions" is "head
84
+ // version is 0" — 'init' alone, or 'init' followed only by 'refused' entries
85
+ // (refused does not bump the version).
86
+ const head = ledger[ledger.length - 1];
87
+ const baselineVersion = await currentPolicyVersion(repoRoot, opts.targetId);
88
+ if (baselineVersion === null || baselineVersion < 1) {
89
+ return {
90
+ run: false,
91
+ reason: `policy lineage for ${opts.targetId} has < 2 versions (head v${baselineVersion ?? 0}); no prior policy to rerun`,
92
+ baselineVersion: null,
93
+ };
94
+ }
95
+ if (head.action === 'refused') {
96
+ return {
97
+ run: false,
98
+ reason: `last episode refused to evolve ${opts.targetId} (vN+1 ≡ vN); rerunning the baseline would compare a policy against itself`,
99
+ baselineVersion: null,
100
+ };
101
+ }
102
+ return {
103
+ run: true,
104
+ reason: `policy lineage for ${opts.targetId} head v${baselineVersion} (last action '${head.action}'); rerunning the baseline arm`,
105
+ baselineVersion,
106
+ };
107
+ }
108
+ /**
109
+ * Assemble the CRITIC AGENT(基线智能体 baseline agent)rerun prompt. STRIPPED
110
+ * of every arm/candidate word: the agent is simply told to re-run change
111
+ * <changeName> end-to-end (apply → gen-test → run-test) under the templates
112
+ * already installed in its working directory, measurement only, never editing
113
+ * canonical files, and to print the runner summary line verbatim as its final
114
+ * line.
115
+ */
116
+ export function assembleCriticPrompt(changeName) {
117
+ return [
118
+ `You are RE-RUNNING an existing SynergySpec change end-to-end to measure its`,
119
+ `test outcome under the artifact templates already installed in your working`,
120
+ `directory. This is a measurement run only — do NOT modify any canonical`,
121
+ `workflow prompt, artifact template, or schema, and do NOT edit the frozen`,
122
+ `gen-test/run-test oracle.`,
123
+ ``,
124
+ `Change name: ${changeName}`,
125
+ ``,
126
+ `Run the change's tests (apply → gen-test → run-test) and output the test`,
127
+ `runner's SUMMARY LINE verbatim as the final line of your response, e.g.`,
128
+ `"Tests 12 passed | 1 failed (13)" or "5 passed, 0 failed in 0.4s".`,
129
+ ].join('\n');
130
+ }
131
+ const NODE_MODULES = 'node_modules';
132
+ const CONFIG_DIR = '.synergyspec-selfevolving';
133
+ const SCHEMAS_REL = path.join('synergyspec-selfevolving', 'schemas');
134
+ /**
135
+ * Run the CRITIC AGENT(基线智能体 baseline agent)'s full baseline arm and
136
+ * persist its capture. ALWAYS tears the worktree down (产物即弃). On success it
137
+ * advances the episode to 'baseline-arm-captured' (patch
138
+ * `{policyVersionBaseline}`). The SKIP path is the caller's job (see
139
+ * {@link shouldRunCriticAgent}).
140
+ */
141
+ export async function runCriticAgent(opts) {
142
+ const repoRoot = path.resolve(opts.repoRoot);
143
+ const spawnImpl = opts.spawn ?? nodeSpawn;
144
+ const timeoutMs = opts.timeoutMs ?? 600000;
145
+ const homeDir = opts.homeDir ?? os.homedir();
146
+ if (!Number.isInteger(opts.baselineVersion) || opts.baselineVersion < 0) {
147
+ throw new Error(`runCriticAgent requires a non-negative integer baselineVersion, got ${JSON.stringify(opts.baselineVersion)}`);
148
+ }
149
+ const worktreeName = `synergyspec-critic-${opts.episodeId}`;
150
+ const worktreePath = path.join(os.tmpdir(), worktreeName);
151
+ // The run window opens just before the spawn; the claude transcript discovery
152
+ // selects the newest session file written after this instant.
153
+ const runStart = (opts.now ?? new Date()).getTime();
154
+ let worktreeMode = 'git-worktree';
155
+ try {
156
+ // 1) Isolated worktree OUTSIDE the repo (git worktree --detach, else copy).
157
+ worktreeMode = await createIsolatedWorktree(repoRoot, worktreePath, spawnImpl);
158
+ // 2) Make it runnable: node_modules junction/symlink + untracked surfaces.
159
+ await makeWorktreeRunnable(repoRoot, worktreePath, opts.changeName);
160
+ // 3) INSTALL 策略 policy vN (byte-for-byte snapshot files) — the fidelity
161
+ // fix the old GA replay never performed.
162
+ await installPolicyVersion(repoRoot, worktreePath, opts.targetId, opts.baselineVersion);
163
+ // 4) Rerun headlessly with cwd = worktree (measurement only).
164
+ const prompt = assembleCriticPrompt(opts.changeName);
165
+ const run = await runHeadlessAgent(prompt, {
166
+ cwd: worktreePath,
167
+ spawn: spawnImpl,
168
+ timeoutMs,
169
+ });
170
+ // 5) Build + persist the baseline arm.
171
+ const measuredAt = new Date().toISOString();
172
+ const metrics = parseTestMetrics(run.stdout);
173
+ // Discover + normalize the claude session transcript for the WORKTREE path
174
+ // (newest session file written after `runStart`). Yields the observed
175
+ // verdict + the action skeleton; absent on non-claude harnesses or a miss.
176
+ const trajectory = await discoverWorktreeTrajectory({
177
+ worktreePath,
178
+ changeName: opts.changeName,
179
+ homeDir,
180
+ runStartMs: runStart,
181
+ });
182
+ const facts = trajectory
183
+ ? // Local import keeps the facts derivation in one place (learn uses the
184
+ // same function); imported lazily to avoid a top-level cycle hazard.
185
+ (await import('../trajectory/facts.js')).toTrajectoryFacts(trajectory, opts.changeName)
186
+ : null;
187
+ // Honesty: prefer the OBSERVED pass rate (a real runner ran), else the
188
+ // stdout-parsed summary; null when neither parsed (never fabricated).
189
+ const observedPassRate = facts?.testRunObserved && facts.observedPassRate !== null
190
+ ? facts.observedPassRate
191
+ : null;
192
+ const passRate = observedPassRate ?? metrics?.passRate ?? null;
193
+ const verified = facts ? facts.verified : false;
194
+ const observedStatus = facts ? facts.observedStatus : null;
195
+ // Health measured against the WORKTREE produced code, via the project's
196
+ // configured source (resolved from the worktree's copied config). No signal
197
+ // ⇒ null, exactly like the main arm.
198
+ const metricSource = resolveMetricSource(readProjectConfig(worktreePath));
199
+ const healthPenalty = (await measureHealthPenalty(metricSource, worktreePath)) ?? null;
200
+ const loss = passRate !== null
201
+ ? computePerChangeLoss({
202
+ passRate,
203
+ healthPenalty: healthPenalty ?? undefined,
204
+ verified: facts ? facts.verified : undefined,
205
+ }).loss
206
+ : null;
207
+ const objective = {
208
+ passRate,
209
+ ...(metrics ? { testsTotal: metrics.total, testsFailed: metrics.failed } : {}),
210
+ healthPenalty,
211
+ loss,
212
+ verified,
213
+ observedStatus,
214
+ measuredAt,
215
+ };
216
+ // Transcript: the claude session `.jsonl` when discovered, else stdout.
217
+ let transcriptDiscovered = false;
218
+ let transcript;
219
+ let skeleton;
220
+ const sessionPath = trajectory?.sourcePaths[0];
221
+ if (trajectory && sessionPath) {
222
+ try {
223
+ const content = await fs.readFile(sessionPath, 'utf8');
224
+ transcript = { fileName: 'transcript.jsonl', content };
225
+ transcriptDiscovered = true;
226
+ const actionSkeleton = toActionSkeleton(trajectory);
227
+ if (actionSkeleton)
228
+ skeleton = actionSkeleton;
229
+ }
230
+ catch {
231
+ // Unreadable session file — fall back to stdout below.
232
+ transcript = { fileName: 'stdout.txt', content: run.stdout };
233
+ }
234
+ }
235
+ else {
236
+ transcript = { fileName: 'stdout.txt', content: run.stdout };
237
+ }
238
+ const { armDir } = await writeArmCapture({
239
+ repoRoot,
240
+ episodeId: opts.episodeId,
241
+ arm: 'baseline-arm',
242
+ transcript,
243
+ ...(skeleton ? { skeleton } : {}),
244
+ objective,
245
+ });
246
+ // Record the arm landed (monotonic stage advance + which version reran).
247
+ await advanceEpisodeStage({
248
+ repoRoot,
249
+ episodeId: opts.episodeId,
250
+ stage: 'baseline-arm-captured',
251
+ patch: { policyVersionBaseline: opts.baselineVersion },
252
+ });
253
+ return {
254
+ armDir,
255
+ objective,
256
+ transcriptDiscovered,
257
+ worktreePath,
258
+ worktreeMode,
259
+ };
260
+ }
261
+ finally {
262
+ // 6) 产物即弃: ALWAYS tear the worktree down — even when a step above threw.
263
+ await teardownWorktree(repoRoot, worktreePath, worktreeMode, spawnImpl);
264
+ }
265
+ }
266
+ // ---------------------------------------------------------------------------
267
+ // Worktree lifecycle
268
+ // ---------------------------------------------------------------------------
269
+ /**
270
+ * Create an isolated worktree at `worktreePath` OUTSIDE the repo. Tries
271
+ * `git worktree add --detach <worktreePath> HEAD` first; on ANY git failure
272
+ * (not a repo, git missing, etc.) falls back to a recursive file copy of the
273
+ * repo excluding `node_modules` and `.git`. Returns which mode succeeded.
274
+ */
275
+ async function createIsolatedWorktree(repoRoot, worktreePath, spawnImpl) {
276
+ // Best-effort: a stale worktree dir from an interrupted run would make both
277
+ // git-add and copy fail; clear it first (产物即弃 — nothing here is durable).
278
+ await fs.rm(worktreePath, { recursive: true, force: true }).catch(() => { });
279
+ try {
280
+ await runGit(repoRoot, ['worktree', 'add', '--detach', worktreePath, 'HEAD'], spawnImpl);
281
+ return 'git-worktree';
282
+ }
283
+ catch {
284
+ // Fall through to the copy fallback (not a git repo, git unavailable, …).
285
+ }
286
+ try {
287
+ await copyRepoTree(repoRoot, worktreePath);
288
+ return 'copy-fallback';
289
+ }
290
+ catch (err) {
291
+ throw new CriticWorktreeError(`git worktree add failed and the copy fallback failed too: ${err instanceof Error ? err.message : String(err)}`);
292
+ }
293
+ }
294
+ /**
295
+ * Tear down the worktree. For a git worktree: `git worktree remove --force` then
296
+ * `git worktree prune` (both best-effort), and an explicit rmdir to be sure.
297
+ * For the copy fallback: recursive rmdir. Never throws — teardown failures must
298
+ * not mask a real error from the run.
299
+ */
300
+ async function teardownWorktree(repoRoot, worktreePath, mode, spawnImpl) {
301
+ if (mode === 'git-worktree') {
302
+ await runGit(repoRoot, ['worktree', 'remove', '--force', worktreePath], spawnImpl).catch(() => { });
303
+ await runGit(repoRoot, ['worktree', 'prune'], spawnImpl).catch(() => { });
304
+ }
305
+ // The node_modules entry is a junction/symlink; `rm -rf` removes the link, not
306
+ // the real tree behind it. Belt-and-suspenders rmdir for both modes.
307
+ await fs.rm(worktreePath, { recursive: true, force: true }).catch(() => { });
308
+ }
309
+ /** Run a git subcommand in `repoRoot`; rejects on a non-zero exit or spawn error. */
310
+ async function runGit(repoRoot, args, spawnImpl) {
311
+ await new Promise((resolve, reject) => {
312
+ const child = spawnImpl('git', args, { cwd: repoRoot, shell: false });
313
+ const err = [];
314
+ child.stderr?.on('data', (c) => err.push(Buffer.from(c)));
315
+ child.on('error', (e) => reject(e));
316
+ child.on('close', (code) => {
317
+ if (code === 0)
318
+ resolve();
319
+ else
320
+ reject(new Error(`git ${args[0]} exited ${code}: ${Buffer.concat(err).toString('utf8')}`));
321
+ });
322
+ });
323
+ }
324
+ /**
325
+ * Recursive copy of the repo tree into `dest`, excluding `node_modules` and
326
+ * `.git` (the two directories that are huge and/or meaningless in an isolated
327
+ * checkout — node_modules is re-linked separately, .git is the worktree's
328
+ * parent's concern).
329
+ */
330
+ async function copyRepoTree(src, dest) {
331
+ await fs.cp(src, dest, {
332
+ recursive: true,
333
+ filter: (source) => {
334
+ const base = path.basename(source);
335
+ return base !== NODE_MODULES && base !== '.git';
336
+ },
337
+ });
338
+ }
339
+ /**
340
+ * Make the worktree runnable:
341
+ * - junction/symlink `node_modules` into the worktree (junction on Windows so
342
+ * no admin/dev-mode is needed; a plain dir symlink elsewhere), and
343
+ * - copy the untracked surfaces the rerun reads that git worktree / the copy
344
+ * filter do not bring: the change dir, the project-local schemas dir (if
345
+ * present), and the `.synergyspec-selfevolving/` config EXCLUDING its
346
+ * `self-evolution/` subdir (the loop's own state must NOT leak into the
347
+ * isolated rerun).
348
+ */
349
+ async function makeWorktreeRunnable(repoRoot, worktreePath, changeName) {
350
+ // node_modules link.
351
+ const srcNodeModules = path.join(repoRoot, NODE_MODULES);
352
+ if (await pathExists(srcNodeModules)) {
353
+ const destNodeModules = path.join(worktreePath, NODE_MODULES);
354
+ // A git worktree starts empty of node_modules; the copy fallback excluded
355
+ // it. Either way the dest should not exist — clear a stray one to be safe.
356
+ await fs.rm(destNodeModules, { recursive: true, force: true }).catch(() => { });
357
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
358
+ try {
359
+ await fs.symlink(srcNodeModules, destNodeModules, linkType);
360
+ }
361
+ catch {
362
+ // Symlink/junction unavailable (rare) — leave it absent; the rerun may
363
+ // still resolve the linked CLI from the parent install. Non-fatal.
364
+ }
365
+ }
366
+ // Untracked change dir (git tracks it once committed, but a fresh change is
367
+ // untracked; the copy fallback already brought it — copying is idempotent).
368
+ await copyDirInto(path.join(repoRoot, 'synergyspec-selfevolving', 'changes', changeName), path.join(worktreePath, 'synergyspec-selfevolving', 'changes', changeName));
369
+ // Project-local schemas dir, when present.
370
+ await copyDirInto(path.join(repoRoot, SCHEMAS_REL), path.join(worktreePath, SCHEMAS_REL));
371
+ // `.synergyspec-selfevolving/` config, EXCLUDING the self-evolution/ subdir.
372
+ const srcConfig = path.join(repoRoot, CONFIG_DIR);
373
+ if (await pathExists(srcConfig)) {
374
+ await fs.cp(srcConfig, path.join(worktreePath, CONFIG_DIR), {
375
+ recursive: true,
376
+ force: true,
377
+ filter: (source) => {
378
+ const rel = path.relative(srcConfig, source);
379
+ // Drop the loop's own state dir and everything under it.
380
+ return rel !== 'self-evolution' && !rel.startsWith(`self-evolution${path.sep}`);
381
+ },
382
+ });
383
+ }
384
+ }
385
+ /**
386
+ * INSTALL the byte-for-byte 策略 policy vN snapshot files into the worktree at
387
+ * their repo-relative paths. This is the fidelity fix: the baseline arm runs the
388
+ * SAME policy the LAST episode settled on, not whatever happens to be live.
389
+ * Snapshot reads are sha256-verified by {@link readPolicySnapshotFiles}, so a
390
+ * corrupt snapshot throws here rather than silently installing wrong bytes.
391
+ */
392
+ async function installPolicyVersion(repoRoot, worktreePath, targetId, version) {
393
+ const files = await readPolicySnapshotFiles(repoRoot, targetId, version);
394
+ for (const f of files) {
395
+ const abs = path.join(worktreePath, ...f.relPath.split('/'));
396
+ // Defense-in-depth: snapshot relPaths are repo-relative POSIX paths; refuse
397
+ // anything that escapes the worktree.
398
+ const rel = path.relative(worktreePath, abs);
399
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
400
+ throw new Error(`Refusing to install policy file outside the worktree: ${f.relPath}`);
401
+ }
402
+ await fs.mkdir(path.dirname(abs), { recursive: true });
403
+ await fs.writeFile(abs, f.content, 'utf8');
404
+ }
405
+ }
406
+ /**
407
+ * Discover + normalize the claude session transcript produced by the rerun, by
408
+ * computing the claude project-dir path-hash FOR THE WORKTREE PATH (the rerun's
409
+ * cwd) and picking the newest `.jsonl` written after the run started, then
410
+ * reusing the claude adapter to normalize it. Returns `null` on a non-claude
411
+ * harness, no projects dir, or no session file in the window — exactly the
412
+ * "no trajectory ⇒ stdout only" fallback the caller relies on.
413
+ *
414
+ * Reuses {@link claudeProjectsDir} (the path-hash encoding) and the public
415
+ * {@link claudeSourceFactory} (the per-line transcript parser + subagent
416
+ * stitching) so this never reimplements either; full reuse, no new exports.
417
+ */
418
+ async function discoverWorktreeTrajectory(opts) {
419
+ const projectsDir = claudeProjectsDir(opts.worktreePath, opts.homeDir);
420
+ // No projects dir for the worktree ⇒ the host harness is not claude (or never
421
+ // wrote a session). Skip cleanly.
422
+ let entries;
423
+ try {
424
+ entries = await fs.readdir(projectsDir, { withFileTypes: true });
425
+ }
426
+ catch {
427
+ return null;
428
+ }
429
+ // Newest `.jsonl` whose mtime is within the run window (>= runStart). Picking
430
+ // the newest matches trajectory-discovery's window intent: the rerun's own
431
+ // session is the most-recently-written one under the worktree's project dir.
432
+ let newest = null;
433
+ for (const entry of entries) {
434
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
435
+ continue;
436
+ const full = path.join(projectsDir, entry.name);
437
+ let mtimeMs;
438
+ try {
439
+ mtimeMs = (await fs.stat(full)).mtimeMs;
440
+ }
441
+ catch {
442
+ continue;
443
+ }
444
+ if (mtimeMs < opts.runStartMs)
445
+ continue;
446
+ if (!newest || mtimeMs > newest.mtimeMs)
447
+ newest = { path: full, mtimeMs };
448
+ }
449
+ if (!newest)
450
+ return null;
451
+ // Normalize via the claude adapter. The source's getTrajectory re-discovers
452
+ // through findTranscriptsForChange against the WORKTREE root: with no
453
+ // events.ndjson it uses the mtime-overlap fallback over this same projects
454
+ // dir, so the session we just selected is the one normalized (main session +
455
+ // its subagents stitched). Detect against the worktree so the source is
456
+ // pinned to the worktree's project hash.
457
+ try {
458
+ const source = await claudeSourceFactory.detect(opts.worktreePath, {
459
+ homeDir: opts.homeDir,
460
+ });
461
+ if (!source)
462
+ return null;
463
+ return await source.getTrajectory(opts.changeName);
464
+ }
465
+ catch {
466
+ return null;
467
+ }
468
+ }
469
+ // ---------------------------------------------------------------------------
470
+ // Small fs helpers (match the neighbor idiom: no throw on probe)
471
+ // ---------------------------------------------------------------------------
472
+ async function pathExists(p) {
473
+ try {
474
+ await fs.stat(p);
475
+ return true;
476
+ }
477
+ catch {
478
+ return false;
479
+ }
480
+ }
481
+ /** Recursive copy of `src` into `dest` when `src` exists; idempotent, no throw on a missing src. */
482
+ async function copyDirInto(src, dest) {
483
+ if (!(await pathExists(src)))
484
+ return;
485
+ await fs.cp(src, dest, { recursive: true, force: true });
486
+ }
487
+ //# sourceMappingURL=critic-agent.js.map
@@ -0,0 +1,53 @@
1
+ export declare class CanonicalProposerOutputInvalid extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /** The model declined to edit anything (empty edits). Not an error — a no-op. */
5
+ export declare class CanonicalProposerNoOp extends Error {
6
+ constructor();
7
+ }
8
+ /** The headless agent invocation itself failed (crash / empty output). */
9
+ export declare class CanonicalProposerInvocationError extends Error {
10
+ constructor(stderr: string);
11
+ }
12
+ /**
13
+ * The packaged result of one validated candidate edit set: the human-readable
14
+ * unified diff, the POSIX paths actually edited (a subset of the target's
15
+ * declared files), a non-empty rationale, and the parsed full-file-replacement
16
+ * edits. Produced by the manual host-authored channel (`packageHostEdits`).
17
+ */
18
+ export interface CanonicalProposeOutput {
19
+ targetId: string;
20
+ /** A unified-diff rendering of the edits (opaque to the gate; readable by a human). */
21
+ diffPatch: string;
22
+ /** POSIX paths actually edited — always a subset of the target's declared files. */
23
+ changedFiles: string[];
24
+ /** Non-empty rationale (the static gate requires one). */
25
+ rationale: string;
26
+ /** The parsed full-file-replacement edits. */
27
+ edits: {
28
+ relPath: string;
29
+ content: string;
30
+ }[];
31
+ }
32
+ /**
33
+ * Validate already-structured candidate edits against the allowed (target-
34
+ * scoped) file set and the frozen gate-defining files. Author-agnostic: this is
35
+ * the SINGLE place that enforces, at propose time, that every edit (a) is a
36
+ * well-formed `{relPath, content}` object, (b) does not touch a
37
+ * `GATE_DEFINING_FILES` entry (the frozen oracle/gate files), and (c) stays
38
+ * inside `allowedFiles`. Both the manual host-authored (`--from-edits`) path and
39
+ * the loop-v2 演进智能体 EVOLVING AGENT call this so their safety contract is
40
+ * byte-identical. relPaths are normalized to POSIX separators.
41
+ *
42
+ * Throws {@link CanonicalProposerNoOp} when `rawEdits` is empty and
43
+ * {@link CanonicalProposerOutputInvalid} for any shape / frozen / scope
44
+ * violation. Path traversal and absolute paths are rejected transitively: they
45
+ * can never be a member of `allowedFiles`, so they fail the scope check.
46
+ */
47
+ export declare function validateCandidateEdits(rawEdits: readonly unknown[], allowedFiles: readonly string[]): {
48
+ relPath: string;
49
+ content: string;
50
+ }[];
51
+ /** Render a whole-file-replacement unified diff (human-readable; git-apply friendly). */
52
+ export declare function renderUnifiedDiff(relPath: string, oldContent: string, newContent: string): string;
53
+ //# sourceMappingURL=edits-contract.d.ts.map
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared candidate-EDITS contract.
3
+ *
4
+ * The single place that (a) defines the no-op / invalid-output error classes the
5
+ * manual host-authored channel raises, (b) validates already-structured
6
+ * candidate edits against the target-scoped file set + the frozen gate-defining
7
+ * files, and (c) renders a whole-file-replacement unified diff. Both surviving
8
+ * edit channels share it byte-for-byte:
9
+ * - the manual `--from-edits` / `--from-learn` host-authored path
10
+ * (`commands/self-evolution.ts` → `packageHostEdits`, `promote.ts`), and
11
+ * - the loop-v2 演进智能体 EVOLVING AGENT (`evolving-agent.ts`).
12
+ *
13
+ * Pure (no I/O, no spawn): this module never applies, promotes, or mutates any
14
+ * canonical file.
15
+ */
16
+ import { GATE_DEFINING_FILES } from './candidate-gates.js';
17
+ export class CanonicalProposerOutputInvalid extends Error {
18
+ constructor(message) {
19
+ super(`canonical proposer output invalid: ${message}`);
20
+ this.name = 'CanonicalProposerOutputInvalid';
21
+ }
22
+ }
23
+ /** The model declined to edit anything (empty edits). Not an error — a no-op. */
24
+ export class CanonicalProposerNoOp extends Error {
25
+ constructor() {
26
+ super('canonical proposer returned no edits');
27
+ this.name = 'CanonicalProposerNoOp';
28
+ }
29
+ }
30
+ /** The headless agent invocation itself failed (crash / empty output). */
31
+ export class CanonicalProposerInvocationError extends Error {
32
+ constructor(stderr) {
33
+ super(`canonical proposer invocation failed: ${stderr}`);
34
+ this.name = 'CanonicalProposerInvocationError';
35
+ }
36
+ }
37
+ /**
38
+ * Validate already-structured candidate edits against the allowed (target-
39
+ * scoped) file set and the frozen gate-defining files. Author-agnostic: this is
40
+ * the SINGLE place that enforces, at propose time, that every edit (a) is a
41
+ * well-formed `{relPath, content}` object, (b) does not touch a
42
+ * `GATE_DEFINING_FILES` entry (the frozen oracle/gate files), and (c) stays
43
+ * inside `allowedFiles`. Both the manual host-authored (`--from-edits`) path and
44
+ * the loop-v2 演进智能体 EVOLVING AGENT call this so their safety contract is
45
+ * byte-identical. relPaths are normalized to POSIX separators.
46
+ *
47
+ * Throws {@link CanonicalProposerNoOp} when `rawEdits` is empty and
48
+ * {@link CanonicalProposerOutputInvalid} for any shape / frozen / scope
49
+ * violation. Path traversal and absolute paths are rejected transitively: they
50
+ * can never be a member of `allowedFiles`, so they fail the scope check.
51
+ */
52
+ export function validateCandidateEdits(rawEdits, allowedFiles) {
53
+ if (rawEdits.length === 0) {
54
+ throw new CanonicalProposerNoOp();
55
+ }
56
+ const allowed = new Set(allowedFiles.map((p) => p.replace(/\\/g, '/')));
57
+ const frozen = new Set(GATE_DEFINING_FILES.map((p) => p.replace(/\\/g, '/')));
58
+ const validated = [];
59
+ for (const e of rawEdits) {
60
+ if (!e || typeof e !== 'object') {
61
+ throw new CanonicalProposerOutputInvalid('edit entry must be an object');
62
+ }
63
+ const relPath = e.relPath;
64
+ const content = e.content;
65
+ if (typeof relPath !== 'string' || typeof content !== 'string') {
66
+ throw new CanonicalProposerOutputInvalid('edit must have string relPath and string content');
67
+ }
68
+ const norm = relPath.replace(/\\/g, '/');
69
+ if (frozen.has(norm)) {
70
+ throw new CanonicalProposerOutputInvalid(`edit relPath "${relPath}" is a gate-defining/frozen file and may never be proposed`);
71
+ }
72
+ if (!allowed.has(norm)) {
73
+ throw new CanonicalProposerOutputInvalid(`edit relPath "${relPath}" is outside the target's declared files`);
74
+ }
75
+ validated.push({ relPath: norm, content });
76
+ }
77
+ return validated;
78
+ }
79
+ /** Render a whole-file-replacement unified diff (human-readable; git-apply friendly). */
80
+ export function renderUnifiedDiff(relPath, oldContent, newContent) {
81
+ const oldLines = oldContent.length === 0 ? [] : oldContent.replace(/\n$/, '').split('\n');
82
+ const newLines = newContent.replace(/\n$/, '').split('\n');
83
+ const oldStart = oldLines.length === 0 ? 0 : 1;
84
+ const header = `--- a/${relPath}\n+++ b/${relPath}\n` +
85
+ `@@ -${oldStart},${oldLines.length} +1,${newLines.length} @@`;
86
+ const body = [...oldLines.map((l) => `-${l}`), ...newLines.map((l) => `+${l}`)].join('\n');
87
+ return `${header}\n${body}`;
88
+ }
89
+ //# sourceMappingURL=edits-contract.js.map