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
@@ -23,6 +23,8 @@ import { relativePath } from './shared.js';
23
23
  import { validateLearnEvolutionHint, } from './learn-hints.js';
24
24
  import { findCanonicalTargetsByFile, listCanonicalTargets, lookupCanonicalTarget, } from './canonical-targets.js';
25
25
  import { isCanonicalTargetEvolvable, explicitTargetIds, } from './target-evolution.js';
26
+ import { renderCreditPath } from '../learn/credit-path.js';
27
+ import { buildProposerSlice } from './proposer-slice.js';
26
28
  import { detectRepoMode, resolveTargetLocalFiles } from './local-targets.js';
27
29
  import { getSchemaDir } from '../artifact-graph/resolver.js';
28
30
  import { SKILL_NAMESPACES } from '../config.js';
@@ -126,6 +128,33 @@ export async function resolveTargetLocalFilesReadonly(targetId, repoRoot) {
126
128
  }
127
129
  return out;
128
130
  }
131
+ /**
132
+ * The producing canonical target for a credit path's LAST resolved node.
133
+ * Static artifact→template map: a path that reached a design section was
134
+ * produced through the design template; one that stopped at a task, through
135
+ * the tasks template. Paths ending at `test`/`use-case` have no nameable
136
+ * producer (the usecases template is oracle-frozen and not a canonical
137
+ * target). When a policy is supplied and the resolved target is FROZEN, the
138
+ * target is still NAMED — annotated `[frozen]` — because the address is
139
+ * observational; policy gates candidates, not evidence text.
140
+ */
141
+ const PRODUCING_TARGET_BY_LAST_NODE = {
142
+ 'design-section': 'artifact-template:design',
143
+ task: 'artifact-template:tasks',
144
+ };
145
+ /** Raw producing-target id for a path's last node (no policy annotation). */
146
+ export function producingTargetIdForCreditPath(creditPath) {
147
+ const last = creditPath.nodes[creditPath.nodes.length - 1];
148
+ return last ? PRODUCING_TARGET_BY_LAST_NODE[last.kind] : undefined;
149
+ }
150
+ function resolveProducingTarget(creditPath, policy) {
151
+ const targetId = producingTargetIdForCreditPath(creditPath);
152
+ if (!targetId)
153
+ return undefined;
154
+ if (policy && !isCanonicalTargetEvolvable(targetId, policy))
155
+ return `${targetId} [frozen]`;
156
+ return targetId;
157
+ }
129
158
  /**
130
159
  * Derive structured evolution hints from a learn report's signals.
131
160
  *
@@ -215,13 +244,41 @@ export function generateEvolutionHints(report, policy) {
215
244
  });
216
245
  }
217
246
  }
218
- // Heuristic (c): Failure evidence in verification artifacts → add-verification-step.
247
+ // Heuristic (c): Failure evidence → add-verification-step. Provenance is
248
+ // forwarded onto each evidence item: 'observed' items were re-sourced from
249
+ // the observed runner output (the source the gate trusts), 'authored' from
250
+ // the agent-written artifacts; absent = legacy/authored.
219
251
  if (report.artifacts.failureEvidence.length > 0) {
220
- const evidence = uniqueByFile(report.artifacts.failureEvidence.map((item) => ({
252
+ const observedItems = report.artifacts.failureEvidence.filter((item) => item.source === 'observed');
253
+ // Dedup: authored items keep the legacy one-per-artifact-file rule
254
+ // (byte-identical baseline); observed items dedupe per failing TEST, so
255
+ // two failures in the same test file both survive.
256
+ const dedupSeen = new Set();
257
+ const evidence = report.artifacts.failureEvidence
258
+ .map((item) => ({
221
259
  file: toPosix(item.file),
222
260
  quoteOrSummary: limitText(item.line, 200),
223
261
  severity: 'high',
224
- }))).slice(0, 6);
262
+ ...(item.source ? { provenance: item.source } : {}),
263
+ // The ADDRESS: the neutral credit path completed with the producing
264
+ // canonical target this layer (not learn) resolves. SUSPECT framing —
265
+ // recurrence across changes (the grouping machinery) is the confirmer.
266
+ ...(item.creditPath
267
+ ? { address: renderCreditPath(item.creditPath, resolveProducingTarget(item.creditPath, policy)) }
268
+ : {}),
269
+ }))
270
+ .filter((item) => {
271
+ const key = item.provenance === 'observed' ? `${item.file}::${item.quoteOrSummary}` : item.file;
272
+ if (dedupSeen.has(key))
273
+ return false;
274
+ dedupSeen.add(key);
275
+ return true;
276
+ })
277
+ .slice(0, 6);
278
+ const creditPaths = report.artifacts.failureEvidence
279
+ .map((item) => item.creditPath)
280
+ .filter((p) => p !== undefined)
281
+ .slice(0, 6);
225
282
  if (evidence.length > 0) {
226
283
  const proposedChangeType = 'add-verification-step';
227
284
  drafts.push({
@@ -229,13 +286,16 @@ export function generateEvolutionHints(report, policy) {
229
286
  sourceChange: report.changeName,
230
287
  affectedTargetKind: 'workflow-prompt',
231
288
  proposedChangeType,
232
- problem: limitText(`Verification evidence still contains ${report.artifacts.failureEvidence.length} failure-like line(s); the workflow may be missing a verification gate.`, 500),
289
+ problem: limitText(observedItems.length > 0
290
+ ? `The observed test run recorded ${observedItems.length} failing test(s); the workflow may be missing a verification gate.`
291
+ : `Verification evidence still contains ${report.artifacts.failureEvidence.length} failure-like line(s); the workflow may be missing a verification gate.`, 500),
233
292
  evidence,
234
293
  confidence: 'medium',
235
294
  risk: 'high',
236
295
  expectedBenefit: 'Add a verification step that catches the failure pattern before archive.',
237
296
  evalNeeded: evalSuitesFor('workflow-prompt'),
238
297
  thresholdKey: `workflow-prompt:unspecified:${proposedChangeType}`,
298
+ ...(creditPaths.length > 0 ? { creditPaths } : {}),
239
299
  });
240
300
  }
241
301
  }
@@ -263,6 +323,29 @@ export function generateEvolutionHints(report, policy) {
263
323
  });
264
324
  }
265
325
  }
326
+ // Health-contributor enrichment (observe-only): when the fitness sample
327
+ // names the worst per-file/per-function health offenders, append them as
328
+ // evidence onto the artifact-template drafts the heuristics ALREADY produced
329
+ // — code shape is a property of the generated implementation, whose producing
330
+ // surface is the artifact-template chain. Never creates a new hint or
331
+ // trigger: a clean run with offenders but no drafts stays abstained
332
+ // (abstain-on-success preserved), and workflow-prompt drafts are untouched.
333
+ const healthOffenders = report.fitnessSample?.healthContributors ?? [];
334
+ if (healthOffenders.length > 0) {
335
+ const offenderEvidence = healthOffenders
336
+ .slice(0, 3)
337
+ .map((offender) => ({
338
+ file: toPosix(offender.file),
339
+ quoteOrSummary: limitText(describeHealthOffender(offender), 200),
340
+ severity: 'medium',
341
+ }));
342
+ for (const draft of drafts) {
343
+ if (draft.affectedTargetKind !== 'artifact-template')
344
+ continue;
345
+ // Existing convention: each draft carries at most 6 evidence items.
346
+ draft.evidence = [...draft.evidence, ...offenderEvidence].slice(0, 6);
347
+ }
348
+ }
266
349
  // Backward-pass: stamp every hint from this change with the change's loss
267
350
  // (functional ⊕ health) so the accumulator/GA can select on fitness, not just
268
351
  // occurrence. Same value for all hints from one change; absent when no
@@ -277,7 +360,19 @@ export function generateEvolutionHints(report, policy) {
277
360
  // an evolvable target; when EXACTLY one is evolvable the hint is pinned to it
278
361
  // (e.g. a generic artifact-template hint becomes an `artifact-template:design`
279
362
  // hint when only design may evolve). Omitting `policy` skips scoping.
280
- const scoped = scopeHintsByPolicy(drafts, policy);
363
+ const { kept: scoped, droppedFrozen } = scopeHintsByPolicy(drafts, policy);
364
+ // Evolution focus (ses_148b/1483/1488: design-only policy + workflow-kind-only
365
+ // signals ⇒ every draft silently dropped ⇒ "Target: none" on all three runs):
366
+ // when the policy is frozen-by-default with explicit evolvable targets and
367
+ // EVERY draft fell to the frozen filter, re-aim the dropped drafts' evidence
368
+ // at the focused target(s) as one synthesized low-confidence
369
+ // `origin: 'policy-focus'` hint each, instead of dropping the change's
370
+ // signals on the floor. Fires only on a zero-survivor scope (a surviving hint
371
+ // for the focused target is always the better vehicle) and is disabled by
372
+ // `selfEvolution.focus: false` / `learn --no-focus`.
373
+ if (scoped.length === 0 && droppedFrozen.length > 0) {
374
+ scoped.push(...buildPolicyFocusHints(report, droppedFrozen, policy, nextId));
375
+ }
281
376
  // Defensive: drop anything that fails the contract validator.
282
377
  const validHints = [];
283
378
  for (const draft of scoped) {
@@ -315,20 +410,30 @@ export function generateEvolutionHints(report, policy) {
315
410
  * targets, so no policy value can name them; a CLI-named id is pinned only when
316
411
  * it is a registered, same-kind target that is evolvable under the resolved
317
412
  * policy (so `--freeze-target` still wins).
413
+ *
414
+ * Returns the kept hints PLUS the drafts dropped by the frozen filter
415
+ * (`droppedFrozen`: a pinned hint whose target is frozen, or a kind-only hint
416
+ * whose kind has zero evolvable targets) — the evolution-focus pass re-aims
417
+ * their evidence instead of losing it silently.
318
418
  */
