@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,470 @@
1
+ // scripts/lib/commit-hook.js
2
+ 'use strict';
3
+ const path = require('node:path');
4
+ const { execFileSync } = require('node:child_process');
5
+ const commitGuard = require("./commit-guard");
6
+ const { checkCommitSafety } = commitGuard;
7
+ const current = require("./current");
8
+ const { readCurrent, resolveEffectiveTask } = current;
9
+ const frontmatter = require("./frontmatter");
10
+ const { readDoc } = frontmatter;
11
+ const tasksDocs = require("./tasks-docs");
12
+ const { listTasksDocs } = tasksDocs;
13
+ const paths = require("./paths");
14
+ const { toPosix } = paths;
15
+ const scope = require("./scope");
16
+ const { coordinatorContext } = scope;
17
+ const crossDrive = require("./cross-drive");
18
+ const compositeGates = require("./composite-gates");
19
+ const { otherDriveTasks } = crossDrive;
20
+ const NO_COMMIT = { commit: false, all: false };
21
+ // 판단 불가(중첩 셸·확장·깊이 초과)는 커밋으로 칠 뿐 아니라 all-flag도
22
+ // 있는 것으로 친다. -a 없이 스테이징만 보면 PreToolUse 시점에 인덱스가
23
+ // 비어 범위 밖 파일이 그대로 들어간다.
24
+ const FAIL_CLOSED = { commit: true, all: true };
25
+ // guard는 실수를 막습니다. 의도적 우회에 대한 방어는 아닙니다
26
+ // fail-closed: 명령을 판단할 수 없는 경우 —
27
+ // 중첩 셸, 셸 확장, alias — commit으로 보고하고 통과시키지 않아
28
+ // scope 검사가 여전히 실행됩니다.
29
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']);
30
+ const GIT_VALUE_FLAGS = new Set([
31
+ '-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--super-prefix',
32
+ ]);
33
+ const SHELL_COMMAND_FLAG = /^-[A-Za-z]*c$/;
34
+ const EXPANSION = /[$`]/;
35
+ const MAX_DEPTH = 4;
36
+ // 공백과 새 명령을 시작하는 연산자 모두에서 분리하되, 따옴표 밖에서만.
37
+ // 인자 위치의 따옴표 토큰은 데이터다(`echo "git commit"`). 다만 세그먼트
38
+ // 첫 토큰 값이 git이면 인용·부분인용도 명령으로 본다 — segmentIsGitCommit 참고.
39
+ function tokenize(command) {
40
+ const tokens = [];
41
+ let value = '';
42
+ let started = false;
43
+ let quoted = false;
44
+ const push = () => {
45
+ if (started)
46
+ tokens.push({ value, quoted });
47
+ value = '';
48
+ started = false;
49
+ quoted = false;
50
+ };
51
+ for (let i = 0; i < command.length; i += 1) {
52
+ const ch = command[i];
53
+ if (ch === '\\' && i + 1 < command.length) {
54
+ value += command[i + 1];
55
+ started = true;
56
+ i += 1;
57
+ }
58
+ else if (ch === '"' || ch === "'") {
59
+ const end = command.indexOf(ch, i + 1);
60
+ const stop = end === -1 ? command.length : end;
61
+ value += command.slice(i + 1, stop);
62
+ started = true;
63
+ quoted = true;
64
+ i = stop;
65
+ }
66
+ else if (/\s/.test(ch)) {
67
+ push();
68
+ if (ch === '\n')
69
+ tokens.push({ separator: true });
70
+ }
71
+ else if (ch === ';' || ch === '&' || ch === '|') {
72
+ push();
73
+ tokens.push({ separator: true });
74
+ }
75
+ else {
76
+ value += ch;
77
+ started = true;
78
+ }
79
+ }
80
+ push();
81
+ return tokens;
82
+ }
83
+ function segments(tokens) {
84
+ const out = [[]];
85
+ for (const token of tokens) {
86
+ if (token.separator)
87
+ out.push([]);
88
+ else
89
+ out[out.length - 1].push(token);
90
+ }
91
+ return out.filter((seg) => seg.length);
92
+ }
93
+ function isWord(token, word) {
94
+ return !token.quoted && token.value === word;
95
+ }
96
+ // 명령어 위치(세그먼트 첫 토큰)의 git만 인용·부분인용을 허용한다.
97
+ // `echo "git" commit`처럼 인자 자리의 "git"까지 열면 오탐이 난다.
98
+ function isCommandPositionGit(token) {
99
+ return Boolean(token && token.value === 'git');
100
+ }
101
+ function orJudgment(a, b) {
102
+ return { commit: a.commit || b.commit, all: a.all || b.all };
103
+ }
104
+ function argvHasAllFlag(tokens, start) {
105
+ for (let i = start; i < tokens.length; i += 1) {
106
+ const t = tokens[i];
107
+ // 따옴표 토큰은 데이터다. `git commit -m "-a"`의 "-a"를 --all로 읽으면 안 된다.
108
+ if (t.quoted)
109
+ continue;
110
+ const v = t.value;
111
+ if (v === '--message' || v === '-m') {
112
+ i += 1;
113
+ continue;
114
+ }
115
+ if (v.startsWith('--message='))
116
+ continue;
117
+ // 롱 옵션은 이름 전체가 정확히 --all일 때만. --amend/--author=/--allow-empty는
118
+ // '-'로 시작하고 a를 포함하지만 all-flag가 아니다.
119
+ if (v === '--all')
120
+ return true;
121
+ if (v.startsWith('--'))
122
+ continue;
123
+ if (v.startsWith('-')) {
124
+ if (v.includes('a'))
125
+ return true;
126
+ // -nm 처럼 m이 묶음에 있으면 다음 토큰은 메시지 값이다.
127
+ if (v.includes('m'))
128
+ i += 1;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+ function aliasIsCommit(name, rest, resolveAlias, depth) {
134
+ if (typeof resolveAlias !== 'function')
135
+ return NO_COMMIT;
136
+ let expansion;
137
+ try {
138
+ expansion = (resolveAlias(name) || '').trim();
139
+ }
140
+ catch (_e) {
141
+ return NO_COMMIT;
142
+ }
143
+ if (!expansion)
144
+ return NO_COMMIT;
145
+ // `!` alias는 임의의 셸 명령을 실행합니다. 그 외는 git 자체 argv입니다.
146
+ const nested = expansion.startsWith('!')
147
+ ? detect(expansion.slice(1), resolveAlias, depth + 1)
148
+ : detect(`git ${expansion}`, resolveAlias, depth + 1);
149
+ // alias 뒤에 붙은 원래 argv(-am 등)는 확장 문자열에 안 들어 있다.
150
+ if (nested.commit && argvHasAllFlag(rest, 0)) {
151
+ return { commit: true, all: true };
152
+ }
153
+ return nested;
154
+ }
155
+ function segmentIsGitCommit(tokens, resolveAlias, depth) {
156
+ const shellIdx = tokens.findIndex((t) => !t.quoted && SHELLS.has(path.basename(t.value)));
157
+ if (shellIdx !== -1) {
158
+ for (let i = shellIdx + 1; i < tokens.length; i += 1) {
159
+ if (!tokens[i].quoted && SHELL_COMMAND_FLAG.test(tokens[i].value)) {
160
+ const script = tokens[i + 1];
161
+ // 읽을 `bash -c` 내용이 없으면 무해한 게 아니라 판단 불가입니다.
162
+ if (!script)
163
+ return FAIL_CLOSED;
164
+ return detect(script.value, resolveAlias, depth + 1);
165
+ }
166
+ }
167
+ }
168
+ // 1. 따옴표 없는 git은 기존처럼 세그먼트 어디서든(예: env=… git commit).
169
+ // 2. 인용·부분인용(`"git"` / `g"it"`)은 첫 토큰일 때만 — 인자 오탐 방지.
170
+ let gitIdx = tokens.findIndex((t) => isWord(t, 'git'));
171
+ if (gitIdx === -1 && isCommandPositionGit(tokens[0]))
172
+ gitIdx = 0;
173
+ if (gitIdx === -1)
174
+ return NO_COMMIT;
175
+ let i = gitIdx + 1;
176
+ while (i < tokens.length) {
177
+ const t = tokens[i];
178
+ // 이 명령을 결정하는 단어는 런타임에 만들어집니다.
179
+ if (EXPANSION.test(t.value))
180
+ return FAIL_CLOSED;
181
+ if (t.value.startsWith('-')) {
182
+ i += GIT_VALUE_FLAGS.has(t.value) ? 2 : 1;
183
+ continue;
184
+ }
185
+ if (t.value === 'commit') {
186
+ return { commit: true, all: argvHasAllFlag(tokens, i + 1) };
187
+ }
188
+ return aliasIsCommit(t.value, tokens.slice(i + 1), resolveAlias, depth);
189
+ }
190
+ return NO_COMMIT;
191
+ }
192
+ function detect(command, resolveAlias, depth) {
193
+ if (typeof command !== 'string')
194
+ return NO_COMMIT;
195
+ if (depth >= MAX_DEPTH)
196
+ return FAIL_CLOSED;
197
+ return segments(tokenize(command))
198
+ .reduce((acc, seg) => orJudgment(acc, segmentIsGitCommit(seg, resolveAlias, depth)), NO_COMMIT);
199
+ }
200
+ // PreToolUse payload의 cwd는 일부 호스트에서 호출한 Bash 도구의 기본 checkout으로
201
+ // 남는다. `git -C <worktree> commit`은 Git 자신이 커밋 대상을 명시하므로, 이 경우
202
+ // scope 판정도 같은 worktree에서 해야 한다. 경로는 실행하지 않고 Git argv의 리터럴
203
+ // `-C` 값만 누적해 해석한다.
204
+ function commitWorktree(command, fallback) {
205
+ if (typeof command !== 'string')
206
+ return fallback;
207
+ let worktree = fallback;
208
+ for (const segment of segments(tokenize(command))) {
209
+ const gitIdx = segment.findIndex((token) => isWord(token, 'git'));
210
+ if (gitIdx === -1)
211
+ continue;
212
+ for (let i = gitIdx + 1; i < segment.length; i += 1) {
213
+ const token = segment[i];
214
+ if (token.value === 'commit') {
215
+ if (worktree === fallback)
216
+ return fallback;
217
+ try {
218
+ return execFileSync('git', ['rev-parse', '--show-toplevel'], {
219
+ cwd: worktree, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
220
+ }).trim();
221
+ }
222
+ catch (_e) {
223
+ // `-C` 대상이 Git worktree임을 증명할 수 없으면 host cwd 판정을 유지한다.
224
+ return fallback;
225
+ }
226
+ }
227
+ if (token.quoted || EXPANSION.test(token.value))
228
+ continue;
229
+ let value;
230
+ if (token.value === '-C')
231
+ value = segment[++i];
232
+ else if (token.value.startsWith('-C') && token.value.length > 2) {
233
+ value = { value: token.value.slice(2), quoted: false };
234
+ }
235
+ if (value && !EXPANSION.test(value.value))
236
+ worktree = path.resolve(worktree, value.value);
237
+ else if (GIT_VALUE_FLAGS.has(token.value))
238
+ i += 1;
239
+ }
240
+ }
241
+ return fallback;
242
+ }
243
+ function realResolveAlias(cwd) {
244
+ return (name) => {
245
+ try {
246
+ return execFileSync('git', ['config', '--get', `alias.${name}`], {
247
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
248
+ });
249
+ }
250
+ catch (_e) {
251
+ return '';
252
+ }
253
+ };
254
+ }
255
+ function isGitCommit(command, { resolveAlias, cwd } = {}) {
256
+ const resolver = resolveAlias === undefined ? realResolveAlias(cwd) : resolveAlias;
257
+ return detect(command, resolver, 0).commit;
258
+ }
259
+ function pathsFromTaskDoc(repoRoot, entryRel) {
260
+ try {
261
+ const { data } = readDoc(path.join(repoRoot, entryRel));
262
+ const bouncer = data ? data.bouncer : data;
263
+ const ap = bouncer ? bouncer.affected_paths : undefined;
264
+ if (!Array.isArray(ap))
265
+ return [];
266
+ return ap.filter((p) => typeof p === 'string');
267
+ }
268
+ catch (_e) {
269
+ return [];
270
+ }
271
+ }
272
+ function readAffectedPaths({ repoRoot, blueprintDir }) {
273
+ // effective task가 있으면 그 문서의 affected_paths 만. 없으면 전체 합집합.
274
+ // worker에서 lease를 못 확인하면 빈 목록 — pointer로 폴백하지 않는다.
275
+ // 가리키던 문서가 사라진 경우에만 합집합으로 폴백한다.
276
+ try {
277
+ const listing = listTasksDocs({ repoRoot, blueprintDir });
278
+ if (listing.mixed || listing.entries.length === 0)
279
+ return [];
280
+ const effective = resolveEffectiveTask({ repoRoot });
281
+ if (effective && effective.source === null)
282
+ return [];
283
+ const bp = toPosix(blueprintDir);
284
+ if (effective
285
+ && typeof effective.path === 'string'
286
+ && toPosix(effective.blueprint) === bp) {
287
+ const match = listing.entries.find((e) => e.rel === toPosix(effective.path));
288
+ if (match)
289
+ return pathsFromTaskDoc(repoRoot, match.rel);
290
+ }
291
+ const out = [];
292
+ const seen = new Set();
293
+ for (const entry of listing.entries) {
294
+ for (const p of pathsFromTaskDoc(repoRoot, entry.rel)) {
295
+ if (!seen.has(p)) {
296
+ seen.add(p);
297
+ out.push(p);
298
+ }
299
+ }
300
+ }
301
+ return out;
302
+ }
303
+ catch (_e) {
304
+ return [];
305
+ }
306
+ }
307
+ function realStagedFiles({ repoRoot }) {
308
+ const out = execFileSync('git', ['diff', '--cached', '--name-only'], {
309
+ cwd: repoRoot, encoding: 'utf8',
310
+ });
311
+ return out.split('\n').filter(Boolean);
312
+ }
313
+ function realTrackedModified({ repoRoot }) {
314
+ // -a/--all 이 담는 것은 인덱스만이 아니라 HEAD 대비 추적 중 수정이다.
315
+ // 삭제된 경로가 섞이므로 이름을 쓸 뿐 파일 내용은 읽지 않는다.
316
+ // git 실패는 삼키지 않는다: 훅 어댑터가 throw를 fail-closed로 exit 2 한다.
317
+ const out = execFileSync('git', ['diff', 'HEAD', '--name-only'], {
318
+ cwd: repoRoot, encoding: 'utf8',
319
+ });
320
+ return out.split('\n').filter(Boolean);
321
+ }
322
+ // active pointer는 Git common directory에 있으므로 primary와 linked worktree
323
+ // 모두 main working tree를 찾지 않고 같은 state를 resolve합니다.
324
+ function realMainRepoCurrent({ repoRoot, deps }) {
325
+ return readCurrent({ repoRoot, deps });
326
+ }
327
+ function mergeCommitHookDeps(deps) {
328
+ return {
329
+ readCurrent,
330
+ readAffectedPaths,
331
+ stagedFiles: realStagedFiles,
332
+ trackedModified: realTrackedModified,
333
+ mainRepoCurrent: realMainRepoCurrent,
334
+ coordinatorContext,
335
+ otherDriveTasks,
336
+ ...(deps || {}),
337
+ };
338
+ }
339
+ /**
340
+ * hook 거절 줄 맨 앞에 붙일 복합 gate 라벨 접두를 만든다.
341
+ * 라벨은 줄 맨 앞에만 둔다 — `commit blocked: <code>:` 연속 문자열을 쪼개면 기존 정규식이 깨진다.
342
+ *
343
+ * @param {unknown} reason - 거절 code. 문자열이 아니거나 매핑이 없으면 접두 없음
344
+ * @returns {string} `[<label>] ` 또는 빈 문자열
345
+ */
346
+ function gateLabelPrefix(reason) {
347
+ const gate = typeof reason === 'string' ? compositeGates.compositeGateForReason(reason) : null;
348
+ return gate ? `[${compositeGates.compositeGateLabel(gate)}] ` : '';
349
+ }
350
+ /**
351
+ * staged(또는 -a면 tracked 수정까지) 목록을 checkCommitSafety에 넘긴다.
352
+ * 명령 탐지는 호출부가 끝낸 뒤라, git pre-commit과 PreToolUse가 같은
353
+ * 이유 문자열을 내게 여기만 공유한다. coordinator scope를 통과하면 상대
354
+ * drive 원장도 검사해 두 경로가 같은 충돌 근거와 복구 안내를 낸다.
355
+ *
356
+ * @param {object} opts
357
+ * @param {string} opts.repoRoot - 커밋이 일어나는 checkout 절대 경로
358
+ * @param {CommitHookDeps | null} [opts.deps] - 테스트 seam
359
+ * @param {boolean} opts.all - true면 staged∪trackedModified (`git commit -a`)
360
+ * @returns {{ block: false } | { block: true, reason: string }}
361
+ */
362
+ function evaluateIndex({ repoRoot, deps, all }) {
363
+ const d = mergeCommitHookDeps(deps);
364
+ // 1. pointer 조기 허용보다 effective task를 먼저 본다. worker에서
365
+ // `if (!current) return {block:false}` 하면 lease fail-closed와
366
+ // lease affected_paths가 통째로 건너뛴다.
367
+ const effective = resolveEffectiveTask({ repoRoot });
368
+ if (effective && effective.source === null) {
369
+ return { block: true, reason: effective.reason };
370
+ }
371
+ const current = d.readCurrent({ repoRoot }) || d.mainRepoCurrent({ repoRoot });
372
+ // lease가 blueprint를 주면 pointer 없이도 scope 판정. 둘 다 없으면
373
+ // standalone/no-blueprint와 같이 허용(기존 계약).
374
+ const blueprintDir = (effective && typeof effective.blueprint === 'string' && effective.blueprint)
375
+ || (current && typeof current.blueprint === 'string' ? current.blueprint : null);
376
+ if (!blueprintDir)
377
+ return { block: false };
378
+ const taskForScope = effective && typeof effective.path === 'string'
379
+ ? effective.path
380
+ : (current && current.task);
381
+ const affectedPaths = d.readAffectedPaths({ repoRoot, blueprintDir });
382
+ const files = all
383
+ ? [...new Set([...d.stagedFiles({ repoRoot }), ...d.trackedModified({ repoRoot })])]
384
+ : d.stagedFiles({ repoRoot });
385
+ // coordinator 실행이면 ledger가 현재 scope와 worktree 경계의 정본이다.
386
+ // 일반 execute에서는 active:false로 떨어져 예전 판정이 그대로 남는다.
387
+ const coordinator = d.coordinatorContext({
388
+ repoRoot, blueprint: blueprintDir, task: taskForScope,
389
+ });
390
+ const otherDrives = coordinator.active && !coordinator.reason
391
+ ? d.otherDriveTasks({ repoRoot, ownLedgerFile: coordinator.ledgerFile })
392
+ : undefined;
393
+ const { allow, violations, code, conflicts, ledgerFile } = checkCommitSafety({
394
+ files, affectedPaths, blueprintDir, coordinator, otherDrives,
395
+ });
396
+ if (allow)
397
+ return { block: false };
398
+ const detail = violations.join(', ');
399
+ if (code === 'cross-drive-conflict' && conflicts && conflicts.length > 0) {
400
+ const first = conflicts[0];
401
+ const target = first.paths[0] || `resource:${first.resources[0]}`;
402
+ const remaining = conflicts.length > 1 ? ` (+${conflicts.length - 1} more)` : '';
403
+ return {
404
+ block: true,
405
+ reason: `${gateLabelPrefix(code)}commit blocked: cross-drive-conflict: ${first.blueprint} ${first.task} ${target}`
406
+ + `${remaining} | next: 상대 drive가 끝나거나 detach될 때까지 기다리거나, 겹치지 않게 계획을 고친다`,
407
+ };
408
+ }
409
+ if (code === 'cross-drive-ledger-unreadable') {
410
+ return {
411
+ block: true,
412
+ reason: `${gateLabelPrefix(code)}commit blocked: cross-drive-ledger-unreadable: ${ledgerFile}`
413
+ + ' | next: `ledgerFile`의 원장을 복구하거나 그 drive를 detach한다',
414
+ };
415
+ }
416
+ if (code && code !== 'out-of-scope') {
417
+ // 경계 거절은 staged 목록이 비어도 성립한다 — 그때는 빈 콜론 대신
418
+ // 어느 checkout에서 무엇이 거절됐는지 남긴다. 원장 경로도 함께 남긴다:
419
+ // 손상된 원장 하나가 모든 checkout을 막으므로 어느 파일인지 보여야 한다.
420
+ const where = detail || `no staged path in ${repoRoot}`;
421
+ const ledger = coordinator && coordinator.ledgerFile ? ` (ledger: ${coordinator.ledgerFile})` : '';
422
+ // 메인 checkout 거절은 막다른 길로 보이지 않게 다음 행동을 덧붙인다(병합 뒤 분리 경로).
423
+ const next = code === 'main-worktree-source-write'
424
+ ? ` | next: ${scope.mainWorktreeNext(coordinator, blueprintDir)}`
425
+ : '';
426
+ return { block: true, reason: `${gateLabelPrefix(code)}commit blocked: ${code}: ${where}${ledger}${next}` };
427
+ }
428
+ return {
429
+ block: true,
430
+ reason: `${gateLabelPrefix('out-of-scope')}commit blocked: files outside affected_paths: ${detail}`,
431
+ };
432
+ }
433
+ /**
434
+ * `-a` 없는 `git commit`과 같은 staged 목록으로 scope를 판정한다.
435
+ * pre-commit hook은 명령 문자열이 없으므로 여기만 호출한다.
436
+ *
437
+ * @param {{ repoRoot: string, deps?: CommitHookDeps | null }} opts
438
+ * @param {string} opts.repoRoot - 커밋이 일어나는 checkout 절대 경로
439
+ * @param {CommitHookDeps | null} [opts.deps] - 테스트 seam(readCurrent·stagedFiles 등)
440
+ * @returns {{ block: false } | { block: true, reason: string }} allow면 block false,
441
+ * worker lease 부재·원장 손상·out-of-scope면 block true와 reason
442
+ */
443
+ function evaluateStaged({ repoRoot, deps }) {
444
+ return evaluateIndex({ repoRoot, deps, all: false });
445
+ }
446
+ /**
447
+ * PreToolUse 훅이 git commit 명령의 scope를 판정한다. worker cwd에서는
448
+ * pointer 유무보다 resolveEffectiveTask를 먼저 본다 — prepare 직후·--set 전
449
+ * 창에서도 lease affected_paths와 fail-closed가 적용돼야 한다.
450
+ * 커밋으로 탐지된 뒤에는 `evaluateStaged`와 같은 판정부를 쓴다(`-a`만 목록이 다름).
451
+ *
452
+ * @param {{ command: unknown, repoRoot: string, deps?: CommitHookDeps | null }} opts
453
+ * @param {unknown} opts.command - 탐지 대상 shell 명령 문자열
454
+ * @param {string} opts.repoRoot - 커밋이 일어나는 checkout 절대 경로
455
+ * @param {CommitHookDeps | null} [opts.deps] - 테스트 seam(readCurrent·stagedFiles 등)
456
+ * @returns {{ block: false } | { block: true, reason: string }} allow면 block false,
457
+ * worker lease 부재·원장 손상·out-of-scope면 block true와 reason
458
+ */
459
+ function evaluateCommit({ command, repoRoot, deps }) {
460
+ const d = mergeCommitHookDeps(deps);
461
+ const worktree = commitWorktree(command, repoRoot);
462
+ const judgment = detect(command, realResolveAlias(worktree), 0);
463
+ if (!judgment.commit)
464
+ return { block: false };
465
+ return evaluateIndex({ repoRoot: worktree, deps: d, all: judgment.all });
466
+ }
467
+ module.exports = {
468
+ isGitCommit, readAffectedPaths, evaluateCommit, evaluateStaged,
469
+ realStagedFiles, realTrackedModified, realMainRepoCurrent,
470
+ };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+ const templates = require("./templates");
3
+ const { normalizeAuthoredLines, parseIntentBody } = templates;
4
+ const commitSha = require("./commit-sha");
5
+ const { buildStableProvenance } = commitSha;
6
+ function asRecord(value) {
7
+ return value;
8
+ }
9
+ // subject와 body는 프로젝트가 document field에 쓰는 commit convention을 따름;
10
+ // 구조만 Bouncer 소유. identifier와 path는 제목·본문에 넣지 않음 — blueprint
11
+ // 문서와 PR body에 있다. 기계가 읽는 식별자는 메시지 끝 Git trailer 두 줄로만
12
+ // 붙인다. 본문에 섞으면 제목 규약과 충돌하고, trailer가 아니면 cherry-pick·
13
+ // explain 소비자가 같은 키를 찾지 못한다.
14
+ // Subject: 대상 task title (없으면 blueprint title). Body: task 문서가 저작한
15
+ // 배경·의도와 변경 요약. verification title은 실행 증적이지 메시지 저작물이
16
+ // 아니므로 사용하지 않는다. 새 필드가 없는 기존 task는 제목만으로 읽는다.
17
+ // commit 경로(`bouncer commit`)가 이 빌더를 쓴다. finalize 마감 메시지는
18
+ // buildFinalizeCommitMessage — task 문서 필드를 넣지 않는다.
19
+ function authoredContainsBouncerTrailer(lines) {
20
+ // Git trailer는 줄 앞의 정확한 키다. 문장 한가운데 언급이 아니라 저작 필드가
21
+ // 이미 trailer 한 줄을 들고 있으면, 생성기가 같은 키를 또 붙여 중복이 된다.
22
+ return lines.some((line) => /^(?:Bouncer-Task|Bouncer-Intent)\s*:/.test(line));
23
+ }
24
+ function buildCommitMessage(docs, taskUnit) {
25
+ const bp = asRecord(docs.blueprintIndex && docs.blueprintIndex.data);
26
+ const bouncer = asRecord(bp.bouncer || {});
27
+ const type = bouncer.commit_type || 'feat';
28
+ const taskTitle = taskUnit && taskUnit.tasks && taskUnit.tasks.data
29
+ ? asRecord(taskUnit.tasks.data).title
30
+ : undefined;
31
+ // 대상 묶음이 없거나 title이 비면 blueprint로 떨어뜨려 빈 subject를 만들지 않음.
32
+ const subjectTitle = (typeof taskTitle === 'string' && taskTitle.trim())
33
+ ? taskTitle.trim()
34
+ : bp.title;
35
+ const taskData = taskUnit && taskUnit.tasks && taskUnit.tasks.data
36
+ ? asRecord(taskUnit.tasks.data)
37
+ : (docs.tasks && docs.tasks.data ? asRecord(docs.tasks.data) : {});
38
+ const taskBouncer = taskData && taskData.bouncer && typeof taskData.bouncer === 'object'
39
+ ? asRecord(taskData.bouncer)
40
+ : {};
41
+ const intent = normalizeAuthoredLines(taskBouncer.commit_intent, 'commit_intent');
42
+ const summary = normalizeAuthoredLines(taskBouncer.commit_summary, 'commit_summary');
43
+ const bodyLines = [...intent, ...summary];
44
+ // 저작 필드가 이미 Bouncer trailer를 들고 있으면 한 줄을 더 붙여 키가 두 번
45
+ // 나온다. Git trailer는 키당 한 줄이므로 중복을 지우지 않고 생성을 거절한다.
46
+ if (authoredContainsBouncerTrailer(bodyLines)) {
47
+ throw new Error('commit message authored fields already contain a Bouncer-Task or Bouncer-Intent trailer');
48
+ }
49
+ const provenance = buildStableProvenance({
50
+ epicId: taskBouncer.epic_id,
51
+ blueprintId: taskBouncer.blueprint_id,
52
+ taskId: taskBouncer.id,
53
+ });
54
+ const body = bodyLines.map((t) => `- ${t}`);
55
+ const lines = [`${type}: ${subjectTitle}`];
56
+ if (body.length)
57
+ lines.push('', ...body);
58
+ // trailer는 본문과 빈 줄로 구분한다. subject만 있어도 같은 구분자를 써서
59
+ // Git이 제목을 trailer 블록으로 붙이지 않게 한다.
60
+ lines.push('', ...provenance.trailers);
61
+ return lines.join('\n');
62
+ }
63
+ // finalize 마감 커밋: subject와 body 모두 blueprint에서 읽는다. Intent를
64
+ // 파싱할 수 없으면 task 일부를 주워 메시지를 만드는 대신 즉시 실패시킨다.
65
+ // task title·verification bullet을 넣으면 이미 남긴 task 커밋과 겹친다.
66
+ function buildFinalizeCommitMessage(docs) {
67
+ const bp = asRecord(docs.blueprintIndex && docs.blueprintIndex.data);
68
+ const bouncer = asRecord(bp.bouncer || {});
69
+ const type = bouncer.commit_type || 'feat';
70
+ const intent = parseIntentBody(docs.blueprintIndex && docs.blueprintIndex.body);
71
+ const body = intent.map((t) => `- ${t}`);
72
+ const lines = [`${type}: ${bp.title}`];
73
+ if (body.length)
74
+ lines.push('', ...body);
75
+ return lines.join('\n');
76
+ }
77
+ module.exports = { buildCommitMessage, buildFinalizeCommitMessage };
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+ /**
3
+ * task 커밋·explain.task_commits에 쓰는 짧은 SHA 길이.
4
+ * 그래프 토큰·프론트매터 표기를 8자리로 통일한다. 이 저장소 규모에서
5
+ * 충돌 위험은 낮고, git의 가변 abbrev(7+)보다 계약이 단순하다.
6
+ */
7
+ const COMMIT_SHA_LEN = 8;
8
+ const contextIds = require("./context-ids");
9
+ const { validateSlug, formatStableIntentId, formatStableTaskId } = contextIds;
10
+ const THREE_DIGIT_ID = /^\d{3}$/;
11
+ const TASKS_ID = /^TASKS-(\d{3})$/;
12
+ /**
13
+ * git 객체 이름을 소문자 8자리 hex로 정규화한다.
14
+ * 7–40자리 hex만 받고, 그보다 짧거나 비hex면 null.
15
+ *
16
+ * @param {unknown} value - rev-parse 결과 또는 기존 필드
17
+ * @returns {string | null}
18
+ */
19
+ function normalizeCommitSha(value) {
20
+ // YAML이 순수 숫자 sha를 number로 읽을 수 있어 문자열로 맞춘다.
21
+ if (typeof value === 'number') {
22
+ if (!Number.isInteger(value) || value < 0)
23
+ return null;
24
+ value = String(value);
25
+ }
26
+ if (typeof value !== 'string')
27
+ return null;
28
+ const hex = value.trim().toLowerCase();
29
+ if (!/^[0-9a-f]{7,40}$/.test(hex))
30
+ return null;
31
+ if (hex.length < COMMIT_SHA_LEN)
32
+ return null;
33
+ return hex.slice(0, COMMIT_SHA_LEN);
34
+ }
35
+ /**
36
+ * epic·blueprint key 한 쌍의 형식(번호 / slug)을 키 모양으로 판정한다.
37
+ * 한 자리·네 자리·number는 패딩하거나 잘라 맞추지 않는다 — 잘못된 ID를
38
+ * 추측해 고치면 다른 Epic·Blueprint의 커밋이 같은 ref로 묶인다.
39
+ * 형식이 섞인 쌍(번호 epic + slug blueprint)은 한 문서가 두 체계에 걸치므로 거절한다.
40
+ *
41
+ * @param {unknown} epicId - epic_id (`ddd` 또는 epic slug)
42
+ * @param {unknown} blueprintId - blueprint_id (`ddd` 또는 blueprint slug)
43
+ * @returns {'numeric' | 'slug'} 판정한 형식
44
+ * @throws {Error} 어느 쪽 형식도 아니거나 두 key의 형식이 다를 때
45
+ */
46
+ function detectKeyFormat(epicId, blueprintId) {
47
+ const isDigits = (v) => typeof v === 'string' && THREE_DIGIT_ID.test(v);
48
+ const isSlug = (v) => typeof v === 'string' && validateSlug(v) === null;
49
+ if (isDigits(epicId)) {
50
+ if (!isDigits(blueprintId))
51
+ throw new Error('blueprint_id must be a three-digit id');
52
+ return 'numeric';
53
+ }
54
+ if (isSlug(epicId)) {
55
+ if (!isSlug(blueprintId))
56
+ throw new Error('blueprint_id must be a slug');
57
+ return 'slug';
58
+ }
59
+ throw new Error('epic_id must be a three-digit id or a slug');
60
+ }
61
+ /**
62
+ * `TASKS-NNN`에서 숫자만 취한다. `TASKS-1`을 `001`로 만들거나 접두를
63
+ * 붙이지 않는다. 잘못된 값을 보정하면 다른 task의 trailer가 된다.
64
+ *
65
+ * @param {unknown} value - tasks.md `bouncer.id`
66
+ * @returns {string} 세 자리 task 번호
67
+ */
68
+ function requireTasksDigits(value) {
69
+ if (typeof value !== 'string') {
70
+ throw new Error('task id must be TASKS-NNN');
71
+ }
72
+ const match = TASKS_ID.exec(value);
73
+ if (!match) {
74
+ throw new Error('task id must be TASKS-NNN');
75
+ }
76
+ return match[1];
77
+ }
78
+ /**
79
+ * Epic·Blueprint·Task 식별자로 stable Task/Intent ref와 Git trailer 두 줄을 만든다.
80
+ * 형식은 key 모양으로 판정한다(번호 `ddd` 또는 slug). 구형 입력의 출력은 이전과 바이트까지 같다.
81
+ * 입력이 정본 형식이 아니거나 형식이 섞이면 커밋 전에 거절한다.
82
+ *
83
+ * @param {object} ids - task frontmatter에서 읽은 식별자
84
+ * @param {unknown} ids.epicId - `bouncer.epic_id` (`ddd` 또는 epic slug)
85
+ * @param {unknown} ids.blueprintId - `bouncer.blueprint_id` (`ddd` 또는 blueprint slug)
86
+ * @param {unknown} ids.taskId - `bouncer.id` (`TASKS-NNN`)
87
+ * @returns {{task: string, intent: string, trailers: [string, string]}}
88
+ * task는 `EPIC-ddd/BP-ddd/TASK-ddd` 또는 `<epic>/<bp>/ddd`, intent는 task와 같은 형식의 blueprint ID,
89
+ * trailers는 `Bouncer-Task` 다음 `Bouncer-Intent` 한 줄씩
90
+ * @throws {Error} key 형식 위반·혼합, `TASKS-NNN`이 아닌 task id
91
+ */
92
+ function buildStableProvenance({ epicId, blueprintId, taskId }) {
93
+ const format = detectKeyFormat(epicId, blueprintId);
94
+ const task = requireTasksDigits(taskId);
95
+ const keys = { format, epicKey: epicId, blueprintKey: blueprintId };
96
+ const intent = formatStableIntentId(keys);
97
+ const taskRef = formatStableTaskId({ ...keys, task });
98
+ return {
99
+ task: taskRef,
100
+ intent,
101
+ trailers: [
102
+ `Bouncer-Task: ${taskRef}`,
103
+ `Bouncer-Intent: ${intent}`,
104
+ ],
105
+ };
106
+ }
107
+ module.exports = { COMMIT_SHA_LEN, normalizeCommitSha, buildStableProvenance };