@wooojin/forgen 0.4.10 → 0.4.11
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 +46 -0
- package/README.md +33 -1
- package/assets/claude/agents/forgen-verify.md +65 -0
- package/assets/claude/workflows/compound-extract.js +136 -0
- package/assets/claude/workflows/evidence-gate-audit.js +107 -0
- package/assets/shared/hook-registry.json +1 -0
- package/dist/checks/_shared/meta-guard-dispatch.d.ts +38 -0
- package/dist/checks/_shared/meta-guard-dispatch.js +80 -0
- package/dist/checks/_shared/text-sanitizer.js +15 -0
- package/dist/cli.js +57 -2
- package/dist/core/changelog-cli.d.ts +7 -0
- package/dist/core/changelog-cli.js +100 -0
- package/dist/core/doctor.d.ts +3 -0
- package/dist/core/doctor.js +38 -0
- package/dist/core/effort-advisory.d.ts +23 -0
- package/dist/core/effort-advisory.js +29 -0
- package/dist/core/explain-cli.d.ts +6 -0
- package/dist/core/explain-cli.js +99 -0
- package/dist/core/health-cli.d.ts +23 -0
- package/dist/core/health-cli.js +86 -0
- package/dist/core/probe-workflow-cli.d.ts +72 -0
- package/dist/core/probe-workflow-cli.js +282 -0
- package/dist/core/stats-cli.d.ts +22 -9
- package/dist/core/stats-cli.js +149 -0
- package/dist/core/watch-cli.d.ts +7 -0
- package/dist/core/watch-cli.js +185 -0
- package/dist/core/workflows-cli.d.ts +26 -0
- package/dist/core/workflows-cli.js +120 -0
- package/dist/engine/compound-export.d.ts +12 -0
- package/dist/engine/compound-export.js +136 -14
- package/dist/engine/compound-extractor.d.ts +12 -43
- package/dist/engine/compound-extractor.js +27 -756
- package/dist/engine/extraction-diff.d.ts +11 -0
- package/dist/engine/extraction-diff.js +105 -0
- package/dist/engine/extraction-gates.d.ts +37 -0
- package/dist/engine/extraction-gates.js +100 -0
- package/dist/engine/extraction-git.d.ts +20 -0
- package/dist/engine/extraction-git.js +75 -0
- package/dist/engine/extraction-persistence.d.ts +27 -0
- package/dist/engine/extraction-persistence.js +140 -0
- package/dist/engine/extraction-session.d.ts +26 -0
- package/dist/engine/extraction-session.js +230 -0
- package/dist/engine/lifecycle/types.d.ts +1 -1
- package/dist/engine/meta-learning/matcher-weight-loader.d.ts +16 -0
- package/dist/engine/meta-learning/matcher-weight-loader.js +45 -0
- package/dist/engine/precision-guards.d.ts +14 -0
- package/dist/engine/precision-guards.js +39 -0
- package/dist/engine/ranking-pipeline.d.ts +45 -0
- package/dist/engine/ranking-pipeline.js +66 -0
- package/dist/engine/relevance-scorer.d.ts +43 -0
- package/dist/engine/relevance-scorer.js +81 -0
- package/dist/engine/scoring-algorithms.d.ts +31 -0
- package/dist/engine/scoring-algorithms.js +109 -0
- package/dist/engine/solution-matcher-eval.d.ts +97 -0
- package/dist/engine/solution-matcher-eval.js +122 -0
- package/dist/engine/solution-matcher.d.ts +21 -380
- package/dist/engine/solution-matcher.js +27 -828
- package/dist/fgx.js +1 -1
- package/dist/hooks/notepad-injector.js +7 -0
- package/dist/hooks/post-tool-use.js +8 -1
- package/dist/hooks/shared/preflight-check.d.ts +15 -0
- package/dist/hooks/shared/preflight-check.js +51 -0
- package/dist/hooks/stop-guard.js +19 -60
- package/dist/hooks/subagent-stop-guard.d.ts +23 -0
- package/dist/hooks/subagent-stop-guard.js +158 -0
- package/dist/hooks/subagent-tracker.d.ts +36 -3
- package/dist/hooks/subagent-tracker.js +86 -39
- package/hooks/hooks.json +6 -1
- package/package.json +7 -7
- package/plugin.json +1 -1
- package/scripts/postinstall.js +10 -7
package/dist/fgx.js
CHANGED
|
@@ -19,7 +19,7 @@ const FORGEN_SUBCOMMANDS = new Set([
|
|
|
19
19
|
'notepad', 'inspect', 'onboarding', 'doctor', 'uninstall', 'rule',
|
|
20
20
|
'classify-enforce', 'rule-meta-scan', 'lifecycle-scan',
|
|
21
21
|
'stats', 'last-block', 'recall', 'migrate', 'suppress-rule', 'activate-rule',
|
|
22
|
-
'regress-map',
|
|
22
|
+
'regress-map', 'watch', 'health', 'probe-workflow', 'workflows', 'explain', 'changelog',
|
|
23
23
|
// 메타 명령도 cli.ts 가 처리 (fgx claude spawn 으로는 의미 없음)
|
|
24
24
|
'help', '--help', '-h', '--version', '-V',
|
|
25
25
|
]);
|
|
@@ -22,6 +22,7 @@ import { truncateContent } from './shared/injection-caps.js';
|
|
|
22
22
|
import { calculateBudget } from './shared/context-budget.js';
|
|
23
23
|
import { approve, approveWithContext, failOpenWithTracking } from './shared/hook-response.js';
|
|
24
24
|
import { escapeAllXmlTags } from './prompt-injection-filter.js';
|
|
25
|
+
import { checkForgenInitialized, hasPreflightWarned, markPreflightWarned } from './shared/preflight-check.js';
|
|
25
26
|
// ── 메인 ──
|
|
26
27
|
async function main() {
|
|
27
28
|
const input = await readStdinJSON();
|
|
@@ -33,6 +34,12 @@ async function main() {
|
|
|
33
34
|
console.log(approve());
|
|
34
35
|
return;
|
|
35
36
|
}
|
|
37
|
+
const sessionId = input.session_id ?? 'unknown';
|
|
38
|
+
const preflight = checkForgenInitialized();
|
|
39
|
+
if (!preflight.initialized && !hasPreflightWarned(sessionId)) {
|
|
40
|
+
markPreflightWarned(sessionId);
|
|
41
|
+
process.stderr.write(`${preflight.message}\n`);
|
|
42
|
+
}
|
|
36
43
|
const effectiveCwd = input.cwd ?? process.env.FORGEN_CWD ?? process.env.COMPOUND_CWD ?? process.cwd();
|
|
37
44
|
const notepadContent = readNotepad(effectiveCwd);
|
|
38
45
|
if (!notepadContent.trim()) {
|
|
@@ -131,7 +131,14 @@ async function main() {
|
|
|
131
131
|
const rawResponse = data.tool_response ?? data.toolOutput ?? '';
|
|
132
132
|
const toolResponse = typeof rawResponse === 'string' ? rawResponse : JSON.stringify(rawResponse);
|
|
133
133
|
const sessionId = data.session_id ?? 'default';
|
|
134
|
-
|
|
134
|
+
// ADR-009 §2d: subagent context (agent_id 존재) 의 tool 추적은 per-agent 파일로
|
|
135
|
+
// 분리한다 → (a) 메인 세션 recentTools 가 subagent tool 노이즈에 오염되지 않고,
|
|
136
|
+
// (b) 각 subagent 가 자기 TEST-2 윈도우를 가지며, (c) 워크플로우 동시 subagent 가
|
|
137
|
+
// 서로의 recentTools 를 덮어쓰는 레이스를 제거. agent_id 가 없으면(메인 세션)
|
|
138
|
+
// sessionId 그대로 → 기존 동작 불변. violation/bypass 기록은 실 sessionId 유지.
|
|
139
|
+
const agentId = data.agent_id ?? data.agentId;
|
|
140
|
+
const trackingKey = agentId ? `${sessionId}.agent-${agentId}` : sessionId;
|
|
141
|
+
const modState = loadModifiedFiles(trackingKey);
|
|
135
142
|
modState.toolCallCount = (modState.toolCallCount ?? 0) + 1;
|
|
136
143
|
// TEST-2: recent tool name window — stop-guard 의 self-score inflation 가드가
|
|
137
144
|
// "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight check for forgen initialization state.
|
|
3
|
+
*
|
|
4
|
+
* Detects "hooks wired but no profile" — a user who ran `forgen install`
|
|
5
|
+
* but never completed onboarding via `forgen`. Emits a one-time warning
|
|
6
|
+
* per session so the user knows personalization is disabled.
|
|
7
|
+
*/
|
|
8
|
+
export declare function checkForgenInitialized(): {
|
|
9
|
+
initialized: boolean;
|
|
10
|
+
message?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Returns true if a preflight warning has already been emitted this session */
|
|
13
|
+
export declare function hasPreflightWarned(sessionId: string): boolean;
|
|
14
|
+
/** Mark that a preflight warning has been emitted for this session */
|
|
15
|
+
export declare function markPreflightWarned(sessionId: string): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preflight check for forgen initialization state.
|
|
3
|
+
*
|
|
4
|
+
* Detects "hooks wired but no profile" — a user who ran `forgen install`
|
|
5
|
+
* but never completed onboarding via `forgen`. Emits a one-time warning
|
|
6
|
+
* per session so the user knows personalization is disabled.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { FORGEN_HOME, STATE_DIR } from '../../core/paths.js';
|
|
11
|
+
const ME_DIR = path.join(FORGEN_HOME, 'me');
|
|
12
|
+
const PROFILE_PATH = path.join(ME_DIR, 'forge-profile.json');
|
|
13
|
+
export function checkForgenInitialized() {
|
|
14
|
+
try {
|
|
15
|
+
if (!fs.existsSync(FORGEN_HOME)) {
|
|
16
|
+
return {
|
|
17
|
+
initialized: false,
|
|
18
|
+
message: '[forgen] ~/.forgen not found — run `forgen` to complete setup.',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (!fs.existsSync(PROFILE_PATH)) {
|
|
22
|
+
return {
|
|
23
|
+
initialized: false,
|
|
24
|
+
message: '[forgen] Profile not found — run `forgen` to complete onboarding. Hooks are active but personalization is disabled.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { initialized: true };
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return { initialized: true };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Returns true if a preflight warning has already been emitted this session */
|
|
34
|
+
export function hasPreflightWarned(sessionId) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.existsSync(path.join(STATE_DIR, `preflight-warned-${sessionId}`));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Mark that a preflight warning has been emitted for this session */
|
|
43
|
+
export function markPreflightWarned(sessionId) {
|
|
44
|
+
try {
|
|
45
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
46
|
+
fs.writeFileSync(path.join(STATE_DIR, `preflight-warned-${sessionId}`), '1');
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// fail-open
|
|
50
|
+
}
|
|
51
|
+
}
|
package/dist/hooks/stop-guard.js
CHANGED
|
@@ -24,10 +24,7 @@ import * as os from 'node:os';
|
|
|
24
24
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
25
25
|
import { approve, approveWithWarning, blockStop, failOpenWithTracking } from './shared/hook-response.js';
|
|
26
26
|
import { takeLastExtractionNotice } from '../core/extraction-notice.js';
|
|
27
|
-
import {
|
|
28
|
-
import { checkSelfScoreInflation } from '../checks/self-score-deflation.js';
|
|
29
|
-
import { checkFactVsAgreement } from '../checks/fact-vs-agreement.js';
|
|
30
|
-
import { checkDangerousResponsePattern } from '../checks/dangerous-response-pattern.js';
|
|
27
|
+
import { runMetaGuards } from '../checks/_shared/meta-guard-dispatch.js';
|
|
31
28
|
import { sanitizeForGuard } from '../checks/_shared/text-sanitizer.js';
|
|
32
29
|
import { STATE_DIR } from '../core/paths.js';
|
|
33
30
|
import { sanitizeId } from './shared/sanitize-id.js';
|
|
@@ -179,14 +176,19 @@ function messageTriggersRule(message, rule) {
|
|
|
179
176
|
const t = rule.trigger;
|
|
180
177
|
if (!t.response_keywords_regex)
|
|
181
178
|
return false;
|
|
179
|
+
// ADR-009 §7 후속: 룰-스토어 트리거도 built-in 메타가드와 동일하게 sanitize 된
|
|
180
|
+
// 텍스트로 매칭한다. 트리거 어휘를 코드펜스/백틱/인용으로 *언급만* 한 referential
|
|
181
|
+
// 케이스(메타 대화)에서 룰이 self-match 하던 거짓양성을 줄인다. 자연 산문 속 실제
|
|
182
|
+
// 주장은 sanitize 후에도 남으므로 진짜 탐지(TP)는 보존된다.
|
|
183
|
+
const m = sanitizeForGuard(message);
|
|
182
184
|
const includeRes = compileSafeRegex(t.response_keywords_regex, 'i');
|
|
183
185
|
if (!includeRes.regex)
|
|
184
186
|
return false;
|
|
185
|
-
if (!safeRegexTest(includeRes.regex,
|
|
187
|
+
if (!safeRegexTest(includeRes.regex, m))
|
|
186
188
|
return false;
|
|
187
189
|
if (t.context_exclude_regex) {
|
|
188
190
|
const excludeRes = compileSafeRegex(t.context_exclude_regex, 'i');
|
|
189
|
-
if (excludeRes.regex && safeRegexTest(excludeRes.regex,
|
|
191
|
+
if (excludeRes.regex && safeRegexTest(excludeRes.regex, m))
|
|
190
192
|
return false;
|
|
191
193
|
}
|
|
192
194
|
return true;
|
|
@@ -498,71 +500,28 @@ export async function main() {
|
|
|
498
500
|
// block/approve 어느 경로이든 동일하게 기록 (참조는 응답 내용이 결정).
|
|
499
501
|
const sessionIdForRef = input?.session_id ?? 'unknown';
|
|
500
502
|
emitRecallReferencesFailOpen(sessionIdForRef, lastMessage);
|
|
501
|
-
// TEST-1/2/3: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
|
|
502
|
-
//
|
|
503
|
-
//
|
|
503
|
+
// TEST-1/2/3 + DANGEROUS: rule-free meta guards — FORGEN_USER_CONFIRMED=1 우회 공통.
|
|
504
|
+
// ADR-009 §2a: 디스패처 본체를 checks/_shared/meta-guard-dispatch 로 추출 (Stop 과
|
|
505
|
+
// SubagentStop 이 공유). 여기서는 순수 평가 결과를 받아 recordViolation·blockStop
|
|
506
|
+
// 부수효과만 수행한다. 평가 순서/sanitize/laziness 는 추출 모듈이 보존.
|
|
504
507
|
if (process.env.FORGEN_USER_CONFIRMED !== '1') {
|
|
505
508
|
const sessionId = input?.session_id ?? 'unknown';
|
|
506
509
|
const recentTools = loadRecentToolNames(sessionId);
|
|
507
|
-
const
|
|
508
|
-
|
|
509
|
-
const checks = [
|
|
510
|
-
{
|
|
511
|
-
shortId: 'dangerous-response-pattern',
|
|
512
|
-
ruleSlug: 'rule:DANGEROUS-RESPONSE — destructive command suggestion',
|
|
513
|
-
kind: '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;
|
|
510
|
+
const results = runMetaGuards({ lastMessage, recentTools, minMeasurements: 1 });
|
|
511
|
+
for (const r of results) {
|
|
553
512
|
recordViolation({
|
|
554
|
-
rule_id: `builtin:${
|
|
513
|
+
rule_id: `builtin:${r.shortId}`,
|
|
555
514
|
session_id: sessionId,
|
|
556
515
|
source: 'stop-guard',
|
|
557
|
-
kind:
|
|
516
|
+
kind: r.kind,
|
|
558
517
|
message_preview: lastMessage.slice(0, 120),
|
|
559
518
|
});
|
|
560
|
-
if (
|
|
519
|
+
if (r.kind !== 'block')
|
|
561
520
|
continue;
|
|
562
|
-
const reasonText = `[forgen:stop-guard/${
|
|
521
|
+
const reasonText = `[forgen:stop-guard/${r.shortId}] ${r.reason}
|
|
563
522
|
|
|
564
523
|
(Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
|
|
565
|
-
console.log(blockStop(reasonText,
|
|
524
|
+
console.log(blockStop(reasonText, r.ruleSlug));
|
|
566
525
|
return;
|
|
567
526
|
}
|
|
568
527
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — SubagentStop Guard (ADR-009 §2)
|
|
4
|
+
*
|
|
5
|
+
* 워크플로우/Task subagent 의 마지막 응답에 메타 가드(TEST-1/2/3 + DANGEROUS)를
|
|
6
|
+
* 적용한다. probe 실측(2026-05-29)으로 워크플로우 내부 에이전트도 forgen 훅을
|
|
7
|
+
* 발화함이 확인되어, 대화 밖에서 수행되는 subagent 산출물도 검증 사각지대에서
|
|
8
|
+
* 끌어낸다. SubagentStop 은 `decision:"block"` + `reason` 을 지원하므로(공식 문서
|
|
9
|
+
* 확인) 메인 Stop 과 동일한 Mech-B 재개 메커니즘이 작동한다.
|
|
10
|
+
*
|
|
11
|
+
* 설계 (ADR-009 §2a/2c/2d):
|
|
12
|
+
* - 평가 본체는 checks/_shared/meta-guard-dispatch.runMetaGuards 를 Stop 과 공유.
|
|
13
|
+
* - block-count 는 (sessionId, agentId) 합성 키로 분리 → 동시 subagent 간 stuck-
|
|
14
|
+
* loop 카운터 충돌 방지 (2c).
|
|
15
|
+
* - recentTools 는 per-agent modified-files (post-tool-use 2d 키) 에서 로드 →
|
|
16
|
+
* subagent 자기 tool 윈도우로 TEST-1/2 를 정확히 판정 (2d).
|
|
17
|
+
*
|
|
18
|
+
* fail-open: 어떤 단계든 throw/누락이면 approve. subagent 추적/검증은 best-effort —
|
|
19
|
+
* 에이전트 실행을 막지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
/** SubagentStop 에는 last_assistant_message 가 없으므로 transcript JSONL 을 역순 스캔. */
|
|
22
|
+
export declare function readLastAssistantFromTranscript(transcriptPath?: string): string | null;
|
|
23
|
+
export declare function main(): Promise<void>;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Forgen — SubagentStop Guard (ADR-009 §2)
|
|
4
|
+
*
|
|
5
|
+
* 워크플로우/Task subagent 의 마지막 응답에 메타 가드(TEST-1/2/3 + DANGEROUS)를
|
|
6
|
+
* 적용한다. probe 실측(2026-05-29)으로 워크플로우 내부 에이전트도 forgen 훅을
|
|
7
|
+
* 발화함이 확인되어, 대화 밖에서 수행되는 subagent 산출물도 검증 사각지대에서
|
|
8
|
+
* 끌어낸다. SubagentStop 은 `decision:"block"` + `reason` 을 지원하므로(공식 문서
|
|
9
|
+
* 확인) 메인 Stop 과 동일한 Mech-B 재개 메커니즘이 작동한다.
|
|
10
|
+
*
|
|
11
|
+
* 설계 (ADR-009 §2a/2c/2d):
|
|
12
|
+
* - 평가 본체는 checks/_shared/meta-guard-dispatch.runMetaGuards 를 Stop 과 공유.
|
|
13
|
+
* - block-count 는 (sessionId, agentId) 합성 키로 분리 → 동시 subagent 간 stuck-
|
|
14
|
+
* loop 카운터 충돌 방지 (2c).
|
|
15
|
+
* - recentTools 는 per-agent modified-files (post-tool-use 2d 키) 에서 로드 →
|
|
16
|
+
* subagent 자기 tool 윈도우로 TEST-1/2 를 정확히 판정 (2d).
|
|
17
|
+
*
|
|
18
|
+
* fail-open: 어떤 단계든 throw/누락이면 approve. subagent 추적/검증은 best-effort —
|
|
19
|
+
* 에이전트 실행을 막지 않는다.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'node:fs';
|
|
22
|
+
import * as path from 'node:path';
|
|
23
|
+
import { readStdinJSON } from './shared/read-stdin.js';
|
|
24
|
+
import { approve, blockStop, failOpenWithTracking } from './shared/hook-response.js';
|
|
25
|
+
import { isHookEnabled } from './hook-config.js';
|
|
26
|
+
import { sanitizeId } from './shared/sanitize-id.js';
|
|
27
|
+
import { recordHookTiming } from './shared/hook-timing.js';
|
|
28
|
+
import { STATE_DIR } from '../core/paths.js';
|
|
29
|
+
import { runMetaGuards } from '../checks/_shared/meta-guard-dispatch.js';
|
|
30
|
+
import { recordViolation } from '../engine/lifecycle/signals.js';
|
|
31
|
+
import { incrementBlockCount, resetBlockCount, getStuckLoopThreshold, logDriftEvent, } from './stop-guard.js';
|
|
32
|
+
const HOOK_NAME = 'subagent-stop-guard';
|
|
33
|
+
/** SubagentStop 에는 last_assistant_message 가 없으므로 transcript JSONL 을 역순 스캔. */
|
|
34
|
+
export function readLastAssistantFromTranscript(transcriptPath) {
|
|
35
|
+
if (!transcriptPath)
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const lines = fs.readFileSync(transcriptPath, 'utf-8').trim().split('\n');
|
|
39
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
40
|
+
const line = lines[i].trim();
|
|
41
|
+
if (!line)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const entry = JSON.parse(line);
|
|
45
|
+
if (entry.role !== 'assistant')
|
|
46
|
+
continue;
|
|
47
|
+
if (typeof entry.content === 'string')
|
|
48
|
+
return entry.content;
|
|
49
|
+
if (Array.isArray(entry.content)) {
|
|
50
|
+
const parts = entry.content
|
|
51
|
+
.map((p) => {
|
|
52
|
+
if (typeof p === 'string')
|
|
53
|
+
return p;
|
|
54
|
+
if (p && typeof p === 'object' && 'text' in p)
|
|
55
|
+
return String(p.text);
|
|
56
|
+
return '';
|
|
57
|
+
})
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
if (parts.length)
|
|
60
|
+
return parts.join('\n');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// skip malformed line
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** ADR-009 §2d: per-agent modified-files 에서 recentToolNames 로드. 없으면 []. */
|
|
74
|
+
function loadAgentRecentTools(sessionId, agentId) {
|
|
75
|
+
try {
|
|
76
|
+
const key = `${sessionId}.agent-${agentId}`;
|
|
77
|
+
const p = path.join(STATE_DIR, `modified-files-${sanitizeId(key)}.json`);
|
|
78
|
+
if (!fs.existsSync(p))
|
|
79
|
+
return [];
|
|
80
|
+
const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
|
|
81
|
+
if (Array.isArray(data.recentToolNames)) {
|
|
82
|
+
return data.recentToolNames.filter((n) => typeof n === 'string');
|
|
83
|
+
}
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export async function main() {
|
|
91
|
+
const started = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
if (!isHookEnabled(HOOK_NAME)) {
|
|
94
|
+
console.log(approve());
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const input = await readStdinJSON();
|
|
98
|
+
const lastMessage = readLastAssistantFromTranscript(input?.transcript_path);
|
|
99
|
+
if (!lastMessage) {
|
|
100
|
+
console.log(approve());
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// 사용자 명시 우회 — 메인 Stop 과 일관.
|
|
104
|
+
if (process.env.FORGEN_USER_CONFIRMED === '1') {
|
|
105
|
+
console.log(approve());
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const sessionId = input?.session_id ?? 'unknown';
|
|
109
|
+
const agentId = input?.agent_id ?? input?.agentId ?? 'unknown';
|
|
110
|
+
const recentTools = loadAgentRecentTools(sessionId, agentId);
|
|
111
|
+
const results = runMetaGuards({ lastMessage, recentTools, minMeasurements: 1 });
|
|
112
|
+
// 2c: stuck-loop 카운터를 (sessionId, agentId) 로 분리해 동시 subagent 간 충돌 방지.
|
|
113
|
+
const counterKey = `${sessionId}:${agentId}`;
|
|
114
|
+
for (const r of results) {
|
|
115
|
+
recordViolation({
|
|
116
|
+
rule_id: `builtin:${r.shortId}`,
|
|
117
|
+
session_id: sessionId,
|
|
118
|
+
source: HOOK_NAME,
|
|
119
|
+
kind: r.kind,
|
|
120
|
+
message_preview: lastMessage.slice(0, 120),
|
|
121
|
+
});
|
|
122
|
+
if (r.kind !== 'block')
|
|
123
|
+
continue;
|
|
124
|
+
const count = incrementBlockCount(counterKey, r.shortId);
|
|
125
|
+
if (count > getStuckLoopThreshold()) {
|
|
126
|
+
// 같은 subagent 가 같은 가드에 반복 차단 → block reason 에 말려든 루프.
|
|
127
|
+
// force approve + drift 기록 후 카운터 리셋 (메인 Stop 과 동일 정책).
|
|
128
|
+
logDriftEvent({
|
|
129
|
+
kind: 'subagent_stuck_loop_force_approve',
|
|
130
|
+
session_id: counterKey,
|
|
131
|
+
rule_id: r.shortId,
|
|
132
|
+
count,
|
|
133
|
+
reason_preview: r.reason.slice(0, 120),
|
|
134
|
+
message_preview: lastMessage.slice(0, 120),
|
|
135
|
+
});
|
|
136
|
+
resetBlockCount(counterKey, r.shortId);
|
|
137
|
+
console.log(approve());
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const reasonText = `[forgen:subagent-stop-guard/${r.shortId}] (agent ${agentId.slice(0, 8)}) ${r.reason}
|
|
141
|
+
|
|
142
|
+
(Override this turn: set FORGEN_USER_CONFIRMED=1 (audited).)`;
|
|
143
|
+
console.log(blockStop(reasonText, r.ruleSlug));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
console.log(approve());
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
console.log(failOpenWithTracking(HOOK_NAME, e));
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
recordHookTiming(HOOK_NAME, Date.now() - started, 'SubagentStop');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
156
|
+
if (isMain) {
|
|
157
|
+
void main();
|
|
158
|
+
}
|
|
@@ -3,8 +3,41 @@
|
|
|
3
3
|
* Forgen — SubagentStart/Stop Hook
|
|
4
4
|
*
|
|
5
5
|
* 에이전트 생성/종료 추적.
|
|
6
|
-
* - 활성 에이전트 수 모니터링
|
|
7
|
-
* - 에이전트 동시 실행 제한 (10개 초과 시 경고)
|
|
6
|
+
* - 활성 에이전트 수 모니터링 + 동시 실행 경고 (ADR-009 §4: 기본 16, workflow 면제)
|
|
8
7
|
* - 에이전트 실행 이력 기록
|
|
8
|
+
* - ADR-009 §A: 상태 갱신을 file-lock 으로 보호 (동시 fanout lost-update 방지)
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* 동시 에이전트 경고 임계값.
|
|
12
|
+
* ADR-009 §4: dynamic workflows 는 동시 16 까지 정상 사용하므로 기본값을 16 으로
|
|
13
|
+
* 올리고 env 로 조정 가능하게 한다. 과거 10 고정값은 workflow/team/swarm 실행마다
|
|
14
|
+
* 거짓 경고를 뱉었다.
|
|
15
|
+
*/
|
|
16
|
+
export declare function maxConcurrentAgents(): number;
|
|
17
|
+
/**
|
|
18
|
+
* 동시성 경고를 띄울지 결정 (순수 — 테스트 대상).
|
|
19
|
+
* ADR-009 §4/§B: workflow-subagent 는 동시 16 이 정상이므로 면제. 그 외 에이전트가
|
|
20
|
+
* 임계값을 초과할 때만 경고.
|
|
21
|
+
*/
|
|
22
|
+
export declare function shouldWarnConcurrency(agentType: string, activeCount: number, max: number): boolean;
|
|
23
|
+
export interface AgentEvent {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
action: 'start' | 'stop';
|
|
26
|
+
agentId: string;
|
|
27
|
+
agentType?: string;
|
|
28
|
+
model?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* 에이전트 이벤트를 file-lock 아래에서 적용한다 (ADR-009 §A).
|
|
32
|
+
*
|
|
33
|
+
* 락이 없던 시절에는 동시 SubagentStart (워크플로우 fanout) 가 같은 active-agents
|
|
34
|
+
* 파일을 read-modify-write 하면서 lost-update 로 일부 에이전트가 누락됐다 (probe 에서
|
|
35
|
+
* 3개 중 1개 손실 관찰). 락 안에서 **fresh re-read** 후 mutate 해야 변경이 보존된다.
|
|
36
|
+
* staleMs 는 hook fn 이 짧으므로 5s 로 단축.
|
|
37
|
+
*
|
|
38
|
+
* statePath 는 테스트 주입용 (기본은 sessionId 파생 경로). 반환값은 start 후 활성
|
|
39
|
+
* 에이전트 수 (경고 판정에 사용; stop 은 0).
|
|
40
|
+
*/
|
|
41
|
+
export declare function recordAgentEvent(ev: AgentEvent, statePath?: string): Promise<{
|
|
42
|
+
activeCount: number;
|
|
43
|
+
}>;
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Forgen — SubagentStart/Stop Hook
|
|
4
4
|
*
|
|
5
5
|
* 에이전트 생성/종료 추적.
|
|
6
|
-
* - 활성 에이전트 수 모니터링
|
|
7
|
-
* - 에이전트 동시 실행 제한 (10개 초과 시 경고)
|
|
6
|
+
* - 활성 에이전트 수 모니터링 + 동시 실행 경고 (ADR-009 §4: 기본 16, workflow 면제)
|
|
8
7
|
* - 에이전트 실행 이력 기록
|
|
8
|
+
* - ADR-009 §A: 상태 갱신을 file-lock 으로 보호 (동시 fanout lost-update 방지)
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from 'node:fs';
|
|
11
11
|
import * as path from 'node:path';
|
|
@@ -14,23 +14,47 @@ import { isHookEnabled } from './hook-config.js';
|
|
|
14
14
|
import { sanitizeId } from './shared/sanitize-id.js';
|
|
15
15
|
import { atomicWriteJSON } from './shared/atomic-write.js';
|
|
16
16
|
import { approve, approveWithWarning, failOpenWithTracking } from './shared/hook-response.js';
|
|
17
|
+
import { withFileLock } from './shared/file-lock.js';
|
|
17
18
|
import { STATE_DIR } from '../core/paths.js';
|
|
18
|
-
|
|
19
|
+
/**
|
|
20
|
+
* 동시 에이전트 경고 임계값.
|
|
21
|
+
* ADR-009 §4: dynamic workflows 는 동시 16 까지 정상 사용하므로 기본값을 16 으로
|
|
22
|
+
* 올리고 env 로 조정 가능하게 한다. 과거 10 고정값은 workflow/team/swarm 실행마다
|
|
23
|
+
* 거짓 경고를 뱉었다.
|
|
24
|
+
*/
|
|
25
|
+
export function maxConcurrentAgents() {
|
|
26
|
+
const env = Number(process.env.FORGEN_MAX_CONCURRENT_AGENTS);
|
|
27
|
+
return Number.isFinite(env) && env > 0 ? env : 16;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Claude Code 가 dynamic-workflow 내부 에이전트에 부여하는 agentType (probe 실측,
|
|
31
|
+
* 2026-05-29). 워크플로우 에이전트는 동시 16 이 정상이므로 동시성 경고에서 면제한다.
|
|
32
|
+
*/
|
|
33
|
+
const WORKFLOW_AGENT_TYPE = 'workflow-subagent';
|
|
34
|
+
/**
|
|
35
|
+
* 동시성 경고를 띄울지 결정 (순수 — 테스트 대상).
|
|
36
|
+
* ADR-009 §4/§B: workflow-subagent 는 동시 16 이 정상이므로 면제. 그 외 에이전트가
|
|
37
|
+
* 임계값을 초과할 때만 경고.
|
|
38
|
+
*/
|
|
39
|
+
export function shouldWarnConcurrency(agentType, activeCount, max) {
|
|
40
|
+
if (agentType === WORKFLOW_AGENT_TYPE)
|
|
41
|
+
return false;
|
|
42
|
+
return activeCount > max;
|
|
43
|
+
}
|
|
19
44
|
const AGENT_GC_AGE_MS = 60 * 60 * 1000; // 1시간 이상 종료된 에이전트는 GC
|
|
20
45
|
function getAgentsStatePath(sessionId) {
|
|
21
46
|
return path.join(STATE_DIR, `active-agents-${sanitizeId(sessionId)}.json`);
|
|
22
47
|
}
|
|
23
|
-
function
|
|
48
|
+
function loadAgentsStateAt(statePath, sessionId) {
|
|
24
49
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
50
|
+
if (fs.existsSync(statePath)) {
|
|
51
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
28
52
|
}
|
|
29
53
|
}
|
|
30
|
-
catch { /*
|
|
54
|
+
catch { /* parse failure — starting fresh, prior agent history for this session is lost */ }
|
|
31
55
|
return { sessionId, agents: [] };
|
|
32
56
|
}
|
|
33
|
-
function
|
|
57
|
+
function saveAgentsStateAt(statePath, state) {
|
|
34
58
|
// GC: 1시간 이상 종료된 에이전트 제거
|
|
35
59
|
const now = Date.now();
|
|
36
60
|
state.agents = state.agents.filter(a => {
|
|
@@ -38,7 +62,41 @@ function saveAgentsState(state) {
|
|
|
38
62
|
return true; // 활성 에이전트는 유지
|
|
39
63
|
return now - new Date(a.stoppedAt).getTime() < AGENT_GC_AGE_MS;
|
|
40
64
|
});
|
|
41
|
-
atomicWriteJSON(
|
|
65
|
+
atomicWriteJSON(statePath, state);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 에이전트 이벤트를 file-lock 아래에서 적용한다 (ADR-009 §A).
|
|
69
|
+
*
|
|
70
|
+
* 락이 없던 시절에는 동시 SubagentStart (워크플로우 fanout) 가 같은 active-agents
|
|
71
|
+
* 파일을 read-modify-write 하면서 lost-update 로 일부 에이전트가 누락됐다 (probe 에서
|
|
72
|
+
* 3개 중 1개 손실 관찰). 락 안에서 **fresh re-read** 후 mutate 해야 변경이 보존된다.
|
|
73
|
+
* staleMs 는 hook fn 이 짧으므로 5s 로 단축.
|
|
74
|
+
*
|
|
75
|
+
* statePath 는 테스트 주입용 (기본은 sessionId 파생 경로). 반환값은 start 후 활성
|
|
76
|
+
* 에이전트 수 (경고 판정에 사용; stop 은 0).
|
|
77
|
+
*/
|
|
78
|
+
export async function recordAgentEvent(ev, statePath = getAgentsStatePath(ev.sessionId)) {
|
|
79
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true }); // lock 파일 생성 전 디렉토리 보장
|
|
80
|
+
let activeCount = 0;
|
|
81
|
+
await withFileLock(statePath, () => {
|
|
82
|
+
const state = loadAgentsStateAt(statePath, ev.sessionId); // 락 안에서 fresh re-read
|
|
83
|
+
if (ev.action === 'start') {
|
|
84
|
+
state.agents.push({
|
|
85
|
+
agentId: ev.agentId,
|
|
86
|
+
agentType: ev.agentType || undefined,
|
|
87
|
+
model: ev.model,
|
|
88
|
+
startedAt: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
activeCount = state.agents.filter(a => !a.stoppedAt).length;
|
|
91
|
+
}
|
|
92
|
+
else if (ev.action === 'stop') {
|
|
93
|
+
const agent = state.agents.find(a => a.agentId === ev.agentId && !a.stoppedAt);
|
|
94
|
+
if (agent)
|
|
95
|
+
agent.stoppedAt = new Date().toISOString();
|
|
96
|
+
}
|
|
97
|
+
saveAgentsStateAt(statePath, state);
|
|
98
|
+
}, { staleMs: 5000 });
|
|
99
|
+
return { activeCount };
|
|
42
100
|
}
|
|
43
101
|
async function main() {
|
|
44
102
|
const data = await readStdinJSON();
|
|
@@ -54,37 +112,26 @@ async function main() {
|
|
|
54
112
|
}
|
|
55
113
|
const sessionId = data.session_id ?? 'default';
|
|
56
114
|
// 이벤트 타입은 argv[2] 또는 data 필드에서 판별
|
|
57
|
-
const action = process.argv[2] ?? data.action ?? '';
|
|
115
|
+
const action = (process.argv[2] ?? data.action ?? '') === 'stop' ? 'stop' : 'start';
|
|
58
116
|
const agentId = data.agent_id ?? data.agentId ?? `agent-${Date.now()}`;
|
|
59
117
|
const agentType = data.agent_type ?? data.agentType ?? data.subagent_type ?? '';
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
69
|
-
saveAgentsState(state);
|
|
70
|
-
// 활성 에이전트 수 체크
|
|
71
|
-
const activeCount = state.agents.filter(a => !a.stoppedAt).length;
|
|
72
|
-
if (activeCount > MAX_CONCURRENT_AGENTS) {
|
|
73
|
-
console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ ${activeCount} active agents — too many concurrent executions. Watch resource usage.\n</compound-tool-warning>`));
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
else if (action === 'stop') {
|
|
78
|
-
// 해당 에이전트 종료 표시
|
|
79
|
-
const agent = state.agents.find(a => a.agentId === agentId && !a.stoppedAt);
|
|
80
|
-
if (agent) {
|
|
81
|
-
agent.stoppedAt = new Date().toISOString();
|
|
82
|
-
}
|
|
83
|
-
saveAgentsState(state);
|
|
118
|
+
const model = data.model ?? data.agentModel ?? undefined;
|
|
119
|
+
// ADR-009 §A: 상태 갱신은 file-lock 아래에서. 락 실패/타임아웃은 .catch 의 fail-open
|
|
120
|
+
// 으로 흡수 (추적은 best-effort — 에이전트 실행 자체를 막지 않는다).
|
|
121
|
+
const { activeCount } = await recordAgentEvent({ sessionId, action, agentId, agentType, model });
|
|
122
|
+
// 동시성 경고 (락 밖). 면제/임계값 판정은 shouldWarnConcurrency (테스트 박제).
|
|
123
|
+
if (action === 'start' && shouldWarnConcurrency(agentType, activeCount, maxConcurrentAgents())) {
|
|
124
|
+
console.log(approveWithWarning(`<compound-tool-warning>\n[Forgen] ⚠ ${activeCount} active agents — too many concurrent executions. Watch resource usage.\n</compound-tool-warning>`));
|
|
125
|
+
return;
|
|
84
126
|
}
|
|
85
127
|
console.log(approve());
|
|
86
128
|
}
|
|
87
|
-
main()
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
// import.meta 가드: 직접 실행 시에만 main() — 테스트가 헬퍼를 import 할 때
|
|
130
|
+
// stdin 을 읽는 main 이 돌지 않도록 한다 (모듈 위생).
|
|
131
|
+
const isMain = import.meta.url === `file://${process.argv[1]}`;
|
|
132
|
+
if (isMain) {
|
|
133
|
+
main().catch((e) => {
|
|
134
|
+
process.stderr.write(`[ch-hook] ${e instanceof Error ? e.message : String(e)}\n`);
|
|
135
|
+
console.log(failOpenWithTracking('subagent-tracker', e));
|
|
136
|
+
});
|
|
137
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "Forgen harness hooks (auto-generated,
|
|
2
|
+
"description": "Forgen harness hooks (auto-generated, 22/22 active)",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"UserPromptSubmit": [
|
|
5
5
|
{
|
|
@@ -175,6 +175,11 @@
|
|
|
175
175
|
"type": "command",
|
|
176
176
|
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/subagent-tracker.js\" \"stop\"",
|
|
177
177
|
"timeout": 2
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"type": "command",
|
|
181
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/subagent-stop-guard.js\"",
|
|
182
|
+
"timeout": 10
|
|
178
183
|
}
|
|
179
184
|
]
|
|
180
185
|
}
|