@wooojin/forgen 0.4.10 → 0.4.12
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 +62 -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/spawn.d.ts +13 -0
- package/dist/core/spawn.js +36 -8
- 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/secret-filter.d.ts +1 -0
- package/dist/hooks/secret-filter.js +17 -7
- 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
|
// "최근 세션에서 측정 도구 몇 번 불렸나?" 를 이 배열로 계산한다.
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* 도구 실행 결과에서 API 키, 토큰, 비밀번호 등 민감 정보 노출을 감지합니다.
|
|
6
6
|
* 차단하지 않고 경고 메시지만 출력합니다.
|
|
7
7
|
*/
|
|
8
|
+
import * as fs from 'node:fs';
|
|
9
|
+
import * as path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
8
11
|
import { HookError } from '../core/errors.js';
|
|
9
12
|
import { readStdinJSON } from './shared/read-stdin.js';
|
|
10
13
|
import { isHookEnabled } from './hook-config.js';
|
|
@@ -53,7 +56,7 @@ export function detectSecrets(text) {
|
|
|
53
56
|
}
|
|
54
57
|
return found;
|
|
55
58
|
}
|
|
56
|
-
async function main() {
|
|
59
|
+
export async function main() {
|
|
57
60
|
const data = await readStdinJSON();
|
|
58
61
|
if (!isHookEnabled('secret-filter')) {
|
|
59
62
|
console.log(approve());
|
|
@@ -82,10 +85,17 @@ async function main() {
|
|
|
82
85
|
}
|
|
83
86
|
console.log(approve());
|
|
84
87
|
}
|
|
85
|
-
main()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
// ESM main guard: import 시 main() 실행 방지 (context-guard 와 동일 패턴).
|
|
89
|
+
// secret-filter 는 redactSecrets 등을 다른 hook (context-guard) 이 import 하므로,
|
|
90
|
+
// guard 없이 top-level main() 을 호출하면 import 부작용으로 main() 이 실행되어
|
|
91
|
+
// stdout 에 유령 {"continue":true} 가 1줄 추가된다. 그 결과 import 한 hook 의
|
|
92
|
+
// stdout 이 JSON 2줄이 되어 Claude Code 파싱 실패 → raw JSON 이 터미널에 노출됨.
|
|
93
|
+
if (process.argv[1] && fs.realpathSync(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url)) {
|
|
94
|
+
main().catch((e) => {
|
|
95
|
+
const hookErr = new HookError(e instanceof Error ? e.message : String(e), {
|
|
96
|
+
hookName: 'secret-filter', eventType: 'PostToolUse', cause: e,
|
|
97
|
+
});
|
|
98
|
+
process.stderr.write(`[ch-hook] ${hookErr.name}: ${hookErr.message}\n`);
|
|
99
|
+
console.log(failOpenWithTracking('secret-filter', e));
|
|
88
100
|
});
|
|
89
|
-
|
|
90
|
-
console.log(failOpenWithTracking('secret-filter', e));
|
|
91
|
-
});
|
|
101
|
+
}
|
|
@@ -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
|
+
}>;
|