@su-record/vibe 3.2.61 → 3.2.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +32 -5
  4. package/dist/tools/bench/benchCompare.d.ts +78 -0
  5. package/dist/tools/bench/benchCompare.d.ts.map +1 -0
  6. package/dist/tools/bench/benchCompare.js +113 -0
  7. package/dist/tools/bench/benchCompare.js.map +1 -0
  8. package/dist/tools/bench/benchDefinition.d.ts +37 -0
  9. package/dist/tools/bench/benchDefinition.d.ts.map +1 -0
  10. package/dist/tools/bench/benchDefinition.js +69 -0
  11. package/dist/tools/bench/benchDefinition.js.map +1 -0
  12. package/dist/tools/bench/benchReport.d.ts +16 -0
  13. package/dist/tools/bench/benchReport.d.ts.map +1 -0
  14. package/dist/tools/bench/benchReport.js +64 -0
  15. package/dist/tools/bench/benchReport.js.map +1 -0
  16. package/dist/tools/bench/index.d.ts +10 -0
  17. package/dist/tools/bench/index.d.ts.map +1 -0
  18. package/dist/tools/bench/index.js +7 -0
  19. package/dist/tools/bench/index.js.map +1 -0
  20. package/dist/tools/contract/agentContract.d.ts +86 -0
  21. package/dist/tools/contract/agentContract.d.ts.map +1 -0
  22. package/dist/tools/contract/agentContract.js +140 -0
  23. package/dist/tools/contract/agentContract.js.map +1 -0
  24. package/dist/tools/contract/index.d.ts +8 -0
  25. package/dist/tools/contract/index.d.ts.map +1 -0
  26. package/dist/tools/contract/index.js +6 -0
  27. package/dist/tools/contract/index.js.map +1 -0
  28. package/dist/tools/contract/reverseDrift.d.ts +71 -0
  29. package/dist/tools/contract/reverseDrift.d.ts.map +1 -0
  30. package/dist/tools/contract/reverseDrift.js +136 -0
  31. package/dist/tools/contract/reverseDrift.js.map +1 -0
  32. package/dist/tools/docs/agentsMd.d.ts +45 -0
  33. package/dist/tools/docs/agentsMd.d.ts.map +1 -0
  34. package/dist/tools/docs/agentsMd.js +51 -0
  35. package/dist/tools/docs/agentsMd.js.map +1 -0
  36. package/dist/tools/docs/cacheSurface.d.ts +37 -0
  37. package/dist/tools/docs/cacheSurface.d.ts.map +1 -0
  38. package/dist/tools/docs/cacheSurface.js +98 -0
  39. package/dist/tools/docs/cacheSurface.js.map +1 -0
  40. package/dist/tools/index.d.ts +6 -0
  41. package/dist/tools/index.d.ts.map +1 -1
  42. package/dist/tools/index.js +6 -0
  43. package/dist/tools/index.js.map +1 -1
  44. package/dist/tools/spec/index.d.ts +2 -0
  45. package/dist/tools/spec/index.d.ts.map +1 -1
  46. package/dist/tools/spec/index.js +2 -0
  47. package/dist/tools/spec/index.js.map +1 -1
  48. package/dist/tools/spec/specLifecycle.d.ts +40 -0
  49. package/dist/tools/spec/specLifecycle.d.ts.map +1 -0
  50. package/dist/tools/spec/specLifecycle.js +147 -0
  51. package/dist/tools/spec/specLifecycle.js.map +1 -0
  52. package/dist/tools/spec/validateSpecDocument.d.ts.map +1 -1
  53. package/dist/tools/spec/validateSpecDocument.js +13 -0
  54. package/dist/tools/spec/validateSpecDocument.js.map +1 -1
  55. package/hooks/scripts/code-check.js +7 -0
  56. package/hooks/scripts/lib/ephemeral-lane.js +106 -0
  57. package/hooks/scripts/lib/iteration-cost.js +178 -0
  58. package/hooks/scripts/lib/loop-ledger.js +38 -5
  59. package/hooks/scripts/loop-ledger.js +9 -1
  60. package/hooks/scripts/pre-tool-guard.js +16 -0
  61. package/hooks/scripts/step-counter.js +29 -0
  62. package/package.json +7 -3
  63. package/skills/vibe.contract/SKILL.md +114 -1
  64. package/skills/vibe.docs/references/agent.md +4 -0
  65. package/skills/vibe.loop/SKILL.md +47 -1
  66. package/skills/vibe.spec/SKILL.md +2 -1
  67. package/vibe/rules/loop-contract.md +25 -5
  68. package/vibe/rules/prefix-cache-surface.md +98 -0
  69. package/vibe/templates/spec-template.md +31 -2
