@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/bouncer +26 -0
- package/bin/bouncer-root +25 -0
- package/bin/build-stamp.js +78 -0
- package/bin/register-path.js +71 -0
- package/dist/.build-stamp +1 -0
- package/dist/approval-snapshot.js +99 -0
- package/dist/bouncer-root.js +80 -0
- package/dist/canonical-json.js +37 -0
- package/dist/cli-commit-guard-command.js +74 -0
- package/dist/cli-config-command.js +47 -0
- package/dist/cli-context-review-command.js +273 -0
- package/dist/cli-current-command.js +374 -0
- package/dist/cli-dispatch-command.js +119 -0
- package/dist/cli-doc-commands.js +352 -0
- package/dist/cli-epic-command.js +130 -0
- package/dist/cli-flags.js +50 -0
- package/dist/cli-git-commands.js +931 -0
- package/dist/cli-intent-command.js +491 -0
- package/dist/cli-metrics-command.js +134 -0
- package/dist/cli-ontology-command.js +109 -0
- package/dist/cli-project-commands.js +580 -0
- package/dist/cli-retro-command.js +138 -0
- package/dist/cli-review-command.js +251 -0
- package/dist/cli-review-dispatch-command.js +338 -0
- package/dist/cli-usage.js +72 -0
- package/dist/cli.js +70 -0
- package/dist/codex-agents.js +189 -0
- package/dist/command-argv.js +125 -0
- package/dist/commit-guard.js +72 -0
- package/dist/commit-hook.js +470 -0
- package/dist/commit-message.js +77 -0
- package/dist/commit-sha.js +107 -0
- package/dist/commit.js +519 -0
- package/dist/composite-gates.js +129 -0
- package/dist/comprehension.js +127 -0
- package/dist/config.js +298 -0
- package/dist/context-ids.js +288 -0
- package/dist/context-migration-rewrite.js +421 -0
- package/dist/context-ontology.js +746 -0
- package/dist/context-review-contracts.js +2 -0
- package/dist/context-review-projection.js +147 -0
- package/dist/context-review-state.js +824 -0
- package/dist/context-review-store.js +602 -0
- package/dist/context-review-transitions.js +412 -0
- package/dist/coordinate-advance.js +253 -0
- package/dist/coordinate-next.js +998 -0
- package/dist/coordinate-output.js +57 -0
- package/dist/coordinator-command-contracts.js +2 -0
- package/dist/coordinator-commands.js +1908 -0
- package/dist/coordinator-fanin.js +1021 -0
- package/dist/coordinator-git.js +121 -0
- package/dist/coordinator-input.js +37 -0
- package/dist/coordinator-ledger.js +381 -0
- package/dist/coordinator-prepare.js +1047 -0
- package/dist/coordinator-repair.js +1416 -0
- package/dist/coordinator-verification.js +42 -0
- package/dist/coordinator-worker.js +388 -0
- package/dist/coordinator.js +7 -0
- package/dist/cross-drive.js +85 -0
- package/dist/current.js +1148 -0
- package/dist/dag-shape.js +93 -0
- package/dist/doc-shape-contracts.js +241 -0
- package/dist/drive-metrics.js +691 -0
- package/dist/epic-index.js +347 -0
- package/dist/epic-registry.js +453 -0
- package/dist/execute-prepare.js +262 -0
- package/dist/execution-contracts.js +46 -0
- package/dist/execution-dag.js +148 -0
- package/dist/finalize-checks.js +177 -0
- package/dist/finalize-collect.js +343 -0
- package/dist/finalize-comprehension.js +116 -0
- package/dist/finalize-digest-collect.js +388 -0
- package/dist/finalize-digest-model.js +595 -0
- package/dist/finalize-digest.js +164 -0
- package/dist/finalize-explain.js +176 -0
- package/dist/finalize-pr.js +610 -0
- package/dist/finalize-release-main.js +652 -0
- package/dist/finalize.js +714 -0
- package/dist/frontmatter.js +22 -0
- package/dist/git-ops.js +49 -0
- package/dist/graph-exec.js +179 -0
- package/dist/graph-scope.js +268 -0
- package/dist/graphify.js +995 -0
- package/dist/guards.js +82 -0
- package/dist/import-git.js +78 -0
- package/dist/import-history.js +392 -0
- package/dist/import-render.js +59 -0
- package/dist/import-types.js +2 -0
- package/dist/init.js +571 -0
- package/dist/intent-bundle.js +936 -0
- package/dist/intent-provenance.js +927 -0
- package/dist/layout.js +131 -0
- package/dist/lease.js +145 -0
- package/dist/legacy-comments.js +71 -0
- package/dist/migrate-context-slugs.js +262 -0
- package/dist/migrate-merge-legacy-epics.js +392 -0
- package/dist/migrate-review-scope.js +144 -0
- package/dist/migrate-task-layout.js +194 -0
- package/dist/path-overlap.js +38 -0
- package/dist/paths.js +45 -0
- package/dist/plan-approve.js +218 -0
- package/dist/plan-inspect.js +575 -0
- package/dist/plan-prerequisites.js +197 -0
- package/dist/plan-snapshot-contract.js +98 -0
- package/dist/plan-snapshot.js +138 -0
- package/dist/plugin-root.js +237 -0
- package/dist/posix-path.js +14 -0
- package/dist/pre-commit-hook.js +170 -0
- package/dist/print-dispatch.js +285 -0
- package/dist/render.js +8 -0
- package/dist/retention-migration.js +595 -0
- package/dist/retro-read.js +259 -0
- package/dist/retro-sources.js +318 -0
- package/dist/review-dispatch.js +1054 -0
- package/dist/review-policy.js +28 -0
- package/dist/review-record.js +447 -0
- package/dist/review-repair-state.js +52 -0
- package/dist/review-snapshot.js +659 -0
- package/dist/run-detach.js +165 -0
- package/dist/run-preflight.js +368 -0
- package/dist/runtime-state.js +1038 -0
- package/dist/scaffold.js +533 -0
- package/dist/schema.js +294 -0
- package/dist/scope.js +1073 -0
- package/dist/seed-worktree.js +926 -0
- package/dist/session-graph.js +337 -0
- package/dist/subagents.js +86 -0
- package/dist/symbol-index.js +1390 -0
- package/dist/task-brief-hash.js +34 -0
- package/dist/task-commits.js +124 -0
- package/dist/tasks-docs.js +192 -0
- package/dist/templates.js +469 -0
- package/dist/time.js +21 -0
- package/dist/validate-docs.js +302 -0
- package/dist/validate-gates-finalize.js +303 -0
- package/dist/validate-gates-plan.js +720 -0
- package/dist/validate-gates-run.js +420 -0
- package/dist/validate-gates-shared.js +124 -0
- package/dist/validate-gates.js +87 -0
- package/dist/validate-sections.js +654 -0
- package/dist/validate-structural.js +347 -0
- package/dist/validate.js +417 -0
- package/dist/verification-contracts.js +26 -0
- package/dist/verification-evidence.js +233 -0
- package/dist/verification-execution.js +163 -0
- package/dist/verification-identity.js +413 -0
- package/dist/verification-target.js +51 -0
- package/dist/verification.js +383 -0
- package/dist/windows-npm.js +38 -0
- package/package.json +32 -4
- package/resources/graphify-compat.json +9 -0
- package/vendor/README.md +23 -0
- package/vendor/js-yaml.LICENSE +21 -0
- package/vendor/js-yaml.js +3022 -0
package/dist/init.js
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const schema = require("./schema");
|
|
6
|
+
const { detectLegacyFormat } = schema;
|
|
7
|
+
const codexAgents = require("./codex-agents");
|
|
8
|
+
const { ensureCodexAgents, shouldEnsureCodexAgents } = codexAgents;
|
|
9
|
+
const graphify = require("./graphify");
|
|
10
|
+
const { setupGraphify, upgradeGraphify, readGraphifyLock, loadCompatManifest, } = graphify;
|
|
11
|
+
const config = require("./config");
|
|
12
|
+
const { readConfig, DEFAULT_VERIFY_ALLOWLIST, } = config;
|
|
13
|
+
const preCommitHook = require("./pre-commit-hook");
|
|
14
|
+
const { installPreCommitHook } = preCommitHook;
|
|
15
|
+
const epicRegistry = require("./epic-registry");
|
|
16
|
+
const { REGISTRY_REL, COMMON_EPICS, readEpicRegistry, expandProfile, planRegistry, serializeEpicRegistry, } = epicRegistry;
|
|
17
|
+
const epicIndex = require("./epic-index");
|
|
18
|
+
const { listEpicDirNames } = epicIndex;
|
|
19
|
+
const scaffold = require("./scaffold");
|
|
20
|
+
const { scaffoldEpic } = scaffold;
|
|
21
|
+
// default source_dirs용 고정 probe 순서. init 시점에 존재하는 directory만
|
|
22
|
+
// 남기며, 이 목록 순서가 config에 쓰이는 순서. SOURCE_DIR_CANDIDATES를
|
|
23
|
+
// import하는 test와 동기 유지. test/tests는 구현 그래프 seed가 되지 않게
|
|
24
|
+
// TEST_DIR_CANDIDATES로 분리한다.
|
|
25
|
+
const SOURCE_DIR_CANDIDATES = ['src', 'lib', 'app', 'packages', 'scripts'];
|
|
26
|
+
const TEST_DIR_CANDIDATES = ['test', 'tests'];
|
|
27
|
+
function detectSourceDirs(repoRoot) {
|
|
28
|
+
return SOURCE_DIR_CANDIDATES.filter((name) => {
|
|
29
|
+
try {
|
|
30
|
+
return fs.statSync(path.join(repoRoot, name)).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch (_e) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function detectTestDirs(repoRoot) {
|
|
38
|
+
return TEST_DIR_CANDIDATES.filter((name) => {
|
|
39
|
+
try {
|
|
40
|
+
return fs.statSync(path.join(repoRoot, name)).isDirectory();
|
|
41
|
+
}
|
|
42
|
+
catch (_e) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// git 실패는 호출부에서 미해결로 수렴한다. 예외를 밖으로 던지지 않는다 —
|
|
48
|
+
// detached HEAD·원격 없는 저장소·git 없는 디렉터리가 같은 경로를 탄다.
|
|
49
|
+
function gitSymbolicRefShort(repoRoot, ref) {
|
|
50
|
+
try {
|
|
51
|
+
const out = execFileSync('git', ['symbolic-ref', '--short', ref], {
|
|
52
|
+
cwd: repoRoot,
|
|
53
|
+
encoding: 'utf8',
|
|
54
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
55
|
+
});
|
|
56
|
+
const name = String(out).trim();
|
|
57
|
+
return name || null;
|
|
58
|
+
}
|
|
59
|
+
catch (_e) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// origin/HEAD가 있으면 그 값에서 origin/ 접두사만 뗀다. 실패하면 현재 HEAD.
|
|
64
|
+
// 둘 다 실패하면 null — develop/main으로 추측하지 않는다.
|
|
65
|
+
function detectDefaultBranch(repoRoot) {
|
|
66
|
+
const originHead = gitSymbolicRefShort(repoRoot, 'refs/remotes/origin/HEAD');
|
|
67
|
+
if (originHead)
|
|
68
|
+
return originHead.replace(/^origin\//, '');
|
|
69
|
+
return gitSymbolicRefShort(repoRoot, 'HEAD');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* package.json에 문자열 `scripts.build`가 있는지 본다.
|
|
73
|
+
* 읽기·파싱 실패는 "build 없음"으로 수렴한다 — 이 값은 선택 기본값일 뿐이라
|
|
74
|
+
* package.json 문제로 init을 실패시키지 않는다.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
77
|
+
* @returns {boolean} 문자열 build 스크립트가 있으면 true
|
|
78
|
+
*/
|
|
79
|
+
function hasBuildScript(repoRoot) {
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8');
|
|
83
|
+
}
|
|
84
|
+
catch (_e) {
|
|
85
|
+
// 파일 없음·권한 없음 등 읽기 실패는 모두 "build 없음"으로 본다.
|
|
86
|
+
// 기본값을 못 넣을 뿐 데이터 손실이 없어 안전하다.
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const pkg = JSON.parse(raw);
|
|
91
|
+
const scripts = pkg && typeof pkg === 'object' ? pkg.scripts : null;
|
|
92
|
+
return !!scripts && typeof scripts === 'object'
|
|
93
|
+
&& Object.hasOwn(scripts, 'build') && typeof scripts.build === 'string';
|
|
94
|
+
}
|
|
95
|
+
catch (_e) {
|
|
96
|
+
// 깨진 JSON(SyntaxError)만 흡수한다. 다른 스크립트 이름으로 추측하지 않는다.
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function defaultConfig(repoRoot) {
|
|
101
|
+
// base_branch와 pr.base는 같은 탐지 결과를 쓴다. 갈라지는 경로를 만들지 않는다.
|
|
102
|
+
const detected = detectDefaultBranch(repoRoot);
|
|
103
|
+
const testDirs = detectTestDirs(repoRoot);
|
|
104
|
+
return {
|
|
105
|
+
// scaffold 시점에만 감지 — ready bootstrap에서는 다시 쓰지 않음.
|
|
106
|
+
source_dirs: detectSourceDirs(repoRoot),
|
|
107
|
+
// 라이브러리 기본(install:false)은 enabled만 true — bin은 설치 성공 시에만 기록.
|
|
108
|
+
// CLI는 install:true가 기본이라 실패 시 enabled:false로 내려 soft-fail한다.
|
|
109
|
+
// 실재하는 test/tests만 test_dirs에 넣어 구현 그래프 seed와 분리한다.
|
|
110
|
+
// 없으면 키를 생략해 예전 두-scope config와 같은 형태로 유지한다.
|
|
111
|
+
graphify: {
|
|
112
|
+
enabled: true,
|
|
113
|
+
...(testDirs.length ? { test_dirs: testDirs } : {}),
|
|
114
|
+
},
|
|
115
|
+
verify: 'npm test',
|
|
116
|
+
// 신규 저장소는 기본 허용 목록을 파일에 박아 둔다. 이후 기본값이
|
|
117
|
+
// 바뀌어도 이미 init된 저장소의 실행 경계를 조용히 넓히지 않기 위함.
|
|
118
|
+
verify_allowlist: [...DEFAULT_VERIFY_ALLOWLIST],
|
|
119
|
+
...(detected ? { base_branch: detected } : {}),
|
|
120
|
+
// build 스크립트가 있는 새 저장소만 worktree 준비 끝에 빌드를 돌려, 빌드
|
|
121
|
+
// 산출물(gitignore 대상)이 없는 worktree에서 verify가 깨지지 않게 한다.
|
|
122
|
+
// 기존 config에는 쓰지 않는다(준비된 저장소는 다시 쓰지 않는 계약).
|
|
123
|
+
...(hasBuildScript(repoRoot) ? { worktree: { setup: 'npm run build' } } : {}),
|
|
124
|
+
autonomy: 'auto',
|
|
125
|
+
// draft는 항상 둔다. base는 탐지 성공 시에만 — 실패를 develop/main으로
|
|
126
|
+
// 채우지 않고 키를 비워 /bouncer-init이 묻게 한다.
|
|
127
|
+
// 기존 config에 남은 pr.labels는 알 수 없는 키로 읽고, create 인자로 쓰지 않는다.
|
|
128
|
+
pr: detected ? { draft: true, base: detected } : { draft: true },
|
|
129
|
+
// host별 model ID용 placeholder slot. 모든 값은 "inherit"로 시작해 init이
|
|
130
|
+
// 편집 가능한 형태를 보여 주되 model을 고정하지 않음; resolveSubagentModel은
|
|
131
|
+
// "inherit"를 parent-session fallback으로 처리.
|
|
132
|
+
// provider block은 분리 — host마다 model namespace가 다름
|
|
133
|
+
// (Claude / Cursor / Codex / Antigravity slug는 호환되지 않음).
|
|
134
|
+
// plan의 context reviewer도 같은 inherit 슬롯이 있어야 init이 편집 자리를
|
|
135
|
+
// 보여 준다. 키가 없으면 resolve는 null로 수렴해 동작은 같지만, 사용자가
|
|
136
|
+
// 모델을 고를 자리가 사라진다. 이미 init을 돌린 소비자 config는 건드리지
|
|
137
|
+
// 않는다(부모 세션 상속 = 같은 동작).
|
|
138
|
+
// run이 위임하는 coordinator도 같은 이유로 슬롯을 갖는다 — drive 전체를
|
|
139
|
+
// 끌고 가는 역할이라 worker와 다른 모델을 고르고 싶을 수 있다.
|
|
140
|
+
subagents: {
|
|
141
|
+
claude: {
|
|
142
|
+
'bouncer-reviewer': 'inherit',
|
|
143
|
+
'bouncer-implementer': 'inherit',
|
|
144
|
+
'bouncer-debugger': 'inherit',
|
|
145
|
+
'bouncer-context-reviewer': 'inherit',
|
|
146
|
+
'bouncer-coordinator': 'inherit',
|
|
147
|
+
},
|
|
148
|
+
cursor: {
|
|
149
|
+
'bouncer-reviewer': 'inherit',
|
|
150
|
+
'bouncer-implementer': 'inherit',
|
|
151
|
+
'bouncer-debugger': 'inherit',
|
|
152
|
+
'bouncer-context-reviewer': 'inherit',
|
|
153
|
+
'bouncer-coordinator': 'inherit',
|
|
154
|
+
},
|
|
155
|
+
codex: {
|
|
156
|
+
'bouncer-reviewer': 'inherit',
|
|
157
|
+
'bouncer-implementer': 'inherit',
|
|
158
|
+
'bouncer-debugger': 'inherit',
|
|
159
|
+
'bouncer-context-reviewer': 'inherit',
|
|
160
|
+
'bouncer-coordinator': 'inherit',
|
|
161
|
+
},
|
|
162
|
+
antigravity: {
|
|
163
|
+
'bouncer-reviewer': 'inherit',
|
|
164
|
+
'bouncer-implementer': 'inherit',
|
|
165
|
+
'bouncer-debugger': 'inherit',
|
|
166
|
+
'bouncer-context-reviewer': 'inherit',
|
|
167
|
+
'bouncer-coordinator': 'inherit',
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// Bundle root. OKF §11은 index file 중 여기에만 frontmatter를 허용;
|
|
173
|
+
// §6은 body 형태를 `* [Title](url) - description` 그룹으로 고정한다. 행의
|
|
174
|
+
// description은 epic frontmatter 정본에서 scaffold가 append/replace하고,
|
|
175
|
+
// S13은 정본과 행의 요약 불일치를 검사한다.
|
|
176
|
+
// bouncer_schema는 번들 루트에만 둔다(문서마다 두지 않음). EMPTY_CONTEXT_INDEX와
|
|
177
|
+
// 같은 frontmatter여야 ensureEpicIndexEntry가 파일을 새로 만들 때도 일치한다.
|
|
178
|
+
const CONTEXT_INDEX = `---
|
|
179
|
+
okf_version: "0.1"
|
|
180
|
+
bouncer_schema: "0.1"
|
|
181
|
+
---
|
|
182
|
+
# Epics
|
|
183
|
+
|
|
184
|
+
<!-- bouncer scaffold epic이 여기에 한 줄씩 추가합니다 (OKF §6).
|
|
185
|
+
validate는 S13으로 디렉터리·frontmatter description ↔ 목록 요약 일치를 검사합니다.
|
|
186
|
+
* [00x 제목](epics/00x-slug/index.md) - 한 줄 설명 -->
|
|
187
|
+
`;
|
|
188
|
+
function writeFile(repoRoot, rel, content, created) {
|
|
189
|
+
const abs = path.join(repoRoot, rel);
|
|
190
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
191
|
+
fs.writeFileSync(abs, content);
|
|
192
|
+
created.push(rel);
|
|
193
|
+
}
|
|
194
|
+
// advisory(+ 동의 시 마커 블록 쓰기) 목록. 신규 graphify venv는 git common
|
|
195
|
+
// directory 아래라 작업 트리 gitignore가 필요 없다. `.bouncer/.venv/`는
|
|
196
|
+
// 레거시 설치와 비-git 폴백만 아직 작업 트리에 남을 수 있어 제안에 둔다.
|
|
197
|
+
const SUGGESTED_IGNORES = [
|
|
198
|
+
'node_modules/',
|
|
199
|
+
'graphify-out/',
|
|
200
|
+
'.worktrees/',
|
|
201
|
+
'.bouncer/.venv/',
|
|
202
|
+
// coordinator 원장(`.bouncer/runtime/coordinator.json`)은 실행 상태다.
|
|
203
|
+
// scope.ts의 RUNTIME_ARTIFACTS와 같은 항목을 유지해야 새 저장소가 처음부터
|
|
204
|
+
// 원장을 추적하지 않는다.
|
|
205
|
+
'.bouncer/runtime/',
|
|
206
|
+
];
|
|
207
|
+
const GITIGNORE_MARKER_START = '# bouncer';
|
|
208
|
+
const GITIGNORE_MARKER_END = '# /bouncer';
|
|
209
|
+
function gitignoreSuggestions({ repoRoot }) {
|
|
210
|
+
let ignored = [];
|
|
211
|
+
try {
|
|
212
|
+
ignored = fs.readFileSync(path.join(repoRoot, '.gitignore'), 'utf8')
|
|
213
|
+
.split('\n')
|
|
214
|
+
.map((line) => line.trim().replace(/\/+$/, ''))
|
|
215
|
+
.filter((line) => line && !line.startsWith('#'));
|
|
216
|
+
}
|
|
217
|
+
catch (_e) {
|
|
218
|
+
ignored = [];
|
|
219
|
+
}
|
|
220
|
+
return SUGGESTED_IGNORES.filter((entry) => !ignored.includes(entry.replace(/\/+$/, '')));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* `# bouncer` … `# /bouncer` 마커 블록만 갱신한다.
|
|
224
|
+
* 마커 밖 사용자 줄은 읽기만 하고 바꾸지 않는다 — 동의 신호(writeGitignore)가
|
|
225
|
+
* 있을 때만 호출된다.
|
|
226
|
+
* 마커 탐지는 줄 전체가 정확히 일치할 때만(substring `indexOf` 금지) —
|
|
227
|
+
* `# bouncer note` 같은 사용자 주석을 마커로 오인하지 않기 위함.
|
|
228
|
+
*/
|
|
229
|
+
function writeGitignoreMarkerBlock(repoRoot) {
|
|
230
|
+
const abs = path.join(repoRoot, '.gitignore');
|
|
231
|
+
const block = `${GITIGNORE_MARKER_START}\n${SUGGESTED_IGNORES.join('\n')}\n${GITIGNORE_MARKER_END}`;
|
|
232
|
+
let content;
|
|
233
|
+
try {
|
|
234
|
+
content = fs.readFileSync(abs, 'utf8');
|
|
235
|
+
}
|
|
236
|
+
catch (_e) {
|
|
237
|
+
fs.writeFileSync(abs, `${block}\n`);
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
const startMatch = /^# bouncer$/m.exec(content);
|
|
241
|
+
const endMatch = /^# \/bouncer$/m.exec(content);
|
|
242
|
+
if (startMatch && endMatch && endMatch.index > startMatch.index) {
|
|
243
|
+
const before = content.slice(0, startMatch.index);
|
|
244
|
+
const after = content.slice(endMatch.index + GITIGNORE_MARKER_END.length);
|
|
245
|
+
fs.writeFileSync(abs, `${before}${block}${after}`);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
const sep = content.length === 0 || content.endsWith('\n') ? '' : '\n';
|
|
249
|
+
fs.writeFileSync(abs, `${content}${sep}${block}\n`);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
function graphifyEnabledIsTrue(config) {
|
|
253
|
+
const graphify = config && config.graphify;
|
|
254
|
+
return !!(config
|
|
255
|
+
&& graphify
|
|
256
|
+
&& typeof graphify === 'object'
|
|
257
|
+
&& graphify.enabled === true);
|
|
258
|
+
}
|
|
259
|
+
function inspectBootstrap({ repoRoot }) {
|
|
260
|
+
if (detectLegacyFormat({ repoRoot }).legacy)
|
|
261
|
+
return 'legacy';
|
|
262
|
+
const bouncerAbs = path.join(repoRoot, '.bouncer');
|
|
263
|
+
if (!fs.existsSync(bouncerAbs))
|
|
264
|
+
return 'missing';
|
|
265
|
+
// 파싱은 config.ts에 맡기고 유효성만 여기서 본다. 배열/원시값을 ready로
|
|
266
|
+
// 올리면 이후 승격이 비객체에 키를 심는다. 깨진 JSON·비객체는 missing이
|
|
267
|
+
// 아니라 partial — .bouncer가 이미 있으므로 재생성하면 기존 내용을 덮는다.
|
|
268
|
+
const config = readConfig(repoRoot);
|
|
269
|
+
const rec = config
|
|
270
|
+
&& typeof config === 'object'
|
|
271
|
+
&& !Array.isArray(config)
|
|
272
|
+
? config
|
|
273
|
+
: null;
|
|
274
|
+
// base_branch는 탐지 실패 시 생략한다. 키 부재를 partial로 보면 첫 init
|
|
275
|
+
// 직후 재실행이 기존 config를 덮지 못하고 partial-bouncer-state로 멈춘다.
|
|
276
|
+
const valid = rec
|
|
277
|
+
&& Array.isArray(rec.source_dirs)
|
|
278
|
+
&& typeof rec.verify === 'string';
|
|
279
|
+
if (valid)
|
|
280
|
+
return 'ready';
|
|
281
|
+
return 'partial';
|
|
282
|
+
}
|
|
283
|
+
function lockNeedsUpgrade(repoRoot) {
|
|
284
|
+
const lock = readGraphifyLock({ repoRoot });
|
|
285
|
+
const manifest = loadCompatManifest();
|
|
286
|
+
if (!lock.ok || !manifest.ok)
|
|
287
|
+
return false;
|
|
288
|
+
const expectedPkg = String(manifest.value.install_spec).split('==')[1] || '';
|
|
289
|
+
return lock.value.cli_version !== manifest.value.cli_version
|
|
290
|
+
|| lock.value.graph_schema_version !== manifest.value.graph_schema_version
|
|
291
|
+
|| (expectedPkg !== '' && lock.value.package_version !== expectedPkg);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* init이 쓸 등록부 계획을 파일을 쓰기 전에 만든다. 등록부가 이미 있으면(깨진 것 포함)
|
|
295
|
+
* null을 돌려 건드리지 않는다 — 프로필 변경은 사용자 승인 행위라 init 재실행의 몫이 아니다.
|
|
296
|
+
* 공통 slug 중복 디렉터리는 실패로 접어 호출자가 아무것도 쓰지 않고 멈추게 한다.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
299
|
+
* @param {string} profile - 프로필 이름(standard | domain-only)
|
|
300
|
+
* @param {number} domainEpicLimit - 도메인 epic 상한
|
|
301
|
+
* @returns {null | {ok: true, plan: RegistryPlan} | {ok: false, cause: string}} 계획 / 실패 / 건드리지 않음
|
|
302
|
+
* @throws {Error} 알 수 없는 프로필·잘못된 상한(expandProfile)이거나 ID가 부족할 때
|
|
303
|
+
*/
|
|
304
|
+
function planRegistryForInit(repoRoot, profile, domainEpicLimit) {
|
|
305
|
+
if (readEpicRegistry(repoRoot).status !== 'missing')
|
|
306
|
+
return null;
|
|
307
|
+
const base = expandProfile(profile, { domainEpicLimit });
|
|
308
|
+
try {
|
|
309
|
+
return { ok: true, plan: planRegistry(base, listEpicDirNames(repoRoot)) };
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
// planRegistry가 던지는 중복 디렉터리 오류만 실패 보고로 흡수한다. ID 소진 같은
|
|
313
|
+
// 다른 오류는 숨기면 안 되므로 다시 던진다.
|
|
314
|
+
const message = error.message;
|
|
315
|
+
if (message.startsWith('more than one existing directory'))
|
|
316
|
+
return { ok: false, cause: message };
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* 계획한 등록부를 쓰고 새 공통 epic만 scaffold한다. 기존 epic 파일은 건드리지 않는다.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} repoRoot - 저장소 루트 절대 경로
|
|
324
|
+
* @param {RegistryPlan} plan - planRegistryForInit 결과
|
|
325
|
+
* @param {string} timestamp - scaffold 시각
|
|
326
|
+
* @param {string[]} created - 만든 상대 경로 누적 목록
|
|
327
|
+
* @returns {void}
|
|
328
|
+
*/
|
|
329
|
+
function applyRegistryPlan(repoRoot, plan, timestamp, created) {
|
|
330
|
+
writeFile(repoRoot, REGISTRY_REL, serializeEpicRegistry(plan.registry), created);
|
|
331
|
+
for (const { slug, id } of plan.scaffold) {
|
|
332
|
+
// 공통 epic 설명은 scaffoldEpic이 요구하는 ASCII 문장을 그대로 쓴다.
|
|
333
|
+
const common = COMMON_EPICS.find((c) => c.slug === slug);
|
|
334
|
+
const made = scaffoldEpic({ repoRoot, epicId: id, name: slug, timestamp, description: common.description });
|
|
335
|
+
for (const rel of made) {
|
|
336
|
+
if (!created.includes(rel))
|
|
337
|
+
created.push(rel);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function defaultLauncherPath() {
|
|
342
|
+
// dist에서 한 단계 위가 package root다. source ts의 __dirname이 아니라 emit 위치를
|
|
343
|
+
// 기준으로 삼아 hook이 checkout과 npm package가 공유하는 launcher를 가리킨다.
|
|
344
|
+
return path.resolve(__dirname, '..', 'bin', 'bouncer');
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 동의 플래그가 있을 때만 hook을 설치하고 결과 필드를 붙인다.
|
|
348
|
+
* 플래그 없는 기존 호출은 필드 자체가 없어야 테스트 스냅샷이 깨지지 않는다.
|
|
349
|
+
*
|
|
350
|
+
* @param {object} opts
|
|
351
|
+
* @param {string} opts.repoRoot - 대상 저장소
|
|
352
|
+
* @param {boolean} [opts.want] - true일 때만 설치
|
|
353
|
+
* @param {string} [opts.launcherPath] - 생략 시 package의 bin/bouncer
|
|
354
|
+
* @returns {object} `preCommitHook`과 있으면 `preCommitHookWarning`, 아니면 빈 객체
|
|
355
|
+
*/
|
|
356
|
+
function preCommitHookFields({ repoRoot, want, launcherPath, }) {
|
|
357
|
+
if (want !== true)
|
|
358
|
+
return {};
|
|
359
|
+
const installed = installPreCommitHook({
|
|
360
|
+
repoRoot,
|
|
361
|
+
launcherPath: launcherPath || defaultLauncherPath(),
|
|
362
|
+
});
|
|
363
|
+
return {
|
|
364
|
+
preCommitHook: installed.preCommitHook,
|
|
365
|
+
...(installed.warning ? { preCommitHookWarning: installed.warning } : {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function init({ repoRoot, timestamp, graphify, promote, writeGitignore, seedCodexAgents, upgradeGraphify: wantUpgradeGraphify, preCommitHook: wantPreCommitHook, launcherPath, profile = 'standard', domainEpicLimit = 0, } = {}) {
|
|
369
|
+
const bootstrap = inspectBootstrap({ repoRoot });
|
|
370
|
+
// partial/legacy는 설치·승격·gitignore 쓰기를 시도하지 않는다 — 기존 반환 유지.
|
|
371
|
+
if (bootstrap === 'legacy') {
|
|
372
|
+
const legacy = detectLegacyFormat({ repoRoot });
|
|
373
|
+
return { ok: false, created: [], skipped: true, reason: legacy.reason };
|
|
374
|
+
}
|
|
375
|
+
if (bootstrap === 'partial') {
|
|
376
|
+
return { ok: false, created: [], skipped: true, reason: 'partial-bouncer-state' };
|
|
377
|
+
}
|
|
378
|
+
// 등록부 계획은 어떤 파일을 쓰기 전에 끝낸다 — 중복 slug 실패가 반쯤 쓴 상태를 남기지 않게.
|
|
379
|
+
const registryPlan = planRegistryForInit(repoRoot, profile, domainEpicLimit);
|
|
380
|
+
if (registryPlan && !registryPlan.ok) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false, created: [], skipped: true,
|
|
383
|
+
reason: 'epic-registry-duplicate-common-slug', cause: registryPlan.cause,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
// 라이브러리 기본 install:false — 테스트가 실제 pip을 타지 않게 한다.
|
|
387
|
+
// CLI cmdInit만 install:true를 기본으로 넘긴다.
|
|
388
|
+
const wantInstall = !!(graphify && graphify.install === true);
|
|
389
|
+
const setup = (graphify && typeof graphify.setup === 'function')
|
|
390
|
+
? graphify.setup
|
|
391
|
+
: setupGraphify;
|
|
392
|
+
const upgrade = (graphify && typeof graphify.upgrade === 'function')
|
|
393
|
+
? graphify.upgrade
|
|
394
|
+
: upgradeGraphify;
|
|
395
|
+
const wantUpgrade = wantUpgradeGraphify === true;
|
|
396
|
+
const wantPromote = promote === true;
|
|
397
|
+
const wantWriteGitignore = writeGitignore === true;
|
|
398
|
+
const wantSeedCodex = shouldEnsureCodexAgents(repoRoot, seedCodexAgents === true);
|
|
399
|
+
// 동의 시 마커 블록을 먼저 쓰고, 제안 목록은 최종 파일 기준으로 계산한다.
|
|
400
|
+
let gitignoreWritten = false;
|
|
401
|
+
if (wantWriteGitignore) {
|
|
402
|
+
writeGitignoreMarkerBlock(repoRoot);
|
|
403
|
+
gitignoreWritten = true;
|
|
404
|
+
}
|
|
405
|
+
const suggestions = gitignoreSuggestions({ repoRoot: repoRoot });
|
|
406
|
+
// timestamp는 예전 파생 memory seed 시각용이었다. seed를 끊었으므로
|
|
407
|
+
// 쓰지 않지만, session-graph 등 호출 계약은 유지한다.
|
|
408
|
+
void timestamp;
|
|
409
|
+
if (bootstrap === 'ready') {
|
|
410
|
+
// 파생 memory·config seed는 하지 않는다. 이미 있는 파일은 그대로 두고
|
|
411
|
+
// context bundle과 Graphify 상태만 다룬다.
|
|
412
|
+
const created = [];
|
|
413
|
+
if (registryPlan) {
|
|
414
|
+
applyRegistryPlan(repoRoot, registryPlan.plan, timestamp, created);
|
|
415
|
+
}
|
|
416
|
+
if (wantSeedCodex) {
|
|
417
|
+
ensureCodexAgents({ repoRoot: repoRoot, created });
|
|
418
|
+
}
|
|
419
|
+
// 승격은 객체에만 키를 심는다. readConfig는 배열/원시값도 통과시키므로
|
|
420
|
+
// 여기서 걸러야 비객체에 graphify.enabled를 쓰다가 파일을 잘못된 형태로
|
|
421
|
+
// 덮지 않는다. null은 승격 no-op (existing이 falsy면 write 생략).
|
|
422
|
+
const raw = readConfig(repoRoot);
|
|
423
|
+
const existing = (raw && typeof raw === 'object' && !Array.isArray(raw))
|
|
424
|
+
? raw
|
|
425
|
+
: null;
|
|
426
|
+
const alreadyEnabled = graphifyEnabledIsTrue(existing);
|
|
427
|
+
let graphifyPromotion;
|
|
428
|
+
let graphifyInstall;
|
|
429
|
+
let graphifyUpgrade;
|
|
430
|
+
let graphifyUpgradeAvailable;
|
|
431
|
+
if (wantUpgrade) {
|
|
432
|
+
// 명시적 flag만 공유 venv와 graph를 바꾼다. 일반 재실행은 lock을 보존한다.
|
|
433
|
+
graphifyUpgrade = upgrade({
|
|
434
|
+
repoRoot,
|
|
435
|
+
rebuild: graphify && graphify.rebuild,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
else if (lockNeedsUpgrade(repoRoot)) {
|
|
439
|
+
graphifyUpgradeAvailable = true;
|
|
440
|
+
}
|
|
441
|
+
// enabled가 이미 true면 승격 경로 자체가 없다 — config를 건드리지 않는다.
|
|
442
|
+
// --upgrade-graphify는 위 분기에서 이미 처리했다.
|
|
443
|
+
if (!alreadyEnabled) {
|
|
444
|
+
if (!wantPromote) {
|
|
445
|
+
// --promote-graphify 없이 기존 config가 바뀌는 경로는 없다.
|
|
446
|
+
graphifyPromotion = 'candidate';
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
// 승격은 graphify.enabled(+ 설치 성공 시 bin)만 바꾼다. 파일 재생성 금지.
|
|
450
|
+
if (wantInstall) {
|
|
451
|
+
graphifyInstall = setup({ repoRoot });
|
|
452
|
+
}
|
|
453
|
+
if (existing) {
|
|
454
|
+
// 이번 실행에서 설치를 시도했는데 실패하면 enabled를 올리지 않는다.
|
|
455
|
+
// 승격만(install 없음)이면 기존처럼 enabled:true.
|
|
456
|
+
const installOk = !wantInstall || !!(graphifyInstall
|
|
457
|
+
&& (graphifyInstall.status === 'installed' || graphifyInstall.status === 'reused')
|
|
458
|
+
&& typeof graphifyInstall.bin === 'string'
|
|
459
|
+
&& graphifyInstall.bin);
|
|
460
|
+
const nextGraphify = {
|
|
461
|
+
...(existing.graphify && typeof existing.graphify === 'object'
|
|
462
|
+
? existing.graphify
|
|
463
|
+
: {}),
|
|
464
|
+
enabled: installOk,
|
|
465
|
+
};
|
|
466
|
+
if (installOk
|
|
467
|
+
&& graphifyInstall
|
|
468
|
+
&& (graphifyInstall.status === 'installed' || graphifyInstall.status === 'reused')
|
|
469
|
+
&& typeof graphifyInstall.bin === 'string'
|
|
470
|
+
&& graphifyInstall.bin) {
|
|
471
|
+
nextGraphify.bin = graphifyInstall.bin;
|
|
472
|
+
}
|
|
473
|
+
existing.graphify = nextGraphify;
|
|
474
|
+
fs.writeFileSync(path.join(repoRoot, '.bouncer', 'config.json'), `${JSON.stringify(existing, null, 2)}\n`);
|
|
475
|
+
}
|
|
476
|
+
graphifyPromotion = 'promoted';
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
ok: true,
|
|
481
|
+
created,
|
|
482
|
+
skipped: created.length === 0,
|
|
483
|
+
reason: registryPlan ? 'epic-registry-added'
|
|
484
|
+
: (created.length ? 'codex-agents-seeded' : 'already-initialized'),
|
|
485
|
+
gitignoreSuggestions: suggestions,
|
|
486
|
+
gitignoreWritten,
|
|
487
|
+
...(graphifyPromotion ? { graphifyPromotion } : {}),
|
|
488
|
+
...(graphifyInstall ? { graphifyInstall } : {}),
|
|
489
|
+
...(graphifyUpgrade ? { graphifyUpgrade } : {}),
|
|
490
|
+
...(graphifyUpgradeAvailable ? { graphifyUpgradeAvailable: true } : {}),
|
|
491
|
+
...preCommitHookFields({
|
|
492
|
+
repoRoot: repoRoot,
|
|
493
|
+
want: wantPreCommitHook,
|
|
494
|
+
launcherPath,
|
|
495
|
+
}),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
// 신규 부트스트랩
|
|
499
|
+
const created = [];
|
|
500
|
+
const config = defaultConfig(repoRoot);
|
|
501
|
+
let graphifyInstall;
|
|
502
|
+
let graphifyUpgrade;
|
|
503
|
+
if (wantUpgrade) {
|
|
504
|
+
graphifyUpgrade = upgrade({
|
|
505
|
+
repoRoot,
|
|
506
|
+
rebuild: graphify && graphify.rebuild,
|
|
507
|
+
});
|
|
508
|
+
if (graphifyUpgrade
|
|
509
|
+
&& (graphifyUpgrade.status === 'upgraded' || graphifyUpgrade.status === 'installed')
|
|
510
|
+
&& typeof graphifyUpgrade.bin === 'string'
|
|
511
|
+
&& graphifyUpgrade.bin) {
|
|
512
|
+
config.graphify = {
|
|
513
|
+
...config.graphify,
|
|
514
|
+
enabled: true,
|
|
515
|
+
bin: graphifyUpgrade.bin,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
else if (graphifyUpgrade && graphifyUpgrade.status === 'failed') {
|
|
519
|
+
config.graphify = { ...config.graphify, enabled: false };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
else if (wantInstall) {
|
|
523
|
+
graphifyInstall = setup({ repoRoot });
|
|
524
|
+
if (graphifyInstall
|
|
525
|
+
&& (graphifyInstall.status === 'installed' || graphifyInstall.status === 'reused')
|
|
526
|
+
&& typeof graphifyInstall.bin === 'string'
|
|
527
|
+
&& graphifyInstall.bin) {
|
|
528
|
+
// bin은 설치 성공 시에만 붙인다 — defaultConfig가 넣은 test_dirs는 유지.
|
|
529
|
+
config.graphify = {
|
|
530
|
+
...config.graphify,
|
|
531
|
+
enabled: true,
|
|
532
|
+
bin: graphifyInstall.bin,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
// 설치 실패는 soft-fail: ok는 유지하고 enabled만 끈다. test_dirs는 유지.
|
|
537
|
+
config.graphify = { ...config.graphify, enabled: false };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
writeFile(repoRoot, '.bouncer/context/index.md', CONTEXT_INDEX, created);
|
|
541
|
+
writeFile(repoRoot, '.bouncer/config.json', `${JSON.stringify(config, null, 2)}\n`, created);
|
|
542
|
+
if (registryPlan) {
|
|
543
|
+
applyRegistryPlan(repoRoot, registryPlan.plan, timestamp, created);
|
|
544
|
+
}
|
|
545
|
+
if (wantSeedCodex) {
|
|
546
|
+
ensureCodexAgents({ repoRoot: repoRoot, created });
|
|
547
|
+
}
|
|
548
|
+
// gitignoreSuggestions와 같은 advisory layer: detection이 아무것도 못 찾으면
|
|
549
|
+
// operator에게 source_dirs를 채우라고 알려 빈 graph(BP-001 missing warning)에
|
|
550
|
+
// opt-in하지 않게 함. dir을 찾았으면 생략.
|
|
551
|
+
// base_branch도 같다 — 미해결이면 키를 쓰지 않고 신호만 실어 /bouncer-init이 묻는다.
|
|
552
|
+
return {
|
|
553
|
+
ok: true, created, skipped: false, reason: 'initialized',
|
|
554
|
+
gitignoreSuggestions: suggestions,
|
|
555
|
+
gitignoreWritten,
|
|
556
|
+
...(graphifyInstall ? { graphifyInstall } : {}),
|
|
557
|
+
...(graphifyUpgrade ? { graphifyUpgrade } : {}),
|
|
558
|
+
...(config.source_dirs.length === 0 ? { sourceDirsUnresolved: true } : {}),
|
|
559
|
+
...(!Object.prototype.hasOwnProperty.call(config, 'base_branch')
|
|
560
|
+
? { baseBranchUnresolved: true } : {}),
|
|
561
|
+
...preCommitHookFields({
|
|
562
|
+
repoRoot: repoRoot,
|
|
563
|
+
want: wantPreCommitHook,
|
|
564
|
+
launcherPath,
|
|
565
|
+
}),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
module.exports = {
|
|
569
|
+
init, inspectBootstrap, gitignoreSuggestions, SUGGESTED_IGNORES,
|
|
570
|
+
SOURCE_DIR_CANDIDATES, TEST_DIR_CANDIDATES,
|
|
571
|
+
};
|