@cheongfish/bouncer 0.0.0-stage → 0.1.13

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 (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,491 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { catchMessage } = guards;
4
+ const contextIds = require("./context-ids");
5
+ const { parseContextPath } = contextIds;
6
+ const INTENT_ROLES = new Set(['implementer', 'reviewer', 'debugger']);
7
+ const SECTIONS_FAIL_REASONS = new Set([
8
+ 'intent-bundle-missing',
9
+ 'intent-bundle-stale',
10
+ 'intent-sections-drift',
11
+ 'intent-task-invalid',
12
+ ]);
13
+ // intent 전용 command. projectCommands는 이 모듈을 정적 import하지 않고
14
+ // 첫 intent 실행에서만 require한다. 그 안에서도 argv 검증을 통과한 뒤에만
15
+ // intent-provenance / intent-bundle을 적재해, 거절·help 경로가 resolver를
16
+ // require.cache에 남기지 않는다.
17
+ // bundle argv만 이 정규식으로 거절한다. sections의 비정규 --task는
18
+ // loadExecutionTask가 exit 1 intent-task-invalid로 내야 하므로 파서가 막지 않는다.
19
+ // 번호·slug 두 경로 형식을 받는다. 세그먼트 해석은 순수 모듈 context-ids에 맡겨 resolver를 적재하지 않는다.
20
+ const TASK_SHAPE_RE = /^\.bouncer\/context\/epics\/[^/]+\/blueprints\/[^/]+\/tasks\/\d{3}\/tasks\.md$/;
21
+ function isCanonicalTaskPath(rel) {
22
+ if (!TASK_SHAPE_RE.test(rel))
23
+ return false;
24
+ const ids = parseContextPath(rel);
25
+ return ids !== null && ids.blueprintKey !== null;
26
+ }
27
+ /**
28
+ * intent argv를 query·bundle·sections로 분기한다. `bundle`과 `sections`
29
+ * positional만 하위 명령이고, 그 외 positional·잘못된 option은 resolver를
30
+ * 열기 전에 거절한다.
31
+ *
32
+ * @param {string[]} rest - `intent` 뒤 argv
33
+ * @returns {IntentArgs} 성공 시 mode별 필드, 실패 시 error
34
+ */
35
+ function parseIntentArgs(rest) {
36
+ if (rest[0] === 'bundle') {
37
+ return parseBundleArgs(rest.slice(1));
38
+ }
39
+ if (rest[0] === 'sections') {
40
+ return parseSectionsArgs(rest.slice(1));
41
+ }
42
+ return parseQueryArgs(rest);
43
+ }
44
+ /**
45
+ * 단일 함수 intent 조회 인자. parseFlags는 마지막 값만 남기고 빈 문자열을
46
+ * 통과시키므로 중복 singleton·빈 --symbol/--candidate·정수 아닌 --limit를
47
+ * 여기서 거절한다. 값 검증만 하고 Git은 치지 않는다.
48
+ *
49
+ * @param {string[]} rest - `intent` 뒤 argv(`bundle` 아님)
50
+ * @returns {QueryArgs} 성공 시 symbol·optional candidate/limit/repo, 실패 시 error
51
+ */
52
+ function parseQueryArgs(rest) {
53
+ let symbol = null;
54
+ let symbolSeen = false;
55
+ let candidate = null;
56
+ let candidateSeen = false;
57
+ let limit = null;
58
+ let limitSeen = false;
59
+ let repo;
60
+ let repoSeen = false;
61
+ const fail = (message) => ({
62
+ mode: 'query',
63
+ error: `intent: ${message}\n`,
64
+ symbol,
65
+ candidate,
66
+ limit,
67
+ repo,
68
+ });
69
+ for (let i = 0; i < rest.length; i += 1) {
70
+ const token = rest[i];
71
+ if (token === '--symbol') {
72
+ // 1. singleton — 두 번째 --symbol을 마지막 값으로 덮으면 호출자가 고른
73
+ // 함수와 다른 조회가 성공한 것처럼 보인다.
74
+ if (symbolSeen)
75
+ return fail('duplicate option: --symbol');
76
+ symbolSeen = true;
77
+ const value = rest[++i];
78
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
79
+ return fail('--symbol requires a non-empty function name');
80
+ }
81
+ symbol = value.trim();
82
+ continue;
83
+ }
84
+ if (token === '--candidate') {
85
+ if (candidateSeen)
86
+ return fail('duplicate option: --candidate');
87
+ candidateSeen = true;
88
+ const value = rest[++i];
89
+ // opaque ref는 resolver가 발급한 값이다. 빈 문자열을 path로 재해석하지 않는다.
90
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
91
+ return fail('--candidate requires a non-empty qualified-ref');
92
+ }
93
+ candidate = value.trim();
94
+ continue;
95
+ }
96
+ if (token === '--limit') {
97
+ if (limitSeen)
98
+ return fail('duplicate option: --limit');
99
+ limitSeen = true;
100
+ const value = rest[++i];
101
+ if (value === undefined || value.startsWith('--') || value.length === 0) {
102
+ return fail('--limit requires an integer 1..5');
103
+ }
104
+ // 1..5 한 자리만 받는다. Number('3.0')·parseInt('3abc')는 통과하므로 정규식으로 막는다.
105
+ if (!/^[1-5]$/.test(value)) {
106
+ return fail('--limit requires an integer 1..5');
107
+ }
108
+ limit = Number(value);
109
+ continue;
110
+ }
111
+ if (token === '--repo') {
112
+ if (repoSeen)
113
+ return fail('duplicate option: --repo');
114
+ repoSeen = true;
115
+ const value = rest[++i];
116
+ if (!value || value.startsWith('--'))
117
+ return fail('--repo requires a directory');
118
+ repo = value;
119
+ continue;
120
+ }
121
+ if (token.startsWith('--'))
122
+ return fail(`unknown option: ${token}`);
123
+ return fail(`unexpected argument: ${token}`);
124
+ }
125
+ if (!symbolSeen || symbol === null) {
126
+ return fail('--symbol <function-name> is required');
127
+ }
128
+ return { mode: 'query', symbol, candidate, limit, repo };
129
+ }
130
+ /**
131
+ * intent bundle 인자. --symbol은 반복 가능하고 --candidate는 바로 앞
132
+ * --symbol에만 붙는다. --limit은 query 전용이라 bundle에서 거절한다.
133
+ * --no-symbols는 함수 없는 task(문서·테스트 전용)용 명시 opt-in이며
134
+ * --symbol/--candidate와 함께 쓸 수 없다.
135
+ *
136
+ * @param {string[]} rest - `intent bundle` 뒤 argv
137
+ * @returns {BundleArgs} 성공 시 task·functions·optional repo, 실패 시 error
138
+ */
139
+ function parseBundleArgs(rest) {
140
+ let task = null;
141
+ let taskSeen = false;
142
+ const functions = [];
143
+ const seenSymbols = new Set();
144
+ let openIndex = -1;
145
+ let repo;
146
+ let repoSeen = false;
147
+ const fail = (message) => ({
148
+ mode: 'bundle',
149
+ error: `intent: ${message}\n`,
150
+ task,
151
+ functions,
152
+ repo,
153
+ });
154
+ // --no-symbols 조합 검사는 위치와 무관해야 한다(`--no-symbols --candidate x`가
155
+ // "--candidate requires a preceding --symbol"로 새지 않도록). 그래서 루프 전에
156
+ // 한 번 훑는다. --task/--repo 값은 `--`로 시작할 수 없어(루프에서 거절) 값 위치
157
+ // 토큰으로 인한 오탐이 없다.
158
+ const noSymbolsCount = rest.filter((token) => token === '--no-symbols').length;
159
+ if (noSymbolsCount > 0) {
160
+ if (rest.includes('--symbol') || rest.includes('--candidate')) {
161
+ return fail('--no-symbols cannot be combined with --symbol or --candidate');
162
+ }
163
+ if (noSymbolsCount > 1)
164
+ return fail('duplicate option: --no-symbols');
165
+ }
166
+ const noSymbols = noSymbolsCount === 1;
167
+ for (let i = 0; i < rest.length; i += 1) {
168
+ const token = rest[i];
169
+ if (token === '--no-symbols')
170
+ continue; // 위에서 이미 검증했다.
171
+ if (token === '--task') {
172
+ if (taskSeen)
173
+ return fail('duplicate option: --task');
174
+ taskSeen = true;
175
+ const value = rest[++i];
176
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
177
+ return fail('--task requires a non-empty tasks.md path');
178
+ }
179
+ const trimmed = value.trim();
180
+ // absolute·`..`·비-canonical은 사용법 오류. resolver를 열면 exit 1로
181
+ // 섞이므로 여기서 막아 query와 같은 exit 2 경계를 유지한다.
182
+ if (trimmed.startsWith('/')
183
+ || trimmed.includes('..')
184
+ || !isCanonicalTaskPath(trimmed)) {
185
+ return fail('--task must be a repo-relative canonical tasks.md path');
186
+ }
187
+ task = trimmed;
188
+ continue;
189
+ }
190
+ if (token === '--symbol') {
191
+ const value = rest[++i];
192
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
193
+ return fail('--symbol requires a non-empty function name');
194
+ }
195
+ const symbol = value.trim();
196
+ if (seenSymbols.has(symbol)) {
197
+ return fail(`duplicate function symbol: ${symbol}`);
198
+ }
199
+ seenSymbols.add(symbol);
200
+ functions.push({ symbol });
201
+ openIndex = functions.length - 1;
202
+ continue;
203
+ }
204
+ if (token === '--candidate') {
205
+ // 바로 앞 --symbol에만 결합. 앞선 symbol이 없거나 이미 candidate가
206
+ // 있으면 한 symbol에 두 ref를 붙인 요청이므로 거절한다.
207
+ if (openIndex < 0) {
208
+ return fail('--candidate requires a preceding --symbol');
209
+ }
210
+ const open = functions[openIndex];
211
+ if (open.candidateRef !== undefined && open.candidateRef !== null) {
212
+ return fail('duplicate option: --candidate for the same --symbol');
213
+ }
214
+ const value = rest[++i];
215
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
216
+ return fail('--candidate requires a non-empty qualified-ref');
217
+ }
218
+ open.candidateRef = value.trim();
219
+ continue;
220
+ }
221
+ if (token === '--limit') {
222
+ // bundle은 전체 함수 집합을 기록한다. query limit를 받으면 예산이
223
+ // 기록 계약처럼 보이므로 알 수 없는 option과 같이 거절한다.
224
+ return fail('unknown option: --limit');
225
+ }
226
+ if (token === '--repo') {
227
+ if (repoSeen)
228
+ return fail('duplicate option: --repo');
229
+ repoSeen = true;
230
+ const value = rest[++i];
231
+ if (!value || value.startsWith('--'))
232
+ return fail('--repo requires a directory');
233
+ repo = value;
234
+ continue;
235
+ }
236
+ if (token.startsWith('--'))
237
+ return fail(`unknown option: ${token}`);
238
+ return fail(`unexpected argument: ${token}`);
239
+ }
240
+ if (!taskSeen || task === null) {
241
+ return fail('--task <tasks.md> is required');
242
+ }
243
+ if (functions.length === 0 && !noSymbols) {
244
+ return fail('--symbol <function-name> is required');
245
+ }
246
+ return { mode: 'bundle', task, functions, allowEmpty: noSymbols, repo };
247
+ }
248
+ /**
249
+ * intent sections 인자. --task와 --role만 받고 query·bundle 옵션이 섞이면
250
+ * 거절한다. 유효 argv 전에는 intent-bundle을 적재하지 않는다.
251
+ * --task가 비어 있지 않으면 파서가 받고, canonical 판정은 `loadExecutionTask`가
252
+ * 하고 exit 1 `intent-task-invalid`로 낸다.
253
+ *
254
+ * @param {string[]} rest - `intent sections` 뒤 argv
255
+ * @returns {SectionsArgs} 성공 시 task·role·optional repo, 실패 시 error
256
+ */
257
+ function parseSectionsArgs(rest) {
258
+ let task = null;
259
+ let taskSeen = false;
260
+ let role = null;
261
+ let roleSeen = false;
262
+ let repo;
263
+ let repoSeen = false;
264
+ const fail = (message) => ({
265
+ mode: 'sections',
266
+ error: `intent: ${message}\n`,
267
+ task,
268
+ role,
269
+ repo,
270
+ });
271
+ for (let i = 0; i < rest.length; i += 1) {
272
+ const token = rest[i];
273
+ if (token === '--task') {
274
+ if (taskSeen)
275
+ return fail('duplicate option: --task');
276
+ taskSeen = true;
277
+ const value = rest[++i];
278
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
279
+ return fail('--task requires a non-empty tasks.md path');
280
+ }
281
+ // 비어 있지 않은 값만 확인한다. 절대경로·`..`·비정규 layout은
282
+ // loadExecutionTask가 던지고 cmdIntentSections가 exit 1 JSON으로 낸다.
283
+ task = value.trim();
284
+ continue;
285
+ }
286
+ if (token === '--role') {
287
+ if (roleSeen)
288
+ return fail('duplicate option: --role');
289
+ roleSeen = true;
290
+ const value = rest[++i];
291
+ if (value === undefined || value.startsWith('--') || value.trim().length === 0) {
292
+ return fail('--role requires implementer, reviewer, or debugger');
293
+ }
294
+ if (!INTENT_ROLES.has(value)) {
295
+ return fail('--role must be implementer, reviewer, or debugger');
296
+ }
297
+ role = value;
298
+ continue;
299
+ }
300
+ if (token === '--repo') {
301
+ if (repoSeen)
302
+ return fail('duplicate option: --repo');
303
+ repoSeen = true;
304
+ const value = rest[++i];
305
+ if (!value || value.startsWith('--'))
306
+ return fail('--repo requires a directory');
307
+ repo = value;
308
+ continue;
309
+ }
310
+ if (token.startsWith('--'))
311
+ return fail(`unknown option: ${token}`);
312
+ return fail(`unexpected argument: ${token}`);
313
+ }
314
+ if (!taskSeen || task === null) {
315
+ return fail('--task <tasks.md> is required');
316
+ }
317
+ if (!roleSeen || role === null) {
318
+ return fail('--role <implementer|reviewer|debugger> is required');
319
+ }
320
+ return { mode: 'sections', task, role, repo };
321
+ }
322
+ /**
323
+ * 함수 의도 provenance를 조회하거나 task intent bundle·역할 절 projection을 만든다.
324
+ * query의 resolved가 아닌 상태 JSON도 exit 0이다 — Plan이 코드 탐색을
325
+ * 이어가려면 ambiguous/unresolved/unlinked가 사용법 오류가 아니어야 한다.
326
+ * bundle도 ambiguous면 record를 쓰지 않고 opaque candidate만 돌려 exit 0이다.
327
+ *
328
+ * @param {string[]} rest - `intent` 뒤 argv
329
+ * @param {CliIo} io - stdout은 JSON 하나만, 진단은 stderr
330
+ * @returns {number} 상태 JSON 0, Git/filesystem 조회 실패 1, 사용법 2
331
+ */
332
+ function cmdIntent(rest, io) {
333
+ const parsed = parseIntentArgs(rest);
334
+ if (parsed.error) {
335
+ io.err(parsed.error);
336
+ return 2;
337
+ }
338
+ if (parsed.mode === 'bundle') {
339
+ return cmdIntentBundle(parsed, io);
340
+ }
341
+ if (parsed.mode === 'sections') {
342
+ return cmdIntentSections(parsed, io);
343
+ }
344
+ return cmdIntentQuery(parsed, io);
345
+ }
346
+ /**
347
+ * 단일 함수 intent 조회. argv 검증 뒤에만 provenance를 적재한다.
348
+ *
349
+ * @param {QueryArgs} parsed - 검증된 query 인자
350
+ * @param {CliIo} io - stdout/stderr 싱크
351
+ * @returns {number} 상태 JSON 0, 조회 실패 1
352
+ */
353
+ function cmdIntentQuery(parsed, io) {
354
+ // argv 검증을 통과한 뒤에만 resolver를 적재한다. 빈·중복 option 거절이
355
+ // intent-provenance·symbol-index를 require.cache에 남기지 않게 하기 위함.
356
+ // export= 모듈이라 import=require 정적 경계를 쓰지 않고 실행 시점 require만 한다.
357
+ const intentProvenance = require('./intent-provenance');
358
+ const { resolveIntentProvenance } = intentProvenance;
359
+ const repoRoot = (parsed.repo || process.cwd());
360
+ try {
361
+ // JSON은 resolver가 돌아온 뒤에만 쓴다. throw 경로에 부분 payload가 남지 않게.
362
+ const result = resolveIntentProvenance({
363
+ repoRoot,
364
+ symbol: parsed.symbol,
365
+ candidateRef: parsed.candidate,
366
+ limit: parsed.limit === null ? undefined : parsed.limit,
367
+ });
368
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
369
+ return 0;
370
+ }
371
+ catch (error) {
372
+ // Git 부재·명령 실패, repo 실경로 조회 실패, 현재 후보에 없는 candidate ref는
373
+ // resolver가 Error로 던진다. 입력 shape는 파서가 이미 exit 2로 거절했으므로
374
+ // 메시지 있는 조회 실패만 stderr+1로 흡수한다. 메시지 없는 예외는 핸들러
375
+ // 버그이므로 다시 던져 숨기지 않는다.
376
+ const message = catchMessage(error);
377
+ if (typeof message !== 'string' || message.length === 0)
378
+ throw error;
379
+ io.err(`intent: ${message}\n`);
380
+ return 1;
381
+ }
382
+ }
383
+ /**
384
+ * task intent bundle 생성·재사용. 유효 argv 뒤에만 intent-bundle을 적재한다.
385
+ * ambiguous는 core가 throw하므로 candidate 목록만 돌려 record를 남기지 않는다.
386
+ *
387
+ * @param {BundleArgs} parsed - 검증된 bundle 인자
388
+ * @param {CliIo} io - stdout/stderr 싱크
389
+ * @returns {number} created/reused/ambiguous 0, 조회 실패 1
390
+ */
391
+ function cmdIntentBundle(parsed, io) {
392
+ const intentBundle = require('./intent-bundle');
393
+ const { resolveTaskIntentBundle } = intentBundle;
394
+ const repoRoot = (parsed.repo || process.cwd());
395
+ try {
396
+ const result = resolveTaskIntentBundle({
397
+ repoRoot,
398
+ taskFile: parsed.task,
399
+ functions: parsed.functions,
400
+ allowEmpty: parsed.allowEmpty === true,
401
+ });
402
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
403
+ return 0;
404
+ }
405
+ catch (error) {
406
+ const message = catchMessage(error);
407
+ if (typeof message !== 'string' || message.length === 0)
408
+ throw error;
409
+ // core는 incomplete selection을 Error로 올린다. CLI는 같은 opaque
410
+ // candidate_ref를 돌려 호출자가 --candidate로 재선택하게 한다.
411
+ const ambiguousMatch = /^ambiguous symbol requires candidate ref: (.+)$/.exec(message);
412
+ if (ambiguousMatch) {
413
+ const intentProvenance = require('./intent-provenance');
414
+ try {
415
+ const selection = intentProvenance.resolveIntentProvenance({
416
+ repoRoot,
417
+ symbol: ambiguousMatch[1],
418
+ });
419
+ // provenance의 ambiguous JSON만 쓴다. bundle record·부분 stdout은 없다.
420
+ io.out(`${JSON.stringify(selection, null, 2)}\n`);
421
+ return 0;
422
+ }
423
+ catch (selectionError) {
424
+ // ambiguous recovery용 2차 provenance 조회가 실패해도 바깥 catch로
425
+ // 다시 던지지 않는다. 부분 stdout 없이 다른 조회 실패와 같은 stderr+1.
426
+ const selectionMessage = catchMessage(selectionError);
427
+ if (typeof selectionMessage !== 'string' || selectionMessage.length === 0) {
428
+ throw selectionError;
429
+ }
430
+ io.err(`intent: ${selectionMessage}\n`);
431
+ return 1;
432
+ }
433
+ }
434
+ io.err(`intent: ${message}\n`);
435
+ return 1;
436
+ }
437
+ }
438
+ /**
439
+ * 역할별 intent 절 본문 projection. 유효 argv 뒤에만 intent-bundle을 적재한다.
440
+ * 계약된 reason은 stdout JSON이고, 그 외 조회 실패는 bundle과 같이 stderr+1이다.
441
+ *
442
+ * @param {SectionsArgs} parsed - 검증된 sections 인자
443
+ * @param {CliIo} io - stdout/stderr 싱크
444
+ * @returns {number} 성공 JSON 0, 계약된 실패 JSON 1
445
+ */
446
+ function cmdIntentSections(parsed, io) {
447
+ const intentBundle = require('./intent-bundle');
448
+ const { projectRoleIntentSections } = intentBundle;
449
+ const repoRoot = (parsed.repo || process.cwd());
450
+ try {
451
+ const result = projectRoleIntentSections({
452
+ repoRoot,
453
+ taskFile: parsed.task,
454
+ role: parsed.role,
455
+ });
456
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
457
+ return 0;
458
+ }
459
+ catch (error) {
460
+ const message = catchMessage(error);
461
+ if (typeof message !== 'string' || message.length === 0)
462
+ throw error;
463
+ const reason = error.reason;
464
+ const next = error.next;
465
+ if (typeof reason === 'string' && SECTIONS_FAIL_REASONS.has(reason)) {
466
+ // core의 intent-task-invalid next는 같은 비정규 경로로 bundle을 다시
467
+ // 만들라고 한다. 따르면 parseBundleArgs가 exit 2로 끝나므로, 이
468
+ // reason만 canonical tasks.md 형식의 sections 재실행으로 바꾼다.
469
+ // max-len: 한 줄 템플릿은 145자라 eslint가 거절한다. 값을 바꾸지 않고
470
+ // 이어 붙여 줄만 자른다 — sections exit 1 next 계약은 그대로다.
471
+ const nextHint = reason === 'intent-task-invalid'
472
+ ? 'bouncer intent sections --task .bouncer/context/epics/<ddd>-<slug>/'
473
+ + `blueprints/<ddd>-<slug>/tasks/<ddd>/tasks.md --role ${parsed.role}`
474
+ : typeof next === 'string'
475
+ ? next
476
+ : `bouncer intent bundle --task ${parsed.task} --symbol <name>...`;
477
+ io.out(`${JSON.stringify({
478
+ ok: false,
479
+ reason,
480
+ cause: message,
481
+ next: nextHint,
482
+ }, null, 2)}\n`);
483
+ return 1;
484
+ }
485
+ io.err(`intent: ${message}\n`);
486
+ return 1;
487
+ }
488
+ }
489
+ module.exports = {
490
+ run: cmdIntent,
491
+ };
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ const cliUsage = require("./cli-usage");
3
+ const cliFlags = require("./cli-flags");
4
+ const { parseFlags } = cliFlags;
5
+ const driveMetrics = require("./drive-metrics");
6
+ const runtimeState = require("./runtime-state");
7
+ const { runtimePaths, coordinatorPathsFor } = runtimeState;
8
+ const configMod = require("./config");
9
+ const { readMetricsPricing } = configMod;
10
+ const KINDS = 'coordinator-session|context-review|root-direct-session';
11
+ const USAGE = `usage: bouncer metrics start --kind <${KINDS}> --blueprint <dir> `
12
+ + '[--label <text>] [--target-digest <64hex>]\n'
13
+ + ' bouncer metrics end --id <m-id> [--tokens <n>] [--input-tokens <n>] [--output-tokens <n>] '
14
+ + '[--cache-read-tokens <n>] [--cache-write-tokens <n>] [--model <slug>] [--host-duration-ms <n>]\n'
15
+ + ' bouncer metrics summary --blueprint <dir>\n'
16
+ + ' bouncer metrics snapshot --blueprint <dir>\n';
17
+ // 모든 하위 명령이 받는 --repo 외 플래그 목록. 모르는 플래그는 오타라 usage(2)로 거절한다.
18
+ const FLAGS = {
19
+ start: ['kind', 'blueprint', 'label', 'target-digest', 'repo'],
20
+ end: [
21
+ 'id', 'tokens', 'input-tokens', 'output-tokens', 'cache-read-tokens', 'cache-write-tokens',
22
+ 'model', 'host-duration-ms', 'repo',
23
+ ],
24
+ summary: ['blueprint', 'repo'],
25
+ snapshot: ['blueprint', 'repo'],
26
+ };
27
+ function emit(io, result) {
28
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
29
+ return result.ok ? 0 : 1;
30
+ }
31
+ // 0 이상의 정수 문자열만 받는다. 실패는 undefined가 아니라 NaN으로 구분해 "없음"과 섞이지 않게 한다.
32
+ function parseCount(raw) {
33
+ if (raw === undefined)
34
+ return undefined;
35
+ return typeof raw === 'string' && /^\d+$/.test(raw) && Number.isSafeInteger(Number(raw))
36
+ ? Number(raw) : NaN;
37
+ }
38
+ /**
39
+ * `bouncer metrics` 핸들러. 기록은 main worktree의 drive-metrics.json에만 쓰고 원장은 읽기만 한다.
40
+ * 예외로 `snapshot`은 Git common dir의 worker 집계 snapshot 파일 하나만 쓴다.
41
+ * 잘못된 사용(모르는 하위 명령·플래그)은 stderr usage와 2, 입력 값·기록 거절은 stdout JSON과 1이다.
42
+ *
43
+ * @param {string[]} rest - `metrics` 뒤 원시 argv
44
+ * @param {CliIo} io - stdout/stderr 싱크
45
+ * @returns {number} 성공 0, 거절 1, 잘못된 사용 2
46
+ */
47
+ function cmdMetrics(rest, io) {
48
+ const sub = rest[0];
49
+ if (sub === undefined || !Object.hasOwn(FLAGS, sub)) {
50
+ io.err(USAGE);
51
+ return 2;
52
+ }
53
+ const flags = parseFlags(rest.slice(1));
54
+ if (Object.keys(flags).some((k) => !FLAGS[sub].includes(k))) {
55
+ io.err(USAGE);
56
+ return 2;
57
+ }
58
+ // 값이 필요한 플래그가 값 없이 오면 parseFlags는 true를 돌려준다.
59
+ const str = (k) => (typeof flags[k] === 'string' ? flags[k] : undefined);
60
+ const hasBare = Object.values(flags).some((v) => v === true);
61
+ const invalid = { ok: false, reason: 'invalid-metrics-input' };
62
+ if (hasBare)
63
+ return emit(io, invalid);
64
+ const repoRoot = str('repo') ?? process.cwd();
65
+ const main = runtimePaths({ repoRoot });
66
+ if (main.unavailable || !main.projectRoot)
67
+ return emit(io, { ok: false, reason: 'non-git-root' });
68
+ const file = driveMetrics.metricsFilePath(main.projectRoot);
69
+ // 원장 경로는 blueprint에서만 나온다. end는 기록된 세션의 blueprint를 써야 하므로 아래에서 구한다.
70
+ const ledgerFor = (blueprint) => {
71
+ try {
72
+ return coordinatorPathsFor({ repoRoot, blueprint }).ledgerFile;
73
+ }
74
+ catch (_error) {
75
+ // blueprint 경로에서 epic/blueprint id를 못 뽑는 입력 오류만 여기로 온다(git 부재는 위에서 걸렀다).
76
+ return null;
77
+ }
78
+ };
79
+ if (sub === 'summary') {
80
+ if (str('blueprint') === undefined)
81
+ return emit(io, invalid);
82
+ // `report`는 사용자에게 토큰·시간 표를 보일지 정하는 신호다. 기록은 항상 남기고, 표시만
83
+ // 벤치마크 셸이 BOUNCER_METRICS_REPORT=1로 켠다. skill 문서는 같고 이 출력만 달라진다.
84
+ const summary = driveMetrics.summarize({
85
+ file, blueprint: str('blueprint'), resolveLedger: ledgerFor,
86
+ commonGitDir: main.commonGitDir ?? null,
87
+ });
88
+ return emit(io, summary.ok ? { ...summary, report: process.env.BOUNCER_METRICS_REPORT === '1' } : summary);
89
+ }
90
+ if (sub === 'snapshot') {
91
+ // 정리 전에 worker 집계를 Git common dir에 보존한다. 저장 위치는 프로젝트 경로가 아니라
92
+ // runtimePaths가 계산한 common dir이다(worktree 제거로 사라지지 않는 자리).
93
+ const blueprint = str('blueprint');
94
+ if (blueprint === undefined || main.commonGitDir === undefined)
95
+ return emit(io, invalid);
96
+ const ledgerFile = ledgerFor(blueprint);
97
+ if (ledgerFile === null)
98
+ return emit(io, invalid);
99
+ return emit(io, driveMetrics.saveWorkerSnapshot({
100
+ commonGitDir: main.commonGitDir, ledgerFile, blueprint,
101
+ }));
102
+ }
103
+ if (sub === 'start') {
104
+ const blueprint = str('blueprint');
105
+ if (blueprint === undefined)
106
+ return emit(io, invalid);
107
+ const ledgerFile = ledgerFor(blueprint);
108
+ if (ledgerFile === null)
109
+ return emit(io, invalid);
110
+ return emit(io, driveMetrics.startSession({
111
+ file, ledgerFile, kind: str('kind'), blueprint,
112
+ label: str('label'), targetDigest: str('target-digest'),
113
+ }));
114
+ }
115
+ // end
116
+ const id = str('id');
117
+ const counts = {
118
+ tokens: parseCount(flags.tokens),
119
+ inputTokens: parseCount(flags['input-tokens']),
120
+ outputTokens: parseCount(flags['output-tokens']),
121
+ cacheReadTokens: parseCount(flags['cache-read-tokens']),
122
+ cacheWriteTokens: parseCount(flags['cache-write-tokens']),
123
+ hostDurationMs: parseCount(flags['host-duration-ms']),
124
+ };
125
+ if (id === undefined || Object.values(counts).some((v) => v !== undefined && Number.isNaN(v))) {
126
+ return emit(io, invalid);
127
+ }
128
+ const priced = readMetricsPricing(main.projectRoot);
129
+ return emit(io, driveMetrics.endSession({
130
+ file, resolveLedger: ledgerFor, id, ...counts, model: str('model'),
131
+ pricing: priced.ok ? priced.pricing : null,
132
+ }));
133
+ }
134
+ module.exports = { run: cmdMetrics, usage: cliUsage.COMMAND_USAGE['metrics'] };