@wooojin/forgen 0.4.3 → 0.4.5

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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +235 -0
  3. package/README.ko.md +14 -0
  4. package/README.md +32 -0
  5. package/assets/claude/commands/calibrate.md +4 -3
  6. package/assets/claude/commands/forge-loop.md +62 -2
  7. package/assets/claude/commands/retro.md +2 -2
  8. package/dist/checks/_shared/text-sanitizer.d.ts +21 -0
  9. package/dist/checks/_shared/text-sanitizer.js +60 -0
  10. package/dist/checks/dangerous-response-pattern.d.ts +32 -0
  11. package/dist/checks/dangerous-response-pattern.js +65 -0
  12. package/dist/checks/fact-vs-agreement.js +25 -1
  13. package/dist/cli.js +8 -0
  14. package/dist/core/auto-compound-runner.js +31 -4
  15. package/dist/core/settings-injector.js +8 -2
  16. package/dist/core/statusline-cli.d.ts +13 -0
  17. package/dist/core/statusline-cli.js +150 -0
  18. package/dist/hooks/hook-registry.js +9 -4
  19. package/dist/hooks/stop-guard.js +56 -39
  20. package/dist/host/install-claude.js +34 -3
  21. package/dist/mcp/tools.js +4 -0
  22. package/dist/renderer/rule-renderer.d.ts +1 -1
  23. package/dist/renderer/rule-renderer.js +73 -1
  24. package/dist/store/compound-usage-store.d.ts +28 -0
  25. package/dist/store/compound-usage-store.js +59 -0
  26. package/package.json +1 -1
  27. package/plugin.json +1 -1
  28. package/scripts/postinstall.js +61 -6
  29. package/skills/architecture-decision/SKILL.md +21 -0
  30. package/skills/calibrate/SKILL.md +25 -3
  31. package/skills/code-review/SKILL.md +21 -0
  32. package/skills/compound/SKILL.md +21 -0
  33. package/skills/deep-interview/SKILL.md +21 -0
  34. package/skills/docker/SKILL.md +21 -0
  35. package/skills/forge-loop/SKILL.md +76 -1
  36. package/skills/learn/SKILL.md +21 -0
  37. package/skills/retro/SKILL.md +23 -2
  38. package/skills/ship/SKILL.md +21 -0
@@ -47,6 +47,26 @@ const AGREEMENT_SOFTENERS = [
47
47
  /(생각합니다|생각함|생각해|봅니다|예상(합니다|돼))/,
48
48
  /(그럴\s*것\s*같|맞을\s*것\s*같)/,
49
49
  ];
