@su-record/vibe 3.2.21 → 3.2.22

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.md CHANGED
@@ -143,6 +143,6 @@ Public skills use the `vibe.*` namespace and are classified as **entry** / **sta
143
143
 
144
144
  **Include**: `.vibe/{plans,specs,features,todos,research,regressions,contracts,recipes,anti-patterns,loops,config.json,constitution.md}`, `CLAUDE.md`
145
145
  **Vibe-global (not project-local)**: `~/.vibe/test-reports/` — `/vibe.test` artifacts live with the vibe install, not with the project
146
- **Exclude**: `~/.claude/{rules,commands,agents,skills}/`, `.claude/settings.local.json`, `.codex/hooks.json`, `.vibe/{memories,checkpoints,metrics}/`
146
+ **Exclude**: `~/.claude/{rules,commands,agents,skills}/`, `.claude/settings.local.json`, `.codex/hooks.json`, `.vibe/{memories,checkpoints,metrics,gates}/`
147
147
 
148
148
  <!-- VIBE:END -->
@@ -10,6 +10,7 @@ import path from 'path';
10
10
  import { readLedger } from './run-ledger.js';
11
11
  import { projectVibePath } from '../utils.js';
12
12
  import { inboxPath } from './inbox.js';
13
+ import { listOpenGates } from './gates.js';
13
14
 
14
15
  /** 우선순위대로 첫 번째로 존재하는 경로를 고른다 */
15
16
  function firstExisting(projectDir, candidates) {
@@ -65,7 +66,8 @@ function readLatestInboxBlock(projectDir) {
65
66
  * @param {string} projectDir
66
67
  * @param {string} [feature] - 생략 시 `.vibe/.last-feature`
67
68
  * @returns {{ feature: string|null, spec: string|null, scope: string|null,
68
- * ledger: object|null, latestInbox: string|null, missing: string[] }}
69
+ * ledger: object|null, latestInbox: string|null, openGates: object[],
70
+ * missing: string[] }}
69
71
  */
