@wooojin/forgen 0.5.0 → 0.5.6

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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +236 -0
  3. package/README.ja.md +16 -8
  4. package/README.ko.md +17 -9
  5. package/README.md +6 -1
  6. package/README.zh.md +16 -8
  7. package/assets/claude/skills/verify/SKILL.md +66 -0
  8. package/assets/shared/hook-registry.json +217 -22
  9. package/dist/cli.js +7 -3
  10. package/dist/core/auto-compound-runner.js +73 -14
  11. package/dist/core/changelog-cli.d.ts +4 -1
  12. package/dist/core/changelog-cli.js +8 -7
  13. package/dist/core/compound-sweep-cli.d.ts +4 -1
  14. package/dist/core/compound-sweep-cli.js +25 -5
  15. package/dist/core/doctor.js +37 -0
  16. package/dist/core/spawn.d.ts +26 -0
  17. package/dist/core/spawn.js +38 -3
  18. package/dist/core/uninstall.d.ts +7 -0
  19. package/dist/core/uninstall.js +46 -1
  20. package/dist/engine/compound-loop.js +20 -1
  21. package/dist/hooks/context-guard.d.ts +3 -0
  22. package/dist/hooks/context-guard.js +16 -8
  23. package/dist/hooks/hook-config.js +5 -0
  24. package/dist/hooks/hook-registry.d.ts +17 -1
  25. package/dist/hooks/hooks-generator.d.ts +2 -0
  26. package/dist/hooks/hooks-generator.js +8 -1
  27. package/dist/hooks/session-end.d.ts +41 -0
  28. package/dist/hooks/session-end.js +113 -0
  29. package/dist/hooks/session-recovery.js +18 -1
  30. package/dist/hooks/shared/hook-timing.js +6 -1
  31. package/dist/host/codex-adapter.js +5 -0
  32. package/dist/host/codex-hook-alive.d.ts +23 -0
  33. package/dist/host/codex-hook-alive.js +51 -0
  34. package/dist/host/codex-notify.d.ts +55 -0
  35. package/dist/host/codex-notify.js +153 -0
  36. package/dist/host/codex-rollout.d.ts +21 -0
  37. package/dist/host/codex-rollout.js +82 -0
  38. package/dist/host/codex-rules-context.d.ts +25 -0
  39. package/dist/host/codex-rules-context.js +62 -0
  40. package/dist/host/exec-host.d.ts +10 -0
  41. package/dist/host/exec-host.js +18 -3
  42. package/dist/host/install-claude.d.ts +14 -0
  43. package/dist/host/install-claude.js +53 -0
  44. package/dist/host/install-codex.d.ts +120 -0
  45. package/dist/host/install-codex.js +560 -52
  46. package/dist/host/install-orchestrator.d.ts +4 -0
  47. package/dist/host/install-orchestrator.js +32 -2
  48. package/dist/host/invoke-agent.js +1 -0
  49. package/dist/host/parity-harness.js +7 -3
  50. package/dist/host/projection.d.ts +37 -3
  51. package/dist/host/projection.js +93 -42
  52. package/dist/store/evidence-store.js +121 -57
  53. package/dist/store/rule-store.d.ts +41 -0
  54. package/dist/store/rule-store.js +77 -2
  55. package/hooks/hooks.json +13 -1
  56. package/package.json +1 -1
  57. package/plugin.json +1 -1
  58. package/skills/architecture-decision/SKILL.md +18 -0
  59. package/skills/calibrate/SKILL.md +18 -0
  60. package/skills/code-review/SKILL.md +17 -0
  61. package/skills/compound/SKILL.md +17 -0
  62. package/skills/deep-interview/SKILL.md +7 -0
  63. package/skills/docker/SKILL.md +18 -0
  64. package/skills/forge-loop/SKILL.md +23 -0
  65. package/skills/learn/SKILL.md +15 -0
  66. package/skills/retro/SKILL.md +16 -0
  67. package/skills/ship/SKILL.md +18 -0
@@ -22,6 +22,10 @@ export interface OrchestratorOptions {
22
22
  pkgRoot: string;
23
23
  dryRun?: boolean;
24
24
  registerMcp?: boolean;
25
+ /** ADR-016 D1: Codex config.toml notify 폴백 등록 (default true). */
26
+ registerNotify?: boolean;
27
+ /** ADR-016 D3: Claude user-level `verify` 스킬 설치 (default true). */
28
+ installVerifySkill?: boolean;
25
29
  }
26
30
  export interface OrchestratorResult {
27
31
  target: InstallTarget;
@@ -97,10 +97,10 @@ export async function runInstall(opts) {
97
97
  const dryRun = opts.dryRun ?? false;
98
98
  const registerMcp = opts.registerMcp ?? true;
99
99
  if (target === 'claude' || target === 'both') {
100
- result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
100
+ result.claude = planClaudeInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, installVerifySkill: opts.installVerifySkill });
101
101
  }
102
102
  if (target === 'codex' || target === 'both') {
103
- result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp });
103
+ result.codex = planCodexInstall({ pkgRoot: opts.pkgRoot, dryRun, registerMcp, registerNotify: opts.registerNotify });
104
104
  }
105
105
  // W3-3: opencode 는 명시 타겟일 때만 설치('both' 는 claude+codex primary-pair 유지).
