synergyspec-selfevolving 1.1.10 → 1.1.12

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 (30) hide show
  1. package/README.md +12 -3
  2. package/dist/commands/learn.js +78 -11
  3. package/dist/commands/self-evolution.d.ts +13 -0
  4. package/dist/commands/self-evolution.js +156 -20
  5. package/dist/commands/workflow/status.js +13 -0
  6. package/dist/core/change-readiness.d.ts +24 -0
  7. package/dist/core/change-readiness.js +47 -0
  8. package/dist/core/config-prompts.js +10 -0
  9. package/dist/core/fitness/health/local-source.d.ts +9 -6
  10. package/dist/core/fitness/health/local-source.js +9 -6
  11. package/dist/core/fitness/health/resolve-source.d.ts +4 -3
  12. package/dist/core/fitness/health/resolve-source.js +5 -4
  13. package/dist/core/fitness/sample.d.ts +17 -0
  14. package/dist/core/learn.d.ts +7 -0
  15. package/dist/core/learn.js +57 -5
  16. package/dist/core/project-config.d.ts +1 -0
  17. package/dist/core/project-config.js +11 -8
  18. package/dist/core/self-evolution/health-baseline.d.ts +24 -0
  19. package/dist/core/self-evolution/health-baseline.js +78 -0
  20. package/dist/core/self-evolution/index.d.ts +1 -0
  21. package/dist/core/self-evolution/index.js +1 -0
  22. package/dist/core/self-evolution/learn-observation-adapter.d.ts +16 -1
  23. package/dist/core/self-evolution/learn-observation-adapter.js +101 -15
  24. package/dist/core/self-evolution/promote.d.ts +25 -0
  25. package/dist/core/self-evolution/promote.js +21 -0
  26. package/dist/core/self-evolution/target-evolution.d.ts +7 -0
  27. package/dist/core/self-evolution/target-evolution.js +9 -0
  28. package/dist/core/templates/workflows/learn.js +10 -5
  29. package/package.json +2 -1
  30. package/scripts/code-health.py +1154 -0
@@ -22,7 +22,7 @@ import * as path from 'node:path';
22
22
  import { relativePath } from './shared.js';
23
23
  import { validateLearnEvolutionHint, } from './learn-hints.js';
24
24
  import { findCanonicalTargetsByFile, listCanonicalTargets, lookupCanonicalTarget, } from './canonical-targets.js';
25
- import { isCanonicalTargetEvolvable, } from './target-evolution.js';
25
+ import { isCanonicalTargetEvolvable, explicitTargetIds, } from './target-evolution.js';
26
26
  import { detectRepoMode, resolveTargetLocalFiles } from './local-targets.js';
27
27
  import { getSchemaDir } from '../artifact-graph/resolver.js';
28
28
  import { limitText, } from '../learn.js';
