synergyspec-selfevolving 2.1.7 → 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;
@@ -111,6 +111,8 @@ function preludeLines(editBudget) {
111
111
  '- Only edit files listed under "CANONICAL TARGET" below. Never invent paths.',
112
112
  '- Prefer compact edit operations: `replaceText`, `insertAfter`, or `insertBefore`.',
113
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.',
114
116
  '- Include needed newlines inside `replace`/`insert`; the runner will expand the operation locally.',
115
117
  '- Legacy `content` full-file replacements are accepted only for small files or true rewrites.',
116
118
  '- Do not re-emit a large canonical file just to make a small localized edit.',
@@ -253,6 +255,9 @@ export function parseEvolvingAgentResponse(text) {
253
255
  // Empty edits with no refusal reason is a malformed no-op, not a refusal.
254
256
  throw new EvolvingAgentNoOp();
255
257
  }
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
+ }
256
261
  // Concrete-edit shape: validate prediction + edit instruction shapes.
257
262
  const prediction = parsePrediction(o.prediction);
258
263
  const edits = [];
@@ -339,6 +344,7 @@ function findExactlyOnce(content, needle, description) {
339
344
  export function resolveEvolvingAgentEditInstructions(candidate, currentFiles) {
340
345
  const currentByPath = new Map(currentFiles.map((f) => [f.relPath.replace(/\\/g, '/'), f.content]));
341
346
  const proposedByPath = new Map();
347
+ const editModeByPath = new Map();
342
348
  const order = [];
343
349
  const rememberPath = (relPath) => {
344
350
  if (!proposedByPath.has(relPath))
@@ -346,6 +352,15 @@ export function resolveEvolvingAgentEditInstructions(candidate, currentFiles) {
346
352
  };
347
353
  for (const raw of candidate.edits) {
348
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);
349
364
  if ('content' in raw) {
350
365
  rememberPath(relPath);
351
366
  proposedByPath.set(relPath, raw.content);
@@ -377,6 +392,16 @@ export function resolveEvolvingAgentEditInstructions(candidate, currentFiles) {
377
392
  edits: order.map((relPath) => ({ relPath, content: proposedByPath.get(relPath) ?? '' })),
378
393
  };
379
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
+ }
380
405
  function parsePrediction(raw) {
381
406
  if (!raw || typeof raw !== 'object') {
382
407
  throw new EvolvingAgentOutputInvalid('a concrete edit requires a `prediction` object {metric, direction, checkBy}');
@@ -613,10 +638,14 @@ export async function runEvolvingAgent(opts) {
613
638
  parsed = response;
614
639
  break;
615
640
  }
616
- const candidate = resolveEvolvingAgentEditInstructions(response, currentFiles);
641
+ const resolved = resolveEvolvingAgentEditInstructions(response, currentFiles);
617
642
  // Static-shape edit: validate scope-to-target + frozen freeze here so a
618
643
  // bad path is a REPAIRABLE failure (the evolving agent's repair contract).
619
- 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
+ }
620
649
  // ≤ L budget (repairable).
621
650
  const changed = countChangedLines(candidate.edits, currentFiles);
622
651
  if (changed > editBudget) {
@@ -656,7 +685,8 @@ export async function runEvolvingAgent(opts) {
656
685
  break;
657
686
  }
658
687
  catch (err) {
659
- if (err instanceof EvolvingAgentOutputInvalid && attempt < maxRepairAttempts) {
688
+ if ((err instanceof EvolvingAgentOutputInvalid || err instanceof EvolvingAgentNoOp) &&
689
+ attempt < maxRepairAttempts) {
660
690
  feedback = gateFeedback(err.message);
661
691
  continue;
662
692
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synergyspec-selfevolving",
3
- "version": "2.1.7",
3
+ "version": "2.1.8",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "synergyspec-selfevolving",