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.
- package/README.md +50 -19
- package/dist/commands/learn.d.ts +12 -1
- package/dist/commands/learn.js +373 -31
- package/dist/commands/self-evolution-episode.d.ts +177 -0
- package/dist/commands/self-evolution-episode.js +423 -0
- package/dist/commands/self-evolution.d.ts +12 -190
- package/dist/commands/self-evolution.js +179 -786
- package/dist/commands/workflow/status.js +3 -1
- package/dist/core/archive.d.ts +0 -1
- package/dist/core/archive.js +0 -58
- package/dist/core/artifact-graph/instruction-loader.d.ts +2 -4
- package/dist/core/artifact-graph/instruction-loader.js +3 -31
- package/dist/core/config-prompts.js +4 -0
- package/dist/core/fitness/health/health-metrics.d.ts +26 -56
- package/dist/core/fitness/health/health-metrics.js +19 -58
- package/dist/core/fitness/health/index.d.ts +15 -2
- package/dist/core/fitness/health/index.js +25 -1
- package/dist/core/fitness/health/local-source.d.ts +43 -4
- package/dist/core/fitness/health/local-source.js +181 -25
- package/dist/core/fitness/health/metric-source.d.ts +48 -19
- package/dist/core/fitness/health/metric-source.js +8 -18
- package/dist/core/fitness/health/resolve-source.js +4 -1
- package/dist/core/fitness/loss.d.ts +7 -7
- package/dist/core/fitness/loss.js +6 -6
- package/dist/core/fitness/sample.d.ts +10 -0
- package/dist/core/fitness/test-failures.d.ts +30 -0
- package/dist/core/fitness/test-failures.js +123 -0
- package/dist/core/learn/credit-path.d.ts +36 -0
- package/dist/core/learn/credit-path.js +198 -0
- package/dist/core/learn/trajectory-discovery.d.ts +39 -0
- package/dist/core/learn/trajectory-discovery.js +140 -0
- package/dist/core/learn.d.ts +39 -5
- package/dist/core/learn.js +131 -14
- package/dist/core/project-config.d.ts +4 -0
- package/dist/core/project-config.js +52 -1
- package/dist/core/self-evolution/candidate-fitness.d.ts +23 -1
- package/dist/core/self-evolution/candidate-fitness.js +31 -5
- package/dist/core/self-evolution/candidates.d.ts +0 -9
- package/dist/core/self-evolution/canonical-targets.d.ts +8 -4
- package/dist/core/self-evolution/canonical-targets.js +8 -4
- package/dist/core/self-evolution/critic-agent.d.ts +150 -0
- package/dist/core/self-evolution/critic-agent.js +487 -0
- package/dist/core/self-evolution/edits-contract.d.ts +53 -0
- package/dist/core/self-evolution/edits-contract.js +89 -0
- package/dist/core/self-evolution/episode-orchestrator.d.ts +197 -0
- package/dist/core/self-evolution/episode-orchestrator.js +534 -0
- package/dist/core/self-evolution/episode-store.d.ts +266 -0
- package/dist/core/self-evolution/episode-store.js +573 -0
- package/dist/core/self-evolution/evolution-switches.d.ts +1 -1
- package/dist/core/self-evolution/evolution-switches.js +5 -10
- package/dist/core/self-evolution/evolving-agent.d.ts +162 -0
- package/dist/core/self-evolution/evolving-agent.js +449 -0
- package/dist/core/self-evolution/health-baseline.d.ts +25 -6
- package/dist/core/self-evolution/health-baseline.js +30 -6
- package/dist/core/self-evolution/host-harness.d.ts +1 -2
- package/dist/core/self-evolution/host-harness.js +1 -2
- package/dist/core/self-evolution/index.d.ts +10 -6
- package/dist/core/self-evolution/index.js +19 -6
- package/dist/core/self-evolution/learn-hints.d.ts +31 -0
- package/dist/core/self-evolution/learn-hints.js +16 -0
- package/dist/core/self-evolution/learn-observation-adapter.d.ts +35 -0
- package/dist/core/self-evolution/learn-observation-adapter.js +285 -10
- package/dist/core/self-evolution/line-diff.d.ts +60 -0
- package/dist/core/self-evolution/line-diff.js +130 -0
- package/dist/core/self-evolution/policy/fs-safe.d.ts +19 -0
- package/dist/core/self-evolution/policy/fs-safe.js +89 -0
- package/dist/core/self-evolution/policy/index.d.ts +13 -0
- package/dist/core/self-evolution/policy/index.js +13 -0
- package/dist/core/self-evolution/policy/policy-store.d.ts +217 -0
- package/dist/core/self-evolution/policy/policy-store.js +774 -0
- package/dist/core/self-evolution/policy/reject-buffer.d.ts +48 -0
- package/dist/core/self-evolution/policy/reject-buffer.js +168 -0
- package/dist/core/self-evolution/promote.d.ts +1 -1
- package/dist/core/self-evolution/promote.js +6 -33
- package/dist/core/self-evolution/promotion.js +1 -2
- package/dist/core/self-evolution/proposer-agent.d.ts +41 -0
- package/dist/core/self-evolution/proposer-agent.js +94 -13
- package/dist/core/self-evolution/proposer-slice.d.ts +26 -0
- package/dist/core/self-evolution/proposer-slice.js +54 -0
- package/dist/core/self-evolution/reward-agent.d.ts +234 -0
- package/dist/core/self-evolution/reward-agent.js +564 -0
- package/dist/core/self-evolution/scope-gate.d.ts +66 -0
- package/dist/core/self-evolution/scope-gate.js +107 -0
- package/dist/core/self-evolution/success-channel.d.ts +79 -0
- package/dist/core/self-evolution/success-channel.js +361 -0
- package/dist/core/self-evolution/target-evolution.d.ts +11 -0
- package/dist/core/self-evolution/target-evolution.js +2 -0
- package/dist/core/self-evolution/tool-evolution.js +2 -13
- package/dist/core/self-evolution/verdict.d.ts +8 -5
- package/dist/core/self-evolution/verdict.js +4 -7
- package/dist/core/templates/skill-templates.d.ts +1 -0
- package/dist/core/templates/skill-templates.js +1 -0
- package/dist/core/templates/workflow-manifest.js +2 -0
- package/dist/core/templates/workflows/learn.d.ts +4 -2
- package/dist/core/templates/workflows/learn.js +25 -166
- package/dist/core/templates/workflows/self-evolving.d.ts +13 -0
- package/dist/core/templates/workflows/self-evolving.js +127 -0
- package/dist/core/trajectory/facts.d.ts +16 -0
- package/dist/core/trajectory/facts.js +12 -4
- package/dist/core/trajectory/skeleton.d.ts +43 -0
- package/dist/core/trajectory/skeleton.js +239 -0
- package/dist/dashboard/data.d.ts +25 -51
- package/dist/dashboard/data.js +68 -180
- package/dist/dashboard/react-client.js +458 -503
- package/dist/dashboard/react-styles.js +3 -3
- package/dist/dashboard/server.js +23 -17
- package/dist/ui/ascii-patterns.d.ts +7 -15
- package/dist/ui/ascii-patterns.js +123 -54
- package/dist/ui/welcome-screen.d.ts +0 -14
- package/dist/ui/welcome-screen.js +16 -35
- package/package.json +3 -1
- package/scripts/code-health.py +1066 -638
- package/scripts/slop_rules.yaml +2151 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 磁盘 DISK episode store(transcripts ×双臂)— loop v2 (self-evolution as
|
|
3
|
+
* in-context RL).
|
|
4
|
+
*
|
|
5
|
+
* This module is DUMB STORAGE plus a stage machine — it never spawns an agent
|
|
6
|
+
* and never computes a skeleton; callers pass objects in. One episode = one
|
|
7
|
+
* synergyspec-selfevolving change run through the loop, recorded under:
|
|
8
|
+
*
|
|
9
|
+
* <repoRoot>/.synergyspec-selfevolving/self-evolution/episodes/<episodeId>/
|
|
10
|
+
* episode.json // the state-machine record (this module owns it)
|
|
11
|
+
* main-arm/ // 主智能体 MAIN AGENT (frozen actor; the user's host
|
|
12
|
+
* // agent running the current policy vN+1):
|
|
13
|
+
* // transcript.jsonl + skeleton.json + objective.json
|
|
14
|
+
* baseline-arm/ // CRITIC AGENT(基线智能体 baseline agent)output — an
|
|
15
|
+
* // AGENT with the same input/output as the main agent
|
|
16
|
+
* // that reruns LAST episode's policy vN on the SAME
|
|
17
|
+
* // change. This dir is the ONLY artifact that survives
|
|
18
|
+
* // its worktree (产物即弃 — worktree artifacts
|
|
19
|
+
* // discarded): same three files, except the transcript
|
|
20
|
+
* // may be `stdout.txt` when the harness exposes no
|
|
21
|
+
* // session file.
|
|
22
|
+
* diagnosis.json // written LATER by the 奖励智能体 REWARD AGENT via
|
|
23
|
+
* // {@link writeDiagnosis} — LLM as judge; CALCULATES
|
|
24
|
+
* // 算分 reward(主臂)&reward(基线臂); advantage =
|
|
25
|
+
* // reward(主臂) − reward(基线臂); 文本梯度 textual
|
|
26
|
+
* // gradient; never edits; 弃权 abstains when no
|
|
27
|
+
* // nameable gap. {@link createEpisode} never creates
|
|
28
|
+
* // this file; this module only stores its content.
|
|
29
|
+
*
|
|
30
|
+
* The 5 artifacts + test-report stay in the change dir
|
|
31
|
+
* (`synergyspec-selfevolving/changes/<name>/`) — `episode.json` records that
|
|
32
|
+
* path (`changeDirPath`), never copies.
|
|
33
|
+
*
|
|
34
|
+
* 策略 POLICY = design template(主智能体的「权重」)· CLI 持有版本:
|
|
35
|
+
* `policyVersionMain` / `policyVersionBaseline` record WHICH version each arm
|
|
36
|
+
* ran. The versions themselves live in the 版本账本 ledger (a sibling module);
|
|
37
|
+
* 单一血统 single lineage and the 否决缓冲 reject-buffer are enforced there,
|
|
38
|
+
* not here. The 演进智能体 EVOLVING AGENT (optimizer.step; ONE bounded edit ≤L;
|
|
39
|
+
* never scores) runs after scoring; its outcome lands in this store only as a
|
|
40
|
+
* stage (`evolved` | `evolution-refused`).
|
|
41
|
+
*
|
|
42
|
+
* Hard rules (matching candidates.ts):
|
|
43
|
+
* - This module ONLY reads/writes inside the episodes base dir.
|
|
44
|
+
* - All writes use a tmp + rename pattern so a half-written record never
|
|
45
|
+
* appears under its final name.
|
|
46
|
+
* - Stage changes go through a validated MONOTONIC state machine —
|
|
47
|
+
* advancing to a stage not reachable from the current one throws.
|
|
48
|
+
*/
|
|
49
|
+
import { promises as fs } from 'node:fs';
|
|
50
|
+
import * as path from 'node:path';
|
|
51
|
+
import * as crypto from 'node:crypto';
|
|
52
|
+
/**
|
|
53
|
+
* Iterable list of every legal {@link EpisodeStage} value. Order follows the
|
|
54
|
+
* documented state machine for readability, not behavior.
|
|
55
|
+
*/
|
|
56
|
+
export const EPISODE_STAGES = [
|
|
57
|
+
'created',
|
|
58
|
+
'main-arm-captured',
|
|
59
|
+
'baseline-arm-captured',
|
|
60
|
+
'baseline-skipped',
|
|
61
|
+
'scored',
|
|
62
|
+
'rolled-back',
|
|
63
|
+
'kept',
|
|
64
|
+
'evolved',
|
|
65
|
+
'evolution-refused',
|
|
66
|
+
'abstained',
|
|
67
|
+
'closed',
|
|
68
|
+
];
|
|
69
|
+
const EPISODES_SUBDIR = path.join('.synergyspec-selfevolving', 'self-evolution', 'episodes');
|
|
70
|
+
const EPISODE_JSON_FILE = 'episode.json';
|
|
71
|
+
const DIAGNOSIS_JSON_FILE = 'diagnosis.json';
|
|
72
|
+
const SKELETON_JSON_FILE = 'skeleton.json';
|
|
73
|
+
const OBJECTIVE_JSON_FILE = 'objective.json';
|
|
74
|
+
const EPISODE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
75
|
+
const LEGAL_STAGE_TRANSITIONS = new Map([
|
|
76
|
+
['created', new Set(['main-arm-captured'])],
|
|
77
|
+
[
|
|
78
|
+
'main-arm-captured',
|
|
79
|
+
new Set(['baseline-arm-captured', 'baseline-skipped']),
|
|
80
|
+
],
|
|
81
|
+
['baseline-arm-captured', new Set(['scored'])],
|
|
82
|
+
['baseline-skipped', new Set(['scored'])],
|
|
83
|
+
// 'abstained' directly from 'scored': the 奖励智能体 REWARD AGENT 弃权
|
|
84
|
+
// abstained, so no rollback decision is needed and the 演进智能体
|
|
85
|
+
// EVOLVING AGENT is never spawned.
|
|
86
|
+
['scored', new Set(['rolled-back', 'kept', 'abstained'])],
|
|
87
|
+
[
|
|
88
|
+
'rolled-back',
|
|
89
|
+
new Set(['evolved', 'evolution-refused', 'abstained']),
|
|
90
|
+
],
|
|
91
|
+
['kept', new Set(['evolved', 'evolution-refused', 'abstained'])],
|
|
92
|
+
['evolved', new Set(['closed'])],
|
|
93
|
+
['evolution-refused', new Set(['closed'])],
|
|
94
|
+
['abstained', new Set(['closed'])],
|
|
95
|
+
['closed', new Set()],
|
|
96
|
+
]);
|
|
97
|
+
/**
|
|
98
|
+
* True iff `(from -> to)` is a legal transition in the episode stage machine.
|
|
99
|
+
* See the type-level doc on {@link EpisodeStage} for the full table.
|
|
100
|
+
*
|
|
101
|
+
* Pure function; safe to call from validators, tests, and UIs.
|
|
102
|
+
*/
|
|
103
|
+
export function isLegalEpisodeStageTransition(from, to) {
|
|
104
|
+
const allowed = LEGAL_STAGE_TRANSITIONS.get(from);
|
|
105
|
+
if (!allowed)
|
|
106
|
+
return false;
|
|
107
|
+
return allowed.has(to);
|
|
108
|
+
}
|
|
109
|
+
/** Computed: `<repoRoot>/.synergyspec-selfevolving/self-evolution/episodes`. */
|
|
110
|
+
function episodesBaseDir(repoRoot) {
|
|
111
|
+
return path.join(repoRoot, EPISODES_SUBDIR);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Absolute path of one episode's directory. No I/O; the dir may not exist yet.
|
|
115
|
+
* Throws on an unsafe id so this can never be used for path traversal.
|
|
116
|
+
*/
|
|
117
|
+
export function episodeDir(repoRoot, episodeId) {
|
|
118
|
+
assertSafeEpisodeId(episodeId);
|
|
119
|
+
return path.join(episodesBaseDir(repoRoot), episodeId);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Throws if `episodeId` is unsafe to use as a directory name. Episode ids are
|
|
123
|
+
* sanitized at creation to `[a-z0-9-]`, so anything else (separators, parent
|
|
124
|
+
* traversal, uppercase, dots) is rejected. Called from every reader/writer
|
|
125
|
+
* before touching disk.
|
|
126
|
+
*/
|
|
127
|
+
function assertSafeEpisodeId(episodeId) {
|
|
128
|
+
if (typeof episodeId !== 'string' || episodeId.length === 0) {
|
|
129
|
+
throw new Error(`Invalid episode id (empty): ${JSON.stringify(episodeId)}`);
|
|
130
|
+
}
|
|
131
|
+
if (!EPISODE_ID_PATTERN.test(episodeId)) {
|
|
132
|
+
throw new Error(`Episode id must match [a-z0-9-] (lowercase, no separators): ${JSON.stringify(episodeId)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Throws if `target` resolves to a path outside the episodes base dir. This is
|
|
137
|
+
* the sole sandbox enforcement for write operations (mirrors candidates.ts).
|
|
138
|
+
*/
|
|
139
|
+
function assertWithinEpisodesBaseDir(repoRoot, target) {
|
|
140
|
+
const normalizedBase = path.resolve(episodesBaseDir(repoRoot));
|
|
141
|
+
const normalizedTarget = path.resolve(target);
|
|
142
|
+
const rel = path.relative(normalizedBase, normalizedTarget);
|
|
143
|
+
if (rel === '' || rel === '.') {
|
|
144
|
+
return; // target is exactly the base dir
|
|
145
|
+
}
|
|
146
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
147
|
+
throw new Error(`Refusing to write outside episodes base dir. base=${normalizedBase} target=${normalizedTarget}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Local atomic-write helper modeled on the candidates.ts idiom: write to a
|
|
152
|
+
* sibling `.<name>.tmp-<rand>` file, then rename in-place; on a rename failure
|
|
153
|
+
* remove the tmp file so the original error surfaces with nothing half-written.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately NOT imported from `./policy/` — a parallel changeset is creating
|
|
156
|
+
* that module right now; a later integration step may consolidate the helpers.
|
|
157
|
+
*/
|
|
158
|
+
async function atomicWriteFile(repoRoot, dir, fileName, body) {
|
|
159
|
+
const finalPath = path.join(dir, fileName);
|
|
160
|
+
assertWithinEpisodesBaseDir(repoRoot, finalPath);
|
|
161
|
+
const tmpPath = path.join(dir, `.${fileName}.tmp-${crypto.randomBytes(4).toString('hex')}`);
|
|
162
|
+
assertWithinEpisodesBaseDir(repoRoot, tmpPath);
|
|
163
|
+
await fs.writeFile(tmpPath, body, 'utf8');
|
|
164
|
+
try {
|
|
165
|
+
await fs.rename(tmpPath, finalPath);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
try {
|
|
169
|
+
await fs.rm(tmpPath, { force: true });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// ignore — surface the original failure
|
|
173
|
+
}
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
return finalPath;
|
|
177
|
+
}
|
|
178
|
+
/** Serialize a JSON payload the way every sibling store does (2-space + trailing \n). */
|
|
179
|
+
function jsonBody(value) {
|
|
180
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Sanitize a raw id seed to `[a-z0-9-]`: lowercase, collapse every illegal run
|
|
184
|
+
* to a single `-`, trim leading/trailing `-`. Note the compact UTC timestamp's
|
|
185
|
+
* `T` separator comes out lowercase (`20260612t153045`).
|
|
186
|
+
*/
|
|
187
|
+
function sanitizeEpisodeId(raw) {
|
|
188
|
+
return raw
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
191
|
+
.replace(/-{2,}/g, '-')
|
|
192
|
+
.replace(/^-+|-+$/g, '');
|
|
193
|
+
}
|
|
194
|
+
/** Compact UTC timestamp `yyyyMMddTHHmmss` (pre-sanitization). */
|
|
195
|
+
function compactUtcTimestamp(now) {
|
|
196
|
+
const pad = (n, width = 2) => String(n).padStart(width, '0');
|
|
197
|
+
return (`${pad(now.getUTCFullYear(), 4)}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}` +
|
|
198
|
+
`T${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Create a new episode directory with its `episode.json` at stage `created`
|
|
202
|
+
* (plus empty `main-arm/` and `baseline-arm/` dirs so the on-disk layout is
|
|
203
|
+
* self-describing). `diagnosis.json` is deliberately NOT created here — the
|
|
204
|
+
* 奖励智能体 REWARD AGENT writes it later via {@link writeDiagnosis}.
|
|
205
|
+
*
|
|
206
|
+
* Atomic: the whole episode dir is staged in a sibling tmp dir and renamed
|
|
207
|
+
* into place, so a half-written episode never appears under its final id.
|
|
208
|
+
*
|
|
209
|
+
* @returns The created record and the absolute episode directory. Callers must
|
|
210
|
+
* read `episode.episodeId` back — a collision may have suffixed it.
|
|
211
|
+
*/
|
|
212
|
+
export async function createEpisode(opts) {
|
|
213
|
+
const { repoRoot, changeName, changeDirPath, targetId, policyVersionMain } = opts;
|
|
214
|
+
if (typeof changeName !== 'string' || changeName.length === 0) {
|
|
215
|
+
throw new Error(`Invalid changeName (empty): ${JSON.stringify(changeName)}`);
|
|
216
|
+
}
|
|
217
|
+
if (typeof changeDirPath !== 'string' || changeDirPath.length === 0) {
|
|
218
|
+
throw new Error(`Invalid changeDirPath (empty): ${JSON.stringify(changeDirPath)}`);
|
|
219
|
+
}
|
|
220
|
+
if (typeof targetId !== 'string' || targetId.length === 0) {
|
|
221
|
+
throw new Error(`Invalid targetId (empty): ${JSON.stringify(targetId)}`);
|
|
222
|
+
}
|
|
223
|
+
if (policyVersionMain !== null && !Number.isFinite(policyVersionMain)) {
|
|
224
|
+
throw new Error(`Invalid policyVersionMain (must be a finite number or null): ${JSON.stringify(policyVersionMain)}`);
|
|
225
|
+
}
|
|
226
|
+
const now = opts.now ?? new Date();
|
|
227
|
+
let baseId;
|
|
228
|
+
if (opts.episodeId !== undefined) {
|
|
229
|
+
assertSafeEpisodeId(opts.episodeId);
|
|
230
|
+
baseId = opts.episodeId;
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
baseId = sanitizeEpisodeId(`${changeName}-${compactUtcTimestamp(now)}`);
|
|
234
|
+
}
|
|
235
|
+
const baseDir = episodesBaseDir(repoRoot);
|
|
236
|
+
// Collision suffixing: first free of <baseId>, <baseId>-2, <baseId>-3, …
|
|
237
|
+
let id = baseId;
|
|
238
|
+
for (let n = 2;; n++) {
|
|
239
|
+
const candidateDir = path.join(baseDir, id);
|
|
240
|
+
try {
|
|
241
|
+
await fs.stat(candidateDir);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
if (err.code === 'ENOENT')
|
|
245
|
+
break;
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
id = `${baseId}-${n}`;
|
|
249
|
+
}
|
|
250
|
+
const at = now.toISOString();
|
|
251
|
+
const episode = {
|
|
252
|
+
schemaVersion: 1,
|
|
253
|
+
episodeId: id,
|
|
254
|
+
changeName,
|
|
255
|
+
changeDirPath,
|
|
256
|
+
targetId,
|
|
257
|
+
policyVersionMain,
|
|
258
|
+
policyVersionBaseline: null,
|
|
259
|
+
stage: 'created',
|
|
260
|
+
stageHistory: [{ stage: 'created', at }],
|
|
261
|
+
createdAt: at,
|
|
262
|
+
updatedAt: at,
|
|
263
|
+
};
|
|
264
|
+
await fs.mkdir(baseDir, { recursive: true });
|
|
265
|
+
const finalDir = path.join(baseDir, id);
|
|
266
|
+
assertWithinEpisodesBaseDir(repoRoot, finalDir);
|
|
267
|
+
// mkdtemp returns the actual path used (handles os-suffix). Prefix with id.
|
|
268
|
+
const tmpDir = await fs.mkdtemp(path.join(baseDir, `${id}.tmp-`));
|
|
269
|
+
assertWithinEpisodesBaseDir(repoRoot, tmpDir);
|
|
270
|
+
try {
|
|
271
|
+
await fs.writeFile(path.join(tmpDir, EPISODE_JSON_FILE), jsonBody(episode), 'utf8');
|
|
272
|
+
await fs.mkdir(path.join(tmpDir, 'main-arm'));
|
|
273
|
+
await fs.mkdir(path.join(tmpDir, 'baseline-arm'));
|
|
274
|
+
await fs.rename(tmpDir, finalDir);
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// Best-effort cleanup; surface the original failure.
|
|
278
|
+
try {
|
|
279
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
// ignore
|
|
283
|
+
}
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
return { episode, episodeDir: finalDir };
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Parse an `episode.json` payload with light runtime validation (mirrors
|
|
290
|
+
* `parseCandidateJson` in candidates.ts): obvious shape violations are
|
|
291
|
+
* rejected at the read boundary while additive future fields pass through.
|
|
292
|
+
*/
|
|
293
|
+
function parseEpisodeJson(jsonRaw, episodeId) {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(jsonRaw);
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
throw new Error(`Invalid episode.json for ${episodeId}: ${err.message}`);
|
|
300
|
+
}
|
|
301
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
302
|
+
throw new Error(`Invalid episode.json for ${episodeId}: expected object, got ${typeof parsed}`);
|
|
303
|
+
}
|
|
304
|
+
const o = parsed;
|
|
305
|
+
if (o.schemaVersion !== 1) {
|
|
306
|
+
throw new Error(`Invalid episode.json for ${episodeId}: unsupported schemaVersion ${JSON.stringify(o.schemaVersion)}`);
|
|
307
|
+
}
|
|
308
|
+
const requiredString = (key) => {
|
|
309
|
+
const v = o[key];
|
|
310
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
311
|
+
throw new Error(`Invalid episode.json for ${episodeId}: missing/empty string field "${key}"`);
|
|
312
|
+
}
|
|
313
|
+
return v;
|
|
314
|
+
};
|
|
315
|
+
const numberOrNull = (key) => {
|
|
316
|
+
const v = o[key];
|
|
317
|
+
if (v === null)
|
|
318
|
+
return null;
|
|
319
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
320
|
+
throw new Error(`Invalid episode.json for ${episodeId}: field "${key}" must be a finite number or null`);
|
|
321
|
+
}
|
|
322
|
+
return v;
|
|
323
|
+
};
|
|
324
|
+
requiredString('episodeId');
|
|
325
|
+
requiredString('changeName');
|
|
326
|
+
requiredString('changeDirPath');
|
|
327
|
+
requiredString('targetId');
|
|
328
|
+
requiredString('createdAt');
|
|
329
|
+
requiredString('updatedAt');
|
|
330
|
+
numberOrNull('policyVersionMain');
|
|
331
|
+
numberOrNull('policyVersionBaseline');
|
|
332
|
+
const stage = requiredString('stage');
|
|
333
|
+
if (!EPISODE_STAGES.includes(stage)) {
|
|
334
|
+
throw new Error(`Invalid episode.json for ${episodeId}: stage "${stage}" is not a known EpisodeStage`);
|
|
335
|
+
}
|
|
336
|
+
const history = o.stageHistory;
|
|
337
|
+
if (!Array.isArray(history)) {
|
|
338
|
+
throw new Error(`Invalid episode.json for ${episodeId}: field "stageHistory" must be an array`);
|
|
339
|
+
}
|
|
340
|
+
for (const entry of history) {
|
|
341
|
+
const entryStage = entry?.stage;
|
|
342
|
+
const entryAt = entry?.at;
|
|
343
|
+
if (typeof entryStage !== 'string' ||
|
|
344
|
+
!EPISODE_STAGES.includes(entryStage) ||
|
|
345
|
+
typeof entryAt !== 'string') {
|
|
346
|
+
throw new Error(`Invalid episode.json for ${episodeId}: stageHistory entries need a known stage and a string "at"`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return o;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Read one episode's `episode.json`. Throws a deterministic `Error` when the
|
|
353
|
+
* episode does not exist.
|
|
354
|
+
*/
|
|
355
|
+
export async function readEpisode(repoRoot, episodeId) {
|
|
356
|
+
const dir = episodeDir(repoRoot, episodeId);
|
|
357
|
+
let raw;
|
|
358
|
+
try {
|
|
359
|
+
raw = await fs.readFile(path.join(dir, EPISODE_JSON_FILE), 'utf8');
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
if (err.code === 'ENOENT') {
|
|
363
|
+
throw new Error(`Episode not found: ${episodeId} (looked under ${dir})`);
|
|
364
|
+
}
|
|
365
|
+
throw err;
|
|
366
|
+
}
|
|
367
|
+
return parseEpisodeJson(raw, episodeId);
|
|
368
|
+
}
|
|
369
|
+
const ALLOWED_PATCH_KEYS = new Set([
|
|
370
|
+
'policyVersionBaseline',
|
|
371
|
+
'baselineSkippedReason',
|
|
372
|
+
'advantage',
|
|
373
|
+
]);
|
|
374
|
+
/** Validate an {@link EpisodeStagePatch} fail-closed; returns the merge slice. */
|
|
375
|
+
function validateStagePatch(patch, episodeId) {
|
|
376
|
+
const merge = {};
|
|
377
|
+
for (const key of Object.keys(patch)) {
|
|
378
|
+
if (!ALLOWED_PATCH_KEYS.has(key)) {
|
|
379
|
+
throw new Error(`Illegal episode patch field for ${episodeId}: "${key}" ` +
|
|
380
|
+
`(allowed: policyVersionBaseline, baselineSkippedReason, advantage)`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if ('policyVersionBaseline' in patch) {
|
|
384
|
+
const v = patch.policyVersionBaseline;
|
|
385
|
+
if (v !== null && !Number.isFinite(v)) {
|
|
386
|
+
throw new Error(`Invalid patch for ${episodeId}: policyVersionBaseline must be a finite number or null`);
|
|
387
|
+
}
|
|
388
|
+
merge.policyVersionBaseline = v ?? null;
|
|
389
|
+
}
|
|
390
|
+
if ('baselineSkippedReason' in patch) {
|
|
391
|
+
const v = patch.baselineSkippedReason;
|
|
392
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
393
|
+
throw new Error(`Invalid patch for ${episodeId}: baselineSkippedReason must be a non-empty string`);
|
|
394
|
+
}
|
|
395
|
+
merge.baselineSkippedReason = v;
|
|
396
|
+
}
|
|
397
|
+
if ('advantage' in patch) {
|
|
398
|
+
const v = patch.advantage;
|
|
399
|
+
if (v !== null && !Number.isFinite(v)) {
|
|
400
|
+
throw new Error(`Invalid patch for ${episodeId}: advantage must be a finite number or null`);
|
|
401
|
+
}
|
|
402
|
+
merge.advantage = v ?? null;
|
|
403
|
+
}
|
|
404
|
+
return merge;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Advance an episode to its next stage — atomic read-modify-write of
|
|
408
|
+
* `episode.json` (sibling tmp file + rename, so a half-written advance is
|
|
409
|
+
* never observed).
|
|
410
|
+
*
|
|
411
|
+
* - Validates the transition via {@link isLegalEpisodeStageTransition};
|
|
412
|
+
* advancing to a stage not reachable from the current one throws.
|
|
413
|
+
* - Appends `{stage, at}` to `stageHistory`.
|
|
414
|
+
* - Merges the allowlisted `patch` fields (`policyVersionBaseline`,
|
|
415
|
+
* `baselineSkippedReason`, `advantage`) in the same write.
|
|
416
|
+
* - Bumps `updatedAt`.
|
|
417
|
+
*/
|
|
418
|
+
export async function advanceEpisodeStage(opts) {
|
|
419
|
+
const { repoRoot, episodeId, stage, patch } = opts;
|
|
420
|
+
if (!EPISODE_STAGES.includes(stage)) {
|
|
421
|
+
throw new Error(`Unknown episode stage: ${JSON.stringify(stage)}`);
|
|
422
|
+
}
|
|
423
|
+
const current = await readEpisode(repoRoot, episodeId);
|
|
424
|
+
if (!isLegalEpisodeStageTransition(current.stage, stage)) {
|
|
425
|
+
throw new Error(`Illegal episode stage transition for ${episodeId}: ${current.stage} -> ${stage}`);
|
|
426
|
+
}
|
|
427
|
+
const merge = patch ? validateStagePatch(patch, episodeId) : {};
|
|
428
|
+
const at = new Date().toISOString();
|
|
429
|
+
const updated = {
|
|
430
|
+
...current,
|
|
431
|
+
...merge,
|
|
432
|
+
stage,
|
|
433
|
+
stageHistory: [...current.stageHistory, { stage, at }],
|
|
434
|
+
updatedAt: at,
|
|
435
|
+
};
|
|
436
|
+
const dir = episodeDir(repoRoot, episodeId);
|
|
437
|
+
await atomicWriteFile(repoRoot, dir, EPISODE_JSON_FILE, jsonBody(updated));
|
|
438
|
+
return updated;
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* List every episode (newest first by `createdAt`; ties resolve by id for
|
|
442
|
+
* determinism, matching `listCandidates`).
|
|
443
|
+
*
|
|
444
|
+
* - Returns `[]` cleanly when the episodes base dir does not exist.
|
|
445
|
+
* - Skips in-flight `.tmp-` dirs from a previous interrupted create.
|
|
446
|
+
* - Skips directories without a readable, valid `episode.json` (prints a
|
|
447
|
+
* warning to stderr so corrupted episodes are surfaced).
|
|
448
|
+
*/
|
|
449
|
+
export async function listEpisodes(repoRoot) {
|
|
450
|
+
const baseDir = episodesBaseDir(repoRoot);
|
|
451
|
+
let entries;
|
|
452
|
+
try {
|
|
453
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (err.code === 'ENOENT') {
|
|
457
|
+
return [];
|
|
458
|
+
}
|
|
459
|
+
throw err;
|
|
460
|
+
}
|
|
461
|
+
const results = [];
|
|
462
|
+
for (const entry of entries) {
|
|
463
|
+
if (!entry.isDirectory())
|
|
464
|
+
continue;
|
|
465
|
+
// Skip in-flight tmp dirs from a previous interrupted create.
|
|
466
|
+
if (entry.name.includes('.tmp-'))
|
|
467
|
+
continue;
|
|
468
|
+
let raw;
|
|
469
|
+
try {
|
|
470
|
+
raw = await fs.readFile(path.join(baseDir, entry.name, EPISODE_JSON_FILE), 'utf8');
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
// eslint-disable-next-line no-console
|
|
474
|
+
console.warn(`[episode-store] skipping ${entry.name}: missing or unreadable ${EPISODE_JSON_FILE}`);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
let parsed;
|
|
478
|
+
try {
|
|
479
|
+
parsed = parseEpisodeJson(raw, entry.name);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// eslint-disable-next-line no-console
|
|
483
|
+
console.warn(`[episode-store] skipping ${entry.name}: invalid ${EPISODE_JSON_FILE}`);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
results.push(parsed);
|
|
487
|
+
}
|
|
488
|
+
results.sort((a, b) => {
|
|
489
|
+
// Descending createdAt; ties resolve by id for determinism.
|
|
490
|
+
if (a.createdAt === b.createdAt) {
|
|
491
|
+
return a.episodeId < b.episodeId ? 1 : a.episodeId > b.episodeId ? -1 : 0;
|
|
492
|
+
}
|
|
493
|
+
return a.createdAt < b.createdAt ? 1 : -1;
|
|
494
|
+
});
|
|
495
|
+
return results;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Throws if `fileName` is unsafe to write inside an arm dir (path separators,
|
|
499
|
+
* traversal, hidden tmp collisions, NUL).
|
|
500
|
+
*/
|
|
501
|
+
function assertSafeArmFileName(fileName) {
|
|
502
|
+
if (typeof fileName !== 'string' || fileName.length === 0) {
|
|
503
|
+
throw new Error(`Invalid transcript fileName (empty): ${JSON.stringify(fileName)}`);
|
|
504
|
+
}
|
|
505
|
+
if (fileName.includes('/') ||
|
|
506
|
+
fileName.includes('\\') ||
|
|
507
|
+
fileName.includes('..') ||
|
|
508
|
+
fileName.includes('\x00') ||
|
|
509
|
+
fileName.startsWith('.')) {
|
|
510
|
+
throw new Error(`Refusing to write transcript with unsafe fileName: ${JSON.stringify(fileName)}`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Persist one arm's capture into `<episodeDir>/<arm>/`. DUMB STORAGE: callers
|
|
515
|
+
* pass the transcript/skeleton/objective in; this module never spawns the
|
|
516
|
+
* 主智能体 MAIN AGENT or the CRITIC AGENT(基线智能体 baseline agent)and never
|
|
517
|
+
* computes a skeleton. Each file is written atomically (tmp + rename);
|
|
518
|
+
* re-capturing overwrites (last write wins). Stage advancement is the
|
|
519
|
+
* caller's job via {@link advanceEpisodeStage} — storage and state machine
|
|
520
|
+
* stay decoupled.
|
|
521
|
+
*
|
|
522
|
+
* @returns The absolute arm dir and the absolute paths written.
|
|
523
|
+
*/
|
|
524
|
+
export async function writeArmCapture(opts) {
|
|
525
|
+
const { repoRoot, episodeId, arm, transcript, skeleton, objective } = opts;
|
|
526
|
+
if (arm !== 'main-arm' && arm !== 'baseline-arm') {
|
|
527
|
+
throw new Error(`Invalid arm (expected 'main-arm' | 'baseline-arm'): ${JSON.stringify(arm)}`);
|
|
528
|
+
}
|
|
529
|
+
if (typeof objective !== 'object' || objective === null) {
|
|
530
|
+
throw new Error(`Invalid objective for ${episodeId}: expected an object`);
|
|
531
|
+
}
|
|
532
|
+
if (skeleton !== undefined && (typeof skeleton !== 'object' || skeleton === null)) {
|
|
533
|
+
throw new Error(`Invalid skeleton for ${episodeId}: expected an object`);
|
|
534
|
+
}
|
|
535
|
+
if (transcript !== undefined) {
|
|
536
|
+
assertSafeArmFileName(transcript.fileName);
|
|
537
|
+
if (typeof transcript.content !== 'string') {
|
|
538
|
+
throw new Error(`Invalid transcript content for ${episodeId}: expected a string`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
// Fail closed when the episode record does not exist.
|
|
542
|
+
await readEpisode(repoRoot, episodeId);
|
|
543
|
+
const armDir = path.join(episodeDir(repoRoot, episodeId), arm);
|
|
544
|
+
assertWithinEpisodesBaseDir(repoRoot, armDir);
|
|
545
|
+
await fs.mkdir(armDir, { recursive: true });
|
|
546
|
+
const writtenFiles = [];
|
|
547
|
+
if (transcript !== undefined) {
|
|
548
|
+
writtenFiles.push(await atomicWriteFile(repoRoot, armDir, transcript.fileName, transcript.content));
|
|
549
|
+
}
|
|
550
|
+
if (skeleton !== undefined) {
|
|
551
|
+
writtenFiles.push(await atomicWriteFile(repoRoot, armDir, SKELETON_JSON_FILE, jsonBody(skeleton)));
|
|
552
|
+
}
|
|
553
|
+
writtenFiles.push(await atomicWriteFile(repoRoot, armDir, OBJECTIVE_JSON_FILE, jsonBody(objective)));
|
|
554
|
+
return { armDir, writtenFiles };
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Atomic write of `<episodeDir>/diagnosis.json`. Last write wins (an episode
|
|
558
|
+
* has exactly ONE current diagnosis; the audit trail is `stageHistory`).
|
|
559
|
+
* Throws deterministically when the episode does not exist.
|
|
560
|
+
*
|
|
561
|
+
* @returns The absolute path of the written `diagnosis.json`.
|
|
562
|
+
*/
|
|
563
|
+
export async function writeDiagnosis(opts) {
|
|
564
|
+
const { repoRoot, episodeId, diagnosis } = opts;
|
|
565
|
+
if (typeof diagnosis !== 'object' || diagnosis === null) {
|
|
566
|
+
throw new Error(`Invalid diagnosis for ${episodeId}: expected an object`);
|
|
567
|
+
}
|
|
568
|
+
// Fail closed when the episode record does not exist.
|
|
569
|
+
await readEpisode(repoRoot, episodeId);
|
|
570
|
+
const dir = episodeDir(repoRoot, episodeId);
|
|
571
|
+
return atomicWriteFile(repoRoot, dir, DIAGNOSIS_JSON_FILE, jsonBody(diagnosis));
|
|
572
|
+
}
|
|
573
|
+
//# sourceMappingURL=episode-store.js.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const EVOLVABLE_PARTS: readonly ["
|
|
1
|
+
export declare const EVOLVABLE_PARTS: readonly ["artifact-templates", "archive-memory", "task-decomposition", "alignment-verifier", "tool-evolution-guard", "runtime-memory"];
|
|
2
2
|
export type EvolvablePart = (typeof EVOLVABLE_PARTS)[number];
|
|
3
3
|
export interface EvolutionSwitches {
|
|
4
4
|
enabled: EvolvablePart[];
|
|
@@ -1,26 +1,24 @@
|
|
|
1
1
|
export const EVOLVABLE_PARTS = [
|
|
2
|
-
'
|
|
2
|
+
'artifact-templates',
|
|
3
3
|
'archive-memory',
|
|
4
4
|
'task-decomposition',
|
|
5
5
|
'alignment-verifier',
|
|
6
6
|
'tool-evolution-guard',
|
|
7
7
|
'runtime-memory',
|
|
8
|
-
'dgm-harness',
|
|
9
8
|
];
|
|
10
9
|
export const EVOLVABLE_PART_DESCRIPTIONS = {
|
|
11
|
-
'
|
|
10
|
+
'artifact-templates': 'Artifact and workflow-prompt template contracts (the user-facing template surface)',
|
|
12
11
|
'archive-memory': 'Archived change experience retrieval and prompt injection',
|
|
13
12
|
'task-decomposition': 'Task breakdown scoring and strategy feedback',
|
|
14
13
|
'alignment-verifier': 'No-reference spec-code alignment verification',
|
|
15
14
|
'tool-evolution-guard': 'Guard for schema, CLI, and tool self-modifications',
|
|
16
15
|
'runtime-memory': 'Persistent runtime memory retrieval, extraction, feedback, and writing',
|
|
17
|
-
'dgm-harness': 'Outer-loop variant proposal, benchmark selection, and lineage evolution',
|
|
18
16
|
};
|
|
19
17
|
const PART_SET = new Set(EVOLVABLE_PARTS);
|
|
20
18
|
const PART_ALIASES = new Map([
|
|
21
|
-
['templates', '
|
|
22
|
-
['template', '
|
|
23
|
-
['template
|
|
19
|
+
['templates', 'artifact-templates'],
|
|
20
|
+
['template', 'artifact-templates'],
|
|
21
|
+
['artifact-template', 'artifact-templates'],
|
|
24
22
|
['archive', 'archive-memory'],
|
|
25
23
|
['archive-experience', 'archive-memory'],
|
|
26
24
|
['task', 'task-decomposition'],
|
|
@@ -35,9 +33,6 @@ const PART_ALIASES = new Map([
|
|
|
35
33
|
['tool-evolution', 'tool-evolution-guard'],
|
|
36
34
|
['memory', 'runtime-memory'],
|
|
37
35
|
['runtime', 'runtime-memory'],
|
|
38
|
-
['dgm', 'dgm-harness'],
|
|
39
|
-
['outer-loop', 'dgm-harness'],
|
|
40
|
-
['harness', 'dgm-harness'],
|
|
41
36
|
]);
|
|
42
37
|
export function resolveEvolutionSwitches(input = {}) {
|
|
43
38
|
const env = input.env ?? process.env;
|