@@ -288,14 +288,27 @@ export function generateEvolutionHints(report, policy) {
288
288
  * - A hint that already names a target (`affectedTargetId`) is kept iff that
289
289
  * target is evolvable.
290
290
  * - A kind-only hint is kept iff at least one registered target of its kind is
291
- * evolvable. When exactly one is evolvable, the hint is PINNED to it (its
292
- * `affectedTargetId` and `thresholdKey` are filled in) so the downstream
293
- * propose/gate path treats it as a concrete, single-target hint instead of an
294
- * `unspecified` group.
291
+ * evolvable, and is PINNED to a concrete target (its `affectedTargetId` and
292
+ * `thresholdKey` are filled in) so the downstream propose/gate path treats it
293
+ * as a concrete, single-target hint instead of an `unspecified` group. The pin
294
+ * target is, in order:
295
+ * 1. the single registered target of the hint's kind named explicitly on the
296
+ * CLI via `--evolve-target` (authoritative operator intent — issue #4),
297
+ * 2. otherwise the sole evolvable target of the kind (count heuristic).
298
+ * When neither uniquely resolves (none named, and 0 or >=2 evolvable) the hint
299
+ * stays kind-only/`unspecified`.
300
+ *
301
+ * The explicit-CLI pin (1) exists because `--evolve-target` previously only fed
302
+ * the evolvability POLICY: naming `artifact-template:tasks` left every other
303
+ * artifact-template target evolvable too, so the count heuristic saw >1 and never
304
+ * pinned — the operator's explicit choice was silently dropped and the persisted
305
+ * hint could not be promoted by `evolve-from-edits` (issue #4).
295
306
  *
296
307
  * When `policy` is undefined the drafts are returned unchanged (back-compat).
297
308
  * The HARD oracle/gate freeze is unaffected — oracle files are not canonical
298
- * targets, so no policy value can name them.
309
+ * targets, so no policy value can name them; a CLI-named id is pinned only when
310
+ * it is a registered, same-kind target that is evolvable under the resolved
311
+ * policy (so `--freeze-target` still wins).
299
312
  */
300
313
  function scopeHintsByPolicy(drafts, policy) {
301
314
  if (!policy)
@@ -307,24 +320,97 @@ function scopeHintsByPolicy(drafts, policy) {
307
320
  kept.push(draft);
308
321
  continue;
309
322
  }
310
- const evolvable = listCanonicalTargets({ kind: draft.affectedTargetKind }).filter((target) => isCanonicalTargetEvolvable(target.id, policy));
311
- if (evolvable.length === 0)
312
- continue;
313
- if (evolvable.length === 1) {
314
- const id = evolvable[0].id;
323
+ const pinId = resolveKindOnlyPinTarget(draft, policy);
324
+ if (pinId === null)
325
+ continue; // kind has no evolvable target → drop
326
+ if (pinId !== undefined) {
315
327
  kept.push({
316
328
  ...draft,
317
- affectedTargetId: id,
318
- // `id` is already a full `<kind>:<name>` target id, so the grouping key
329
+ affectedTargetId: pinId,
330
+ // `pinId` is already a full `<kind>:<name>` target id, so the grouping key
319
331
  // is `<id>:<changeType>` (no doubled kind prefix).
320
- thresholdKey: `${id}:${draft.proposedChangeType}`,
332
+ thresholdKey: `${pinId}:${draft.proposedChangeType}`,
321
333
  });
322
334
  continue;
323
335
  }
324
- kept.push(draft);
336
+ kept.push(draft); // ambiguous → keep kind-only/unspecified
325
337
  }
326
338
  return kept;
327
339
  }
340
+ /**
341
+ * Decide the concrete target a kind-only hint should be pinned to under the
342
+ * policy. Returns the target id to pin to, `undefined` to keep the hint
343
+ * kind-only (ambiguous — caller leaves it `unspecified`), or `null` to drop the
344
+ * hint entirely (no evolvable target of its kind exists).
345
+ */
346
+ function resolveKindOnlyPinTarget(draft, policy) {
347
+ // (1) Authoritative operator intent: a single registered, same-kind target
348
+ // named on the CLI via `--evolve-target` pins the hint even when config leaves
349
+ // other same-kind targets evolvable. `isCanonicalTargetEvolvable` honors
350
+ // freeze-wins, so a `--freeze-target`'d id is never pinned.
351
+ const namedOfKind = [...new Set(explicitTargetIds(policy.source.cliEvolve))].filter((id) => lookupCanonicalTarget(id)?.kind === draft.affectedTargetKind &&
352
+ isCanonicalTargetEvolvable(id, policy));
353
+ if (namedOfKind.length === 1)
354
+ return namedOfKind[0];
355
+ // (2) Count heuristic: pin only when exactly one target of the kind is
356
+ // evolvable; drop when none are; otherwise keep kind-only.
357
+ const evolvable = listCanonicalTargets({ kind: draft.affectedTargetKind }).filter((target) => isCanonicalTargetEvolvable(target.id, policy));
358
+ if (evolvable.length === 0)
359
+ return null;
360
+ if (evolvable.length === 1)
361
+ return evolvable[0].id;
362
+ return undefined;
363
+ }
364
+ /**
365
+ * Surface an UNBINDABLE kind-only evolution hint as an actionable DEFECT
366
+ * observation. After {@link scopeHintsByPolicy} runs, a hint that still has no
367
+ * `affectedTargetId` is one that could not be pinned to a concrete target (>1
368
+ * same-kind target evolvable and none named via `--evolve-target`) — it would
369
+ * surface as the `<kind>:unspecified` placeholder and yield a "0 surviving hint
370
+ * group" refusal that is a BINDING DEFECT, not a safe gate refusal. Emitting this
371
+ * is what lets the agent (and the skill) tell the two apart instead of recording a
372
+ * binding bug as "the gate correctly refused".
373
+ *
374
+ * Reads the SCOPED hints directly (no second pin pass), so it cannot drift from
375
+ * {@link scopeHintsByPolicy}. Returns `[]` when `policy` is undefined or nothing is
376
+ * unbindable, keeping learn output byte-identical in the common case.
377
+ */
378
+ export function detectUnbindableHintObservations(hints, policy) {
379
+ if (!policy)
380
+ return [];
381
+ const byKind = new Map();
382
+ for (const hint of hints) {
383
+ if (hint.affectedTargetId)
384
+ continue; // pinned to a concrete target → fine
385
+ const list = byKind.get(hint.affectedTargetKind) ?? [];
386
+ list.push(hint);
387
+ byKind.set(hint.affectedTargetKind, list);
388
+ }
389
+ const observations = [];
390
+ for (const [kind, kindHints] of byKind) {
391
+ const candidates = listCanonicalTargets({ kind })
392
+ .filter((target) => isCanonicalTargetEvolvable(target.id, policy))
393
+ .map((target) => target.id);
394
+ const evidence = [];
395
+ for (const hint of kindHints) {
396
+ for (const item of hint.evidence) {
397
+ if (evidence.length >= 4)
398
+ break;
399
+ evidence.push({ file: item.file, detail: `unbindable ${kind} hint ${hint.id}` });
400
+ }
401
+ }
402
+ observations.push({
403
+ code: 'evolution-target-unresolved',
404
+ severity: 'defect',
405
+ summary: limitText(`Evolution target unresolved — a kind-only ${kind} hint could not be pinned to a concrete target` +
406
+ (candidates.length > 0 ? ` (candidates: ${candidates.join(', ')})` : '') +
407
+ '. Pass --evolve-target <concrete> to bind it; this is a binding defect, NOT a safe gate refusal.', 300),
408
+ evidence,
409
+ tags: ['evolution', 'unbindable', 'action-required'],
410
+ });
411
+ }
412
+ return observations;
413
+ }
328
414
  function inferTemplateObservation(candidate) {
329
415
  const tags = candidate.tags;
330
416
  const templateTag = tags.find((tag) => /^template:/i.test(tag));
@@ -68,7 +68,32 @@ export interface AutoPromoteInput {
68
68
  baselineLoss: number | null;
69
69
  /** When true, require a MEASURED improvement (history < baseline) to promote. */
70
70
  requireProvenImprovement: boolean;
71
+ /**
72
+ * This change's RAW measured code-health penalty in [0,1] (the "post" side of
73
+ * the default-path health gate). `null`/omitted ⇒ no health signal ⇒ the
74
+ * health gate does not fire (it can never block on a missing measurement).
75
+ */
76
+ healthPenalty?: number | null;
77
+ /**
78
+ * Recorded baseline code-health penalty to compare against (the "pre" side;
79
+ * see {@link import('./health-baseline.js').HealthBaseline}). `null`/omitted
80
+ * ⇒ no baseline yet ⇒ the health gate does not fire (first measured run is
81
+ * recorded, not gated).
82
+ */
83
+ baselineHealthPenalty?: number | null;
84
+ /**
85
+ * How much the health penalty may worsen vs the baseline before it counts as a
86
+ * regression. Defaults to {@link DEFAULT_HEALTH_REGRESSION_MARGIN}; absorbs
87
+ * measurement noise so a trivial uptick does not block the loop.
88
+ */
89
+ healthRegressionMargin?: number;
71
90
  }
91
+ /**
92
+ * Default slack on the health-regression gate: a change may worsen the measured
93
+ * health penalty by up to this much vs the recorded baseline without being
94
+ * treated as a regression. Keeps measurement noise from blocking promotion.
95
+ */
96
+ export declare const DEFAULT_HEALTH_REGRESSION_MARGIN = 0.05;
72
97
  export interface AutoPromoteDecision {
73
98
  promote: boolean;
74
99
  reason: string;
@@ -198,6 +198,12 @@ export async function rollbackCandidatePromotion(layout, candidateId, opts) {
198
198
  });
199
199
  return { candidateId, status: rolled.status, restoredFiles };
200
200
  }
201
+ /**
202
+ * Default slack on the health-regression gate: a change may worsen the measured
203
+ * health penalty by up to this much vs the recorded baseline without being
204
+ * treated as a regression. Keeps measurement noise from blocking promotion.
205
+ */
206
+ export const DEFAULT_HEALTH_REGRESSION_MARGIN = 0.05;
201
207
  /**
202
208
  * Pure auto-promote predicate for one-button auto-evolve. The static gate +
203
209
  * per-target switch are hard prerequisites; the fitness comparison is the
@@ -215,6 +221,21 @@ export function shouldAutoPromote(input) {
215
221
  if (!input.targetEvolvable) {
216
222
  return { promote: false, reason: 'target frozen by per-target evolution switch' };
217
223
  }
224
+ // Code-health regression gate (default-on health). Independent of the
225
+ // functional/loss history, so it fires even on the no-replay default path
226
+ // where `meanLoss` is null: if THIS change's measured code-health is worse
227
+ // than the recorded baseline by more than the margin, block — do not bake a
228
+ // lesson learned from a health-regressing codebase into the canonical
229
+ // template. No signal (penalty null) or no baseline ⇒ the gate cannot fire.
230
+ if (input.healthPenalty != null && input.baselineHealthPenalty != null) {
231
+ const margin = input.healthRegressionMargin ?? DEFAULT_HEALTH_REGRESSION_MARGIN;
232
+ if (input.healthPenalty > input.baselineHealthPenalty + margin) {
233
+ return {
234
+ promote: false,
235
+ reason: `code-health regression: penalty ${fmt(input.healthPenalty)} > baseline ${fmt(input.baselineHealthPenalty)} + margin ${fmt(margin)}`,
236
+ };
237
+ }
238
+ }
218
239
  const hasHistory = input.accumulatedCount > 0 && input.meanLoss !== null;
219
240
  if (input.requireProvenImprovement) {
220
241
  if (!hasHistory || input.baselineLoss === null) {
@@ -31,6 +31,13 @@ export interface TargetEvolutionPolicy {
31
31
  cliFreeze?: string;
32
32
  };
33
33
  }
34
+ /**
35
+ * The concrete (non-`all`/`none`) target ids named in a `--evolve-target` /
36
+ * `--freeze-target` csv flag. Public so the learn → hint adapter can treat an
37
+ * explicitly named id as an authoritative pin (the policy map alone loses the
38
+ * distinction between "named on the CLI" and "evolvable via config default").
39
+ */
40
+ export declare function explicitTargetIds(csv?: string): string[];
34
41
  /**
35
42
  * Build the effective policy from config + CLI flags. Pure; no I/O.
36
43
  */
@@ -11,6 +11,15 @@ function parseIds(csv) {
11
11
  none: tokens.includes('none'),
12
12
  };
13
13
  }
14
+ /**
15
+ * The concrete (non-`all`/`none`) target ids named in a `--evolve-target` /
16
+ * `--freeze-target` csv flag. Public so the learn → hint adapter can treat an
17
+ * explicitly named id as an authoritative pin (the policy map alone loses the
18
+ * distinction between "named on the CLI" and "evolvable via config default").
19
+ */
20
+ export function explicitTargetIds(csv) {
21
+ return parseIds(csv).ids;
22
+ }
14
23
  /**
15
24
  * Build the effective policy from config + CLI flags. Pure; no I/O.
16
25
  */
@@ -9,7 +9,10 @@ Preview-only is the bare-CLI default and the explicit opt-out: run \`synergyspec
9
9
  **Default Mode: Autonomous self-evolution**
10
10
 
11
11
  - After reviewing the change, you DO the evolution. Do not stop at a report, and do not ask permission.
12
- - Safety is AUTOMATED, not human-gated: a canonical file is promoted ONLY when the change's test evidence is OBSERVED green (the CLI verifies the ACTUAL test run from the session trajectory, not just the authored \`test-report.md\`), the static gate passes, the target is evolvable under the per-target switch, and a rollback snapshot is taken. If evidence is missing or red, evolution stops after gating and nothing canonical is written — that is the safety floor working, not a failure; surface it and move on.
12
+ - Safety is AUTOMATED, not human-gated: a canonical file is promoted ONLY when the change's test evidence is OBSERVED green (the CLI verifies the ACTUAL test run from the session trajectory, not just the authored \`test-report.md\`), the static gate passes, the target is evolvable under the per-target switch, and a rollback snapshot is taken.
13
+ - When nothing canonical is written, CLASSIFY why before moving on — do not blanket-archive every no-op as "safety working":
14
+ - **(a) SAFE refusal (expected, not a bug):** evidence is missing or red, the target is frozen, or the static gate failed on real grounds. The floor refused to evolve on unverified / failing / out-of-scope edits. State the reason in the Evolution Result and move on.
15
+ - **(b) DEFECT (a tool bug to SURFACE, not archive over):** the evolution target could not be RESOLVED or BOUND (an \`evolution-target-unresolved\` observation in the learn output, or a preview target with \`targetId: null\` / \`needsDisambiguation: true\` that still will not bind after you name one concrete \`--evolve-target\`), or promotion failed for a reason that is NOT about evidence / freezing / scope. Nothing was written because the CLI COULD NOT act — not because it correctly declined. Do NOT record this as "safety working": surface it as an unresolved issue (keep an \`incident\` memory entry), name the target id that would not bind, and flag it for a fix. \`synergyspec-selfevolving status\` prints the machine-written \`Evolution:\` outcome — do not contradict it in free text.
13
16
  - Frozen gate-defining / oracle files (the gen-test/run-test oracle, schema contracts you were not asked to evolve) are NEVER touched — the CLI rejects any such edit.
14
17
 
15
18
  This run also produces neutral \`observations\` in the JSON output (reflection signals). During autonomous evolution learn persists derived evolution hints to \`.synergyspec-selfevolving/learn-handoffs/<change>/<timestamp>/hints.json\`; you then author the edit and promote it via the \`self-evolution evolve-from-edits\` command in the evolve step. The \`--agent\` flag (a headless \`claude -p\` proposer) is a cron/CI fallback ONLY — never use it when you are the running agent, and never assume \`claude\` exists on a non-Claude host.
@@ -110,7 +113,7 @@ This run also produces neutral \`observations\` in the JSON output (reflection s
110
113
 
111
114
  Unless \`--preview\` was requested, apply the learn writes directly — do not ask which to apply:
112
115
  - write the learn report (\`synergyspec-selfevolving/changes/<name>/learn-report.md\`);
113
- - add the approved keep memory entries via \`synergyspec-selfevolving memory add\` (skip report-only / reject candidates);
116
+ - the approved keep memory entries are written FOR you by this skill's \`learn --apply --yes\` run — it stamps them with the learn-candidate tags + \`synergyspec-selfevolving-learn\` provenance. Do NOT also hand-write them with a bare \`synergyspec-selfevolving memory add\` (that loses the provenance/tag set and double-writes); reserve \`memory add\` for ad-hoc notes that are deliberately NOT learn candidates (report-only / reject candidates stay out of memory either way);
114
117
  - persist evolution hints: \`synergyspec-selfevolving learn "<name>" --persist-hints\` (this writes the hints.json you use in the evolve step).
115
118
 
116
119
  \`--preview\` is the only mode that skips these writes.
@@ -119,7 +122,7 @@ This run also produces neutral \`observations\` in the JSON output (reflection s
119
122
 
120
123
  If applying a report, write \`synergyspec-selfevolving/changes/<name>/learn-report.md\` with the preview content plus an "Applied Writes" section.
121
124
 
122
- If applying memory entries, use \`synergyspec-selfevolving memory add\` with:
125
+ If applying memory entries MANUALLY (only when you did NOT run \`learn --apply\`, which already writes them with these tags), use \`synergyspec-selfevolving memory add\` with:
123
126
  - \`--type workflow\` for reusable workflow lessons
124
127
  - \`--type incident\` for problems to avoid
125
128
  - \`--tag synspec-learn\`
@@ -133,9 +136,11 @@ This run also produces neutral \`observations\` in the JSON output (reflection s
133
136
 
134
137
  This is the close-the-loop step: you author a concrete improvement to a canonical prompt/template and promote it onto the LOCAL installed file — no rebuild, no republish, no confirmation, no \`claude -p\`.
135
138
 
136
- a. **Pick the concrete target + its local file.** From the "Skill/Template Optimization Preview" take the canonical target id and its resolved LOCAL file path (e.g. \`artifact-template:design\` → \`synergyspec-selfevolving/schemas/spec-driven/templates/design.md\`). If the preview shows a kind-only \`:unspecified\` target, choose the concrete target yourself from your analysis and pass it explicitly with \`--evolve-target\` (e.g. \`--evolve-target artifact-template:design\`).
139
+ a. **Pick the concrete target + its local file.** From the "Skill/Template Optimization Preview" take the canonical target id and its resolved LOCAL file path (e.g. \`artifact-template:design\` → \`synergyspec-selfevolving/schemas/spec-driven/templates/design.md\`). If the preview marks a target unbindable / needs-disambiguation (\`targetId: null\`, \`needsDisambiguation: true\`, formerly shown as \`:unspecified\`), choose ONE concrete id from its \`candidateTargetIds\` and pass it explicitly with \`--evolve-target\` (e.g. \`--evolve-target artifact-template:design\`); then re-run the preview to confirm it now binds. If it STILL will not bind after you name a single concrete \`--evolve-target\`, that is the case-(b) DEFECT above (\`evolution-target-unresolved\`) — surface it and stop; do NOT hand-edit the file to work around it.
137
140
 
138
- b. **Author the edit yourself.** Reason about the exact prompt/template gap that caused the missed evidence (e.g. the design step missed a stdlib/API-shape compatibility check), then READ that local file and write its FULL improved contents. Keep the change minimal and targeted; never touch frozen oracle files.
141
+ b. **Author the edit yourself.** Reason about the exact prompt/template gap that caused the missed evidence (e.g. the design step missed a stdlib/API-shape compatibility check), then READ the LOCAL file the preview's \`localFiles\` resolves to and write its FULL improved contents. Keep the change minimal and targeted; never touch frozen oracle files.
142
+
143
+ Author against the path the preview gives you in \`localFiles\` (project-local). For an artifact-template / schema target on the FIRST evolution that project-local base may not exist on disk yet — the preview resolves the path read-only, and \`evolve-from-edits\` MATERIALIZES the canonical default into it (project-local override → user override → packaged default) when you promote. So if reading \`localFiles\` returns "not found", author your full new file against the canonical default content (the same base the CLI will materialize), not against a global copy. Do NOT go hunting in the GLOBAL npm install for the base (e.g. \`npm root -g\` → \`…/AppData/Roaming/npm/node_modules/synergyspec-selfevolving/schemas/…\`), and never edit anything under the global install — the materialize + promote writes land project-local under the repo (the promote write is guarded by an explicit within-repo assertion). If \`localFiles\` is empty, that target has no user-editable local surface here; treat it as the case-(b) DEFECT, not a reason to reach outside the repo.
139
144
 
140
145
  c. **Promote it in one non-interactive command** (validates → gates → observed-verified → promotes onto the local file):
141
146
  \`\`\`bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synergyspec-selfevolving",
3
- "version": "1.1.10",
3
+ "version": "1.1.12",
4
4
  "description": "AI-native system for spec-driven development",
5
5
  "keywords": [
6
6
  "synergyspec-selfevolving",
@@ -37,6 +37,7 @@
37
37
  "schemas",
38
38
  "scripts/postinstall.js",
39
39
  "scripts/nl2repo_synergyspec-selfevolving_wrapper.py",
40
+ "scripts/code-health.py",
40
41
  "!dist/**/*.test.js",
41
42
  "!dist/**/__tests__",
42
43
  "!dist/**/*.map"