@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,337 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { catchMessageOrString } = guards;
4
+ const initMod = require("./init");
5
+ const { init, inspectBootstrap } = initMod;
6
+ const time = require("./time");
7
+ const { nowIsoKst } = time;
8
+ const graphScope = require("./graph-scope");
9
+ const { realGraphifyEnabled, realSourceDirs, realTestDirs, realExcludeDirs, realExistingDirs, newestMtimeUnder, realNewestMtime, realGraphMtime, resolveGraphScopes, SCAN_EXCLUDED_DIRS, DEFAULT_SOURCE_OUT, DEFAULT_TEST_OUT, } = graphScope;
10
+ const graphExec = require("./graph-exec");
11
+ const { realHasGraphify, defaultExecGraphify, normalizeGraphPaths, runGraphifyUpdate, partOutDir, } = graphExec;
12
+ const graphify = require("./graphify");
13
+ const { checkGraphifyCompatibility } = graphify;
14
+ // 계획·오케스트레이션·경고 문구 + 공개 배럴.
15
+ // 훅과 테스트는 이 파일 이름만 본다. 구현이 형제로 옮겨도 키 집합은 유지한다.
16
+ /**
17
+ * 한 scope의 freshness를 결정한다. unconfigured·dirs 부재는 force보다 먼저
18
+ * 고정한다 — 승격이 소스 없는 test leftover를 빌드 대상으로 올리지 않게.
19
+ *
20
+ * @param {{ name: string, dirs: string[], outDir: string, scanDirs?: string[],
21
+ * watchFiles?: string[], excludeDirs?: string[], unconfiguredReason?: string,
22
+ * existingDirs: Function, newestMtime: Function, graphMtime: Function,
23
+ * force?: boolean }} args
24
+ * @returns {GraphPlan}
25
+ */
26
+ function planOneGraph(args) {
27
+ const { name, dirs, outDir, scanDirs, watchFiles, excludeDirs, unconfiguredReason, existingDirs, newestMtime, graphMtime, force, } = args;
28
+ // 미설정·무효 test는 디스크 leftover graph.json이 있어도 build/skip-fresh로
29
+ // 가지 않는다. missing·SessionStart 경고에도 올리지 않으려면 action이
30
+ // skip-unconfigured로 먼저 고정돼야 한다.
31
+ if (unconfiguredReason) {
32
+ return {
33
+ name,
34
+ dirs: [],
35
+ configured: [],
36
+ outDir,
37
+ action: 'skip-unconfigured',
38
+ reason: unconfiguredReason,
39
+ };
40
+ }
41
+ const present = existingDirs(dirs);
42
+ const mtime = graphMtime(outDir);
43
+ // `configured`는 전체 config 목록; `dirs`는 present만 유지해 호출자가
44
+ // config를 다시 읽지 않고 "X를 요청했지만 Y만 존재"라고 말할 수 있게 한다.
45
+ // scanDirs/watchFiles 는 결정 객체에 그대로 실어 소비 측이 scanDirs||dirs /
46
+ // watchFiles||[] 로 읽게 한다. configured 문구에는 섞지 않는다.
47
+ const base = {
48
+ name, dirs: present, configured: dirs, outDir, action: '', reason: '',
49
+ };
50
+ if (scanDirs !== undefined)
51
+ base.scanDirs = scanDirs;
52
+ if (watchFiles !== undefined)
53
+ base.watchFiles = watchFiles;
54
+ // source merge 뒤 필터가 읽을 수 있게 계획에 유지. 빈 배열도 명시성
55
+ // 테스트가 보므로 키가 있으면 그대로 싣는다.
56
+ if (excludeDirs !== undefined)
57
+ base.excludeDirs = excludeDirs;
58
+ if (present.length === 0) {
59
+ return { ...base, action: 'skip-no-dirs', reason: `${name} dirs missing` };
60
+ }
61
+ // 승격은 패키지 schema가 바뀌므로 mtime이 같아도 두 graph를 다시 만들어야 한다.
62
+ // skip-fresh를 그대로 두면 stampGraphSchema가 옛 graph.json만 다시 찍는다.
63
+ if (force === true) {
64
+ return { ...base, action: 'build', reason: `${name} graph force rebuild` };
65
+ }
66
+ if (mtime === null) {
67
+ return { ...base, action: 'build', reason: `${name} graph missing` };
68
+ }
69
+ const newest = newestMtime(present, watchFiles || []);
70
+ if (newest <= mtime) {
71
+ return { ...base, action: 'skip-fresh', reason: `${name} graph is up to date` };
72
+ }
73
+ return { ...base, action: 'build', reason: `${name} sources changed since last build` };
74
+ }
75
+ /**
76
+ * SessionStart·graph-sync용 freshness 계획. force는 승격 경로만 넘긴다.
77
+ *
78
+ * @param {{ repoRoot: string, deps?: SessionGraphDeps, force?: boolean }} opts
79
+ * @returns {SessionDecision}
80
+ */
81
+ function planSessionGraph({ repoRoot, deps, force }) {
82
+ const d = {
83
+ inspectBootstrap: () => inspectBootstrap({ repoRoot }),
84
+ init: () => init({ repoRoot, timestamp: nowIsoKst() }),
85
+ graphifyEnabled: () => realGraphifyEnabled(repoRoot),
86
+ hasGraphify: () => realHasGraphify(repoRoot),
87
+ checkCompatibility: () => checkGraphifyCompatibility({ repoRoot }),
88
+ sourceDirs: () => realSourceDirs(repoRoot),
89
+ existingDirs: (dirs) => realExistingDirs(repoRoot, dirs),
90
+ newestMtime: (dirs, watchFiles) => realNewestMtime(repoRoot, dirs, watchFiles),
91
+ graphMtime: (outDir) => realGraphMtime(repoRoot, outDir),
92
+ ...(deps || {}),
93
+ };
94
+ let bootstrap = d.inspectBootstrap();
95
+ if (bootstrap === 'missing') {
96
+ const initialized = d.init();
97
+ if (!initialized.ok) {
98
+ return { bootstrap: 'partial', action: 'skip-partial-bootstrap', reason: initialized.reason, graphs: [] };
99
+ }
100
+ bootstrap = initialized.skipped ? 'ready' : 'created';
101
+ }
102
+ if (bootstrap === 'partial') {
103
+ return { bootstrap, action: 'skip-partial-bootstrap', reason: 'partial-bouncer-state', graphs: [] };
104
+ }
105
+ if (bootstrap === 'legacy') {
106
+ return { bootstrap, action: 'skip-legacy-bootstrap', reason: 'legacy-bootstrap-state', graphs: [] };
107
+ }
108
+ if (!d.graphifyEnabled()) {
109
+ return { bootstrap, action: 'skip-graph-disabled', reason: 'graphify auto-build disabled', graphs: [] };
110
+ }
111
+ if (!d.hasGraphify()) {
112
+ return { bootstrap, action: 'skip-no-graphify', reason: 'graphify not on PATH', graphs: [] };
113
+ }
114
+ const compat = d.checkCompatibility
115
+ ? d.checkCompatibility()
116
+ : checkGraphifyCompatibility({ repoRoot });
117
+ if (compat && compat.status === 'version-incompatible') {
118
+ // 조회 경로는 설치를 고치지 않는다. 두 graph 빌드를 건너뛰고 상태만 남긴다.
119
+ return {
120
+ bootstrap,
121
+ action: 'skip-version-incompatible',
122
+ reason: 'version-incompatible',
123
+ status: 'version-incompatible',
124
+ graphs: [],
125
+ compatibility: compat,
126
+ };
127
+ }
128
+ // deps가 testDirs/excludeDirs를 넘기면 config 파서를 우회한다(단위 테스트).
129
+ // 그 외에는 real*가 무효 필드를 적용하지 않고 skips에 사유를 모은다.
130
+ // testDirs null은 더 이상 scope 생략이 아니라 skip-unconfigured 자리표시다.
131
+ const skips = [];
132
+ let testDirs = null;
133
+ let testUnconfiguredReason;
134
+ let excludeDirs;
135
+ if (deps && Object.prototype.hasOwnProperty.call(deps, 'testDirs')) {
136
+ testDirs = d.testDirs ? d.testDirs() : null;
137
+ if (testDirs == null) {
138
+ testUnconfiguredReason = 'graphify.test_dirs is not configured';
139
+ }
140
+ }
141
+ else {
142
+ const testList = realTestDirs(repoRoot);
143
+ if (testList.present && testList.dirs === null) {
144
+ // 무효 값은 skips(SessionStart 진단)와 graphs[].reason에 같이 남긴다.
145
+ skips.push(testList.skipReason);
146
+ testUnconfiguredReason = testList.skipReason;
147
+ }
148
+ else if (testList.present) {
149
+ testDirs = testList.dirs;
150
+ }
151
+ else {
152
+ testUnconfiguredReason = 'graphify.test_dirs is not configured';
153
+ }
154
+ }
155
+ if (deps && Object.prototype.hasOwnProperty.call(deps, 'excludeDirs')) {
156
+ excludeDirs = d.excludeDirs ? d.excludeDirs() : [];
157
+ }
158
+ else {
159
+ const excludeList = realExcludeDirs(repoRoot);
160
+ if (excludeList.skipReason)
161
+ skips.push(excludeList.skipReason);
162
+ excludeDirs = excludeList.dirs;
163
+ }
164
+ const scopes = resolveGraphScopes({
165
+ sourceDirs: d.sourceDirs(),
166
+ testDirs,
167
+ excludeDirs,
168
+ testUnconfiguredReason,
169
+ });
170
+ const graphs = scopes.map((scope) => planOneGraph({
171
+ ...scope,
172
+ existingDirs: d.existingDirs,
173
+ newestMtime: d.newestMtime,
174
+ graphMtime: d.graphMtime,
175
+ force: force === true,
176
+ }));
177
+ const withSkips = (decision) => (skips.length ? { ...decision, skips } : decision);
178
+ const toBuild = graphs.filter((g) => g.action === 'build');
179
+ if (toBuild.length) {
180
+ return withSkips({
181
+ bootstrap,
182
+ action: 'build',
183
+ // 하위 호환: 첫 build target의 dirs(hook은 graphs[]를 선호).
184
+ dirs: toBuild[0].dirs,
185
+ graphs,
186
+ reason: toBuild.map((g) => g.reason).join('; '),
187
+ });
188
+ }
189
+ // skip-unconfigured는 빌드 시도가 아니다. 요약 집계에서 빼야 source가
190
+ // skip-no-dirs인 저장소가 항상 실리는 test 자리 때문에 skip-fresh로
191
+ // 뒤집히지 않는다. 제외 후 남는 항목이 없으면 skip-no-dirs를 유지한다.
192
+ const countable = graphs.filter((g) => g.action !== 'skip-unconfigured');
193
+ if (countable.length === 0 || countable.every((g) => g.action === 'skip-no-dirs')) {
194
+ return withSkips({
195
+ bootstrap, action: 'skip-no-dirs', graphs, reason: 'no graph source dirs exist',
196
+ });
197
+ }
198
+ return withSkips({
199
+ bootstrap, action: 'skip-fresh', graphs, reason: 'graphs are up to date',
200
+ });
201
+ }
202
+ // graph 작업을 시도하지 않고 조기 종료 — 해당 scope를 "missing"으로 보고하면
203
+ // 의도적 no-op(disabled/bootstrap)을 사용자 탓으로 돌린다.
204
+ const NO_GRAPH_WORK = new Set([
205
+ 'skip-graph-disabled',
206
+ 'skip-partial-bootstrap',
207
+ 'skip-legacy-bootstrap',
208
+ 'skip-no-graphify',
209
+ 'skip-version-incompatible',
210
+ ]);
211
+ /**
212
+ * source + test graph freshness를 계획하고 stale한 것을 재빌드한다.
213
+ * SessionStart와 /bouncer-plan(graphify-runner) query 전에 다시 사용.
214
+ * graphs[]는 config와 무관하게 두 항목이지만, skip-unconfigured test는
215
+ * 빌드·missing·경고 대상이 아니다. force는 명시적 승격 전용 — 일반
216
+ * graph-sync가 skip-fresh를 버리게 하지 않는다.
217
+ *
218
+ * @param {{ repoRoot: string, deps?: SessionGraphDeps,
219
+ * execGraphify?: (graph: GraphPlan) => unknown, force?: boolean }} opts
220
+ * @returns {SessionDecision & { built: string[], failed: Array<{ name: string, message: string }>, missing: string[] }}
221
+ */
222
+ function syncSessionGraphs({ repoRoot, deps, execGraphify, force }) {
223
+ const decision = planSessionGraph({ repoRoot, deps, force });
224
+ const run = execGraphify || ((graph) => defaultExecGraphify(repoRoot, graph));
225
+ const built = [];
226
+ const failed = [];
227
+ if (decision.action === 'build') {
228
+ for (const graph of decision.graphs) {
229
+ if (graph.action !== 'build' || !graph.dirs.length)
230
+ continue;
231
+ try {
232
+ // context digest 제거 후 defaultExecGraphify는 skippedEmpty/touched를
233
+ // 반환하지 않는다. 성공하면 항상 built에 올린다.
234
+ run(graph);
235
+ built.push(graph.name);
236
+ }
237
+ catch (error) {
238
+ failed.push({ name: graph.name, message: catchMessageOrString(error) });
239
+ }
240
+ }
241
+ }
242
+ // planSessionGraph가 쓰는 것과 같은 deps.graphMtime 주입 — build loop 후
243
+ // 실제 graph.json을 probe; action 문자열로 부재를 추론하지 말 것
244
+ // (이전 세션 graph는 skip-no-dirs에서도 present로 친다).
245
+ const graphMtime = (deps && deps.graphMtime)
246
+ ? deps.graphMtime
247
+ : (outDir) => realGraphMtime(repoRoot, outDir);
248
+ // NO_GRAPH_WORK 는 그래프를 시도하지 않은 상태. missing 을 채우거나
249
+ // ok 를 뒤집으면 disabled/bootstrap 을 실패로 보이게 된다.
250
+ // skip-unconfigured는 graph.json 존재와 무관하게 missing에서 뺀다 —
251
+ // 미설정 test leftover 산출물이 SessionStart 경고를 만들지 않게.
252
+ const missing = NO_GRAPH_WORK.has(decision.action)
253
+ ? []
254
+ : decision.graphs
255
+ .filter((g) => g.action !== 'skip-unconfigured')
256
+ .filter((g) => graphMtime(g.outDir) === null)
257
+ .map((g) => g.name);
258
+ return { ...decision, built, failed, missing };
259
+ }
260
+ /**
261
+ * sync decision을 SessionStart용 stderr 경고 줄로 변환.
262
+ * 순서: bootstrap(partial/legacy) → no-graphify → skips → missing → failed.
263
+ * NO_GRAPH_WORK 종료는 missing을 비우므로 bootstrap 줄이 missing/failed와
264
+ * stderr를 공유하지 않는다. 각 줄은 hook용으로 \n으로 끝난다.
265
+ */
266
+ function graphSyncWarnings(decision) {
267
+ const lines = [];
268
+ if (decision.bootstrap === 'partial') {
269
+ lines.push('Bouncer: partial Bouncer state detected; preserving .bouncer and skipping SessionStart work.\n');
270
+ }
271
+ else if (decision.bootstrap === 'legacy') {
272
+ lines.push('Bouncer: legacy state detected; remove or migrate legacy files, then run /bouncer-init.\n');
273
+ }
274
+ if (decision.action === 'skip-no-graphify') {
275
+ // enabled인데 CLI가 없을 때만 경고 — disabled면 이 action에 오지 않는다.
276
+ lines.push('Bouncer: graphify.enabled is true but graphify is not on PATH — path suggestions '
277
+ + 'will fall back to manual affected_paths. '
278
+ + 'Install: pip install graphifyy && graphify install. See docs/install.md.\n');
279
+ }
280
+ if (decision.action === 'skip-version-incompatible') {
281
+ lines.push('Bouncer: graphify is version-incompatible with the plugin contract. '
282
+ + 'Run bouncer init --upgrade-graphify. Search and graph-sync will not install or mutate the venv.\n');
283
+ }
284
+ // 잘못된 exclude_dirs·무효 test_dirs는 skips에 남는다(test는 graphs에도
285
+ // skip-unconfigured로 실린다). SessionStart가 못 보면 운영자가 왜 필드가
286
+ // 무시됐는지 알 수 없다.
287
+ for (const reason of decision.skips || []) {
288
+ lines.push(`Bouncer: skipped graphify config — ${reason}. `
289
+ + 'Update .bouncer/config.json; path suggestions may be incomplete.\n');
290
+ }
291
+ const byName = Object.fromEntries((decision.graphs || []).map((g) => [g.name, g]));
292
+ // failed scope는 전용 줄이 있음; configured dirs가 missing이라고
293
+ // 중복 주장하지 말 것(dirs는 있었고 build가 실패).
294
+ // skip-unconfigured는 missing에 안 들어가지만, 방어적으로 action도 건너뛴다.
295
+ const failedNames = new Set((decision.failed || []).map((f) => f.name));
296
+ for (const name of decision.missing || []) {
297
+ if (failedNames.has(name))
298
+ continue;
299
+ const graph = (byName[name] || {});
300
+ if (graph.action === 'skip-unconfigured')
301
+ continue;
302
+ const configured = Array.isArray(graph.configured) ? graph.configured : [];
303
+ // test는 config 상 graphify.test_dirs. source는 루트 키.
304
+ const dirsKey = name === 'test' ? 'graphify.test_dirs' : `${name}_dirs`;
305
+ // "none of … exist"는 skip-no-dirs/빈 present dirs일 때만 참.
306
+ const noDirs = graph.action === 'skip-no-dirs'
307
+ || !Array.isArray(graph.dirs)
308
+ || graph.dirs.length === 0;
309
+ if (noDirs) {
310
+ lines.push(`Bouncer: graphify is enabled but no ${name} graph was built — none of `
311
+ + `${dirsKey} ${JSON.stringify(configured)} exist. Update .bouncer/config.json; path `
312
+ + 'suggestions will fall back to manual affected_paths.\n');
313
+ }
314
+ else {
315
+ lines.push(`Bouncer: graphify is enabled but no ${name} graph was built after sync. `
316
+ + 'Path suggestions will fall back to manual affected_paths.\n');
317
+ }
318
+ }
319
+ if (decision.failed && decision.failed.length) {
320
+ lines.push(`Bouncer: graphify sync failed for ${decision.failed.map((f) => f.name).join(', ')}. `
321
+ + 'Plan will re-check; confirm affected_paths manually if graphs stay stale.\n');
322
+ }
323
+ return lines;
324
+ }
325
+ module.exports = {
326
+ planSessionGraph,
327
+ syncSessionGraphs,
328
+ graphSyncWarnings,
329
+ resolveGraphScopes,
330
+ normalizeGraphPaths,
331
+ newestMtimeUnder,
332
+ runGraphifyUpdate,
333
+ partOutDir,
334
+ SCAN_EXCLUDED_DIRS,
335
+ DEFAULT_SOURCE_OUT,
336
+ DEFAULT_TEST_OUT,
337
+ };
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+ const guards = require("./guards");
3
+ const { isRecord } = guards;
4
+ const config = require("./config");
5
+ const { readConfig } = config;
6
+ // `bouncer config --help`와 docs 일치 테스트의 원본 값 목록. provider 이름은
7
+ // 그동안 init 기본 블록·print-dispatch·주석에만 흩어져 있어, 도움말이 문자열을
8
+ // 다시 적지 않도록 여기 한 곳에 둔다. 값 검증 게이트로 쓰지 않는다(읽기 전용 안내).
9
+ const SUBAGENT_PROVIDERS = ['claude', 'cursor', 'codex', 'antigravity'];
10
+ // `subagents.dispatch`가 받는 값. 부재가 기본(호스트 Task 서브에이전트)이라 목록에 넣지 않는다.
11
+ const SUBAGENT_DISPATCH_VALUES = ['print'];
12
+ /**
13
+ * provider 해석 순서(먼저 맞는 것이 이김):
14
+ * 1. 명시적 `provider` 인자(skill override)
15
+ * 2. `config.subagents.provider`(프로젝트 pin — Cursor·Antigravity에 필수)
16
+ * 3. CLAUDE_PLUGIN_ROOT → 'claude'
17
+ * 4. PLUGIN_ROOT → 'codex'
18
+ * 5. 호스트가 자기 터미널에 직접 내보내는 세션 변수:
19
+ * CLAUDECODE → 'claude', CODEX_THREAD_ID → 'codex'
20
+ * 6. null
21
+ *
22
+ * BOUNCER_HOME은 의도적으로 무시한다: 어떤 호스트에서도 설정 가능한
23
+ * 수동 plugin-root override이므로 Cursor(또는 다른 provider)를 의미하지
24
+ * 않는다. Cursor 사용자는 `subagents.provider: "cursor"`를 설정해야 한다.
25
+ *
26
+ * 5번은 plugin-root 변수가 주입되지 않는 터미널(예: Bash 도구)에서 provider가
27
+ * null로 풀려 역할별 모델이 조용히 inherit로 떨어지는 것을 막는다. Claude Code 안에서
28
+ * codex를 띄우는 식의 중첩에서는 더 안쪽 호스트가 아니라 CLAUDECODE가 이긴다 —
29
+ * 틀리면 `subagents.provider`로 명시 고정한다. Cursor는 CURSOR_AGENT가 있어도
30
+ * 자동 감지하지 않는다(print dispatch 분기가 걸려 있어 명시 고정만 허용).
31
+ *
32
+ * Antigravity도 plugin-root 환경 변수를 내보내지 않으므로 env 표에 분기를
33
+ * 추가하지 않는다 — `subagents.provider: "antigravity"` 명시 pin만 허용.
34
+ */
35
+ function resolveProvider(config, providerArg) {
36
+ if (typeof providerArg === 'string' && providerArg.length > 0) {
37
+ return providerArg;
38
+ }
39
+ const subagents = isRecord(config) ? config.subagents : undefined;
40
+ const pinned = isRecord(subagents) ? subagents.provider : undefined;
41
+ if (typeof pinned === 'string' && pinned.length > 0) {
42
+ return pinned;
43
+ }
44
+ if (process.env.CLAUDE_PLUGIN_ROOT)
45
+ return 'claude';
46
+ if (process.env.PLUGIN_ROOT)
47
+ return 'codex';
48
+ if (process.env.CLAUDECODE)
49
+ return 'claude';
50
+ if (process.env.CODEX_THREAD_ID)
51
+ return 'codex';
52
+ return null;
53
+ }
54
+ /**
55
+ * 활성 호스트용 named-agent model slug를 조회한다.
56
+ * 절대 throw하지 않으며, 모든 miss/inherit/비문자열은
57
+ * `{ model: null }`로 수렴해 호출자가 부모 세션 model을 상속하게 한다.
58
+ *
59
+ * @param {{ repoRoot: string, agentName: string, provider?: string }} opts
60
+ * @returns {{ model: string | null, provider: string | null }}
61
+ */
62
+ function resolveSubagentModel({ repoRoot, agentName, provider: providerArg, } = {}) {
63
+ // 부재·깨진 JSON은 null. ?? {}로 받아야 resolveProvider가 throw 없이
64
+ // inherit fallback({ model: null })으로 수렴한다. session-graph는 null을
65
+ // 구분하지만 이쪽은 "설정 없음 = 빈 설정"이 안전하다.
66
+ const config = readConfig(repoRoot) ?? {};
67
+ const provider = resolveProvider(config, providerArg);
68
+ if (!provider) {
69
+ return { model: null, provider: null };
70
+ }
71
+ const subagents = isRecord(config) ? config.subagents : undefined;
72
+ const block = isRecord(subagents) ? subagents[provider] : undefined;
73
+ if (!block || typeof block !== 'object') {
74
+ return { model: null, provider };
75
+ }
76
+ // agentName이 빠지면 예전 `block[undefined]`와 같이 키 'undefined'를 본다.
77
+ // 배열도 typeof object라 통과시켜, 인덱스 키 조회를 객체만 받도록 바꾸지 않는다.
78
+ const value = block[`${agentName}`];
79
+ // 'inherit'은 "부모 세션 model 사용"을 위한 문서화된 sentinel이다.
80
+ // 비문자열(숫자, 객체, null)도 동일하게 취급 — 절대 throw하지 않음.
81
+ if (typeof value !== 'string' || value.length === 0 || value === 'inherit') {
82
+ return { model: null, provider };
83
+ }
84
+ return { model: value, provider };
85
+ }
86
+ module.exports = { resolveSubagentModel, SUBAGENT_PROVIDERS, SUBAGENT_DISPATCH_VALUES };