@wooojin/forgen 0.3.0 → 0.3.1

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to forgen will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.3.1] - 2026-04-16
9
+
10
+ ### Added — Self-Evolving Harness (inspired by Stanford meta-harness)
11
+
12
+ Three-phase evolution loop around the existing compound solution store:
13
+
14
+ **Phase 1 — Fitness Loop (Select axis):**
15
+ - `solution-outcomes`: per-session inject→outcome event log (accept/correct/error/unknown) with fail-open semantics; attribution through solution-injector (appendPending/flushAccept), correction-record MCP (attributeCorrection), and post-tool-failure hook (attributeError).
16
+ - `solution-fitness`: Laplace-smoothed acceptance ratio × log(1+injected) confidence. State classification: draft / active / champion / underperform. No auto-delete — population-relative thresholds only.
17
+ - `solution-quarantine`: malformed frontmatter no longer silently dropped — invalid files surface in `~/.forgen/state/solution-quarantine.jsonl` with actionable diagnostics; `listQuarantined` / `pruneQuarantine` helpers.
18
+ - `solution-fixup`: schema migration for legacy defects (missing `extractedBy`, missing `evidence` block, missing `supersedes`). Applied to the live install, this recovered 5 dead solutions and one was injected on the next matching prompt.
19
+
20
+ **Phase 4 — Self-Evolution (Propose + Select axes):**
21
+ - `solution-weakness`: structured discovery report from four detectors — under-served tags (correction evidence without a matching champion), conflict clusters, dead corners (injected=0 with unique tags), volatile solutions (accept-rate shift >0.3).
22
+ - `ch-solution-evolver` agent: Opus proposer, Bash-disabled, emits exactly 3 novel candidates into `~/.forgen/lab/candidates/` with 30%-80% tag overlap gate and self-critique novelty check.
23
+ - Candidate cold-start bonus: solutions with `status: candidate` get confidence × 1.3 so they reach enough injections to accumulate fitness. Auto-promotes to `verified` at 5 injections; bonus disappears naturally.
24
+ - Candidate lifecycle: `promoteCandidate` validates schema + refuses name collisions before moving files from lab to `me/solutions`. `rollbackSince` archives every `source: evolved` solution newer than a cutoff to `~/.forgen/lab/archived/rollback-{ts}/` (never deletes — always recoverable).
25
+
26
+ **CLI surface:**
27
+ - `forgen learn fix-up [--apply]` — dry-run repair of malformed solutions.
28
+ - `forgen learn quarantine [--prune]` — show / clean dropped solutions.
29
+ - `forgen learn fitness [--json]` — per-solution fitness table.
30
+ - `forgen learn evolve [--save]` — weakness report + proposer hint.
31
+ - `forgen learn evolve --promote --list` / `--promote <name>` — candidate promotion.
32
+ - `forgen learn evolve --rollback <epoch-ms-or-ISO>` — time-bounded rollback.
33
+ - Dashboard gains a 🎯 Solution Fitness panel (state distribution + top-3).
34
+
35
+ **Dogfood evidence:** the full pipeline was exercised end-to-end — weakness report → evolver-agent proposal → schema validation → promotion → cold-start-boosted match (relevance 0.78) → injection counter increment.
36
+
37
+ ### Documentation
38
+ - `docs/design-solution-evolution.md` — Phase 4 design spec with open questions, prerequisites, and rollout plan.
39
+
8
40
  ## [0.3.0] - 2026-04-15
9
41
 
10
42
  ### BREAKING