70
72
  export function buildAnchor(projectDir, feature) {
71
73
  const resolved = feature || readLastFeature(projectDir);
@@ -81,5 +83,17 @@ export function buildAnchor(projectDir, feature) {
81
83
  if (!spec) missing.push('spec');
82
84
  if (!ledger) missing.push('run-ledger');
83
85
 
84
- return { feature: resolved, spec, scope, ledger, latestInbox: readLatestInboxBlock(projectDir), missing };
86
+ // 열린 게이트는 재고정 대상이다 사람이 무엇을 기다리는지 모르면 다음 턴이
87
+ // 같은 질문을 다시 만들거나, 답을 기다리는 줄도 모르고 진행한다
88
+ const openGates = listOpenGates(projectDir);
89
+
90
+ return {
91
+ feature: resolved,
92
+ spec,
93
+ scope,
94
+ ledger,
95
+ latestInbox: readLatestInboxBlock(projectDir),
96
+ openGates,
97
+ missing,
98
+ };
85
99
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * 사람 판단 게이트 — 대기 중인 질문을 디스크에 남긴다.
3
+ *
4
+ * 배경: vibe 의 사람 개입 지점(SPEC 승인 · stuck 질문 · 비용 게이트)은 전부
5
+ * 모델이 그 자리에서 만들어 출력하는 텍스트였다. 세션이 죽거나 컨텍스트가
6
+ * 압축되면 **무엇을 묻고 있었는지가 사라진다** — 사람은 돌아왔는데 답할 대상이
7
+ * 없다. run-ledger·loop-history·인박스가 전부 디스크에 사는데 정작 "지금 사람을
8
+ * 기다리는 이유"만 컨텍스트에 살고 있었다.
9
+ *
10
+ * 게이트는 **모호한 상태가 아니라 구체적 질문**을 담는다. "승인 대기" 는 게이트가
11
+ * 아니다 — 무엇을 묻는지, 어떤 선택지가 있는지, 답이 무엇을 바꾸는지가 있어야
12
+ * 다음 턴(또는 다음 사람)이 이어받을 수 있다.
13
+ *
14
+ * 저장: `.vibe/gates/<id>.json` — 파일 하나가 게이트 하나. 동시에 여러 루프가
15
+ * 게이트를 열어도 서로 덮어쓰지 않는다.
16
+ *
17
+ * fail-open — 기록 실패가 루프를 멈추지 않는다.
18
+ */
19
+ import fs from 'fs';
20
+ import path from 'path';
21
+ import { projectVibePath, projectVibePathPreferred } from '../utils.js';
22
+
23
+ /** 읽기용 게이트 디렉토리 (레거시 인식) */
24
+ export function gatesDir(projectDir) {
25
+ return projectVibePath(projectDir, 'gates');
26
+ }
27
+
28
+ /** 쓰기용 게이트 디렉토리 — 항상 신규 레이아웃 */
29
+ function gatesWriteDir(projectDir) {
30
+ return projectVibePathPreferred(projectDir, 'gates');
31
+ }
32
+
33
+ /** 파일명에 쓸 수 있는 형태로 정규화 — 경로 이탈 방지 */
34
+ function safeId(id) {
35
+ return String(id).replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 80);
36
+ }
37
+
38
+ /**
39
+ * 게이트를 연다.
40
+ *
41
+ * @param {string} projectDir
42
+ * @param {{
43
+ * id: string,
44
+ * question: string,
45
+ * options?: string[],
46
+ * kind?: 'spec-approval'|'stuck'|'cost'|'other',
47
+ * context?: Record<string, unknown>,
48
+ * at: string,
49
+ * }} gate - `at` 은 호출자가 넘긴다 (이 모듈은 시각을 읽지 않는다 — 테스트 결정성)
50
+ * @returns {string|null} 기록된 파일 경로, 실패 시 null
51
+ */
52
+ export function openGate(projectDir, gate) {
53
+ try {
54
+ if (!gate?.id || !gate?.question || !gate?.at) return null;
55
+ // 구체적 질문이 아니면 게이트가 아니다 — 빈 질문·모호한 상태 문구를 거른다
56
+ if (gate.question.trim().length < 5) return null;
57
+
58
+ const dir = gatesWriteDir(projectDir);
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ const file = path.join(dir, `${safeId(gate.id)}.json`);
61
+
62
+ const record = {
63
+ id: gate.id,
64
+ kind: gate.kind ?? 'other',
65
+ question: gate.question,
66
+ options: Array.isArray(gate.options) ? gate.options : [],
67
+ context: gate.context ?? {},
68
+ status: 'open',
69
+ openedAt: gate.at,
70
+ };
71
+ fs.writeFileSync(file, JSON.stringify(record, null, 2) + '\n', 'utf-8');
72
+ return file;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** 게이트 파일 하나를 읽는다. 손상된 파일은 무시한다. */
79
+ function readGate(file) {
80
+ try {
81
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
82
+ return parsed && typeof parsed === 'object' ? parsed : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 열려 있는 게이트 목록 — 오래된 것부터.
90
+ * @returns {Array<object>}
91
+ */
92
+ export function listOpenGates(projectDir) {
93
+ try {
94
+ const dir = gatesDir(projectDir);
95
+ return fs.readdirSync(dir)
96
+ .filter(f => f.endsWith('.json'))
97
+ .map(f => readGate(path.join(dir, f)))
98
+ .filter(g => g && g.status === 'open')
99
+ .sort((a, b) => String(a.openedAt).localeCompare(String(b.openedAt)));
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ /**
106
+ * 게이트에 답한다 — 파일은 남기고 status 만 바꾼다.
107
+ * 지우지 않는 이유: 무엇을 물었고 무엇으로 답했는지가 증거다.
108
+ *
109
+ * @returns {boolean} 성공 여부
110
+ */
111
+ export function answerGate(projectDir, id, answer, at) {
112
+ try {
113
+ if (!id || !answer || !at) return false;
114
+ const file = path.join(gatesDir(projectDir), `${safeId(id)}.json`);
115
+ const gate = readGate(file);
116
+ if (!gate || gate.status !== 'open') return false;
117
+
118
+ fs.writeFileSync(
119
+ file,
120
+ JSON.stringify({ ...gate, status: 'answered', answer, answeredAt: at }, null, 2) + '\n',
121
+ 'utf-8',
122
+ );
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+
129
+ /** 사람이 읽는 요약 — 스킬이 그대로 출력한다 */
130
+ export function formatOpenGates(gates) {
131
+ if (gates.length === 0) return '열린 게이트 없음';
132
+ return gates.map(g => {
133
+ const opts = g.options.length > 0
134
+ ? '\n' + g.options.map((o, i) => ` [${i + 1}] ${o}`).join('\n')
135
+ : '';
136
+ return `⏸️ ${g.id} (${g.kind}, ${g.openedAt})\n ${g.question}${opts}`;
137
+ }).join('\n\n');
138
+ }
@@ -8,6 +8,9 @@
8
8
  * node hooks/scripts/loop-ledger.js check-stuck <name> <discoverHash>
9
9
  * node hooks/scripts/loop-ledger.js anchor [feature]
10
10
  * node hooks/scripts/loop-ledger.js inbox <name> <ok|fail|stuck> [line...]
11
+ * node hooks/scripts/loop-ledger.js gate open <id> <question> [option...]
12
+ * node hooks/scripts/loop-ledger.js gate list
13
+ * node hooks/scripts/loop-ledger.js gate answer <id> <answer>
11
14
  *
12
15
  * check-stuck: 'stuck' 또는 'ok'를 stdout에 출력하고 항상 exit 0.
13
16
  * anchor: 재고정 번들 JSON을 stdout에 출력한다 (loop-contract ANCHOR 절).
@@ -17,6 +20,7 @@
17
20
  import { appendLoopEvent, isStuck } from './lib/loop-ledger.js';
18
21
  import { buildAnchor } from './lib/anchor.js';
19
22
  import { prependInboxBlock } from './lib/inbox.js';
23
+ import { openGate, listOpenGates, answerGate, formatOpenGates } from './lib/gates.js';
20
24
 
21
25
  const [, , subcommand, ...args] = process.argv;
22
26
  const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -69,10 +73,39 @@ if (subcommand === 'start') {
69
73
  : '[loop-ledger] WARNING: inbox write failed\n'
70
74
  );
71
75
 
76
+ } else if (subcommand === 'gate') {
77
+ // 사람 판단 지점을 디스크에 남긴다 — 세션이 죽어도 무엇을 묻고 있었는지 남는다
78
+ const [action, id, ...rest] = args;
79
+
80
+ if (action === 'list') {
81
+ process.stdout.write(formatOpenGates(listOpenGates(projectDir)) + '\n');
82
+
83
+ } else if (action === 'open') {
84
+ const [question, ...options] = rest;
85
+ if (!id || !question) {
86
+ process.stdout.write('[loop-ledger] error: gate open 에 id 와 구체적 질문이 필요합니다\n');
87
+ process.exit(0);
88
+ }
89
+ const file = openGate(projectDir, { id, question, options, at: new Date().toISOString() });
90
+ process.stdout.write(file
91
+ ? `[loop-ledger] gate opened: ${id}\n`
92
+ : '[loop-ledger] WARNING: gate open failed (질문이 너무 짧거나 쓰기 실패)\n');
93
+
94
+ } else if (action === 'answer') {
95
+ const answer = rest.join(' ');
96
+ const ok = answerGate(projectDir, id, answer, new Date().toISOString());
97
+ process.stdout.write(ok
98
+ ? `[loop-ledger] gate answered: ${id}\n`
99
+ : `[loop-ledger] WARNING: gate not found or already answered: ${id}\n`);
100
+
101
+ } else {
102
+ process.stdout.write('[loop-ledger] 사용법: gate open <id> <question> [option...] | gate list | gate answer <id> <answer>\n');
103
+ }
104
+
72
105
  } else {
73
106
  process.stdout.write(
74
107
  '[loop-ledger] 사용법: start <name> | end <name> <ok|fail|stuck> [summary] | '
75
- + 'check-stuck <name> <hash> | anchor [feature] | inbox <name> <ok|fail|stuck> [line...]\n'
108
+ + 'check-stuck <name> <hash> | anchor [feature] | inbox <name> <ok|fail|stuck> [line...] | gate <open|list|answer> …\n'
76
109
  );
77
110
  }
78
111
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@su-record/vibe",
3
- "version": "3.2.21",
3
+ "version": "3.2.22",
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",
@@ -208,7 +208,7 @@ Phase 4: /vibe.verify → 검증
208
208
  각 phase 종료 후 JUDGE 단계:
209
209
  - 게이트 통과 (**측정된** P1=0 ∧ verifyPassed) → 루프 종료, Phase 5 보고. 판정된 P1(리뷰어 findings)은 단독으로 게이트를 막지 않는다 — SSOT: `vibe/rules/loop-contract.md` Judge 권한 경계
210
210
  - 게이트 미통과 → RECORD(run-ledger + loop-history.jsonl) 후 다음 ANCHOR로
211
- - stuck(연속 2회 동일 findings 해시) → **어느 automationLevel 에서도 루프를 종료한다.** `confirm`이면 사용자 질문, `autonomous`이면 질문 없이 TODO 기록 후 다음 독립 단위로. 미달을 완료로 기록하지 않는다 (SSOT: `vibe/rules/loop-contract.md` stuck 절)
211
+ - stuck(연속 2회 동일 findings 해시) → **어느 automationLevel 에서도 루프를 종료한다.** `confirm` 이면 사용자에게 묻되 질문을 컨텍스트에만 두지 말고 `loop-ledger.js gate open stuck-{feature} "<무엇이 막혔는지>" "<선택지…>"` 로 남긴다 — 세션이 끊겨도 무엇을 기다리는지 살아남는다 (게이트 객체 절). `autonomous` 질문 없이 TODO 기록 후 다음 독립 단위로. 미달을 완료로 기록하지 않는다 (SSOT: `vibe/rules/loop-contract.md` stuck 절)
212
212
  - max_iterations(기본 10) 도달 → 잔여를 인박스로 이월
213
213
  - **실행 실패(error)** — 스킬 미설치·도구 부재·파일 없음·명령 비정상 종료는 stuck 이 아니다(해시 비교로 안 잡힌다). 같은 방식으로 재시도하지 않고 루프를 종료한다: `confirm` 이면 원인을 제시하고 조치/건너뛰기/중단을 묻고, `autonomous` 이면 `loop-ledger.js inbox <name> fail "<원인>"` 기록 후 다음 독립 단위로. 실행 실패도 완료로 기록하지 않는다 (SSOT: `vibe/rules/loop-contract.md` 실행 실패 절)
214
214
 
@@ -86,6 +86,20 @@ node -e "import('{{VIBE_PATH_URL}}/node_modules/@su-record/vibe/dist/tools/index
86
86
 
87
87
  **P1 이 하나라도 있으면 승인 요청으로 넘어가지 않는다** — SPEC 작성으로 되돌아가 고치고 다시 검사한다 (backward edge). 2회 연속 같은 findings 면 stuck 으로 처리한다 (SSOT: `vibe/rules/loop-contract.md`). P2 는 통과를 막지 않고 승인 메시지에 함께 표시한다.
88
88
 
89
+ ## 승인 게이트를 디스크에 남긴다
90
+
91
+ 승인을 요청하기 **전에** 게이트를 연다. 세션이 끊겨도 무엇을 묻고 있었는지 남는다
92
+ (SSOT: `vibe/rules/loop-contract.md` 게이트 객체 절):
93
+
94
+ ```bash
95
+ node "$HOOKS_DIR/loop-ledger.js" gate open spec-{feature-name} \
96
+ "SPEC '{feature-name}' 로 진행할까요? {가정 1건 명시}" \
97
+ "승인 → run 진행" "수정 후 재작성" "중단"
98
+ ```
99
+
100
+ 사용자가 답하면 `gate answer spec-{feature-name} "<선택>"` 으로 닫는다. 이미 열린
101
+ 게이트가 있으면(ANCHOR 의 `openGates`) 같은 질문을 새로 만들지 말고 그것을 이어받는다.
102
+
89
103
  ## 승인과 루프
90
104
 
91
105
  SPEC 승인이 `vibe/rules/loop-contract.md` 가 정의하는 **유일한 의무적 사람 개입**이다. 승인 후에는 ANCHOR→ACT→JUDGE→RECORD 루프가 게이트 통과까지 자동 반복한다 (`/vibe.run` → `/vibe.verify`). 별도의 파이프라인 승인·단계별 stop gate 는 없다.
@@ -62,6 +62,27 @@ node "$HOOKS_DIR/loop-ledger.js" anchor [feature]
62
62
 
63
63
  > `autonomous` 의 "계속" 은 **stuck 난 루프를 더 돌린다는 뜻이 아니다** — 2회 연속 동일 발견은 정의상 재시도가 무의미하다. 같은 목표를 붙잡지 않고 다음 단위로 넘어간다는 뜻이며, 미달은 TODO/인박스에 남는다. 미달 상태를 **완료로 기록하지 않는다.**
64
64
 
65
+ ### 게이트 객체 — 사람을 기다리는 이유는 디스크에 산다
66
+
67
+ 사람 개입 지점의 질문이 컨텍스트에만 있으면, 세션이 죽거나 compact 로 소실될 때 **무엇을 묻고 있었는지가 사라진다.** 사람은 돌아왔는데 답할 대상이 없다. run-ledger·loop-history·인박스가 전부 디스크에 사는데 "지금 왜 멈춰 있는가"만 컨텍스트에 있었다.
68
+
69
+ ```bash
70
+ node "$HOOKS_DIR/loop-ledger.js" gate open <id> "<구체적 질문>" "<선택지1>" "<선택지2>" …
71
+ node "$HOOKS_DIR/loop-ledger.js" gate list # 열린 게이트
72
+ node "$HOOKS_DIR/loop-ledger.js" gate answer <id> "<답>"
73
+ ```
74
+
75
+ - **모호한 상태는 게이트가 아니다.** "승인 대기" 는 질문이 아니다 — 무엇을 묻는지, 선택지가 무엇인지, 답이 무엇을 바꾸는지가 있어야 다음 턴(또는 다음 사람)이 이어받는다. 너무 짧은 문구는 `gate open` 이 거부한다.
76
+ - 답한 게이트는 **지우지 않는다** — 무엇을 묻고 무엇으로 답했는지가 증거다 (`status: answered` 로 남는다).
77
+ - ANCHOR 가 `openGates` 로 함께 재고정한다. 열린 게이트가 있으면 같은 질문을 다시 만들지 말고 그것을 이어받는다.
78
+ - **게이트와 인박스의 경계**: 게이트는 *지금 답을 기다리는 살아 있는 질문*이라 런타임 상태다(`.vibe/gates/` — gitignore). 결정이 끝난 뒤 남길 기록은 인박스가 맡는다(`.vibe/loops/` — 커밋). 진행 중인 질문을 커밋하면 동시 실행마다 충돌한다.
79
+
80
+ | 게이트 지점 | kind | 질문 |
81
+ |---|---|---|
82
+ | SPEC 승인 | `spec-approval` | 이 SPEC 으로 진행할지 — 승인 / 수정 / 중단 |
83
+ | stuck (`confirm`) | `stuck` | 무엇이 막혔는지 + 값 채우기 / sub-100 승인 / 중단 |
84
+ | 비용 게이트 `ask` | `cost` | 무엇을 얼마나 쓸지 — 진행 / 축소 / 중단 |
85
+
65
86
  ### 비용 게이트 — 사람은 시작점에만 서지 않는다
66
87
 
67
88
  SPEC 승인은 **유일한 의무 게이트**로 남는다. 다만 승인 이후 max_iterations 까지 무인이라, 그 안의 되돌릴 수 없는 지출과 이상 규모 팬아웃을 아무도 보지 못했다. 비용 게이트는 그 둘만 잡는다 — 평상시 규모는 그대로 통과시킨다.