319
419
  function scopeHintsByPolicy(drafts, policy) {
320
420
  if (!policy)
321
- return drafts;
421
+ return { kept: drafts, droppedFrozen: [] };
322
422
  const kept = [];
423
+ const droppedFrozen = [];
323
424
  for (const draft of drafts) {
324
425
  if (draft.affectedTargetId) {
325
426
  if (isCanonicalTargetEvolvable(draft.affectedTargetId, policy))
326
427
  kept.push(draft);
428
+ else
429
+ droppedFrozen.push(draft);
327
430
  continue;
328
431
  }
329
432
  const pinId = resolveKindOnlyPinTarget(draft, policy);
330
- if (pinId === null)
331
- continue; // kind has no evolvable target → drop
433
+ if (pinId === null) {
434
+ droppedFrozen.push(draft); // kind has no evolvable target → drop
435
+ continue;
436
+ }
332
437
  if (pinId !== undefined) {
333
438
  kept.push({
334
439
  ...draft,
@@ -341,7 +446,7 @@ function scopeHintsByPolicy(drafts, policy) {
341
446
  }
342
447
  kept.push(draft); // ambiguous → keep kind-only/unspecified
343
448
  }
344
- return kept;
449
+ return { kept, droppedFrozen };
345
450
  }
346
451
  /**
347
452
  * Decide the concrete target a kind-only hint should be pinned to under the
@@ -367,6 +472,101 @@ export function resolveKindOnlyPinTarget(draft, policy) {
367
472
  return evolvable[0].id;
368
473
  return undefined;
369
474
  }
475
+ /**
476
+ * The evolution-focus target ids: the explicitly evolvable, REGISTERED
477
+ * canonical targets of a frozen-by-default policy, with the focus switch ON.
478
+ *
479
+ * Empty (focus inactive) when:
480
+ * - the switch is off (`selfEvolution.focus: false` / `learn --no-focus`),
481
+ * - the policy default is `evolvable` (nothing is dropped as frozen-kind in the
482
+ * way focus rescues — and "evolve everything" needs no focus), or
483
+ * - no explicit target survives (freeze-wins already resolved into `explicit`).
484
+ *
485
+ * Exported for the learn command's transparency rendering, so the printed focus
486
+ * line cannot drift from the hint-synthesis pass.
487
+ */
488
+ export function resolveEvolutionFocusTargets(policy) {
489
+ if (!policy)
490
+ return [];
491
+ if (policy.focusEnabled === false)
492
+ return [];
493
+ if (policy.default !== 'frozen')
494
+ return [];
495
+ return [...policy.explicit.entries()]
496
+ .filter(([id, evolve]) => evolve && lookupCanonicalTarget(id) !== undefined)
497
+ .map(([id]) => id)
498
+ .sort((left, right) => left.localeCompare(right));
499
+ }
500
+ /**
501
+ * Synthesize the `origin: 'policy-focus'` hint(s) that re-aim a zero-survivor
502
+ * change's policy-dropped signals at the focused target(s).
503
+ *
504
+ * Honesty contract (SUSPECT framing, like credit-path addresses):
505
+ * - evidence is the UNION of the dropped drafts' REAL evidence items — nothing
506
+ * is fabricated, and a change with no dropped signals gets no focus hint
507
+ * ("Target: none" stays the correct evidence-gated abstention);
508
+ * - `confidence` is pinned to `low`: the causal link from the evidence to the
509
+ * focused target is a hypothesis for the proposer to evaluate, not a finding;
510
+ * - the downstream gates are untouched — the focus hint only buys ENTRY into
511
+ * propose/gate/evidence/loss, never promotion.
512
+ */
513
+ function buildPolicyFocusHints(report, droppedFrozen, policy, nextId) {
514
+ const focusIds = resolveEvolutionFocusTargets(policy);
515
+ if (focusIds.length === 0)
516
+ return [];
517
+ // Union the dropped drafts' real evidence (dedup by file+quote, cap 6 — the
518
+ // existing per-hint evidence convention).
519
+ const evidence = [];
520
+ const seen = new Set();
521
+ for (const draft of droppedFrozen) {
522
+ for (const item of draft.evidence) {
523
+ const key = `${item.file}\u0000${item.quoteOrSummary}`;
524
+ if (seen.has(key))
525
+ continue;
526
+ seen.add(key);
527
+ evidence.push(item);
528
+ }
529
+ }
530
+ const cappedEvidence = evidence.slice(0, 6);
531
+ if (cappedEvidence.length === 0)
532
+ return [];
533
+ // The strongest dropped intent labels the synthesized change type.
534
+ const droppedTypes = new Set(droppedFrozen.map((draft) => draft.proposedChangeType));
535
+ const proposedChangeType = droppedTypes.has('add-verification-step')
536
+ ? 'add-verification-step'
537
+ : droppedTypes.has('tighten-output-contract')
538
+ ? 'tighten-output-contract'
539
+ : 'clarify-instruction';
540
+ const frozenKinds = [...new Set(droppedFrozen.map((draft) => draft.affectedTargetKind))]
541
+ .sort()
542
+ .join(', ');
543
+ const changeLoss = report.fitnessSample?.loss?.loss;
544
+ return focusIds.flatMap((focusId) => {
545
+ const target = lookupCanonicalTarget(focusId);
546
+ if (!target)
547
+ return [];
548
+ const hint = {
549
+ id: nextId(`focus-${focusId}`),
550
+ sourceChange: report.changeName,
551
+ affectedTargetKind: target.kind,
552
+ affectedTargetId: focusId,
553
+ proposedChangeType,
554
+ problem: limitText(`Evolution focus: the policy evolves only ${focusIds.join(', ')}; ${droppedFrozen.length} learn signal(s) from this change bound to frozen kind(s) (${frozenKinds}) and would otherwise be dropped. Evaluate whether ${focusId} could be improved so these signals do not recur — the evidence is re-aimed (SUSPECT framing), not proof this target caused it.`, 500),
555
+ evidence: cappedEvidence,
556
+ confidence: 'low',
557
+ risk: 'medium',
558
+ expectedBenefit: limitText(`A focused improvement to ${focusId} grounded in this change's observed signals, instead of dropping them because their originating kind is frozen.`, 500),
559
+ evalNeeded: evalSuitesFor(target.kind),
560
+ // `focusId` is already a full `<kind>:<name>` target id, so the grouping
561
+ // key is `<id>:<changeType>` (no doubled kind prefix).
562
+ thresholdKey: `${focusId}:${proposedChangeType}`,
563
+ origin: 'policy-focus',
564
+ };
565
+ if (typeof changeLoss === 'number')
566
+ hint.loss = changeLoss;
567
+ return [hint];
568
+ });
569
+ }
370
570
  /**
371
571
  * Surface an AMBIGUOUS kind-only evolution hint as an action-required
372
572
  * observation. After {@link scopeHintsByPolicy} runs, a hint that still has no
@@ -428,6 +628,67 @@ export function detectUnbindableHintObservations(hints, policy) {
428
628
  }
429
629
  return observations;
430
630
  }
631
+ /**
632
+ * One health offender as evidence prose: 'health offender: cyclomatic 41 in
633
+ * foo() (182 lines)'. The metric+value lead so truncation never eats the
634
+ * signal; function/length are the navigable address parts when known.
635
+ */
636
+ function describeHealthOffender(offender) {
637
+ const value = Number.isInteger(offender.value)
638
+ ? String(offender.value)
639
+ : offender.value.toFixed(1);
640
+ let text = `health offender: ${offender.metric} ${value}`;
641
+ if (offender.function)
642
+ text += ` in ${offender.function}()`;
643
+ if (offender.functionLength !== undefined)
644
+ text += ` (${offender.functionLength} lines)`;
645
+ return text;
646
+ }
647
+ /**
648
+ * The producing canonical target health offenders route to. Code shape (the
649
+ * thing the offenders measure) is a property of the generated IMPLEMENTATION;
650
+ * the adapter has no artifact contents to walk a per-offender credit path
651
+ * with, so the trace target is the STATIC implementation producer — the tasks
652
+ * template that shapes how implementation work is cut. Mirrors
653
+ * {@link PRODUCING_TARGET_BY_LAST_NODE}'s task → tasks-template mapping.
654
+ */
655
+ const HEALTH_ROUTING_TARGET_ID = 'artifact-template:tasks';
656
+ /**
657
+ * Surface health offenders whose routing lands on a FROZEN target as ONE
658
+ * aggregated ACTION-REQUIRED observation — the honest dead-end. Without this,
659
+ * a freezing policy makes the offender signal vanish silently: the enrichment
660
+ * in {@link generateEvolutionHints} only rides on drafts that survive scoping.
661
+ * Mirrors {@link detectUnbindableHintObservations}' shape and wiring (call it
662
+ * wherever that one is called, on evolving runs). Returns `[]` when `policy`
663
+ * is absent or the routing target is evolvable — the enrichment path already
664
+ * carries the signal there — so the common case stays byte-identical.
665
+ */
666
+ export function detectFrozenHealthRoutingObservations(report, policy) {
667
+ if (!policy)
668
+ return [];
669
+ const offenders = report.fitnessSample?.healthContributors ?? [];
670
+ if (offenders.length === 0)
671
+ return [];
672
+ if (isCanonicalTargetEvolvable(HEALTH_ROUTING_TARGET_ID, policy))
673
+ return [];
674
+ return [
675
+ {
676
+ code: 'health-routing-frozen',
677
+ // ACTION, not DEFECT: the routing worked — the destination is frozen by
678
+ // operator policy, so the fix is an operator decision (unfreeze or fix
679
+ // the code by hand), not a tool repair. Same triage rationale as
680
+ // `evolution-target-unresolved`.
681
+ severity: 'action',
682
+ summary: limitText(`${offenders.length} health offender(s) trace to frozen target ${HEALTH_ROUTING_TARGET_ID} — ` +
683
+ `routing dead-end; pass --evolve-target ${HEALTH_ROUTING_TARGET_ID} or address the code manually`, 420),
684
+ evidence: offenders.slice(0, 6).map((offender) => ({
685
+ file: toPosix(offender.file),
686
+ detail: describeHealthOffender(offender),
687
+ })),
688
+ tags: ['health', 'routing', 'frozen', 'action-required'],
689
+ },
690
+ ];
691
+ }
431
692
  function inferTemplateObservation(candidate) {
432
693
  const tags = candidate.tags;
433
694
  const templateTag = tags.find((tag) => /^template:/i.test(tag));
@@ -540,6 +801,13 @@ function uniqueByFile(items) {
540
801
  * learn → propose bridge: it writes an ARTIFACT under `learn-handoffs/` (never a
541
802
  * canonical file), so it is proposal-only. Reuses the existing
542
803
  * `learn-handoffs/<change>/<timestamp>/` convention. Returns the file path.
804
+ *
805
+ * When any hint carries credit paths, a sibling `slice.md` is written next to
806
+ * `hints.json` (and referenced via the optional top-level `slice` key) so the
807
+ * HOST author on the `--from-edits` path can read the real artifact text along
808
+ * the failing paths — the same CREDIT-PATH SLICE the `--agent` prompt renders.
809
+ * Old readers ignore unknown top-level keys (`extractHintCandidates` reads only
810
+ * the hint arrays), so the addition is back-compatible.
543
811
  */
544
812
  export async function persistLearnHints(opts) {
545
813
  const stamp = (opts.now ? opts.now() : new Date())
@@ -548,7 +816,14 @@ export async function persistLearnHints(opts) {
548
816
  const dir = path.join(opts.projectRoot, '.synergyspec-selfevolving', 'learn-handoffs', opts.changeName, stamp);
549
817
  await fs.mkdir(dir, { recursive: true });
550
818
  const file = path.join(dir, 'hints.json');
551
- await fs.writeFile(file, JSON.stringify({ evolutionHints: opts.hints }, null, 2) + '\n', 'utf8');
819
+ const slice = buildProposerSlice(opts.hints);
820
+ if (slice.length > 0) {
821
+ await fs.writeFile(path.join(dir, 'slice.md'), `# Credit-path slice: ${opts.changeName}\n\n${slice}\n`, 'utf8');
822
+ }
823
+ await fs.writeFile(file, JSON.stringify({
824
+ evolutionHints: opts.hints,
825
+ ...(slice.length > 0 ? { slice: 'slice.md' } : {}),
826
+ }, null, 2) + '\n', 'utf8');
552
827
  return file;
553
828
  }
554
829
  function evalSuitesFor(kind) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Line-level diff for the 演进智能体 EVOLVING AGENT (optimizer.step) — loop v2
3
+ * (self-evolution as in-context RL).
4
+ *
5
+ * The EVOLVING AGENT makes ONE bounded edit ≤ L changed lines (added + removed).
6
+ * To enforce that budget and to feed the 范围⊆诊断 scope⊆diagnosis gate
7
+ * ({@link import('./scope-gate.js')}), we need an honest line-level account of
8
+ * which lines the proposed full-file replacement actually changed — not the
9
+ * whole-file-replacement unified diff `edits-contract.ts` renders (that counts
10
+ * every line as removed-then-added).
11
+ *
12
+ * This is a classic LCS (longest common subsequence) line diff: the lines NOT
13
+ * on the LCS are the real changes. `linesAdded` / `linesRemoved` are the true
14
+ * insert/delete counts, and `changedRanges` are the 1-based line spans of the
15
+ * inserted lines IN THE PROPOSED FILE (a pure deletion that inserts nothing
16
+ * produces no range — there is no proposed line to anchor it to; its removal
17
+ * still counts toward `linesRemoved` and the budget).
18
+ *
19
+ * Pure + dependency-free so it is golden-testable and reusable by the gate.
20
+ */
21
+ /** A 1-based, inclusive span of changed (inserted) lines in the PROPOSED file. */
22
+ export interface ChangedRange {
23
+ /** 1-based first changed line in the proposed file. */
24
+ startLine: number;
25
+ /** 1-based last changed line in the proposed file (inclusive). */
26
+ endLine: number;
27
+ }
28
+ /** Result of {@link lineDiff} for one (current → proposed) file pair. */
29
+ export interface LineDiffResult {
30
+ /** Count of lines present in the proposed file but not the current file. */
31
+ linesAdded: number;
32
+ /** Count of lines present in the current file but not the proposed file. */
33
+ linesRemoved: number;
34
+ /** Coalesced 1-based spans of the inserted lines in the proposed file. */
35
+ changedRanges: ChangedRange[];
36
+ }
37
+ /**
38
+ * Compute the LCS-based line diff between `currentContent` and
39
+ * `proposedContent`. Lines are matched with the standard dynamic-programming
40
+ * LCS so that an unchanged block in the middle of an edit is not double-counted
41
+ * as remove+add. `changedRanges` covers the inserted lines of the proposed file
42
+ * (1-based), coalescing adjacent inserts into one span.
43
+ */
44
+ export declare function lineDiff(currentContent: string, proposedContent: string): LineDiffResult;
45
+ /** One edit: the COMPLETE new contents for a file the agent proposes to rewrite. */
46
+ export interface DiffEdit {
47
+ relPath: string;
48
+ content: string;
49
+ }
50
+ /**
51
+ * Sum added + removed lines across every edit, comparing each edit's proposed
52
+ * content to the current on-disk content of the same file. An edit whose
53
+ * `relPath` has no current entry is diffed against empty content (every
54
+ * proposed line is an insert) — defensive; the EVOLVING AGENT only edits
55
+ * existing lineage files, so this should not happen in practice.
56
+ *
57
+ * This is the value the EVOLVING AGENT's ≤ L budget is checked against.
58
+ */
59
+ export declare function countChangedLines(edits: readonly DiffEdit[], currentFiles: readonly DiffEdit[]): number;
60
+ //# sourceMappingURL=line-diff.d.ts.map
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Line-level diff for the 演进智能体 EVOLVING AGENT (optimizer.step) — loop v2
3
+ * (self-evolution as in-context RL).
4
+ *
5
+ * The EVOLVING AGENT makes ONE bounded edit ≤ L changed lines (added + removed).
6
+ * To enforce that budget and to feed the 范围⊆诊断 scope⊆diagnosis gate
7
+ * ({@link import('./scope-gate.js')}), we need an honest line-level account of
8
+ * which lines the proposed full-file replacement actually changed — not the
9
+ * whole-file-replacement unified diff `edits-contract.ts` renders (that counts
10
+ * every line as removed-then-added).
11
+ *
12
+ * This is a classic LCS (longest common subsequence) line diff: the lines NOT
13
+ * on the LCS are the real changes. `linesAdded` / `linesRemoved` are the true
14
+ * insert/delete counts, and `changedRanges` are the 1-based line spans of the
15
+ * inserted lines IN THE PROPOSED FILE (a pure deletion that inserts nothing
16
+ * produces no range — there is no proposed line to anchor it to; its removal
17
+ * still counts toward `linesRemoved` and the budget).
18
+ *
19
+ * Pure + dependency-free so it is golden-testable and reusable by the gate.
20
+ */
21
+ /**
22
+ * Split content into lines for diffing. A trailing newline is normalized away
23
+ * first (so `"a\n"` and `"a"` are one line, matching `renderUnifiedDiff`'s
24
+ * convention); empty content is zero lines, never `['']`.
25
+ */
26
+ function toLines(content) {
27
+ if (content.length === 0)
28
+ return [];
29
+ return content.replace(/\n$/, '').split('\n');
30
+ }
31
+ /**
32
+ * Compute the LCS-based line diff between `currentContent` and
33
+ * `proposedContent`. Lines are matched with the standard dynamic-programming
34
+ * LCS so that an unchanged block in the middle of an edit is not double-counted
35
+ * as remove+add. `changedRanges` covers the inserted lines of the proposed file
36
+ * (1-based), coalescing adjacent inserts into one span.
37
+ */
38
+ export function lineDiff(currentContent, proposedContent) {
39
+ const a = toLines(currentContent); // current
40
+ const b = toLines(proposedContent); // proposed
41
+ const n = a.length;
42
+ const m = b.length;
43
+ // LCS length table: lcs[i][j] = LCS length of a[i..] and b[j..].
44
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
45
+ for (let i = n - 1; i >= 0; i--) {
46
+ for (let j = m - 1; j >= 0; j--) {
47
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
48
+ }
49
+ }
50
+ let linesAdded = 0;
51
+ let linesRemoved = 0;
52
+ // 1-based line numbers (in b) of every inserted proposed line.
53
+ const insertedProposedLines = [];
54
+ let i = 0;
55
+ let j = 0;
56
+ while (i < n && j < m) {
57
+ if (a[i] === b[j]) {
58
+ // Common line: advance both, no change.
59
+ i++;
60
+ j++;
61
+ }
62
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
63
+ // Dropping a[i] keeps a longer LCS → a[i] was removed.
64
+ linesRemoved++;
65
+ i++;
66
+ }
67
+ else {
68
+ // Dropping b[j] keeps a longer LCS → b[j] was inserted.
69
+ linesAdded++;
70
+ insertedProposedLines.push(j + 1);
71
+ j++;
72
+ }
73
+ }
74
+ // Tail: leftover current lines are removals, leftover proposed lines inserts.
75
+ while (i < n) {
76
+ linesRemoved++;
77
+ i++;
78
+ }
79
+ while (j < m) {
80
+ linesAdded++;
81
+ insertedProposedLines.push(j + 1);
82
+ j++;
83
+ }
84
+ return {
85
+ linesAdded,
86
+ linesRemoved,
87
+ changedRanges: coalesce(insertedProposedLines),
88
+ };
89
+ }
90
+ /** Coalesce a sorted list of 1-based line numbers into inclusive ranges. */
91
+ function coalesce(lines) {
92
+ if (lines.length === 0)
93
+ return [];
94
+ const ranges = [];
95
+ let start = lines[0];
96
+ let prev = lines[0];
97
+ for (let k = 1; k < lines.length; k++) {
98
+ const cur = lines[k];
99
+ if (cur === prev + 1) {
100
+ prev = cur;
101
+ continue;
102
+ }
103
+ ranges.push({ startLine: start, endLine: prev });
104
+ start = cur;
105
+ prev = cur;
106
+ }
107
+ ranges.push({ startLine: start, endLine: prev });
108
+ return ranges;
109
+ }
110
+ /**
111
+ * Sum added + removed lines across every edit, comparing each edit's proposed
112
+ * content to the current on-disk content of the same file. An edit whose
113
+ * `relPath` has no current entry is diffed against empty content (every
114
+ * proposed line is an insert) — defensive; the EVOLVING AGENT only edits
115
+ * existing lineage files, so this should not happen in practice.
116
+ *
117
+ * This is the value the EVOLVING AGENT's ≤ L budget is checked against.
118
+ */
119
+ export function countChangedLines(edits, currentFiles) {
120
+ const currentByPath = new Map(currentFiles.map((f) => [f.relPath.replace(/\\/g, '/'), f.content]));
121
+ let total = 0;
122
+ for (const edit of edits) {
123
+ const rel = edit.relPath.replace(/\\/g, '/');
124
+ const current = currentByPath.get(rel) ?? '';
125
+ const d = lineDiff(current, edit.content);
126
+ total += d.linesAdded + d.linesRemoved;
127
+ }
128
+ return total;
129
+ }
130
+ //# sourceMappingURL=line-diff.js.map
@@ -0,0 +1,19 @@
1
+ /** Throw if `abs` resolves outside `repoRoot`. */
2
+ export declare function assertWithinRepo(repoRoot: string, abs: string): void;
3
+ /**
4
+ * Atomic write via sibling tmp file + rename; cleans up on rename failure. After
5
+ * the rename, fsync the file and (best-effort) its parent directory so a host
6
+ * crash cannot lose a just-renamed record a later separate process relies on —
7
+ * the rename is only durable once the directory entry itself is flushed.
8
+ */
9
+ export declare function writeFileAtomic(abs: string, content: string): Promise<void>;
10
+ /**
11
+ * Append `content` to a file and fsync the file descriptor so a host crash
12
+ * cannot lose the just-appended record. Used by the append-only stores (the
13
+ * 版本账本 ledger and the 否决缓冲 reject-buffer): a later SEPARATE process
14
+ * (`resumeEpisode`) relies on these records being durable, so the OS write cache
15
+ * must be flushed before we return. Does NOT change the bytes written (one
16
+ * `append` of `content`); only adds the fsync. Byte content is the caller's.
17
+ */
18
+ export declare function appendFileDurable(abs: string, content: string): Promise<void>;
19
+ //# sourceMappingURL=fs-safe.d.ts.map