@@ -0,0 +1,115 @@
1
+ ---
2
+ name: ch-solution-evolver
3
+ description: Propose 3 novel compound-solution candidates from a weakness report (Phase 4 evolution loop)
4
+ model: opus
5
+ maxTurns: 10
6
+ color: cyan
7
+ disallowedTools:
8
+ - Bash
9
+ ---
10
+
11
+ <!-- forgen-managed -->
12
+
13
+ <Agent_Prompt>
14
+
15
+ # Solution Evolver — compound-solution 후보 제안자
16
+
17
+ "기존에 통한 패턴은 보존한다. 부족한 영역만 새 패턴을 심는다."
18
+
19
+ 당신은 forgen 하네스의 **진화 엔진**입니다. 입력으로 주어진 weakness report를 읽고, **정확히 3개**의 compound-solution 후보를 제안합니다.
20
+
21
+ <Success_Criteria>
22
+ - 정확히 3개 후보를 제안 (더 적거나 많으면 실패)
23
+ - 각 후보는 weakness report의 under-served tags 또는 conflict cluster 중 하나를 타깃
24
+ - 각 후보는 기존 champion과 **tag overlap 30~80%** — 완전 중복도 완전 무관도 거부
25
+ - 본문 길이 ≤ 1200 chars (토큰 비용 제약)
26
+ - 각 후보에 "왜 novel한가"를 한 줄로 기재
27
+ </Success_Criteria>
28
+
29
+ <Failure_Modes_To_Avoid>
30
+ - 파라미터만 다른 변형 (예: "TDD를 더 엄격히" — 진짜 novel이 아님)
31
+ - 같은 이름 재사용 (collision 유발)
32
+ - 기존 champion을 직접 수정 제안 (stable한 건 건드리지 않음)
33
+ - 도메인 specific 하드코딩 (예: "forgen 코드 베이스 전용" → 일반화 불가)
34
+ - dataset/언어 specific (예: "Python에서만" — 범용성 훼손)
35
+ </Failure_Modes_To_Avoid>
36
+
37
+ ## 입력 형식
38
+
39
+ 호출자가 아래를 제공합니다:
40
+
41
+ 1. **Weakness Report** JSON (`~/.forgen/state/weakness-report-{ts}.json`)
42
+ - `under_served_tags`: correction은 많은데 champion이 없는 태그
43
+ - `conflict_clusters`: 같은 태그에서 champion/underperform 공존 영역
44
+ - `dead_corners`: 아예 매칭 안 되는 고립 태그
45
+ 2. **기존 champion 솔루션** 상위 5개 (참고 맥락)
46
+
47
+ ## 출력 형식
48
+
49
+ 각 후보를 **파일로 직접 작성**합니다. 대상 디렉토리: `~/.forgen/lab/candidates/`.
50
+ 파일명은 `evolved-{slug}.md` 형식 (slug는 후보 이름에서 영문 소문자 + 하이픈만).
51
+ 이 디렉토리는 격리된 qurantine 영역으로, 여기 쓴 파일은 매칭에 바로 참여하지 **않습니다**.
52
+ 사용자가 `forgen learn evolve --promote <name>` 을 실행해야 `me/solutions/`로 이동합니다.
53
+
54
+ 파일 구조:
55
+
56
+ ```markdown
57
+ ### Candidate 1: {slug}
58
+ novelty: {한 줄 설명 — 왜 기존과 다른가}
59
+ target_weakness: {under_served_tag | conflict_cluster | dead_corner}
60
+ target_detail: {구체적 약점 레퍼런스}
61
+
62
+ ---
63
+ name: evolved-{slug}
64
+ version: 1
65
+ status: candidate
66
+ confidence: 0.6
67
+ type: pattern
68
+ scope: me
69
+ tags:
70
+ - {tag1}
71
+ - {tag2}
72
+ - ...
73
+ identifiers: []
74
+ created: "YYYY-MM-DD"
75
+ updated: "YYYY-MM-DD"
76
+ supersedes: null
77
+ extractedBy: auto
78
+ source: evolved
79
+ evidence:
80
+ injected: 0
81
+ reflected: 0
82
+ negative: 0
83
+ sessions: 0
84
+ reExtracted: 0
85
+ ---
86
+
87
+ ## Context
88
+ {한두 문장: 언제 이 패턴을 적용하는가}
89
+
90
+ ## Rule
91
+ {핵심 규칙 1~2개, 짧게}
92
+
93
+ ## Anti-pattern
94
+ {이것만은 피하라 1개}
95
+ ```
96
+
97
+ ### Candidate 2, 3도 동일 형식.
98
+
99
+ ## Workflow
100
+
101
+ 1. **Read weakness report** — 어떤 구멍이 큰지 파악 (correction_mentions, dead_corner 크기 순)
102
+ 2. **Read top 5 champions** — 그들의 태그/본문/길이 관찰 (본받을 구조, 중복 피할 영역)
103
+ 3. **Select 3 targets** — 각기 다른 weakness에서 1개씩 (under-served 1 + conflict 1 + dead-corner 1 이상적)
104
+ 4. **Prototype mentally** — 각 후보의 한 줄 핵심 rule이 기존 champion과 실제로 다른지 self-check
105
+ 5. **Emit 3 candidates** — 위 format 준수
106
+
107
+ ## Novelty Gate — Self-critique
108
+
109
+ 제출 전 각 후보에 대해 다음 질문에 답하세요:
110
+
111
+ - 기존 champion 중 tag overlap 50% 이상인 솔루션이 있다면, 이 후보의 **Rule**이 그 champion의 Rule과 **다른 조언**을 하는가? (Yes가 아니면 탈락)
112
+ - 이 후보가 맞출 weakness 타깃이 report에 명시되어 있는가? (없으면 탈락 — 근거 없는 제안 거부)
113
+ - 본문이 1200자를 초과하는가? (초과면 요약)
114
+
115
+ </Agent_Prompt>
package/dist/cli.js CHANGED
@@ -79,6 +79,14 @@ const commands = [
79
79
  await handleDashboard();
80
80
  },
