synergyspec-selfevolving 1.3.0 → 1.4.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 (59) hide show
  1. package/README.md +19 -1
  2. package/dist/commands/learn.js +228 -26
  3. package/dist/commands/self-evolution.js +171 -26
  4. package/dist/commands/workflow/status.js +3 -1
  5. package/dist/core/config-prompts.js +4 -0
  6. package/dist/core/fitness/health/health-metrics.d.ts +26 -56
  7. package/dist/core/fitness/health/health-metrics.js +19 -58
  8. package/dist/core/fitness/health/index.d.ts +15 -2
  9. package/dist/core/fitness/health/index.js +25 -1
  10. package/dist/core/fitness/health/local-source.d.ts +43 -4
  11. package/dist/core/fitness/health/local-source.js +181 -25
  12. package/dist/core/fitness/health/metric-source.d.ts +48 -19
  13. package/dist/core/fitness/health/metric-source.js +8 -18
  14. package/dist/core/fitness/health/resolve-source.js +4 -1
  15. package/dist/core/fitness/loss.d.ts +2 -2
  16. package/dist/core/fitness/loss.js +2 -2
  17. package/dist/core/fitness/sample.d.ts +10 -0
  18. package/dist/core/fitness/test-failures.d.ts +30 -0
  19. package/dist/core/fitness/test-failures.js +123 -0
  20. package/dist/core/learn/credit-path.d.ts +36 -0
  21. package/dist/core/learn/credit-path.js +198 -0
  22. package/dist/core/learn/trajectory-discovery.d.ts +39 -0
  23. package/dist/core/learn/trajectory-discovery.js +140 -0
  24. package/dist/core/learn.d.ts +39 -5
  25. package/dist/core/learn.js +131 -14
  26. package/dist/core/project-config.d.ts +2 -0
  27. package/dist/core/project-config.js +24 -1
  28. package/dist/core/self-evolution/canonical-targets.d.ts +8 -4
  29. package/dist/core/self-evolution/canonical-targets.js +8 -4
  30. package/dist/core/self-evolution/health-baseline.d.ts +25 -6
  31. package/dist/core/self-evolution/health-baseline.js +30 -6
  32. package/dist/core/self-evolution/index.d.ts +1 -0
  33. package/dist/core/self-evolution/index.js +1 -0
  34. package/dist/core/self-evolution/learn-hints.d.ts +31 -0
  35. package/dist/core/self-evolution/learn-hints.js +16 -0
  36. package/dist/core/self-evolution/learn-observation-adapter.d.ts +35 -0
  37. package/dist/core/self-evolution/learn-observation-adapter.js +285 -10
  38. package/dist/core/self-evolution/proposer-agent.d.ts +41 -0
  39. package/dist/core/self-evolution/proposer-agent.js +94 -13
  40. package/dist/core/self-evolution/proposer-slice.d.ts +26 -0
  41. package/dist/core/self-evolution/proposer-slice.js +54 -0
  42. package/dist/core/self-evolution/success-channel.d.ts +79 -0
  43. package/dist/core/self-evolution/success-channel.js +361 -0
  44. package/dist/core/self-evolution/target-evolution.d.ts +11 -0
  45. package/dist/core/self-evolution/target-evolution.js +2 -0
  46. package/dist/core/templates/skill-templates.d.ts +1 -0
  47. package/dist/core/templates/skill-templates.js +1 -0
  48. package/dist/core/templates/workflow-manifest.js +2 -0
  49. package/dist/core/templates/workflows/learn.d.ts +3 -2
  50. package/dist/core/templates/workflows/learn.js +24 -167
  51. package/dist/core/templates/workflows/self-evolving.d.ts +11 -0
  52. package/dist/core/templates/workflows/self-evolving.js +237 -0
  53. package/dist/core/trajectory/facts.d.ts +16 -0
  54. package/dist/core/trajectory/facts.js +12 -4
  55. package/dist/core/trajectory/skeleton.d.ts +43 -0
  56. package/dist/core/trajectory/skeleton.js +239 -0
  57. package/package.json +3 -1
  58. package/scripts/code-health.py +1066 -638
  59. package/scripts/slop_rules.yaml +2151 -0
