@wooojin/forgen 0.3.1 → 0.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 (125) hide show
  1. package/.claude-plugin/plugin.json +7 -2
  2. package/CHANGELOG.md +164 -0
  3. package/README.ja.md +90 -7
  4. package/README.ko.md +44 -1
  5. package/README.md +128 -9
  6. package/README.zh.md +90 -7
  7. package/dist/cli.js +140 -8
  8. package/dist/core/auto-compound-runner.js +16 -5
  9. package/dist/core/dashboard.js +11 -4
  10. package/dist/core/doctor.d.ts +6 -1
  11. package/dist/core/doctor.js +85 -11
  12. package/dist/core/global-config.d.ts +2 -2
  13. package/dist/core/global-config.js +6 -14
  14. package/dist/core/harness.d.ts +3 -5
  15. package/dist/core/harness.js +34 -338
  16. package/dist/core/inspect-cli.js +65 -5
  17. package/dist/core/installer.d.ts +10 -0
  18. package/dist/core/installer.js +185 -0
  19. package/dist/core/paths.d.ts +0 -34
  20. package/dist/core/paths.js +0 -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 +49 -0
  27. package/dist/core/state-gc.js +163 -0
  28. package/dist/core/stats-cli.d.ts +15 -0
  29. package/dist/core/stats-cli.js +143 -0
  30. package/dist/core/uninstall.d.ts +1 -0
  31. package/dist/core/uninstall.js +36 -5
  32. package/dist/core/v1-bootstrap.js +11 -3
  33. package/dist/engine/classify-enforce-cli.d.ts +8 -0
  34. package/dist/engine/classify-enforce-cli.js +61 -0
  35. package/dist/engine/compound-cli.d.ts +27 -2
  36. package/dist/engine/compound-cli.js +69 -16
  37. package/dist/engine/compound-export.d.ts +15 -0
  38. package/dist/engine/compound-export.js +32 -5
  39. package/dist/engine/compound-loop.js +3 -2
  40. package/dist/engine/enforce-classifier.d.ts +31 -0
  41. package/dist/engine/enforce-classifier.js +123 -0
  42. package/dist/engine/learn-cli.js +52 -0
  43. package/dist/engine/lifecycle/bypass-detector.d.ts +34 -0
  44. package/dist/engine/lifecycle/bypass-detector.js +82 -0
  45. package/dist/engine/lifecycle/lifecycle-cli.d.ts +7 -0
  46. package/dist/engine/lifecycle/lifecycle-cli.js +102 -0
  47. package/dist/engine/lifecycle/meta-cli.d.ts +4 -0
  48. package/dist/engine/lifecycle/meta-cli.js +7 -0
  49. package/dist/engine/lifecycle/meta-reclassifier.d.ts +78 -0
  50. package/dist/engine/lifecycle/meta-reclassifier.js +351 -0
  51. package/dist/engine/lifecycle/orchestrator.d.ts +32 -0
  52. package/dist/engine/lifecycle/orchestrator.js +131 -0
  53. package/dist/engine/lifecycle/signals.d.ts +30 -0
  54. package/dist/engine/lifecycle/signals.js +142 -0
  55. package/dist/engine/lifecycle/trigger-t1-correction.d.ts +23 -0
  56. package/dist/engine/lifecycle/trigger-t1-correction.js +78 -0
  57. package/dist/engine/lifecycle/trigger-t2-violation.d.ts +18 -0
  58. package/dist/engine/lifecycle/trigger-t2-violation.js +42 -0
  59. package/dist/engine/lifecycle/trigger-t3-bypass.d.ts +17 -0
  60. package/dist/engine/lifecycle/trigger-t3-bypass.js +39 -0
  61. package/dist/engine/lifecycle/trigger-t4-decay.d.ts +18 -0
  62. package/dist/engine/lifecycle/trigger-t4-decay.js +40 -0
  63. package/dist/engine/lifecycle/trigger-t5-conflict.d.ts +16 -0
  64. package/dist/engine/lifecycle/trigger-t5-conflict.js +78 -0
  65. package/dist/engine/lifecycle/types.d.ts +52 -0
  66. package/dist/engine/lifecycle/types.js +7 -0
  67. package/dist/engine/match-eval-log.js +45 -0
  68. package/dist/engine/rule-toggle-cli.d.ts +13 -0
  69. package/dist/engine/rule-toggle-cli.js +76 -0
  70. package/dist/engine/solution-format.d.ts +0 -2
  71. package/dist/engine/solution-format.js +0 -4
  72. package/dist/engine/solution-matcher.d.ts +8 -0
  73. package/dist/engine/solution-matcher.js +7 -4
  74. package/dist/engine/solution-outcomes.d.ts +4 -0
  75. package/dist/engine/solution-outcomes.js +174 -97
  76. package/dist/engine/solution-writer.d.ts +8 -5
  77. package/dist/engine/solution-writer.js +43 -19
  78. package/dist/fgx.js +9 -2
  79. package/dist/forge/cli.js +7 -7
  80. package/dist/forge/evidence-processor.js +10 -2
  81. package/dist/hooks/context-guard.js +86 -1
  82. package/dist/hooks/hook-config.d.ts +9 -1
  83. package/dist/hooks/hook-config.js +25 -3
  84. package/dist/hooks/internal/run-lifecycle-check.d.ts +2 -0
  85. package/dist/hooks/internal/run-lifecycle-check.js +32 -0
  86. package/dist/hooks/notepad-injector.js +6 -3
  87. package/dist/hooks/permission-handler.d.ts +10 -2
  88. package/dist/hooks/permission-handler.js +31 -12
  89. package/dist/hooks/post-tool-use.js +62 -0
  90. package/dist/hooks/pre-tool-use.js +67 -5
  91. package/dist/hooks/secret-filter.d.ts +10 -0
  92. package/dist/hooks/secret-filter.js +26 -0
  93. package/dist/hooks/session-recovery.js +15 -7
  94. package/dist/hooks/shared/atomic-write.d.ts +8 -1
  95. package/dist/hooks/shared/atomic-write.js +17 -3
  96. package/dist/hooks/shared/hook-response.d.ts +11 -2
  97. package/dist/hooks/shared/hook-response.js +20 -7
  98. package/dist/hooks/shared/hook-timing.js +10 -1
  99. package/dist/hooks/shared/safe-regex.d.ts +25 -0
  100. package/dist/hooks/shared/safe-regex.js +50 -0
  101. package/dist/hooks/shared/stop-triggers.d.ts +19 -0
  102. package/dist/hooks/shared/stop-triggers.js +19 -0
  103. package/dist/hooks/solution-injector.d.ts +21 -0
  104. package/dist/hooks/solution-injector.js +60 -1
  105. package/dist/hooks/stop-guard.d.ts +84 -0
  106. package/dist/hooks/stop-guard.js +482 -0
  107. package/dist/mcp/solution-reader.d.ts +2 -0
  108. package/dist/mcp/solution-reader.js +28 -1
  109. package/dist/mcp/tools.js +24 -4
  110. package/dist/preset/preset-manager.js +12 -2
  111. package/dist/store/evidence-store.d.ts +15 -0
  112. package/dist/store/evidence-store.js +55 -6
  113. package/dist/store/profile-store.d.ts +9 -0
  114. package/dist/store/profile-store.js +25 -4
  115. package/dist/store/rule-lifecycle.d.ts +23 -0
  116. package/dist/store/rule-lifecycle.js +63 -0
  117. package/dist/store/rule-store.d.ts +21 -0
  118. package/dist/store/rule-store.js +133 -13
  119. package/dist/store/types.d.ts +83 -0
  120. package/dist/store/types.js +7 -1
  121. package/hooks/hook-registry.json +1 -0
  122. package/hooks/hooks.json +6 -1
  123. package/package.json +10 -2
  124. package/plugin.json +7 -2
  125. package/scripts/postinstall.js +52 -5