50
+ /**
51
+ * 측정-증거 지표 — 실제 실행/측정 결과가 응답에 *paste 되어 있다*는 신호.
52
+ *
53
+ * v0.4.4 (2026-05-06): FP 감소. "Docker e2e 77/77 PASS" 같은 *정량 사실 보고*
54
+ * 가 recentTools 윈도우 밖 측정 (예: 이전 turn Bash 결과, 사용자 paste, CI 로그
55
+ * 인용)이라 Bash 카운트가 0이지만 본질적으로 measurement-backed 응답.
56
+ *
57
+ * 임계: 본 패턴이 2+ 매칭되면 alert 억제 (응답이 측정 증거를 *제시*하고 있다고 본다).
58
+ */
59
+ const EVIDENCE_INDICATORS = [
60
+ /\b\d+\/\d+\b/, // test counts: "77/77", "22/22"
61
+ /\bexit\s*code\s*[:=]?\s*\d+/i, // exit code
62
+ /\b\d+(\.\d+)?\s*(ms|s|sec|seconds)\b/i, // timings: "232s", "1.5ms"
63
+ /\b(?:Test|Spec)s?\s*Files?\s+\d+/i, // vitest "Test Files 218"
64
+ /\b(?:Tests?:?\s+)?\d+\s+passed?\b/i, // "2382 passed"
65
+ /\b(?:CI|HEAD|sha|commit)\s*[:=]?\s*[a-f0-9]{7,}/i, // commit ref
66
+ /^[+-]{3}\s/m, // diff hunks
67
+ /\bcoverage\s*[:=]?\s*\d+(\.\d+)?%/i, // coverage %
68
+ /^\s*✓\s|^\s*✗\s|^\s*PASS\b|^\s*FAIL\b/m, // test runner output markers
69
+ ];
50
70
  function findMatches(text, patterns, max = 3) {
51
71
  const out = [];
52
72
  for (const p of patterns) {
@@ -69,8 +89,12 @@ export function checkFactVsAgreement(input) {
69
89
  const factAssertions = findMatches(text, FACT_ASSERTION_PATTERNS);
70
90
  const agreementSofteners = findMatches(text, AGREEMENT_SOFTENERS);
71
91
  const measurementCount = recentTools.filter((t) => MEASUREMENT_TOOL_CATEGORIES.has(t)).length;
92
+ // Evidence indicator suppression — 응답에 측정 결과가 *paste* 되어 있으면
93
+ // recentTools 윈도우 밖 측정으로 보고 alert 억제 (FP 감소).
94
+ const evidenceIndicators = findMatches(text, EVIDENCE_INDICATORS, 99);
95
+ const hasMeasurementEvidence = evidenceIndicators.length >= 2;
72
96
  const hasFactAssertion = factAssertions.length > 0;
73
- const measurementMissing = measurementCount < minMeasurements;
97
+ const measurementMissing = measurementCount < minMeasurements && !hasMeasurementEvidence;
74
98
  const alert = hasFactAssertion && measurementMissing;
75
99
  let reason = '';
76
100
  if (alert) {
package/dist/cli.js CHANGED
@@ -95,6 +95,14 @@ const commands = [
95
95
  await handleInspect(['profile']);
96
96
  },
97
97
  },
98
+ {
99
+ name: 'statusline',
100
+ description: 'Compact HUD for Claude Code statusLine (reads stdin JSON)',
101
+ handler: async (_args) => {
102
+ const { handleStatusline } = await import('./core/statusline-cli.js');
103
+ await handleStatusline();
104
+ },
105
+ },
98
106
  {
99
107
  name: 'config',
100
108
  description: 'Configuration (hooks [--regenerate])',
@@ -40,17 +40,22 @@ function execClaudeRetry(args, opts) {
40
40
  if (host === 'claude') {
41
41
  // Claude 측은 기존 보안 hardening 보존: --allowedTools 등 args 그대로 전달.
42
42
  const TRANSIENT = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EPIPE/;
43
- for (let attempt = 0; attempt < 2; attempt++) {
43
+ const MAX_ATTEMPTS = 2;
44
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
44
45
  try {
45
46
  return execFileSync('claude', args, opts);
46
47
  }
47
48
  catch (e) {
48
49
  const msg = e instanceof Error ? e.message : String(e);
49
- if (attempt === 0 && TRANSIENT.test(msg)) {
50
- process.stderr.write(`[forgen-auto-compound] transient error, retrying in 3s...\n`);
50
+ const match = msg.match(TRANSIENT);
51
+ if (attempt < MAX_ATTEMPTS && match) {
52
+ process.stderr.write(`[forgen-auto-compound] ${match[0]} on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in 3s (auto-recovery)...\n`);
51
53
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 3000);
52
54
  continue;
53
55
  }
56
+ if (match) {
57
+ process.stderr.write(`[forgen-auto-compound] ${match[0]} after ${attempt}/${MAX_ATTEMPTS} attempts — giving up (fail-open)\n`);
58
+ }
54
59
  throw e;
55
60
  }
56
61
  }
@@ -425,7 +430,9 @@ ${profileContext}
425
430
  "pack_direction": null 또는 "opposite_quality" 또는 "opposite_autonomy",
426
431
  "profile_delta": {
427
432
  "quality_safety": { "verification_depth": 0.0, "stop_threshold": 0.0, "change_conservatism": 0.0 },
428
- "autonomy": { "confirmation_independence": 0.0, "assumption_tolerance": 0.0, "scope_expansion_tolerance": 0.0, "approval_threshold": 0.0 }
433
+ "autonomy": { "confirmation_independence": 0.0, "assumption_tolerance": 0.0, "scope_expansion_tolerance": 0.0, "approval_threshold": 0.0 },
434
+ "judgment_philosophy": { "minimal_change_bias": 0.0, "abstraction_bias": 0.0, "evidence_first_bias": 0.0 },
435
+ "communication_style": { "verbosity": 0.0, "structure": 0.0, "teaching_bias": 0.0 }
429
436
  }
430
437
  }
431
438
 
@@ -493,6 +500,26 @@ ${sanitizedSummary.slice(0, 4000)}
493
500
  }
494
501
  }
495
502
  }
503
+ if (parsed.profile_delta.judgment_philosophy) {
504
+ const d = parsed.profile_delta.judgment_philosophy;
505
+ const f = profile.axes.judgment_philosophy.facets;
506
+ for (const [k, v] of Object.entries(d)) {
507
+ if (typeof v === 'number' && Math.abs(v) > 0.001 && k in f) {
508
+ f[k] = clamp(f[k] + v);
509
+ changed = true;
510
+ }
511
+ }
512
+ }
513
+ if (parsed.profile_delta.communication_style) {
514
+ const d = parsed.profile_delta.communication_style;
515
+ const f = profile.axes.communication_style.facets;
516
+ for (const [k, v] of Object.entries(d)) {
517
+ if (typeof v === 'number' && Math.abs(v) > 0.001 && k in f) {
518
+ f[k] = clamp(f[k] + v);
519
+ changed = true;
520
+ }
521
+ }
522
+ }
496
523
  if (changed) {
497
524
  profile.metadata.updated_at = new Date().toISOString();
498
525
  fs.writeFileSync(V1_PROFILE, JSON.stringify(profile, null, 2));
@@ -43,12 +43,18 @@ function readSettingsWithBackup() {
43
43
  }
44
44
  return settings;
45
45
  }
46
- /** Apply forgen statusLine only if user hasn't set a custom one. */
46
+ /** Apply forgen statusLine only if user hasn't set a custom one.
47
+ * Migration: 'forgen me' → 'forgen statusline' (multi-line dump → compact HUD). */
47
48
  function applyStatusLine(settings) {
48
49
  const existing = settings.statusLine;
50
+ // 기존에 'forgen me'로 주입된 경우 → 'forgen statusline'으로 자동 마이그레이션
51
+ if (existing?.command === 'forgen me') {
52
+ settings.statusLine = { type: 'command', command: 'forgen statusline' };
53
+ return;
54
+ }
49
55
  const isForgenOwned = !existing || !existing.command || existing.command.startsWith('forgen');
50
56
  if (isForgenOwned) {
51
- settings.statusLine = { type: 'command', command: 'forgen me' };
57
+ settings.statusLine = { type: 'command', command: 'forgen statusline' };
52
58
  }
53
59
  }
54
60
  /** Check if a settings.json hook entry was installed by forgen. */
@@ -0,0 +1,13 @@
1
+ /**
2
+ * forgen statusline — Claude Code statusLine 명령
3
+ *
4
+ * Claude Code는 statusLine.command를 주기적으로 호출하고 stdin에 JSON을 전달함.
5
+ * 이 명령은 compact multi-line 형식으로 HUD 정보를 출력함.
6
+ *
7
+ * Line 1: 모델 | cwd | git branch
8
+ * Line 2: (TODO: context/usage — stdin spec 미확인으로 생략)
9
+ * Line 3: CLAUDE.md count | rules count | MCPs count | hooks count
10
+ * Line 4: (TODO: tool counts — 추적 인프라 없음)
11
+ * Line 5: (TODO: active task — 추적 인프라 없음)
12
+ */
13
+ export declare function handleStatusline(): Promise<void>;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * forgen statusline — Claude Code statusLine 명령
3
+ *
4
+ * Claude Code는 statusLine.command를 주기적으로 호출하고 stdin에 JSON을 전달함.
5
+ * 이 명령은 compact multi-line 형식으로 HUD 정보를 출력함.
6
+ *
7
+ * Line 1: 모델 | cwd | git branch
8
+ * Line 2: (TODO: context/usage — stdin spec 미확인으로 생략)
9
+ * Line 3: CLAUDE.md count | rules count | MCPs count | hooks count
10
+ * Line 4: (TODO: tool counts — 추적 인프라 없음)
11
+ * Line 5: (TODO: active task — 추적 인프라 없음)
12
+ */
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import * as os from 'node:os';
16
+ import { execSync } from 'node:child_process';
17
+ import { loadActiveRules } from '../store/rule-store.js';
18
+ // ANSI codes
19
+ const DIM = '\x1b[2m';
20
+ const CYAN = '\x1b[36m';
21
+ const GREEN = '\x1b[32m';
22
+ const YELLOW = '\x1b[33m';
23
+ const BOLD = '\x1b[1m';
24
+ const RESET = '\x1b[0m';
25
+ function readStdinJson() {
26
+ // stdin이 TTY면 파이프 입력 없음 → 빈 payload로 fallback
27
+ if (process.stdin.isTTY)
28
+ return {};
29
+ try {
30
+ const raw = fs.readFileSync('/dev/stdin', 'utf-8').trim();
31
+ if (!raw)
32
+ return {};
33
+ return JSON.parse(raw);
34
+ }
35
+ catch {
36
+ return {};
37
+ }
38
+ }
39
+ function getGitBranch(cwd) {
40
+ try {
41
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
42
+ cwd,
43
+ stdio: ['ignore', 'pipe', 'ignore'],
44
+ timeout: 2000,
45
+ })
46
+ .toString()
47
+ .trim();
48
+ const isDirty = (() => {
49
+ try {
50
+ const status = execSync('git status --porcelain', {
51
+ cwd,
52
+ stdio: ['ignore', 'pipe', 'ignore'],
53
+ timeout: 2000,
54
+ }).toString().trim();
55
+ return status.length > 0;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ })();
61
+ return `git:(${branch}${isDirty ? '*' : ''})`;
62
+ }
63
+ catch {
64
+ return '';
65
+ }
66
+ }
67
+ function getSettingsJson(claudeDir) {
68
+ const settingsPath = path.join(claudeDir, 'settings.json');
69
+ if (!fs.existsSync(settingsPath))
70
+ return {};
71
+ try {
72
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
73
+ }
74
+ catch {
75
+ return {};
76
+ }
77
+ }
78
+ function countMcps(settings) {
79
+ const mcpServers = settings.mcpServers;
80
+ if (!mcpServers || typeof mcpServers !== 'object')
81
+ return 0;
82
+ return Object.keys(mcpServers).length;
83
+ }
84
+ function countHooks(settings) {
85
+ const hooks = settings.hooks;
86
+ if (!hooks || typeof hooks !== 'object')
87
+ return 0;
88
+ return Object.values(hooks).reduce((acc, matchers) => {
89
+ if (!Array.isArray(matchers))
90
+ return acc;
91
+ return acc + matchers.length;
92
+ }, 0);
93
+ }
94
+ function countClaudeMd(cwd) {
95
+ try {
96
+ const result = execSync('find . -maxdepth 2 -name CLAUDE.md', {
97
+ cwd,
98
+ stdio: ['ignore', 'pipe', 'ignore'],
99
+ timeout: 3000,
100
+ }).toString().trim();
101
+ if (!result)
102
+ return 0;
103
+ return result.split('\n').filter(Boolean).length;
104
+ }
105
+ catch {
106
+ return 0;
107
+ }
108
+ }
109
+ function buildLine1(payload, cwd) {
110
+ const modelName = payload.model?.display_name ?? 'Claude';
111
+ const gitBranch = getGitBranch(cwd);
112
+ const cwdDisplay = cwd.replace(os.homedir(), '~');
113
+ const parts = [`${BOLD}${CYAN}${modelName}${RESET}`];
114
+ parts.push(`${DIM}${cwdDisplay}${RESET}`);
115
+ if (gitBranch)
116
+ parts.push(`${GREEN}${gitBranch}${RESET}`);
117
+ return parts.join(` ${DIM}|${RESET} `);
118
+ }
119
+ function buildLine3(claudeDir, cwd) {
120
+ const settings = getSettingsJson(claudeDir);
121
+ const claudeMdCount = countClaudeMd(cwd);
122
+ const rulesCount = (() => {
123
+ try {
124
+ return loadActiveRules().length;
125
+ }
126
+ catch {
127
+ return 0;
128
+ }
129
+ })();
130
+ const mcpCount = countMcps(settings);
131
+ const hookCount = countHooks(settings);
132
+ return [
133
+ `${YELLOW}${claudeMdCount} CLAUDE.md${RESET}`,
134
+ `${YELLOW}${rulesCount} rules${RESET}`,
135
+ `${YELLOW}${mcpCount} MCPs${RESET}`,
136
+ `${YELLOW}${hookCount} hooks${RESET}`,
137
+ ].join(` ${DIM}|${RESET} `);
138
+ }
139
+ export async function handleStatusline() {
140
+ const payload = readStdinJson();
141
+ const cwd = payload.workspace?.current_dir ?? process.cwd();
142
+ const claudeDir = path.join(os.homedir(), '.claude');
143
+ const line1 = buildLine1(payload, cwd);
144
+ const line3 = buildLine3(claudeDir, cwd);
145
+ // Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
146
+ // Line 4 (tool counts): 추적 인프라 없음 — TODO
147
+ // Line 5 (active task): 추적 인프라 없음 — TODO
148
+ console.log(line1);
149
+ console.log(line3);
150
+ }
@@ -10,8 +10,9 @@
10
10
  * - safety: 범용 안전 훅 (기본 활성, 개별 비활성 가능)
11
11
  * - workflow: 워크플로우 스킬 훅 (다른 플러그인 감지 시 자동 비활성)
12
12
  */
13
- import { createRequire } from 'node:module';
14
- const require = createRequire(import.meta.url);
13
+ import { readFileSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname, join } from 'node:path';
15
16
  /**
16
17
  * 단일 소스 오브 트루스: hooks/hook-registry.json
17
18
  *
@@ -19,9 +20,13 @@ const require = createRequire(import.meta.url);
19
20
  * - pre-tool-use는 db-guard/rate-limiter보다 앞에 위치
20
21
  * (Code Reflection + permission hints 주입 타이밍)
21
22
  * - 같은 이벤트 내 훅은 배열 순서대로 실행됨
23
+ *
24
+ * Why readFileSync (not `import ... with { type: 'json' }`):
25
+ * Import attributes는 Node 20.10+에서만 파싱됨. 20.0-20.9 사용자가 npm i -g
26
+ * 이후 모든 훅이 SyntaxError로 깨지는 것을 방지하기 위해 fs.readFileSync 사용.
22
27
  */
23
- import registryData from '../../assets/shared/hook-registry.json' with { type: 'json' };
24
- export const HOOK_REGISTRY = registryData;
28
+ const REGISTRY_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'shared', 'hook-registry.json');
29
+ export const HOOK_REGISTRY = JSON.parse(readFileSync(REGISTRY_PATH, 'utf-8'));
25
30
  /** 티어별 훅 목록 조회 */