package/README.md CHANGED
@@ -211,10 +211,28 @@ What actually works today:
211
211
  `MetricSource` selected via `health:` in `synergyspec-selfevolving/config.yaml`.
212
212
  New projects scaffold `source: local` (default-on): a dependency-free,
213
213
  multi-language analyzer (`scripts/code-health.py`, Python 3 stdlib only) that
214
- scores Python, Rust, C, and C++ no server, no network. Set `source: stub` to
214
+ scores Python, Rust, C, and C++ by computing the SlopCodeBench
215
+ `structural_erosion` and `verbosity` scores (for Python, the slop rules are
216
+ the actual SlopCodeBench v0.3 ast-grep rule set, bundled) — no server, no
217
+ network. Set `source: stub` to
215
218
  make the loss functional-only; `sonarqube` is also supported; `local-python` is
216
219
  a back-compat alias for `local`. See
217
220
  [docs/customization.md](docs/customization.md#code-health-metrics-self-evolution).
221
+ - **Rollout/critic separation** (`learn` → `synergyspec-selfevolving-self-evolving`):
222
+ the end-of-cycle critique runs in a fresh-context critic subagent — an
223
+ always-installed utility skill that reads only the on-disk record
224
+ (transcripts, hints, evolution result, learn report) and returns a
225
+ `## Critic Verdict` block that the thin `learn` relays. The session that did
226
+ the work never grades its own rollout; hosts without subagents run the critic
227
+ inline, marked as degraded isolation. Headless and fresh-context invokers can
228
+ pass explicit trajectory handles (`--transcript` / `--session-id` on `learn`,
229
+ `learn handoff`, `learn debug-trajectory`, and `evolve-from-edits`, or the
230
+ `SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT` / `SYNERGYSPEC_SELFEVOLVING_SESSION_ID`
231
+ env vars; flags beat env vars, transcript beats session-id). An explicit
232
+ handle beats change-window discovery and never silently grades another
233
+ session: an unresolvable flag is an up-front error (exit non-zero), while a
234
+ missing env handle fails closed — no trajectory, and the observed-verified
235
+ gate refuses to promote.
218
236
  - **Code-health gate** (auto-evolve / `evolve-from-edits`): a measured code-health
219
237
  regression vs the last accepted state blocks auto-promotion (and surfaces a
220
238
  loud `health-signal-unavailable` observation if a configured analyzer can't
@@ -1,12 +1,14 @@
1
1
  import path from 'node:path';
2
2
  import { applyLearnCandidates, applyLearnMemoryCandidates, generateLearnReport, renderLearnReport, } from '../core/learn.js';
3
- import { detectUnbindableHintObservations, generateEvolutionHints, isCanonicalTargetEvolvable, listCanonicalTargets, lookupCanonicalTarget, persistLearnHints, resolveTargetEvolutionPolicy, resolveTargetLocalFilesReadonly, } from '../core/self-evolution/index.js';
3
+ import { detectFrozenHealthRoutingObservations, detectUnbindableHintObservations, generateEvolutionHints, isCanonicalTargetEvolvable, listCanonicalTargets, lookupCanonicalTarget, persistLearnHints, resolveTargetEvolutionPolicy, resolveTargetLocalFilesReadonly, } from '../core/self-evolution/index.js';
4
4
  import { readProjectConfig } from '../core/project-config.js';
5
5
  import { assembleTrajectoryContext, } from '../core/learn/trajectory-assembler.js';
6
- import { findTranscriptsForChange, resolveChangeDir, } from '../core/learn/trajectory-discovery.js';
6
+ import { findTranscriptsForChange, resolveChangeDir, validateExplicitTrajectoryHandle, } from '../core/learn/trajectory-discovery.js';
7
7
  import { getTrajectoryForChange } from '../core/trajectory/registry.js';
8
8
  import { toTrajectoryFacts, describeRunnerResults } from '../core/trajectory/facts.js';
9
+ import { toActionSkeleton } from '../core/trajectory/skeleton.js';
9
10
  import { resolveHostHarness } from '../core/self-evolution/host-harness.js';
11
+ import { mineSuccessSignals } from '../core/self-evolution/success-channel.js';
10
12
  import { buildLLMSummaryCandidates, ingestLearnHandoff, } from '../core/learn/llm-summary.js';
11
13
  function collect(value, previous) {
12
14
  previous.push(value);
@@ -21,15 +23,62 @@ export function registerLearnCommand(program) {
21
23
  .option('--only <candidate-id>', 'When applying, write only this keep candidate id (repeatable)', collect, [])
22
24
  .option('--exclude <candidate-id>', 'When applying, skip this candidate id (repeatable)', collect, [])
23
25
  .option('-y, --yes', 'Confirm --apply and skip confirmation prompts')
24
- .option('--persist-hints', 'Persist derived evolution hints to a learn-handoffs/ file for propose-canonical --from-learn (proposal-only; implied by --apply)')
26
+ .option('--persist-hints', 'Persist derived evolution hints to a learn-handoffs/ file consumed by self-evolution evolve-from-edits --from-learn (writes no canonical file; implied by --apply)')
25
27
  .option('--evolve-target <ids>', 'comma-separated canonical target ids whose hints learn may emit (supports all/none); overrides config selfEvolution for this run')
26
28
  .option('--freeze-target <ids>', 'comma-separated canonical target ids whose hints learn must NOT emit (supports all/none; beats --evolve-target)')
29
+ .option('--no-focus', 'Disable the evolution-focus switch for this run: when the policy is frozen-by-default, frozen-kind signals are dropped silently instead of being re-aimed at the explicitly evolvable target(s) as a policy-focus hint (config: selfEvolution.focus)')
30
+ .option('--transcript <path>', 'Explicit transcript .jsonl to grade (bypasses change-window discovery; Claude transcript store only)')
31
+ .option('--session-id <id>', 'Explicit Claude session id to grade (bypasses change-window discovery; Claude transcript store only)')
27
32
  .option('--json', 'Output as JSON')
28
33
  .option('--no-interactive', 'Disable interactive prompts (learn is non-interactive by default)')
29
34
  .action(async (change, options) => {
30
35
  try {
31
36
  const projectRoot = process.cwd();
32
- const report = await generateLearnReport({ projectRoot, changeName: change });
37
+ // USER-TYPED handle flags are validated up front and fail LOUD
38
+ // (exit 1) on a miss — unlike the env-var channel, which keeps the
39
+ // fail-closed refusal semantics inside discovery (empty result, the
40
+ // gate refuses), and unlike debug-trajectory, which is the diagnostic
41
+ // that SHOWS the miss. Validated BEFORE the env is mutated below so a
42
+ // bad flag never leaks into the environment.
43
+ const handleError = await validateExplicitTrajectoryHandle({
44
+ projectRoot,
45
+ transcriptPath: options.transcript,
46
+ sessionId: options.sessionId,
47
+ });
48
+ if (handleError)
49
+ throw new Error(handleError);
50
+ // Explicit trajectory handle: surfaced to the discovery layer via env
51
+ // (same set/restore shape as debug-trajectory's --harness) so it
52
+ // reaches the registry adapter inside generateLearnReport without
53
+ // changing any core signature. Restored as soon as the report exists.
54
+ const prevTranscriptEnv = process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
55
+ const prevSessionEnv = process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
56
+ if (options.transcript)
57
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = options.transcript;
58
+ if (options.sessionId)
59
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = options.sessionId;
60
+ let report;
61
+ try {
62
+ report = await generateLearnReport({ projectRoot, changeName: change });
63
+ }
64
+ finally {
65
+ if (options.transcript) {
66
+ if (prevTranscriptEnv === undefined) {
67
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
68
+ }
69
+ else {
70
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = prevTranscriptEnv;
71
+ }
72
+ }
73
+ if (options.sessionId) {
74
+ if (prevSessionEnv === undefined) {
75
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
76
+ }
77
+ else {
78
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = prevSessionEnv;
79
+ }
80
+ }
81
+ }
33
82
  if (options.apply === true && options.yes !== true) {
34
83
  throw new Error('learn --apply requires --yes to confirm memory writes');
35
84
  }
@@ -40,6 +89,7 @@ export function registerLearnCommand(program) {
40
89
  config: readProjectConfig(projectRoot),
41
90
  evolveTarget: options.evolveTarget,
42
91
  freezeTarget: options.freezeTarget,
92
+ focus: options.focus,
43
93
  });
44
94
  const evolutionHints = generateEvolutionHints(report, targetPolicy);
45
95
  const evolutionPreview = await buildEvolutionPreview(evolutionHints, targetPolicy, projectRoot);
@@ -49,10 +99,11 @@ export function registerLearnCommand(program) {
49
99
  // is actually trying to evolve (--apply / --persist-hints / a named
50
100
  // --evolve-target). On a plain preview run the kind-only ambiguity is the
51
101
  // designed state, not a defect, so a bare `learn <change>` stays byte-identical.
52
- if (options.apply === true ||
53
- options.persistHints === true ||
54
- options.evolveTarget !== undefined) {
55
- report.observations.push(...detectUnbindableHintObservations(evolutionHints, targetPolicy));
102
+ if (isEvolvingRun(options)) {
103
+ report.observations.push(...detectUnbindableHintObservations(evolutionHints, targetPolicy),
104
+ // Health offenders whose producing target is frozen are a routing
105
+ // dead-end the operator must see (ACTION), never a silent drop.
106
+ ...detectFrozenHealthRoutingObservations(report, targetPolicy));
56
107
  }
57
108
  const applied = options.apply === true
58
109
  ? await applyLearnCandidates({
@@ -77,6 +128,7 @@ export function registerLearnCommand(program) {
77
128
  config: readProjectConfig(projectRoot),
78
129
  evolveTarget: options.evolveTarget,
79
130
  freezeTarget: options.freezeTarget,
131
+ focus: options.focus,
80
132
  });
81
133
  const hints = generateEvolutionHints(report, targetPolicy);
82
134
  if (hints.length > 0) {
@@ -87,6 +139,24 @@ export function registerLearnCommand(program) {
87
139
  });
88
140
  }
89
141
  }
142
+ // SUCCESS CHANNEL (R4): on an opt-in evolving run (--apply /
143
+ // --persist-hints) a verified-GREEN report mines load-bearing
144
+ // protections + exemplars — side-writes only, never a candidate, so
145
+ // abstain-on-success is untouched. A bare preview `learn` run never
146
+ // reaches this (no files created), and the mining is best-effort so a
147
+ // side-write failure never fails the learn run.
148
+ let successSummary;
149
+ if (options.apply === true || options.persistHints === true) {
150
+ try {
151
+ const mined = await mineSuccessSignals({ projectRoot, report });
152
+ if (mined.protectionsWritten > 0) {
153
+ successSummary = `Success channel: recorded ${mined.protectionsWritten} protection(s) for ${mined.protectedTargets.join(', ')}`;
154
+ }
155
+ }
156
+ catch {
157
+ // side-write only; never fail learn over it.
158
+ }
159
+ }
90
160
  if (options.json) {
91
161
  printJson(report, applied, evolutionPreview, hintsPath);
92
162
  return;
@@ -94,6 +164,10 @@ export function registerLearnCommand(program) {
94
164
  console.log(renderLearnReport(report, applied));
95
165
  console.log('');
96
166
  console.log(renderLearnTransparency(report, applied, evolutionPreview, hintsPath, options));
167
+ if (successSummary) {
168
+ console.log('');
169
+ console.log(successSummary);
170
+ }
97
171
  }
98
172
  catch (error) {
99
173
  if (options.json) {
@@ -110,10 +184,20 @@ export function registerLearnCommand(program) {
110
184
  .description('Print the assembled TrajectoryContext for a change as JSON. Read-only; runs no LLM handoff and writes nothing.')
111
185
  .option('--preview', 'Truncate the trajectory text field to 4000 chars in the output')
112
186
  .option('--harness <name>', 'Force the observed-run trajectory adapter (claude|codex|opencode); defaults to the resolved host harness')
113
- .action(async (change, opts) => {
187
+ .option('--transcript <path>', 'Explicit transcript .jsonl to grade (bypasses change-window discovery; Claude transcript store only)')
188
+ .option('--session-id <id>', 'Explicit Claude session id to grade (bypasses change-window discovery; Claude transcript store only)')
189
+ .action(async (change, _opts, command) => {
114
190
  const projectRoot = process.cwd();
191
+ // --transcript/--session-id are also declared on the parent `learn`
192
+ // command, which consumes them before this subcommand sees them.
193
+ // optsWithGlobals() merges ancestor + local options so the explicit
194
+ // handle is honored either way.
195
+ const opts = command.optsWithGlobals();
115
196
  try {
116
- const discovered = await findTranscriptsForChange(change, projectRoot);
197
+ const discovered = await findTranscriptsForChange(change, projectRoot, {
198
+ transcriptPath: opts.transcript,
199
+ sessionId: opts.sessionId,
200
+ });
117
201
  const assembled = await assembleTrajectoryContext({
118
202
  changeName: change,
119
203
  projectRoot,
@@ -137,8 +221,17 @@ export function registerLearnCommand(program) {
137
221
  // its facts + per-runner-result breakdown so a misgrade is visible in one
138
222
  // command. `--harness` forces a specific adapter for cross-host debugging.
139
223
  const prevHarnessEnv = process.env.SYNERGYSPEC_SELFEVOLVING_HOST_HARNESS;
224
+ const prevTranscriptEnv = process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
225
+ const prevSessionEnv = process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
140
226
  if (opts.harness)
141
227
  process.env.SYNERGYSPEC_SELFEVOLVING_HOST_HARNESS = opts.harness;
228
+ // The explicit trajectory handle must reach the adapter's own discovery
229
+ // call too (env is the only channel through the registry), so the
230
+ // introspected payload reflects the same override as `discovery` above.
231
+ if (opts.transcript)
232
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = opts.transcript;
233
+ if (opts.sessionId)
234
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = opts.sessionId;
142
235
  try {
143
236
  const adapterTrajectory = await getTrajectoryForChange(projectRoot, change);
144
237
  payload.adapter = {
@@ -148,6 +241,9 @@ export function registerLearnCommand(program) {
148
241
  sourcePaths: adapterTrajectory ? [...new Set(adapterTrajectory.sourcePaths)] : [],
149
242
  facts: toTrajectoryFacts(adapterTrajectory, change),
150
243
  runnerResults: describeRunnerResults(adapterTrajectory),
244
+ // Bounded play-by-play projection (file edits / test runs /
245
+ // commands) so a wrong skeleton is visible in one command.
246
+ steps: toActionSkeleton(adapterTrajectory),
151
247
  };
152
248
  }
153
249
  finally {
@@ -159,6 +255,22 @@ export function registerLearnCommand(program) {
159
255
  process.env.SYNERGYSPEC_SELFEVOLVING_HOST_HARNESS = prevHarnessEnv;
160
256
  }
161
257
  }
258
+ if (opts.transcript) {
259
+ if (prevTranscriptEnv === undefined) {
260
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
261
+ }
262
+ else {
263
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = prevTranscriptEnv;
264
+ }
265
+ }
266
+ if (opts.sessionId) {
267
+ if (prevSessionEnv === undefined) {
268
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
269
+ }
270
+ else {
271
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = prevSessionEnv;
272
+ }
273
+ }
162
274
  }
163
275
  if (assembled.kind === 'ok') {
164
276
  const t = assembled.trajectory;
@@ -194,25 +306,69 @@ export function registerLearnCommand(program) {
194
306
  learnCmd
195
307
  .command('handoff <change>')
196
308
  .description('Write an LLM trajectory-extraction brief for a change and return immediately (no polling). Fulfill it in-session by writing memory-items.md, then run `learn ingest-handoff`.')
309
+ .option('--transcript <path>', 'Explicit transcript .jsonl to grade (bypasses change-window discovery; Claude transcript store only)')
310
+ .option('--session-id <id>', 'Explicit Claude session id to grade (bypasses change-window discovery; Claude transcript store only)')
197
311
  .option('--json', 'Output as JSON')
198
312
  .action(async (change, _opts, command) => {
199
313
  const projectRoot = process.cwd();
200
- // The parent `learn [change]` command also declares --json, so it
201
- // consumes the flag before the subcommand sees it. optsWithGlobals()
202
- // merges ancestor + local options so the flag is honored either way.
314
+ // The parent `learn [change]` command also declares --json, --transcript
315
+ // and --session-id, so it consumes the flags before the subcommand sees
316
+ // them. optsWithGlobals() merges ancestor + local options so they are
317
+ // honored either way.
203
318
  const opts = command.optsWithGlobals();
204
319
  try {
205
- const changeDir = await resolveChangeDir(projectRoot, change);
206
- // forceEnable: invoking this command IS the opt-in, so it does not
207
- // depend on SYNERGYSPEC_SELFEVOLVING_LEARN_LLM. pollForResponse:false
208
- // is what avoids the single-actor poll deadlock.
209
- const result = await buildLLMSummaryCandidates({
320
+ // USER-TYPED handle flags fail LOUD up front (exit 1) — without this
321
+ // a bad --session-id would silently fall back to window discovery,
322
+ // the exact failure mode the explicit handles exist to kill.
323
+ const handleError = await validateExplicitTrajectoryHandle({
210
324
  projectRoot,
211
- changeName: change,
212
- changeDir,
213
- pollForResponse: false,
214
- forceEnable: true,
325
+ transcriptPath: opts.transcript,
326
+ sessionId: opts.sessionId,
215
327
  });
328
+ if (handleError)
329
+ throw new Error(handleError);
330
+ const changeDir = await resolveChangeDir(projectRoot, change);
331
+ // The explicit trajectory handle must reach the discovery call inside
332
+ // buildLLMSummaryCandidates (env is the only channel through it), so
333
+ // set/restore the same way the main learn action does. Restored as
334
+ // soon as the trajectory assembly returns.
335
+ const prevTranscriptEnv = process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
336
+ const prevSessionEnv = process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
337
+ if (opts.transcript)
338
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = opts.transcript;
339
+ if (opts.sessionId)
340
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = opts.sessionId;
341
+ let result;
342
+ try {
343
+ // forceEnable: invoking this command IS the opt-in, so it does not
344
+ // depend on SYNERGYSPEC_SELFEVOLVING_LEARN_LLM. pollForResponse:false
345
+ // is what avoids the single-actor poll deadlock.
346
+ result = await buildLLMSummaryCandidates({
347
+ projectRoot,
348
+ changeName: change,
349
+ changeDir,
350
+ pollForResponse: false,
351
+ forceEnable: true,
352
+ });
353
+ }
354
+ finally {
355
+ if (opts.transcript) {
356
+ if (prevTranscriptEnv === undefined) {
357
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT;
358
+ }
359
+ else {
360
+ process.env.SYNERGYSPEC_SELFEVOLVING_TRANSCRIPT = prevTranscriptEnv;
361
+ }
362
+ }
363
+ if (opts.sessionId) {
364
+ if (prevSessionEnv === undefined) {
365
+ delete process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID;
366
+ }
367
+ else {
368
+ process.env.SYNERGYSPEC_SELFEVOLVING_SESSION_ID = prevSessionEnv;
369
+ }
370
+ }
371
+ }
216
372
  if (!result) {
217
373
  throw new Error('handoff stage returned no result');
218
374
  }
@@ -458,6 +614,11 @@ async function buildEvolutionPreview(hints, targetPolicy, projectRoot) {
458
614
  target.localFiles = [];
459
615
  }
460
616
  }
617
+ // Surface the evolution-focus re-aim explicitly: a synthesized
618
+ // `origin: 'policy-focus'` hint means every ordinary signal of this change
619
+ // bound to a frozen kind and its evidence was re-aimed at the focused
620
+ // target(s). Agents read this field instead of inferring from hint ids.
621
+ const focusHints = hints.filter((hint) => hint.origin === 'policy-focus');
461
622
  return {
462
623
  hintCount: hints.length,
463
624
  targetPolicy: {
@@ -468,6 +629,16 @@ async function buildEvolutionPreview(hints, targetPolicy, projectRoot) {
468
629
  ...(targetPolicy.source.cliEvolve ? { cliEvolve: targetPolicy.source.cliEvolve } : {}),
469
630
  ...(targetPolicy.source.cliFreeze ? { cliFreeze: targetPolicy.source.cliFreeze } : {}),
470
631
  },
632
+ ...(focusHints.length > 0
633
+ ? {
634
+ focus: {
635
+ targetIds: [
636
+ ...new Set(focusHints.flatMap((hint) => hint.affectedTargetId ? [hint.affectedTargetId] : [])),
637
+ ].sort((left, right) => left.localeCompare(right)),
638
+ hintIds: focusHints.map((hint) => hint.id),
639
+ },
640
+ }
641
+ : {}),
471
642
  targets: [...byTarget.values()].sort((left, right) => (left.targetId ?? `~${left.targetKind}`).localeCompare(right.targetId ?? `~${right.targetKind}`)),
472
643
  };
473
644
  }
@@ -507,9 +678,15 @@ function renderLearnTransparency(report, applied, evolutionPreview, hintsPath, o
507
678
  lines.push('');
508
679
  lines.push('### Skill/Template Optimization Preview');
509
680
  lines.push(`- Evolution policy: default=${evolutionPreview.targetPolicy.default}${renderExplicitPolicy(evolutionPreview.targetPolicy.explicit)}`);
681
+ if (evolutionPreview.focus) {
682
+ lines.push(`- Evolution focus: ${evolutionPreview.focus.targetIds.join(', ')} — every signal of this change bound to a frozen kind, so the dropped evidence was re-aimed at the focused target as ${evolutionPreview.focus.hintIds.join(', ')} (origin: policy-focus). Disable with --no-focus or selfEvolution.focus: false.`);
683
+ }
510
684
  if (evolutionPreview.targets.length === 0) {
511
685
  lines.push('- Target: none under the current evidence and evolve/freeze policy.');
512
686
  lines.push('- How: no prompt/template/skill optimization will be proposed from this learn report unless more evidence is added or the target policy is widened.');
687
+ if (options.focus === false && evolutionPreview.targetPolicy.default === 'frozen') {
688
+ lines.push('- Note: the evolution-focus switch is disabled for this run (--no-focus); frozen-kind signals, if any, were dropped without a focus re-aim.');
689
+ }
513
690
  }
514
691
  else {
515
692
  for (const target of evolutionPreview.targets) {
@@ -566,12 +743,37 @@ function renderLearnTransparency(report, applied, evolutionPreview, hintsPath, o
566
743
  else if (evolutionPreview.targets.length > 0) {
567
744
  lines.push(`- Persist the optimization evidence: synergyspec-selfevolving learn "${report.changeName}" --persist-hints${renderTargetArgs(options)}`);
568
745
  }
569
- lines.push('');
570
- lines.push('headless fallback (no host agent):');
571
- lines.push(`- One-button local evolve: synergyspec-selfevolving self-evolution auto-evolve --change "${report.changeName}" --agent${renderTargetArgs(options)}`);
572
- lines.push('- After reviewing or evolving, run /synspec:archive to close the change.');
746
+ else if (isEvolvingRun(options)) {
747
+ // Empty-target evolving run: say so explicitly instead of leaving the
748
+ // section blank and falling through to the headless fallback (ses_148b:
749
+ // that fallthrough made the skill-banned `--agent` steering the only
750
+ // actionable next-step a host agent saw).
751
+ lines.push('- No evolvable optimization target bound under the current evidence and evolve/freeze policy — a safe no-op (Outcome: not-run).');
752
+ }
753
+ if (isEvolvingRun(options)) {
754
+ lines.push('- After reviewing or evolving, run /synspec:archive to close the change.');
755
+ }
756
+ else {
757
+ lines.push('');
758
+ lines.push('headless fallback (no host agent):');
759
+ lines.push(`- One-button local evolve: synergyspec-selfevolving self-evolution auto-evolve --change "${report.changeName}"${renderTargetArgs(options)}`);
760
+ lines.push('- After reviewing or evolving, run /synspec:archive to close the change.');
761
+ }
573
762
  return lines.join('\n');
574
763
  }
764
+ /**
765
+ * An "evolving run" is one where the operator opted into evolution
766
+ * (`--apply` / `--persist-hints` / a named `--evolve-target`) — per the skill,
767
+ * the bare CLI previews and only the skill/agent flow passes these flags, so
768
+ * this is the agent-in-the-loop proxy (the same signal that gates the
769
+ * unbindable-hint observations in the learn action). The headless
770
+ * `auto-evolve` fallback (it spawns its proposer internally) is for runs with
771
+ * NO agent in the loop; the skill forbids the headless proposer when an agent
772
+ * IS the proposer, so the fallback is suppressed on evolving runs.
773
+ */
774
+ function isEvolvingRun(options) {
775
+ return (options.apply === true || options.persistHints === true || options.evolveTarget !== undefined);
776
+ }
575
777
  function renderExplicitPolicy(explicit) {
576
778
  if (explicit.length === 0)
577
779
  return '';