@@ -0,0 +1,61 @@
1
+ /**
2
+ * CLI handler for `forgen classify-enforce [--apply] [--force]`.
3
+ *
4
+ * 기본: dry-run — 각 rule 의 제안만 출력. 변경 없음.
5
+ * --apply: 제안을 rule 파일에 저장 (enforce_via 미설정 rule 만).
6
+ * --force: enforce_via 가 이미 있어도 덮어쓴다.
7
+ */
8
+ import { loadAllRules, saveRule } from '../store/rule-store.js';
9
+ import { classifyAll, applyProposal } from './enforce-classifier.js';
10
+ export async function handleClassifyEnforce(args) {
11
+ const apply = args.includes('--apply');
12
+ const force = args.includes('--force');
13
+ const rules = loadAllRules();
14
+ if (rules.length === 0) {
15
+ console.log('\n No rules in ~/.forgen/me/rules. Nothing to classify.\n');
16
+ return;
17
+ }
18
+ const proposals = classifyAll(rules);
19
+ let saved = 0;
20
+ let skipped = 0;
21
+ let alreadySet = 0;
22
+ console.log(`\n Enforce Classifier — ${rules.length} rule(s) scanned\n`);
23
+ for (let i = 0; i < proposals.length; i++) {
24
+ const p = proposals[i];
25
+ const rule = rules[i];
26
+ const marker = p.current_enforce_via ? '↻' : '+';
27
+ console.log(` ${marker} ${p.rule_id.slice(0, 8)} "${p.trigger_preview}"`);
28
+ console.log(` strength=${rule.strength} status=${rule.status}`);
29
+ for (const spec of p.proposed) {
30
+ const vparts = [spec.verifier?.kind ?? 'none'];
31
+ if (spec.drift_key)
32
+ vparts.push(`drift_key=${spec.drift_key}`);
33
+ console.log(` → Mech-${spec.mech} @ ${spec.hook} verifier=${vparts.join(' ')}`);
34
+ }
35
+ for (const reason of p.reasoning) {
36
+ console.log(` · ${reason}`);
37
+ }
38
+ if (apply) {
39
+ if (p.current_enforce_via && p.current_enforce_via.length > 0 && !force) {
40
+ alreadySet += 1;
41
+ console.log(' (skipped — enforce_via already set; use --force to overwrite)');
42
+ }
43
+ else {
44
+ const updated = applyProposal(rule, p, { force });
45
+ saveRule(updated);
46
+ saved += 1;
47
+ console.log(' (saved)');
48
+ }
49
+ }
50
+ else {
51
+ skipped += 1;
52
+ }
53
+ console.log('');
54
+ }
55
+ if (apply) {
56
+ console.log(` Summary: saved=${saved} already-set=${alreadySet} total=${rules.length}\n`);
57
+ }
58
+ else {
59
+ console.log(` Summary: ${skipped} proposal(s) previewed. Run with --apply to save.\n`);
60
+ }
61
+ }
@@ -20,5 +20,30 @@ export declare function removeSolution(name: string): void;
20
20
  export declare function cleanStaleSolutions(): void;