@@ -0,0 +1,178 @@
1
+ /**
2
+ * 회전 비용 계측 — "그 전진이 얼마짜리였나" 를 코드가 센다.
3
+ *
4
+ * 원장은 `iterations`(폭주 방어)와 `verified`(전진량) 두 축만 세고 있었다. 둘로는
5
+ * "10회를 썼고 2회 전진했다" 까지만 알 수 있고, 그 2회가 싼 전진이었는지 비싼
6
+ * 전진이었는지는 모른다. vibe 가 효율에 대해 아무 주장도 할 수 없는 이유가 그것이다
7
+ * (constitution §3.5 — 측정하지 않은 수치는 쓰지 않는다).
8
+ *
9
+ * **수치의 출처는 코드여야 한다.** 모델이 "도구를 12번 썼다" 고 보고하는 형태면 그건
10
+ * vibe 가 완료 판정에서 배제하는 바로 그 자기보고이고, 그 위에 쌓인 데이터로는
11
+ * 아무것도 주장할 수 없다. 그래서 인자로 받지 않고 이미 코드가 세고 있는 것을 읽는다 —
12
+ * `step-counter.js`(PostToolUse)가 액션 툴콜을 `current-run.jsonl` 에 스로틀 없이 append 한다.
13
+ *
14
+ * 두 가지를 0 과 구분해서 남긴다. 이게 이 모듈의 존재 이유의 절반이다:
15
+ * - `measured: false` — 훅 미설치·첫 실행이라 **재지 못했다**. 0 이 아니다
16
+ * - `truncated: true` — 로그가 회전해 창의 앞부분이 사라졌다. 그 값은 **하한**이다
17
+ * 재지 못한 것을 0 으로 채우면 훅 미설치 환경에서 "회전당 도구 0회" 라는 거짓 데이터가
18
+ * 쌓이고, 훅은 프로젝트 로컬이라 미설치가 흔하다.
19
+ */
20
+
21
+ import fs from 'fs';
22
+ import path from 'path';
23
+
24
+ /** 서브에이전트 스폰 툴 — 하네스가 이름을 바꾼다. 옛 이름을 지우지 않는다 (step-counter 와 같은 규칙) */
25
+ const SUBAGENT_TOOLS = new Set(['Agent', 'Task']);
26
+
27
+ function toolLogPath(projectDir) {
28
+ return path.join(projectDir, '.vibe', 'metrics', 'current-run.jsonl');
29
+ }
30
+
31
+ function historyPath(projectDir) {
32
+ return path.join(projectDir, '.vibe', 'metrics', 'loop-history.jsonl');
33
+ }
34
+
35
+ /** 회전 마커 — `step-counter.js` 가 jsonl 앞부분을 잘라낼 때 남긴다 */
36
+ function rotationMarkerPath(projectDir) {
37
+ return path.join(projectDir, '.vibe', 'metrics', 'current-run-rotation.json');
38
+ }
39
+
40
+ /**
41
+ * 창의 앞부분이 회전으로 사라졌는가.
42
+ *
43
+ * **추측하지 않고 마커를 읽는다.** "로그의 첫 줄이 창 시작보다 뒤" 라는 추측은 회전이
44
+ * 없어도 항상 참이다 — 창 시작 직후에 툴콜이 없었을 뿐일 수 있다. 그 추측으로 두면
45
+ * 멀쩡한 회전이 전부 `truncated` 로 찍혀 합계에서 조용히 빠진다. 재지 못한 것을 0 으로
46
+ * 적지 않으려다 잰 것까지 버리는 셈이다.
47
+ *
48
+ * @param {string} projectDir
49
+ * @param {string} startTs 창 시작 ISO-8601
50
+ * @returns {boolean}
51
+ */
52
+ function windowTruncated(projectDir, startTs) {
53
+ try {
54
+ const marker = JSON.parse(fs.readFileSync(rotationMarkerPath(projectDir), 'utf-8'));
55
+ return Boolean(marker && marker.keptFrom && marker.keptFrom > startTs);
56
+ } catch {
57
+ return false; // 마커 없음 = 회전 없음
58
+ }
59
+ }
60
+
61
+ function readJsonLines(filePath) {
62
+ const raw = fs.readFileSync(filePath, 'utf-8');
63
+ const out = [];
64
+ for (const line of raw.split('\n')) {
65
+ if (!line) continue;
66
+ try { out.push(JSON.parse(line)); } catch { /* 손상된 줄 무시 */ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * 회전 창의 시작 시각 — 직전 `iteration`, 없으면 직전 `start`.
73
+ *
74
+ * 별도 신호를 만들지 않는 이유: 원장이 이미 두 시각을 갖고 있다. 새 신호를 만들면
75
+ * 그것이 기록되지 않는 경로가 생기고, 그 경로에서 계측이 조용히 죽는다.
76
+ *
77
+ * @param {string} projectDir
78
+ * @param {string} loop
79
+ * @returns {string|null} ISO-8601, 판단 불가면 null
80
+ */
81
+ export function windowStart(projectDir, loop) {
82
+ try {
83
+ const events = readJsonLines(historyPath(projectDir)).filter((e) => e && e.loop === loop);
84
+ for (let i = events.length - 1; i >= 0; i -= 1) {
85
+ if (events[i].event === 'iteration' || events[i].event === 'start') {
86
+ return events[i].ts || null;
87
+ }
88
+ }
89
+ return null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 창은 `(startTs, endTs]` **반열림**이다.
97
+ *
98
+ * 경계에 정확히 걸친 호출을 앞 회전 몫으로 두어야 이중 계수가 나지 않는다 — 직전
99
+ * 회전의 창은 그 시각을 끝으로 포함해 이미 세었다. 같은 밀리초 충돌은 실제로 일어난다.
100
+ */
101
+ function countInWindow(entries, startTs, endTs) {
102
+ let toolCalls = 0;
103
+ let subagents = 0;
104
+ for (const entry of entries) {
105
+ const ts = entry && entry.ts;
106
+ if (!ts || !entry.tool || ts <= startTs || ts > endTs) continue;
107
+ toolCalls += 1;
108
+ if (SUBAGENT_TOOLS.has(entry.tool)) subagents += 1;
109
+ }
110
+ return { toolCalls, subagents };
111
+ }
112
+
113
+ /** 재지 못한 회전 — 0 이 아니라 null 로 남긴다 */
114
+ function unmeasured(elapsedMs, reason) {
115
+ return { measured: false, reason, elapsedMs, toolCalls: null, subagents: null, truncated: false };
116
+ }
117
+
118
+ /**
119
+ * 이번 회전의 비용을 잰다.
120
+ *
121
+ * `elapsedMs` 는 툴 로그와 무관하게 원장 `ts` 만으로 나오므로 로그가 없어도 계산한다.
122
+ *
123
+ * @param {string} projectDir
124
+ * @param {string} loop
125
+ * @param {string} [endTs] 회전 종료 시각 ISO-8601 (기본: 지금)
126
+ * @returns {{ measured: boolean, reason?: string, elapsedMs: number|null,
127
+ * toolCalls: number|null, subagents: number|null, truncated: boolean }}
128
+ */
129
+ export function measureIterationCost(projectDir, loop, endTs) {
130
+ const end = endTs || new Date().toISOString();
131
+ let elapsedMs = null;
132
+ try {
133
+ const start = windowStart(projectDir, loop);
134
+ if (start) elapsedMs = Math.max(0, Date.parse(end) - Date.parse(start));
135
+
136
+ if (!start) return unmeasured(elapsedMs, 'no-window-start');
137
+ const logPath = toolLogPath(projectDir);
138
+ if (!fs.existsSync(logPath)) return unmeasured(elapsedMs, 'no-tool-log');
139
+
140
+ const entries = readJsonLines(logPath);
141
+ if (entries.length === 0) return unmeasured(elapsedMs, 'empty-tool-log');
142
+
143
+ // 로그가 회전해 창의 앞부분이 사라졌으면 이 회전의 수치는 하한이다
144
+ const truncated = windowTruncated(projectDir, start);
145
+ const { toolCalls, subagents } = countInWindow(entries, start, end);
146
+ return { measured: true, elapsedMs, toolCalls, subagents, truncated };
147
+ } catch {
148
+ return unmeasured(elapsedMs, 'measure-failed');
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 회전별 비용을 합산한다.
154
+ *
155
+ * 미측정·절단 회전은 **합계에서 뺀다.** 대신 `measuredIterations` 로 분모를 함께 낸다 —
156
+ * 분모를 모르면 합계는 거짓말이 된다 ("5회전에 도구 20회" 인지 "그중 2회전만 잰 20회" 인지).
157
+ *
158
+ * @param {Array<{cost?: object}>} iterationEvents
159
+ * @returns {{ measuredIterations: number, unmeasuredIterations: number,
160
+ * elapsedMs: number, toolCalls: number, subagents: number }}
161
+ */
162
+ export function sumIterationCosts(iterationEvents) {
163
+ const total = {
164
+ measuredIterations: 0, unmeasuredIterations: 0, elapsedMs: 0, toolCalls: 0, subagents: 0,
165
+ };
166
+ for (const event of iterationEvents) {
167
+ const cost = event && event.cost;
168
+ if (!cost || cost.measured !== true || cost.truncated === true) {
169
+ total.unmeasuredIterations += 1;
170
+ continue;
171
+ }
172
+ total.measuredIterations += 1;
173
+ total.elapsedMs += Number(cost.elapsedMs) || 0;
174
+ total.toolCalls += Number(cost.toolCalls) || 0;
175
+ total.subagents += Number(cost.subagents) || 0;
176
+ }
177
+ return total;
178
+ }
@@ -14,6 +14,7 @@
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import crypto from 'crypto';
17
+ import { measureIterationCost, sumIterationCosts } from './iteration-cost.js';
17
18
 
18
19
  /** 루프 이력 파일 경로 */
19
20
  function historyPath(projectDir) {
@@ -36,7 +37,7 @@ export function hashDiscoverOutput(text) {
36
37
  * 루프 이벤트를 jsonl 파일에 append한다.
37
38
  *
38
39
  * @param {string} projectDir
39
- * @param {{ loop: string, event: 'start'|'discover'|'end'|'iteration'|'trial-approved', result?: 'ok'|'fail'|'stuck', summary?: string, discoverHash?: string, verified?: boolean }} opts
40
+ * @param {{ loop: string, event: 'start'|'discover'|'end'|'iteration'|'trial-approved', result?: 'ok'|'fail'|'stuck', summary?: string, discoverHash?: string, verified?: boolean, cost?: object }} opts
40
41
  * @returns {boolean} 성공 여부
41
42
  */
42
43
  export function appendLoopEvent(projectDir, opts) {
@@ -51,6 +52,7 @@ export function appendLoopEvent(projectDir, opts) {
51
52
  ...(opts.summary !== undefined ? { summary: opts.summary } : {}),
52
53
  ...(opts.discoverHash !== undefined ? { discoverHash: opts.discoverHash } : {}),
53
54
  ...(opts.verified !== undefined ? { verified: opts.verified } : {}),
55
+ ...(opts.cost !== undefined ? { cost: opts.cost } : {}),
54
56
  };
55
57
  fs.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf-8');
56
58
  return true;
@@ -121,13 +123,28 @@ export function isStuck(projectDir, loop, discoverHash) {
121
123
  /**
122
124
  * 회전 1회를 기록한다.
123
125
  *
126
+ * 비용은 **기록 시점에 재서 원장에 박는다.** 나중에 파생하지 않는 이유: 툴 로그
127
+ * (`current-run.jsonl`)는 2MB/5000줄에서 회전하므로 과거 창은 이미 사라졌을 수 있고,
128
+ * 그러면 조용히 낮은 값이 나온다. 계측은 재지 못한 것을 0 으로 채우지 않는 것이 절반이다.
129
+ *
130
+ * 계측은 append 보다 **먼저** 한다 — 창의 끝이 이번 회전이므로, 이 이벤트가 이력에
131
+ * 들어간 뒤에 재면 창이 자기 자신에서 시작한다.
132
+ *
124
133
  * @param {string} projectDir
125
134
  * @param {string} loop
126
135
  * @param {boolean} verified - 이 회전이 검증을 통과했는가 (JUDGE 결정론 게이트 기준)
127
136
  * @returns {boolean}
128
137
  */
129
138
  export function recordIteration(projectDir, loop, verified) {
130
- return appendLoopEvent(projectDir, { loop, event: 'iteration', verified: Boolean(verified) });
139
+ let cost;
140
+ try {
141
+ cost = measureIterationCost(projectDir, loop);
142
+ } catch {
143
+ cost = undefined; // fail-open — 계측 실패가 회전 기록을 막지 않는다
144
+ }
145
+ return appendLoopEvent(projectDir, {
146
+ loop, event: 'iteration', verified: Boolean(verified), ...(cost ? { cost } : {}),
147
+ });
131
148
  }
132
149
 
133
150
  /**
@@ -146,11 +163,25 @@ export function recordIteration(projectDir, loop, verified) {
146
163
  *
147
164
  * @param {string} projectDir
148
165
  * @param {string} loop
166
+ * 세 번째 축(`cost`)은 **계측이지 판정이 아니다** — 예산 소진이나 stuck 을 좌우하지
167
+ * 않는다. 재지 못하거나 창이 잘린 회전은 합계에서 빠지고 `cost.measuredIterations` 가
168
+ * 분모를 알려준다. 분모를 모르면 합계는 거짓말이 된다.
169
+ *
170
+ * ⚠️ 이 수치로 "N% 절감" 같은 문구를 쓰지 않는다 — 비교 데이터가 쌓이기 전의
171
+ * 배수·퍼센트는 constitution §3.5 가 금지하는 바로 그것이다. 쌓기만 한다.
172
+ *
149
173
  * @param {number} [maxIterations=10]
150
- * @returns {{ iterations: number, verified: number, remaining: number, exhausted: boolean }}
174
+ * @returns {{ iterations: number, verified: number, remaining: number, exhausted: boolean,
175
+ * cost: { measuredIterations: number, unmeasuredIterations: number,
176
+ * elapsedMs: number, toolCalls: number, subagents: number } }}
151
177
  */
152
178
  export function readBudget(projectDir, loop, maxIterations = 10) {
153
- const empty = { iterations: 0, verified: 0, remaining: maxIterations, exhausted: false };
179
+ const emptyCost = {
180
+ measuredIterations: 0, unmeasuredIterations: 0, elapsedMs: 0, toolCalls: 0, subagents: 0,
181
+ };
182
+ const empty = {
183
+ iterations: 0, verified: 0, remaining: maxIterations, exhausted: false, cost: emptyCost,
184
+ };
154
185
  try {
155
186
  const raw = fs.readFileSync(historyPath(projectDir), 'utf-8');
156
187
  const events = raw.split('\n')
@@ -164,7 +195,9 @@ export function readBudget(projectDir, loop, maxIterations = 10) {
164
195
  const iterations = iters.length;
165
196
  const verified = iters.filter(e => e.verified === true).length;
166
197
  const remaining = Math.max(0, maxIterations - iterations);
167
- return { iterations, verified, remaining, exhausted: remaining === 0 };
198
+ return {
199
+ iterations, verified, remaining, exhausted: remaining === 0, cost: sumIterationCosts(iters),
200
+ };
168
201
  } catch {
169
202
  return empty;
170
203
  }
@@ -13,6 +13,9 @@
13
13
  * node hooks/scripts/loop-ledger.js gate answer <id> <answer>
14
14
  * node hooks/scripts/loop-ledger.js iteration <name> <verified|unverified>
15
15
  * node hooks/scripts/loop-ledger.js budget <name> [maxIterations]
16
+ *
17
+ * iteration: 회전을 기록하면서 비용(경과 시간·도구 호출 수·서브에이전트 수)을 함께 잰다.
18
+ * 수치의 출처는 코드다 — 모델이 보고한 값을 받는 인자는 두지 않는다.
16
19
  * node hooks/scripts/loop-ledger.js trial <name> [trialIterations]
17
20
  * node hooks/scripts/loop-ledger.js trial-approve <name>
18
21
  *
@@ -88,7 +91,12 @@ if (subcommand === 'start') {
88
91
  }
89
92
  recordIteration(projectDir, loop, verifiedArg === 'verified');
90
93
  const b = readBudget(projectDir, loop);
91
- process.stdout.write(`[loop-ledger] iteration recorded: ${loop} ${verifiedArg} (${b.iterations} 회전 / 검증 ${b.verified})\n`);
94
+ // 비용은 계측이지 판정이 아니다 — 재지 못한 회전은 0 이 아니라 '미측정' 으로 보인다
95
+ const c = b.cost;
96
+ const costNote = c.measuredIterations > 0
97
+ ? `도구 ${c.toolCalls} / 서브에이전트 ${c.subagents} / ${Math.round(c.elapsedMs / 1000)}s (측정 ${c.measuredIterations}회전)`
98
+ : '비용 미측정';
99
+ process.stdout.write(`[loop-ledger] iteration recorded: ${loop} ${verifiedArg} (${b.iterations} 회전 / 검증 ${b.verified}) — ${costNote}\n`);
92
100
 
93
101
  } else if (subcommand === 'budget') {
94
102
  const [loop, maxRaw] = args;
@@ -198,6 +198,7 @@ function formatOutput(toolName, validation) {
198
198
  import { logHookDecision, PROJECT_DIR } from './utils.js';
199
199
  import { buildCliCtx, isDirectRun } from './lib/hook-context.js';
200
200
  import { runPrTestGate } from './lib/pr-gate-runner.js';
201
+ import { ephemeralPathsInGitCommand, formatEphemeralBlock } from './lib/ephemeral-lane.js';
201
202
 
202
203
  /** gh pr create 감지 정규식 (단어 경계 기준, 플래그 허용). */
203
204
  const GH_PR_CREATE_RE = /\bgh\s+pr\s+create\b/;
@@ -237,6 +238,21 @@ export async function run(ctx) {
237
238
  }
238
239
  }
239
240
 
241
+ // 2-b단계: 일회성 경로를 커밋하려는 시도 차단 (심층 방어)
242
+ //
243
+ // 1차 방어는 gitignore 다 — `git add` 는 무시된 경로를 그냥 거부한다. 실제로 뚫리는
244
+ // 경로는 `git add -f` 하나뿐이고 여기서 그것을 막는다. 훅은 프로젝트 로컬이라
245
+ // 미설치가 흔하므로 이것을 1차라고 부르지 않는다.
246
+ if (toolName === 'Bash' || toolName === 'bash') {
247
+ const command = extractTarget(toolInput, 'command') || toolInput;
248
+ const ephemeral = ephemeralPathsInGitCommand(command);
249
+ if (ephemeral.length > 0) {
250
+ console.error(formatEphemeralBlock(ephemeral));
251
+ logHookDecision('pre-tool-guard', 'Bash', 'block', `ephemeral commit: ${ephemeral.join(', ')}`);
252
+ return 2;
253
+ }
254
+ }
255
+
240
256
  // 3단계: 위험 패턴 검증 (보안 탐지)
241
257
  const validation = validateCommand(toolName, toolInput);
242
258
  const output = formatOutput(toolName, validation);
@@ -41,6 +41,8 @@ const METRICS_DIR = projectVibePath(PROJECT_DIR, 'metrics');
41
41
  const ANTI_PATTERNS_DIR = projectVibePath(PROJECT_DIR, 'anti-patterns');
42
42
  const CURRENT_RUN_JSON = path.join(METRICS_DIR, 'current-run.json');
43
43
  const CURRENT_RUN_JSONL = path.join(METRICS_DIR, 'current-run.jsonl');
44
+ // 회전 마커 — jsonl 앞부분이 잘린 사실을 소비자가 추측하지 않게 남긴다 (iteration-cost.js 가 읽는다)
45
+ const CURRENT_RUN_ROTATION = path.join(METRICS_DIR, 'current-run-rotation.json');
44
46
  const MAX_JSONL_LINES = 5000;
45
47
  const FAIL_WINDOW = 10;
46
48
  const FAIL_THRESHOLD = 3;
@@ -169,6 +171,32 @@ function bumpCounter(toolName, currentLineCount) {
169
171
  // 책임 2: current-run.jsonl append + error_category 채움
170
172
  // 항상 즉시 append — 스로틀 없음 (3-fail detector, recipe-extractor 가 소비)
171
173
  // ─────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * 회전 사실을 **기록한다** — 소비자가 추측하지 않게.
177
+ *
178
+ * 회전은 로그의 앞부분을 지우므로, 그 뒤로 계산되는 구간 집계는 전부 하한이 된다.
179
+ * 그런데 "로그의 첫 줄이 구간 시작보다 뒤" 라는 추측으로는 회전을 알아낼 수 없다 —
180
+ * 그건 회전이 없어도 항상 참이다(구간 시작 직후에 툴콜이 없었을 뿐일 수 있다).
181
+ * 추측으로 두면 소비자가 멀쩡한 구간을 조용히 버린다.
182
+ *
183
+ * 별도 파일에 쓰는 이유: jsonl 에 마커 줄을 끼우면 이 파일을 툴콜 기록으로 읽는
184
+ * 기존 소비자(recipe-extractor · 3-fail detector)가 그것을 툴콜로 센다.
185
+ *
186
+ * @param {string[]} keptLines 회전 후 남긴 줄들
187
+ */
188
+ function writeRotationMarker(keptLines) {
189
+ try {
190
+ let keptFrom = null;
191
+ for (const line of keptLines) {
192
+ try { keptFrom = JSON.parse(line).ts || null; break; } catch { /* 손상된 줄 건너뜀 */ }
193
+ }
194
+ fs.writeFileSync(
195
+ CURRENT_RUN_ROTATION,
196
+ JSON.stringify({ rotatedAt: new Date().toISOString(), keptFrom }, null, 2),
197
+ );
198
+ } catch { /* 마커 실패는 무시 — 회전 자체를 막지 않는다 */ }
199
+ }
172
200
  function appendJsonl(ctx) {
173
201
  const toolName = ctx.toolName;
174
202
  if (!toolName) return null;
@@ -194,6 +222,7 @@ function appendJsonl(ctx) {
194
222
  if (lines.length > MAX_JSONL_LINES) {
195
223
  const keep = lines.slice(-Math.floor(MAX_JSONL_LINES / 2));
196
224
  fs.writeFileSync(CURRENT_RUN_JSONL, keep.join('\n') + '\n');
225
+ writeRotationMarker(keep);
197
226
  }
198
227
  }
199
228
  } catch { /* 회전 실패는 무시 */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "3.2.61",
3
+ "version": "3.2.64",
4
4
  "description": "AI Coding Framework for Claude Code — 7+ agents, 52 skills, multi-LLM orchestration",
5
5
  "type": "module",
6
6
  "main": "dist/cli/index.js",
@@ -31,7 +31,7 @@
31
31
  "validate:counts": "tsx scripts/validate-counts.ts",
32
32
  "lint": "oxlint",
33
33
  "lint:ratchet": "tsx scripts/lint-ratchet.ts",
34
- "verify:all": "pnpm lint && pnpm lint:ratchet && pnpm gen:skill-docs:check && pnpm sync:agent-models:check && pnpm validate:skill-invocation && pnpm validate:counts",
34
+ "verify:all": "pnpm lint && pnpm lint:ratchet && pnpm gen:skill-docs:check && pnpm sync:agent-models:check && pnpm validate:skill-invocation && pnpm validate:counts && pnpm gen:agents-md:check && pnpm validate:spec-lifecycle && pnpm validate:cache-surface",
35
35
  "test": "vitest run",
36
36
  "test:watch": "vitest",
37
37
  "test:coverage": "vitest run --coverage",
@@ -44,7 +44,11 @@
44
44
  "gen:plugin-hooks": "npx tsx scripts/gen-plugin-hooks.ts",
45
45
  "gen:plugin-hooks:check": "npx tsx scripts/gen-plugin-hooks.ts --check",
46
46
  "validate:plugin-tree": "npx tsx scripts/build-plugin.ts && git diff --exit-code --stat HEAD -- plugins/",
47
- "validate:mermaid": "npx tsx scripts/validate-mermaid.ts"
47
+ "validate:mermaid": "npx tsx scripts/validate-mermaid.ts",
48
+ "validate:spec-lifecycle": "npx tsx scripts/validate-spec-lifecycle.ts",
49
+ "validate:cache-surface": "npx tsx scripts/validate-cache-surface.ts",
50
+ "gen:agents-md": "npx tsx scripts/gen-agents-md.ts",
51
+ "gen:agents-md:check": "npx tsx scripts/gen-agents-md.ts --check"
48
52
  },
49
53
  "keywords": [
50
54
  "ai",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: vibe.contract
3
3
  description: Use when a SPEC API contract may differ from implementation or endpoint and schema drift must be detected.
4
- argument-hint: "extract | check | diff [feature-name]"
4
+ argument-hint: "extract | check | reverse | agent | diff [feature-name]"
5
5
  user-invocable: true
6
6
  ---
7
7
 
@@ -17,6 +17,8 @@ user-invocable: true
17
17
  /vibe.contract extract <feature> # SPEC → contract record at .vibe/contracts/<feature>.md
18
18
  /vibe.contract check <feature> # contract vs implementation, drift report
19
19
  /vibe.contract diff <feature> # changed fields since last check
20
+ /vibe.contract reverse <feature> # implementation → SPEC, SPEC 결손을 인박스로
21
+ /vibe.contract agent <feature> # 에이전트 계약 vs 도구 호출 로그
20
22
  ```
21
23
 
22
24
  ## What counts as an "API contract"
@@ -91,6 +93,7 @@ Hidden vibe-coding weakness: as the implementation grows, response shapes drift
91
93
  .vibe/contracts/
92
94
  <feature>.md # contract SSOT (extracted from SPEC)
93
95
  <feature>.snapshot.md # implementation snapshot (last check)
96
+ <feature>.reverse.md # 역방향 대조 결과 (구현에만 있는 표면)
94
97
  ```
95
98
 
96
99
  ### Contract frontmatter schema
@@ -176,6 +179,101 @@ endpoints:
176
179
  ```
177
180
  4. On any drift, auto-call `/vibe.regress register --from-contract`
178
181
 
182
+ ### 4. `reverse` — implementation → SPEC (역방향)
183
+
184
+ `check` 가 "SPEC 이 약속한 것을 구현이 지키는가" 를 본다면, `reverse` 는 **"구현이 가진 표면을 SPEC 이
185
+ 알고 있는가"** 를 본다. 둘을 합치지 않는 이유는 방향이 아니라 **귀결이 반대**이기 때문이다.
186
+
187
+ > SPEC 은 승인 시점에 얼어붙는다. 구현이 알아낸 사실이 돌아오는 경로가 없으면
188
+ > "의도 설계자" 의 산출물은 시간이 지날수록 실제 시스템과 멀어진다.
189
+
190
+ **대상**: `구현 ⊃ SPEC` 인 경우만. 구현이 SPEC 보다 **좁은** 것(약속한 필드가 없다 등)은
191
+ `check` 의 관할이다 — 여기서 다시 보지 않는다.
192
+
193
+ **Steps**:
194
+ 1. `.vibe/contracts/<feature>.md` 로드. 없으면 → `extract` 를 먼저 제안하고 종료
195
+ 2. 구현 표면을 수집한다 — **탐지기를 새로 만들지 않고** 아래 Framework Detection Rules 를 그대로 쓴다
196
+ 3. 계약에 없는 표면을 4종으로 분류한다 (아래 등급표)
197
+ 4. `.vibe/contracts/<feature>.reverse.md` 로 기록 — **발견 0건이어도 파일을 낸다**
198
+ ("아직 안 돌렸다" 와 "돌렸는데 깨끗하다" 는 구분되어야 한다)
199
+ 5. 발견이 있으면 인박스에 올린다 (아래 명령)
200
+
201
+ **등급 매핑은 정방향과 반대다** — 구현에만 있는 표면은 코드 실패가 아니라 **SPEC 결손**이다:
202
+
203
+ | 종류 | 등급 | 예 |
204
+ |---|---|---|
205
+ | `unspecified-endpoint` | P2 | 구현에 `GET /users/:id/avatar` 가 있는데 SPEC 에 없다 |
206
+ | `unspecified-field` | P3 | 응답에 `phoneNumber` 가 있는데 계약에 없다 |
207
+ | `unspecified-status-code` | P3 | 구현이 `429` 를 내는데 SPEC 에 없다 |
208
+ | `unspecified-parameter` | P3 | 문서화되지 않은 쿼리 파라미터 |
209
+
210
+ ⛔ **역방향은 차단하지 않는다. P1 을 만들지 않는다.** 판정 주체가 LLM 추출이므로
211
+ loop-contract 의 Judge 권한 경계상 "판정된 P1" 이고, 그것은 단독으로 차단 근거가 아니다.
212
+ verify 를 강등하지 않고, `vibe.regress` 에 등록하지 않으며, SPEC 을 자동으로 고쳐 쓰지 않는다
213
+ (SPEC 확정은 유일한 의무적 사람 개입 지점이다 — 자동 갱신은 그 게이트를 우회한다).
214
+
215
+ 등급·목적지와 출력 형식은 산문이 아니라 코드가 고정한다 (`src/tools/contract/reverseDrift.ts`) —
216
+ `blocking: false` 와 `destination: 'inbox'` 가 리터럴 타입이라 차단하는 분류를 만들 수 없다:
217
+
218
+ ```bash
219
+ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/index.js').then(t => {
220
+ const findings = [{ kind: 'unspecified-endpoint', surface: '<GET /path>', location: '<file:line>' }];
221
+ console.log(t.formatReverseReport({ feature: '<feature>', specPath: '<.vibe/specs/x.md>', comparedAt: new Date().toISOString(), findings }));
222
+ console.log(JSON.stringify(t.formatReverseInboxLines('<feature>', findings)));
223
+ })"
224
+ ```
225
+
226
+ 인박스 기록 (발견 0건이면 줄이 빈 배열로 나오므로 **호출하지 않는다**):
227
+
228
+ ```bash
229
+ node "$HOOKS_DIR/loop-ledger.js" inbox <feature> ok "<line 1>" "<line 2>" …
230
+ ```
231
+
232
+ ### 5. `agent` — 에이전트 계약 vs 도구 호출 로그 (런타임 축)
233
+
234
+ vibe 는 빌드타임만 다룬다. 그런데 사용자가 만드는 것이 **에이전트 제품**일 때, 그 에이전트가
235
+ 사용자 앞에서 내리는 판단은 vibe 의 어떤 게이트도 보지 않는다. 그렇다고 vibe 를 런타임에 넣으면
236
+ `loop-contract` 의 push·release·배포 금지를 정면으로 깬다.
237
+
238
+ 그래서 vibe 가 런타임에 들어가는 대신 **런타임 게이트를 빌드타임에 생성한다.** SPEC 의
239
+ `## Agent Contract` 섹션이 스위치다 (`## API Contract` 와 같은 관례).
240
+
241
+ ⛔ **LLM 이 에이전트 응답을 채점하지 않는다.** 그건 Model Judge 이고 완료 권한이 없다.
242
+ 단언 대상은 **도구 호출 로그**이고, "금지된 도구를 불렀는가" 는 관측된 사실이라 **차단한다** —
243
+ `reverse` 가 절대 차단하지 않는 것과 반대 방향의 결정이며, 그 차이가 곧 Judge 권한 경계다.
244
+
245
+ **Steps**:
246
+ 1. SPEC 에서 `## Agent Contract` 를 파싱한다. 없으면 → 조용히 종료 (모든 기능에 에이전트가 있지 않다)
247
+ 2. `validateAgentContract` 로 정의를 검사한다. P1 이면 실행하지 않는다
248
+ 3. 에이전트 실행 로그를 `{ tool, approved?, ts? }[]` 로 모은다 — **vibe 자신의 로그 형식**이다
249
+ (`step-counter.js` 의 `current-run.jsonl`). 새 형식을 발명하지 않는다
250
+ 4. `checkAgentToolLog` → 위반과 advisory
251
+ 5. 위반이 있으면 `blocking: true` — verify 를 실패로 내린다. **로그가 없으면 `checked: false` 이고,
252
+ 그것은 위반 0건이 아니다** (안 본 것과 봤는데 깨끗한 것은 다르다)
253
+
254
+ | 위반 | 뜻 |
255
+ |---|---|
256
+ | `forbidden-tool` | 계약이 금지한 도구를 호출했다 |
257
+ | `unlisted-tool` | allowlist 가 선언됐는데 목록 밖 도구를 호출했다 (미선언이면 검사하지 않는다) |
258
+ | `unapproved-irreversible` | 되돌릴 수 없는 작업을 승인 기록 없이 호출했다 |
259
+
260
+ **`Escalate` 는 게이트가 아니다.** 조건 충족 여부가 도구 로그에 없어 판정할 수 없다. 선언은 받되
261
+ `advisory` 로 사람에게 넘긴다 — 판정할 수 없는 것을 게이트에 넣으면 통과 의식이 되고,
262
+ 그건 없는 게이트보다 나쁘다.
263
+
264
+ 도구 이름은 **정확히 일치**만 인정한다. 글롭을 허용하면 `send_*` 가 `send_log` 까지 잡는지
265
+ 아무도 확신하지 못한다.
266
+
267
+ ```bash
268
+ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/index.js').then(t => {
269
+ const spec = require('fs').readFileSync('<.vibe/specs/x.md>', 'utf-8');
270
+ const contract = t.parseAgentContract(spec);
271
+ if (!contract) { console.log('no-agent-contract'); return; }
272
+ console.log(JSON.stringify(t.validateAgentContract(contract)));
273
+ console.log(JSON.stringify(t.checkAgentToolLog(contract, JSON.parse(process.env.AGENT_LOG || 'null'))));
274
+ })"
275
+ ```
276
+
179
277
  ## Integration Points
180
278
 
181
279
  ### From /vibe.spec
@@ -196,6 +294,15 @@ Load skill `vibe.contract` with: check <feature>
196
294
  - P1 drift → demote verify to fail; auto-register
197
295
  - P2 / P3 drift → warning only; verify still passes
198
296
 
297
+ 이어서 역방향도 한 번 돌린다:
298
+ ```
299
+ Load skill `vibe.contract` with: reverse <feature>
300
+ ```
301
+ SPEC 에 `## Agent Contract` 가 있으면 `agent <feature>` 도 돌린다 — 위반은 **차단한다**
302
+ (판정 대상이 도구 로그이므로 관측된 사실이다).
303
+ - 결과와 **무관하게 verify 판정은 바뀌지 않는다** — 발견은 인박스로만 간다
304
+ - 인박스에 올린 뒤 파이프라인을 계속 진행한다 (사람을 세우지 않는다)
305
+
199
306
  ### To /vibe.regress
200
307
 
201
308
  On P1 drift:
@@ -230,3 +337,9 @@ After detection, grep for each framework's **route definition pattern** to map e
230
337
  - [ ] `check` reports each drift with severity + location (file:line)
231
338
  - [ ] P1 drift always invokes `/vibe.regress`
232
339
  - [ ] On framework detection failure, ask the user — do not silently skip
340
+ - [ ] `reverse` 는 어떤 발견에도 verify 판정을 바꾸지 않는다
341
+ - [ ] `reverse` 는 발견 0건이어도 `.reverse.md` 를 남긴다
342
+ - [ ] `reverse` 는 발견 0건이면 인박스를 호출하지 않는다
343
+ - [ ] `agent` 는 섹션이 없는 SPEC 에서 조용히 종료한다
344
+ - [ ] `agent` 는 로그가 없으면 `checked:false` 로 남긴다 — 위반 0건으로 적지 않는다
345
+ - [ ] `agent` 의 `Escalate` 는 advisory 로만 나가고 차단하지 않는다
@@ -17,6 +17,10 @@ Cursor is not supported — do not generate or check Cursor-specific context fil
17
17
  **Source of truth:**
18
18
  - **`CLAUDE.md` is the content SSOT.** Always edit it first; `AGENTS.md` is a regenerated derivative.
19
19
  - Behavioral block: `skills/vibe.docs/templates/behavioral-principles.md` (4 Karpathy principles, wrapped in `<!-- VIBE-BEHAVIORAL:START/END -->` markers).
20
+ - **번역 규칙이 파일로 존재하면 그 파일이 SSOT다.** 프로젝트 루트에 `scripts/agents-md-rules.json` 이 있으면
21
+ 아래 4·5번 손 치환 목록을 쓰지 않고 `npm run gen:agents-md` 로 생성한다 — 규칙을 두 곳에 적으면
22
+ 그 순간 두 벌이 되고, 어느 쪽이 맞는지 판정할 수단이 사라진다. 검증은 `npm run gen:agents-md:check`.
23
+ (vibe 저장소 자신이 이 경로다. 규칙 파일이 없는 프로젝트는 4·5번을 그대로 적용한다.)
20
24
 
21
25
  **Procedure (applies to both creation and modification):**
22
26
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: vibe.loop
3
3
  description: 반복 작업을 자율 goal loop로 설계·설치·실행하고 결정론적 gate로 완료를 판정해야 할 때 사용한다.
4
- argument-hint: "design | install | run | status | list [loop-name]"
4
+ argument-hint: "design | install | run | status | list | bench [loop-name]"
5
5
  user-invocable: true
6
6
  ---
7
7
 
@@ -27,6 +27,7 @@ user-invocable: true
27
27
  /vibe.loop run <name> # 1회 반복 실행 (스케줄러가 호출하는 진입점)
28
28
  /vibe.loop status [name] # 실행 이력 + 인박스 요약
29
29
  /vibe.loop list # 정의된 루프 목록과 상태
30
+ /vibe.loop bench <name> # 루프 설정 자기 대조 — 조건만 바꿔 돌린 실행을 비교
30
31
  ```
31
32
 
32
33
  ## 경로 상수 (모든 서브커맨드 공통)
@@ -129,6 +130,50 @@ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/loop/
129
130
  - `list`: `$LOOPS_DIR/*.md`의 frontmatter(name, trigger, status, goal) 테이블.
130
131
  - `status [name]`: `$HISTORY`에서 해당 루프(생략 시 전체)의 최근 10개 이벤트 + 인박스의 미처리 블록 수.
131
132
 
133
+ ## bench — 자기 대조 벤치마크
134
+
135
+ `loop-contract.md` 는 `per-iteration` vs `continuous` 를 두고 **"어느 쪽이 결과가 나은지는 vibe 가
136
+ 측정한 바 없다"** 고 적어두었다. 축만 열고 기본값을 바꾸지 않은 이유가 그것이다. `bench` 는 그
137
+ 문장을 지울 수 있게 하는 도구다 — 외부 벤치마크(SWE-bench 등)와 겨루는 것이 아니라 **같은 루프를
138
+ 조건만 바꿔 돌린 결과끼리** 비교한다.
139
+
140
+ **핵심은 비교가 아니라 판정 불가다.** 벤치 산출물은 곧 문서의 수치가 되므로, 판정을 절제에
141
+ 맡기면 constitution §3.5 가 금지하는 배수·퍼센트가 그 자리에서 만들어진다. 그래서 코드가 낸다.
142
+
143
+ **Steps**:
144
+ 1. 벤치 정의를 만든다 — `name` · `taskSet`(모든 arm 이 동일하게 수행할 과제 id) ·
145
+ `arms`(비교할 조건 2개 이상, **이 축만 다르고 나머지는 같아야 한다**) · `minRunsPerArm`(기본 5)
146
+ 2. `validateBenchDefinition` 으로 검사한다. P1 이 있으면 실행하지 않는다
147
+ 3. arm 마다 `taskSet` 을 `minRunsPerArm` 회 이상 돌린다. 각 실행은 `loop-ledger.js budget` 산출물에서
148
+ `{armId, taskSetHash, gatesPassed, iterations, cost}` 를 남긴다
149
+ 4. `summarizeArm` → `compareArms` → `formatBenchReport`
150
+ 5. 리포트를 `~/.vibe/test-reports/<YYYYMMDD-HHmm>-bench-<name>.md` 에 쓴다 (**프로젝트 로컬 금지**)
151
+
152
+ **판정 어휘 4종 — `winner` 는 없다**:
153
+
154
+ | verdict | 뜻 |
155
+ |---|---|
156
+ | `insufficient-runs` | arm 당 사용 가능 실행이 `minRunsPerArm` 미만 — 어떤 차이든 우연과 구분되지 않는다 |
157
+ | `mixed-task-sets` | 두 arm 이 같은 과제 셋을 돌지 않았다 — 다른 일을 시킨 결과는 비교가 아니다 |
158
+ | `inconclusive` | 관측 범위(min~max)가 겹친다 — 이 표본으로는 차이를 말할 수 없다 |
159
+ | `difference-observed` | 범위가 겹치지 않는다. **"차이가 관측됐다" 이지 "A 가 낫다" 가 아니다** |
160
+
161
+ ⛔ **비율·퍼센트·배수를 만들지 않는다.** 차이는 절대 단위 `delta` 로만 나오고, 게이트 통과도
162
+ "3/5 회" 로 적지 "60%" 로 적지 않는다. 판정 규칙은 범위 겹침이며 **유의성 검정이 아니다** —
163
+ 표본이 한 자릿수인데 t-검정을 붙이면 정밀해 *보이는* 숫자가 나온다.
164
+
165
+ ⛔ **bench 는 아무것도 차단하지 않고, 기본값을 자동으로 바꾸지 않는다.** 리포트다.
166
+ 어느 조건을 고를지는 지표의 방향을 아는 사람이 정한다.
167
+
168
+ ```bash
169
+ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/index.js').then(t => {
170
+ const runs = JSON.parse(process.env.BENCH_RUNS); // 위 3단계에서 모은 실행 기록
171
+ const a = t.summarizeArm('<arm-a>', runs), b = t.summarizeArm('<arm-b>', runs);
172
+ const cmp = t.compareArms(a, b, 'iterations');
173
+ console.log(t.formatBenchReport({ name: '<bench-name>', ranAt: new Date().toISOString(), comparison: cmp }));
174
+ })"
175
+ ```
176
+
132
177
  ## Auto-integration
133
178
 
134
179
  - `/vibe.regress`의 open 항목, `/vibe.contract`의 drift는 nightly-triage discover의 1차 입력이다.
@@ -141,6 +186,7 @@ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/loop/
141
186
  | 완료는 게이트가 판정 | run-ledger `verifyPassed` / 테스트 exit code (REQ-005) |
142
187
  | stuck은 해시가 판정 | loop-ledger check-stuck, 동일 발견 2회 연속 (REQ-009) |
143
188
  | 회전 수도 코드가 판정 | loop-ledger iteration/budget — iterations(폭주 방어) 와 verified(전진량) 를 분리 |
189
+ | 판정 불가도 코드가 판정 | bench — 표본 부족·과제 셋 불일치·범위 겹침이면 차이를 말하지 않는다 (§3.5) |
144
190
  | 이해 부채 가드 | 인박스 리뷰 큐 + push/release 금지 (REQ-008) |
145
191
  | 기록 없는 실행 없음 | loop-history.jsonl start/end 의무 (REQ-006) |
146
192