@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,691 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { createHash, randomBytes } = require('node:crypto');
5
+ const guards = require("./guards");
6
+ const { isRecord, isEnoentError } = guards;
7
+ /**
8
+ * drive 계측 기록(`.bouncer/runtime/drive-metrics.json`)의 읽기·쓰기·요약.
9
+ *
10
+ * 원칙: 호스트가 준 값만 기록한다. 토큰·시간·비용을 추정해 채우지 않고, 얻지 못한 값은
11
+ * `null`과 사유 문자열로 남겨 합계가 누락을 0으로 세지 않게 한다. 원장은 읽기만 하며
12
+ * 기록 파일 쓰기는 임시 파일 뒤 rename이다.
13
+ */
14
+ const SESSION_KINDS = ['coordinator-session', 'context-review', 'root-direct-session'];
15
+ // 이 kind의 decision, 또는 outcome이 accepted가 아닌 report는 "예외 판단"이다.
16
+ const EXCEPTION_KINDS = new Set([
17
+ 'stale-report', 'stale-lease', 'repair', 'supplement', 'rerecord',
18
+ 'critical-recovery', 'fanin-verification-failed', 'revoke',
19
+ ]);
20
+ /**
21
+ * 기록 파일의 절대 경로. runtime 디렉터리는 gitignore라 기록이 커밋에 섞이지 않는다.
22
+ *
23
+ * @param {string} mainRoot - main worktree 루트(`runtimePaths().projectRoot`)
24
+ * @returns {string} drive-metrics.json 절대 경로
25
+ */
26
+ function metricsFilePath(mainRoot) {
27
+ return path.join(mainRoot, '.bouncer', 'runtime', 'drive-metrics.json');
28
+ }
29
+ // blueprint 비교용 정규화. 호출자마다 끝 슬래시·역슬래시가 달라도 같은 blueprint로 묶는다.
30
+ function normalizeBlueprint(value) {
31
+ return value.replace(/\\/g, '/').replace(/\/+$/, '');
32
+ }
33
+ /**
34
+ * 기록 파일을 읽는다. 파일 없음(ENOENT)만 빈 기록이고, 깨진 JSON·모양 불일치는
35
+ * `metrics-file-invalid`다 — 거절해야 이후 쓰기가 깨진 파일을 덮어쓰지 않는다.
36
+ *
37
+ * @param {string} file - drive-metrics.json 경로
38
+ * @returns {{ok: true, data: MetricsFile} | Failure} 읽은 기록 또는 거절
39
+ */
40
+ function readMetrics(file) {
41
+ let raw;
42
+ try {
43
+ raw = fs.readFileSync(file, 'utf8');
44
+ }
45
+ catch (error) {
46
+ // ENOENT만 "아직 기록 없음". 권한 오류 등은 삼키지 않고 그대로 던진다.
47
+ if (isEnoentError(error))
48
+ return { ok: true, data: { version: 1, sessions: [] } };
49
+ throw error;
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ }
55
+ catch (_error) {
56
+ // SyntaxError만 여기로 온다(입력이 문자열). 깨진 파일은 invalid로 접는다.
57
+ return { ok: false, reason: 'metrics-file-invalid' };
58
+ }
59
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.sessions)
60
+ || !parsed.sessions.every((s) => isRecord(s) && typeof s.id === 'string'
61
+ && typeof s.kind === 'string' && typeof s.blueprint === 'string')) {
62
+ return { ok: false, reason: 'metrics-file-invalid' };
63
+ }
64
+ return { ok: true, data: parsed };
65
+ }
66
+ // 임시 파일 뒤 rename: 쓰는 도중 중단돼도 기존 기록이 반쯤 쓰인 채 남지 않는다.
67
+ function writeMetrics(file, data) {
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ const temporary = `${file}.${process.pid}.tmp`;
70
+ fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`);
71
+ fs.renameSync(temporary, file);
72
+ }
73
+ /**
74
+ * 원장의 진행 snapshot을 읽는다. 원장은 읽기만 한다. 원장이 아직 없으면 snapshot은 null이고,
75
+ * 깨진 원장은 진행 차이를 추정하지 않고 `metrics-ledger-invalid`로 거절한다.
76
+ *
77
+ * @param {string} ledgerFile - integration worktree의 coordinator.json 경로
78
+ * @param {SnapshotFs} [fsx] - 읽기 seam. 기본은 node:fs
79
+ * @returns {LedgerRead} snapshot과 decision 배열, 또는 거절
80
+ */
81
+ function readLedgerSnapshot(ledgerFile, fsx = fs) {
82
+ let bytes;
83
+ try {
84
+ bytes = fsx.readFileSync(ledgerFile);
85
+ }
86
+ catch (error) {
87
+ if (isEnoentError(error))
88
+ return { ok: true, snapshot: null, decisions: [] };
89
+ throw error;
90
+ }
91
+ let ledger;
92
+ try {
93
+ ledger = JSON.parse(bytes.toString('utf8'));
94
+ }
95
+ catch (_error) {
96
+ return { ok: false, reason: 'metrics-ledger-invalid' };
97
+ }
98
+ if (!isRecord(ledger))
99
+ return { ok: false, reason: 'metrics-ledger-invalid' };
100
+ const decisions = Array.isArray(ledger.decisions) ? ledger.decisions : [];
101
+ const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
102
+ const completed = tasks
103
+ .filter((t) => isRecord(t) && t.status === 'integrated' && typeof t.id === 'string')
104
+ .map((t) => t.id);
105
+ return {
106
+ ok: true,
107
+ snapshot: {
108
+ decisions: decisions.length,
109
+ completed_tasks: completed,
110
+ ledger_sha256: createHash('sha256').update(bytes).digest('hex'),
111
+ },
112
+ decisions,
113
+ };
114
+ }
115
+ /**
116
+ * 원장 항목이 "예외 판단"인지 가른다. 세션 경계 판정(`coordinate next`)과 metrics의
117
+ * 예외 수 계산이 같은 정의를 쓰도록 export한다.
118
+ *
119
+ * @param {unknown} entry - 원장 decision 항목
120
+ * @returns {boolean} 예외 kind이거나 accepted가 아닌 report면 true
121
+ */
122
+ function isException(entry) {
123
+ if (!isRecord(entry))
124
+ return false;
125
+ if (typeof entry.kind === 'string' && EXCEPTION_KINDS.has(entry.kind))
126
+ return true;
127
+ return entry.kind === 'report' && entry.outcome !== 'accepted';
128
+ }
129
+ /**
130
+ * 세션 시작을 기록한다. coordinator-session은 그 시점의 원장 snapshot을, context-review는
131
+ * `target_digest`를 남기고 원장 진행 필드를 만들지 않는다.
132
+ *
133
+ * @param {object} opts - 시작 입력
134
+ * @param {string} opts.file - drive-metrics.json 경로
135
+ * @param {string} opts.ledgerFile - 해당 blueprint의 integration 원장 경로
136
+ * @param {string} opts.kind - `coordinator-session` | `context-review` | `root-direct-session`
137
+ * @param {string} opts.blueprint - blueprint 디렉터리
138
+ * @param {string} [opts.label] - 세션 라벨(context-review는 perspective)
139
+ * @param {string} [opts.targetDigest] - context-review 필수 64 hex
140
+ * @param {Clock} [opts.now] - 테스트 seam. 기본 현재 시각
141
+ * @param {() => string} [opts.randomId] - 테스트 seam. 16 hex를 돌려준다
142
+ * @returns {{ok: true, id: string} | Failure} 새 세션 id 또는 거절 사유
143
+ */
144
+ function startSession(opts) {
145
+ const now = opts.now ?? (() => new Date());
146
+ const randomId = opts.randomId ?? (() => randomBytes(8).toString('hex'));
147
+ const invalid = { ok: false, reason: 'invalid-metrics-input' };
148
+ if (!SESSION_KINDS.includes(opts.kind)
149
+ || typeof opts.blueprint !== 'string' || opts.blueprint === '')
150
+ return invalid;
151
+ const kind = opts.kind;
152
+ const isReview = kind === 'context-review';
153
+ // target-digest는 context-review의 호출 식별자다. 다른 kind에 주면 오타 가능성이 커서 거절한다.
154
+ if (isReview !== (opts.targetDigest !== undefined))
155
+ return invalid;
156
+ if (isReview && !/^[a-f0-9]{64}$/.test(opts.targetDigest))
157
+ return invalid;
158
+ // 깨진 기록 파일은 어떤 쓰기보다 먼저 거절한다.
159
+ const current = readMetrics(opts.file);
160
+ if (current.ok === false)
161
+ return current;
162
+ const session = {
163
+ id: `m-${randomId()}`,
164
+ kind,
165
+ blueprint: normalizeBlueprint(opts.blueprint),
166
+ label: opts.label ?? null,
167
+ started_at: now().toISOString(),
168
+ ended_at: null,
169
+ };
170
+ if (isReview) {
171
+ session.target_digest = opts.targetDigest;
172
+ }
173
+ else {
174
+ const ledger = readLedgerSnapshot(opts.ledgerFile);
175
+ if (ledger.ok === false)
176
+ return ledger;
177
+ session.start = ledger.snapshot;
178
+ }
179
+ current.data.sessions.push(session);
180
+ writeMetrics(opts.file, current.data);
181
+ return { ok: true, id: session.id };
182
+ }
183
+ // 비용은 호스트가 준 모델·입출력 토큰과 설정 단가가 모두 있을 때만 계산한다.
184
+ function computeCost(model, input, output, pricing) {
185
+ if (model === null)
186
+ return { cost: null, reason: 'no-model' };
187
+ if (input === null || output === null)
188
+ return { cost: null, reason: 'no-token-split' };
189
+ if (pricing === null)
190
+ return { cost: null, reason: 'invalid-pricing' };
191
+ if (!Object.hasOwn(pricing, model))
192
+ return { cost: null, reason: 'no-pricing' };
193
+ const price = pricing[model];
194
+ return { cost: (input / 1e6) * price.input_per_mtok + (output / 1e6) * price.output_per_mtok };
195
+ }
196
+ /**
197
+ * 세션 종료를 기록한다. 호스트가 준 토큰·시간만 쓰고, 없으면 `null`과 사유를 남긴다.
198
+ *
199
+ * @param {object} opts - 종료 입력
200
+ * @param {string} opts.file - drive-metrics.json 경로
201
+ * @param {(blueprint: string) => string | null} opts.resolveLedger - 세션 blueprint의 integration 원장 경로.
202
+ * null이면 기록된 blueprint를 경로로 풀 수 없다는 뜻이라 기록 파일 손상으로 거절한다
203
+ * @param {string} opts.id - start가 돌려준 세션 id
204
+ * @param {number} [opts.tokens] - 호스트가 준 총 토큰
205
+ * @param {number} [opts.inputTokens] - 호스트가 준 입력 토큰
206
+ * @param {number} [opts.outputTokens] - 호스트가 준 출력 토큰
207
+ * @param {number} [opts.cacheReadTokens] - 호스트가 준 cache read 토큰(독립 필드, 총합·비용에 안 더함)
208
+ * @param {number} [opts.cacheWriteTokens] - 호스트가 준 cache write 토큰(독립 필드, 총합·비용에 안 더함)
209
+ * @param {string} [opts.model] - 모델 slug
210
+ * @param {number} [opts.hostDurationMs] - 호스트가 준 소요 시간
211
+ * @param {PricingTable | null} opts.pricing - 단가 표. 깨진 설정이면 null
212
+ * @param {Clock} [opts.now] - 테스트 seam
213
+ * @returns {{ok: true} | Failure} 성공 또는 거절 사유
214
+ */
215
+ function endSession(opts) {
216
+ const now = opts.now ?? (() => new Date());
217
+ const current = readMetrics(opts.file);
218
+ if (current.ok === false)
219
+ return current;
220
+ const session = current.data.sessions.find((s) => s.id === opts.id);
221
+ if (!session)
222
+ return { ok: false, reason: 'metrics-session-not-found' };
223
+ if (session.ended_at !== null)
224
+ return { ok: false, reason: 'metrics-session-ended' };
225
+ const endedAt = now();
226
+ session.ended_at = endedAt.toISOString();
227
+ session.wall_ms = Math.max(0, endedAt.getTime() - Date.parse(session.started_at));
228
+ session.host_duration_ms = opts.hostDurationMs ?? null;
229
+ session.model = opts.model ?? null;
230
+ session.input_tokens = opts.inputTokens ?? null;
231
+ session.output_tokens = opts.outputTokens ?? null;
232
+ // 총합은 호스트가 준 --tokens를 우선하고, 없을 때만 입출력이 둘 다 있으면 그 합이다(추정 아님).
233
+ session.tokens = opts.tokens
234
+ ?? (opts.inputTokens !== undefined && opts.outputTokens !== undefined
235
+ ? opts.inputTokens + opts.outputTokens : null);
236
+ if (session.tokens === null)
237
+ session.tokens_unavailable_reason = 'not-reported';
238
+ // cache는 호스트가 준 값만 쓴다. 둘 다 없을 때만 사유를 남기고, 하나라도 있으면 사유 키를 쓰지 않는다.
239
+ session.cache_read_tokens = opts.cacheReadTokens ?? null;
240
+ session.cache_write_tokens = opts.cacheWriteTokens ?? null;
241
+ if (session.cache_read_tokens === null && session.cache_write_tokens === null) {
242
+ session.cache_unavailable_reason = 'not-reported';
243
+ }
244
+ const cost = computeCost(session.model, session.input_tokens, session.output_tokens, opts.pricing);
245
+ session.cost = cost.cost;
246
+ if (cost.reason !== undefined)
247
+ session.cost_unavailable_reason = cost.reason;
248
+ if (session.kind === 'coordinator-session') {
249
+ const ledgerFile = opts.resolveLedger(session.blueprint);
250
+ if (ledgerFile === null)
251
+ return { ok: false, reason: 'metrics-file-invalid' };
252
+ const ledger = readLedgerSnapshot(ledgerFile);
253
+ if (ledger.ok === false)
254
+ return ledger;
255
+ session.end = ledger.snapshot;
256
+ if (ledger.snapshot === null) {
257
+ session.tasks_completed = null;
258
+ session.exception_decisions = null;
259
+ }
260
+ else {
261
+ const before = new Set(session.start?.completed_tasks ?? []);
262
+ session.tasks_completed = ledger.snapshot.completed_tasks.filter((id) => !before.has(id));
263
+ session.exception_decisions = ledger.decisions
264
+ .slice(session.start?.decisions ?? 0).filter(isException).length;
265
+ }
266
+ }
267
+ writeMetrics(opts.file, current.data);
268
+ return { ok: true, id: session.id };
269
+ }
270
+ /**
271
+ * 관측값 하나를 kind 합계에 반영한다. 숫자(명시적 0 포함)만 더하고, `null`·필드 부재(legacy 기록)는
272
+ * 합에 넣지 않고 missing으로 센다. 누락을 0으로 접으면 측정 못 한 세션이 값 0처럼 읽히기 때문이다.
273
+ * 첫 관측값이 들어올 때 total이 null에서 숫자로 바뀐다.
274
+ *
275
+ * @param {KindSummary} sum - 갱신할 kind 합계
276
+ * @param {TotalKey} totalKey - 합계 필드
277
+ * @param {MissingKey} missingKey - 누락 수 필드
278
+ * @param {unknown} value - 세션에 기록된 값
279
+ * @returns {void}
280
+ */
281
+ function observe(sum, totalKey, missingKey, value) {
282
+ if (typeof value === 'number')
283
+ sum[totalKey] = (sum[totalKey] ?? 0) + value;
284
+ else
285
+ sum[missingKey] += 1;
286
+ }
287
+ /**
288
+ * blueprint의 kind별 합계. 끝난 세션만 토큰·시간·비용에 합산하고, `null`은 합에서 빼
289
+ * 누락 수로 따로 센다. 0으로 세면 측정하지 못한 세션이 값이 0인 세션처럼 읽힌다.
290
+ *
291
+ * @param {{file: string, blueprint: string, resolveLedger?: Function}} opts - 기록 파일, blueprint,
292
+ * 원장 경로 해석기 `(blueprint) => string | null`(주입)
293
+ * 토큰(total·input·output)·host 소요 시간·wall 시간은 관측값이 하나도 없으면 total이 `null`이고,
294
+ * 일부만 있으면 알려진 합과 `*_missing`을 함께 낸다. cost_total은 기존 입력 호환으로 0 시작을 유지한다.
295
+ * worker_tokens는 원장의 `worker-model`·`worker-tokens` decision에서 따로 합산한다. coordinator
296
+ * 총량에 이미 포함됐는지 알 수 없으므로 `kinds`에 더하지 않는다. 원장이 없거나 resolveLedger가
297
+ * 없으면 0이고, 깨진 원장은 `metrics-ledger-invalid`로 거절한다. 원장 파일이 없으면 정리 전에 저장한
298
+ * snapshot(`commonGitDir` 아래)으로 복원하고, 그것도 없을 때만 0이다. 출처는 `worker_tokens_source`
299
+ * (`ledger`|`snapshot`|`unavailable`)로 구별해 복원값과 실제 0이 섞이지 않게 한다.
300
+ * cache read·write는 kind와 무관하게 blueprint 전체 합을 최상위 `cache`에 낸다. 숫자만 합산하고
301
+ * `null`·필드 부재(필드가 생기기 전 기록)는 `missing`으로 센다. `0`은 값이다.
302
+ *
303
+ * @returns {{ok: true, blueprint: string, kinds: Record<string, KindSummary>, cache: CacheSummary} | Failure} 합계 또는 거절
304
+ */
305
+ function summarize(opts) {
306
+ const current = readMetrics(opts.file);
307
+ if (current.ok === false)
308
+ return current;
309
+ const blueprint = normalizeBlueprint(opts.blueprint);
310
+ const kinds = {};
311
+ for (const kind of SESSION_KINDS) {
312
+ kinds[kind] = {
313
+ sessions: 0, open: 0, tokens_total: null, tokens_missing: 0,
314
+ input_tokens_total: null, input_tokens_missing: 0,
315
+ output_tokens_total: null, output_tokens_missing: 0,
316
+ host_duration_ms_total: null, host_duration_ms_missing: 0,
317
+ wall_ms_total: null, wall_ms_missing: 0, cost_total: 0, cost_missing: 0,
318
+ };
319
+ if (kind === 'coordinator-session')
320
+ kinds[kind].tasks_completed = 0;
321
+ }
322
+ const cache = { read: { total: 0, missing: 0 }, write: { total: 0, missing: 0 } };
323
+ for (const s of current.data.sessions) {
324
+ if (s.blueprint !== blueprint || !Object.hasOwn(kinds, s.kind))
325
+ continue;
326
+ const sum = kinds[s.kind];
327
+ sum.sessions += 1;
328
+ if (s.ended_at === null) {
329
+ sum.open += 1;
330
+ continue;
331
+ }
332
+ observe(sum, 'tokens_total', 'tokens_missing', s.tokens);
333
+ observe(sum, 'input_tokens_total', 'input_tokens_missing', s.input_tokens);
334
+ observe(sum, 'output_tokens_total', 'output_tokens_missing', s.output_tokens);
335
+ // 호스트가 준 소요 시간과 직접 잰 wall time은 출처가 달라 서로 대신 채우지 않는다.
336
+ observe(sum, 'host_duration_ms_total', 'host_duration_ms_missing', s.host_duration_ms);
337
+ observe(sum, 'wall_ms_total', 'wall_ms_missing', s.wall_ms);
338
+ if (typeof s.cache_read_tokens === 'number')
339
+ cache.read.total += s.cache_read_tokens;
340
+ else
341
+ cache.read.missing += 1;
342
+ if (typeof s.cache_write_tokens === 'number')
343
+ cache.write.total += s.cache_write_tokens;
344
+ else
345
+ cache.write.missing += 1;
346
+ if (typeof s.cost === 'number')
347
+ sum.cost_total += s.cost;
348
+ else
349
+ sum.cost_missing += 1;
350
+ if (sum.tasks_completed !== undefined && Array.isArray(s.tasks_completed)) {
351
+ sum.tasks_completed += s.tasks_completed.length;
352
+ }
353
+ }
354
+ const workerTokens = summarizeWorkerTokens(opts.resolveLedger?.(blueprint) ?? null, opts.commonGitDir ?? null, blueprint);
355
+ if (workerTokens.ok === false)
356
+ return workerTokens;
357
+ return {
358
+ ok: true, blueprint, kinds, cache,
359
+ worker_tokens: workerTokens.summary, worker_tokens_source: workerTokens.source,
360
+ };
361
+ }
362
+ const WORKER_ROLES = ['implementer', 'reviewer', 'debugger'];
363
+ function emptyWorkerSummary() {
364
+ return {
365
+ total: 0, missing: 0,
366
+ by_role: {
367
+ implementer: { total: 0, missing: 0 }, reviewer: { total: 0, missing: 0 },
368
+ debugger: { total: 0, missing: 0 },
369
+ },
370
+ };
371
+ }
372
+ /**
373
+ * 원장 decision 배열에서 하위 에이전트 토큰을 합산한다. 키 `(task, role, perspective)`마다
374
+ * `runs`(worker-model 수)와 `reported`(숫자 tokens가 달린 worker-model + worker-tokens 수)를
375
+ * 세어, 누락은 `max(0, runs - reported)`다. 늦게 온 worker-tokens가 같은 키의 토큰 없는
376
+ * worker-model을 메우므로 한 호출이 두 번 세이지 않는다. 호스트가 주지 않은 값은 만들지 않는다.
377
+ *
378
+ * @param {unknown[]} decisions - 원장 decisions
379
+ * @returns {WorkerTokensSummary} 합계와 역할별 합계
380
+ */
381
+ function sumWorkerDecisions(decisions) {
382
+ const summary = emptyWorkerSummary();
383
+ const counts = new Map();
384
+ for (const d of decisions) {
385
+ if (!isRecord(d) || (d.kind !== 'worker-model' && d.kind !== 'worker-tokens'))
386
+ continue;
387
+ const role = WORKER_ROLES.find((r) => r === d.role);
388
+ if (role === undefined)
389
+ continue;
390
+ const key = JSON.stringify([d.task ?? '', role, d.perspective ?? '']);
391
+ const entry = counts.get(key) ?? { role, runs: 0, reported: 0 };
392
+ if (d.kind === 'worker-model')
393
+ entry.runs += 1;
394
+ if (typeof d.tokens === 'number') {
395
+ entry.reported += 1;
396
+ summary.by_role[role].total += d.tokens;
397
+ summary.total += d.tokens;
398
+ }
399
+ counts.set(key, entry);
400
+ }
401
+ for (const { role, runs, reported } of counts.values()) {
402
+ const missing = Math.max(0, runs - reported);
403
+ summary.by_role[role].missing += missing;
404
+ summary.missing += missing;
405
+ }
406
+ return summary;
407
+ }
408
+ /**
409
+ * worker 집계의 출처를 정해 합산한다. 우선순위는 live 원장 → 저장 snapshot → unavailable이다.
410
+ * 원장 파일이 있으면(깨졌으면 거절) 언제나 원장이 이기므로 오래된 snapshot이 live 자료를
411
+ * 대신하지 않는다. 원장이 없을 때만 snapshot을 읽고, 깨진 snapshot은 0으로 접지 않고 거절한다.
412
+ *
413
+ * @param {string | null} ledgerFile - 원장 경로. null이면 원장 없음으로 본다
414
+ * @param {string | null} commonGitDir - snapshot 저장소가 있는 Git common dir. null이면 snapshot 없음
415
+ * @param {string} blueprint - 정규화된 blueprint 경로
416
+ * @returns {{ok: true, summary: WorkerTokensSummary, source: WorkerTokensSource} | Failure}
417
+ * 합계와 출처, 또는 깨진 원장·snapshot 거절
418
+ */
419
+ function summarizeWorkerTokens(ledgerFile, commonGitDir, blueprint) {
420
+ const ledger = ledgerFile === null
421
+ ? { ok: true, snapshot: null, decisions: [] } : readLedgerSnapshot(ledgerFile);
422
+ if (ledger.ok === false)
423
+ return ledger;
424
+ if (ledger.snapshot !== null) {
425
+ return { ok: true, summary: sumWorkerDecisions(ledger.decisions), source: 'ledger' };
426
+ }
427
+ if (commonGitDir !== null) {
428
+ const saved = loadWorkerSnapshot({ commonGitDir, blueprint });
429
+ if (saved.ok === false)
430
+ return saved;
431
+ if (saved.snapshot !== null) {
432
+ return { ok: true, summary: saved.snapshot.worker_tokens, source: 'snapshot' };
433
+ }
434
+ }
435
+ return { ok: true, summary: emptyWorkerSummary(), source: 'unavailable' };
436
+ }
437
+ /**
438
+ * snapshot 파일 경로. 파일명은 blueprint 경로(UTF-8)의 SHA-256이라 blueprint 문자열이
439
+ * 경로 조각으로 새지 않는다. 프로젝트 경로가 아니라 Git common dir 아래다 — worktree
440
+ * 정리와 `git clean`이 지우지 못하는 자리여야 정리 뒤에도 살아남는다.
441
+ *
442
+ * @param {string} commonGitDir - `git rev-parse --git-common-dir`의 절대 경로
443
+ * @param {string} blueprint - blueprint 경로(정규화 전이어도 된다)
444
+ * @returns {string} snapshot 파일 절대 경로
445
+ */
446
+ function workerSnapshotPath(commonGitDir, blueprint) {
447
+ const name = createHash('sha256').update(normalizeBlueprint(blueprint), 'utf8').digest('hex');
448
+ return path.join(commonGitDir, 'bouncer', 'metrics-snapshots', `${name}.json`);
449
+ }
450
+ // realpath가 상위가 아닌 곳으로 새는지 본다(심볼릭 링크로 common dir 밖을 가리키는 경우).
451
+ function isInsideReal(child, parent) {
452
+ const rel = path.relative(parent, child);
453
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
454
+ }
455
+ // 파일 시스템 오류(code가 문자열인 Error)인지 가른다. 프로그래밍 오류(TypeError 등)는 I/O 실패로 삼키지 않는다.
456
+ function isFsError(error) {
457
+ return error instanceof Error && typeof error.code === 'string';
458
+ }
459
+ /**
460
+ * snapshot 파일 자리가 Git common dir 안쪽이고 파일 자체가 symlink가 아닌지 검사한다.
461
+ * mkdir 전에도 호출되므로 아직 없는 하위 경로는 "가장 가까운 기존 조상"까지 거슬러 올라가
462
+ * 그 realpath가 경계 안인지 본다 — 미존재 디렉터리를 이유로 바로 통과시키면 조상이 외부를
463
+ * 가리키는 symlink일 때 recursive mkdir이 외부에 디렉터리를 만든다. realpath가 ENOENT인데
464
+ * lstat은 성공하는 조상은 가리키는 곳이 없는(dangling) symlink이므로 안전하지 않다고 본다.
465
+ * 경계 검사 중의 ENOENT 외 I/O 오류(EACCES 등)는 안전을 확인할 수 없으므로 false이고,
466
+ * 프로그래밍 오류는 그대로 던진다.
467
+ *
468
+ * @param {string} commonGitDir - Git common dir
469
+ * @param {string} file - workerSnapshotPath 결과
470
+ * @param {SnapshotFs} [fsx] - 검사 seam. 없는 메서드는 node:fs로 fallback한다
471
+ * @returns {boolean} 경계 안쪽이고 symlink가 아니면 true
472
+ */
473
+ function snapshotLocationSafe(commonGitDir, file, fsx = fs) {
474
+ const realpath = fsx.realpathSync ?? fs.realpathSync;
475
+ const lstat = fsx.lstatSync ?? fs.lstatSync;
476
+ try {
477
+ const base = realpath(commonGitDir);
478
+ let dir = path.dirname(file);
479
+ for (;;) {
480
+ try {
481
+ return isInsideReal(realpath(dir), base) && !lstatIsSymlink(lstat, file);
482
+ }
483
+ catch (error) {
484
+ if (!isEnoentError(error))
485
+ throw error;
486
+ }
487
+ // realpath ENOENT: 진짜 없는 조상이면 한 칸 위로, lstat이 성공하면 dangling symlink라 거절.
488
+ try {
489
+ lstat(dir);
490
+ return false;
491
+ }
492
+ catch (error) {
493
+ if (!isEnoentError(error))
494
+ throw error;
495
+ }
496
+ const parent = path.dirname(dir);
497
+ if (parent === dir)
498
+ return false;
499
+ dir = parent;
500
+ }
501
+ }
502
+ catch (error) {
503
+ if (isFsError(error))
504
+ return false;
505
+ throw error;
506
+ }
507
+ }
508
+ // 파일이 아직 없는 것(ENOENT)만 "첫 저장"으로 보아 symlink가 아니라고 답한다.
509
+ function lstatIsSymlink(lstat, file) {
510
+ try {
511
+ return lstat(file).isSymbolicLink();
512
+ }
513
+ catch (error) {
514
+ if (isEnoentError(error))
515
+ return false;
516
+ throw error;
517
+ }
518
+ }
519
+ function isWorkerTotal(value) {
520
+ return isRecord(value) && Number.isSafeInteger(value.total) && Number.isSafeInteger(value.missing);
521
+ }
522
+ function isWorkerSnapshotFile(value, blueprint) {
523
+ if (!isRecord(value) || value.version !== 1 || value.blueprint !== blueprint
524
+ || typeof value.recorded_at !== 'string'
525
+ || typeof value.ledger_sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.ledger_sha256))
526
+ return false;
527
+ const tokens = value.worker_tokens;
528
+ return isWorkerTotal(tokens) && isRecord(tokens.by_role)
529
+ && WORKER_ROLES.every((r) => isWorkerTotal(tokens.by_role[r]));
530
+ }
531
+ /**
532
+ * 저장된 worker 집계 snapshot을 읽는다. 파일 없음만 "snapshot 없음"이고, 경계 밖·symlink·
533
+ * 깨진 JSON·다른 blueprint·모양 불일치는 `metrics-snapshot-invalid`다 — 복원값으로
534
+ * 잘못된 숫자를 내느니 거절해야 한다.
535
+ *
536
+ * @param {object} opts - 읽기 입력
537
+ * @param {string} opts.commonGitDir - Git common dir
538
+ * @param {string} opts.blueprint - blueprint 경로
539
+ * @param {SnapshotFs} [opts.fs] - 읽기 seam
540
+ * @returns {{ok: true, snapshot: WorkerSnapshotFile | null} | Failure} snapshot 또는 거절
541
+ */
542
+ function loadWorkerSnapshot(opts) {
543
+ const invalid = { ok: false, reason: 'metrics-snapshot-invalid' };
544
+ const file = workerSnapshotPath(opts.commonGitDir, opts.blueprint);
545
+ if (!snapshotLocationSafe(opts.commonGitDir, file, opts.fs))
546
+ return invalid;
547
+ let raw;
548
+ try {
549
+ raw = (opts.fs ?? fs).readFileSync(file, 'utf8');
550
+ }
551
+ catch (error) {
552
+ if (isEnoentError(error))
553
+ return { ok: true, snapshot: null };
554
+ // EACCES·EISDIR 같은 읽기 오류는 값을 신뢰할 수 없으므로 던지지 않고 invalid로 접는다.
555
+ if (isFsError(error))
556
+ return invalid;
557
+ throw error;
558
+ }
559
+ let parsed;
560
+ try {
561
+ parsed = JSON.parse(raw);
562
+ }
563
+ catch (_error) {
564
+ // SyntaxError만 온다. 깨진 파일은 invalid로 접는다.
565
+ return invalid;
566
+ }
567
+ return isWorkerSnapshotFile(parsed, normalizeBlueprint(opts.blueprint))
568
+ ? { ok: true, snapshot: parsed } : invalid;
569
+ }
570
+ // 이 호출이 방금 만든 temp를 지운다. 정리 실패(fs 오류)는 원래의 저장 실패 보고를 가리지 않도록
571
+ // 삼킨다: 남는 것은 우리 pid가 붙은 .tmp뿐이고 최종 snapshot은 건드리지 않는다.
572
+ function removeOwnTemporary(fsx, temporary) {
573
+ try {
574
+ (fsx.unlinkSync ?? fs.unlinkSync)(temporary);
575
+ }
576
+ catch (error) {
577
+ if (!isFsError(error))
578
+ throw error;
579
+ }
580
+ }
581
+ /**
582
+ * 실제 원장에서 worker 집계와 원장 SHA-256을 읽어 snapshot으로 atomic 저장한다.
583
+ * 같은 원장 hash의 snapshot은 다시 쓰지 않고(`reused: true`), 새 원장이면 갱신한다.
584
+ * 원장이 없으면 새 값을 만들지 않는다: 유효한 기존 snapshot은 그대로 보존해 돌려주고,
585
+ * 둘 다 없으면 `metrics-ledger-unavailable`이다. 기존 snapshot이 깨졌으면 덮어쓰지 않고
586
+ * `metrics-snapshot-invalid`로 거절한다. 경계 검사·snapshot 읽기의 I/O 오류도 invalid이고,
587
+ * mkdir·temp 생성·쓰기·rename 실패는 `metrics-snapshot-write-failed`다.
588
+ *
589
+ * @param {object} opts - 저장 입력
590
+ * @param {string} opts.commonGitDir - Git common dir
591
+ * @param {string | null} opts.ledgerFile - integration 원장 경로. null이면 원장 없음
592
+ * @param {string} opts.blueprint - blueprint 경로
593
+ * @param {SnapshotFs} [opts.fs] - I/O seam(optional 메서드는 node:fs fallback)
594
+ * @param {Clock} [opts.now] - 시계 seam
595
+ * @returns {{ok: true, blueprint: string, path: string, ledger_sha256: string, reused: boolean} | Failure}
596
+ * 저장(또는 재사용)된 snapshot 정보 또는 거절 사유
597
+ */
598
+ function saveWorkerSnapshot(opts) {
599
+ const fsx = opts.fs ?? fs;
600
+ const blueprint = normalizeBlueprint(opts.blueprint);
601
+ const file = workerSnapshotPath(opts.commonGitDir, blueprint);
602
+ const existing = loadWorkerSnapshot({ commonGitDir: opts.commonGitDir, blueprint, fs: fsx });
603
+ if (existing.ok === false)
604
+ return existing;
605
+ const ledger = opts.ledgerFile === null
606
+ ? { ok: true, snapshot: null, decisions: [] } : readLedgerSnapshot(opts.ledgerFile, fsx);
607
+ if (ledger.ok === false)
608
+ return ledger;
609
+ if (ledger.snapshot === null) {
610
+ if (existing.snapshot === null)
611
+ return { ok: false, reason: 'metrics-ledger-unavailable' };
612
+ return { ok: true, blueprint, path: file, ledger_sha256: existing.snapshot.ledger_sha256, reused: true };
613
+ }
614
+ const hash = ledger.snapshot.ledger_sha256;
615
+ if (existing.snapshot !== null && existing.snapshot.ledger_sha256 === hash) {
616
+ return { ok: true, blueprint, path: file, ledger_sha256: hash, reused: true };
617
+ }
618
+ const data = {
619
+ version: 1, blueprint, recorded_at: (opts.now ?? (() => new Date()))().toISOString(),
620
+ ledger_sha256: hash, worker_tokens: sumWorkerDecisions(ledger.decisions),
621
+ };
622
+ const temporary = `${file}.${process.pid}.tmp`;
623
+ const invalid = { ok: false, reason: 'metrics-snapshot-invalid' };
624
+ const writeFailed = { ok: false, reason: 'metrics-snapshot-write-failed' };
625
+ // 위 loadWorkerSnapshot이 mkdir 전에 기존 조상까지 경계를 검사했다. 여기서는 만들기만 한다.
626
+ try {
627
+ (fsx.mkdirSync ?? fs.mkdirSync)(path.dirname(file), { recursive: true });
628
+ }
629
+ catch (error) {
630
+ // 디렉터리 생성 실패(EACCES·ENOSPC 등 fs 오류)만 저장 실패로 접는다. 프로그래밍 오류는 던진다.
631
+ if (!isFsError(error))
632
+ throw error;
633
+ return writeFailed;
634
+ }
635
+ // mkdir 뒤에 다시 검사한다: 검사와 mkdir 사이에 조상이 바뀌었다면 이제야 드러난다.
636
+ if (!snapshotLocationSafe(opts.commonGitDir, file, fsx))
637
+ return invalid;
638
+ try {
639
+ // 'wx' 배타 생성: 예측 가능한 temp 자리에 누가 symlink·파일을 심어 두었더라도 따라 쓰지 않고
640
+ // EEXIST로 실패한다. 이 경우 temp는 우리 것이 아니므로 지우지 않는다.
641
+ fsx.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`, { flag: 'wx' });
642
+ }
643
+ catch (error) {
644
+ if (!isFsError(error))
645
+ throw error;
646
+ // 파일이 만들어진 뒤에만 생기는 오류(공간 부족 등)일 때만 자신의 부분 temp를 정리한다.
647
+ if (['ENOSPC', 'EDQUOT', 'EIO', 'EFBIG'].includes(error.code)) {
648
+ removeOwnTemporary(fsx, temporary);
649
+ }
650
+ return writeFailed;
651
+ }
652
+ try {
653
+ fsx.renameSync(temporary, file);
654
+ }
655
+ catch (error) {
656
+ if (!isFsError(error))
657
+ throw error;
658
+ removeOwnTemporary(fsx, temporary);
659
+ return writeFailed;
660
+ }
661
+ return { ok: true, blueprint, path: file, ledger_sha256: hash, reused: false };
662
+ }
663
+ /**
664
+ * `coordinate status` checkpoint에 실을 같은 blueprint의 coordinator-session 요약.
665
+ * status는 읽기 전용 관측이라 기록 파일이 없거나 깨져도 실패시키지 않고 빈 배열을 낸다.
666
+ * 깨진 파일의 거절은 `metrics` 명령이 맡는다.
667
+ *
668
+ * @param {{file: string, blueprint: string}} opts - 기록 파일과 blueprint
669
+ * @returns {object[]} `{id,label,started_at,ended_at,tasks_completed,exception_decisions,tokens}` 목록
670
+ */
671
+ function listCoordinatorSessions(opts) {
672
+ const current = readMetrics(opts.file);
673
+ if (current.ok === false)
674
+ return [];
675
+ const blueprint = normalizeBlueprint(opts.blueprint);
676
+ return current.data.sessions
677
+ .filter((s) => s.kind === 'coordinator-session' && s.blueprint === blueprint)
678
+ .map((s) => ({
679
+ id: s.id,
680
+ label: s.label ?? null,
681
+ started_at: s.started_at,
682
+ ended_at: s.ended_at ?? null,
683
+ tasks_completed: s.tasks_completed ?? null,
684
+ exception_decisions: s.exception_decisions ?? null,
685
+ tokens: s.tokens ?? null,
686
+ }));
687
+ }
688
+ module.exports = {
689
+ EXCEPTION_KINDS, isException, metricsFilePath, startSession, endSession, summarize,
690
+ listCoordinatorSessions, saveWorkerSnapshot,
691
+ };