21
21
  /** Retag all solutions using improved extractTags */
22
22
  export declare function retagSolutions(): void;
23
- /** Rollback auto-extracted solutions since a given date */
24
- export declare function rollbackSolutions(sinceDate: string): void;
23
+ /** Result of a rollback operation used by tests and callers that need counts. */
24
+ export interface RollbackCliResult {
25
+ archived: string[];
26
+ archiveDir: string | null;
27
+ skipped: string[];
28
+ errors: string[];
29
+ dryRun: boolean;
30
+ }
31
+ /**
32
+ * Rollback auto-extracted solutions created since a given date.
33
+ *
34
+ * Invariant (2026-04-20, feedback_core_loop_invariant):
35
+ * rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
36
+ * 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
37
+ * rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
38
+ * time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
39
+ *
40
+ * 필터 기준:
41
+ * - category === 'solution'만 대상 (rule은 제외)
42
+ * - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
43
+ * - created >= since 인 것만 대상
44
+ *
45
+ * dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
46
+ */
47
+ export declare function rollbackSolutions(sinceDate: string, opts?: {
48
+ dryRun?: boolean;
49
+ }): RollbackCliResult;
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { extractTags, parseFrontmatterOnly, parseSolutionV3 } from './solution-format.js';
4
4
  import { mutateSolutionFile } from './solution-writer.js';
5
- import { ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
5
+ import { ARCHIVED_DIR, ME_SOLUTIONS, ME_RULES } from '../core/paths.js';
6
6
  /** Scan saved compound entries and return summaries */
7
7
  function scanEntries() {
8
8
  const summaries = [];
@@ -78,7 +78,17 @@ export function listSolutions() {
78
78
  console.log(` ${entry.name} [${entry.category}] (${entry.confidence.toFixed(2)}) ${evStr} [${entry.tags.slice(0, 3).join(', ')}]`);
79
79
  }
80
80
  }
81
- console.log(`\n Total: ${total} entries\n`);
81
+ // retired 카운트: scanEntries는 모든 상태를 포함하므로 직접 계산
82
+ const retiredCount = entries.filter(e => e.status === 'retired').length;
83
+ const activeCount = total - retiredCount;
84
+ const highConfidence = entries.filter(e => e.status === 'verified' || e.status === 'mature').length;
85
+ const denominator = total;
86
+ const precision = denominator > 0 ? Math.round((highConfidence / denominator) * 100) : null;
87
+ console.log(`\n Total: ${activeCount} active + ${retiredCount} retired`);
88
+ if (precision !== null) {
89
+ console.log(` Extraction precision: ${precision}%`);
90
+ }
91
+ console.log();
82
92
  }
83
93
  /** Inspect a single saved entry in detail */
84
94
  export function inspectSolution(name) {
@@ -218,33 +228,76 @@ export function retagSolutions() {
218
228
  }
219
229
  console.log(`\n Retagged ${retagged}/${entries.length} solutions.\n`);
220
230
  }
