synergyspec-selfevolving 2.1.6 → 2.1.8

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.
@@ -423,13 +423,14 @@ function packageHostEdits(editsInput, allowedFiles, currentFiles, group, targetI
423
423
  }
424
424
  const validated = validateCandidateEdits(editsInput.edits, allowedFiles);
425
425
  const oldByPath = new Map(currentFiles.map((f) => [f.relPath.replace(/\\/g, '/'), f.content]));
426
- // No-op guard: if every edit's content equals the current file there is
427
- // nothing to evolve — surface it as a no-op (placeholder), like the agent path.
428
- const changesSomething = validated.some((e) => (oldByPath.get(e.relPath) ?? '') !== e.content);
429
- if (!changesSomething) {
426
+ // No-op guard: package only edits whose content differs from the current
427
+ // file. If none remain there is nothing to evolve — surface it as a no-op
428
+ // (placeholder), like the agent path.
429
+ const changed = validated.filter((e) => (oldByPath.get(e.relPath) ?? '') !== e.content);
430
+ if (changed.length === 0) {
430
431
  throw new EvolvingAgentNoOp();
431
432
  }
432
- const diffPatch = validated
433
+ const diffPatch = changed
433
434
  .map((e) => renderUnifiedDiff(e.relPath, oldByPath.get(e.relPath) ?? '', e.content))
434
435
  .join('\n');
435
436
  const rationale = (editsInput.rationale ?? '').trim() ||
@@ -437,9 +438,9 @@ function packageHostEdits(editsInput, allowedFiles, currentFiles, group, targetI
437
438
  return {
438
439
  targetId,
439
440
  diffPatch,
440
- changedFiles: validated.map((e) => e.relPath),
441
+ changedFiles: changed.map((e) => e.relPath),
441
442
  rationale,
442
- edits: validated,
443
+ edits: changed,
443
444
  };
444
445
  }
445
446
  /**
@@ -53,6 +53,9 @@ export interface CanonicalProposeOutput {
53
53
  * {@link EvolvingAgentOutputInvalid} for any shape / frozen / scope
54
54
  * violation. Path traversal and absolute paths are rejected transitively: they
55
55
  * can never be a member of `allowedFiles`, so they fail the scope check.
56
+ * Duplicate relPaths are rejected because full-file replacements have no
57
+ * stable sequencing semantics; allowing them would let a later entry silently
58
+ * override an earlier one.
56
59
  */
57
60
  export declare function validateCandidateEdits(rawEdits: readonly unknown[], allowedFiles: readonly string[]): {
58
61
  relPath: string;
@@ -58,6 +58,9 @@ export const CanonicalProposerInvocationError = EvolvingAgentInvocationError;
58
58
  * {@link EvolvingAgentOutputInvalid} for any shape / frozen / scope
59
59
  * violation. Path traversal and absolute paths are rejected transitively: they
60
60
  * can never be a member of `allowedFiles`, so they fail the scope check.
61
+ * Duplicate relPaths are rejected because full-file replacements have no
62
+ * stable sequencing semantics; allowing them would let a later entry silently
63
+ * override an earlier one.
61
64
  */
62
65
  export function validateCandidateEdits(rawEdits, allowedFiles) {
63
66
  if (rawEdits.length === 0) {
@@ -66,6 +69,7 @@ export function validateCandidateEdits(rawEdits, allowedFiles) {
66
69
  const allowed = new Set(allowedFiles.map((p) => p.replace(/\\/g, '/')));
67
70
  const frozen = new Set(GATE_DEFINING_FILES.map((p) => p.replace(/\\/g, '/')));
68
71
  const validated = [];
72
+ const seen = new Set();
69
73
  for (const e of rawEdits) {
70
74
  if (!e || typeof e !== 'object') {
71
75
  throw new EvolvingAgentOutputInvalid('edit entry must be an object');
@@ -82,6 +86,10 @@ export function validateCandidateEdits(rawEdits, allowedFiles) {
82
86
  if (!allowed.has(norm)) {
83
87
  throw new EvolvingAgentOutputInvalid(`edit relPath "${relPath}" is outside the target's declared files`);
84
88
  }
89
+ if (seen.has(norm)) {
90
+ throw new EvolvingAgentOutputInvalid(`edit relPath "${relPath}" appears more than once; combine changes into one full-file content edit`);
91
+ }
92
+ seen.add(norm);
85
93
  validated.push({ relPath: norm, content });
86
94
  }
87
95
  return validated;
@@ -41,6 +41,7 @@ import { type EvidenceCheck } from './promote.js';
41
41
  import { type TargetProtection } from './success-channel.js';
42
42
  import { type RejectBufferEntry } from './policy/reject-buffer.js';
43
43
  import { type PolicyPrediction, type PolicyLedgerEntry } from './policy/policy-store.js';
44
+ import { type DiffEdit } from './line-diff.js';
44
45
  import { type DiagnosisGap } from './scope-gate.js';
45
46
  import type { WeaknessClass, GapSeverity } from './reward-agent.js';
46
47
  /** Default edit budget L: at most this many changed lines (added + removed). */
@@ -122,17 +123,40 @@ export interface EvolvingAgentRefusal {
122
123
  kind: 'refusal';
123
124
  reason: string;
124
125
  }
126
+ /** Legacy full-file replacement, still accepted for compatibility and small files. */
127
+ export interface EvolvingAgentFullContentEdit {
128
+ relPath: string;
129
+ content: string;
130
+ }
131
+ /** Replace one exact existing text block with a new text block. */
132
+ export interface EvolvingAgentReplaceTextEdit {
133
+ relPath: string;
134
+ operation: 'replaceText';
135
+ find: string;
136
+ replace: string;
137
+ }
138
+ /** Insert text adjacent to one exact marker block. */
139
+ export interface EvolvingAgentInsertMarkerEdit {
140
+ relPath: string;
141
+ operation: 'insertAfter' | 'insertBefore';
142
+ marker: string;
143
+ insert: string;
144
+ }
145
+ export type EvolvingAgentEditInstruction = EvolvingAgentFullContentEdit | EvolvingAgentReplaceTextEdit | EvolvingAgentInsertMarkerEdit;
125
146
  /** A concrete bounded edit with its checkable prediction. */
126
147
  export interface EvolvingAgentEdit {
127
148
  kind: 'edit';
128
149
  rationale: string;
129
150
  prediction: PolicyPrediction;
130
- edits: {
131
- relPath: string;
132
- content: string;
133
- }[];
151
+ edits: EvolvingAgentEditInstruction[];
134
152
  }
135
153
  export type ParsedEvolvingAgentResponse = EvolvingAgentRefusal | EvolvingAgentEdit;
154
+ export interface ResolvedEvolvingAgentEdit {
155
+ kind: 'edit';
156
+ rationale: string;
157
+ prediction: PolicyPrediction;
158
+ edits: DiffEdit[];
159
+ }
136
160
  /**
137
161
  * Parse the model's single `json:patch` block. Accepts EITHER the refusal shape
138
162
  * (`{edits: [], refusal: string}`) OR a concrete edit (`{rationale, prediction,
@@ -144,6 +168,11 @@ export type ParsedEvolvingAgentResponse = EvolvingAgentRefusal | EvolvingAgentEd
144
168
  * them); this only enforces the SHAPE of the contract.
145
169
  */
146
170
  export declare function parseEvolvingAgentResponse(text: string): ParsedEvolvingAgentResponse;
171
+ /**
172
+ * Expand model-facing compact edit operations into the full-file DiffEdit shape
173
+ * consumed by the existing static, budget, scope, and policy-promotion gates.
174
+ */
175
+ export declare function resolveEvolvingAgentEditInstructions(candidate: EvolvingAgentEdit, currentFiles: readonly DiffEdit[]): ResolvedEvolvingAgentEdit;
147
176
  export interface RunEvolvingAgentOptions {
148
177
  repoRoot: string;
149
178
  episodeId: string;
@@ -101,12 +101,21 @@ function preludeLines(editBudget) {
101
101
  ' "prediction": {"metric": "loss" | "passRate" | "healthPenalty",',
102
102
  ' "direction": "down" | "up",',
103
103
  ' "checkBy": "<one sentence: how a later episode settles this>"},',
104
- ' "edits": [{"relPath": "<one of the allowed files>", "content": "<FULL new file contents>"}]}',
104
+ ' "edits": [{"relPath": "<one of the allowed files>",',
105
+ ' "operation": "replaceText",',
106
+ ' "find": "<exact existing text that occurs once>",',
107
+ ' "replace": "<replacement text>"}]}',
105
108
  '```',
106
109
  '',
107
110
  'Rules:',
108
111
  '- Only edit files listed under "CANONICAL TARGET" below. Never invent paths.',
109
- "- Each edit's `content` is the COMPLETE new file, not a patch fragment.",
112
+ '- Prefer compact edit operations: `replaceText`, `insertAfter`, or `insertBefore`.',
113
+ '- `replaceText.find` and marker text must match exactly one location in the current file.',
114
+ '- For `insertAfter`/`insertBefore`, include a non-empty `marker` field and a string `insert` field.',
115
+ '- Empty `edits` is valid only with a non-empty `refusal`; never emit `{"edits":[]}` alone.',
116
+ '- Include needed newlines inside `replace`/`insert`; the runner will expand the operation locally.',
117
+ '- Legacy `content` full-file replacements are accepted only for small files or true rewrites.',
118
+ '- Do not re-emit a large canonical file just to make a small localized edit.',
110
119
  '- You NEVER score and you NEVER touch the gate/oracle files.',
111
120
  ].join('\n');
112
121
  }
@@ -246,20 +255,153 @@ export function parseEvolvingAgentResponse(text) {
246
255
  // Empty edits with no refusal reason is a malformed no-op, not a refusal.
247
256
  throw new EvolvingAgentNoOp();
248
257
  }
249
- // Concrete-edit shape: validate prediction + edit shapes.
258
+ if (Object.prototype.hasOwnProperty.call(o, 'refusal')) {
259
+ throw new EvolvingAgentOutputInvalid('refusal is only valid with empty edits; choose either a refusal or a concrete edit');
260
+ }
261
+ // Concrete-edit shape: validate prediction + edit instruction shapes.
250
262
  const prediction = parsePrediction(o.prediction);
251
263
  const edits = [];
252
264
  for (const e of rawEdits) {
253
- const relPath = e?.relPath;
254
- const content = e?.content;
255
- if (typeof relPath !== 'string' || typeof content !== 'string') {
256
- throw new EvolvingAgentOutputInvalid('edit must have string relPath and string content');
265
+ if (!e || typeof e !== 'object') {
266
+ throw new EvolvingAgentOutputInvalid('edit entry must be an object');
267
+ }
268
+ const entry = e;
269
+ const relPath = entry.relPath;
270
+ if (typeof relPath !== 'string') {
271
+ throw new EvolvingAgentOutputInvalid('edit must have string relPath');
272
+ }
273
+ const normalizedRelPath = relPath.replace(/\\/g, '/');
274
+ const hasContent = Object.prototype.hasOwnProperty.call(entry, 'content');
275
+ const operation = entry.operation;
276
+ if (hasContent && operation !== undefined) {
277
+ throw new EvolvingAgentOutputInvalid('edit must use either legacy `content` or a compact `operation`, not both');
278
+ }
279
+ if (hasContent) {
280
+ if (typeof entry.content !== 'string') {
281
+ throw new EvolvingAgentOutputInvalid('legacy edit content must be a string');
282
+ }
283
+ edits.push({ relPath: normalizedRelPath, content: entry.content });
284
+ continue;
285
+ }
286
+ if (operation === 'replaceText') {
287
+ if (typeof entry.find !== 'string' || entry.find.length === 0) {
288
+ throw new EvolvingAgentOutputInvalid('replaceText edit requires a non-empty string `find`');
289
+ }
290
+ if (typeof entry.replace !== 'string') {
291
+ throw new EvolvingAgentOutputInvalid('replaceText edit requires string `replace`');
292
+ }
293
+ edits.push({
294
+ relPath: normalizedRelPath,
295
+ operation,
296
+ find: entry.find,
297
+ replace: entry.replace,
298
+ });
299
+ continue;
257
300
  }
258
- edits.push({ relPath: relPath.replace(/\\/g, '/'), content });
301
+ if (operation === 'insertAfter' || operation === 'insertBefore') {
302
+ if (typeof entry.marker !== 'string' || entry.marker.length === 0) {
303
+ throw new EvolvingAgentOutputInvalid(`${operation} edit requires a non-empty string marker`);
304
+ }
305
+ if (typeof entry.insert !== 'string') {
306
+ throw new EvolvingAgentOutputInvalid(`${operation} edit requires string insert`);
307
+ }
308
+ edits.push({
309
+ relPath: normalizedRelPath,
310
+ operation,
311
+ marker: entry.marker,
312
+ insert: entry.insert,
313
+ });
314
+ continue;
315
+ }
316
+ throw new EvolvingAgentOutputInvalid("edit must have legacy string `content` or operation 'replaceText' | 'insertAfter' | 'insertBefore'");
259
317
  }
260
318
  const rationale = typeof o.rationale === 'string' ? o.rationale.trim() : '';
261
319
  return { kind: 'edit', rationale, prediction, edits };
262
320
  }
321
+ function findExactlyOnce(content, needle, description) {
322
+ let foundAt = -1;
323
+ let count = 0;
324
+ let searchFrom = 0;
325
+ while (true) {
326
+ const idx = content.indexOf(needle, searchFrom);
327
+ if (idx === -1)
328
+ break;
329
+ foundAt = idx;
330
+ count += 1;
331
+ if (count > 1)
332
+ break;
333
+ searchFrom = idx + 1;
334
+ }
335
+ if (count !== 1) {
336
+ throw new EvolvingAgentOutputInvalid(`${description} must match exactly one location in the current file, found ${count} (missing or ambiguous)`);
337
+ }
338
+ return foundAt;
339
+ }
340
+ /**
341
+ * Expand model-facing compact edit operations into the full-file DiffEdit shape
342
+ * consumed by the existing static, budget, scope, and policy-promotion gates.
343
+ */
344
+ export function resolveEvolvingAgentEditInstructions(candidate, currentFiles) {
345
+ const currentByPath = new Map(currentFiles.map((f) => [f.relPath.replace(/\\/g, '/'), f.content]));
346
+ const proposedByPath = new Map();
347
+ const editModeByPath = new Map();
348
+ const order = [];
349
+ const rememberPath = (relPath) => {
350
+ if (!proposedByPath.has(relPath))
351
+ order.push(relPath);
352
+ };
353
+ for (const raw of candidate.edits) {
354
+ const relPath = raw.relPath.replace(/\\/g, '/');
355
+ const mode = 'content' in raw ? 'fullContent' : 'compact';
356
+ const previousMode = editModeByPath.get(relPath);
357
+ if (mode === 'fullContent' && previousMode !== undefined) {
358
+ throw new EvolvingAgentOutputInvalid(`legacy full-file content edit for "${relPath}" cannot be combined with other edits for the same file`);
359
+ }
360
+ if (mode === 'compact' && previousMode === 'fullContent') {
361
+ throw new EvolvingAgentOutputInvalid(`legacy full-file content edit for "${relPath}" cannot be combined with other edits for the same file`);
362
+ }
363
+ editModeByPath.set(relPath, mode);
364
+ if ('content' in raw) {
365
+ rememberPath(relPath);
366
+ proposedByPath.set(relPath, raw.content);
367
+ continue;
368
+ }
369
+ const current = proposedByPath.get(relPath) ?? currentByPath.get(relPath);
370
+ if (current === undefined) {
371
+ throw new EvolvingAgentOutputInvalid(`compact edit for "${relPath}" cannot be expanded because the current file was not loaded`);
372
+ }
373
+ rememberPath(relPath);
374
+ if (raw.operation === 'replaceText') {
375
+ const start = findExactlyOnce(current, raw.find, `replaceText.find for "${relPath}"`);
376
+ const content = current.slice(0, start) +
377
+ raw.replace +
378
+ current.slice(start + raw.find.length);
379
+ proposedByPath.set(relPath, content);
380
+ continue;
381
+ }
382
+ const markerStart = findExactlyOnce(current, raw.marker, `${raw.operation}.marker for "${relPath}"`);
383
+ const insertAt = raw.operation === 'insertAfter'
384
+ ? markerStart + raw.marker.length
385
+ : markerStart;
386
+ proposedByPath.set(relPath, current.slice(0, insertAt) + raw.insert + current.slice(insertAt));
387
+ }
388
+ return {
389
+ kind: 'edit',
390
+ rationale: candidate.rationale,
391
+ prediction: candidate.prediction,
392
+ edits: order.map((relPath) => ({ relPath, content: proposedByPath.get(relPath) ?? '' })),
393
+ };
394
+ }
395
+ function dropByteIdenticalEdits(candidate, currentFiles) {
396
+ const currentByPath = new Map(currentFiles.map((f) => [f.relPath.replace(/\\/g, '/'), f.content]));
397
+ return {
398
+ ...candidate,
399
+ edits: candidate.edits.filter((edit) => {
400
+ const current = currentByPath.get(edit.relPath.replace(/\\/g, '/'));
401
+ return current === undefined || current !== edit.content;
402
+ }),
403
+ };
404
+ }
263
405
  function parsePrediction(raw) {
264
406
  if (!raw || typeof raw !== 'object') {
265
407
  throw new EvolvingAgentOutputInvalid('a concrete edit requires a `prediction` object {metric, direction, checkBy}');
@@ -475,6 +617,7 @@ export async function runEvolvingAgent(opts) {
475
617
  : `${basePrompt}\n\n# PREVIOUS ATTEMPT WAS REJECTED\n${feedback}\n` +
476
618
  'Re-emit EXACTLY ONE ```json:patch fenced block — either a refusal ' +
477
619
  '({"edits": [], "refusal": string}) or a single bounded edit ' +
620
+ 'using a compact operation or legacy full-file content ' +
478
621
  '({"rationale", "prediction", "edits"}), staying inside the diagnosed ' +
479
622
  'sections and within the changed-line budget.';
480
623
  const run = await runHeadlessAgent(prompt, {
@@ -490,14 +633,19 @@ export async function runEvolvingAgent(opts) {
490
633
  throw new EvolvingAgentInvocationError(run.stderr);
491
634
  }
492
635
  try {
493
- const candidate = parseEvolvingAgentResponse(run.stdout);
494
- if (candidate.kind === 'refusal') {
495
- parsed = candidate;
636
+ const response = parseEvolvingAgentResponse(run.stdout);
637
+ if (response.kind === 'refusal') {
638
+ parsed = response;
496
639
  break;
497
640
  }
641
+ const resolved = resolveEvolvingAgentEditInstructions(response, currentFiles);
498
642
  // Static-shape edit: validate scope-to-target + frozen freeze here so a
499
643
  // bad path is a REPAIRABLE failure (the evolving agent's repair contract).
500
- validateCandidateEdits(candidate.edits, allowedFiles);
644
+ validateCandidateEdits(resolved.edits, allowedFiles);
645
+ const candidate = dropByteIdenticalEdits(resolved, currentFiles);
646
+ if (candidate.edits.length === 0) {
647
+ throw new EvolvingAgentOutputInvalid('edit makes no changes against the current policy head - emit a refusal or a real bounded edit');
648
+ }
501
649
  // ≤ L budget (repairable).
502
650
  const changed = countChangedLines(candidate.edits, currentFiles);
503
651
  if (changed > editBudget) {
@@ -537,7 +685,8 @@ export async function runEvolvingAgent(opts) {
537
685
  break;
538
686
  }
539
687
  catch (err) {
540
- if (err instanceof EvolvingAgentOutputInvalid && attempt < maxRepairAttempts) {
688
+ if ((err instanceof EvolvingAgentOutputInvalid || err instanceof EvolvingAgentNoOp) &&
689
+ attempt < maxRepairAttempts) {
541
690
  feedback = gateFeedback(err.message);
542
691
  continue;
543
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synergyspec-selfevolving",
3
- "version": "2.1.6",
3
+ "version": "2.1.8",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "synergyspec-selfevolving",