@wooojin/forgen 0.3.0 → 0.3.2

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 (86) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +132 -0
  3. package/README.ja.md +29 -0
  4. package/README.ko.md +29 -0
  5. package/README.md +36 -3
  6. package/README.zh.md +29 -0
  7. package/agents/solution-evolver.md +115 -0
  8. package/dist/cli.js +11 -3
  9. package/dist/core/auto-compound-runner.js +6 -3
  10. package/dist/core/dashboard.js +57 -4
  11. package/dist/core/doctor.d.ts +6 -1
  12. package/dist/core/doctor.js +21 -1
  13. package/dist/core/global-config.d.ts +2 -2
  14. package/dist/core/global-config.js +6 -14
  15. package/dist/core/harness.d.ts +3 -5
  16. package/dist/core/harness.js +34 -338
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +25 -34
  20. package/dist/core/paths.js +25 -35
  21. package/dist/core/settings-injector.d.ts +13 -0
  22. package/dist/core/settings-injector.js +167 -0
  23. package/dist/core/settings-lock.d.ts +35 -2
  24. package/dist/core/settings-lock.js +65 -7
  25. package/dist/core/spawn.js +100 -39
  26. package/dist/core/state-gc.d.ts +30 -0
  27. package/dist/core/state-gc.js +119 -0
  28. package/dist/core/uninstall.js +12 -4
  29. package/dist/core/v1-bootstrap.js +2 -2
  30. package/dist/engine/compound-cli.d.ts +27 -2
  31. package/dist/engine/compound-cli.js +69 -16
  32. package/dist/engine/compound-export.d.ts +15 -0
  33. package/dist/engine/compound-export.js +32 -5
  34. package/dist/engine/compound-loop.js +3 -2
  35. package/dist/engine/learn-cli.d.ts +1 -0
  36. package/dist/engine/learn-cli.js +234 -0
  37. package/dist/engine/match-eval-log.js +45 -0
  38. package/dist/engine/solution-candidate.d.ts +30 -0
  39. package/dist/engine/solution-candidate.js +124 -0
  40. package/dist/engine/solution-fitness.d.ts +52 -0
  41. package/dist/engine/solution-fitness.js +95 -0
  42. package/dist/engine/solution-fixup.d.ts +30 -0
  43. package/dist/engine/solution-fixup.js +116 -0
  44. package/dist/engine/solution-format.d.ts +8 -2
  45. package/dist/engine/solution-format.js +38 -27
  46. package/dist/engine/solution-index.js +10 -0
  47. package/dist/engine/solution-matcher.d.ts +8 -0
  48. package/dist/engine/solution-matcher.js +27 -1
  49. package/dist/engine/solution-outcomes.d.ts +74 -0
  50. package/dist/engine/solution-outcomes.js +319 -0
  51. package/dist/engine/solution-quarantine.d.ts +36 -0
  52. package/dist/engine/solution-quarantine.js +172 -0
  53. package/dist/engine/solution-weakness.d.ts +45 -0
  54. package/dist/engine/solution-weakness.js +225 -0
  55. package/dist/engine/solution-writer.d.ts +9 -1
  56. package/dist/engine/solution-writer.js +44 -2
  57. package/dist/fgx.js +9 -2
  58. package/dist/forge/cli.js +7 -7
  59. package/dist/hooks/context-guard.js +15 -1
  60. package/dist/hooks/hook-config.d.ts +9 -1
  61. package/dist/hooks/hook-config.js +25 -3
  62. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  63. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  64. package/dist/hooks/notepad-injector.js +6 -3
  65. package/dist/hooks/permission-handler.d.ts +10 -2
  66. package/dist/hooks/permission-handler.js +31 -12
  67. package/dist/hooks/post-tool-failure.js +7 -0
  68. package/dist/hooks/pre-tool-use.js +10 -4
  69. package/dist/hooks/secret-filter.js +6 -0
  70. package/dist/hooks/session-recovery.js +15 -7
  71. package/dist/hooks/shared/hook-response.d.ts +0 -2
  72. package/dist/hooks/shared/hook-response.js +3 -8
  73. package/dist/hooks/shared/hook-timing.js +10 -1
  74. package/dist/hooks/solution-injector.d.ts +21 -0
  75. package/dist/hooks/solution-injector.js +80 -1
  76. package/dist/mcp/solution-reader.d.ts +2 -0
  77. package/dist/mcp/solution-reader.js +28 -1
  78. package/dist/mcp/tools.js +13 -2
  79. package/dist/preset/preset-manager.js +12 -2
  80. package/dist/store/evidence-store.js +5 -5
  81. package/dist/store/profile-store.d.ts +9 -0
  82. package/dist/store/profile-store.js +25 -4
  83. package/dist/store/rule-store.js +8 -8
  84. package/package.json +1 -1
  85. package/plugin.json +7 -2
  86. package/scripts/postinstall.js +52 -5