221
- /** Rollback auto-extracted solutions since a given date */
222
- export function rollbackSolutions(sinceDate) {
231
+ /**
232
+ * Rollback auto-extracted solutions created since a given date.
233
+ *
234
+ * Invariant (2026-04-20, feedback_core_loop_invariant):
235
+ * rollback은 **archive 이동**만 수행한다. `fs.unlinkSync`로 솔루션 파일을
236
+ * 영구 삭제하지 않는다. 실수로 rollback을 실행해도 `~/.forgen/lab/archived/
237
+ * rollback-{ts}/`에서 복구할 수 있어야 한다. (과거 `unlinkSync` 경로는
238
+ * time-bounded rollback이 "되돌리기 불가 영구 삭제"로 동작하던 버그였다.)
239
+ *
240
+ * 필터 기준:
241
+ * - category === 'solution'만 대상 (rule은 제외)
242
+ * - reflected > 0 OR sessions > 0인 것은 유지 (사용된 솔루션 보호)
243
+ * - created >= since 인 것만 대상
244
+ *
245
+ * dryRun=true면 아무 파일도 건드리지 않고 대상 목록만 반환.
246
+ */
247
+ export function rollbackSolutions(sinceDate, opts = {}) {
248
+ const result = {
249
+ archived: [],
250
+ archiveDir: null,
251
+ skipped: [],
252
+ errors: [],
253
+ dryRun: !!opts.dryRun,
254
+ };
223
255
  const since = new Date(sinceDate);
224
256
  if (Number.isNaN(since.getTime())) {
225
257
  console.log(`\n Invalid date: ${sinceDate}\n`);
226
- return;
258
+ result.errors.push(`invalid-date:${sinceDate}`);
259
+ return result;
227
260
  }
228
261
  const solutions = scanEntries().filter((entry) => entry.category === 'solution');
229
- const toRemove = solutions.filter((solution) => {
262
+ const toRollback = solutions.filter((solution) => {
230
263
  if (solution.evidence.reflected > 0 || solution.evidence.sessions > 0)
231
- return false; // keep used ones
264
+ return false;
232
265
  const created = new Date(solution.created);
233
266
  return created >= since;
234
267
  });
235
- if (toRemove.length === 0) {
268
+ if (toRollback.length === 0) {
236
269
  console.log(`\n No solutions to rollback since ${sinceDate}.\n`);
237
- return;
270
+ return result;
238
271
  }
239
- console.log(`\n Rolling back ${toRemove.length} solutions since ${sinceDate}:\n`);
240
- for (const sol of toRemove) {
272
+ if (opts.dryRun) {
273
+ console.log(`\n [dry-run] ${toRollback.length} solutions would be archived since ${sinceDate}:\n`);
274
+ for (const sol of toRollback) {
275
+ console.log(` Would archive: ${sol.name}`);
276
+ result.skipped.push(sol.filePath);
277
+ }
278
+ console.log(`\n Re-run without --dry-run to archive them.\n`);
279
+ return result;
280
+ }
281
+ const archiveDir = path.join(ARCHIVED_DIR, `rollback-${Date.now()}`);
282
+ result.archiveDir = archiveDir;
283
+ console.log(`\n Rolling back ${toRollback.length} solutions since ${sinceDate} → ${archiveDir}:\n`);
284
+ for (const sol of toRollback) {
241
285
  try {
242
- fs.unlinkSync(sol.filePath);
243
- console.log(` Removed: ${sol.name}`);
286
+ fs.mkdirSync(archiveDir, { recursive: true });
287
+ // 원본 경로 정보를 destName에 보존 — 복원 시 원위치 판별용.
288
+ // 예: "solutions__my-pattern.md"
289
+ const originDir = path.basename(path.dirname(sol.filePath));
290
+ const destName = `${originDir}__${path.basename(sol.filePath)}`;
291
+ fs.renameSync(sol.filePath, path.join(archiveDir, destName));
292
+ console.log(` Archived: ${sol.name}`);
293
+ result.archived.push(sol.filePath);
244
294
  }
245
- catch {
246
- console.log(` Failed: ${sol.name}`);
295
+ catch (e) {
296
+ const msg = e instanceof Error ? e.message : String(e);
297
+ console.log(` Failed: ${sol.name} — ${msg}`);
298
+ result.errors.push(`${sol.filePath}: ${msg}`);
247
299
  }
248
300
  }
249
- console.log();
301
+ console.log(`\n ${result.archived.length}/${toRollback.length} archived. Restore from ${archiveDir} if needed.\n`);
302
+ return result;
250
303
  }
@@ -7,6 +7,21 @@
7
7
  * Export creates a tar.gz archive; Import extracts it while skipping existing
8
8
  * files to prevent accidental overwrites.
9
9
  */
10
+ /**
11
+ * Test-friendly containment check (exported for unit tests).
12
+ *
13
+ * Audit fix (2026-04-21, follow-up #A): prior guard
14
+ * `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
15
+ * `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
16
+ * bypassed the check — even though it's a sibling, not a child, of
17
+ * ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
18
+ * cannot prefix-match `/home/u/.forgen/me-evil/...`.
19
+ *
20
+ * @param parentWithSep absolute canonical parent path that INCLUDES a
21
+ * trailing `path.sep` (precomputed by caller once per archive).
22
+ * @param candidate any path string (will be resolved lexically).
23
+ */
24
+ export declare function isPathInside(parentWithSep: string, candidate: string): boolean;
10
25
  export interface ExportResult {
11
26
  outputPath: string;
12
27
  counts: Record<string, number>;
@@ -14,6 +14,24 @@ import { execFileSync } from 'node:child_process';
14
14
  import { ME_DIR } from '../core/paths.js';
15
15
  /** Directories within ME_DIR to include in the archive. */
16
16
  const KNOWLEDGE_DIRS = ['solutions', 'rules', 'behavior'];
17
+ /**
18
+ * Test-friendly containment check (exported for unit tests).
19
+ *
20
+ * Audit fix (2026-04-21, follow-up #A): prior guard
21
+ * `realDest.startsWith(ME_DIR)` was a naive prefix match. A path like
22
+ * `/home/u/.forgen/me-evil/x.md` starts with `/home/u/.forgen/me` and
23
+ * bypassed the check — even though it's a sibling, not a child, of
24
+ * ME_DIR. Fix: compare with `parent + path.sep` so `/home/u/.forgen/me/`
25
+ * cannot prefix-match `/home/u/.forgen/me-evil/...`.
26
+ *
27
+ * @param parentWithSep absolute canonical parent path that INCLUDES a
28
+ * trailing `path.sep` (precomputed by caller once per archive).
29
+ * @param candidate any path string (will be resolved lexically).
30
+ */
31
+ export function isPathInside(parentWithSep, candidate) {
32
+ const resolved = path.resolve(candidate) + path.sep;
33
+ return resolved.startsWith(parentWithSep) && resolved !== parentWithSep;
34
+ }
17
35
  /**
18
36
  * Count .md files in a directory (non-recursive).
19
37
  * Returns 0 if the directory does not exist.
@@ -92,12 +110,21 @@ export function importKnowledge(archivePath) {
92
110
  stdio: ['pipe', 'pipe', 'pipe'],
93
111
  });
94
112
  const result = { imported: 0, skipped: 0, details: [] };
113
+ // Audit fix (2026-04-21, follow-up #A): prior check
114
+ // `realDest.startsWith(ME_DIR)` was a broken prefix guard. An archive
115
+ // entry `../me-evil/payload.md` resolves to a sibling directory path
116
+ // that still startsWith ME_DIR (prefix collision) and bypasses the
117
+ // check. Fix: compare lexically resolved paths with an explicit
118
+ // `path.sep` suffix so `.../me-evil` can never masquerade as
119
+ // `.../me/...`. Both source and destination are validated — a
120
+ // malformed archive must neither read from outside tmpDir nor write
121
+ // outside ME_DIR.
122
+ const meDirCanon = path.resolve(ME_DIR) + path.sep;
123
+ const tmpDirCanon = path.resolve(tmpDir) + path.sep;
95
124
  for (const relFile of archiveFiles) {
96
- const srcPath = path.join(tmpDir, relFile);
97
- const destPath = path.join(ME_DIR, relFile);
98
- // Security: ensure the dest path stays within ME_DIR
99
- const realDest = path.resolve(destPath);
100
- if (!realDest.startsWith(ME_DIR)) {
125
+ const srcPath = path.resolve(path.join(tmpDir, relFile));
126
+ const destPath = path.resolve(path.join(ME_DIR, relFile));
127
+ if (!isPathInside(meDirCanon, destPath) || !isPathInside(tmpDirCanon, srcPath)) {
101
128
  result.skipped++;
102
129
  result.details.push({ file: relFile, action: 'skipped' });
103
130
  continue;
@@ -324,11 +324,12 @@ export async function handleCompound(args) {
324
324
  const sinceIdx = args.indexOf('--since');
325
325
  const since = sinceIdx !== -1 ? args[sinceIdx + 1] : undefined;
326
326
  if (!since) {
327
- console.log(' Usage: forgen compound rollback --since 2026-03-20\n');
327
+ console.log(' Usage: forgen compound rollback --since YYYY-MM-DD [--dry-run]\n');
328
328
  return;
329
329
  }
330
+ const dryRun = args.includes('--dry-run');
330
331
  const { rollbackSolutions } = await import('./compound-cli.js');
331
- rollbackSolutions(since);
332
+ rollbackSolutions(since, { dryRun });
332
333
  return;
333
334
  }
334
335
  // --- explicit interactive command ---
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Forgen — Enforce Classifier (ADR-001 §Migration)
3
+ *
4
+ * 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
5
+ * 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
6
+ *
7
+ * 휴리스틱 (ADR-001 §Migration heuristics):
8
+ * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
10
+ * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
+ * - 그 외 soft/default → Mech-C (drift 측정)
12
+ *
13
+ * 설계 원칙:
14
+ * - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
15
+ * - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
16
+ * - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
17
+ */
18
+ import type { Rule, EnforceSpec } from '../store/types.js';
19
+ export interface EnforceProposal {
20
+ rule_id: string;
21
+ trigger_preview: string;
22
+ current_enforce_via: EnforceSpec[] | null;
23
+ proposed: EnforceSpec[];
24
+ reasoning: string[];
25
+ }
26
+ export declare function classify(rule: Rule): EnforceProposal;
27
+ export declare function classifyAll(rules: Rule[]): EnforceProposal[];
28
+ /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
29
+ export declare function applyProposal(rule: Rule, proposal: EnforceProposal, options?: {
30
+ force?: boolean;
31
+ }): Rule;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Forgen — Enforce Classifier (ADR-001 §Migration)
3
+ *
4
+ * 기존 Rule 에 `enforce_via: EnforceSpec[]` 이 없을 때, trigger/policy 자연어
5
+ * 패턴과 strength 조합으로 mech(A/B/C) 와 hook 을 자동 제안한다.
6
+ *
7
+ * 휴리스틱 (ADR-001 §Migration heuristics):
8
+ * - trigger/policy 에 `rm|force|DROP|credentials|\.env` → Mech-A PreToolUse + tool_arg_regex
9
+ * - trigger/policy 에 `완료|complete|done|e2e|mock|verify` → Mech-A Stop + artifact_check
10
+ * - strength ∈ {strong, hard} + 문체/응답 맥락 → Mech-B UserPromptSubmit + self_check_prompt
11
+ * - 그 외 soft/default → Mech-C (drift 측정)
12
+ *
13
+ * 설계 원칙:
14
+ * - pure: classify(rule) 는 부수효과 없음. CLI 에서만 save 가 발생.
15
+ * - 미리 존재하는 enforce_via 는 덮어쓰지 않음 (`force=false` 기본).
16
+ * - 신규 제안은 reason 주석(문자열) 과 함께 반환해 사용자 리뷰 가능.
17
+ */
18
+ const DESTRUCTIVE_PATTERN = /\b(rm\s+-rf|rm\s+-fr|force|DROP\s+TABLE|credentials|\.env|sudo|mkfs|dd\s+if=)/i;
19
+ const COMPLETION_PATTERN = /(완료|complete|done|ready|shipped|finished|e2e|mock|verify|검증|배포)/i;
20
+ const STYLE_PATTERN = /(문체|응답|설명|톤|어투|장황|간결|verbose|tone|style)/i;
21
+ // R6-F2: shared single source of truth — stop-guard 와 동일 regex 재사용.
22
+ import { DEFAULT_STOP_TRIGGER_RE as STOP_COMPLETION_TRIGGER, DEFAULT_STOP_EXCLUDE_RE as STOP_COMPLETION_EXCLUDE, MOCK_TRIGGER_RE as STOP_MOCK_TRIGGER, MOCK_EXCLUDE_RE as STOP_MOCK_EXCLUDE, } from '../hooks/shared/stop-triggers.js';
23
+ export function classify(rule) {
24
+ const reasoning = [];
25
+ const proposed = [];
26
+ const text = `${rule.trigger}\n${rule.policy}`;
27
+ const isDestructive = DESTRUCTIVE_PATTERN.test(text);
28
+ const isCompletion = COMPLETION_PATTERN.test(text);
29
+ const isStyle = STYLE_PATTERN.test(text);
30
+ const isStrong = rule.strength === 'strong' || rule.strength === 'hard';
31
+ // Mech-A PreToolUse — 파괴적 명령 패턴.
32
+ // 이전에는 DESTRUCTIVE_PATTERN.source 를 다시 .match() 하여 alternation 의 첫 리터럴
33
+ // ("credentials") 만 반환하는 버그가 있었음. 이제 rule 텍스트에서 실제 매칭된 구문을
34
+ // 뽑아 그 구문에 맞는 runtime regex 로 변환.
35
+ if (isDestructive) {
36
+ const matched = text.match(DESTRUCTIVE_PATTERN);
37
+ const matchedLiteral = matched?.[0] ?? '';
38
+ // 안전을 위해 매칭된 literal 을 공백 보존 + escape 해서 runtime regex 로 재구성.
39
+ // 예: "rm -rf" → "rm\s+-rf" (공백 유연); "DROP TABLE" → "DROP\s+TABLE"; ".env" → "\.env"
40
+ const pattern = matchedLiteral
41
+ ? matchedLiteral
42
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex metachar
43
+ .replace(/\s+/g, '\\s+') // 공백 하나 이상
44
+ : 'rm\\s+-rf'; // fallback
45
+ proposed.push({
46
+ mech: 'A',
47
+ hook: 'PreToolUse',
48
+ verifier: {
49
+ kind: 'tool_arg_regex',
50
+ params: { pattern, requires_flag: 'user_confirmed' },
51
+ },
52
+ block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 80)}`,
53
+ });
54
+ reasoning.push(`destructive literal "${matchedLiteral}" → Mech-A PreToolUse+tool_arg_regex ${pattern}`);
55
+ }
56
+ // Mech-A Stop — 완료 선언 + 증거 요구 (destructive 와 독립적으로 평가: 하나의 rule 이 둘 다 해당 가능)
57
+ if (isCompletion) {
58
+ const mockAsProof = /mock|stub|fake/i.test(text);
59
+ // 증거 파일 경로는 v0.4.0 최종 구현에서 rule.policy 에서 추출; 지금은 default 사용
60
+ proposed.push({
61
+ mech: 'A',
62
+ hook: 'Stop',
63
+ verifier: {
64
+ kind: 'artifact_check',
65
+ params: { path: '.forgen/state/e2e-result.json', max_age_s: 3600 },
66
+ },
67
+ block_message: `${rule.rule_id.slice(0, 8)}: ${rule.policy.slice(0, 120)}`,
68
+ trigger_keywords_regex: mockAsProof ? STOP_MOCK_TRIGGER : STOP_COMPLETION_TRIGGER,
69
+ trigger_exclude_regex: mockAsProof ? STOP_MOCK_EXCLUDE : STOP_COMPLETION_EXCLUDE,
70
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — ${mockAsProof ? 'no-mock-as-proof' : 'e2e-before-done'}`,
71
+ });
72
+ reasoning.push(mockAsProof
73
+ ? 'completion + mock keyword → Mech-A Stop+artifact_check (mock trigger)'
74
+ : 'completion keyword → Mech-A Stop+artifact_check (completion trigger)');
75
+ }
76
+ // Mech-B — 문체/응답 관련 또는 strong/hard 정책이지만 기계 판정 어려운 경우
77
+ if ((isStyle || (isStrong && !isDestructive && !isCompletion))) {
78
+ proposed.push({
79
+ mech: 'B',
80
+ hook: 'Stop',
81
+ verifier: {
82
+ kind: 'self_check_prompt',
83
+ params: {
84
+ question: `직전 응답이 다음 규칙을 위반했는지 자가점검하라: "${rule.policy.slice(0, 120)}". 위반 시 구체적 근거와 함께 수정해 재응답하라.`,
85
+ },
86
+ },
87
+ trigger_keywords_regex: STOP_COMPLETION_TRIGGER,
88
+ trigger_exclude_regex: STOP_COMPLETION_EXCLUDE,
89
+ system_tag: `rule:${rule.rule_id.slice(0, 8)} — style-check`,
90
+ });
91
+ reasoning.push(isStyle ? 'style/tone keyword → Mech-B Stop+self_check_prompt' : 'strong/hard strength + non-mechanical → Mech-B Stop+self_check_prompt');
92
+ }
93
+ // 잔여 — drift measure only (Mech-C)
94
+ if (proposed.length === 0) {
95
+ proposed.push({
96
+ mech: 'C',
97
+ hook: 'PostToolUse',
98
+ drift_key: `rule.${rule.rule_id.slice(0, 8)}`,
99
+ });
100
+ reasoning.push('no direct enforcement pattern → Mech-C drift measurement');
101
+ }
102
+ return {
103
+ rule_id: rule.rule_id,
104
+ trigger_preview: rule.trigger.slice(0, 60),
105
+ current_enforce_via: rule.enforce_via ?? null,
106
+ proposed,
107
+ reasoning,
108
+ };
109
+ }
110
+ export function classifyAll(rules) {
111
+ return rules.map(classify);
112
+ }
113
+ /** 제안을 적용해 새 Rule 을 반환 (pure). 이미 enforce_via 가 있으면 force=false 에서 건너뜀. */
114
+ export function applyProposal(rule, proposal, options = {}) {
115
+ if (rule.enforce_via && rule.enforce_via.length > 0 && !options.force) {
116
+ return rule;
117
+ }
118
+ return {
119
+ ...rule,
120
+ enforce_via: proposal.proposed,
121
+ updated_at: new Date().toISOString(),
122
+ };
123
+ }
@@ -1,3 +1,4 @@
1
+ import * as fs from 'node:fs';
1
2
  import * as path from 'node:path';
2
3
  import * as os from 'node:os';
3
4
  import { fixupSolutions } from './solution-fixup.js';
@@ -6,6 +7,8 @@ import { computeFitness } from './solution-fitness.js';
6
7
  import { buildWeaknessReport, saveWeaknessReport } from './solution-weakness.js';
7
8
  import { listCandidates, promoteCandidate, rollbackSince } from './solution-candidate.js';
8
9
  const ME_SOLUTIONS = path.join(os.homedir(), '.forgen', 'me', 'solutions');
10
+ const STATE_DIR = path.join(os.homedir(), '.forgen', 'state');
11
+ const OUTCOMES_DIR = path.join(STATE_DIR, 'outcomes');
9
12
  export async function handleLearn(args) {
10
13
  const sub = args[0];
11
14
  if (sub === 'fix-up')
@@ -16,6 +19,8 @@ export async function handleLearn(args) {
16
19
  return runFitness(args.slice(1));
17
20
  if (sub === 'evolve')
18
21
  return runEvolve(args.slice(1));
22
+ if (sub === 'reset-outcomes')
23
+ return runResetOutcomes(args.slice(1));
19
24
  printUsage();
20
25
  }
21
26
  function printUsage() {
@@ -28,8 +33,55 @@ function printUsage() {
28
33
  forgen learn fitness [--json] Show per-solution fitness (accept/correct/error ratios)
29
34
  forgen learn evolve [--save|--rollback <ts>|--promote <name>]
30
35
  Phase 4 evolution: weakness report + candidate lifecycle
36
+ forgen learn reset-outcomes [--apply] Archive pre-audit outcome history (dry-run by default).
37
+ Use after upgrading from <0.3.2 to start fitness fresh
38
+ under the new attribution gates. Old data is preserved
39
+ at ~/.forgen/state/outcomes.archive-<ts>/ (never deleted).
31
40
  `);
32
41
  }
42
+ /**
43
+ * Archive the outcomes directory to a timestamped sibling so fitness
44
+ * computation starts fresh under v0.3.2's corrected attribution gates
45
+ * (match_score≥0.3, lag≤5min, top-3, single-tag rejection). Pre-0.3.2
46
+ * outcomes were recorded with blanket error-attribution on every tool
47
+ * failure in the session window, producing a 91% global error rate even
48
+ * for solutions that weren't actually causing the failures.
49
+ *
50
+ * Archive, never delete — users who want to audit their pre-0.3.2
51
+ * history can still read `outcomes.archive-<ts>/*.jsonl`.
52
+ */
53
+ function runResetOutcomes(args) {
54
+ const apply = args.includes('--apply');
55
+ if (!fs.existsSync(OUTCOMES_DIR)) {
56
+ console.log(`\n No outcomes directory yet — nothing to reset.\n`);
57
+ return;
58
+ }
59
+ const files = fs.readdirSync(OUTCOMES_DIR).filter((f) => f.endsWith('.jsonl'));
60
+ if (files.length === 0) {
61
+ console.log(`\n Outcomes directory is empty — nothing to reset.\n`);
62
+ return;
63
+ }
64
+ let totalLines = 0;
65
+ for (const f of files) {
66
+ try {
67
+ totalLines += fs.readFileSync(path.join(OUTCOMES_DIR, f), 'utf-8').split('\n').filter(Boolean).length;
68
+ }
69
+ catch { /* ignore */ }
70
+ }
71
+ console.log(`\n Outcomes snapshot: ${files.length} session files, ${totalLines} events.`);
72
+ if (!apply) {
73
+ console.log(`\n Dry-run. Re-run with --apply to archive:`);
74
+ console.log(` mv ~/.forgen/state/outcomes → ~/.forgen/state/outcomes.archive-<ts>`);
75
+ console.log(` new empty ~/.forgen/state/outcomes/\n`);
76
+ return;
77
+ }
78
+ const archivePath = `${OUTCOMES_DIR}.archive-${Date.now()}`;
79
+ fs.renameSync(OUTCOMES_DIR, archivePath);
80
+ fs.mkdirSync(OUTCOMES_DIR, { recursive: true });
81
+ console.log(`\n ✓ Archived to ${archivePath}`);
82
+ console.log(` ✓ Fresh ${OUTCOMES_DIR} created.`);
83
+ console.log(`\n Next fitness snapshot reflects v0.3.2 attribution gates only.\n`);
84
+ }
33
85
  function runFixUp(args) {
34
86
  const apply = args.includes('--apply');
35
87
  const result = fixupSolutions(ME_SOLUTIONS, { dryRun: !apply });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bypass detector — T3 signal source.
3
+ *
4
+ * Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
5
+ * 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
6
+ *
7
+ * Heuristic:
8
+ * 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
9
+ * 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
10
+ * 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
11
+ * 4) 그 외: 빈 배열 (탐지 불가).
12
+ *
13
+ * 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
14
+ */
15
+ import type { Rule } from '../../store/types.js';
16
+ export declare function extractBypassPatterns(rule: Rule): string[];
17
+ export interface BypassScanInput {
18
+ rules: Rule[];
19
+ tool_name: string;
20
+ tool_output: string;
21
+ session_id: string;
22
+ }
23
+ export interface BypassCandidate {
24
+ rule_id: string;
25
+ session_id: string;
26
+ tool: string;
27
+ pattern_preview: string;
28
+ matched: string;
29
+ }
30
+ /**
31
+ * Pure — rules + tool output 으로 bypass candidates 추출.
32
+ * 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
33
+ */
34
+ export declare function scanForBypass(input: BypassScanInput): BypassCandidate[];
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Bypass detector — T3 signal source.
3
+ *
4
+ * Rule.policy 자연어에서 "피해야 할 패턴" 을 추출하고, Write/Edit/Bash 도구
5
+ * 출력에서 해당 패턴을 찾아 BypassEntry 후보로 반환한다.
6
+ *
7
+ * Heuristic:
8
+ * 1) "use X not Y" / "use X instead of Y" / "X over Y" → bypass = Y
9
+ * 2) "avoid X" / "don't use X" / "never use X" / "do not use X" → bypass = X
10
+ * 3) Korean: "X 말라" / "X 금지" / "X 하지 않" → bypass = X
11
+ * 4) 그 외: 빈 배열 (탐지 불가).
12
+ *
13
+ * 반환된 패턴은 escape 된 정규식 문자열 — caller 가 `new RegExp(p)` 로 사용.
14
+ */
15
+ function escapeRegex(s) {
16
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
17
+ }
18
+ /**
19
+ * Trim punctuation 많이 붙은 자연어 표현을 "검색용 토큰" 으로 정규화.
20
+ * Leading `.` 는 유지 — `.then`, `.mock` 같은 메서드 참조가 의도된 매칭 대상.
21
+ * Trailing `()` 는 제거 — `.then()` 을 `.then` 으로 정규화해 `.then(x=>...)` 에 매치.
22
+ */
23
+ function trimPunct(s) {
24
+ let out = s;
25
+ // Strip trailing "()" once (natural-language shorthand for method calls)
26
+ if (out.endsWith('()'))
27
+ out = out.slice(0, -2);
28
+ // Strip other leading/trailing punctuation, preserving leading `.`
29
+ out = out.replace(/^[,;:!?"'`(]+|[.,;:!?"'`)]+$/g, '');
30
+ return out;
31
+ }
32
+ export function extractBypassPatterns(rule) {
33
+ const patterns = [];
34
+ const p = rule.policy;
35
+ // use X not Y / use X instead of Y / use X over Y
36
+ // X, Y may contain dots (e.g., ".then()", "vi.mock"). Strip trailing punctuation.
37
+ const useNot = p.match(/\b(?:use|prefer|choose)\s+(\S+?)\s+(?:not|instead\s+of|over|rather\s+than)\s+(\S+)/i);
38
+ if (useNot)
39
+ patterns.push(escapeRegex(trimPunct(useNot[2])));
40
+ // avoid X / don't use X / never use X / do not use X
41
+ const avoid = p.match(/\b(?:avoid|don'?t\s+use|never\s+use|do\s+not\s+use)\s+(\S+)/i);
42
+ if (avoid)
43
+ patterns.push(escapeRegex(trimPunct(avoid[1])));
44
+ // Korean: "X 말라" / "X 금지" / "X 하지 마"
45
+ const ko = p.match(/(\S+)\s*(?:말라|금지|하지\s*마|쓰지\s*마)/);
46
+ if (ko)
47
+ patterns.push(escapeRegex(trimPunct(ko[1])));
48
+ // Dedupe + filter trivial
49
+ return [...new Set(patterns)].filter((pat) => pat.length >= 2);
50
+ }
51
+ /**
52
+ * Pure — rules + tool output 으로 bypass candidates 추출.
53
+ * 같은 rule/pattern 이 여러 번 매칭돼도 한 번만 기록.
54
+ */
55
+ export function scanForBypass(input) {
56
+ const { rules, tool_name, tool_output, session_id } = input;
57
+ const candidates = [];
58
+ const reported = new Set(); // rule_id|pattern
59
+ for (const rule of rules) {
60
+ if (rule.status !== 'active')
61
+ continue;
62
+ const patterns = extractBypassPatterns(rule);
63
+ for (const pat of patterns) {
64
+ const re = new RegExp(pat, 'i');
65
+ const m = tool_output.match(re);
66
+ if (!m)
67
+ continue;
68
+ const key = `${rule.rule_id}|${pat}`;
69
+ if (reported.has(key))
70
+ continue;
71
+ reported.add(key);
72
+ candidates.push({
73
+ rule_id: rule.rule_id,
74
+ session_id,
75
+ tool: tool_name,
76
+ pattern_preview: pat.slice(0, 40),
77
+ matched: m[0].slice(0, 40),
78
+ });
79
+ }
80
+ }
81
+ return candidates;
82
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * CLI handler for `forgen lifecycle-scan`.
3
+ *
4
+ * 전체 lifecycle 트리거(T1~T5 + Meta) 를 쓴 집계 데이터 기반으로 실행.
5
+ * default dry-run, --apply 시 rule 파일에 상태 전이 반영.
6
+ */
7
+ export declare function handleLifecycleScan(args: string[]): Promise<void>;