106
106
  if (target === 'opencode') {
@@ -120,6 +120,12 @@ export function renderResult(result, dryRun) {
120
120
  lines.push(` settings.json hooks: ${result.claude.hooksInjected}`);
121
121
  lines.push(` MCP: ${result.claude.mcpAlreadyPresent ? 'already present' : (result.claude.mcpRegistered ? 'registered' : 'skipped')}`);
122
122
  lines.push(` skills: ${result.claude.skillsInstalled ?? 0} installed → ${result.claude.skillsPath ?? ''}`);
123
+ const verifyLine = {
124
+ installed: 'installed → ~/.claude/skills/verify (Claude runs it right before non-docs commits)',
125
+ 'user-owned': 'skipped — you already have your own `verify` skill (kept as is)',
126
+ skipped: 'skipped (--no-verify-skill)',
127
+ };
128
+ lines.push(` verify skill: ${verifyLine[result.claude.verifySkill]}`);
123
129
  }
124
130
  if (result.codex) {
125
131
  lines.push('');
@@ -127,6 +133,30 @@ export function renderResult(result, dryRun) {
127
133
  lines.push(` CODEX_HOME: ${result.codex.codexHome}`);
128
134
  lines.push(` hooks.json: ${result.codex.hooksCount} forgen hooks (preserved user: ${result.codex.preservedUserHookCount})`);
129
135
  lines.push(` MCP: ${result.codex.mcpAlreadyPresent ? 'already present' : (result.codex.mcpRegistered ? 'registered' : 'skipped')}`);
136
+ lines.push(` skills: ${result.codex.skillsInstalled} commands + ${result.codex.devGuideSkillsInstalled} dev-guide → ${result.codex.skillsPath}`);
137
+ lines.push(` agents: ${result.codex.agentsInstalled} ch-*.toml → ${result.codex.agentsPath}${result.codex.multiAgentEnabled ? '' : ' (⚠ config.toml 에 [features] multi_agent = true 가 없어 Codex 가 spawn 하지 못함 — 추가 권장)'}`);
138
+ lines.push(` rules: injected per session via SessionStart hook (<forgen-rules host="codex">)`);
139
+ const t = result.codex.hookTrust;
140
+ if (t.total > 0 && t.trusted === t.total) {
141
+ lines.push(` hook trust: ${t.trusted}/${t.total} trusted${t.ignoredByCodex.length ? ` (+${t.ignoredByCodex.length} Claude-only event ignored by Codex)` : ''}`);
142
+ }
143
+ else {
144
+ const pending = [...t.modified.map((k) => `${k} (modified)`), ...t.untrusted.map((k) => `${k} (new)`), ...t.disabled.map((k) => `${k} (disabled)`)];
145
+ lines.push(` hook trust: ${t.trusted}/${t.total} trusted — ${pending.length} hook(s) need review: ${pending.slice(0, 6).join(', ')}${pending.length > 6 ? ', …' : ''}`);
146
+ lines.push(' Codex skips these until approved/enabled: run `/hooks` inside codex and trust the forgen entries.');
147
+ if (t.modified.some((k) => k.startsWith('session_start:'))) {
148
+ lines.push(' ⚠ session_start is pending — the <forgen-rules> block is NOT injected into Codex sessions until you approve it.');
149
+ }
150
+ }
151
+ const notifyLine = {
152
+ installed: 'registered (turn-complete fallback — runs even while hooks are untrusted)',
153
+ 'already-present': 'already present',
154
+ 'user-defined': 'skipped — your config.toml already defines `notify` (kept as is). To chain forgen: notify = ["node", "<pkgRoot>/dist/host/codex-notify.js", "--", <your argv…>]',
155
+ 'custom-block': 'left as is — the forgen notify block in config.toml was hand-edited into a form forgen will not rewrite (keep it on one line as a JSON array to let forgen manage it)',
156
+ skipped: 'skipped (--no-notify)',
157
+ removed: 'removed (--no-notify)',
158
+ };
159
+ lines.push(` notify: ${notifyLine[result.codex.notify]}`);
130
160
  }
131
161
  if (result.opencode) {
132
162
  lines.push('');
@@ -95,6 +95,7 @@ export async function invokeAgent(opts) {
95
95
  // Phase 3 critic fix: default timeout 60s → 90s (codex sandbox startup +
96
96
  // 인증 + LLM 응답까지 60s 부족할 수 있음. tail latency 안전마진).
97
97
  const result = execHost({
98
+ nestedRun: false, // 위임 에이전트는 forgen 훅(안전 tier 포함)이 살아 있어야 한다 (ADR-015 critic #3)
98
99
  prompt,
99
100
  timeout: opts.timeoutMs ?? 90000,
100
101
  host: opts.host,
@@ -80,9 +80,11 @@ export const SCENARIO_CORPUS = [
80
80
  claude: { decision: 'block', reason: 'tests not yet executed' },
81
81
  codex: { decision: 'block', reason: 'tests not yet executed' },
82
82
  },
83
+ // ADR-015 G1: Stop block 은 top-level decision/reason 으로 전달된다 (양 host 동일 스키마).
83
84
  compareKeys: [
84
85
  'continue',
85
- 'hookSpecificOutput.permissionDecision',
86
+ 'decision',
87
+ 'reason',
86
88
  ],
87
89
  },
88
90
  {
@@ -106,9 +108,11 @@ export const SCENARIO_CORPUS = [
106
108
  },
107
109
  },
108
110
  },
111
+ // 0.5.4: Codex PostToolUse 스키마는 hookSpecificOutput.permissionDecision 을 모른다 —
112
+ // 사영이 top-level decision:"block" + reason 으로 번역하므로 그 키로 동치 비교.
109
113
  compareKeys: [
110
- 'hookSpecificOutput.permissionDecision',
111
- 'hookSpecificOutput.permissionDecisionReason',
114
+ 'decision',
115
+ 'reason',
112
116
  ],
113
117
  },
114
118
  {
@@ -16,12 +16,45 @@
16
16
  import type { HookEventInput, HookEventOutput } from '../core/types.js';
17
17
  import type { HostId } from '../core/trust-layer-intent.js';
18
18
  export type ProjectToClaudeEvent = (raw: unknown, input: HookEventInput) => HookEventOutput;
19
+ interface DecisionView {
20
+ continueFlag: boolean;
21
+ permissionDecision?: string;
22
+ }
23
+ export declare function parseDecision(raw: unknown): DecisionView;
19
24
  /**
20
- * Codex 출력 → Claude HookEventOutput 정식 사영.
25
+ * Codex 출력 정규화 — **Codex 0.153.4 hook 출력 스키마 준수** (ADR-015 G1 + 0.5.4 수정).
21
26
  *
22
- * spec §18.2 fact #3 에 따라 PreToolUse 의 *이중* decision 필드 중 어댑터는
23
- * `hookSpecificOutput.permissionDecision` 을 우선한다. 본 함수가 그 규약을 강제.
27
+ * 이 함수의 출력은 *Codex 가 읽는다* (codex-adapter 가 stdout 으로 내보냄). Codex 는 이벤트별 출력
28
+ * 스키마가 `additionalProperties:false` 라서 **허용되지 않은 키가 하나라도 있으면 출력 전체를 버리고
29
+ * "hook returned invalid ... JSON output" → Failed** 로 처리한다 (codex-rs/hooks/src/engine/
30
+ * output_parser.rs `parse_json` + events/stop.rs `parse_completed`; 스키마 사본은
31
+ * tests/fixtures/codex-hook-schemas/). 특히 Stop/SubagentStop 은 `hookSpecificOutput` 자체를
32
+ * 허용하지 않는다 — 0.5.3 의 사영이 모든 출력에 `hookSpecificOutput.hookEventName` 을 붙여 실환경에서
33
+ * Stop 훅 2개가 매 턴 Failed 로 떨어졌다 (0.5.4 에서 수정, 실세션 재현 후).
34
+ *
35
+ * 규칙 (이벤트별 allowlist — CODEX_OUTPUT_SCHEMA):
36
+ * 1. 객체가 아니면 fail-open `{ continue: true }`.
37
+ * 2. universal 키(`continue`/`stopReason`/`suppressOutput`/`systemMessage`) 보존 (Interrupt 는
38
+ * systemMessage 만).
39
+ * 3. `decision`/`reason` 은 PreToolUse/PostToolUse/UserPromptSubmit/Stop/SubagentStop 에서만 보존.
40
+ * `decision:"block"` 인데 reason 이 비면 systemMessage → 고정 문구로 보강 (Codex 가 거부).
41
+ * 4. `hookSpecificOutput` 은 허용 이벤트에서만, 허용 하위 키만 남기고 `hookEventName` 을 이벤트명으로
42
+ * 고정한다. Stop/SubagentStop/PreCompact/PostCompact/Interrupt 에서는 통째로 제거. 절대 새로
43
+ * 만들어 붙이지 않는다.
44
+ * 5. PostToolUse 에 forgen 이 `permissionDecision:"deny"` (PreToolUse 형) 를 냈으면 Codex 의
45
+ * PostToolUse block 형(top-level `decision:"block"` + `reason`) 으로 번역.
46
+ * 6. PreToolUse: `permissionDecision` 이 있으면 `continue:false` 제거 (Codex "unsupported").
47
+ * 7. legacy `approved:false` → `hookSpecificOutput.permissionDecision:"deny"` (PreToolUse 한정).
48
+ * 8. 이벤트명을 모르면(입력에 없음) 키를 깎지 않고 pass-through 한다 — 모르면 건드리지 않는다.
24
49
  */
50
+ interface EventOutputPolicy {
51
+ universal: ReadonlyArray<string>;
52
+ decision: boolean;
53
+ /** hookSpecificOutput 허용 하위 키. undefined = hookSpecificOutput 자체 불허. */
54
+ hso?: ReadonlyArray<string>;
55
+ }
56
+ /** codex-rs/hooks/schema/generated/*.command.output.schema.json (rust-v0.153.4) 요약. */
57
+ export declare const CODEX_OUTPUT_SCHEMA: Readonly<Record<string, EventOutputPolicy>>;
25
58
  export declare const projectCodexToClaude: ProjectToClaudeEvent;
26
59
  /**
27
60
  * Claude 어댑터의 사영. 1원칙(Claude reference) + spec §18.4 (Codex hooks.json schema 동일성)
@@ -33,3 +66,4 @@ export declare const projectCodexToClaude: ProjectToClaudeEvent;
33
66
  */
34
67
  export declare const projectClaudeToClaude: ProjectToClaudeEvent;
35
68
  export declare function getProjection(host: HostId): ProjectToClaudeEvent;
69
+ export {};
@@ -13,7 +13,9 @@
13
13
  * semantics 알아도 됨, Codex 표면 모름). 즉 Codex CLI 의 stdout 을 받아 코어가 학습 가능한
14
14
  * Claude 형 객체로 변환만 한다.
15
15
  */
16
- function parseDecision(raw) {
16
+ // parseDecision 은 구 사영(결정 → continue:false 변환) 의 잔재. ADR-015 G1 이후 Codex 사영은
17
+ // 호스트 스키마를 보존하므로 사용하지 않는다. 다른 host binding 이 참고할 수 있어 export 만 유지.
18
+ export function parseDecision(raw) {
17
19
  if (typeof raw === 'boolean')
18
20
  return { continueFlag: raw };
19
21
  if (typeof raw === 'string') {
@@ -51,58 +53,107 @@ function parseDecision(raw) {
51
53
  }
52
54
  return { continueFlag: true };
53
55
  }
54
- /**
55
- * Codex 출력 → Claude HookEventOutput 정식 사영.
56
- *
57
- * spec §18.2 fact #3 에 따라 PreToolUse 의 *이중* decision 필드 중 어댑터는
58
- * `hookSpecificOutput.permissionDecision` 을 우선한다. 본 함수가 그 규약을 강제.
59
- */
56
+ const UNIVERSAL = ['continue', 'stopReason', 'suppressOutput', 'systemMessage'];
57
+ /** codex-rs/hooks/schema/generated/*.command.output.schema.json (rust-v0.153.4) 요약. */
58
+ export const CODEX_OUTPUT_SCHEMA = {
59
+ SessionStart: { universal: UNIVERSAL, decision: false, hso: ['hookEventName', 'additionalContext'] },
60
+ SubagentStart: { universal: UNIVERSAL, decision: false, hso: ['hookEventName', 'additionalContext'] },
61
+ UserPromptSubmit: { universal: UNIVERSAL, decision: true, hso: ['hookEventName', 'additionalContext'] },
62
+ PreToolUse: {
63
+ universal: UNIVERSAL,
64
+ decision: true,
65
+ hso: ['hookEventName', 'additionalContext', 'permissionDecision', 'permissionDecisionReason', 'updatedInput'],
66
+ },
67
+ PostToolUse: { universal: UNIVERSAL, decision: true, hso: ['hookEventName', 'additionalContext', 'updatedMCPToolOutput'] },
68
+ PermissionRequest: { universal: UNIVERSAL, decision: false, hso: ['hookEventName', 'decision'] },
69
+ Stop: { universal: UNIVERSAL, decision: true },
70
+ SubagentStop: { universal: UNIVERSAL, decision: true },
71
+ PreCompact: { universal: UNIVERSAL, decision: false },
72
+ PostCompact: { universal: UNIVERSAL, decision: false },
73
+ Interrupt: { universal: ['systemMessage'], decision: false },
74
+ };
75
+ function resolveEventName(raw, input) {
76
+ const hso = raw.hookSpecificOutput;
77
+ const fromOutput = hso && typeof hso === 'object' ? hso.hookEventName : undefined;
78
+ const candidate = input.hookEventName
79
+ ?? input.hook_event_name // 실 stdin 은 snake_case
80
+ ?? input.event
81
+ ?? (typeof fromOutput === 'string' ? fromOutput : undefined);
82
+ return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined;
83
+ }
60
84
  export const projectCodexToClaude = (raw, input) => {
61
- const result = { continue: true };
62
- const decision = parseDecision(raw);
63
- result.continue = decision.continueFlag;
64
- if (typeof raw === 'object' && raw !== null) {
65
- const payload = raw;
66
- if (typeof payload.continue === 'boolean')
67
- result.continue = payload.continue;
68
- if (typeof payload.systemMessage === 'string')
69
- result.systemMessage = payload.systemMessage;
70
- if (typeof payload.suppressOutput === 'boolean')
71
- result.suppressOutput = payload.suppressOutput;
72
- if (typeof payload.hookSpecificOutput === 'object' && payload.hookSpecificOutput !== null) {
73
- result.hookSpecificOutput = { ...payload.hookSpecificOutput };
85
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
86
+ return { continue: true };
87
+ const payload = raw;
88
+ const eventName = resolveEventName(payload, input);
89
+ const policy = eventName ? CODEX_OUTPUT_SCHEMA[eventName] : undefined;
90
+ // 8. 모르는 이벤트 → pass-through (continue 기본값만 보장)
91
+ if (!policy) {
92
+ const out = { ...payload };
93
+ if (typeof out.continue !== 'boolean')
94
+ out.continue = true;
95
+ return out;
96
+ }
97
+ const result = {};
98
+ // 2. universal
99
+ for (const k of policy.universal) {
100
+ const v = payload[k];
101
+ if (v !== undefined && v !== null)
102
+ result[k] = v;
103
+ }
104
+ if (policy.universal.includes('continue') && typeof result.continue !== 'boolean')
105
+ result.continue = true;
106
+ // 3. decision / reason
107
+ if (policy.decision) {
108
+ if (typeof payload.decision === 'string')
109
+ result.decision = payload.decision;
110
+ if (typeof payload.reason === 'string')
111
+ result.reason = payload.reason;
112
+ }
113
+ // 4. hookSpecificOutput — 허용 이벤트 + 허용 키만
114
+ const rawHso = payload.hookSpecificOutput;
115
+ if (policy.hso && rawHso && typeof rawHso === 'object') {
116
+ const filtered = { hookEventName: eventName };
117
+ for (const k of policy.hso) {
118
+ if (k === 'hookEventName')
119
+ continue;
120
+ const v = rawHso[k];
121
+ if (v !== undefined && v !== null)
122
+ filtered[k] = v;
74
123
  }
75
- // top-level decision (Codex 의 PreToolUse 이중 decision 중 legacy 측 또는 Stop/Post 의 단일 측)
76
- // 이 있고, hookSpecificOutput.permissionDecision 이 비었을 때만 보존.
77
- if (typeof payload.decision === 'string' &&
78
- !(result.hookSpecificOutput && 'permissionDecision' in result.hookSpecificOutput)) {
79
- result.hookSpecificOutput = {
80
- ...(result.hookSpecificOutput ?? {}),
81
- permissionDecision: payload.decision,
82
- };
124
+ // 5. PostToolUse: PreToolUse 형 deny → Codex PostToolUse block 형으로 번역
125
+ if (eventName === 'PostToolUse') {
126
+ const pd = rawHso.permissionDecision;
127
+ if (pd === 'deny' || pd === 'block') {
128
+ result.decision = 'block';
129
+ const pdr = rawHso.permissionDecisionReason;
130
+ if (typeof result.reason !== 'string' && typeof pdr === 'string')
131
+ result.reason = pdr;
132
+ }
83
133
  }
134
+ result.hookSpecificOutput = filtered;
84
135
  }
85
- const eventName = result.hookSpecificOutput?.hookEventName ?? input.hookEventName ?? input.event;
86
- if (eventName) {
136
+ // 7. legacy approved boolean (PreToolUse 한정)
137
+ if (eventName === 'PreToolUse' && typeof payload.approved === 'boolean' && !result.hookSpecificOutput?.permissionDecision) {
87
138
  result.hookSpecificOutput = {
88
139
  hookEventName: eventName,
89
140
  ...(result.hookSpecificOutput ?? {}),
141
+ permissionDecision: payload.approved ? 'allow' : 'deny',
90
142
  };
91
143
  }
92
- if (!result.continue && !result.hookSpecificOutput?.permissionDecision) {
93
- if (decision.permissionDecision) {
94
- result.hookSpecificOutput = {
95
- ...(result.hookSpecificOutput ?? {}),
96
- permissionDecision: decision.permissionDecision,
97
- };
98
- }
99
- else {
100
- result.hookSpecificOutput = {
101
- ...(result.hookSpecificOutput ?? {}),
102
- permissionDecision: 'deny',
103
- };
144
+ // 3b. block 은 non-empty reason 필수
145
+ if (typeof result.decision === 'string' && result.decision.toLowerCase() === 'block') {
146
+ const reason = typeof result.reason === 'string' ? result.reason.trim() : '';
147
+ if (!reason) {
148
+ result.reason = typeof result.systemMessage === 'string' && result.systemMessage.trim()
149
+ ? result.systemMessage
150
+ : '[forgen] hook blocked this step; re-check the rule that fired before continuing.';
104
151
  }
105
152
  }
153
+ // 6. PreToolUse: continue:false 는 Codex 미지원 — permissionDecision 이 차단을 표현
154
+ if (eventName === 'PreToolUse' && result.continue === false && typeof result.hookSpecificOutput?.permissionDecision === 'string') {
155
+ result.continue = true;
156
+ }
106
157
  return result;
107
158
  };
108
159
  /**
@@ -7,14 +7,16 @@
7
7
  import * as fs from 'node:fs';
8
8
  import * as path from 'node:path';
9
9
  import * as crypto from 'node:crypto';
10
- import { ME_BEHAVIOR } from '../core/paths.js';
10
+ import { ME_BEHAVIOR, ME_RULES } from '../core/paths.js';
11
11
  import { atomicWriteJSON, safeReadJSON } from '../hooks/shared/atomic-write.js';
12
12
  import { HOST_IDS } from '../core/trust-layer-intent.js';
13
- import { createRule, saveRule, loadActiveRules, updateRuleStatus } from './rule-store.js';
13
+ import { createRule, saveRule, loadActiveRules, updateRuleStatus, findRuleByRenderKey } from './rule-store.js';
14
14
  import { classify, applyProposal } from '../engine/enforce-classifier.js';
15
15
  import { detect as detectT1 } from '../engine/lifecycle/trigger-t1-correction.js';
16
16
  import { foldEvents } from '../engine/lifecycle/orchestrator.js';
17
17
  import { appendLifecycleEvents } from '../engine/lifecycle/meta-reclassifier.js';
18
+ import { laplaceConfidence, strengthForConfidence } from '../engine/correction-clustering.js';
19
+ import { withFileLockSync } from '../hooks/shared/file-lock.js';
18
20
  function evidencePath(evidenceId) {
19
21
  return path.join(ME_BEHAVIOR, `${evidenceId}.json`);
20
22
  }
@@ -200,62 +202,124 @@ function retireStaleAutoMinedRules(now) {
200
202
  for (let i = 0; i < excess; i++)
201
203
  updateRuleStatus(keep[i].r.rule_id, 'removed');
202
204
  }
205
+ /**
206
+ * retire+dedupe+create 시퀀스 전체를 잠그는 lock 타깃. 실제 파일은 만들지 않고
207
+ * (`${target}.lock`) 파생 lock 파일만 생성 — withFileLockSync 관례.
208
+ * RCA (C): 여러 detached auto-compound-runner 가 동시에 이 함수를 돌리면 각자
209
+ * "render_key 없음" 스냅샷을 보고 중복 rule 을 만들 수 있었다(TOCTOU). retire ~
210
+ * create 를 하나의 critical section 으로 묶어 그 창을 없앤다.
211
+ */
212
+ const RULE_STORE_MUTATE_LOCK = path.join(ME_RULES, '.rule-store-mutate');
203
213
  export function promoteSessionCandidates(sessionId) {
204
- // 채굴 룰 TTL 은퇴는 **early-return 앞에서** 실행 (critic 재검증 #1): 승급 후보가 없는
205
- // 조용한 세션에서도 wall-clock TTL 이 실제로 강제되도록. (auto-compound-runner 는 매
206
- // 세션 종료 시 promoteSessionCandidates 를 호출하므로 이 경로가 세션당 1회 보장된다.)
207
- retireStaleAutoMinedRules(Date.now());
208
- const candidates = loadPromotionCandidates().filter(e => e.session_id === sessionId);
209
- if (candidates.length === 0)
210
- return 0;
211
- const activeRules = loadActiveRules();
212
- const existingRenderKeys = new Set(activeRules.filter(r => r.scope === 'me').map(r => r.render_key));
213
- let promoted = 0;
214
- for (const candidate of candidates) {
215
- const payload = candidate.raw_payload;
216
- const axisHint = payload?.axis_hint;
217
- const target = payload?.target;
218
- const kind = payload?.kind;
219
- const autoMined = payload?.auto_mined === true;
220
- if (!target)
221
- continue;
222
- // 채굴 룰은 "auto:" 네임스페이스 — 실시간 교정 render_key 와 절대 충돌 안 함 (SEV-2 #4).
223
- const baseKey = `${axisHint ?? 'workflow'}.${target.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}`;
224
- const renderKey = autoMined ? `${AUTO_MINED_PREFIX}${baseKey}` : baseKey;
225
- if (existingRenderKeys.has(renderKey))
226
- continue;
227
- const category = axisHint === 'quality_safety' ? 'quality'
228
- : axisHint === 'autonomy' ? 'autonomy'
229
- : 'workflow';
230
- // ADR-013: 채굴 교정(auto_mined)은 실시간 명시 교정과 엄격 차등 — provenance
231
- // =behavior_inference, strength 절대 strong 아님(default).
232
- let rule = createRule({
233
- category,
234
- scope: 'me',
235
- trigger: target,
236
- policy: candidate.summary,
237
- strength: autoMined ? 'default' : kind === 'avoid-this' ? 'strong' : 'default',
238
- source: autoMined ? 'behavior_inference' : 'explicit_correction',
239
- evidence_refs: [candidate.evidence_id],
240
- render_key: renderKey,
241
- });
242
- if (autoMined) {
243
- // advisory-only (SEV-1 #2): classify() 를 돌리지 않고 enforce_via 를 비워, 채굴 룰이
244
- // 절대 Mech-A 차단(PreToolUse/Stop block)을 얻지 못하게 한다. 컨텍스트 주입만 되고
245
- // 어떤 훅도 강제하지 않음 — 환각 교정이 영구 차단 룰이 되는 위험 근절.
246
- rule.enforce_via = [];
247
- }
248
- else {
249
- // ADR-001 auto-classify — 실시간 승격 rule 에만 enforce_via 자동 주입.
250
- try {
251
- const proposal = classify(rule);
252
- rule = applyProposal(rule, proposal);
214
+ // withFileLockSync 는 O_CREAT 로 lock 파일을 만들 뿐 부모 디렉터리는 만들지 않는다 —
215
+ // 첫 rule 승급 이전(=아직 ME_RULES 없음)에도 락을 잡을 수 있어야 하므로 선행 mkdir.
216
+ fs.mkdirSync(ME_RULES, { recursive: true });
217
+ // 락 타임아웃/보수 시간은 이 critical section 이 짧다는 전제(디렉터리 스캔 + 파일
218
+ // 몇 개 쓰기, 초 단위 아님) 로 file-lock.ts 기본값(2s/30s)보다 넉넉히 잡되, 죽은
219
+ // holder 를 오래 방치하지 않도록 stale 은 짧게 유지한다.
220
+ return withFileLockSync(RULE_STORE_MUTATE_LOCK, () => {
221
+ // 채굴 룰 TTL 은퇴는 **early-return 앞에서** 실행 (critic 재검증 #1): 승급 후보가 없는
222
+ // 조용한 세션에서도 wall-clock TTL 이 실제로 강제되도록. (auto-compound-runner 는 매
223
+ // 세션 종료 시 promoteSessionCandidates 를 호출하므로 이 경로가 세션당 1회 보장된다.)
224
+ retireStaleAutoMinedRules(Date.now());
225
+ const candidates = loadPromotionCandidates().filter(e => e.session_id === sessionId);
226
+ if (candidates.length === 0)
227
+ return 0;
228
+ let promoted = 0;
229
+ for (const candidate of candidates) {
230
+ const payload = candidate.raw_payload;
231
+ const axisHint = payload?.axis_hint;
232
+ const target = payload?.target;
233
+ const kind = payload?.kind;
234
+ const autoMined = payload?.auto_mined === true;
235
+ if (!target)
236
+ continue;
237
+ // 채굴 룰은 "auto:" 네임스페이스 — 실시간 교정 render_key 와 절대 충돌 안 함 (SEV-2 #4).
238
+ const baseKey = `${axisHint ?? 'workflow'}.${target.toLowerCase().replace(/\s+/g, '-').slice(0, 30)}`;
239
+ const renderKey = autoMined ? `${AUTO_MINED_PREFIX}${baseKey}` : baseKey;
240
+ const source = autoMined ? 'behavior_inference' : 'explicit_correction';
241
+ // (B) 이 evidence 가 과거 어떤 rule 로든 이미 소비됐는가 — candidate_rule_refs 를
242
+ // "승급 완료" 마커로 재사용한다(T1 이 rule_id 매칭에 쓰는 필드와 동일 필드,
243
+ // 의미상 호환: "이 evidence 가 가리키는 rule"). loadPromotionCandidates() 는
244
+ // evidence 를 영구히 반환하므로, 이 마커가 없으면 재-sweep 마다 같은 evidence 가
245
+ // 무한 재처리된다.
246
+ const alreadyConsumed = (candidate.candidate_rule_refs ?? []).length > 0;
247
+ // (A) render_key upsert identity — status 무관 기존 rule 조회. TTL/cap 로
248
+ // retire(status='removed')된 render_key 도 여기서 발견되어 "재생성"이 아니라
249
+ // "재활성화"된다 — 새 rule_id/created_at 을 절대 새로 만들지 않는다.
250
+ // scope:'me' 로 한정 — processCorrection() 이 fix-now/avoid-this 에 대해 만드는
251
+ // scope:'session' 임시 rule 은 같은 render_key 공식을 쓰지만 별개 개체다(승격 대상 아님).
252
+ const existingRule = findRuleByRenderKey(renderKey, source, 'me');
253
+ if (existingRule) {
254
+ if (alreadyConsumed)
255
+ continue; // 같은 evidence 의 sweep 재관측 — no-op.
256
+ const evidenceRefs = existingRule.evidence_refs.includes(candidate.evidence_id)
257
+ ? existingRule.evidence_refs
258
+ : [...existingRule.evidence_refs, candidate.evidence_id];
259
+ // (D1) confidence 재계산 — correction-clustering.ts 의 Laplace 계승법칙을 그대로
260
+ // 상속(리터럴 복제 아님). N=evidence_refs.length(=관측 반복 횟수). auto_mined 은
261
+ // ADR-013 불변식(advisory-only, strong 절대 자동 도달 금지)을 유지하기 위해
262
+ // 몇 번을 재확인해도 'default' 를 벗어나지 않는다. hard 는 confidence 로 낮추지
263
+ // 않는다(안전 룰은 강도 하락이 없어야 함).
264
+ const strength = autoMined
265
+ ? 'default'
266
+ : existingRule.strength === 'hard'
267
+ ? 'hard'
268
+ : strengthForConfidence(laplaceConfidence(evidenceRefs.length));
269
+ // TTL-on-reactivation 결정: created_at 은 절대 now 로 리셋하지 않는다(carry-forward).
270
+ // 이유 — AUTO_MINED_TTL_MS 는 "이 개념이 처음 채굴된 이후 최대 수명"을 뜻하는
271
+ // 하드 상한이지, "마지막으로 확인된 이후 경과 시간"이 아니다. created_at 을
272
+ // 리셋하면 반복 채굴(같은 세션이 오래 지속되며 매 sweep 마다 재-mine)로 TTL 을
273
+ // 무한 연장할 수 있어 은퇴 메커니즘 자체가 무력화된다(원 결함의 재발). 대신
274
+ // rule_id/created_at 은 그대로 두고 status 만 'active' 로 되돌린다 — 재활성화된
275
+ // rule 은 다음 retireStaleAutoMinedRules() 호출 때 기존 나이 기준으로 다시
276
+ // 은퇴 여부가 평가된다(의도된 동작, thrash 아님: 최소 1세션은 유효하게 주입됨).
277
+ saveRule({ ...existingRule, status: 'active', evidence_refs: evidenceRefs, strength });
278
+ saveEvidence({
279
+ ...candidate,
280
+ candidate_rule_refs: [...(candidate.candidate_rule_refs ?? []), existingRule.rule_id],
281
+ });
282
+ promoted++;
283
+ continue;
284
+ }
285
+ if (alreadyConsumed)
286
+ continue; // 소비 마킹된 evidence 인데 매칭 rule 없음(이례) — 재생성 금지.
287
+ const category = axisHint === 'quality_safety' ? 'quality'
288
+ : axisHint === 'autonomy' ? 'autonomy'
289
+ : 'workflow';
290
+ // ADR-013: 채굴 교정(auto_mined)은 실시간 명시 교정과 엄격 차등 — provenance
291
+ // =behavior_inference, strength 절대 strong 아님(default).
292
+ let rule = createRule({
293
+ category,
294
+ scope: 'me',
295
+ trigger: target,
296
+ policy: candidate.summary,
297
+ strength: autoMined ? 'default' : kind === 'avoid-this' ? 'strong' : 'default',
298
+ source,
299
+ evidence_refs: [candidate.evidence_id],
300
+ render_key: renderKey,
301
+ });
302
+ if (autoMined) {
303
+ // advisory-only (SEV-1 #2): classify() 를 돌리지 않고 enforce_via 를 비워, 채굴 룰이
304
+ // 절대 Mech-A 차단(PreToolUse/Stop block)을 얻지 못하게 한다. 컨텍스트 주입만 되고
305
+ // 어떤 훅도 강제하지 않음 — 환각 교정이 영구 차단 룰이 되는 위험 근절.
306
+ rule.enforce_via = [];
253
307
  }
254
- catch { /* fail-open */ }
308
+ else {
309
+ // ADR-001 auto-classify — 실시간 승격 rule 에만 enforce_via 자동 주입.
310
+ try {
311
+ const proposal = classify(rule);
312
+ rule = applyProposal(rule, proposal);
313
+ }
314
+ catch { /* fail-open */ }
315
+ }
316
+ saveRule(rule);
317
+ saveEvidence({
318
+ ...candidate,
319
+ candidate_rule_refs: [...(candidate.candidate_rule_refs ?? []), rule.rule_id],
320
+ });
321
+ promoted++;
255
322
  }
256
- saveRule(rule);
257
- existingRenderKeys.add(renderKey);
258
- promoted++;
259
- }
260
- return promoted;
323
+ return promoted;
324
+ }, { timeoutMs: 8000, staleMs: 15000 });
261
325
  }
@@ -33,6 +33,23 @@ export declare function appendRule(rule: Rule): Promise<{
33
33
  export declare function loadRule(ruleId: string): Rule | null;
34
34
  export declare function loadAllRules(): Rule[];
35
35
  export declare function loadActiveRules(): Rule[];
36
+ /**
37
+ * render_key(+source, +scope)로 기존 rule을 찾는다 — status 무관(active/suppressed/removed/
38
+ * superseded 전부 포함). ADR-013 채굴 룰 재생성 결함 수정(RCA): promoteSessionCandidates가
39
+ * loadActiveRules() 로만 dedup 하다 보니 TTL/cap 로 은퇴(removed)된 render_key가 재차 채굴되면
40
+ * "없음" 으로 오판해 매번 새 rule_id + created_at=now 로 재생성 — 이게 6,701개 파일 중
41
+ * 6,525개가 removed 로 죽어 쌓인 근본 원인. 이 함수로 upsert identity 를 render_key 로
42
+ * 고정한다: 있으면 재활성화, 없을 때만 신규 생성.
43
+ *
44
+ * scope 필터가 필요한 이유: processCorrection()(evidence-processor.ts)이 fix-now/avoid-this
45
+ * 에 대해 scope:'session' 임시 rule 을 만들 때 *같은 render_key 공식*(`${axis}.${target-slug}`)
46
+ * 을 쓴다. scope 를 안 걸면 promoteSessionCandidates 가 그 임시 rule 을 "기존 me rule" 로
47
+ * 오인해 재활성화해버려 영구 승격이 아예 안 되는 회귀가 생긴다(실측: 회귀 테스트로 발견).
48
+ *
49
+ * 동일 render_key 매칭이 여럿(레거시 데이터 등 이례적 상황)이면 active 우선, 그다음
50
+ * updated_at 최신순으로 하나만 반환.
51
+ */
52
+ export declare function findRuleByRenderKey(renderKey: string, source?: RuleSource, scope?: RuleScope): Rule | null;
36
53
  /**
37
54
  * ADR-002 Meta signal — rule 들이 프롬프트에 inject 되었음을 기록.
38
55
  * rule.lifecycle.inject_count +1, last_inject_at = now.
@@ -45,4 +62,28 @@ export declare function updateRuleStatus(ruleId: string, status: RuleStatus): bo
45
62
  * 현재 세션 ID와 다른 scope:'session' 규칙을 비활성화.
46
63
  * 이전 세션의 임시 규칙이 새 세션에서 영향을 미치지 않도록 정리.
47
64
  */
65
+ /** status:'removed' rule 파일 정리 결과. */
66
+ export interface PruneResult {
67
+ /** retention 기간을 지나 삭제 대상인 rule_id (dry-run/apply 공통). */
68
+ candidates: string[];
69
+ /** apply=true 일 때 실제 unlink 된 rule_id (dry-run 이면 항상 빈 배열). */
70
+ deleted: string[];
71
+ }
72
+ /** removed 룰 보관 기간 기본값 — 7일. TTL 로 은퇴된 지 이만큼 지나면 prune 대상. */
73
+ export declare const DEFAULT_PRUNE_RETENTION_MS: number;
74
+ /**
75
+ * status:'removed' 파일을 retention 기간이 지난 것만 실제 unlink.
76
+ * RCA: retire(updateRuleStatus)는 status flip만 하고 파일을 지우지 않아 6,525개 죽은
77
+ * 파일이 ~/.forgen/me/rules 에 쌓여 loadAllRules() linear scan 비용을 키우고
78
+ * cross-process race 창을 넓혔다. status 전이 시각(updated_at) 기준으로 retention을
79
+ * 재는 이유: removed 직후 즉시 지우면 (a) findRuleByRenderKey 가 재활성화할 기회를
80
+ * 없애고 (b) 오탐 은퇴를 되돌릴 유예가 사라진다.
81
+ *
82
+ * 기본 dry-run(apply=false) — 프로덕션 데이터 삭제는 호출측이 명시적으로 apply:true
83
+ * 를 넘길 때만. (compound-sweep-cli 의 `--prune-removed`는 `--apply` 없으면 dry-run.)
84
+ */
85
+ export declare function pruneRemovedRules(opts?: {
86
+ retentionMs?: number;
87
+ apply?: boolean;
88
+ }): PruneResult;
48
89
  export declare function cleanupStaleSessionRules(_currentSessionId: string): number;