@@ -0,0 +1,116 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import { DEFAULT_EVIDENCE } from './solution-format.js';
5
+ import { diagnoseFromRawContent } from './solution-quarantine.js';
6
+ import { createLogger } from '../core/logger.js';
7
+ const log = createLogger('solution-fixup');
8
+ /**
9
+ * Attempt to repair known-safe frontmatter defects.
10
+ *
11
+ * Handled defects (pre-0.3.1 schema drift, observed on 5 auto-extracted
12
+ * solutions from 2026-04-10):
13
+ * - `extractedBy` missing → add `extractedBy: auto`
14
+ * - `evidence` block missing → add `DEFAULT_EVIDENCE`
15
+ *
16
+ * All other validation errors (bad scope, non-numeric confidence, etc.)
17
+ * are surfaced in `remaining_errors` and the file is left untouched —
18
+ * those require human judgement, not a mechanical default.
19
+ *
20
+ * `dryRun: true` (default) reports what would change without writing.
21
+ */
22
+ export function fixupSolutions(solutionsDir, opts = {}) {
23
+ const dryRun = opts.dryRun !== false;
24
+ const result = { scanned: 0, fixed: 0, untouched: 0, unfixable: 0, reports: [] };
25
+ if (!fs.existsSync(solutionsDir))
26
+ return result;
27
+ const files = fs.readdirSync(solutionsDir).filter((f) => f.endsWith('.md'));
28
+ for (const file of files) {
29
+ const filePath = path.join(solutionsDir, file);
30
+ result.scanned++;
31
+ let content;
32
+ try {
33
+ content = fs.readFileSync(filePath, 'utf-8');
34
+ }
35
+ catch {
36
+ result.unfixable++;
37
+ continue;
38
+ }
39
+ const errors = diagnoseFromRawContent(content);
40
+ if (errors.length === 0) {
41
+ result.untouched++;
42
+ continue;
43
+ }
44
+ const fix = tryFix(content, errors);
45
+ result.reports.push({
46
+ path: filePath,
47
+ changed: fix.changed,
48
+ added: fix.added,
49
+ remaining_errors: fix.remaining,
50
+ });
51
+ if (fix.changed && fix.remaining.length === 0) {
52
+ if (!dryRun) {
53
+ try {
54
+ fs.writeFileSync(filePath, fix.content);
55
+ log.debug(`fixed: ${filePath} (${fix.added.join(', ')})`);
56
+ }
57
+ catch (e) {
58
+ log.debug(`write failed: ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
59
+ result.unfixable++;
60
+ continue;
61
+ }
62
+ }
63
+ result.fixed++;
64
+ }
65
+ else {
66
+ result.unfixable++;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+ function tryFix(content, initialErrors) {
72
+ const trimmed = content.trimStart();
73
+ const added = [];
74
+ if (!trimmed.startsWith('---')) {
75
+ return { changed: false, added, remaining: initialErrors, content };
76
+ }
77
+ const endIdx = trimmed.indexOf('---', 3);
78
+ if (endIdx === -1) {
79
+ return { changed: false, added, remaining: initialErrors, content };
80
+ }
81
+ const leadingWs = content.slice(0, content.length - trimmed.length);
82
+ const fmRaw = trimmed.slice(3, endIdx);
83
+ const body = trimmed.slice(endIdx + 3);
84
+ let fm;
85
+ try {
86
+ const parsed = yaml.load(fmRaw, { schema: yaml.JSON_SCHEMA });
87
+ if (parsed == null || typeof parsed !== 'object') {
88
+ return { changed: false, added, remaining: initialErrors, content };
89
+ }
90
+ fm = parsed;
91
+ }
92
+ catch {
93
+ return { changed: false, added, remaining: initialErrors, content };
94
+ }
95
+ if (fm.extractedBy !== 'auto' && fm.extractedBy !== 'manual') {
96
+ fm.extractedBy = 'auto';
97
+ added.push('extractedBy: auto');
98
+ }
99
+ if (fm.evidence == null || typeof fm.evidence !== 'object') {
100
+ fm.evidence = { ...DEFAULT_EVIDENCE };
101
+ added.push('evidence: default');
102
+ }
103
+ if (fm.supersedes === undefined) {
104
+ fm.supersedes = null;
105
+ added.push('supersedes: null');
106
+ }
107
+ const newFmRaw = yaml.dump(fm, { lineWidth: 120, noRefs: true, sortKeys: false });
108
+ const rebuilt = `${leadingWs}---\n${newFmRaw}---${body}`;
109
+ const remaining = diagnoseFromRawContent(rebuilt);
110
+ return {
111
+ changed: added.length > 0,
112
+ added,
113
+ remaining,
114
+ content: rebuilt,
115
+ };
116
+ }
@@ -60,14 +60,20 @@ export declare const DEFAULT_EVIDENCE: SolutionEvidence;
60
60
  export declare function slugify(text: string): string;
61
61
  /** Runtime type guard for SolutionFrontmatter */
62
62
  export declare function validateFrontmatter(fm: unknown): fm is SolutionFrontmatter;
63
+ /**
64
+ * Return a list of validation errors for a parsed frontmatter object.
65
+ *
66
+ * Empty array = valid. Non-empty = each entry describes one missing/wrong
67
+ * field. Callers that only need a boolean should use `validateFrontmatter`.
68
+ * Slow path (quarantine logging) uses this to produce actionable diagnostics.
69
+ */
70
+ export declare function diagnoseFrontmatter(fm: unknown): string[];
63
71
  /** Parse YAML frontmatter from solution file content */
64
72
  export declare function parseFrontmatterOnly(content: string): SolutionFrontmatter | null;
65
73
  /** Parse a full V3 solution file into its components */
66
74
  export declare function parseSolutionV3(content: string): SolutionV3 | null;
67
75
  /** Serialize a SolutionV3 to a markdown string with YAML frontmatter */
68
76
  export declare function serializeSolutionV3(solution: SolutionV3): string;
69
- /** Check if content is in V3 format (YAML frontmatter) */
70
- export declare function isV3Format(content: string): boolean;
71
77
  /** Check if content is in V1 format (# Title + > Type: pattern) */
72
78
  export declare function isV1Format(content: string): boolean;
73
79
  /** 한국어 일반 조사/어미 — strip 대상 (긴 것부터 매칭)
@@ -35,43 +35,58 @@ export function slugify(text) {
35
35
  // ── Validation ──
36
36
  /** Runtime type guard for SolutionFrontmatter */
37
37
  export function validateFrontmatter(fm) {
38
- if (fm == null || typeof fm !== 'object')
39
- return false;
38
+ return diagnoseFrontmatter(fm).length === 0;
39
+ }
40
+ /**
41
+ * Return a list of validation errors for a parsed frontmatter object.
42
+ *
43
+ * Empty array = valid. Non-empty = each entry describes one missing/wrong
44
+ * field. Callers that only need a boolean should use `validateFrontmatter`.
45
+ * Slow path (quarantine logging) uses this to produce actionable diagnostics.
46
+ */
47
+ export function diagnoseFrontmatter(fm) {
48
+ const errors = [];
49
+ if (fm == null || typeof fm !== 'object') {
50
+ errors.push('frontmatter is not an object');
51
+ return errors;
52
+ }
40
53
  const o = fm;
41
54
  if (typeof o.name !== 'string')
42
- return false;
55
+ errors.push('name: must be string');
43
56
  if (typeof o.version !== 'number' || o.version <= 0)
44
- return false;
57
+ errors.push('version: must be positive number');
45
58
  if (typeof o.status !== 'string' || !VALID_STATUSES.includes(o.status))
46
- return false;
59
+ errors.push(`status: must be one of ${VALID_STATUSES.join('|')}`);
47
60
  if (typeof o.confidence !== 'number' || o.confidence < 0 || o.confidence > 1)
48
- return false;
61
+ errors.push('confidence: must be number in [0,1]');
49
62
  if (typeof o.type !== 'string' || !VALID_TYPES.includes(o.type))
50
- return false;
63
+ errors.push(`type: must be one of ${VALID_TYPES.join('|')}`);
51
64
  if (o.scope !== 'me' && o.scope !== 'team' && o.scope !== 'project' && o.scope !== 'universal')
52
- return false;
65
+ errors.push('scope: must be me|team|project|universal');
53
66
  if (!Array.isArray(o.tags) || !o.tags.every((t) => typeof t === 'string'))
54
- return false;
67
+ errors.push('tags: must be string[]');
55
68
  if (!Array.isArray(o.identifiers) || !o.identifiers.every((t) => typeof t === 'string'))
56
- return false;
69
+ errors.push('identifiers: must be string[]');
57
70
  if (typeof o.created !== 'string')
58
- return false;
71
+ errors.push('created: must be string');
59
72
  if (typeof o.updated !== 'string')
60
- return false;
73
+ errors.push('updated: must be string');
61
74
  if (o.supersedes !== null && typeof o.supersedes !== 'string')
62
- return false;
75
+ errors.push('supersedes: must be string or null');
63
76
  if (o.extractedBy !== 'auto' && o.extractedBy !== 'manual')
64
- return false;
65
- // evidence
66
- if (o.evidence == null || typeof o.evidence !== 'object')
67
- return false;
68
- const ev = o.evidence;
69
- const evFields = ['injected', 'reflected', 'negative', 'sessions', 'reExtracted'];
70
- for (const f of evFields) {
71
- if (typeof ev[f] !== 'number')
72
- return false;
77
+ errors.push('extractedBy: missing or not auto|manual');
78
+ if (o.evidence == null || typeof o.evidence !== 'object') {
79
+ errors.push('evidence: block missing');
80
+ }
81
+ else {
82
+ const ev = o.evidence;
83
+ const evFields = ['injected', 'reflected', 'negative', 'sessions', 'reExtracted'];
84
+ for (const f of evFields) {
85
+ if (typeof ev[f] !== 'number')
86
+ errors.push(`evidence.${f}: must be number`);
87
+ }
73
88
  }
74
- return true;
89
+ return errors;
75
90
  }
76
91
  // ── Parsing ──
77
92
  /** Parse YAML frontmatter from solution file content */
@@ -147,10 +162,6 @@ export function serializeSolutionV3(solution) {
147
162
  return `---\n${yamlStr}---\n\n## Context\n${solution.context}\n\n## Content\n${solution.content}\n`;
148
163
  }
149
164
  // ── Format Detection ──
150
- /** Check if content is in V3 format (YAML frontmatter) */
151
- export function isV3Format(content) {
152
- return content.trimStart().startsWith('---');
153
- }
154
165
  /** Check if content is in V1 format (# Title + > Type: pattern) */
155
166
  export function isV1Format(content) {
156
167
  const lines = content.split('\n');
@@ -5,6 +5,7 @@ import { defaultNormalizer } from './term-normalizer.js';
5
5
  import { withFileLockSync } from '../hooks/shared/file-lock.js';
6
6
  import { atomicWriteText } from '../hooks/shared/atomic-write.js';
7
7
  import { createLogger } from '../core/logger.js';
8
+ import { recordQuarantine, diagnoseFromRawContent } from './solution-quarantine.js';
8
9
  const log = createLogger('solution-index');
9
10
  /**
10
11
  * Cache keyed by an order-preserving directory signature.
@@ -155,6 +156,15 @@ function buildIndex(dirs) {
155
156
  const fm = parseFrontmatterOnly(content);
156
157
  if (!fm) {
157
158
  droppedMalformed++;
159
+ // Slow-path diagnosis: re-parse YAML to produce actionable errors,
160
+ // then persist to ~/.forgen/state/solution-quarantine.jsonl so the
161
+ // file is visible to `forgen doctor` instead of silently dead.
162
+ // Best-effort: quarantine writes must never throw.
163
+ try {
164
+ const errors = diagnoseFromRawContent(content);
165
+ recordQuarantine(filePath, errors);
166
+ }
167
+ catch { /* ignore */ }
158
168
  log.debug(`dropped (malformed frontmatter): ${filePath}`);
159
169
  continue;
160
170
  }
@@ -41,6 +41,14 @@ export interface SolutionMatch {
41
41
  tags: string[];
42
42
  identifiers: string[];
43
43
  matchedTags: string[];
44
+ /**
45
+ * Identifier substrings (function/file names) that appeared literally in the
46
+ * prompt. Added 2026-04-21 so solution-injector can enforce a precision gate
47
+ * distinguishing "user typed a specific identifier" (strong signal, survives
48
+ * 1-tag overlap) from "only 1 tag happens to overlap" (often noise — common
49
+ * nouns like 'type', 'file', 'forgen' trigger rare-tag BM25 boost).
50
+ */
51
+ matchedIdentifiers: string[];
44
52
  }
45
53
  /**
46
54
  * Optional hints for the v3 `calculateRelevance` path. Used by hot-path
@@ -810,6 +810,31 @@ function loadTunedMatcherWeights() {
810
810
  _weightsCacheTime = now;
811
811
  return undefined;
812
812
  }
813
+ /**
814
+ * Cold-start exploration bonus for candidate solutions.
815
+ *
816
+ * Phase 4 evolution: newly proposed solutions enter at `status: candidate`.
817
+ * Without a nudge they compete head-to-head with mature verified/champion
818
+ * entries and almost always lose the first few rounds — not because
819
+ * they're worse, but because matchers favor solutions with richer tag
820
+ * histories. A small confidence multiplier lets candidates surface often
821
+ * enough to accumulate reflected/sessions evidence, after which the
822
+ * lifecycle loop decides their fate.
823
+ *
824
+ * The 1.3× factor is a starting point (Q1 in docs/design-solution-evolution.md).
825
+ * Bonus deactivation happens implicitly when compound-lifecycle.ts::
826
+ * runLifecycleCheck promotes the candidate to `verified` based on accumulated
827
+ * reflected/sessions evidence. There is no inject-count-based auto promotion
828
+ * (removed 2026-04-20 — see feedback_core_loop_invariant).
829
+ */
830
+ const CANDIDATE_EXPLORATION_MULTIPLIER = 1.3;
831
+ function applyCandidateExplorationBonus(entries) {
832
+ return entries.map((e) => {
833
+ if (e.status !== 'candidate')
834
+ return e;
835
+ return { ...e, confidence: Math.min(1, e.confidence * CANDIDATE_EXPLORATION_MULTIPLIER) };
836
+ });
837
+ }
813
838
  export function matchSolutions(prompt, scope, cwd) {
814
839
  // Build solution dirs for index cache
815
840
  const dirs = [{ dir: ME_SOLUTIONS, scope: 'me' }];
@@ -819,7 +844,7 @@ export function matchSolutions(prompt, scope, cwd) {
819
844
  dirs.push({ dir: path.join(cwd, '.compound', 'solutions'), scope: 'project' });
820
845
  // Use cached index (rebuilt only when dirs change)
821
846
  const index = getOrBuildIndex(dirs);
822
- const allSolutions = index.entries.map((e) => ({ ...e }));
847
+ const allSolutions = applyCandidateExplorationBonus(index.entries.map((e) => ({ ...e })));
823
848
  const promptTags = extractTags(prompt);
824
849
  const promptLower = prompt.toLowerCase();
825
850
  // Meta-learning: load tuned weights if available
@@ -842,5 +867,6 @@ export function matchSolutions(prompt, scope, cwd) {
842
867
  tags: c.solution.tags,
843
868
  identifiers: c.solution.identifiers,
844
869
  matchedTags: [...c.matchedTags, ...c.matchedIdentifiers],
870
+ matchedIdentifiers: c.matchedIdentifiers,
845
871
  }));
846
872
  }
@@ -0,0 +1,74 @@
1
+ export type Outcome = 'accept' | 'correct' | 'error' | 'unknown';
2
+ export type Attribution = 'explicit' | 'window' | 'session_end' | 'default';
3
+ /**
4
+ * One inject → outcome event. Written append-only to
5
+ * ~/.forgen/state/outcomes/{session_id}.jsonl. The pending state (inject
6
+ * happened, outcome not yet decided) is stored separately in
7
+ * ~/.forgen/state/outcome-pending-{session_id}.json.
8
+ */
9
+ export interface OutcomeEvent {
10
+ ts: number;
11
+ session_id: string;
12
+ solution: string;
13
+ match_score: number;
14
+ injected_chars: number;
15
+ outcome: Outcome;
16
+ outcome_lag_ms: number;
17
+ attribution: Attribution;
18
+ }
19
+ /**
20
+ * Record that solutions were injected. Called from solution-injector right
21
+ * after `approveWithContext` is emitted. Fails silently — outcome tracking
22
+ * must never block the user's workflow.
23
+ */
24
+ export declare function appendPending(sessionId: string, injections: Array<{
25
+ solution: string;
26
+ match_score: number;
27
+ injected_chars: number;
28
+ }>): void;
29
+ /**
30
+ * Flush pending injections as `accept` events. Called when a new user
31
+ * prompt arrives without any intervening correction/error, signaling that
32
+ * the previous injections were silently accepted. "Silence = consent."
33
+ *
34
+ * If `excludeSolutions` is provided, those solutions are NOT flushed (e.g.
35
+ * because an earlier step already attributed them as `correct` or `error`).
36
+ */
37
+ export declare function flushAccept(sessionId: string, excludeSolutions?: Set<string>): number;
38
+ /**
39
+ * Attribute a correction to the most recent pending injection(s). Called
40
+ * from the correction-record MCP tool. Removes attributed entries from
41
+ * pending so subsequent `flushAccept` does not double-count them.
42
+ *
43
+ * Strategy: all currently-pending solutions in this session are marked as
44
+ * `correct`. This is conservative (the correction may target only one of
45
+ * them), but without semantic attribution we err on the side of the user's
46
+ * feedback signal being louder than acceptance.
47
+ */
48
+ export declare function attributeCorrection(sessionId: string): string[];
49
+ /**
50
+ * Attribute a tool error to pending solutions in this session. Called from
51
+ * post-tool-failure hook. Unlike corrections, errors do not clear pending
52
+ * — an error is a weaker signal and the next user prompt can still produce
53
+ * a correct/accept decision.
54
+ *
55
+ * Only the top-K most-relevant, recent, above-threshold pending solutions
56
+ * are attributed (see gates above). Below-threshold or stale pending
57
+ * entries are left untouched — they will resolve via accept/unknown later.
58
+ *
59
+ * To avoid flooding the log with duplicate errors for the same pending
60
+ * batch, we cap at one `error` event per (session, solution) pair per
61
+ * pending-cycle by tracking a `error_flagged` set in the pending state.
62
+ */
63
+ export declare function attributeError(sessionId: string): string[];
64
+ /**
65
+ * At session end, any still-pending entries are logged as `unknown` (we
66
+ * can't tell if the user was happy or just stopped). Pending file is
67
+ * removed.
68
+ */
69
+ export declare function finalizeSession(sessionId: string): number;
70
+ /**
71
+ * Read all outcome events across all sessions. Used by fitness
72
+ * calculation. Returns events sorted by timestamp ascending.
73
+ */
74
+ export declare function readAllOutcomes(): OutcomeEvent[];