81
81
  },
82
+ {
83
+ name: 'learn',
84
+ description: 'Solution maintenance: fix-up | quarantine | fitness',
85
+ handler: async (args) => {
86
+ const { handleLearn } = await import('./engine/learn-cli.js');
87
+ await handleLearn(args);
88
+ },
89
+ },
82
90
  {
83
91
  name: 'me',
84
92
  description: 'Personal dashboard (→ inspect profile)',
@@ -457,6 +457,50 @@ function renderLearningCurve(data) {
457
457
  ` ${dim('※ compound가 힌트를 제공한 매 1회당 평균 8분 절약 가정')}`,
458
458
  ].join('\n');
459
459
  }
460
+ function renderFitnessSummary() {
461
+ // Lazy import: keep dashboard startup cheap if outcomes are absent.
462
+ let summary;
463
+ try {
464
+ const { computeFitness } = require('../engine/solution-fitness.js');
465
+ const records = computeFitness();
466
+ summary = {
467
+ total: records.length,
468
+ champion: records.filter((r) => r.state === 'champion').length,
469
+ active: records.filter((r) => r.state === 'active').length,
470
+ underperform: records.filter((r) => r.state === 'underperform').length,
471
+ draft: records.filter((r) => r.state === 'draft').length,
472
+ top: records.slice(0, 3).map((r) => ({ name: r.solution, fitness: r.fitness, state: r.state })),
473
+ };
474
+ }
475
+ catch {
476
+ summary = { total: 0, champion: 0, active: 0, underperform: 0, draft: 0, top: [] };
477
+ }
478
+ if (summary.total === 0) {
479
+ return [
480
+ ` ${bold('🎯 Solution Fitness / 솔루션 적합도')}`,
481
+ ``,
482
+ ` ${dim('아직 outcome 이벤트 데이터 없음.')}`,
483
+ ` ${dim('솔루션 주입이 누적되면 자동으로 채워집니다.')}`,
484
+ ].join('\n');
485
+ }
486
+ const topLines = summary.top.length > 0
487
+ ? summary.top.map((t) => {
488
+ const icon = t.state === 'champion' ? green('●') : t.state === 'underperform' ? red('●') : cyan('●');
489
+ return ` ${icon} ${t.name.slice(0, 44).padEnd(44)} ${t.fitness.toFixed(2)} (${t.state})`;
490
+ }).join('\n')
491
+ : ` ${dim('(top 3 없음)')}`;
492
+ return [
493
+ ` ${bold('🎯 Solution Fitness / 솔루션 적합도')}`,
494
+ ``,
495
+ ` 상태 분포 (총 ${summary.total}개):`,
496
+ ` ${green('champion')}: ${summary.champion} ${cyan('active')}: ${summary.active} ${red('underperform')}: ${summary.underperform} ${dim('draft')}: ${summary.draft}`,
497
+ ``,
498
+ ` Top 3 by fitness:`,
499
+ topLines,
500
+ ``,
501
+ ` ${dim('상세: forgen learn fitness')}`,
502
+ ].join('\n');
503
+ }
460
504
  export function renderDashboard() {
461
505
  const knowledge = collectKnowledgeOverview();
462
506
  const injection = collectInjectionActivity();
@@ -474,6 +518,8 @@ export function renderDashboard() {
474
518
  '',
475
519
  renderLearningCurve(learning),
476
520
  divider,
521
+ renderFitnessSummary(),
522
+ divider,
477
523
  renderKnowledgeOverview(knowledge),
478
524
  divider,
479
525
  renderInjectionActivity(injection),
@@ -44,6 +44,31 @@ export declare const STATE_DIR: string;
44
44
  * `src/engine/match-eval-log.ts`; never on the hook critical path.
45
45
  */
46
46
  export declare const MATCH_EVAL_LOG_PATH: string;
47
+ /**
48
+ * ~/.forgen/state/solution-quarantine.jsonl — JSONL log of solution files
49
+ * dropped during index build due to malformed frontmatter. Append-only,
50
+ * dedupe-by-path. Used by `forgen doctor` to surface dead solutions that
51
+ * would otherwise vanish silently (see `diagnoseFrontmatter`).
52
+ */
53
+ export declare const SOLUTION_QUARANTINE_PATH: string;
54
+ /**
55
+ * ~/.forgen/state/outcomes/ — per-session JSONL logs of solution inject →
56
+ * outcome events (accept / correct / error / unknown). Written by the
57
+ * solution-outcome-tracker hook. One file per session for write-safety
58
+ * under concurrent sessions. Consumers aggregate across files to compute
59
+ * fitness (see `solution-fitness.ts`).
60
+ */
61
+ export declare const OUTCOMES_DIR: string;
62
+ /**
63
+ * ~/.forgen/lab/candidates/ — Phase 4 quarantine zone for evolver-agent
64
+ * proposals before they enter the live solution index. The evolver writes
65
+ * here; promotion and rollback commands move files out (to ME_SOLUTIONS
66
+ * or to `lab/archived-{ts}/`). Keeping candidates isolated means a
67
+ * runaway agent cannot silently poison the match pool.
68
+ */
69
+ export declare const CANDIDATES_DIR: string;
70
+ /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
71
+ export declare const ARCHIVED_DIR: string;
47
72
  /** ~/.forgen/sessions/ — 세션 로그 */
48
73
  export declare const SESSIONS_DIR: string;
49
74
  /** ~/.forgen/config.json — 글로벌 설정 */
@@ -47,6 +47,31 @@ export const STATE_DIR = path.join(FORGEN_HOME, 'state');
47
47
  * `src/engine/match-eval-log.ts`; never on the hook critical path.
48
48
  */
49
49
  export const MATCH_EVAL_LOG_PATH = path.join(STATE_DIR, 'match-eval-log.jsonl');
50
+ /**
51
+ * ~/.forgen/state/solution-quarantine.jsonl — JSONL log of solution files
52
+ * dropped during index build due to malformed frontmatter. Append-only,
53
+ * dedupe-by-path. Used by `forgen doctor` to surface dead solutions that
54
+ * would otherwise vanish silently (see `diagnoseFrontmatter`).
55
+ */
56
+ export const SOLUTION_QUARANTINE_PATH = path.join(STATE_DIR, 'solution-quarantine.jsonl');
57
+ /**
58
+ * ~/.forgen/state/outcomes/ — per-session JSONL logs of solution inject →
59
+ * outcome events (accept / correct / error / unknown). Written by the
60
+ * solution-outcome-tracker hook. One file per session for write-safety
61
+ * under concurrent sessions. Consumers aggregate across files to compute
62
+ * fitness (see `solution-fitness.ts`).
63
+ */
64
+ export const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
65
+ /**
66
+ * ~/.forgen/lab/candidates/ — Phase 4 quarantine zone for evolver-agent
67
+ * proposals before they enter the live solution index. The evolver writes
68
+ * here; promotion and rollback commands move files out (to ME_SOLUTIONS
69
+ * or to `lab/archived-{ts}/`). Keeping candidates isolated means a
70
+ * runaway agent cannot silently poison the match pool.
71
+ */
72
+ export const CANDIDATES_DIR = path.join(FORGEN_HOME, 'lab', 'candidates');
73
+ /** ~/.forgen/lab/archived/ — rollback destination for evolved solutions. */
74
+ export const ARCHIVED_DIR = path.join(FORGEN_HOME, 'lab', 'archived');
50
75
  /** ~/.forgen/sessions/ — 세션 로그 */
51
76
  export const SESSIONS_DIR = path.join(FORGEN_HOME, 'sessions');
52
77
  /** ~/.forgen/config.json — 글로벌 설정 */
@@ -0,0 +1 @@
1
+ export declare function handleLearn(args: string[]): Promise<void>;
@@ -0,0 +1,182 @@
1
+ import * as path from 'node:path';
2
+ import * as os from 'node:os';
3
+ import { fixupSolutions } from './solution-fixup.js';
4
+ import { listQuarantined, pruneQuarantine } from './solution-quarantine.js';
5
+ import { computeFitness } from './solution-fitness.js';
6
+ import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
7
+ import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
8
+ const ME_SOLUTIONS = path.join(os.homedir(), '.forgen', 'me', 'solutions');
9
+ export async function handleLearn(args) {
10
+ const sub = args[0];
11
+ if (sub === 'fix-up')
12
+ return runFixUp(args.slice(1));
13
+ if (sub === 'quarantine')
14
+ return runQuarantine(args.slice(1));
15
+ if (sub === 'fitness')
16
+ return runFitness(args.slice(1));
17
+ if (sub === 'evolve')
18
+ return runEvolve(args.slice(1));
19
+ printUsage();
20
+ }
21
+ function printUsage() {
22
+ console.log(`
23
+ forgen learn — solution index maintenance and fitness
24
+
25
+ Usage:
26
+ forgen learn fix-up [--apply] Repair malformed solution frontmatter (dry-run by default)
27
+ forgen learn quarantine [--prune] Show files dropped by the index; --prune removes fixed/deleted
28
+ forgen learn fitness [--json] Show per-solution fitness (accept/correct/error ratios)
29
+ forgen learn evolve [--save|--rollback <ts>|--promote <name>]
30
+ Phase 4 evolution: weakness report + candidate lifecycle
31
+ `);
32
+ }
33
+ function runFixUp(args) {
34
+ const apply = args.includes('--apply');
35
+ const result = fixupSolutions(ME_SOLUTIONS, { dryRun: !apply });
36
+ console.log(`\n ${apply ? 'Applied' : 'Dry-run'}: scanned=${result.scanned} fixed=${result.fixed} untouched=${result.untouched} unfixable=${result.unfixable}`);
37
+ for (const rep of result.reports) {
38
+ const rel = path.basename(rep.path);
39
+ if (rep.changed && rep.remaining_errors.length === 0) {
40
+ console.log(` ✓ ${rel} — add: ${rep.added.join(', ')}`);
41
+ }
42
+ else {
43
+ console.log(` ✗ ${rel} — remaining: ${rep.remaining_errors.join('; ')}`);
44
+ }
45
+ }
46
+ if (!apply && result.fixed > 0) {
47
+ console.log(`\n Re-run with --apply to write changes.\n`);
48
+ }
49
+ else if (apply && result.fixed > 0) {
50
+ console.log(`\n Consider: forgen learn quarantine --prune\n`);
51
+ }
52
+ else {
53
+ console.log('');
54
+ }
55
+ }
56
+ function runQuarantine(args) {
57
+ if (args.includes('--prune')) {
58
+ const result = pruneQuarantine();
59
+ console.log(`\n Pruned: removed=${result.removed} kept=${result.kept}\n`);
60
+ return;
61
+ }
62
+ const entries = listQuarantined();
63
+ if (entries.length === 0) {
64
+ console.log(`\n No quarantined solutions. ✓\n`);
65
+ return;
66
+ }
67
+ console.log(`\n Quarantined solutions (${entries.length}):\n`);
68
+ for (const e of entries) {
69
+ const rel = path.basename(e.path);
70
+ console.log(` ${rel} (${e.at})`);
71
+ for (const err of e.errors)
72
+ console.log(` - ${err}`);
73
+ }
74
+ console.log(`\n Fix: forgen learn fix-up --apply → then: forgen learn quarantine --prune\n`);
75
+ }
76
+ function runFitness(args) {
77
+ const records = computeFitness();
78
+ if (args.includes('--json')) {
79
+ console.log(JSON.stringify(records, null, 2));
80
+ return;
81
+ }
82
+ if (records.length === 0) {
83
+ console.log(`\n No outcome events yet. Fitness becomes available after solution injections accumulate.\n`);
84
+ return;
85
+ }
86
+ console.log(`\n Solution Fitness (${records.length} tracked):\n`);
87
+ console.log(` ${'name'.padEnd(48)} ${'state'.padEnd(14)} ${'inj'.padStart(4)} ${'acc/cor/err'.padStart(11)} ${'fit'.padStart(6)}`);
88
+ console.log(` ${'-'.repeat(48)} ${'-'.repeat(14)} ${'-'.repeat(4)} ${'-'.repeat(11)} ${'-'.repeat(6)}`);
89
+ for (const r of records) {
90
+ const name = r.solution.length > 47 ? r.solution.slice(0, 45) + '..' : r.solution;
91
+ const acr = `${r.accepted}/${r.corrected}/${r.errored}`;
92
+ console.log(` ${name.padEnd(48)} ${r.state.padEnd(14)} ${String(r.injected).padStart(4)} ${acr.padStart(11)} ${r.fitness.toFixed(2).padStart(6)}`);
93
+ }
94
+ console.log('');
95
+ }
96
+ function runEvolve(args) {
97
+ const save = args.includes('--save');
98
+ const rollbackIdx = args.indexOf('--rollback');
99
+ const promoteIdx = args.indexOf('--promote');
100
+ if (rollbackIdx >= 0 && args[rollbackIdx + 1]) {
101
+ return runEvolveRollback(args[rollbackIdx + 1]);
102
+ }
103
+ if (promoteIdx >= 0 && args[promoteIdx + 1]) {
104
+ return runEvolvePromote(args[promoteIdx + 1]);
105
+ }
106
+ // Default: generate + optionally save weakness report, print proposer
107
+ // brief so the user can hand it to the ch-solution-evolver agent.
108
+ const report = buildWeaknessReport();
109
+ console.log(`\n Weakness Report @ ${report.generated_at}\n`);
110
+ console.log(` Population: ${report.population.total} solutions`);
111
+ console.log(` champion=${report.population.champion} active=${report.population.active} underperform=${report.population.underperform} draft=${report.population.draft}\n`);
112
+ renderTagRow('Under-served tags', report.under_served_tags.map((t) => `${t.tag} (×${t.correction_mentions})`));
113
+ renderTagRow('Conflict clusters', report.conflict_clusters.map((c) => `${c.shared_tags.slice(0, 2).join('+')}: ${c.champion.name} vs ${c.underperform.name}`));
114
+ renderTagRow('Dead corners', report.dead_corners.map((d) => `${d.solution}: [${d.unique_tags.slice(0, 2).join(',')}]`));
115
+ renderTagRow('Volatile', report.volatile.map((v) => `${v.solution} Δ${v.delta}`));
116
+ if (save) {
117
+ const p = saveWeaknessReport(report);
118
+ console.log(`\n Saved: ${p}`);
119
+ console.log(` Next: invoke the ch-solution-evolver agent with this report, then run:`);
120
+ console.log(` forgen learn evolve --promote <candidate-name> # accept one of the 3 proposals`);
121
+ console.log(` forgen learn evolve --rollback ${Date.now()} # undo this week's candidates`);
122
+ console.log('');
123
+ }
124
+ else {
125
+ console.log(`\n Dry-run. Re-run with --save to persist this report and proceed to proposer.\n`);
126
+ }
127
+ }
128
+ function renderTagRow(label, items) {
129
+ if (items.length === 0) {
130
+ console.log(` ${label}: (none)`);
131
+ return;
132
+ }
133
+ console.log(` ${label}:`);
134
+ for (const item of items.slice(0, 5))
135
+ console.log(` - ${item}`);
136
+ }
137
+ function runEvolveRollback(ts) {
138
+ const epochMs = /^\d+$/.test(ts) ? Number(ts) : Date.parse(ts);
139
+ if (!Number.isFinite(epochMs)) {
140
+ console.log(`\n Invalid timestamp: ${ts}. Use epoch ms or ISO-8601.\n`);
141
+ return;
142
+ }
143
+ const result = rollbackSince(epochMs);
144
+ console.log(`\n Rollback since ${new Date(epochMs).toISOString()}:`);
145
+ if (result.archived.length === 0) {
146
+ console.log(` (no evolved solutions newer than cutoff)\n`);
147
+ return;
148
+ }
149
+ console.log(` Archived ${result.archived.length} file(s) → ${result.archive_dir}`);
150
+ for (const p of result.archived)
151
+ console.log(` - ${path.basename(p)}`);
152
+ if (result.errors.length > 0) {
153
+ console.log(` Errors:`);
154
+ for (const e of result.errors)
155
+ console.log(` ! ${e}`);
156
+ }
157
+ console.log('');
158
+ }
159
+ function runEvolvePromote(candidateNameOrList) {
160
+ if (candidateNameOrList === '--list' || candidateNameOrList === 'list') {
161
+ const found = listCandidates();
162
+ if (found.length === 0) {
163
+ console.log(`\n No pending candidates in ~/.forgen/lab/candidates/\n`);
164
+ return;
165
+ }
166
+ console.log(`\n Pending candidates (${found.length}):`);
167
+ for (const p of found)
168
+ console.log(` - ${path.basename(p, '.md')}`);
169
+ console.log(`\n Promote one: forgen learn evolve --promote <name>\n`);
170
+ return;
171
+ }
172
+ const result = promoteCandidate(candidateNameOrList);
173
+ if (result.ok) {
174
+ console.log(`\n ✓ Promoted: ${path.basename(result.dest)}`);
175
+ console.log(` from: ${result.source}`);
176
+ console.log(` to: ${result.dest}`);
177
+ console.log(` Cold-start bonus active until 5 injections accumulate (auto-promotes to verified).\n`);
178
+ }
179
+ else {
180
+ console.log(`\n ✗ Promotion refused: ${result.reason}\n`);
181
+ }
182
+ }
@@ -0,0 +1,30 @@
1
+ export interface PromoteResult {
2
+ ok: boolean;
3
+ source?: string;
4
+ dest?: string;
5
+ reason?: string;
6
+ }
7
+ export interface RollbackResult {
8
+ archived: string[];
9
+ archive_dir: string;
10
+ errors: string[];
11
+ }
12
+ export declare function listCandidates(): string[];
13
+ /**
14
+ * Move one candidate file from lab/candidates/ to me/solutions/ after
15
+ * schema + ownership checks. Refuses to overwrite an existing solution.
16
+ * Returns `{ok:false, reason}` for any precondition failure so the CLI
17
+ * can report exactly why promotion was rejected.
18
+ */
19
+ export declare function promoteCandidate(nameOrPath: string): PromoteResult;
20
+ /**
21
+ * Archive evolved-* solutions created at-or-after the given epoch ms.
22
+ * Looks in ME_SOLUTIONS first (live, promoted candidates) then in
23
+ * CANDIDATES_DIR (unpromoted). Archive is a timestamp-suffixed
24
+ * directory so concurrent rollbacks don't clobber each other.
25
+ *
26
+ * "evolved" is identified by `source: evolved` in frontmatter; we
27
+ * deliberately do NOT use filename prefix so a manually-renamed
28
+ * evolved solution can still be rolled back.
29
+ */
30
+ export declare function rollbackSince(epochMs: number): RollbackResult;
@@ -0,0 +1,124 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { ARCHIVED_DIR, CANDIDATES_DIR, ME_SOLUTIONS } from '../core/paths.js';
4
+ import { parseFrontmatterOnly } from './solution-format.js';
5
+ import { diagnoseFromRawContent } from './solution-quarantine.js';
6
+ import { createLogger } from '../core/logger.js';
7
+ const log = createLogger('solution-candidate');
8
+ export function listCandidates() {
9
+ if (!fs.existsSync(CANDIDATES_DIR))
10
+ return [];
11
+ return fs
12
+ .readdirSync(CANDIDATES_DIR)
13
+ .filter((f) => f.endsWith('.md'))
14
+ .map((f) => path.join(CANDIDATES_DIR, f));
15
+ }
16
+ /**
17
+ * Move one candidate file from lab/candidates/ to me/solutions/ after
18
+ * schema + ownership checks. Refuses to overwrite an existing solution.
19
+ * Returns `{ok:false, reason}` for any precondition failure so the CLI
20
+ * can report exactly why promotion was rejected.
21
+ */
22
+ export function promoteCandidate(nameOrPath) {
23
+ const source = resolveCandidatePath(nameOrPath);
24
+ if (!source)
25
+ return { ok: false, reason: `candidate not found: ${nameOrPath}` };
26
+ const content = fs.readFileSync(source, 'utf-8');
27
+ const errors = diagnoseFromRawContent(content);
28
+ if (errors.length > 0) {
29
+ return { ok: false, source, reason: `schema errors: ${errors.join('; ')}` };
30
+ }
31
+ const fm = parseFrontmatterOnly(content);
32
+ if (!fm)
33
+ return { ok: false, source, reason: 'frontmatter parse failed post-diagnose (unexpected)' };
34
+ if (fm.status !== 'candidate') {
35
+ return { ok: false, source, reason: `status must be 'candidate', got '${fm.status}'` };
36
+ }
37
+ if (fm.extractedBy !== 'auto') {
38
+ return { ok: false, source, reason: `extractedBy must be 'auto' (evolved proposals)` };
39
+ }
40
+ const dest = path.join(ME_SOLUTIONS, `${fm.name}.md`);
41
+ if (fs.existsSync(dest)) {
42
+ return { ok: false, source, reason: `name collision: ${fm.name} already exists in me/solutions` };
43
+ }
44
+ fs.mkdirSync(ME_SOLUTIONS, { recursive: true });
45
+ try {
46
+ fs.renameSync(source, dest);
47
+ }
48
+ catch {
49
+ // renameSync fails across filesystems — fall back to copy+unlink.
50
+ fs.copyFileSync(source, dest);
51
+ try {
52
+ fs.unlinkSync(source);
53
+ }
54
+ catch { /* ignore */ }
55
+ }
56
+ log.debug(`promoted: ${fm.name}`);
57
+ return { ok: true, source, dest };
58
+ }
59
+ /**
60
+ * Archive evolved-* solutions created at-or-after the given epoch ms.
61
+ * Looks in ME_SOLUTIONS first (live, promoted candidates) then in
62
+ * CANDIDATES_DIR (unpromoted). Archive is a timestamp-suffixed
63
+ * directory so concurrent rollbacks don't clobber each other.
64
+ *
65
+ * "evolved" is identified by `source: evolved` in frontmatter; we
66
+ * deliberately do NOT use filename prefix so a manually-renamed
67
+ * evolved solution can still be rolled back.
68
+ */
69
+ export function rollbackSince(epochMs) {
70
+ const archiveDir = path.join(ARCHIVED_DIR, `rollback-${Date.now()}`);
71
+ const archived = [];
72
+ const errors = [];
73
+ const dirs = [ME_SOLUTIONS, CANDIDATES_DIR];
74
+ for (const dir of dirs) {
75
+ if (!fs.existsSync(dir))
76
+ continue;
77
+ for (const file of fs.readdirSync(dir)) {
78
+ if (!file.endsWith('.md'))
79
+ continue;
80
+ const filePath = path.join(dir, file);
81
+ let content;
82
+ try {
83
+ content = fs.readFileSync(filePath, 'utf-8');
84
+ }
85
+ catch (e) {
86
+ errors.push(`read ${filePath}: ${errMsg(e)}`);
87
+ continue;
88
+ }
89
+ const fm = parseFrontmatterOnly(content);
90
+ if (!fm)
91
+ continue;
92
+ // `source` is an optional free-form field written by the evolver.
93
+ const source = fm.source;
94
+ if (source !== 'evolved')
95
+ continue;
96
+ // `created` is YAML-formatted date string. If parsing fails or the
97
+ // created date is older than epochMs, leave the file in place.
98
+ const createdMs = Date.parse(fm.created);
99
+ if (Number.isFinite(createdMs) && createdMs < epochMs)
100
+ continue;
101
+ try {
102
+ fs.mkdirSync(archiveDir, { recursive: true });
103
+ const destName = path.basename(dir) + '__' + file;
104
+ fs.renameSync(filePath, path.join(archiveDir, destName));
105
+ archived.push(filePath);
106
+ }
107
+ catch (e) {
108
+ errors.push(`archive ${filePath}: ${errMsg(e)}`);
109
+ }
110
+ }
111
+ }
112
+ return { archived, archive_dir: archiveDir, errors };
113
+ }
114
+ function resolveCandidatePath(nameOrPath) {
115
+ if (fs.existsSync(nameOrPath))
116
+ return nameOrPath;
117
+ const byBasename = path.join(CANDIDATES_DIR, nameOrPath.endsWith('.md') ? nameOrPath : `${nameOrPath}.md`);
118
+ if (fs.existsSync(byBasename))
119
+ return byBasename;
120
+ return null;
121
+ }
122
+ function errMsg(e) {
123
+ return e instanceof Error ? e.message : String(e);
124
+ }