@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFileSync: realExecFileSync } = require('node:child_process');
|
|
5
|
+
const MARKER = '# bouncer-pre-commit v1';
|
|
6
|
+
const INTERNAL_COMMIT_ENV = 'BOUNCER_INTERNAL_COMMIT';
|
|
7
|
+
/**
|
|
8
|
+
* POSIX 단일 인용으로 경로를 hook 본문에 넣는다. 공백·글롭이 셸에서
|
|
9
|
+
* 다시 해석되면 다른 바이너리를 실행하므로, 런처 절대 경로는 데이터로만 둔다.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} value - 인용할 절대 경로
|
|
12
|
+
* @returns {string} POSIX 단일 인용 문자열
|
|
13
|
+
*/
|
|
14
|
+
function shSingleQuote(value) {
|
|
15
|
+
return `'${value.replace(/'/g, '\'\\\'\'')}'`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* git hook 둘째 줄 마커인지 본다. 내용이 같아도 위치가 다르면 사용자 hook으로
|
|
19
|
+
* 본다 — 덮어쓰면 기존 검사가 사라지고 chained 이전이 유실된다.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} content - hook 파일 본문
|
|
22
|
+
* @returns {boolean} 둘째 줄이 `# bouncer-pre-commit v1`이면 true
|
|
23
|
+
*/
|
|
24
|
+
function hasBouncerMarker(content) {
|
|
25
|
+
const lines = content.split(/\r?\n/);
|
|
26
|
+
return lines[1] === MARKER;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* PATH의 `bouncer`를 먼저 쓰고, 없을 때만 설치 시점 런처로 폴백하는 hook 본문.
|
|
30
|
+
* 이전 hook은 내부 커밋 env와 무관하게 먼저 실행한다 — 표식은 Bouncer
|
|
31
|
+
* 검사만 건너뛰라는 뜻이지 사용자 hook을 끄라는 뜻이 아니다.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} launcherPath - 기록할 런처 절대 경로
|
|
34
|
+
* @returns {string} POSIX sh hook 본문
|
|
35
|
+
*/
|
|
36
|
+
function renderHookScript(launcherPath) {
|
|
37
|
+
const quoted = shSingleQuote(launcherPath);
|
|
38
|
+
return `#!/bin/sh
|
|
39
|
+
${MARKER}
|
|
40
|
+
dir=$(dirname -- "$0")
|
|
41
|
+
prev="$dir/pre-commit.bouncer-prev"
|
|
42
|
+
if [ -f "$prev" ]; then
|
|
43
|
+
if [ -x "$prev" ]; then
|
|
44
|
+
"$prev" "$@"
|
|
45
|
+
else
|
|
46
|
+
sh "$prev" "$@"
|
|
47
|
+
fi
|
|
48
|
+
st=$?
|
|
49
|
+
if [ "$st" -ne 0 ]; then
|
|
50
|
+
exit "$st"
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
53
|
+
if [ "\${${INTERNAL_COMMIT_ENV}-}" = "1" ]; then
|
|
54
|
+
exit 0
|
|
55
|
+
fi
|
|
56
|
+
if command -v bouncer >/dev/null 2>&1; then
|
|
57
|
+
bouncer_bin=bouncer
|
|
58
|
+
elif [ -x ${quoted} ]; then
|
|
59
|
+
bouncer_bin=${quoted}
|
|
60
|
+
else
|
|
61
|
+
echo "bouncer pre-commit: CLI not found; allowing commit" >&2
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
"$bouncer_bin" commit-guard --staged
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* git spawn env에 내부 커밋 표식을 붙인다. hook이 이 값이면 commit-guard를
|
|
69
|
+
* 건너뛴다 — G17을 이미 통과한 bouncer 커밋이 인덱스 잔여로 다시 막히지
|
|
70
|
+
* 않게 하기 위함이다.
|
|
71
|
+
*
|
|
72
|
+
* @param {NodeJS.ProcessEnv} [base] - 복사할 env. 생략 시 process.env
|
|
73
|
+
* @returns {NodeJS.ProcessEnv} BOUNCER_INTERNAL_COMMIT=1 이 설정된 사본
|
|
74
|
+
*/
|
|
75
|
+
function internalCommitEnv(base = process.env) {
|
|
76
|
+
return { ...base, [INTERNAL_COMMIT_ENV]: '1' };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Git common dir의 hooks/pre-commit에 Bouncer hook을 설치한다.
|
|
80
|
+
* core.hooksPath가 있으면 git이 그 디렉터리만 보므로 기본 hooks/에 쓰면
|
|
81
|
+
* 죽은 파일이 되고, 사용자 hook을 옮기지도 못한 채 검사가 빠진다.
|
|
82
|
+
* 이미 `pre-commit.bouncer-prev`가 있고 현재 파일이 비-Bouncer면 throw한다.
|
|
83
|
+
* Linux `renameSync`는 대상을 덮어쓰므로, 막지 않으면 첫 설치가 저장한
|
|
84
|
+
* 사용자 hook이 사라진다. 공개 반환 상태를 늘리지 않기 위해 새 값을 두지 않는다.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {string} opts.repoRoot - 대상 저장소 루트
|
|
88
|
+
* @param {string} opts.launcherPath - PATH에 bouncer가 없을 때 쓸 런처 절대 경로
|
|
89
|
+
* @param {{ execFileSync?: Function, fs?: object }} [opts.deps] - git·fs 테스트 seam
|
|
90
|
+
* @returns {{ preCommitHook: PreCommitHookState, warning?: string }}
|
|
91
|
+
* installed: 새 파일. chained: 기존 hook을 pre-commit.bouncer-prev로 옮김.
|
|
92
|
+
* already-installed: 마커가 있어 본문만 갱신. skipped-hooks-path: core.hooksPath.
|
|
93
|
+
* skipped-no-git: git common dir을 못 찾음.
|
|
94
|
+
* prev가 이미 있으면 throw하므로 이 객체는 반환되지 않는다.
|
|
95
|
+
*/
|
|
96
|
+
function installPreCommitHook({ repoRoot, launcherPath, deps, }) {
|
|
97
|
+
const execFileSync = (cmd, args, opts) => String((deps && deps.execFileSync ? deps.execFileSync : realExecFileSync)(cmd, args, opts));
|
|
98
|
+
const io = {
|
|
99
|
+
existsSync: fs.existsSync,
|
|
100
|
+
readFileSync: fs.readFileSync,
|
|
101
|
+
writeFileSync: fs.writeFileSync,
|
|
102
|
+
renameSync: fs.renameSync,
|
|
103
|
+
chmodSync: fs.chmodSync,
|
|
104
|
+
mkdirSync: fs.mkdirSync,
|
|
105
|
+
...(deps && deps.fs ? deps.fs : {}),
|
|
106
|
+
};
|
|
107
|
+
let commonDir;
|
|
108
|
+
try {
|
|
109
|
+
// 1. common dir이 있어야 worktree여도 같은 hooks/에 설치된다.
|
|
110
|
+
commonDir = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
111
|
+
cwd: repoRoot,
|
|
112
|
+
encoding: 'utf8',
|
|
113
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
114
|
+
}).trim();
|
|
115
|
+
}
|
|
116
|
+
catch (_error) {
|
|
117
|
+
// git이 아니거나 common dir 조회 실패만 흡수. 그 외 I/O는 호출부에 둔다.
|
|
118
|
+
return { preCommitHook: 'skipped-no-git' };
|
|
119
|
+
}
|
|
120
|
+
if (!commonDir)
|
|
121
|
+
return { preCommitHook: 'skipped-no-git' };
|
|
122
|
+
let hooksPath = '';
|
|
123
|
+
try {
|
|
124
|
+
hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], {
|
|
125
|
+
cwd: repoRoot,
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
}).trim();
|
|
129
|
+
}
|
|
130
|
+
catch (_error) {
|
|
131
|
+
// git config --get 은 unset이면 exit 1. 설치를 진행한다.
|
|
132
|
+
}
|
|
133
|
+
if (hooksPath) {
|
|
134
|
+
return {
|
|
135
|
+
preCommitHook: 'skipped-hooks-path',
|
|
136
|
+
warning: `core.hooksPath is set (${hooksPath}); skipping Bouncer pre-commit hook install`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const hookDir = path.join(path.resolve(repoRoot, commonDir), 'hooks');
|
|
140
|
+
const hookFile = path.join(hookDir, 'pre-commit');
|
|
141
|
+
const prevFile = path.join(hookDir, 'pre-commit.bouncer-prev');
|
|
142
|
+
io.mkdirSync(hookDir, { recursive: true });
|
|
143
|
+
let state = 'installed';
|
|
144
|
+
if (io.existsSync(hookFile)) {
|
|
145
|
+
const existing = io.readFileSync(hookFile, 'utf8');
|
|
146
|
+
if (hasBouncerMarker(existing)) {
|
|
147
|
+
state = 'already-installed';
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// 사용자 hook을 지우지 않는다. 새 본문이 이 파일을 먼저 실행한다.
|
|
151
|
+
// prev가 있으면 renameSync가 그 내용을 덮어쓴다(POSIX). 첫 설치가
|
|
152
|
+
// 맡긴 원본을 지키기 위해 거부하고, 현재 hook도 쓰지 않는다.
|
|
153
|
+
if (io.existsSync(prevFile)) {
|
|
154
|
+
throw new Error(`pre-commit.bouncer-prev already exists at ${prevFile}; refusing to overwrite the saved user hook`);
|
|
155
|
+
}
|
|
156
|
+
io.renameSync(hookFile, prevFile);
|
|
157
|
+
state = 'chained';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
io.writeFileSync(hookFile, renderHookScript(launcherPath));
|
|
161
|
+
// git은 실행 비트가 없는 hook을 건너뛴다. 내용만 있으면 검사가 침묵한다.
|
|
162
|
+
io.chmodSync(hookFile, 0o755);
|
|
163
|
+
return { preCommitHook: state };
|
|
164
|
+
}
|
|
165
|
+
module.exports = {
|
|
166
|
+
installPreCommitHook,
|
|
167
|
+
internalCommitEnv,
|
|
168
|
+
MARKER,
|
|
169
|
+
INTERNAL_COMMIT_ENV,
|
|
170
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { isRecord, isEnoentError } = guards;
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const config = require("./config");
|
|
8
|
+
const { readConfig } = config;
|
|
9
|
+
const frontmatter = require("./frontmatter");
|
|
10
|
+
const { parseFrontmatter } = frontmatter;
|
|
11
|
+
const subagents = require("./subagents");
|
|
12
|
+
const { resolveSubagentModel } = subagents;
|
|
13
|
+
const codexAgents = require("./codex-agents");
|
|
14
|
+
const { pluginAgentsDir } = codexAgents;
|
|
15
|
+
const PRINT_ROLES = ['implementer', 'reviewer', 'debugger', 'context-reviewer', 'coordinator'];
|
|
16
|
+
/**
|
|
17
|
+
* Cursor print 식별 줄. 문구는 `rules/cursor-print-dispatch.md` 3항과 바이트가
|
|
18
|
+
* 같아야 한다. 역할이 named agent를 다시 띄우지 못하게 고정한 문장이다.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} role - implementer|reviewer|debugger|context-reviewer|coordinator
|
|
21
|
+
* @returns {string} prompt 첫 줄
|
|
22
|
+
*/
|
|
23
|
+
function identityLine(role) {
|
|
24
|
+
if (role === 'coordinator') {
|
|
25
|
+
// 식별 문구는 cursor-print-dispatch 3항과 바이트가 같아야 하므로 이어 붙인다.
|
|
26
|
+
return ('You are the dispatched bouncer-coordinator itself. '
|
|
27
|
+
+ 'Dispatch only your workers, each under rules/cursor-print-dispatch.md.');
|
|
28
|
+
}
|
|
29
|
+
return (`You are the dispatched bouncer-${role} itself. `
|
|
30
|
+
+ 'Do this role\'s work directly and never dispatch any Bouncer agent.');
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 플러그인 루트 절대 경로를 prompt에 실을 한 줄로 만든다. 문구는
|
|
34
|
+
* `rules/cursor-print-dispatch.md` 3항과 바이트가 같아야 한다 — 세션이
|
|
35
|
+
* `references/...`를 워크스페이스에서 찾지 않고 이 루트에 붙이게 한다.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} pluginRoot - 플러그인 루트 절대 경로
|
|
38
|
+
* @returns {string} `Plugin root: …` 한 줄
|
|
39
|
+
*/
|
|
40
|
+
function pluginRootLine(pluginRoot) {
|
|
41
|
+
return (`Plugin root: ${pluginRoot}. `
|
|
42
|
+
+ 'Resolve plugin-relative paths (rules/..., references/..., agents/...) against it.');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 역할 본문·controller 입력을 prompt 파일 순서로 붙인다. 식별 줄을 맨 앞에
|
|
46
|
+
* 두는 이유는 payload가 `---`로 시작하면 CLI가 옵션으로 읽기 때문이다.
|
|
47
|
+
* 루트 줄은 식별 줄 바로 뒤 — 탐색 없이 `references/`·`rules/`를 열게 한다.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ role: string, roleMarkdown: string, input: string, pluginRoot: string }} parts
|
|
50
|
+
* @param {string} parts.role - 짧은 역할 이름
|
|
51
|
+
* @param {string} parts.roleMarkdown - frontmatter가 있는 역할 문서 전체
|
|
52
|
+
* @param {string} parts.input - `--input` 파일 내용 그대로
|
|
53
|
+
* @param {string} parts.pluginRoot - 플러그인 루트 절대 경로(`agentsDir`의 부모)
|
|
54
|
+
* @returns {string} 디스크에 쓸 prompt 본문
|
|
55
|
+
*/
|
|
56
|
+
function assemblePrintPrompt({ role, roleMarkdown, input, pluginRoot, }) {
|
|
57
|
+
const { body } = parseFrontmatter(roleMarkdown);
|
|
58
|
+
const roleBody = body.replace(/^(?:\r?\n)+/, '').replace(/(?:\r?\n)+$/, '');
|
|
59
|
+
return `${identityLine(role)}\n\n${pluginRootLine(pluginRoot)}\n\n${roleBody}\n\n${input}`;
|
|
60
|
+
}
|
|
61
|
+
function fail(reason, cause, next, extra) {
|
|
62
|
+
return { ok: false, reason, cause, next, ...extra };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `.bouncer/config.json`이 cursor·print opt-in인지 본다. env provider 추론은
|
|
66
|
+
* 쓰지 않는다 — print는 명시 pin일 때만 켜지고, 그 외는 items 2-4다.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} repoRoot - 설정을 읽을 저장소 루트
|
|
69
|
+
* @returns {boolean} provider가 cursor이고 dispatch가 print일 때만 true
|
|
70
|
+
*/
|
|
71
|
+
function isCursorPrintEnabled(repoRoot) {
|
|
72
|
+
const cfg = readConfig(repoRoot);
|
|
73
|
+
if (!isRecord(cfg) || !isRecord(cfg.subagents))
|
|
74
|
+
return false;
|
|
75
|
+
return cfg.subagents.provider === 'cursor' && cfg.subagents.dispatch === 'print';
|
|
76
|
+
}
|
|
77
|
+
function pathKind(absPath) {
|
|
78
|
+
try {
|
|
79
|
+
const st = fs.statSync(absPath);
|
|
80
|
+
if (st.isFile())
|
|
81
|
+
return 'file';
|
|
82
|
+
if (st.isDirectory())
|
|
83
|
+
return 'dir';
|
|
84
|
+
return 'missing';
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// ENOENT만 입력 부재. EACCES를 missing으로 접으면 권한 오류가 잘못된
|
|
88
|
+
// `--input`/`--cwd` 안내가 된다.
|
|
89
|
+
if (isEnoentError(error))
|
|
90
|
+
return 'missing';
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* stdout jsonl에서 마지막 `type: "result"` 객체를 고른다. 중간 assistant 줄은
|
|
96
|
+
* report가 아니다 — 최종 result만 규칙 6항의 보고다.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw - stdout 파일 내용
|
|
99
|
+
* @returns {Record<string, unknown> | null} 마지막 result 또는 없음
|
|
100
|
+
*/
|
|
101
|
+
function lastResultEvent(raw) {
|
|
102
|
+
let last = null;
|
|
103
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
104
|
+
if (line.trim() === '')
|
|
105
|
+
continue;
|
|
106
|
+
let parsed;
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(line);
|
|
109
|
+
}
|
|
110
|
+
catch (_error) {
|
|
111
|
+
// stream-json이 아닌 줄은 건너뛴다. 한 줄 파손 때문에 전체 디스패치를
|
|
112
|
+
// result-missing으로 바꾸면 실제 result가 뒤에 있어도 잃는다.
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (isRecord(parsed) && parsed.type === 'result')
|
|
116
|
+
last = parsed;
|
|
117
|
+
}
|
|
118
|
+
return last;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Cursor print 한 건을 조립·실행하고 마지막 result를 돌려준다. Task
|
|
122
|
+
* subagent로는 절대 내려가지 않는다. cursor·print가 아니면 prompt 파일도
|
|
123
|
+
* 만들지 않는다.
|
|
124
|
+
*
|
|
125
|
+
* @param {{
|
|
126
|
+
* repoRoot: string, role: string, cwd: string, inputFile: string,
|
|
127
|
+
* outDir: string, deps?: PrintDispatchDeps
|
|
128
|
+
* }} opts
|
|
129
|
+
* @param {string} opts.repoRoot - `.bouncer/config.json`을 읽는 루트
|
|
130
|
+
* @param {string} opts.role - 짧은 역할 이름
|
|
131
|
+
* @param {string} opts.cwd - agent `--workspace`이자 spawn cwd
|
|
132
|
+
* @param {string} opts.inputFile - controller 입력 파일
|
|
133
|
+
* @param {string} opts.outDir - prompt·jsonl·log를 덮어쓸 디렉터리
|
|
134
|
+
* @param {PrintDispatchDeps} [opts.deps] - 테스트 seam (`agentBin`, `agentsDir`)
|
|
135
|
+
* @returns {PrintDispatchResult} 성공 객체 또는 `{ ok: false, reason, cause, next }`
|
|
136
|
+
*/
|
|
137
|
+
function runPrintDispatch({ repoRoot, role, cwd, inputFile, outDir, deps, }) {
|
|
138
|
+
// 1. 실행 전 경로. prompt를 쓰기 전에 거절해야 실패 run이 잔여 파일을 남기지 않는다.
|
|
139
|
+
if (pathKind(inputFile) !== 'file' || pathKind(cwd) !== 'dir') {
|
|
140
|
+
return fail('dispatch-input-invalid', '`--input` must be an existing file and `--cwd` an existing directory', 'create the input file and cwd, then retry `bouncer dispatch print`');
|
|
141
|
+
}
|
|
142
|
+
// 2. opt-in 아니면 여기서 끝. items 2-4(Task)로 조용히 내려가지 않는다.
|
|
143
|
+
if (!isCursorPrintEnabled(repoRoot)) {
|
|
144
|
+
return fail('print-dispatch-disabled', '`.bouncer/config.json` is not `subagents.provider: "cursor"` and `subagents.dispatch: "print"`', 'dispatch using `rules/subagent-model.md` items 2-4, or set those keys and retry');
|
|
145
|
+
}
|
|
146
|
+
let agentsDir;
|
|
147
|
+
if (deps && deps.agentsDir) {
|
|
148
|
+
agentsDir = deps.agentsDir;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
try {
|
|
152
|
+
agentsDir = pluginAgentsDir();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
// resolver의 Error(무효 BOUNCER_HOME, 유효 루트 없음)만 안내 가능한 실패로 바꾼다.
|
|
156
|
+
// 그 외 예외는 버그이므로 다시 던진다.
|
|
157
|
+
if (!(error instanceof Error))
|
|
158
|
+
throw error;
|
|
159
|
+
return fail('plugin-root-unresolved', error.message, 'set BOUNCER_HOME to the plugin install and retry');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// agentsDir 부모 = 플러그인 루트. 테스트 seam의 임시 root와 프로덕션 설치
|
|
163
|
+
// 루트가 같은 규칙으로 잡혀야 prompt의 Plugin root 줄이 탐색 없이 동작한다.
|
|
164
|
+
const pluginRoot = path.resolve(path.dirname(agentsDir));
|
|
165
|
+
const rolePath = path.join(agentsDir, `bouncer-${role}.md`);
|
|
166
|
+
let roleMarkdown;
|
|
167
|
+
try {
|
|
168
|
+
roleMarkdown = fs.readFileSync(rolePath, 'utf8');
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
if (isEnoentError(error)) {
|
|
172
|
+
return fail('role-document-invalid', `role document missing: ${rolePath}`, 'install the plugin agents directory or pass a valid --role');
|
|
173
|
+
}
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
let prompt;
|
|
177
|
+
try {
|
|
178
|
+
prompt = assemblePrintPrompt({
|
|
179
|
+
role,
|
|
180
|
+
roleMarkdown,
|
|
181
|
+
input: fs.readFileSync(inputFile, 'utf8'),
|
|
182
|
+
pluginRoot,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
// parseFrontmatter 실패(블록 없음)와 YAML 파손만 흡수한다. 그 외는 설정·IO 버그다.
|
|
187
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
188
|
+
if (/frontmatter|YAMLException|duplicated mapping/i.test(message)
|
|
189
|
+
|| (error instanceof Error && error.name === 'YAMLException')) {
|
|
190
|
+
return fail('role-document-invalid', `role document frontmatter is invalid: ${rolePath}`, 'fix the role markdown frontmatter, then retry');
|
|
191
|
+
}
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
const agentBin = deps && deps.agentBin ? deps.agentBin : 'agent';
|
|
195
|
+
// 3. login 확인. print argv보다 먼저 — 미로그인 세션에 prompt를 남기지 않기 위함.
|
|
196
|
+
const status = spawnSync(agentBin, ['status'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
shell: false,
|
|
199
|
+
});
|
|
200
|
+
const statusText = `${status.stdout || ''}${status.stderr || ''}`;
|
|
201
|
+
if (status.error
|
|
202
|
+
|| status.status !== 0
|
|
203
|
+
|| /not logged in/i.test(statusText)) {
|
|
204
|
+
return fail('agent-unavailable', status.error
|
|
205
|
+
? `cannot execute ${agentBin}: ${status.error.message}`
|
|
206
|
+
: 'agent status failed or reported not logged in', 'run `agent login`, then retry `bouncer dispatch print`');
|
|
207
|
+
}
|
|
208
|
+
const { model } = resolveSubagentModel({
|
|
209
|
+
repoRoot,
|
|
210
|
+
agentName: `bouncer-${role}`,
|
|
211
|
+
});
|
|
212
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
213
|
+
const promptPath = path.join(outDir, `bouncer-${role}.prompt.md`);
|
|
214
|
+
const stdoutPath = path.join(outDir, `bouncer-${role}.jsonl`);
|
|
215
|
+
const stderrPath = path.join(outDir, `bouncer-${role}.log`);
|
|
216
|
+
fs.writeFileSync(promptPath, prompt);
|
|
217
|
+
const argv = [
|
|
218
|
+
'--print',
|
|
219
|
+
'--force',
|
|
220
|
+
'--trust',
|
|
221
|
+
'--output-format',
|
|
222
|
+
'stream-json',
|
|
223
|
+
'--workspace',
|
|
224
|
+
cwd,
|
|
225
|
+
];
|
|
226
|
+
if (model !== null) {
|
|
227
|
+
argv.push('--model', model);
|
|
228
|
+
}
|
|
229
|
+
// `--`는 prompt가 `-`로 시작해도 옵션으로 읽히지 않게 한다. 내용은 argv
|
|
230
|
+
// 칸으로만 넘기고 `$(cat …)` 셸은 만들지 않는다.
|
|
231
|
+
argv.push('--', prompt);
|
|
232
|
+
// 4. stdout/stderr는 pipe reader가 아니라 fd. tee가 파이프를 붙잡고 남는 회귀를 피한다.
|
|
233
|
+
const stdinFd = fs.openSync('/dev/null', 'r');
|
|
234
|
+
const stdoutFd = fs.openSync(stdoutPath, 'w');
|
|
235
|
+
const stderrFd = fs.openSync(stderrPath, 'w');
|
|
236
|
+
let spawned;
|
|
237
|
+
try {
|
|
238
|
+
spawned = spawnSync(agentBin, argv, {
|
|
239
|
+
cwd,
|
|
240
|
+
stdio: [stdinFd, stdoutFd, stderrFd],
|
|
241
|
+
shell: false,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
fs.closeSync(stdinFd);
|
|
246
|
+
fs.closeSync(stdoutFd);
|
|
247
|
+
fs.closeSync(stderrFd);
|
|
248
|
+
}
|
|
249
|
+
const exitCode = spawned.status == null ? 1 : spawned.status;
|
|
250
|
+
if (spawned.error || exitCode !== 0) {
|
|
251
|
+
return fail('agent-exit-nonzero', spawned.error
|
|
252
|
+
? spawned.error.message
|
|
253
|
+
: `agent --print exited ${exitCode}`, `inspect ${stdoutPath} and ${stderrPath}`, { exit_code: exitCode, stdout: stdoutPath, stderr: stderrPath });
|
|
254
|
+
}
|
|
255
|
+
const stdoutRaw = fs.readFileSync(stdoutPath, 'utf8');
|
|
256
|
+
const resultEvent = lastResultEvent(stdoutRaw);
|
|
257
|
+
if (!resultEvent) {
|
|
258
|
+
return fail('result-missing', `no stream-json result event in ${stdoutPath}`, `inspect ${stdoutPath} and retry the dispatch`, { stdout: stdoutPath, stderr: stderrPath });
|
|
259
|
+
}
|
|
260
|
+
const report = typeof resultEvent.result === 'string' ? resultEvent.result : '';
|
|
261
|
+
if (resultEvent.is_error === true) {
|
|
262
|
+
return fail('result-error', 'the final result event has is_error true', 'fix the dispatch input from the report, then retry', { report, stdout: stdoutPath, stderr: stderrPath });
|
|
263
|
+
}
|
|
264
|
+
// transport가 성공해도 보고서 문자열이 없으면 coordinator가 판정할 보고가 없다.
|
|
265
|
+
// result-error 뒤에 두어 is_error + 빈 result는 기존대로 result-error로 남기고,
|
|
266
|
+
// trim은 판정에만 쓰며 성공 시 report 원문은 그대로 돌려준다.
|
|
267
|
+
if (report.trim().length === 0) {
|
|
268
|
+
return fail('report-missing', 'the final result event has no report string', `inspect ${stdoutPath} and retry the dispatch`, { stdout: stdoutPath, stderr: stderrPath });
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
ok: true,
|
|
272
|
+
role: role,
|
|
273
|
+
model,
|
|
274
|
+
exit_code: 0,
|
|
275
|
+
report,
|
|
276
|
+
prompt: promptPath,
|
|
277
|
+
stdout: stdoutPath,
|
|
278
|
+
stderr: stderrPath,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
module.exports = {
|
|
282
|
+
PRINT_ROLES,
|
|
283
|
+
assemblePrintPrompt,
|
|
284
|
+
runPrintDispatch,
|
|
285
|
+
};
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 경로는 emit된 scripts/lib/ 기준(tsc는 require 문자열을 재작성하지 않음).
|
|
3
|
+
const yaml = require('../vendor/js-yaml');
|
|
4
|
+
function renderDoc(data, body) {
|
|
5
|
+
const front = yaml.dump(data, { lineWidth: -1, sortKeys: false });
|
|
6
|
+
return `---\n${front}---\n${body}`;
|
|
7
|
+
}
|
|
8
|
+
module.exports = { renderDoc };
|