@wooojin/forgen 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +129 -0
- package/README.ja.md +5 -1
- package/README.ko.md +6 -2
- package/README.md +6 -2
- package/README.zh.md +5 -1
- package/assets/claude/skills/verify/SKILL.md +66 -0
- package/assets/shared/hook-registry.json +10 -3
- package/dist/cli.js +7 -3
- package/dist/core/doctor.js +9 -1
- package/dist/core/health-cli.js +11 -12
- package/dist/core/paths.d.ts +1 -1
- package/dist/core/settings-lock.d.ts +7 -1
- package/dist/core/settings-lock.js +19 -4
- package/dist/core/status-cli.d.ts +1 -1
- package/dist/core/trust-layer-intent.d.ts +2 -2
- package/dist/core/uninstall.d.ts +12 -0
- package/dist/core/uninstall.js +83 -4
- package/dist/engine/solution-fixup.js +2 -2
- package/dist/engine/solution-format.js +4 -4
- package/dist/engine/solution-quarantine.js +2 -2
- package/dist/hooks/context-guard.d.ts +3 -0
- package/dist/hooks/context-guard.js +9 -5
- package/dist/hooks/hook-registry.d.ts +11 -0
- package/dist/hooks/hooks-generator.d.ts +2 -0
- package/dist/hooks/hooks-generator.js +5 -1
- package/dist/hooks/session-end.d.ts +10 -3
- package/dist/hooks/session-end.js +17 -4
- package/dist/host/codex-adapter.js +5 -0
- package/dist/host/codex-hook-alive.d.ts +23 -0
- package/dist/host/codex-hook-alive.js +51 -0
- package/dist/host/codex-notify.d.ts +55 -0
- package/dist/host/codex-notify.js +153 -0
- package/dist/host/codex-rollout.d.ts +21 -0
- package/dist/host/codex-rollout.js +82 -0
- package/dist/host/exec-host.js +5 -1
- package/dist/host/install-claude.d.ts +14 -0
- package/dist/host/install-claude.js +52 -0
- package/dist/host/install-codex.d.ts +100 -7
- package/dist/host/install-codex.js +391 -63
- package/dist/host/install-orchestrator.d.ts +4 -0
- package/dist/host/install-orchestrator.js +23 -3
- package/dist/host/managed-marker.d.ts +23 -0
- package/dist/host/managed-marker.js +78 -0
- package/dist/host/opencode/plugin/forgen.d.ts +1 -1
- package/dist/host/uninstall-codex.d.ts +58 -0
- package/dist/host/uninstall-codex.js +246 -0
- package/package.json +5 -6
- package/plugin.json +1 -1
package/dist/core/uninstall.js
CHANGED
|
@@ -2,6 +2,8 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as os from 'node:os';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as readline from 'node:readline';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { hasManagedSkillMarker, listDevGuideSkillNames, removeSkillFile } from '../host/managed-marker.js';
|
|
5
7
|
import { SETTINGS_PATH, acquireLock, releaseLock, atomicWriteFileSync, } from './settings-lock.js';
|
|
6
8
|
/** 플러그인 제거 (plugin-installer.ts 삭제 후 인라인) */
|
|
7
9
|
function uninstallPlugin() {
|
|
@@ -96,6 +98,65 @@ function cleanSlashCommands() {
|
|
|
96
98
|
console.log(' - No forgen-managed slash commands found');
|
|
97
99
|
}
|
|
98
100
|
}
|
|
101
|
+
/** ADR-016 D3 — ~/.claude/skills/verify 제거 (forgen-managed 마커가 있는 것만; 사용자 스킬은 보존) */
|
|
102
|
+
export function cleanVerifySkill(homeDir = os.homedir()) {
|
|
103
|
+
const dir = path.join(homeDir, '.claude', 'skills', 'verify');
|
|
104
|
+
const file = path.join(dir, 'SKILL.md');
|
|
105
|
+
try {
|
|
106
|
+
if (fs.lstatSync(dir).isSymbolicLink() || fs.lstatSync(file).isSymbolicLink())
|
|
107
|
+
return false;
|
|
108
|
+
if (!hasManagedSkillMarker(fs.readFileSync(file, 'utf-8')))
|
|
109
|
+
return false;
|
|
110
|
+
fs.unlinkSync(file);
|
|
111
|
+
try {
|
|
112
|
+
if (fs.readdirSync(dir).length === 0)
|
|
113
|
+
fs.rmdirSync(dir);
|
|
114
|
+
}
|
|
115
|
+
catch { /* ignore */ }
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return false; // 없음
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* ADR-016 D4 — `~/.claude/skills/forgen-<stack>-<skill>/` dev-guide 스킬 제거.
|
|
124
|
+
* 패키지가 실제로 제공하는 이름만 지운다 (사용자가 만든 `forgen-react-mine` 같은 스킬은 보존).
|
|
125
|
+
*/
|
|
126
|
+
export function cleanDevGuideSkills(pkgRoot, homeDir = os.homedir()) {
|
|
127
|
+
const skillsDir = path.join(homeDir, '.claude', 'skills');
|
|
128
|
+
let removed = 0;
|
|
129
|
+
for (const name of listDevGuideSkillNames(pkgRoot)) {
|
|
130
|
+
if (removeSkillFile(skillsDir, name, false))
|
|
131
|
+
removed += 1;
|
|
132
|
+
}
|
|
133
|
+
return removed;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* ADR-016 D4 — `~/.claude.json` 의 `mcpServers["forgen-compound"]` 제거. install 은 settings.json 이 아니라
|
|
137
|
+
* 여기에 등록하는데 uninstall 은 settings.json 만 정리하고 있었다. forgen 서버 경로를 가리킬 때만 지운다.
|
|
138
|
+
*/
|
|
139
|
+
export function cleanClaudeJsonMcp(homeDir = os.homedir()) {
|
|
140
|
+
const claudeJsonPath = path.join(homeDir, '.claude.json');
|
|
141
|
+
try {
|
|
142
|
+
const data = JSON.parse(fs.readFileSync(claudeJsonPath, 'utf-8'));
|
|
143
|
+
const entry = data.mcpServers?.['forgen-compound'];
|
|
144
|
+
if (!entry)
|
|
145
|
+
return false;
|
|
146
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
147
|
+
const isForgenServer = args.some((a) => typeof a === 'string' && /[\\/]dist[\\/]mcp[\\/]server\.js$/.test(a));
|
|
148
|
+
if (!isForgenServer)
|
|
149
|
+
return false; // 사용자가 같은 이름으로 다른 서버를 등록
|
|
150
|
+
delete data.mcpServers?.['forgen-compound'];
|
|
151
|
+
if (data.mcpServers && Object.keys(data.mcpServers).length === 0)
|
|
152
|
+
delete data.mcpServers;
|
|
153
|
+
atomicWriteFileSync(claudeJsonPath, `${JSON.stringify(data, null, 2)}\n`);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false; // 파일 없음 / 파싱 실패 — 건드리지 않는다
|
|
158
|
+
}
|
|
159
|
+
}
|
|
99
160
|
/** 사용자에게 y/n 확인 */
|
|
100
161
|
function confirm(message) {
|
|
101
162
|
return new Promise((resolve) => {
|
|
@@ -301,10 +362,11 @@ export async function handleUninstall(cwd, options) {
|
|
|
301
362
|
console.log(' 2. Delete .claude/agents/ch-*.md agent files');
|
|
302
363
|
console.log(' 3. Delete .claude/rules/ rule files (project-context, routing, forge-*)');
|
|
303
364
|
console.log(' 4. Remove forgen block from CLAUDE.md');
|
|
304
|
-
console.log(' 5. Remove slash commands (~/.claude/commands/forgen/)');
|
|
305
|
-
console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory)');
|
|
365
|
+
console.log(' 5. Remove slash commands (~/.claude/commands/forgen/) and the forgen-managed verify skill (~/.claude/skills/verify/)');
|
|
366
|
+
console.log(' 6. Remove plugin artifacts (cache, installed_plugins.json, plugin directory), dev-guide skills, and the forgen-compound MCP entry in ~/.claude.json');
|
|
367
|
+
console.log(' 7. Codex (if $CODEX_HOME or ~/.codex exists): forgen hooks in hooks.json, the MCP/notify blocks in config.toml, forgen skills, ch-*.toml agents, and the AGENTS.md block — hooks and files from other tools are kept');
|
|
306
368
|
if (options.purge) {
|
|
307
|
-
console.log('
|
|
369
|
+
console.log(' 8. --purge: Delete ~/.forgen/ entirely (rules, me/, state/, solutions/, behavior/)');
|
|
308
370
|
console.log(' WARNING: this erases all accumulated corrections, rules, drift, and lifecycle history.');
|
|
309
371
|
}
|
|
310
372
|
else {
|
|
@@ -330,6 +392,23 @@ export async function handleUninstall(cwd, options) {
|
|
|
330
392
|
cleanCompoundRules(cwd);
|
|
331
393
|
cleanClaudeMd(cwd);
|
|
332
394
|
cleanSlashCommands();
|
|
395
|
+
if (cleanVerifySkill())
|
|
396
|
+
console.log(' ✓ Removed forgen verify skill (~/.claude/skills/verify/)');
|
|
397
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
398
|
+
const devGuide = cleanDevGuideSkills(pkgRoot);
|
|
399
|
+
if (devGuide > 0)
|
|
400
|
+
console.log(` ✓ Removed ${devGuide} dev-guide skill(s) (~/.claude/skills/forgen-*)`);
|
|
401
|
+
if (cleanClaudeJsonMcp())
|
|
402
|
+
console.log(' ✓ Removed forgen-compound MCP server from ~/.claude.json');
|
|
403
|
+
// ADR-016 D4 — Codex 에 등록한 것도 되돌린다 (다른 도구의 훅과 그 신뢰는 보존)
|
|
404
|
+
try {
|
|
405
|
+
const { planCodexUninstall, renderCodexUninstall } = await import('../host/uninstall-codex.js');
|
|
406
|
+
for (const line of renderCodexUninstall(planCodexUninstall({ pkgRoot })))
|
|
407
|
+
console.log(line);
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
console.error(' ✗ Codex cleanup failed:', e instanceof Error ? e.message : String(e));
|
|
411
|
+
}
|
|
333
412
|
cleanPluginArtifacts();
|
|
334
413
|
if (options.purge) {
|
|
335
414
|
try {
|
|
@@ -347,5 +426,5 @@ export async function handleUninstall(cwd, options) {
|
|
|
347
426
|
console.log(` ✗ ~/.forgen/ deletion failed: ${e.message}`);
|
|
348
427
|
}
|
|
349
428
|
}
|
|
350
|
-
console.log('\n[forgen] Uninstall complete. Restart Claude Code for a clean state.\n');
|
|
429
|
+
console.log('\n[forgen] Uninstall complete. Restart Claude Code (and Codex) for a clean state.\n');
|
|
351
430
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
4
|
import { DEFAULT_EVIDENCE } from './solution-format.js';
|
|
5
5
|
import { diagnoseFromRawContent } from './solution-quarantine.js';
|
|
6
6
|
import { createLogger } from '../core/logger.js';
|
|
@@ -83,7 +83,7 @@ function tryFix(content, initialErrors) {
|
|
|
83
83
|
const body = trimmed.slice(endIdx + 3);
|
|
84
84
|
let fm;
|
|
85
85
|
try {
|
|
86
|
-
const parsed = yaml.load(fmRaw, { schema: yaml.
|
|
86
|
+
const parsed = yaml.load(fmRaw, { schema: yaml.CORE_SCHEMA });
|
|
87
87
|
if (parsed == null || typeof parsed !== 'object') {
|
|
88
88
|
return { changed: false, added, remaining: initialErrors, content };
|
|
89
89
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import yaml from 'js-yaml';
|
|
1
|
+
import * as yaml from 'js-yaml';
|
|
2
2
|
export const DEFAULT_EVIDENCE = {
|
|
3
3
|
injected: 0,
|
|
4
4
|
reflected: 0,
|
|
@@ -106,7 +106,7 @@ export function parseFrontmatterOnly(content) {
|
|
|
106
106
|
const anchorCount = (raw.match(/(?<=\s|^)&\w+/g) ?? []).length;
|
|
107
107
|
if (anchorCount > 3)
|
|
108
108
|
return null;
|
|
109
|
-
const parsed = yaml.load(raw, { schema: yaml.
|
|
109
|
+
const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
|
|
110
110
|
if (!validateFrontmatter(parsed))
|
|
111
111
|
return null;
|
|
112
112
|
return parsed;
|
|
@@ -156,8 +156,8 @@ export function parseSolutionV3(content) {
|
|
|
156
156
|
export function serializeSolutionV3(solution) {
|
|
157
157
|
const yamlStr = yaml.dump(solution.frontmatter, {
|
|
158
158
|
lineWidth: -1,
|
|
159
|
-
|
|
160
|
-
schema: yaml.
|
|
159
|
+
quoteStyle: 'double',
|
|
160
|
+
schema: yaml.CORE_SCHEMA,
|
|
161
161
|
});
|
|
162
162
|
return `---\n${yamlStr}---\n\n## Context\n${solution.context}\n\n## Content\n${solution.content}\n`;
|
|
163
163
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
-
import yaml from 'js-yaml';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
4
|
import { SOLUTION_QUARANTINE_PATH, STATE_DIR } from '../core/paths.js';
|
|
5
5
|
import { diagnoseFrontmatter } from './solution-format.js';
|
|
6
6
|
import { createLogger } from '../core/logger.js';
|
|
@@ -24,7 +24,7 @@ export function diagnoseFromRawContent(content) {
|
|
|
24
24
|
return ['frontmatter too large (>5000 chars — YAML bomb guard)'];
|
|
25
25
|
let parsed;
|
|
26
26
|
try {
|
|
27
|
-
parsed = yaml.load(raw, { schema: yaml.
|
|
27
|
+
parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
|
|
28
28
|
}
|
|
29
29
|
catch (e) {
|
|
30
30
|
return [`YAML parse error: ${e instanceof Error ? e.message : String(e)}`];
|
|
@@ -57,6 +57,9 @@ export declare function effectiveCooldownMs(parsed: {
|
|
|
57
57
|
userPatternFound?: boolean;
|
|
58
58
|
promptCount?: number;
|
|
59
59
|
}, currentPromptCount?: number): number;
|
|
60
|
+
export declare function maybeSpawnAutoCompound(sessionId: string, transcriptPath: string | undefined, promptCount: number,
|
|
61
|
+
/** ADR-016 D1: notify 폴백은 훅 env(FORGEN_CWD) 없이 돌므로 페이로드의 cwd 를 넘긴다. */
|
|
62
|
+
cwdOverride?: string): Promise<boolean>;
|
|
60
63
|
/**
|
|
61
64
|
* forge-loop 활성 시 미완료 스토리가 있으면 Stop을 차단하고 지속 메시지 주입.
|
|
62
65
|
* OMC의 persistent-mode.cjs 패턴 참고.
|
|
@@ -439,9 +439,11 @@ export function effectiveCooldownMs(parsed, currentPromptCount = 0) {
|
|
|
439
439
|
const grew = currentPromptCount - (parsed.promptCount ?? 0) >= AUTO_COMPOUND_GROWTH_THRESHOLD;
|
|
440
440
|
return grew ? AUTO_COMPOUND_COOLDOWN_MS : AUTO_COMPOUND_BARREN_COOLDOWN_MS;
|
|
441
441
|
}
|
|
442
|
-
async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount
|
|
442
|
+
export async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount,
|
|
443
|
+
/** ADR-016 D1: notify 폴백은 훅 env(FORGEN_CWD) 없이 돌므로 페이로드의 cwd 를 넘긴다. */
|
|
444
|
+
cwdOverride) {
|
|
443
445
|
if (!transcriptPath || promptCount < 10)
|
|
444
|
-
return;
|
|
446
|
+
return false;
|
|
445
447
|
const markerPath = path.join(STATE_DIR, 'last-auto-compound.json');
|
|
446
448
|
try {
|
|
447
449
|
const raw = fs.readFileSync(markerPath, 'utf-8');
|
|
@@ -450,12 +452,12 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
|
|
|
450
452
|
const last = parsed.completedAt ? Date.parse(parsed.completedAt) : 0;
|
|
451
453
|
const cooldown = effectiveCooldownMs(parsed, promptCount);
|
|
452
454
|
if (Number.isFinite(last) && Date.now() - last < cooldown)
|
|
453
|
-
return;
|
|
455
|
+
return false;
|
|
454
456
|
}
|
|
455
457
|
}
|
|
456
458
|
catch { /* first time or corrupt — proceed */ }
|
|
457
459
|
const { spawn: spawnProcess } = await import('node:child_process');
|
|
458
|
-
const cwd = process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
460
|
+
const cwd = cwdOverride ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
459
461
|
// 기본: 번들된 auto-compound-runner. 프로덕션 빌드는 이 경로만 실행.
|
|
460
462
|
const defaultRunner = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'core', 'auto-compound-runner.js');
|
|
461
463
|
// 테스트 주입 경로 — FORGEN_TEST=1 게이트 + 경로 containment (~/.forgen 또는 /tmp 하위만 허용).
|
|
@@ -486,7 +488,7 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
|
|
|
486
488
|
const { claimAutoCompoundInflight, releaseAutoCompoundInflight } = await import('../core/spawn.js');
|
|
487
489
|
if (!claimAutoCompoundInflight(sessionId)) {
|
|
488
490
|
log.debug('Stop-triggered auto-compound skip: 세션 in-flight');
|
|
489
|
-
return;
|
|
491
|
+
return false;
|
|
490
492
|
}
|
|
491
493
|
try {
|
|
492
494
|
const child = spawnProcess('node', [runnerPath, cwd, transcriptPath, sessionId, String(promptCount)], {
|
|
@@ -495,10 +497,12 @@ async function maybeSpawnAutoCompound(sessionId, transcriptPath, promptCount) {
|
|
|
495
497
|
});
|
|
496
498
|
child.unref();
|
|
497
499
|
log.debug(`Stop-triggered auto-compound 시작: ${sessionId} (${promptCount} prompts)`);
|
|
500
|
+
return true;
|
|
498
501
|
}
|
|
499
502
|
catch (e) {
|
|
500
503
|
releaseAutoCompoundInflight(sessionId); // spawn 실패 → 재시도 가능
|
|
501
504
|
log.debug('Stop-triggered auto-compound spawn 실패', e);
|
|
505
|
+
return false;
|
|
502
506
|
}
|
|
503
507
|
}
|
|
504
508
|
// forge-loop 차단 안전 상한 (무한 루프 방지)
|
|
@@ -32,6 +32,17 @@ export interface HookEntry {
|
|
|
32
32
|
* 훅 신뢰(trust) 와 묶여 있으므로, Claude 에만 추가하는 이벤트는 `["claude"]` 로 한정한다.
|
|
33
33
|
*/
|
|
34
34
|
hosts?: Array<'claude' | 'codex' | 'opencode'>;
|
|
35
|
+
/**
|
|
36
|
+
* ADR-016 D2: Codex hooks.json 핸들러에만 붙는 필드. Codex 의 trust 해시는 핸들러 단위라
|
|
37
|
+
* 여기 값을 바꾸면 *그 핸들러만* `/hooks` 재승인이 필요하다 (다른 핸들러는 영향 없음).
|
|
38
|
+
*/
|
|
39
|
+
codex?: {
|
|
40
|
+
/**
|
|
41
|
+
* `additionalContext` 를 임시 파일로 스필하는 임계(근사 토큰, 기본 2500 ≈ 10KB). 0 = 스필 안 함.
|
|
42
|
+
* Codex 는 PreToolUse/PostToolUse/SessionStart/UserPromptSubmit/SubagentStart 에서만 인정한다.
|
|
43
|
+
*/
|
|
44
|
+
additionalContextLimit?: number;
|
|
45
|
+
};
|
|
35
46
|
}
|
|
36
47
|
export declare const HOOK_REGISTRY: HookEntry[];
|
|
37
48
|
/** 티어별 훅 목록 조회 */
|
|
@@ -92,7 +92,11 @@ export function generateHooksJson(options) {
|
|
|
92
92
|
matcher,
|
|
93
93
|
hooks: matcherEntries.map(h => {
|
|
94
94
|
const command = buildHookCommand(pluginRoot, h.script, runtime);
|
|
95
|
-
|
|
95
|
+
const handler = { type: 'command', command, timeout: h.timeout };
|
|
96
|
+
if (runtime === 'codex' && typeof h.codex?.additionalContextLimit === 'number') {
|
|
97
|
+
handler.additionalContextLimit = h.codex.additionalContextLimit;
|
|
98
|
+
}
|
|
99
|
+
return handler;
|
|
96
100
|
}),
|
|
97
101
|
}));
|
|
98
102
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Forgen — SessionEnd Hook (ADR-015 C-G6
|
|
3
|
+
* Forgen — SessionEnd Hook (ADR-015 C-G6 Claude, ADR-016 D2 Codex)
|
|
4
4
|
*
|
|
5
5
|
* Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
|
|
6
6
|
* 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
|
|
12
12
|
* (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
|
|
13
|
-
* - Codex
|
|
14
|
-
*
|
|
13
|
+
* - Codex 0.153+ 에도 등록한다 (ADR-016 D2). Codex 의 SessionEnd 는 stdin 이
|
|
14
|
+
* `session_id`/`transcript_path`/`cwd`/`reason`(항상 "other") 이고 stdout 을 무시하며 타임아웃을 1~3s 로
|
|
15
|
+
* clamp 한다 — 같은 "bounded count → detached spawn" 경로가 그대로 맞는다. trust 해시가 핸들러 단위라
|
|
16
|
+
* 새 이벤트 추가는 기존 훅의 신뢰를 깨지 않는다 (이 훅 1개만 `/hooks` 승인 필요).
|
|
15
17
|
* - fail-open: 어떤 실패도 종료를 막지 않는다.
|
|
16
18
|
*/
|
|
17
19
|
/** session-recovery 와 동일 임계값 — 짧은 세션은 compound 가치가 없다. */
|
|
@@ -29,6 +31,11 @@ export declare const COUNT_SCAN_BYTES: number;
|
|
|
29
31
|
* 임계값(MIN_USER_MESSAGES) 판정에만 쓰이므로 하한 추정으로 충분하다. 큰 transcript 에서도 O(200KB).
|
|
30
32
|
*/
|
|
31
33
|
export declare function countUserMessagesBounded(transcriptPath: string, maxBytes?: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* host 별 user 메시지 수. Codex rollout 은 스키마가 달라 전용 카운터를 쓴다 (ADR-016 D2 — 실제 사용자
|
|
36
|
+
* 프롬프트만, raw 바이트 스캔). codex-adapter 가 delegate 훅에 FORGEN_RUNTIME=codex 를 주입한다.
|
|
37
|
+
*/
|
|
38
|
+
export declare function countSessionUserMessages(transcriptPath: string, runtime?: string | undefined): Promise<number>;
|
|
32
39
|
/** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
|
|
33
40
|
export declare function shouldRunSessionEndCompound(input: SessionEndInput | null, userMessageCount: number): boolean;
|
|
34
41
|
export declare function main(): Promise<void>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Forgen — SessionEnd Hook (ADR-015 C-G6
|
|
3
|
+
* Forgen — SessionEnd Hook (ADR-015 C-G6 Claude, ADR-016 D2 Codex)
|
|
4
4
|
*
|
|
5
5
|
* Claude Code `SessionEnd` (reason: clear|resume|logout|prompt_input_exit|other, 기본 예산 1.5s)
|
|
6
6
|
* 에서 이전 세션 transcript 를 auto-compound 러너에 넘긴다. 기존 트리거(Stop / PreCompact /
|
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* - 예산(기본 1.5s, registry timeout 3s 로 상향) 안에 끝나야 하므로: stdin 파싱 → user 메시지 수
|
|
12
12
|
* (앞 200KB 만 읽음, 대용량 transcript 보호) → detached spawn.
|
|
13
|
-
* - Codex
|
|
14
|
-
*
|
|
13
|
+
* - Codex 0.153+ 에도 등록한다 (ADR-016 D2). Codex 의 SessionEnd 는 stdin 이
|
|
14
|
+
* `session_id`/`transcript_path`/`cwd`/`reason`(항상 "other") 이고 stdout 을 무시하며 타임아웃을 1~3s 로
|
|
15
|
+
* clamp 한다 — 같은 "bounded count → detached spawn" 경로가 그대로 맞는다. trust 해시가 핸들러 단위라
|
|
16
|
+
* 새 이벤트 추가는 기존 훅의 신뢰를 깨지 않는다 (이 훅 1개만 `/hooks` 승인 필요).
|
|
15
17
|
* - fail-open: 어떤 실패도 종료를 막지 않는다.
|
|
16
18
|
*/
|
|
17
19
|
import * as fs from 'node:fs';
|
|
@@ -52,6 +54,17 @@ export function countUserMessagesBounded(transcriptPath, maxBytes = COUNT_SCAN_B
|
|
|
52
54
|
fs.closeSync(fd);
|
|
53
55
|
}
|
|
54
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* host 별 user 메시지 수. Codex rollout 은 스키마가 달라 전용 카운터를 쓴다 (ADR-016 D2 — 실제 사용자
|
|
59
|
+
* 프롬프트만, raw 바이트 스캔). codex-adapter 가 delegate 훅에 FORGEN_RUNTIME=codex 를 주입한다.
|
|
60
|
+
*/
|
|
61
|
+
export async function countSessionUserMessages(transcriptPath, runtime = process.env.FORGEN_RUNTIME) {
|
|
62
|
+
if (runtime === 'codex') {
|
|
63
|
+
const { countCodexUserPrompts } = await import('../host/codex-rollout.js');
|
|
64
|
+
return countCodexUserPrompts(transcriptPath);
|
|
65
|
+
}
|
|
66
|
+
return countUserMessagesBounded(transcriptPath);
|
|
67
|
+
}
|
|
55
68
|
/** 순수 판정: 이 입력으로 auto-compound 를 띄울지. (테스트 대상) */
|
|
56
69
|
export function shouldRunSessionEndCompound(input, userMessageCount) {
|
|
57
70
|
if (!input || typeof input.transcript_path !== 'string' || input.transcript_path.length === 0)
|
|
@@ -71,7 +84,7 @@ export async function main() {
|
|
|
71
84
|
let count = 0;
|
|
72
85
|
if (transcript && fs.existsSync(transcript)) {
|
|
73
86
|
try {
|
|
74
|
-
count =
|
|
87
|
+
count = await countSessionUserMessages(transcript);
|
|
75
88
|
}
|
|
76
89
|
catch (e) {
|
|
77
90
|
log.debug('user message count 실패', e);
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { spawnSync } from 'node:child_process';
|
|
14
14
|
import { projectCodexToClaude } from './projection.js';
|
|
15
|
+
import { markCodexHookAlive } from './codex-hook-alive.js';
|
|
15
16
|
function lastJSONObjectFromText(raw) {
|
|
16
17
|
const lines = raw.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
17
18
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
@@ -54,6 +55,10 @@ async function main() {
|
|
|
54
55
|
return {};
|
|
55
56
|
}
|
|
56
57
|
})();
|
|
58
|
+
// ADR-016 D1 — "Codex 가 forgen 훅을 실제로 실행 중" 마커 (notify 폴백이 신선도를 본다).
|
|
59
|
+
// forgen 자신의 추출용 중첩 실행은 사용자 세션이 아니므로 제외.
|
|
60
|
+
if (process.env.FORGEN_NESTED_RUN !== '1')
|
|
61
|
+
markCodexHookAlive(input);
|
|
57
62
|
try {
|
|
58
63
|
// 0.4.6 fix — delegate hook 이 자기가 codex runtime context 인지 알 수 있게
|
|
59
64
|
// FORGEN_RUNTIME=codex 명시 주입. 이전엔 buildEnv (forgen wrapper) 경로로만
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 훅 생존 마커 — ADR-016 D1
|
|
3
|
+
*
|
|
4
|
+
* Codex 는 미승인(untrusted)/변경된(modified) 훅을 조용히 skip 한다. "forgen 훅이 실제로 실행되고 있는가"
|
|
5
|
+
* 를 관측하는 유일한 choke point 는 codex-adapter 다 (Codex 가 forgen 훅을 실행하면 반드시 거친다).
|
|
6
|
+
* 어댑터가 턴 경계 이벤트에서 전역 마커를 갱신하고, notify 폴백(codex-notify)이 그 신선도를 본다.
|
|
7
|
+
*
|
|
8
|
+
* 훅 신뢰 상태는 hooks.json 단위로 모든 세션이 공유하므로 마커는 세션별이 아니라 전역 1개다.
|
|
9
|
+
* 어댑터가 매 훅마다 import 하므로 의존성은 node 내장 + paths 만 둔다.
|
|
10
|
+
*/
|
|
11
|
+
export declare const CODEX_HOOK_ALIVE_PATH: string;
|
|
12
|
+
/** notify 는 Stop 훅 직후 발화한다. Stop 훅 타임아웃(10s) 대비 넉넉한 창. */
|
|
13
|
+
export declare const CODEX_HOOK_ALIVE_WINDOW_MS = 120000;
|
|
14
|
+
export interface CodexHookAliveMarker {
|
|
15
|
+
at: number;
|
|
16
|
+
event: string;
|
|
17
|
+
sessionId?: string;
|
|
18
|
+
}
|
|
19
|
+
/** 어댑터 입력(stdin JSON)으로 마커 갱신. 대상 이벤트가 아니면 no-op. 실패는 삼킨다 (fail-open). */
|
|
20
|
+
export declare function markCodexHookAlive(input: unknown, now?: number): boolean;
|
|
21
|
+
export declare function readCodexHookAlive(): CodexHookAliveMarker | null;
|
|
22
|
+
/** 최근 window 안에 forgen 훅이 Codex 에서 실행됐는가. */
|
|
23
|
+
export declare function isCodexHookAlive(now?: number, windowMs?: number): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex 훅 생존 마커 — ADR-016 D1
|
|
3
|
+
*
|
|
4
|
+
* Codex 는 미승인(untrusted)/변경된(modified) 훅을 조용히 skip 한다. "forgen 훅이 실제로 실행되고 있는가"
|
|
5
|
+
* 를 관측하는 유일한 choke point 는 codex-adapter 다 (Codex 가 forgen 훅을 실행하면 반드시 거친다).
|
|
6
|
+
* 어댑터가 턴 경계 이벤트에서 전역 마커를 갱신하고, notify 폴백(codex-notify)이 그 신선도를 본다.
|
|
7
|
+
*
|
|
8
|
+
* 훅 신뢰 상태는 hooks.json 단위로 모든 세션이 공유하므로 마커는 세션별이 아니라 전역 1개다.
|
|
9
|
+
* 어댑터가 매 훅마다 import 하므로 의존성은 node 내장 + paths 만 둔다.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
13
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
14
|
+
export const CODEX_HOOK_ALIVE_PATH = path.join(STATE_DIR, 'codex-hook-alive.json');
|
|
15
|
+
/** notify 는 Stop 훅 직후 발화한다. Stop 훅 타임아웃(10s) 대비 넉넉한 창. */
|
|
16
|
+
export const CODEX_HOOK_ALIVE_WINDOW_MS = 120_000;
|
|
17
|
+
/** 턴 경계 이벤트만 기록 — PreToolUse/PostToolUse 마다 쓰지 않는다. */
|
|
18
|
+
const ALIVE_EVENTS = new Set(['Stop', 'SubagentStop', 'UserPromptSubmit']);
|
|
19
|
+
/** 어댑터 입력(stdin JSON)으로 마커 갱신. 대상 이벤트가 아니면 no-op. 실패는 삼킨다 (fail-open). */
|
|
20
|
+
export function markCodexHookAlive(input, now = Date.now()) {
|
|
21
|
+
try {
|
|
22
|
+
const i = (input ?? {});
|
|
23
|
+
const event = typeof i.hook_event_name === 'string' ? i.hook_event_name
|
|
24
|
+
: typeof i.hookEventName === 'string' ? i.hookEventName : '';
|
|
25
|
+
if (!ALIVE_EVENTS.has(event))
|
|
26
|
+
return false;
|
|
27
|
+
const marker = { at: now, event };
|
|
28
|
+
if (typeof i.session_id === 'string')
|
|
29
|
+
marker.sessionId = i.session_id;
|
|
30
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
31
|
+
fs.writeFileSync(CODEX_HOOK_ALIVE_PATH, JSON.stringify(marker));
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function readCodexHookAlive() {
|
|
39
|
+
try {
|
|
40
|
+
const m = JSON.parse(fs.readFileSync(CODEX_HOOK_ALIVE_PATH, 'utf-8'));
|
|
41
|
+
return typeof m.at === 'number' && Number.isFinite(m.at) ? m : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** 최근 window 안에 forgen 훅이 Codex 에서 실행됐는가. */
|
|
48
|
+
export function isCodexHookAlive(now = Date.now(), windowMs = CODEX_HOOK_ALIVE_WINDOW_MS) {
|
|
49
|
+
const m = readCodexHookAlive();
|
|
50
|
+
return m !== null && now - m.at >= 0 && now - m.at < windowMs;
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Codex `notify` 폴백 — ADR-016 D1
|
|
4
|
+
*
|
|
5
|
+
* Codex 는 config.toml 의 top-level `notify = [argv…]` 프로그램을 **턴 완료마다**(Stop 훅 뒤) detached 로
|
|
6
|
+
* 실행하고, JSON 페이로드를 마지막 argv 로 넘긴다. 훅과 달리 **신뢰(trust) 승인과 무관**하게 돈다.
|
|
7
|
+
* forgen 은 이것을 "훅이 조용히 skip 되는 동안의 안전망" 으로만 쓴다:
|
|
8
|
+
*
|
|
9
|
+
* 1. forgen 훅이 살아 있으면(codex-adapter 가 방금 alive 마커를 갱신) 아무것도 하지 않는다 —
|
|
10
|
+
* auto-compound 는 Stop 훅(context-guard) 소관이다.
|
|
11
|
+
* 2. 훅이 돌지 않았으면 silent 플래그를 남기고(`forgen doctor` 가 노출), 세션이 충분히 길면 Stop 훅과
|
|
12
|
+
* 같은 디바운스 경로로 auto-compound 를 띄운다.
|
|
13
|
+
*
|
|
14
|
+
* 페이로드 (codex-rs/hooks/src/legacy_notify.rs, 0.153.4):
|
|
15
|
+
* { "type":"agent-turn-complete", "thread-id", "turn-id", "cwd", "client"?, "input-messages":[…],
|
|
16
|
+
* "last-assistant-message" } — transcript 경로는 없다. `thread-id` 로 rollout 파일을 찾는다.
|
|
17
|
+
*
|
|
18
|
+
* argv: `codex-notify.js [-- <chain program> <args…>] <payload JSON>`
|
|
19
|
+
* `--` 뒤는 사용자가 수동으로 체인한 자기 notifier (단일 argv 제약 우회). 페이로드를 붙여 먼저 전달한다.
|
|
20
|
+
*
|
|
21
|
+
* 실패 정책: 모든 단계 fail-open. stdout/stderr 는 Codex 가 /dev/null 로 버린다.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CODEX_HOOKS_SILENT_PATH: string;
|
|
24
|
+
export interface CodexNotifyPayload {
|
|
25
|
+
type?: string;
|
|
26
|
+
'thread-id'?: string;
|
|
27
|
+
'turn-id'?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
client?: string;
|
|
30
|
+
}
|
|
31
|
+
export interface CodexHooksSilent {
|
|
32
|
+
detectedAt: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
cwd: string;
|
|
35
|
+
/** 연속으로 관측된 silent 턴 수 */
|
|
36
|
+
count: number;
|
|
37
|
+
}
|
|
38
|
+
export type NotifyOutcome = 'nested-run' | 'no-payload' | 'ignored-event' | 'hooks-alive' | 'silent-recorded' | 'silent-compound-spawned';
|
|
39
|
+
/** argv(스크립트 뒤) → 체인 프로그램 + 페이로드. 페이로드는 항상 마지막 argv. */
|
|
40
|
+
export declare function parseNotifyArgv(argv: string[]): {
|
|
41
|
+
chain: string[];
|
|
42
|
+
payloadRaw: string | null;
|
|
43
|
+
payload: CodexNotifyPayload | null;
|
|
44
|
+
};
|
|
45
|
+
/** doctor 용 — 유효(24h 이내)한 silent 관측만 반환. */
|
|
46
|
+
export declare function readCodexHooksSilent(now?: number): CodexHooksSilent | null;
|
|
47
|
+
export interface NotifyDeps {
|
|
48
|
+
now?: number;
|
|
49
|
+
env?: NodeJS.ProcessEnv;
|
|
50
|
+
/** 테스트 주입: auto-compound 디바운스 트리거 */
|
|
51
|
+
spawnCompound?: (sessionId: string, transcriptPath: string, promptCount: number, cwd: string) => Promise<boolean>;
|
|
52
|
+
/** 테스트 주입: forgen hook-config 조회 */
|
|
53
|
+
isHookEnabled?: (name: string) => boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function handleNotify(argv: string[], deps?: NotifyDeps): Promise<NotifyOutcome>;
|