@wooojin/forgen 0.4.8 → 0.4.9
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/assets/dev-guide/be/README.md +226 -0
- package/assets/dev-guide/be/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/be/principles/common.md +433 -0
- package/assets/dev-guide/be/principles/go.md +469 -0
- package/assets/dev-guide/be/principles/node.md +388 -0
- package/assets/dev-guide/be/skills/go/be-build/SKILL.md +262 -0
- package/assets/dev-guide/be/skills/go/be-perf/SKILL.md +308 -0
- package/assets/dev-guide/be/skills/go/be-review/SKILL.md +119 -0
- package/assets/dev-guide/be/skills/go/be-security/SKILL.md +362 -0
- package/assets/dev-guide/be/skills/node/be-build/SKILL.md +239 -0
- package/assets/dev-guide/be/skills/node/be-perf/SKILL.md +272 -0
- package/assets/dev-guide/be/skills/node/be-review/SKILL.md +118 -0
- package/assets/dev-guide/be/skills/node/be-security/SKILL.md +355 -0
- package/assets/dev-guide/be/sources/12factor/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/api-design/INDEX.md +56 -0
- package/assets/dev-guide/be/sources/ddia/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/go-runtime/INDEX.md +62 -0
- package/assets/dev-guide/be/sources/node-runtime/INDEX.md +60 -0
- package/assets/dev-guide/be/sources/otel/INDEX.md +53 -0
- package/assets/dev-guide/be/sources/owasp-api/INDEX.md +52 -0
- package/assets/dev-guide/be/sources/postgres/INDEX.md +55 -0
- package/assets/dev-guide/be/sources/sre-book/INDEX.md +48 -0
- package/assets/dev-guide/fe/README.md +197 -0
- package/assets/dev-guide/fe/adapters/build-agents-md.sh +63 -0
- package/assets/dev-guide/fe/adapters/refresh.sh +68 -0
- package/assets/dev-guide/fe/principles/common.md +160 -0
- package/assets/dev-guide/fe/principles/react.md +183 -0
- package/assets/dev-guide/fe/principles/vue.md +196 -0
- package/assets/dev-guide/fe/skills/react/fe-build/SKILL.md +139 -0
- package/assets/dev-guide/fe/skills/react/fe-perf/SKILL.md +179 -0
- package/assets/dev-guide/fe/skills/react/fe-review/SKILL.md +141 -0
- package/assets/dev-guide/fe/skills/vue/fe-build/SKILL.md +148 -0
- package/assets/dev-guide/fe/skills/vue/fe-perf/SKILL.md +163 -0
- package/assets/dev-guide/fe/skills/vue/fe-review/SKILL.md +136 -0
- package/assets/dev-guide/fe/sources/a11y-dx/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-memory.md +150 -0
- package/assets/dev-guide/fe/sources/a11y-dx/chrome-devtools-performance.md +99 -0
- package/assets/dev-guide/fe/sources/a11y-dx/lighthouse-audits.md +146 -0
- package/assets/dev-guide/fe/sources/a11y-dx/react-devtools-profiler.md +128 -0
- package/assets/dev-guide/fe/sources/a11y-dx/wcag22-new-criteria.md +174 -0
- package/assets/dev-guide/fe/sources/perf/01-core-web-vitals.md +58 -0
- package/assets/dev-guide/fe/sources/perf/02-inp.md +83 -0
- package/assets/dev-guide/fe/sources/perf/03-lcp-cls.md +130 -0
- package/assets/dev-guide/fe/sources/perf/04-speculation-rules.md +148 -0
- package/assets/dev-guide/fe/sources/perf/05-view-transitions.md +153 -0
- package/assets/dev-guide/fe/sources/perf/06-nextjs-caching.md +188 -0
- package/assets/dev-guide/fe/sources/perf/07-server-components.md +181 -0
- package/assets/dev-guide/fe/sources/perf/08-ppr.md +133 -0
- package/assets/dev-guide/fe/sources/perf/09-nextjs-image.md +200 -0
- package/assets/dev-guide/fe/sources/perf/10-optimize-lcp.md +201 -0
- package/assets/dev-guide/fe/sources/perf/INDEX.md +88 -0
- package/assets/dev-guide/fe/sources/react/INDEX.md +41 -0
- package/assets/dev-guide/fe/sources/react/keeping-components-pure.md +135 -0
- package/assets/dev-guide/fe/sources/react/no-effect-patterns.md +183 -0
- package/assets/dev-guide/fe/sources/react/react-compiler.md +182 -0
- package/assets/dev-guide/fe/sources/react/server-components.md +194 -0
- package/assets/dev-guide/fe/sources/react/server-functions.md +192 -0
- package/assets/dev-guide/fe/sources/react/suspense.md +218 -0
- package/assets/dev-guide/fe/sources/react/use-action-state.md +123 -0
- package/assets/dev-guide/fe/sources/react/use-form-status.md +158 -0
- package/assets/dev-guide/fe/sources/react/use-hook.md +153 -0
- package/assets/dev-guide/fe/sources/react/use-optimistic.md +194 -0
- package/assets/dev-guide/fe/sources/toss-ff/INDEX.md +58 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-code-directory.md +79 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-form-fields.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/cohesion-magic-number.md +47 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-item-edit-modal.md +124 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-bottom-sheet.md +57 -0
- package/assets/dev-guide/fe/sources/toss-ff/coupling-use-page-state.md +71 -0
- package/assets/dev-guide/fe/sources/toss-ff/overview-4-principles.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-hidden-logic.md +59 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-http.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/predictability-use-user.md +110 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-comparison-order.md +52 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-condition-name.md +64 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-login-start-page.md +183 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-magic-number.md +53 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-submit-button.md +73 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-ternary-operator.md +38 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-use-page-state.md +77 -0
- package/assets/dev-guide/fe/sources/toss-ff/readability-user-policy.md +98 -0
- package/assets/dev-guide/fe/sources/vue/INDEX.md +17 -0
- package/assets/dev-guide/fe/sources/vue/composition-api.md +251 -0
- package/assets/dev-guide/fe/sources/vue/nuxt-data-fetching.md +232 -0
- package/assets/dev-guide/fe/sources/vue/pinia-state-management.md +134 -0
- package/assets/dev-guide/fe/sources/vue/reactivity-pitfalls.md +261 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-a.md +117 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-b.md +231 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-c.md +86 -0
- package/assets/dev-guide/fe/sources/vue/style-guide-priority-d.md +72 -0
- package/dist/cli.js +42 -0
- package/dist/core/dashboard-cli.d.ts +12 -0
- package/dist/core/dashboard-cli.js +226 -0
- package/dist/core/dev-guide-injector.d.ts +26 -0
- package/dist/core/dev-guide-injector.js +137 -0
- package/dist/core/init.js +53 -0
- package/dist/core/lifecycle-classifier.d.ts +23 -0
- package/dist/core/lifecycle-classifier.js +104 -0
- package/dist/core/observability-backfill.d.ts +31 -0
- package/dist/core/observability-backfill.js +178 -0
- package/dist/core/observability-store.d.ts +58 -0
- package/dist/core/observability-store.js +195 -0
- package/dist/core/session-store.js +4 -0
- package/dist/core/spawn.d.ts +17 -0
- package/dist/core/spawn.js +179 -2
- package/dist/core/statusline-cli.js +34 -1
- package/dist/engine/compound-extractor.js +39 -0
- package/dist/engine/compound-loop.js +6 -0
- package/dist/engine/compound-retire.d.ts +20 -0
- package/dist/engine/compound-retire.js +85 -0
- package/dist/hooks/context-guard.js +25 -1
- package/dist/hooks/post-tool-use.js +48 -0
- package/dist/hooks/solution-injector.js +93 -0
- package/dist/host/install-claude.d.ts +6 -2
- package/dist/host/install-claude.js +74 -2
- package/dist/host/install-codex.d.ts +4 -0
- package/dist/host/install-codex.js +71 -0
- package/dist/host/install-orchestrator.js +1 -0
- package/package.json +6 -6
- package/plugin.json +1 -1
- package/scripts/postinstall.js +134 -0
package/dist/core/spawn.js
CHANGED
|
@@ -2,13 +2,16 @@ import { spawn, execFileSync } from 'node:child_process';
|
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
|
+
import * as crypto from 'node:crypto';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { buildEnv } from './config-injector.js';
|
|
7
8
|
import { loadGlobalConfig } from './global-config.js';
|
|
8
9
|
import { createLogger } from './logger.js';
|
|
9
|
-
import { STATE_DIR } from './paths.js';
|
|
10
|
+
import { STATE_DIR, ME_SOLUTIONS } from './paths.js';
|
|
10
11
|
import { getHostRuntime } from '../host/host-runtime.js';
|
|
11
12
|
import { sendNotification } from './notify.js';
|
|
13
|
+
import { querySurfacedWithin, emitSolutionEvent } from './observability-store.js';
|
|
14
|
+
import { parseSolutionV3 } from '../engine/solution-format.js';
|
|
12
15
|
const log = createLogger('spawn');
|
|
13
16
|
/** Phase 2: host-runtime 어댑터 위임. */
|
|
14
17
|
function findRuntimeLauncher(runtime) {
|
|
@@ -139,6 +142,83 @@ async function countUserMessages(transcriptPath) {
|
|
|
139
142
|
}
|
|
140
143
|
return count;
|
|
141
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Observability P2: commit-diff acted_on signal.
|
|
147
|
+
* 세션 시작 이후 생성된 commit 들의 diff 를 scan 하여 surfaced 솔루션과 키워드 매칭.
|
|
148
|
+
* 프라이버시: commit sha 는 SHA1 해시 12char prefix 만 저장, diff/path 내용 미저장.
|
|
149
|
+
*/
|
|
150
|
+
async function scanCommitDiffForActedOn(sessionId, cwd, sessionStartTime) {
|
|
151
|
+
try {
|
|
152
|
+
const surfaces = querySurfacedWithin(sessionId, 30); // 30분 window
|
|
153
|
+
if (surfaces.length === 0)
|
|
154
|
+
return;
|
|
155
|
+
const since = new Date(sessionStartTime).toISOString();
|
|
156
|
+
let logOutput;
|
|
157
|
+
try {
|
|
158
|
+
logOutput = execFileSync('git', ['log', '--since', since, '--pretty=format:%H'], {
|
|
159
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return; // git not available or not a repo
|
|
164
|
+
}
|
|
165
|
+
const shas = logOutput.trim().split('\n').filter(Boolean);
|
|
166
|
+
if (shas.length === 0)
|
|
167
|
+
return;
|
|
168
|
+
const seen = new Set(); // (sha12+solutionId) dedup
|
|
169
|
+
for (const sha of shas) {
|
|
170
|
+
let diff;
|
|
171
|
+
try {
|
|
172
|
+
diff = execFileSync('git', ['show', '--unified=0', sha], {
|
|
173
|
+
cwd, encoding: 'utf-8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const diffLower = diff.toLowerCase();
|
|
180
|
+
const shaHash = crypto.createHash('sha1').update(sha).digest('hex').slice(0, 12);
|
|
181
|
+
for (const surf of surfaces) {
|
|
182
|
+
const dedupKey = `${shaHash}:${surf.solutionId}`;
|
|
183
|
+
if (seen.has(dedupKey))
|
|
184
|
+
continue;
|
|
185
|
+
const filePath = path.join(ME_SOLUTIONS, `${surf.solutionId}.md`);
|
|
186
|
+
if (!fs.existsSync(filePath))
|
|
187
|
+
continue;
|
|
188
|
+
let raw;
|
|
189
|
+
try {
|
|
190
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const sol = parseSolutionV3(raw);
|
|
196
|
+
if (!sol)
|
|
197
|
+
continue;
|
|
198
|
+
const tags = sol.frontmatter.tags ?? [];
|
|
199
|
+
const identifiers = sol.frontmatter.identifiers ?? [];
|
|
200
|
+
if (tags.length === 0 && identifiers.length === 0)
|
|
201
|
+
continue;
|
|
202
|
+
const hit = tags.some(t => diffLower.includes(t.toLowerCase()))
|
|
203
|
+
|| identifiers.some(id => diffLower.includes(id.toLowerCase()));
|
|
204
|
+
if (!hit)
|
|
205
|
+
continue;
|
|
206
|
+
seen.add(dedupKey);
|
|
207
|
+
emitSolutionEvent({
|
|
208
|
+
sessionId,
|
|
209
|
+
solutionId: surf.solutionId,
|
|
210
|
+
eventType: 'acted_on',
|
|
211
|
+
signalSource: 'commit-diff',
|
|
212
|
+
signalScore: 0.30,
|
|
213
|
+
meta: { commit_sha_hash: shaHash, surface_ts: surf.ts },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
log.debug('scanCommitDiffForActedOn 실패', e);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
142
222
|
/**
|
|
143
223
|
* 세션 종료 후 자동 compound 추출 + USER.md 업데이트.
|
|
144
224
|
* auto-compound-runner.ts를 동기 실행하여 솔루션 추출 + 사용자 패턴 관찰.
|
|
@@ -178,6 +258,78 @@ async function indexTranscriptToFTS(cwd, transcriptPath, sessionId, runtime = 'c
|
|
|
178
258
|
log.debug('FTS5 인덱싱 실패 (session-store 미구현 시 정상)', e);
|
|
179
259
|
}
|
|
180
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Plan B-1: 세션 transcript 사후 스캔으로 rate-limit 감지.
|
|
263
|
+
*
|
|
264
|
+
* stdio='inherit' 라 Claude 출력을 직접 캡처할 수 없으므로,
|
|
265
|
+
* 세션 종료 후 transcript JSONL 의 마지막 N 라인을 읽어
|
|
266
|
+
* RATE_LIMIT_REGEX 매칭 여부를 확인한다.
|
|
267
|
+
*
|
|
268
|
+
* - transcript 없음 / 빈 파일 / parse 실패 → matched: false (fail-open)
|
|
269
|
+
* - 이미 pending-resume.json 존재 시 hook 이 먼저 잡은 것이므로 덮어쓰지 않음
|
|
270
|
+
*
|
|
271
|
+
* @param transcriptPath JSONL 파일 경로
|
|
272
|
+
* @param tailLines 검사할 마지막 라인 수 (기본 5)
|
|
273
|
+
*/
|
|
274
|
+
export async function scanTranscriptForRateLimit(transcriptPath, tailLines = 5) {
|
|
275
|
+
const notFound = { matched: false, resetAt: null };
|
|
276
|
+
if (!transcriptPath)
|
|
277
|
+
return notFound;
|
|
278
|
+
try {
|
|
279
|
+
if (!fs.existsSync(transcriptPath))
|
|
280
|
+
return notFound;
|
|
281
|
+
const { RATE_LIMIT_REGEX, parseRateLimitResetAt } = await import('../hooks/context-guard.js');
|
|
282
|
+
// tail: 파일 끝에서 tailLines 개 라인만 읽음 (대용량 transcript 효율)
|
|
283
|
+
const { createInterface } = await import('node:readline');
|
|
284
|
+
const stream = fs.createReadStream(transcriptPath, { encoding: 'utf-8' });
|
|
285
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
286
|
+
const lines = [];
|
|
287
|
+
for await (const line of rl) {
|
|
288
|
+
if (line)
|
|
289
|
+
lines.push(line);
|
|
290
|
+
if (lines.length > tailLines)
|
|
291
|
+
lines.shift();
|
|
292
|
+
}
|
|
293
|
+
rl.close();
|
|
294
|
+
stream.close();
|
|
295
|
+
if (lines.length === 0)
|
|
296
|
+
return notFound;
|
|
297
|
+
// 각 라인의 content/text/message 필드를 합쳐 regex 적용
|
|
298
|
+
const combined = lines
|
|
299
|
+
.map((line) => {
|
|
300
|
+
try {
|
|
301
|
+
const obj = JSON.parse(line);
|
|
302
|
+
const parts = [];
|
|
303
|
+
if (typeof obj.content === 'string')
|
|
304
|
+
parts.push(obj.content);
|
|
305
|
+
if (typeof obj.text === 'string')
|
|
306
|
+
parts.push(obj.text);
|
|
307
|
+
if (typeof obj.message === 'string')
|
|
308
|
+
parts.push(obj.message);
|
|
309
|
+
// content 가 배열인 경우 (Claude JSONL block format)
|
|
310
|
+
if (Array.isArray(obj.content)) {
|
|
311
|
+
for (const block of obj.content) {
|
|
312
|
+
if (block && typeof block.text === 'string') {
|
|
313
|
+
parts.push(block.text);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return parts.join(' ');
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
.join(' ');
|
|
324
|
+
if (!RATE_LIMIT_REGEX.test(combined))
|
|
325
|
+
return notFound;
|
|
326
|
+
const resetAt = parseRateLimitResetAt(combined);
|
|
327
|
+
return { matched: true, resetAt };
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return notFound;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
181
333
|
/** Claude Code를 하네스 환경으로 실행. exit code를 반환. */
|
|
182
334
|
export async function spawnClaude(args, context, runtime = 'claude') {
|
|
183
335
|
const launcher = findRuntimeLauncher(runtime);
|
|
@@ -235,7 +387,32 @@ export async function spawnClaude(args, context, runtime = 'claude') {
|
|
|
235
387
|
}
|
|
236
388
|
// 1. FTS5 인덱싱 — v0.4.8 (A1) 부터 Claude/Codex 모두 지원.
|
|
237
389
|
await indexTranscriptToFTS(context.cwd, transcript, sessionId, runtime);
|
|
238
|
-
// 2.
|
|
390
|
+
// 2. Plan B-1: transcript 사후 스캔으로 rate-limit 감지 (hook 미감지 보완)
|
|
391
|
+
const resumePath = path.join(STATE_DIR, 'pending-resume.json');
|
|
392
|
+
if (!fs.existsSync(resumePath)) {
|
|
393
|
+
try {
|
|
394
|
+
const scanResult = await scanTranscriptForRateLimit(transcript);
|
|
395
|
+
if (scanResult.matched) {
|
|
396
|
+
const marker = {
|
|
397
|
+
reason: 'rate-limit',
|
|
398
|
+
sessionId,
|
|
399
|
+
runtime,
|
|
400
|
+
resetAt: scanResult.resetAt,
|
|
401
|
+
savedAt: new Date().toISOString(),
|
|
402
|
+
cwd: context.cwd,
|
|
403
|
+
source: 'spawn-transcript-scan',
|
|
404
|
+
};
|
|
405
|
+
fs.writeFileSync(resumePath, JSON.stringify(marker, null, 2));
|
|
406
|
+
log.debug(`transcript scan: rate-limit 감지 → pending-resume.json 작성`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch (e) {
|
|
410
|
+
log.debug('transcript rate-limit scan 실패 (fail-open)', e);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// 3. Observability P2: commit-diff acted_on scan
|
|
414
|
+
await scanCommitDiffForActedOn(sessionId, context.cwd, sessionStartTime);
|
|
415
|
+
// 4. 자동 compound (10+ user 메시지인 경우만) — 양 runtime 호환
|
|
239
416
|
const userMsgCount = await countUserMessages(transcript);
|
|
240
417
|
if (userMsgCount >= 10) {
|
|
241
418
|
await runAutoCompound(context.cwd, transcript, sessionId);
|
|
@@ -17,6 +17,7 @@ import { execSync } from 'node:child_process';
|
|
|
17
17
|
import { loadActiveRules } from '../store/rule-store.js';
|
|
18
18
|
import { getUsageStats } from './usage-telemetry.js';
|
|
19
19
|
import { STATE_DIR } from './paths.js';
|
|
20
|
+
import { classifySolutions } from './lifecycle-classifier.js';
|
|
20
21
|
// 0.4.6 perf #13 — statusline 출력을 5초 캐싱.
|
|
21
22
|
// claude statusLine 은 짧은 간격으로 재호출되는데 매번 git/find/rule-store 를
|
|
22
23
|
// 실행하면 ~100ms 누적. CACHE_TTL_MS 동안 동일 출력 재사용.
|
|
@@ -123,6 +124,30 @@ function buildLine1(payload, cwd) {
|
|
|
123
124
|
parts.push(`${GREEN}${gitBranch}${RESET}`);
|
|
124
125
|
return parts.join(` ${DIM}|${RESET} `);
|
|
125
126
|
}
|
|
127
|
+
/** Build lifecycle line: "🔥X 🟡X 🥶X 💀X 🌱X" — P3 신설. 0건이면 null */
|
|
128
|
+
function buildLifecycleLine() {
|
|
129
|
+
try {
|
|
130
|
+
const classified = classifySolutions();
|
|
131
|
+
if (classified.length === 0)
|
|
132
|
+
return null;
|
|
133
|
+
const counts = { hot: 0, warm: 0, cold: 0, dead: 0, new: 0 };
|
|
134
|
+
for (const c of classified)
|
|
135
|
+
counts[c.lifecycle]++;
|
|
136
|
+
const total = counts.hot + counts.warm + counts.cold + counts.dead + counts.new;
|
|
137
|
+
if (total === 0)
|
|
138
|
+
return null;
|
|
139
|
+
return [
|
|
140
|
+
`${YELLOW}🔥${counts.hot}${RESET}`,
|
|
141
|
+
`${YELLOW}🟡${counts.warm}${RESET}`,
|
|
142
|
+
`${DIM}🥶${counts.cold}${RESET}`,
|
|
143
|
+
`${DIM}💀${counts.dead}${RESET}`,
|
|
144
|
+
`${DIM}🌱${counts.new}${RESET}`,
|
|
145
|
+
].join(` `);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
126
151
|
/** Build usage line: "📊 87/5h · 412/wk (claude)" — 0.4.6 신설 */
|
|
127
152
|
function buildUsageLine() {
|
|
128
153
|
try {
|
|
@@ -193,6 +218,7 @@ export async function handleStatusline() {
|
|
|
193
218
|
const line1 = buildLine1(payload, cwd);
|
|
194
219
|
const line3 = buildLine3(claudeDir, cwd);
|
|
195
220
|
const usageLine = buildUsageLine();
|
|
221
|
+
const lifecycleLine = buildLifecycleLine();
|
|
196
222
|
// Line 2 (context/usage): stdin JSON spec 미확인으로 생략 — TODO
|
|
197
223
|
// Line 4 (tool counts): 추적 인프라 없음 — TODO
|
|
198
224
|
// Line 5 (active task): 추적 인프라 없음 — TODO
|
|
@@ -200,6 +226,13 @@ export async function handleStatusline() {
|
|
|
200
226
|
console.log(line3);
|
|
201
227
|
if (usageLine)
|
|
202
228
|
console.log(usageLine);
|
|
203
|
-
|
|
229
|
+
if (lifecycleLine)
|
|
230
|
+
console.log(lifecycleLine);
|
|
231
|
+
const cacheLines = [line1, line3];
|
|
232
|
+
if (usageLine)
|
|
233
|
+
cacheLines.push(usageLine);
|
|
234
|
+
if (lifecycleLine)
|
|
235
|
+
cacheLines.push(lifecycleLine);
|
|
236
|
+
const cacheBody = cacheLines.join('\n') + '\n';
|
|
204
237
|
writeCache(cacheBody);
|
|
205
238
|
}
|
|
@@ -21,6 +21,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
21
21
|
import { execHost } from '../host/exec-host.js';
|
|
22
22
|
import { serializeSolutionV3, DEFAULT_EVIDENCE, extractTags } from './solution-format.js';
|
|
23
23
|
import { createLogger } from '../core/logger.js';
|
|
24
|
+
import { emitSolutionEvent } from '../core/observability-store.js';
|
|
24
25
|
const log = createLogger('compound-extractor');
|
|
25
26
|
import { CLAUDE_DIR, ME_SOLUTIONS, STATE_DIR } from '../core/paths.js';
|
|
26
27
|
import { atomicWriteJSON, atomicWriteText } from '../hooks/shared/atomic-write.js';
|
|
@@ -845,6 +846,42 @@ export async function runExtraction(cwd, sessionId) {
|
|
|
845
846
|
}
|
|
846
847
|
return result;
|
|
847
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Observability P2: 새 솔루션 본문/supersedes 에서 기존 솔루션 참조 감지 → acted_on emit.
|
|
851
|
+
* fail-open.
|
|
852
|
+
*/
|
|
853
|
+
function emitCompoundExtractActedOn(sessionId, newSolutionName, newContent, newSupersedes) {
|
|
854
|
+
try {
|
|
855
|
+
if (!fs.existsSync(ME_SOLUTIONS))
|
|
856
|
+
return;
|
|
857
|
+
const bodyLower = newContent.toLowerCase();
|
|
858
|
+
const supersedes = newSupersedes ?? '';
|
|
859
|
+
const files = fs.readdirSync(ME_SOLUTIONS).filter(f => f.endsWith('.md'));
|
|
860
|
+
for (const file of files) {
|
|
861
|
+
const existingName = path.basename(file, '.md');
|
|
862
|
+
if (existingName === newSolutionName)
|
|
863
|
+
continue;
|
|
864
|
+
const referenced = (supersedes && existingName === supersedes)
|
|
865
|
+
|| bodyLower.includes(existingName.toLowerCase());
|
|
866
|
+
if (!referenced)
|
|
867
|
+
continue;
|
|
868
|
+
emitSolutionEvent({
|
|
869
|
+
sessionId,
|
|
870
|
+
solutionId: existingName,
|
|
871
|
+
eventType: 'acted_on',
|
|
872
|
+
signalSource: 'compound-extract',
|
|
873
|
+
signalScore: 0.20,
|
|
874
|
+
meta: {
|
|
875
|
+
new_solution: newSolutionName,
|
|
876
|
+
via: (supersedes && existingName === supersedes) ? 'supersedes' : 'body-mention',
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
catch (e) {
|
|
882
|
+
log.debug('emitCompoundExtractActedOn 실패', e);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
848
885
|
/** Process LLM extraction results (called after LLM returns) */
|
|
849
886
|
export function processExtractionResults(rawJson, sessionId) {
|
|
850
887
|
const saved = [];
|
|
@@ -882,6 +919,8 @@ export function processExtractionResults(rawJson, sessionId) {
|
|
|
882
919
|
const savedName = saveExtractedSolution(sol, sessionId);
|
|
883
920
|
if (savedName) {
|
|
884
921
|
saved.push(savedName);
|
|
922
|
+
// Observability P2: compound-extract acted_on signal
|
|
923
|
+
emitCompoundExtractActedOn(sessionId, savedName, sol.content, null);
|
|
885
924
|
}
|
|
886
925
|
else {
|
|
887
926
|
skipped.push(`${sol.name}: 파일 이미 존재`);
|
|
@@ -332,6 +332,12 @@ export async function handleCompound(args) {
|
|
|
332
332
|
rollbackSolutions(since, { dryRun });
|
|
333
333
|
return;
|
|
334
334
|
}
|
|
335
|
+
// --- retire command (P3: dead 솔루션 archive) ---
|
|
336
|
+
if (args.includes('retire') || args.includes('--retire')) {
|
|
337
|
+
const { handleCompoundRetire } = await import('./compound-retire.js');
|
|
338
|
+
await handleCompoundRetire(args);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
335
341
|
// --- explicit interactive command ---
|
|
336
342
|
if (args.includes('interactive') || args.includes('--interactive')) {
|
|
337
343
|
await interactiveCompound(cwd, scope);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Compound Retire (P3)
|
|
3
|
+
*
|
|
4
|
+
* `forgen compound retire [--dry-run] [--apply]`
|
|
5
|
+
*
|
|
6
|
+
* dead 분류 솔루션을 ~/ .forgen/lab/archived/<id>.md 로 이동.
|
|
7
|
+
* 기본은 dry-run (목록만 출력). --apply 시 사용자 확인 후 이동.
|
|
8
|
+
*/
|
|
9
|
+
export interface RetireResult {
|
|
10
|
+
retired: string[];
|
|
11
|
+
skipped: string[];
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** dead 솔루션을 lab/archived/ 로 이동 */
|
|
15
|
+
export declare function retireDeadSolutions(opts: {
|
|
16
|
+
dryRun: boolean;
|
|
17
|
+
yes?: boolean;
|
|
18
|
+
}): Promise<RetireResult>;
|
|
19
|
+
/** CLI 핸들러: forgen compound retire */
|
|
20
|
+
export declare function handleCompoundRetire(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgen — Compound Retire (P3)
|
|
3
|
+
*
|
|
4
|
+
* `forgen compound retire [--dry-run] [--apply]`
|
|
5
|
+
*
|
|
6
|
+
* dead 분류 솔루션을 ~/ .forgen/lab/archived/<id>.md 로 이동.
|
|
7
|
+
* 기본은 dry-run (목록만 출력). --apply 시 사용자 확인 후 이동.
|
|
8
|
+
*/
|
|
9
|
+
import * as fs from 'node:fs';
|
|
10
|
+
import * as path from 'node:path';
|
|
11
|
+
import * as readline from 'node:readline';
|
|
12
|
+
import { ME_SOLUTIONS, ARCHIVED_DIR } from '../core/paths.js';
|
|
13
|
+
import { classifySolutions } from '../core/lifecycle-classifier.js';
|
|
14
|
+
function promptConfirm(question) {
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
17
|
+
rl.question(question, answer => {
|
|
18
|
+
rl.close();
|
|
19
|
+
resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** dead 솔루션을 lab/archived/ 로 이동 */
|
|
24
|
+
export async function retireDeadSolutions(opts) {
|
|
25
|
+
const classified = classifySolutions();
|
|
26
|
+
const dead = classified.filter(c => c.lifecycle === 'dead');
|
|
27
|
+
const retired = [];
|
|
28
|
+
const skipped = [];
|
|
29
|
+
if (dead.length === 0) {
|
|
30
|
+
return { retired: [], skipped: [], dryRun: opts.dryRun };
|
|
31
|
+
}
|
|
32
|
+
// dry-run or apply 출력
|
|
33
|
+
console.log(`\n Dead solutions (${dead.length}):\n`);
|
|
34
|
+
for (const d of dead) {
|
|
35
|
+
const dest = path.join(ARCHIVED_DIR, `${d.solutionId}.md`);
|
|
36
|
+
console.log(` ${d.solutionId}`);
|
|
37
|
+
console.log(` matched_180d=${d.matched_180d} age=${d.ageDays}d`);
|
|
38
|
+
console.log(` → ${dest}`);
|
|
39
|
+
}
|
|
40
|
+
console.log();
|
|
41
|
+
if (opts.dryRun) {
|
|
42
|
+
console.log(' [dry-run] 파일 이동 없음. --apply 로 실행하세요.\n');
|
|
43
|
+
return { retired: dead.map(d => d.solutionId), skipped: [], dryRun: true };
|
|
44
|
+
}
|
|
45
|
+
// apply — 확인 프롬프트
|
|
46
|
+
if (!opts.yes) {
|
|
47
|
+
const ok = await promptConfirm(` ${dead.length}개 솔루션을 archived 로 이동합니다. 계속하시겠습니까? (y/N) `);
|
|
48
|
+
if (!ok) {
|
|
49
|
+
console.log(' 취소되었습니다.\n');
|
|
50
|
+
return { retired: [], skipped: dead.map(d => d.solutionId), dryRun: false };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// mkdir + rename (fail-stop: 오류 시 즉시 throw)
|
|
54
|
+
fs.mkdirSync(ARCHIVED_DIR, { recursive: true });
|
|
55
|
+
for (const d of dead) {
|
|
56
|
+
const src = path.join(ME_SOLUTIONS, `${d.solutionId}.md`);
|
|
57
|
+
const dest = path.join(ARCHIVED_DIR, `${d.solutionId}.md`);
|
|
58
|
+
// 이미 archived 인 경우 skip
|
|
59
|
+
if (fs.existsSync(dest)) {
|
|
60
|
+
skipped.push(d.solutionId);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// src 없으면 skip
|
|
64
|
+
if (!fs.existsSync(src)) {
|
|
65
|
+
skipped.push(d.solutionId);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// fail-stop: rename 실패 시 throw (데이터 이동 정확성 우선)
|
|
69
|
+
fs.renameSync(src, dest);
|
|
70
|
+
retired.push(d.solutionId);
|
|
71
|
+
}
|
|
72
|
+
console.log(` ✓ ${retired.length}개 이동 완료`);
|
|
73
|
+
if (skipped.length > 0) {
|
|
74
|
+
console.log(` ○ ${skipped.length}개 skip (이미 archived 또는 파일 없음)`);
|
|
75
|
+
}
|
|
76
|
+
console.log();
|
|
77
|
+
return { retired, skipped, dryRun: false };
|
|
78
|
+
}
|
|
79
|
+
/** CLI 핸들러: forgen compound retire */
|
|
80
|
+
export async function handleCompoundRetire(args) {
|
|
81
|
+
const apply = args.includes('--apply');
|
|
82
|
+
const yes = args.includes('--yes');
|
|
83
|
+
const dryRun = !apply;
|
|
84
|
+
await retireDeadSolutions({ dryRun, yes });
|
|
85
|
+
}
|
|
@@ -29,7 +29,7 @@ const PROMPT_HISTORY_TRUNCATE = 1024; // ADR-008: 1KB cap per entry
|
|
|
29
29
|
const RATE_LIMIT_MISSES_PATH = path.join(STATE_DIR, 'rate-limit-misses.jsonl');
|
|
30
30
|
// ADR-008: detection regex 분리. token-limit 은 context window, rate-limit 은 API quota.
|
|
31
31
|
export const TOKEN_LIMIT_REGEX = /context.*limit|token.*limit|conversation.*too.*long/i;
|
|
32
|
-
export const RATE_LIMIT_REGEX = /rate.?limit|5.?hour.*limit|weekly.*limit|usage.*limit|quota.*exceeded
|
|
32
|
+
export const RATE_LIMIT_REGEX = /rate.?limit|5.?hour.*limit|weekly.*limit|usage.*limit|quota.*exceeded|out of (?:extra |free )?usage|usage cap|monthly limit reached?/i;
|
|
33
33
|
/**
|
|
34
34
|
* Best-effort reset 시각 파서 (ADR-008 §2).
|
|
35
35
|
*
|
|
@@ -64,6 +64,30 @@ export function parseRateLimitResetAt(msg, now = Date.now()) {
|
|
|
64
64
|
if (sec > 0)
|
|
65
65
|
return new Date(now + sec * 1000).toISOString();
|
|
66
66
|
}
|
|
67
|
+
// Pattern 5: "resets <H>:<MM><am|pm>" (12h, optional "at", optional TZ label in parens)
|
|
68
|
+
// Pattern 1보다 앞에 위치: "resets at 4:20 pm" 에서 Pattern 1이 am/pm 없이 잡으면
|
|
69
|
+
// 24h 로 오변환되므로 am/pm 있는 경우를 먼저 처리.
|
|
70
|
+
// 예: "resets 4:20pm", "resets 4:20pm (Asia/Seoul)", "resets at 4:20 pm"
|
|
71
|
+
const ampm = msg.match(/resets?\s+(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)/i);
|
|
72
|
+
if (ampm) {
|
|
73
|
+
let h = parseInt(ampm[1], 10);
|
|
74
|
+
const m = parseInt(ampm[2], 10);
|
|
75
|
+
const meridiem = ampm[3].toLowerCase();
|
|
76
|
+
if (h >= 1 && h <= 12 && m >= 0 && m <= 59) {
|
|
77
|
+
// 12h → 24h 변환: 12am=0, 12pm=12, 1-11am=1-11, 1-11pm=13-23
|
|
78
|
+
if (meridiem === 'am') {
|
|
79
|
+
h = h === 12 ? 0 : h;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
h = h === 12 ? 12 : h + 12;
|
|
83
|
+
}
|
|
84
|
+
const d = new Date(now);
|
|
85
|
+
d.setUTCHours(h, m, 0, 0);
|
|
86
|
+
if (d.getTime() <= now)
|
|
87
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
88
|
+
return d.toISOString();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
67
91
|
// Pattern 1: "Resets at HH:MM(:SS)? TZ" — TZ 미지원 (UTC 가정)
|
|
68
92
|
const hhmm = msg.match(/(?:reset|retry|available)s?\s+at\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(UTC|GMT|PST|PDT|EST|EDT|KST|JST)?/i);
|
|
69
93
|
if (hhmm) {
|
|
@@ -22,6 +22,9 @@ import { recordHookTiming } from './shared/hook-timing.js';
|
|
|
22
22
|
import { createDriftState, evaluateDrift } from '../core/drift-score.js';
|
|
23
23
|
import { appendImplicitFeedback } from '../store/implicit-feedback-store.js';
|
|
24
24
|
import { recordToolCall } from '../core/usage-telemetry.js';
|
|
25
|
+
import { emitSolutionEvent, querySurfacedWithin } from '../core/observability-store.js';
|
|
26
|
+
import { parseSolutionV3 } from '../engine/solution-format.js';
|
|
27
|
+
import { ME_SOLUTIONS } from '../core/paths.js';
|
|
25
28
|
const RECENT_TOOL_NAMES_WINDOW = 20;
|
|
26
29
|
/** Lightweight hash for content comparison (not cryptographic) */
|
|
27
30
|
function simpleHash(content) {
|
|
@@ -338,6 +341,51 @@ async function main() {
|
|
|
338
341
|
catch (e) {
|
|
339
342
|
log.debug('success hint generation 실패', e);
|
|
340
343
|
}
|
|
344
|
+
// 8. Observability P2: tool-pattern acted_on signal
|
|
345
|
+
try {
|
|
346
|
+
const recentSurfaces = querySurfacedWithin(sessionId, 5);
|
|
347
|
+
if (recentSurfaces.length > 0 && toolName) {
|
|
348
|
+
const toolNameLower = toolName.toLowerCase();
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
for (const surf of recentSurfaces) {
|
|
351
|
+
if (seen.has(surf.solutionId))
|
|
352
|
+
continue;
|
|
353
|
+
seen.add(surf.solutionId);
|
|
354
|
+
const filePath = path.join(ME_SOLUTIONS, `${surf.solutionId}.md`);
|
|
355
|
+
if (!fs.existsSync(filePath))
|
|
356
|
+
continue;
|
|
357
|
+
let raw;
|
|
358
|
+
try {
|
|
359
|
+
raw = fs.readFileSync(filePath, 'utf-8');
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const sol = parseSolutionV3(raw);
|
|
365
|
+
if (!sol)
|
|
366
|
+
continue;
|
|
367
|
+
const tags = sol.frontmatter.tags ?? [];
|
|
368
|
+
const identifiers = sol.frontmatter.identifiers ?? [];
|
|
369
|
+
if (tags.length === 0 && identifiers.length === 0)
|
|
370
|
+
continue;
|
|
371
|
+
const hit = tags.some(t => toolNameLower.includes(t.toLowerCase()))
|
|
372
|
+
|| identifiers.some(id => toolNameLower.includes(id.toLowerCase()));
|
|
373
|
+
if (!hit)
|
|
374
|
+
continue;
|
|
375
|
+
emitSolutionEvent({
|
|
376
|
+
sessionId,
|
|
377
|
+
solutionId: surf.solutionId,
|
|
378
|
+
eventType: 'acted_on',
|
|
379
|
+
signalSource: 'tool-pattern',
|
|
380
|
+
signalScore: 0.30,
|
|
381
|
+
meta: { tool: toolName, surface_ts: surf.ts },
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
log.debug('tool-pattern acted_on emit 실패', e);
|
|
388
|
+
}
|
|
341
389
|
saveModifiedFiles(modState);
|
|
342
390
|
if (messages.length > 0) {
|
|
343
391
|
console.log(approveWithWarning(messages.join('\n')));
|