26
31
  export function getHooksByTier(tier) {
27
32
  return HOOK_REGISTRY.filter(h => h.tier === tier);
@@ -27,6 +27,8 @@ import { takeLastExtractionNotice } from '../core/extraction-notice.js';
27
27
  import { checkConclusionVerificationRatio } from '../checks/conclusion-verification-ratio.js';
28
28
  import { checkSelfScoreInflation } from '../checks/self-score-deflation.js';
29
29
  import { checkFactVsAgreement } from '../checks/fact-vs-agreement.js';
30
+ import { checkDangerousResponsePattern } from '../checks/dangerous-response-pattern.js';
31
+ import { sanitizeForGuard } from '../checks/_shared/text-sanitizer.js';
30
32
  import { STATE_DIR } from '../core/paths.js';
31
33
  import { sanitizeId } from './shared/sanitize-id.js';
32
34
  import { detectRecallReferences } from '../core/recall-reference-detector.js';
@@ -496,58 +498,73 @@ export async function main() {
496
498
  // block/approve 어느 경로이든 동일하게 기록 (참조는 응답 내용이 결정).
497
499
  const sessionIdForRef = input?.session_id ?? 'unknown';
498
500
  emitRecallReferencesFailOpen(sessionIdForRef, lastMessage);
499
- // TEST-2/3: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
501
+ // TEST-1/2/3: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
502
+ // Pathfinder D7: 3중 보일러플레이트를 CHECKS 배열 + for-loop 디스패처로 통합.
503
+ // Pathfinder D4/D5: sanitizeForGuard 가 모든 체크 입력 단계에 일괄 적용.
500
504
  if (process.env.FORGEN_USER_CONFIRMED !== '1') {
501
505
  const sessionId = input?.session_id ?? 'unknown';
502
- // TEST-2 (자가 점수 인플레이션): 숫자 점수 상승 선언 + 측정 도구 0회 → block.
503
- // TEST-3 보다 강한 신호라 먼저 평가.
504
506
  const recentTools = loadRecentToolNames(sessionId);
505
- const score = checkSelfScoreInflation({ text: lastMessage, recentTools });
506
- if (score.block) {
507
- recordViolation({
508
- rule_id: 'builtin:self-score-inflation',
509
- session_id: sessionId,
510
- source: 'stop-guard',
507
+ const sanitized = sanitizeForGuard(lastMessage);
508
+ // 평가 순서: DANGEROUS-RESPONSE (즉시 차단 — 안전 우선) → TEST-2 (강한 신호) → TEST-3 (텍스트 비율) → TEST-1 (alert-only).
509
+ const checks = [
510
+ {
511
+ shortId: 'dangerous-response-pattern',
512
+ ruleSlug: 'rule:DANGEROUS-RESPONSE — destructive command suggestion',
511
513
  kind: 'block',
512
- message_preview: lastMessage.slice(0, 120),
513
- });
514
- const reasonText = `[forgen:stop-guard/self-score-inflation] ${score.reason}
515
-
516
- (Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
517
- console.log(blockStop(reasonText, 'rule:TEST-2 — self-score inflation'));
518
- return;
519
- }
520
- // TEST-3: 결론/검증 비율 — Claude 가 실제 측정 도구는 돌렸지만 서술이
521
- // 결론-편향이면 여전히 block.
522
- const ratio = checkConclusionVerificationRatio({ text: lastMessage });
523
- if (ratio.block) {
514
+ // 주의: sanitizer 가 백틱/코드블록을 제거하므로 raw lastMessage 를 전달.
515
+ // 위험 명령은 코드 fence 안에 있어도 동등하게 위험함.
516
+ run: () => {
517
+ const r = checkDangerousResponsePattern({ text: lastMessage });
518
+ return { triggered: r.block, reason: r.reason };
519
+ },
520
+ },
521
+ {
522
+ shortId: 'self-score-inflation',
523
+ ruleSlug: 'rule:TEST-2 self-score inflation',
524
+ kind: 'block',
525
+ run: () => {
526
+ const r = checkSelfScoreInflation({ text: sanitized, recentTools });
527
+ return { triggered: r.block, reason: r.reason };
528
+ },
529
+ },
530
+ {
531
+ shortId: 'conclusion-ratio',
532
+ ruleSlug: 'rule:TEST-3 — conclusion/verification ratio',
533
+ kind: 'block',
534
+ run: () => {
535
+ const r = checkConclusionVerificationRatio({ text: sanitized });
536
+ return { triggered: r.block, reason: r.reason };
537
+ },
538
+ },
539
+ {
540
+ shortId: 'fact-vs-agreement',
541
+ ruleSlug: 'rule:TEST-1 — fact vs agreement',
542
+ kind: 'correction', // alert-level only per fact-vs-agreement.ts design
543
+ run: () => {
544
+ const r = checkFactVsAgreement({ text: sanitized, recentTools, minMeasurements: 1 });
545
+ return { triggered: r.alert, reason: r.reason };
546
+ },
547
+ },
548
+ ];
549
+ for (const c of checks) {
550
+ const out = c.run();
551
+ if (!out.triggered)
552
+ continue;
524
553
  recordViolation({
525
- rule_id: 'builtin:conclusion-verification-ratio',
554
+ rule_id: `builtin:${c.shortId}`,
526
555
  session_id: sessionId,
527
556
  source: 'stop-guard',
528
- kind: 'block',
557
+ kind: c.kind,
529
558
  message_preview: lastMessage.slice(0, 120),
530
559
  });
531
- const reasonText = `[forgen:stop-guard/conclusion-ratio] ${ratio.reason}
560
+ if (c.kind !== 'block')
561
+ continue;
562
+ const reasonText = `[forgen:stop-guard/${c.shortId}] ${out.reason}
532
563
 
533
564
  (Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
534
- console.log(blockStop(reasonText, 'rule:TEST-3 — conclusion/verification ratio'));
565
+ console.log(blockStop(reasonText, c.ruleSlug));
535
566
  return;
536
567
  }
537
- // TEST-1: 사실 vs 합의 — fact assertion 키워드가 있으나 측정 도구 호출 0건.
538
- // 원 design intent (per fact-vs-agreement.ts): alert level only — block 은 TEST-2/3.
539
- // 여기서는 measurement 신호를 violations.jsonl 에 'alert' kind 로 기록만 (block 안 함).
540
- // wiring gap 발견 (forgen-eval introspect) → 측정 가능하게 wired up.
541
- const fva = checkFactVsAgreement({ text: lastMessage, recentTools, minMeasurements: 1 });
542
- if (fva.alert) {
543
- recordViolation({
544
- rule_id: 'builtin:fact-vs-agreement',
545
- session_id: sessionId,
546
- source: 'stop-guard',
547
- kind: 'correction', // alert-level signal (not block) per fact-vs-agreement.ts design
548
- message_preview: lastMessage.slice(0, 120),
549
- });
550
- }
551
568
  }
552
569
  const rules = loadStopRules();
553
570
  if (rules.length === 0) {
@@ -40,13 +40,21 @@ function writePluginCache(opts) {
40
40
  catch { /* ignore */ }
41
41
  fs.mkdirSync(cacheParent, { recursive: true });
42
42
  // 1차: symlink 시도 (개발 환경)
43
+ // Why warn on fallback: Windows 비관리자 / macOS SIP 환경에서 symlink 가 EPERM
44
+ // 으로 거부되면 조용히 cpSync 폴백을 탔는데, 사용자는 "왜 install 이 느리지"
45
+ // 를 알 길이 없었다. 폴백 진입을 stderr 로 알려서 진단성 확보.
43
46
  let linked = false;
47
+ let symlinkErr = null;
44
48
  try {
45
49
  fs.symlinkSync(pkgRoot, cacheDir, 'dir');
46
50
  linked = true;
47
51
  }
48
- catch {
49
- // symlink 실패 → cp fallback
52
+ catch (e) {
53
+ symlinkErr = e;
54
+ }
55
+ if (!linked && symlinkErr) {
56
+ const code = symlinkErr.code ?? 'UNKNOWN';
57
+ process.stderr.write(`[forgen] symlink ${pkgRoot} → ${cacheDir} failed (${code}); falling back to cpSync.\n`);
50
58
  }
51
59
  if (!linked) {
52
60
  fs.mkdirSync(cacheDir, { recursive: true });
@@ -91,6 +99,29 @@ function writePluginCache(opts) {
91
99
  return true;
92
100
  }
93
101
  // ── 2. Slash commands ──────────────────────────────────────────────────
102
+ /** Build-time injected --with-codex shared snippet. Mirror of scripts/copy-assets.js. */
103
+ const WITH_CODEX_SNIPPET = `
104
+
105
+ ---
106
+
107
+ ## \`--with-codex\` flag (cross-model review)
108
+
109
+ If \`$ARGUMENTS\` contains any of \`--with-codex\`, \`--코덱스\`, \`with codex\`, \`코덱스 검토\`, \`코덱스로 검토\`,
110
+ then after completing the primary skill work, perform a cross-model review pass:
111
+
112
+ 1. Save your primary output text to a temp file (e.g., \`/tmp/forgen-with-codex-$(date +%s).md\`).
113
+ 2. Invoke codex via Bash:
114
+ \`\`\`bash
115
+ codex exec --json --ignore-user-config --ignore-rules --ephemeral \\
116
+ -s read-only -c approval_policy="never" --skip-git-repo-check \\
117
+ "$(printf 'You are a second-opinion reviewer for another AI assistant\\\\u0027s output. Read the work product below and report ONLY:\\n1. Defects, gaps, or risks the original work missed\\n2. Specific disagreements with the original\\n3. Topics that should have been covered but were not\\n\\nOutput format: prioritized bullet list (max 15 items, severity-sorted, no prose intro). If you find nothing material, say "No critical issues found."\\n\\n<work>\\n%s\\n</work>' "$(cat /tmp/forgen-with-codex-*.md)")"
118
+ \`\`\`
119
+ 3. Append the codex output under heading \`## Codex Cross-Review (--with-codex)\` in your final response.
120
+ 4. If codex flags critical issues, briefly acknowledge + suggest follow-up.
121
+ 5. If \`codex: command not found\`, note in response and skip the review pass (do not fail).
122
+
123
+ OPT-IN per invocation. Without the flag, skip this entire section.
124
+ `;
94
125
  function writeSlashCommands(opts) {
95
126
  const { pkgRoot, targetDir, dryRun } = opts;
96
127
  const sourceDir = path.join(pkgRoot, 'assets', 'claude', 'commands');
@@ -106,7 +137,7 @@ function writeSlashCommands(opts) {
106
137
  const descMatch = skillContent.match(/description:\s*(.+)/);
107
138
  const desc = descMatch?.[1]?.trim() ?? file.replace(/\.md$/, '');
108
139
  const skillName = file.replace(/\.md$/, '');
109
- const out = `# ${desc}\n\n${FORGEN_MANAGED_MARKER}\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}`;
140
+ const out = `# ${desc}\n\n${FORGEN_MANAGED_MARKER}\n\nActivate Forgen "${skillName}" mode for the task: $ARGUMENTS\n\n${skillContent}${WITH_CODEX_SNIPPET}`;
110
141
  const target = path.join(targetDir, file);
111
142
  if (fs.existsSync(target)) {
112
143
  const existing = fs.readFileSync(target, 'utf-8');
package/dist/mcp/tools.js CHANGED
@@ -136,6 +136,10 @@ export function registerTools(server) {
136
136
  }],
137
137
  };
138
138
  }
139
+ // D11 — compound usage signal: 사용자 reuse 신호를 한 줄 기록.
140
+ // mature 승격 정책의 입력 데이터 (정책은 별도 사이클).
141
+ const usageMod = await import('../store/compound-usage-store.js');
142
+ usageMod.recordUsage(result.name, 'mcp');
139
143
  const header = `# ${result.name}\n` +
140
144
  `Status: ${result.status} | Confidence: ${result.confidence.toFixed(2)} | Type: ${result.type} | Scope: ${result.scope}\n` +
141
145
  `Tags: ${result.tags.join(', ')}\n` +
@@ -15,4 +15,4 @@ export interface RenderContext {
15
15
  include_pack_summary: boolean;
16
16
  }
17
17
  export declare const DEFAULT_CONTEXT: RenderContext;
18
- export declare function renderRules(rules: Rule[], state: SessionEffectiveState, _profile: Profile, ctx?: RenderContext): string;
18
+ export declare function renderRules(rules: Rule[], state: SessionEffectiveState, profile: Profile, ctx?: RenderContext): string;
@@ -91,8 +91,74 @@ function communicationPackRules(pack) {
91
91
  case '균형형': return s.commBalanced;
92
92
  }
93
93
  }
94
+ // ── Facet-driven rules ──
95
+ // 4축 facet 값의 양 극단(≤0.15, ≥0.85)에서만 추가 규칙을 emit. 중간 값은
96
+ // pack 기본 규칙으로 충분하다고 본다 — 12-bucket pack lookup 위에 *연속 값
97
+ // 차별화*를 얇게 얹는 설계.
98
+ //
99
+ // Threshold 정당화: 0.5 가 default 이고 자동 갱신은 ±0.1 단위 (auto-compound-runner).
100
+ // 0.15/0.85 = 3 단계 강한 신호 누적 후에야 발화 → 노이즈에 둔감.
101
+ const FACET_HIGH = 0.85;
102
+ const FACET_LOW = 0.15;
103
+ function facetDrivenRules(profile) {
104
+ const out = [];
105
+ const q = profile.axes.quality_safety.facets;
106
+ const a = profile.axes.autonomy.facets;
107
+ const j = profile.axes.judgment_philosophy.facets;
108
+ const c = profile.axes.communication_style.facets;
109
+ // Quality
110
+ if (q.verification_depth >= FACET_HIGH) {
111
+ out.push({ section: 'How To Validate', rule: '완료 선언 전 실행 로그 / 테스트 결과 / e2e 증거를 응답에 첨부' });
112
+ }
113
+ if (q.stop_threshold >= FACET_HIGH) {
114
+ out.push({ section: 'Working Defaults', rule: '첫 실패 시 즉시 멈추고 진단 — 무진단 재시도 금지' });
115
+ }
116
+ if (q.change_conservatism >= FACET_HIGH) {
117
+ out.push({ section: 'Working Defaults', rule: '의뢰 범위 외 리팩토링/일반화 금지 — 최소 diff 우선' });
118
+ }
119
+ else if (q.change_conservatism <= FACET_LOW) {
120
+ out.push({ section: 'Working Defaults', rule: '명확성을 위한 리팩토링은 허용 — 디버그 중 발견된 인접 결함도 같은 PR 가능' });
121
+ }
122
+ // Autonomy
123
+ if (a.confirmation_independence >= FACET_HIGH) {
124
+ out.push({ section: 'When To Ask', rule: '목표 합의 후 단계별 재확인 생략 — 마무리 시점 한 번만 보고' });
125
+ }
126
+ else if (a.confirmation_independence <= FACET_LOW) {
127
+ out.push({ section: 'When To Ask', rule: '비-사소한 단계마다 사용자 확인을 받고 진행' });
128
+ }
129
+ if (a.approval_threshold >= FACET_HIGH) {
130
+ out.push({ section: 'When To Ask', rule: '비가역 작업(force push, 데이터 삭제, 외부 broadcast) 전 명시 승인 필수' });
131
+ }
132
+ // Judgment
133
+ if (j.minimal_change_bias >= FACET_HIGH) {
134
+ out.push({ section: 'Working Defaults', rule: '직면한 문제만 해결 — 인접 개선/추상화 제안은 별도 보고' });
135
+ }
136
+ if (j.abstraction_bias >= FACET_HIGH) {
137
+ out.push({ section: 'Working Defaults', rule: '반복 패턴 발견 시 재사용 가능한 추상화로 통합 제안' });
138
+ }
139
+ else if (j.abstraction_bias <= FACET_LOW) {
140
+ out.push({ section: 'Working Defaults', rule: '명확성을 해치는 추상화 금지 — 인라인 중복 허용' });
141
+ }
142
+ if (j.evidence_first_bias >= FACET_HIGH) {
143
+ out.push({ section: 'How To Report', rule: '결론·가설 명시 전 근거(파일:라인, 로그, 측정값)를 먼저 표면화' });
144
+ }
145
+ // Communication
146
+ if (c.verbosity <= FACET_LOW) {
147
+ out.push({ section: 'How To Report', rule: '코드 표시가 필요하지 않으면 답변은 3문장 이내' });
148
+ }
149
+ else if (c.verbosity >= FACET_HIGH) {
150
+ out.push({ section: 'How To Report', rule: '결정 배경·대안·tradeoff 를 답변에 포함' });
151
+ }
152
+ if (c.structure >= FACET_HIGH) {
153
+ out.push({ section: 'How To Report', rule: '다중 포인트 답변은 헤더/표/리스트로 구조화' });
154
+ }
155
+ if (c.teaching_bias >= FACET_HIGH) {
156
+ out.push({ section: 'How To Report', rule: 'why/how 를 함께 설명 — what 만 답하지 말 것' });
157
+ }
158
+ return out;
159
+ }
94
160
  // ── Main Render ──
95
- export function renderRules(rules, state, _profile, ctx = DEFAULT_CONTEXT) {
161
+ export function renderRules(rules, state, profile, ctx = DEFAULT_CONTEXT) {
96
162
  // 1. active만 수집
97
163
  const active = rules.filter(r => r.status === 'active');
98
164
  // 2. dedupe by render_key
@@ -122,6 +188,12 @@ export function renderRules(rules, state, _profile, ctx = DEFAULT_CONTEXT) {
122
188
  for (const rule of communicationPackRules(state.communication_pack)) {
123
189
  sections.get('How To Report').push(rule);
124
190
  }
191
+ // 4축 facet 극단값 → 추가 규칙 (12-bucket pack 위에 연속 값 차별화).
192
+ // 각 facet 0.5 default 이고 자동 갱신 ±0.1 단위이므로, 0.85/0.15 임계값은
193
+ // 여러 세션에 걸친 강한 신호 누적 후에만 발화한다.
194
+ for (const fr of facetDrivenRules(profile)) {
195
+ sections.get(fr.section).push(fr.rule);
196
+ }
125
197
  }
126
198
  // 6. 섹션 조립 (AI-optimized: 간결한 태그 형식)
127
199
  const parts = [];