@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,998 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('node:crypto');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { execFileSync: realExecFileSync } = require('node:child_process');
|
|
6
|
+
const coordinator = require("./coordinator");
|
|
7
|
+
const schema = require("./schema");
|
|
8
|
+
const reviewRepairState = require("./review-repair-state");
|
|
9
|
+
const { hasReviewRepairDecision, reviewRoundExists } = reviewRepairState;
|
|
10
|
+
const { COORDINATE_FAILURE_HINTS } = coordinator;
|
|
11
|
+
const coordinatorLedger = require("./coordinator-ledger");
|
|
12
|
+
const { assertLeaseShape, loadLedgerBytes, projectCheckpoint } = coordinatorLedger;
|
|
13
|
+
const coordinatorWorker = require("./coordinator-worker");
|
|
14
|
+
const { ensureIntegrationCwd, isBlueprintReviewModeAt, reviewContractAt, taskBriefHashOf, readBouncerBlock, reportPathFor, workerCommitEvidence, workerScopeDirtyPaths, } = coordinatorWorker;
|
|
15
|
+
const coordinatorGit = require("./coordinator-git");
|
|
16
|
+
const { registeredIntegration, initialWorktreeState } = coordinatorGit;
|
|
17
|
+
const runtime = require("./runtime-state");
|
|
18
|
+
const { coordinatorPathsFor, runtimePaths } = runtime;
|
|
19
|
+
const configMod = require("./config");
|
|
20
|
+
const driveMetrics = require("./drive-metrics");
|
|
21
|
+
const plugin_root_1 = require("./plugin-root");
|
|
22
|
+
const execution_contracts_1 = require("./execution-contracts");
|
|
23
|
+
// 계약 카드를 싣는 action. 판단(judge)이나 worker 위임이 필요한 행동만 카드를 받는다.
|
|
24
|
+
// argv만 실행하면 끝나는 prepare·drive_tasks·integrate·verification_node·commit·done·none은
|
|
25
|
+
// 카드가 없다 — 응답을 키우지 않고, 그 행동의 규칙은 argv 자체가 담는다.
|
|
26
|
+
const CARD_IDS = [
|
|
27
|
+
'dispatch', 'implement', 'verify', 'review', 'report', 'revise', 'record', 'final_review', 'supplement',
|
|
28
|
+
'blocked',
|
|
29
|
+
];
|
|
30
|
+
const NEXT_FAILURE_HINTS = {
|
|
31
|
+
'worker-commit-stamp-mismatch': {
|
|
32
|
+
cause: 'The commit_sha stamp in the worker tasks.md differs from the first 8 chars of HEAD and no in-scope ' +
|
|
33
|
+
'change is left to commit.',
|
|
34
|
+
next: 'Do not rerun commit (nothing is left to commit). Report the stale stamp to the user, or `bouncer ' +
|
|
35
|
+
'coordinate revoke` and `prepare` the task again.',
|
|
36
|
+
},
|
|
37
|
+
'partial-closed': {
|
|
38
|
+
cause: 'This drive was partial-closed after the repair-wave limit.',
|
|
39
|
+
next: 'Report the preserved drive to the user; do not retry coordinate mutations on this blueprint.',
|
|
40
|
+
},
|
|
41
|
+
'terminal-verification-failed': {
|
|
42
|
+
cause: 'Terminal verification failed and that verification task is still verifying.',
|
|
43
|
+
next: 'Inspect the failure. If it was environmental, fix it and run ' +
|
|
44
|
+
'`bouncer coordinate reverify --task <terminal>`; ' +
|
|
45
|
+
'otherwise `bouncer coordinate repair`, or report the block to the user.',
|
|
46
|
+
},
|
|
47
|
+
'no-ready-task': {
|
|
48
|
+
cause: 'No prepared, recorded, ready, or integrable work remains, and the blueprint is not done.',
|
|
49
|
+
next: 'Run `bouncer coordinate status` and report the unexpected ledger combination; do not invent an action.',
|
|
50
|
+
},
|
|
51
|
+
'final-review-blocked': {
|
|
52
|
+
cause: 'The blueprint final review already recorded its one discovery round and the root review is not accepted.',
|
|
53
|
+
next: 'Show the open findings to the user and wait for their decision (note + deferred, or '
|
|
54
|
+
+ 'accepted-risk consent); do not run a delta round or repair.',
|
|
55
|
+
},
|
|
56
|
+
'review-contract-invalid': {
|
|
57
|
+
cause: 'The blueprint index `review_contract` value is not an allowed marker, so the final review path is closed.',
|
|
58
|
+
next: 'Report to the user and fix `review_contract` through `/bouncer-plan`; do not run the final review.',
|
|
59
|
+
},
|
|
60
|
+
'critical-recovery-open': {
|
|
61
|
+
cause: 'This prepared task has an open critical-recovery record with no outcome yet.',
|
|
62
|
+
next: 'Close it with `bouncer coordinate critical-recovery` outcome resolved or blocked before driving the task.',
|
|
63
|
+
},
|
|
64
|
+
'task-reported-blocked': {
|
|
65
|
+
cause: 'The worker reported outcome blocked, so the coordinator must stop this task.',
|
|
66
|
+
next: 'Report the block to the user; do not dispatch or record this attempt.',
|
|
67
|
+
},
|
|
68
|
+
'commit-evidence-mismatch': {
|
|
69
|
+
cause: 'Worker HEAD, commit_sha, or porcelain does not match a commit that, beyond the paths already dirty '
|
|
70
|
+
+ 'at dispatch, only dirtied this task tasks.md.',
|
|
71
|
+
next: 'Restore the worker to the recorded commit evidence, or redo commit so only that tasks.md is dirty.',
|
|
72
|
+
},
|
|
73
|
+
'verification-task-uses-blueprint-next': {
|
|
74
|
+
cause: 'Verification tasks are driven with blueprint-scope next, not --task.',
|
|
75
|
+
next: 'Omit --task and run `bouncer coordinate next --blueprint <dir>` so verification_node can be selected.',
|
|
76
|
+
},
|
|
77
|
+
'coordinator-card-missing': {
|
|
78
|
+
cause: 'The contract card for this action is missing or unreadable in plugin references/coordinator-cards/.',
|
|
79
|
+
next: 'Reinstall the Bouncer plugin so references/coordinator-cards/ ships every card, then call next again.',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* reason에 맞는 cause·next를 고른다. next가 만든 reason은 NEXT_FAILURE_HINTS,
|
|
84
|
+
* 기존 coordinate reason은 COORDINATE_FAILURE_HINTS만 본다 — 같은 키를 두 표에
|
|
85
|
+
* 두면 안내가 갈라진다.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} reason - 실패 또는 blocked reason
|
|
88
|
+
* @returns {{ cause: string, next: string }} 안내 문구
|
|
89
|
+
*/
|
|
90
|
+
function hintFor(reason) {
|
|
91
|
+
return NEXT_FAILURE_HINTS[reason] || COORDINATE_FAILURE_HINTS[reason] || {
|
|
92
|
+
cause: reason,
|
|
93
|
+
next: 'Run `bouncer coordinate status` and continue from its checkpoint.',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* ok:false 결과에 힌트를 붙인다.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} reason - 거절 코드
|
|
100
|
+
* @returns {NextErr} exit 1 대상
|
|
101
|
+
*/
|
|
102
|
+
function fail(reason) {
|
|
103
|
+
const hint = hintFor(reason);
|
|
104
|
+
return { ok: false, reason, cause: hint.cause, next: hint.next };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* 플러그인 자산 루트(`resolvePluginAssetRoot`)의 계약 카드 한 장을 UTF-8
|
|
108
|
+
* 그대로 읽는다. cwd·`--repo`·소비 저장소는 보지 않는다 —
|
|
109
|
+
* 카드는 플러그인이 배포하는 계약이지 사용자 저장소 문서가 아니다.
|
|
110
|
+
* 루트 해석 실패는 resolver의 Error를, 파일이 없거나 읽기에 실패하면 fs 오류를 그대로
|
|
111
|
+
* 던진다. 호출부 `attachCard`가 둘을 같은 `coordinator-card-missing`으로 흡수한다.
|
|
112
|
+
*
|
|
113
|
+
* @param {CardId} id - 카드 id. 호출부가 CARD_IDS 안의 값만 넘긴다
|
|
114
|
+
* @returns {string} 카드 파일 본문
|
|
115
|
+
*/
|
|
116
|
+
function readPluginCard(id) {
|
|
117
|
+
return fs.readFileSync(path.join((0, plugin_root_1.resolvePluginAssetRoot)(), 'references', 'coordinator-cards', `${id}.md`), 'utf8');
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 성공 응답의 action이 카드 대상이면 `card: { id, body }`를 붙인다.
|
|
121
|
+
* 카드를 못 읽으면 카드 없는 성공 응답으로 내보내지 않고
|
|
122
|
+
* `coordinator-card-missing`으로 거절한다 — 규칙 없이 행동만 받은 coordinator가
|
|
123
|
+
* 예전처럼 reference를 뒤지거나 규칙을 추측하지 않게 하려는 것이다.
|
|
124
|
+
*
|
|
125
|
+
* @param {NextOk | NextErr} result - blueprintNext/taskNext 판정
|
|
126
|
+
* @param {(id: CardId) => string} readCard - 카드 읽기. 기본은 readPluginCard
|
|
127
|
+
* @returns {NextOk | NextErr} 카드를 붙인 성공 응답, 비대상 응답 그대로, 또는 거절
|
|
128
|
+
*/
|
|
129
|
+
function attachCard(result, readCard) {
|
|
130
|
+
if (!result.ok)
|
|
131
|
+
return result;
|
|
132
|
+
const id = CARD_IDS.find((candidate) => candidate === result.action);
|
|
133
|
+
if (id === undefined)
|
|
134
|
+
return result;
|
|
135
|
+
let body;
|
|
136
|
+
try {
|
|
137
|
+
body = readCard(id);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
// 카드 읽기의 모든 실패(ENOENT·EACCES·EISDIR, 주입 seam의 throw)를 같은 거절로 흡수한다.
|
|
141
|
+
// 어느 경우든 이 action의 계약을 줄 수 없다는 결과는 같고, 원장·worktree는 이
|
|
142
|
+
// 함수가 건드리지 않으므로 흡수해도 상태가 어긋나지 않는다. 안내는 hint가 맡는다.
|
|
143
|
+
if (error instanceof Error && 'code' in error
|
|
144
|
+
&& error.code === 'BOUNCER_PLUGIN_VERSION_MISMATCH') {
|
|
145
|
+
return {
|
|
146
|
+
...fail('coordinator-card-missing'),
|
|
147
|
+
cause: error.message,
|
|
148
|
+
next: 'Install a compatible npm runtime version or repair BOUNCER_HOME to a compatible plugin install.',
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return fail('coordinator-card-missing');
|
|
152
|
+
}
|
|
153
|
+
return { ...result, card: { id, body } };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 증거 파일의 sha256을 계산한다. 읽기 실패는 null miss로 돌려 throw하지 않는다 —
|
|
157
|
+
* 없는 파일을 이유로 판단 응답 전체를 거절하면 카드·report 요약까지 잃는다.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} filePath - 절대 또는 해석 가능한 증거 경로
|
|
160
|
+
* @returns {{ sha256: string } | null} 해시 또는 읽기 실패 시 null
|
|
161
|
+
*/
|
|
162
|
+
function readEvidenceFile(filePath) {
|
|
163
|
+
try {
|
|
164
|
+
const bytes = fs.readFileSync(filePath);
|
|
165
|
+
return { sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
|
|
166
|
+
}
|
|
167
|
+
catch (_error) {
|
|
168
|
+
// ENOENT·EACCES·EISDIR 등 모든 읽기 실패를 같은 miss로 흡수한다. 항목만
|
|
169
|
+
// evidence에서 빠지며 ok:false로 올리지 않는 계약이다.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 후보 경로가 지정한 루트 안(또는 루트 자체)인지 본다. 밖으로 나가면 포인터에
|
|
175
|
+
* 넣지 않는다 — 증거 포인터는 읽기 지침일 뿐 판단 범위를 넓히지 않는다.
|
|
176
|
+
*
|
|
177
|
+
* @param {string} candidatePath - 후보 절대/상대 경로
|
|
178
|
+
* @param {string} rootPath - 허용할 worker 또는 integration 루트
|
|
179
|
+
* @returns {boolean} 지정한 루트 안이면 true
|
|
180
|
+
*/
|
|
181
|
+
function isInsideRootPath(candidatePath, rootPath) {
|
|
182
|
+
const resolved = path.resolve(candidatePath);
|
|
183
|
+
const root = path.resolve(rootPath);
|
|
184
|
+
return resolved === root || resolved.startsWith(`${root}${path.sep}`);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 판단·worker 응답에 `payload.report`와 `payload.evidence`를 붙인다.
|
|
188
|
+
* `judge`도 worker 위임(`implement`)도 아니면 그대로 돌려 prepare·integrate·
|
|
189
|
+
* commit·done 등이 판단 문맥을 받지 않게 한다. 직전 report가 없으면 `report`
|
|
190
|
+
* 키를 만들지 않고, 읽기 실패·worker 밖 후보는 evidence에서만 뺀다.
|
|
191
|
+
*
|
|
192
|
+
* @param {NextOk} result - 카드 첨부 전·후의 성공 응답
|
|
193
|
+
* @param {JudgeContext} ctx - 직전 report·worker 루트·후보·읽기 seam
|
|
194
|
+
* @returns {NextOk} report/evidence가 병합된 응답, 또는 비대상 응답 그대로
|
|
195
|
+
*/
|
|
196
|
+
function attachJudgeContext(result, ctx) {
|
|
197
|
+
// 1. 판단(judge 필드)과 worker 위임(implement)만 대상. 그 밖은 payload를 건드리지 않는다.
|
|
198
|
+
const isJudge = result.judge !== undefined && result.judge !== null;
|
|
199
|
+
const isWorker = result.action === 'implement';
|
|
200
|
+
if (!isJudge && !isWorker)
|
|
201
|
+
return result;
|
|
202
|
+
const payload = { ...(result.payload || {}) };
|
|
203
|
+
// previous_outcome은 report로 대체한다. 옛 키를 남기면 클라이언트가 둘을 섞어 읽는다.
|
|
204
|
+
delete payload.previous_outcome;
|
|
205
|
+
// 2. 원장 마지막 kind:report만 요약으로 싣는다. 원문 outcome/summary는 자르지 않는다.
|
|
206
|
+
if (ctx.lastReport) {
|
|
207
|
+
payload.report = {
|
|
208
|
+
outcome: ctx.lastReport.outcome,
|
|
209
|
+
summary: ctx.lastReport.summary,
|
|
210
|
+
attempt: ctx.lastReport.attempt,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
// 3. worker 안 후보만 읽고, null miss는 항목 생략. 빈 배열이면 evidence 키 자체를 빼
|
|
214
|
+
// "없음"과 빈 목록을 구분하지 않고 응답을 작게 유지한다.
|
|
215
|
+
const evidence = [];
|
|
216
|
+
for (const candidate of ctx.candidates) {
|
|
217
|
+
if (!isInsideRootPath(candidate.path, ctx.workerPath))
|
|
218
|
+
continue;
|
|
219
|
+
const read = ctx.readEvidence(candidate.path);
|
|
220
|
+
if (!read)
|
|
221
|
+
continue;
|
|
222
|
+
evidence.push({ kind: candidate.kind, path: candidate.path, sha256: read.sha256 });
|
|
223
|
+
}
|
|
224
|
+
if (evidence.length > 0)
|
|
225
|
+
payload.evidence = evidence;
|
|
226
|
+
else
|
|
227
|
+
delete payload.evidence;
|
|
228
|
+
if (!ctx.lastReport)
|
|
229
|
+
delete payload.report;
|
|
230
|
+
// payload가 비고 기존에도 없었으면 키 자체를 생략해 응답을 키우지 않는다.
|
|
231
|
+
if (Object.keys(payload).length === 0 && result.payload === undefined) {
|
|
232
|
+
const { payload: _dropped, ...rest } = result;
|
|
233
|
+
void _dropped;
|
|
234
|
+
return rest;
|
|
235
|
+
}
|
|
236
|
+
return { ...result, payload };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 실패한 terminal verification 문서를 integration 루트 안에서만 증거 포인터로 싣는다.
|
|
240
|
+
* 원장 본문이나 로그를 복사하지 않고, 현재 파일의 경로와 바이트 SHA256만 제공한다.
|
|
241
|
+
*
|
|
242
|
+
* @param {NextOk} result - terminal 실패 응답
|
|
243
|
+
* @param {string} integrationPath - 할당된 integration 루트
|
|
244
|
+
* @param {string} blueprint - blueprint 상대 경로
|
|
245
|
+
* @param {string} taskId - 실패한 verification task id
|
|
246
|
+
* @param {ReadEvidence} readEvidence - 증거 파일 SHA256 reader
|
|
247
|
+
* @returns {NextOk} 해시 포인터가 추가된 응답, 읽기 실패 시 원래 응답
|
|
248
|
+
*/
|
|
249
|
+
function attachTerminalVerificationEvidence(result, integrationPath, blueprint, taskId, readEvidence) {
|
|
250
|
+
if (result.action !== 'blocked' || result.reason !== 'terminal-verification-failed')
|
|
251
|
+
return result;
|
|
252
|
+
const verificationPath = path.resolve(integrationPath, blueprint.replaceAll('\\', '/'), 'tasks', taskId, 'verification.md');
|
|
253
|
+
if (!isInsideRootPath(verificationPath, integrationPath))
|
|
254
|
+
return result;
|
|
255
|
+
const file = readEvidence(verificationPath);
|
|
256
|
+
if (!file)
|
|
257
|
+
return result;
|
|
258
|
+
return {
|
|
259
|
+
...result,
|
|
260
|
+
payload: {
|
|
261
|
+
...(result.payload || {}),
|
|
262
|
+
evidence: [{ kind: 'verification', path: verificationPath, sha256: file.sha256 }],
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* worker task 디렉터리의 기본 증거 후보. report.md는 선택 산출물이라 없어도
|
|
268
|
+
* miss로 빠지고, verification.md·review.md는 게이트 문서 경로다.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} workerPath - 할당 worker 절대 경로
|
|
271
|
+
* @param {string} blueprint - blueprint 상대 경로
|
|
272
|
+
* @param {string} taskId - 세 자리 task id
|
|
273
|
+
* @param {number | undefined} attempt - 활성 dispatch attempt. 없으면 보고 후보를 만들지 않는다
|
|
274
|
+
* @returns {EvidenceCandidate[]} kind·path 후보
|
|
275
|
+
*/
|
|
276
|
+
function defaultEvidenceCandidates(workerPath, blueprint, taskId, attempt) {
|
|
277
|
+
const taskDir = path.join(workerPath, blueprint.replaceAll('\\', '/'), 'tasks', taskId);
|
|
278
|
+
return [
|
|
279
|
+
// 보고 파일은 `coordinate report`가 읽는 reportPathFor 경로와 같아야 한다.
|
|
280
|
+
...(attempt === undefined ? []
|
|
281
|
+
: [{ kind: 'report', path: reportPathFor(workerPath, taskId, attempt) }]),
|
|
282
|
+
{ kind: 'verification', path: path.join(taskDir, 'verification.md') },
|
|
283
|
+
{ kind: 'review', path: path.join(taskDir, 'review.md') },
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Git 한 번. trim은 rev-parse 비교용이고 porcelain은 별도 헬퍼가 원문을 지킨다.
|
|
288
|
+
*
|
|
289
|
+
* @param {Exec} exec - 주입 실행기
|
|
290
|
+
* @param {string} cwd - 작업 디렉터리
|
|
291
|
+
* @param {string[]} args - git argv
|
|
292
|
+
* @returns {string} stdout trim
|
|
293
|
+
*/
|
|
294
|
+
function git(exec, cwd, args) {
|
|
295
|
+
return String(exec('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* fence 쌍. next가 읽은 bytes의 path·hash만 넣어야 argv 실행이 stale로 거절되지 않는다.
|
|
299
|
+
*
|
|
300
|
+
* @param {LedgerRef} ledger - checkpoint.ledger
|
|
301
|
+
* @returns {string[]} --ledger-path/--ledger-hash 토큰
|
|
302
|
+
*/
|
|
303
|
+
function fenceArgs(ledger) {
|
|
304
|
+
return ['--ledger-path', ledger.path, '--ledger-hash', ledger.sha256];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 원장 lease가 있을 때만 플래그를 붙인다. verification은 lease가 없어 꾸며내지 않는다.
|
|
308
|
+
*
|
|
309
|
+
* @param {LedgerTask} [item] - 대상 task
|
|
310
|
+
* @returns {string[]} lease 플래그 또는 빈 배열
|
|
311
|
+
*/
|
|
312
|
+
function leaseArgs(item) {
|
|
313
|
+
if (!item || !item.lease || typeof item.lease.id !== 'string')
|
|
314
|
+
return [];
|
|
315
|
+
if (!Number.isInteger(item.lease.generation))
|
|
316
|
+
return [];
|
|
317
|
+
return ['--lease-id', item.lease.id, '--generation', String(item.lease.generation)];
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* `bouncer coordinate <sub>` 뒤에 blueprint와 fence를 고정 순서로 붙인다.
|
|
321
|
+
* 표가 이 순서를 요구하므로 플래그를 명령마다 다르게 섞지 않는다.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} sub - coordinate 서브커맨드
|
|
324
|
+
* @param {string} blueprint - blueprint 상대 경로
|
|
325
|
+
* @param {LedgerRef} ledger - fence
|
|
326
|
+
* @param {{ task?: string, item?: LedgerTask }} [taskOpts] - task 대상이면 id와 lease
|
|
327
|
+
* @param {string[]} [extra] - attempt 등 이미 채운 플래그
|
|
328
|
+
* @returns {string[]} argv
|
|
329
|
+
*/
|
|
330
|
+
function coordinateArgv(sub, blueprint, ledger, taskOpts, extra = []) {
|
|
331
|
+
const argv = ['bouncer', 'coordinate', sub, '--blueprint', blueprint, ...fenceArgs(ledger)];
|
|
332
|
+
if (taskOpts && taskOpts.task) {
|
|
333
|
+
argv.push('--task', taskOpts.task, ...leaseArgs(taskOpts.item));
|
|
334
|
+
}
|
|
335
|
+
argv.push(...extra);
|
|
336
|
+
return argv;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* porcelain v1 경로. rename은 양쪽을 모두 dirty로 본다 — tasks.md만 남긴
|
|
340
|
+
* commit 직후 상태를 다른 경로가 가리면 report가 되면 안 된다.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} text - `git status --porcelain=v1` 원문
|
|
343
|
+
* @returns {string[]} 상대 경로
|
|
344
|
+
*/
|
|
345
|
+
function dirtyPaths(text) {
|
|
346
|
+
const paths = [];
|
|
347
|
+
for (const line of text.split('\n')) {
|
|
348
|
+
if (!line)
|
|
349
|
+
continue;
|
|
350
|
+
const rest = line.slice(3);
|
|
351
|
+
const parts = rest.includes(' -> ') ? rest.split(' -> ') : [rest];
|
|
352
|
+
for (const part of parts) {
|
|
353
|
+
const trimmed = part.trim();
|
|
354
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
355
|
+
try {
|
|
356
|
+
paths.push(JSON.parse(trimmed));
|
|
357
|
+
}
|
|
358
|
+
catch (_error) {
|
|
359
|
+
// 잘못된 따옴표는 원문 그대로 비교한다. 경로를 추측해 지우면 mismatch를 놓친다.
|
|
360
|
+
paths.push(trimmed);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
paths.push(trimmed.replaceAll('\\', '/'));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return paths;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 이 task의 tasks.md만 dirty인지. commit이 SHA를 그 파일에만 쓰므로 그 한
|
|
372
|
+
* 경로의 변경은 report의 정상 잔여물이다.
|
|
373
|
+
*
|
|
374
|
+
* @param {string[]} paths - porcelain 경로
|
|
375
|
+
* @param {string} blueprint - blueprint 상대 경로
|
|
376
|
+
* @param {string} taskId - 세 자리 id
|
|
377
|
+
* @returns {boolean} tasks.md 외 dirty가 있으면 false
|
|
378
|
+
*/
|
|
379
|
+
function onlyTaskBriefDirty(paths, blueprint, taskId) {
|
|
380
|
+
const allowed = `${blueprint.replaceAll('\\', '/')}/tasks/${taskId}/tasks.md`;
|
|
381
|
+
return paths.every((rel) => rel.replaceAll('\\', '/') === allowed);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* 문서 bouncer.status. 파일 부재는 미완으로 본다 — 없는 증적을 통과로 꾸미지 않는다.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} root - worker 또는 integration
|
|
387
|
+
* @param {string} rel - 문서 상대 경로
|
|
388
|
+
* @returns {unknown} status 또는 undefined
|
|
389
|
+
*/
|
|
390
|
+
function bouncerStatus(root, rel) {
|
|
391
|
+
const block = readBouncerBlock(path.join(root, rel));
|
|
392
|
+
return block ? block.status : undefined;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* root review.md에 주어진 mode의 라운드가 있는지 tolerant 정책으로 읽는다. 문서 부재·frontmatter
|
|
396
|
+
* 없음·깨진 YAML·`rounds`가 배열이 아니면 없음으로 본다 — 판독 불가를 예외로 올리면 final_review
|
|
397
|
+
* 분기가 막히고, 있음으로 꾸미면 delta를 잘못 연다. 표식 blueprint는 delta 한 번으로 끝나므로
|
|
398
|
+
* delta 뒤 미accepted를 blocked로 가르는 기준이기도 하다.
|
|
399
|
+
*
|
|
400
|
+
* @param {string} integrationPath - integration worktree
|
|
401
|
+
* @param {string} blueprint - blueprint 상대 경로
|
|
402
|
+
* @param {'discovery' | 'delta'} mode - 찾을 라운드 mode
|
|
403
|
+
* @returns {boolean} 해당 mode 라운드가 있으면 true
|
|
404
|
+
*/
|
|
405
|
+
function rootReviewHasRoundTolerant(integrationPath, blueprint, mode) {
|
|
406
|
+
return reviewRoundExists({ file: path.join(integrationPath, blueprint, 'review.md'), error_policy: 'tolerant' }, mode);
|
|
407
|
+
}
|
|
408
|
+
const rootReviewHasDiscoveryRound = (integrationPath, blueprint) => (rootReviewHasRoundTolerant(integrationPath, blueprint, 'discovery'));
|
|
409
|
+
const rootReviewHasDeltaRound = (integrationPath, blueprint) => (rootReviewHasRoundTolerant(integrationPath, blueprint, 'delta'));
|
|
410
|
+
/**
|
|
411
|
+
* checkpoint.ledger를 붙이고 blocked면 힌트를 채운다.
|
|
412
|
+
*
|
|
413
|
+
* @param {Built} body - action 판정
|
|
414
|
+
* @param {string} cwd - argv 실행 cwd
|
|
415
|
+
* @param {LedgerRef} ledgerRef - 읽기 bytes의 fence
|
|
416
|
+
* @returns {NextOk} 성공 응답
|
|
417
|
+
*/
|
|
418
|
+
function succeed(body, cwd, ledgerRef) {
|
|
419
|
+
const result = { ok: true, checkpoint: { ledger: ledgerRef }, ...body, cwd };
|
|
420
|
+
if (result.action === 'blocked' && typeof result.reason === 'string') {
|
|
421
|
+
const hint = hintFor(result.reason);
|
|
422
|
+
// 호출자가 상황별 문구를 실어 보내면(brief 표식의 final-review-blocked) 그대로 둔다.
|
|
423
|
+
result.cause = result.cause ?? hint.cause;
|
|
424
|
+
result.next = result.next ?? hint.next;
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* 원장과 worker 문서를 읽어 다음에 실행할 행동 하나와 채운 argv를 돌려준다.
|
|
430
|
+
* 잠금·원장 쓰기·worktree 쓰기는 하지 않는다. status와 같이 integration cwd에서
|
|
431
|
+
* 읽고, 응답 cwd만 worker일 수 있다. 판단·worker action이면 플러그인 계약 카드를 `card`로 함께 싣는다.
|
|
432
|
+
*
|
|
433
|
+
* @param {object} opts - 판정 입력
|
|
434
|
+
* @param {string} opts.repoRoot - 메인 체크아웃
|
|
435
|
+
* @param {string} opts.blueprint - blueprint 상대 경로
|
|
436
|
+
* @param {string} opts.cwd - 호출 cwd. integration worktree여야 한다
|
|
437
|
+
* @param {string} [opts.task] - 있으면 task 범위
|
|
438
|
+
* @param {{ execFileSync?: Exec, readCard?: (id: CardId) => string, readEvidence?: ReadEvidence }} [opts.deps]
|
|
439
|
+
* git·카드·증거 읽기 주입(테스트 seam)
|
|
440
|
+
* @returns {NextOk | NextErr} 결정(카드 대상이면 card 포함) 또는 즉시 거절
|
|
441
|
+
*/
|
|
442
|
+
function coordinateNext(opts) {
|
|
443
|
+
const readCard = (opts.deps && opts.deps.readCard) || readPluginCard;
|
|
444
|
+
return attachCard(decideNext(opts), readCard);
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* 원장과 worker 문서를 읽어 다음 행동 하나를 정한다. 카드 첨부 전 단계다.
|
|
448
|
+
*
|
|
449
|
+
* @param {object} opts - coordinateNext와 같은 입력
|
|
450
|
+
* @param {string} opts.repoRoot - 메인 체크아웃
|
|
451
|
+
* @param {string} opts.blueprint - blueprint 상대 경로
|
|
452
|
+
* @param {string} opts.cwd - 호출 cwd. integration worktree여야 한다
|
|
453
|
+
* @param {string} [opts.task] - 있으면 task 범위
|
|
454
|
+
* @param {{ execFileSync?: Exec, readEvidence?: ReadEvidence }} [opts.deps] - git·증거 읽기 주입
|
|
455
|
+
* @returns {NextOk | NextErr} 결정 또는 즉시 거절
|
|
456
|
+
*/
|
|
457
|
+
function decideNext(opts) {
|
|
458
|
+
const { repoRoot, blueprint, cwd, task } = opts;
|
|
459
|
+
const exec = (opts.deps && opts.deps.execFileSync) || realExecFileSync;
|
|
460
|
+
const readEvidence = (opts.deps && opts.deps.readEvidence) || readEvidenceFile;
|
|
461
|
+
// 1. status와 같은 원장 읽기 순서. 쓰기는 이 함수에 없다.
|
|
462
|
+
const main = runtimePaths({ repoRoot, execFileSync: exec });
|
|
463
|
+
if (main.unavailable)
|
|
464
|
+
return fail('non-git-root');
|
|
465
|
+
const integration = coordinatorPathsFor({ repoRoot, blueprint });
|
|
466
|
+
if (!registeredIntegration(exec, repoRoot, integration.integrationPath)) {
|
|
467
|
+
return fail('unassigned-integration-worktree');
|
|
468
|
+
}
|
|
469
|
+
const loaded = loadLedgerBytes(integration.ledgerFile);
|
|
470
|
+
if (!loaded)
|
|
471
|
+
return fail('missing-ledger');
|
|
472
|
+
const leaseShape = assertLeaseShape(loaded.ledger);
|
|
473
|
+
if (!leaseShape.ok)
|
|
474
|
+
return fail(leaseShape.reason);
|
|
475
|
+
ensureIntegrationCwd(repoRoot, blueprint, cwd);
|
|
476
|
+
const ledger = loaded.ledger;
|
|
477
|
+
// 분리된 실행은 다음 행동을 정하지 않는다(task 범위 포함). finalize 또는 새 계획으로 안내한다.
|
|
478
|
+
if (ledger.status === 'detached')
|
|
479
|
+
return fail('run-detached');
|
|
480
|
+
const checkpoint = projectCheckpoint(loaded.ledger, integration.ledgerFile, loaded.bytes);
|
|
481
|
+
const ledgerRef = checkpoint.ledger;
|
|
482
|
+
const integrationCwd = integration.integrationPath;
|
|
483
|
+
if (task !== undefined && task !== '') {
|
|
484
|
+
return taskNext({
|
|
485
|
+
repoRoot, blueprint, task, exec, ledger, ledgerRef, checkpoint, integrationCwd, integration,
|
|
486
|
+
readEvidence,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return blueprintNext({
|
|
490
|
+
blueprint, exec, ledger, ledgerRef, checkpoint, integrationCwd, readEvidence,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* prepare를 내기 직전에, 이 coordinator 세션을 여기서 끊어야 하는지 원장 기록으로 판정한다.
|
|
495
|
+
* 한 세션이 직렬·무예외 wave를 이어 처리해 재기동 고정비를 줄이되, 상한에 닿았거나 예외·
|
|
496
|
+
* 병렬 wave가 끼면 새 세션(깨끗한 컨텍스트)에서 판단하게 하려는 경계다.
|
|
497
|
+
*
|
|
498
|
+
* 세션 경계는 `coordinate session-open`이 남긴 마지막 `session` 결정이고, 없으면 원장
|
|
499
|
+
* 처음부터를 한 세션으로 본다. wave 수는 그 뒤의 `fanin` 결정 수다(원장에 wave id가 없다).
|
|
500
|
+
* 읽기 전용이다.
|
|
501
|
+
*
|
|
502
|
+
* @param {Ledger} ledger - 원장 스냅샷
|
|
503
|
+
* @param {string[]} ready - checkpoint.ready(pending 후보 wave)
|
|
504
|
+
* @param {string} integrationCwd - 설정을 읽을 integration 절대 경로
|
|
505
|
+
* @returns {YieldReason | null} 끊어야 하는 사유. 이어 가도 되면 null
|
|
506
|
+
*/
|
|
507
|
+
function sessionYieldReason(ledger, ready, integrationCwd) {
|
|
508
|
+
// 1. verification 노드만 남은 wave는 세션 상한과 무관하게 이어 간다. 이 노드는 새 commit
|
|
509
|
+
// wave가 아니라 마감 검증이라, 여기서 끊으면 세션만 하나 더 늘어난다.
|
|
510
|
+
const commitReady = ready.filter((id) => {
|
|
511
|
+
const item = ledger.tasks.find((candidate) => candidate.id === id);
|
|
512
|
+
return item !== undefined && item.execution_kind !== 'verification';
|
|
513
|
+
});
|
|
514
|
+
if (commitReady.length === 0)
|
|
515
|
+
return null;
|
|
516
|
+
const decisions = ledger.decisions || [];
|
|
517
|
+
let sessionAt = -1;
|
|
518
|
+
decisions.forEach((entry, index) => {
|
|
519
|
+
if (entry && entry.kind === 'session')
|
|
520
|
+
sessionAt = index;
|
|
521
|
+
});
|
|
522
|
+
const faninAt = [];
|
|
523
|
+
decisions.forEach((entry, index) => {
|
|
524
|
+
if (index > sessionAt && entry && entry.kind === 'fanin')
|
|
525
|
+
faninAt.push(index);
|
|
526
|
+
});
|
|
527
|
+
// 2. 이번 세션에서 아직 wave를 하나도 못 돌렸으면 끊을 이유가 없다. 끊으면 새 세션도
|
|
528
|
+
// 같은 자리에서 다시 yield해 진행이 멈춘다.
|
|
529
|
+
if (faninAt.length === 0)
|
|
530
|
+
return null;
|
|
531
|
+
// 3. 설정이 invalid면 상한 1로 접는다. 잘못된 설정이 세션을 늘리지 못하게 하고, 거절은
|
|
532
|
+
// worktree를 만들기 전 prepare가 맡는다.
|
|
533
|
+
const policy = configMod.readWavesPerSession(integrationCwd);
|
|
534
|
+
const cap = policy.ok ? policy.cap : 1;
|
|
535
|
+
if (faninAt.length >= cap)
|
|
536
|
+
return 'session-wave-cap';
|
|
537
|
+
// 4. 예외 창은 직전 wave의 fanin(없으면 세션 시작) 뒤 전부다. 이번 wave 안에서 생긴 repair·
|
|
538
|
+
// revoke·비수락 report가 새 세션의 깨끗한 판단을 받게 한다. 그 이전 wave의 예외는 이미
|
|
539
|
+
// 처리된 것이라 세지 않는다.
|
|
540
|
+
const windowStart = faninAt.length >= 2 ? faninAt[faninAt.length - 2] + 1 : sessionAt + 1;
|
|
541
|
+
if (decisions.slice(windowStart).some((entry) => driveMetrics.isException(entry))) {
|
|
542
|
+
return 'wave-exception';
|
|
543
|
+
}
|
|
544
|
+
// 5. 병렬 wave는 worker 여럿의 보고가 한꺼번에 와서 컨텍스트가 빠르게 찬다.
|
|
545
|
+
if (commitReady.length >= 2)
|
|
546
|
+
return 'parallel-wave';
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* blueprint 범위 표. 위에서 처음 맞는 행만 쓴다.
|
|
551
|
+
*
|
|
552
|
+
* @param {object} ctx - 읽기 스냅샷
|
|
553
|
+
* @param {string} ctx.blueprint - blueprint 경로
|
|
554
|
+
* @param {Exec} ctx.exec - git
|
|
555
|
+
* @param {Ledger} ctx.ledger - 원장
|
|
556
|
+
* @param {LedgerRef} ctx.ledgerRef - fence
|
|
557
|
+
* @param {{ ready: string[] }} ctx.checkpoint - status checkpoint의 ready wave
|
|
558
|
+
* @param {string} ctx.integrationCwd - integration 절대 경로
|
|
559
|
+
* @param {ReadEvidence} ctx.readEvidence - 증거 파일 읽기 seam
|
|
560
|
+
* @returns {NextOk | NextErr} 판정
|
|
561
|
+
*/
|
|
562
|
+
function blueprintNext(ctx) {
|
|
563
|
+
const { blueprint, exec, ledger, ledgerRef, checkpoint, integrationCwd, readEvidence } = ctx;
|
|
564
|
+
const ok = (body) => succeed(body, body.cwd, ledgerRef);
|
|
565
|
+
if (ledger.status === 'partial_closed') {
|
|
566
|
+
return ok({
|
|
567
|
+
scope: 'blueprint', action: 'blocked', reason: 'partial-closed', cwd: integrationCwd,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
// light 승격 정지는 repair 한도와 별개다. prepare/drive로 내려가기 전에 막아
|
|
571
|
+
// 정지 뒤 inline implement가 다시 열리지 않게 한다. hint는 COORDINATE_FAILURE_HINTS.
|
|
572
|
+
if (ledger.status === 'promotion_stopped') {
|
|
573
|
+
return ok({
|
|
574
|
+
scope: 'blueprint', action: 'blocked', reason: 'promotion-stopped', cwd: integrationCwd,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (ledger.status === 'awaiting_confirmation') {
|
|
578
|
+
return ok({
|
|
579
|
+
scope: 'blueprint', action: 'blocked', reason: 'repair-wave-limit', cwd: integrationCwd,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const failedId = ledger.terminalFailure && ledger.terminalFailure.task;
|
|
583
|
+
if (failedId) {
|
|
584
|
+
const failed = ledger.tasks.find((item) => item.id === failedId);
|
|
585
|
+
// 입증된 범위 밖 분류가 있으면 terminal 실패는 blocked가 아니라 아래 완료 판정으로 넘어간다.
|
|
586
|
+
// 분류가 없거나 stale이면(소스 변경·identity 불일치·미완성) 기존 blocked를 그대로 유지한다.
|
|
587
|
+
if (failed && failed.execution_kind === 'verification' && failed.status === 'verifying'
|
|
588
|
+
&& (0, execution_contracts_1.validOutsideScope)(ledger, failed.id) === null) {
|
|
589
|
+
const blocked = ok({
|
|
590
|
+
scope: 'blueprint', action: 'blocked', reason: 'terminal-verification-failed',
|
|
591
|
+
cwd: integrationCwd,
|
|
592
|
+
});
|
|
593
|
+
return attachTerminalVerificationEvidence(blocked, integrationCwd, blueprint, failed.id, readEvidence);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (ledger.fanin != null) {
|
|
597
|
+
return ok({
|
|
598
|
+
scope: 'blueprint', action: 'integrate', cwd: integrationCwd,
|
|
599
|
+
argv: coordinateArgv('integrate', blueprint, ledgerRef),
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
const prepared = ledger.tasks.filter((item) => item.status === 'prepared'
|
|
603
|
+
&& item.execution_kind !== 'verification');
|
|
604
|
+
if (prepared.length > 0) {
|
|
605
|
+
return ok({
|
|
606
|
+
scope: 'blueprint', action: 'drive_tasks', cwd: integrationCwd,
|
|
607
|
+
task_ids: prepared.map((item) => item.id),
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
const recorded = ledger.tasks.some((item) => item.status === 'recorded'
|
|
611
|
+
&& item.execution_kind !== 'verification');
|
|
612
|
+
if (recorded) {
|
|
613
|
+
return ok({
|
|
614
|
+
scope: 'blueprint', action: 'integrate', cwd: integrationCwd,
|
|
615
|
+
argv: coordinateArgv('integrate', blueprint, ledgerRef),
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
// checkpoint.ready는 pending 후보다. verification node는 prepare가 status를
|
|
619
|
+
// ready로 올린 뒤에만 integrate가 받아 주므로, 여기를 status ready/verifying으로
|
|
620
|
+
// 본다. pending verification은 아래 prepare 행이 연다.
|
|
621
|
+
const verificationLive = ledger.tasks.find((item) => item.execution_kind === 'verification'
|
|
622
|
+
&& (item.status === 'ready'
|
|
623
|
+
|| (item.status === 'verifying'
|
|
624
|
+
&& !(ledger.terminalFailure && ledger.terminalFailure.task === item.id))));
|
|
625
|
+
if (verificationLive) {
|
|
626
|
+
return ok({
|
|
627
|
+
scope: 'blueprint', action: 'verification_node', cwd: integrationCwd, task: verificationLive.id,
|
|
628
|
+
argv: coordinateArgv('integrate', blueprint, ledgerRef, {
|
|
629
|
+
task: verificationLive.id, item: verificationLive,
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (checkpoint.ready.length > 0) {
|
|
634
|
+
const yieldReason = sessionYieldReason(ledger, checkpoint.ready, integrationCwd);
|
|
635
|
+
if (yieldReason !== null) {
|
|
636
|
+
return ok({
|
|
637
|
+
scope: 'blueprint', action: 'yield', reason: yieldReason, cwd: integrationCwd,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return ok({
|
|
641
|
+
scope: 'blueprint', action: 'prepare', cwd: integrationCwd,
|
|
642
|
+
argv: coordinateArgv('prepare', blueprint, ledgerRef),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
// 완료는 전부 integrated이거나, 남은 하나가 입증된 범위 밖 terminal 실패일 때다. 후자는 성공으로
|
|
646
|
+
// 바꾸지 않고 payload.outside_scope로 실패 사실을 흘려 보낸다(passed·integrated로 취급하지 않는다).
|
|
647
|
+
const outsideScope = ledger.tasks
|
|
648
|
+
.map((item) => (item.status === 'integrated' ? null : (0, execution_contracts_1.validOutsideScope)(ledger, item.id)))
|
|
649
|
+
.find((entry) => entry !== null) ?? null;
|
|
650
|
+
const allIntegrated = ledger.tasks.length > 0
|
|
651
|
+
&& ledger.tasks.every((item) => item.status === 'integrated'
|
|
652
|
+
|| (outsideScope !== null && (0, execution_contracts_1.validOutsideScope)(ledger, item.id) !== null));
|
|
653
|
+
const completionPayload = outsideScope !== null
|
|
654
|
+
? { payload: { outside_scope: outsideScope, failed_task: ledger.terminalFailure?.task } }
|
|
655
|
+
: {};
|
|
656
|
+
if (allIntegrated && isBlueprintReviewModeAt(integrationCwd, blueprint)) {
|
|
657
|
+
const rootReview = bouncerStatus(integrationCwd, `${blueprint.replaceAll('\\', '/')}/review.md`);
|
|
658
|
+
if (rootReview !== 'accepted') {
|
|
659
|
+
const head = typeof ledger.integrationHead === 'string'
|
|
660
|
+
? ledger.integrationHead
|
|
661
|
+
: git(exec, integrationCwd, ['rev-parse', 'HEAD']);
|
|
662
|
+
const base = typeof ledger.base === 'string' ? ledger.base : head;
|
|
663
|
+
// 최종 리뷰는 discovery 한 라운드로 끝난다. discovery가 저장됐는데 accepted가 아니면
|
|
664
|
+
// delta·repair·supplement로 되돌리지 않고 사용자 결정까지 멈춘다. 새 review status를
|
|
665
|
+
// 만들지 않으려고 기존 신호(문서 status requested, open finding)를 그대로 blocked로
|
|
666
|
+
// 읽는다. 저장된 과거 delta 이력은 읽기만 하고 여기서 새 delta를 만들지 않는다.
|
|
667
|
+
const contract = reviewContractAt(integrationCwd, blueprint);
|
|
668
|
+
if (contract === null) {
|
|
669
|
+
return ok({
|
|
670
|
+
scope: 'blueprint', action: 'blocked', reason: schema.REVIEW_CONTRACT_INVALID_REASON, cwd: integrationCwd,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
if (rootReviewHasDiscoveryRound(integrationCwd, blueprint)) {
|
|
674
|
+
if (contract !== 'brief') {
|
|
675
|
+
return ok({
|
|
676
|
+
scope: 'blueprint', action: 'blocked', reason: 'final-review-blocked', cwd: integrationCwd,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
// brief 표식은 discovery 뒤 리뷰 원인 repair 한 번과 그 뒤 delta 한 번을 허용한다. repair와
|
|
680
|
+
// terminal 재검증이 끝나(allIntegrated) 여기 도달했고 delta가 아직 없으면 delta를 연다.
|
|
681
|
+
const repaired = hasReviewRepairDecision(ledger.decisions);
|
|
682
|
+
if (repaired && !rootReviewHasDeltaRound(integrationCwd, blueprint)) {
|
|
683
|
+
return ok({
|
|
684
|
+
scope: 'blueprint', action: 'final_review', cwd: integrationCwd,
|
|
685
|
+
argv: [
|
|
686
|
+
'bouncer', 'review-dispatch', 'execute', '--blueprint', blueprint,
|
|
687
|
+
'--base', base, '--head', head, '--mode', 'delta',
|
|
688
|
+
],
|
|
689
|
+
judge: { kind: 'review-round', fields: [] },
|
|
690
|
+
...completionPayload,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
return ok({
|
|
694
|
+
scope: 'blueprint', action: 'blocked', reason: 'final-review-blocked', cwd: integrationCwd,
|
|
695
|
+
cause: repaired
|
|
696
|
+
? 'The blueprint final review used its discovery round, the one review repair, and the delta round, '
|
|
697
|
+
+ 'and the root review is not accepted.'
|
|
698
|
+
: 'The blueprint final review recorded its discovery round, the root review is not accepted, '
|
|
699
|
+
+ 'and no review repair has been opened yet.',
|
|
700
|
+
next: repaired
|
|
701
|
+
? 'Stop: the one review repair and delta are used. Show the open findings to the user and wait for their '
|
|
702
|
+
+ 'decision (note + deferred, or accepted-risk consent); do not open another repair or round.'
|
|
703
|
+
: 'If an open must_fix is a brief violation, open the one repair with `bouncer coordinate repair '
|
|
704
|
+
+ '--review-finding`; otherwise show the open findings to the user and wait for their decision '
|
|
705
|
+
+ '(note + deferred, or accepted-risk consent).',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
// discovery 전 최신 supplement 결정이 이 분기를 좌우한다. pending이면 implementer가
|
|
709
|
+
// 테스트를 보완해야 하고, verified면 보완 뒤 한 번뿐인 discovery 라운드로 간다.
|
|
710
|
+
const supplement = [...(ledger.decisions || [])].reverse()
|
|
711
|
+
.find((entry) => entry && entry.kind === 'supplement');
|
|
712
|
+
if (supplement && supplement.outcome === 'pending') {
|
|
713
|
+
return ok({
|
|
714
|
+
scope: 'blueprint', action: 'supplement', cwd: integrationCwd,
|
|
715
|
+
payload: { paths: supplement.paths || [], findings: supplement.findings || [] },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
return ok({
|
|
719
|
+
scope: 'blueprint', action: 'final_review', cwd: integrationCwd,
|
|
720
|
+
argv: [
|
|
721
|
+
'bouncer', 'review-dispatch', 'execute', '--blueprint', blueprint,
|
|
722
|
+
'--base', base, '--head', head,
|
|
723
|
+
],
|
|
724
|
+
judge: { kind: 'review-round', fields: [] },
|
|
725
|
+
...completionPayload,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (allIntegrated) {
|
|
730
|
+
return ok({ scope: 'blueprint', action: 'done', cwd: integrationCwd, ...completionPayload });
|
|
731
|
+
}
|
|
732
|
+
return ok({
|
|
733
|
+
scope: 'blueprint', action: 'blocked', reason: 'no-ready-task', cwd: integrationCwd,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* 현재 attempt의 implementer 보고 파일이 이미 있는지 읽기만으로 판정한다.
|
|
738
|
+
* 파일이 있고 비어 있지 않으며 `## Brief revision`의 `attempt:` 줄이 현재 attempt와 같을 때만 참이다.
|
|
739
|
+
* 기준(HEAD·porcelain)은 dirty 경로 목록이라 내용 변화를 못 보므로, rework 재시도가 같은
|
|
740
|
+
* 기준에서 끝나도 implement가 반복되지 않게 하는 보조 신호다.
|
|
741
|
+
*
|
|
742
|
+
* @param {string} workerPath - 보고 파일 경로 계산에 쓰는 worker cwd
|
|
743
|
+
* @param {string} task - task 번호(3자리)
|
|
744
|
+
* @param {number} attempt - 원장 dispatch의 현재 attempt
|
|
745
|
+
* @returns {boolean} 현재 attempt 보고가 있으면 true. 없음·빈 파일·읽기 오류·attempt 불일치는 false
|
|
746
|
+
*/
|
|
747
|
+
function currentAttemptReportExists(workerPath, task, attempt) {
|
|
748
|
+
let text;
|
|
749
|
+
try {
|
|
750
|
+
text = fs.readFileSync(reportPathFor(workerPath, task, attempt), 'utf8');
|
|
751
|
+
}
|
|
752
|
+
catch (_error) {
|
|
753
|
+
// 읽기 오류는 모두 "보고 없음"으로 접는다. 판정을 못 하면 implement로 남는 쪽이
|
|
754
|
+
// 보수적이고(보고를 건너뛰어 미완성 구현을 verify로 넘기지 않는다), next는 읽기 전용이라
|
|
755
|
+
// 오류를 숨겨도 상태가 바뀌지 않는다.
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
const match = /^attempt:\s*(\d+)\s*$/m.exec(text);
|
|
759
|
+
return Boolean(match) && Number(match[1]) === attempt;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* task 범위. verification --task는 즉시 거절하고, prepared가 아닌 상태는 none이다.
|
|
763
|
+
* reported `scope_revision`은 brief hash가 보고 때와 같으면 revise, 다르면
|
|
764
|
+
* 개정이 반영된 것으로 보고 재디스패치한다. 판단·worker 행동에는
|
|
765
|
+
* `attachJudgeContext`로 `payload.report`·`evidence`를 붙인다.
|
|
766
|
+
*
|
|
767
|
+
* @param {object} ctx - 읽기 스냅샷
|
|
768
|
+
* @param {string} ctx.repoRoot - 메인 루트
|
|
769
|
+
* @param {string} ctx.blueprint - blueprint 경로
|
|
770
|
+
* @param {string} ctx.task - --task 값
|
|
771
|
+
* @param {Exec} ctx.exec - git
|
|
772
|
+
* @param {Ledger} ctx.ledger - 원장
|
|
773
|
+
* @param {LedgerRef} ctx.ledgerRef - fence
|
|
774
|
+
* @param {{ ready: string[] }} ctx.checkpoint - unused, 시그니처 대칭
|
|
775
|
+
* @param {string} ctx.integrationCwd - integration
|
|
776
|
+
* @param {{ workerPath?: string }} ctx.integration - coordinatorPaths는 호출부에서 다시 계산
|
|
777
|
+
* @param {ReadEvidence} ctx.readEvidence - 증거 파일 읽기 seam
|
|
778
|
+
* @returns {NextOk | NextErr} 판정
|
|
779
|
+
*/
|
|
780
|
+
function taskNext(ctx) {
|
|
781
|
+
const { repoRoot, blueprint, task, exec, ledger, ledgerRef, integrationCwd, readEvidence } = ctx;
|
|
782
|
+
if (!/^\d{3}$/.test(task))
|
|
783
|
+
return fail('task-required');
|
|
784
|
+
const item = ledger.tasks.find((entry) => entry.id === task);
|
|
785
|
+
if (!item)
|
|
786
|
+
return fail('task-outside-blueprint');
|
|
787
|
+
if (item.execution_kind === 'verification')
|
|
788
|
+
return fail('verification-task-uses-blueprint-next');
|
|
789
|
+
const worker = coordinatorPathsFor({ repoRoot, blueprint, task }).workerPath;
|
|
790
|
+
const cwd = item.workerPath || worker;
|
|
791
|
+
const judgeCtx = {
|
|
792
|
+
lastReport: lastReportedOutcome(item),
|
|
793
|
+
workerPath: cwd,
|
|
794
|
+
candidates: defaultEvidenceCandidates(cwd, blueprint, task, item.dispatch?.attempt),
|
|
795
|
+
readEvidence,
|
|
796
|
+
};
|
|
797
|
+
// 성공 응답을 만든 뒤 판단·worker 행동에만 report/evidence를 붙인다. blocked·none·
|
|
798
|
+
// commit·verify는 judge/implement가 아니라서 attachJudgeContext가 no-op이다.
|
|
799
|
+
const ok = (body) => attachJudgeContext(succeed({ ...body, task }, body.cwd, ledgerRef), judgeCtx);
|
|
800
|
+
const status = item.status || 'pending';
|
|
801
|
+
if (status === 'recorded' || status === 'integrated' || status === 'pending' || status === 'ready') {
|
|
802
|
+
return ok({
|
|
803
|
+
scope: 'task', action: 'none', reason: status, cwd,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
if (status !== 'prepared') {
|
|
807
|
+
return ok({
|
|
808
|
+
scope: 'task', action: 'blocked', reason: 'commit-evidence-mismatch', cwd,
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
if (item.criticalRecovery && item.criticalRecovery.outcome === null) {
|
|
812
|
+
return ok({
|
|
813
|
+
scope: 'task', action: 'blocked', reason: 'critical-recovery-open', cwd,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
if (!item.dispatch) {
|
|
817
|
+
return ok({
|
|
818
|
+
scope: 'task', action: 'dispatch', cwd,
|
|
819
|
+
argv: coordinateArgv('dispatch', blueprint, ledgerRef, { task, item }),
|
|
820
|
+
judge: { kind: 'intent-symbols', fields: [] },
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (item.dispatch.status === 'reported' && item.dispatch.outcome === 'accepted') {
|
|
824
|
+
return ok({
|
|
825
|
+
scope: 'task', action: 'record', cwd,
|
|
826
|
+
argv: coordinateArgv('record', blueprint, ledgerRef, { task, item }),
|
|
827
|
+
judge: { kind: 'record-decision', fields: ['--decision'] },
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (item.dispatch.status === 'reported' && item.dispatch.outcome === 'scope_revision') {
|
|
831
|
+
let currentHash;
|
|
832
|
+
try {
|
|
833
|
+
currentHash = taskBriefHashOf(cwd, blueprint, task);
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
// 문서 부재(ENOENT)만 흡수한다. hash를 못 읽으면 같은 brief라고 단정하지 않는다.
|
|
837
|
+
if (error.code !== 'ENOENT')
|
|
838
|
+
throw error;
|
|
839
|
+
}
|
|
840
|
+
if (currentHash === item.dispatch.task_brief_hash) {
|
|
841
|
+
return ok({
|
|
842
|
+
scope: 'task', action: 'revise', cwd,
|
|
843
|
+
argv: coordinateArgv('revise', blueprint, ledgerRef, { task, item }),
|
|
844
|
+
judge: { kind: 'scope-revision', fields: ['--paths', '--reason'] },
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
// hash가 보고 때와 다르면 이미 개정이 반영된 상태다. 표의 말단
|
|
848
|
+
// commit-evidence-mismatch로 접으면 재디스패치가 막히므로 아래 reported
|
|
849
|
+
// 분기로 떨어뜨린다.
|
|
850
|
+
}
|
|
851
|
+
if (item.dispatch.status === 'reported' && item.dispatch.outcome === 'blocked') {
|
|
852
|
+
return ok({
|
|
853
|
+
scope: 'task', action: 'blocked', reason: 'task-reported-blocked', cwd,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
if (item.dispatch.status === 'reported') {
|
|
857
|
+
return ok({
|
|
858
|
+
scope: 'task', action: 'dispatch', cwd,
|
|
859
|
+
argv: coordinateArgv('dispatch', blueprint, ledgerRef, { task, item }),
|
|
860
|
+
judge: { kind: 'intent-symbols', fields: [] },
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
if (item.dispatch.status !== 'active') {
|
|
864
|
+
return ok({
|
|
865
|
+
scope: 'task', action: 'blocked', reason: 'commit-evidence-mismatch', cwd,
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
const workerHead = git(exec, cwd, ['rev-parse', 'HEAD']);
|
|
869
|
+
const porcelainText = initialWorktreeState(exec, cwd);
|
|
870
|
+
// 현재 attempt 보고가 있으면 기준이 같아도 구현이 끝난 것으로 보고 아래 판정으로 내려간다.
|
|
871
|
+
const reportDone = currentAttemptReportExists(cwd, task, item.dispatch.attempt);
|
|
872
|
+
if (!reportDone && workerHead === item.dispatch.base_head
|
|
873
|
+
&& porcelainText === item.dispatch.initial_worktree_state) {
|
|
874
|
+
// dispatch metadata만 싣는다. 직전 보고는 attachJudgeContext가 payload.report로 붙인다
|
|
875
|
+
// (옛 previous_outcome 키는 쓰지 않는다). light는 run 세션이 같은
|
|
876
|
+
// integration cwd에서 직접 구현하도록 inline을 켠다 — worker 위임이 아니다.
|
|
877
|
+
const payload = {
|
|
878
|
+
attempt: item.dispatch.attempt,
|
|
879
|
+
task_brief_hash: item.dispatch.task_brief_hash,
|
|
880
|
+
base_head: item.dispatch.base_head,
|
|
881
|
+
initial_worktree_state: item.dispatch.initial_worktree_state,
|
|
882
|
+
// dispatch가 발급한 것과 같은 경로: 원장 dispatch에서 다시 계산한다.
|
|
883
|
+
report_path: reportPathFor(item.workerPath || cwd, task, item.dispatch.attempt),
|
|
884
|
+
};
|
|
885
|
+
if (ledger.mode === 'light')
|
|
886
|
+
payload.inline = true;
|
|
887
|
+
// patch는 실행 권한을 넓히지 않는다. implementer는 현재 brief의 affected_paths 안에서만 재사용한다.
|
|
888
|
+
if (ledger.promoted_from?.patch)
|
|
889
|
+
payload.promoted_patch = ledger.promoted_from.patch;
|
|
890
|
+
return ok({ scope: 'task', action: 'implement', cwd, payload });
|
|
891
|
+
}
|
|
892
|
+
const tasksRel = `${blueprint.replaceAll('\\', '/')}/tasks/${task}/tasks.md`;
|
|
893
|
+
const verifyRel = `${blueprint.replaceAll('\\', '/')}/tasks/${task}/verification.md`;
|
|
894
|
+
const reviewRel = `${blueprint.replaceAll('\\', '/')}/tasks/${task}/review.md`;
|
|
895
|
+
const tasksBlock = readBouncerBlock(path.join(cwd, tasksRel));
|
|
896
|
+
const tasksStatus = tasksBlock ? tasksBlock.status : undefined;
|
|
897
|
+
const verifyStatus = bouncerStatus(cwd, verifyRel);
|
|
898
|
+
if (tasksStatus !== 'verified' || verifyStatus !== 'passed') {
|
|
899
|
+
return ok({
|
|
900
|
+
scope: 'task', action: 'verify', cwd,
|
|
901
|
+
argv: ['bouncer', 'validate', '--blueprint', blueprint, '--gate', 'execute'],
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
if (!isBlueprintReviewModeAt(integrationCwd, blueprint)
|
|
905
|
+
&& bouncerStatus(cwd, reviewRel) !== 'accepted') {
|
|
906
|
+
let mergeBase;
|
|
907
|
+
try {
|
|
908
|
+
const integrationHead = typeof ledger.integrationHead === 'string'
|
|
909
|
+
? ledger.integrationHead
|
|
910
|
+
: git(exec, integrationCwd, ['rev-parse', 'HEAD']);
|
|
911
|
+
mergeBase = git(exec, cwd, ['merge-base', integrationHead, workerHead]);
|
|
912
|
+
}
|
|
913
|
+
catch (_error) {
|
|
914
|
+
// merge-base 실패는 추측한 base를 넣지 않고 표의 말단 blocked로 접는다.
|
|
915
|
+
return ok({
|
|
916
|
+
scope: 'task', action: 'blocked', reason: 'commit-evidence-mismatch', cwd,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return ok({
|
|
920
|
+
scope: 'task', action: 'review', cwd,
|
|
921
|
+
argv: [
|
|
922
|
+
'bouncer', 'review-dispatch', 'execute', '--blueprint', blueprint, '--task', task,
|
|
923
|
+
'--base', mergeBase, '--head', workerHead,
|
|
924
|
+
],
|
|
925
|
+
judge: { kind: 'review-round', fields: [] },
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const stamped = tasksBlock ? tasksBlock.commit_sha : undefined;
|
|
929
|
+
if (stamped === undefined || stamped === null || stamped === '') {
|
|
930
|
+
return ok({
|
|
931
|
+
scope: 'task', action: 'commit', cwd,
|
|
932
|
+
argv: ['bouncer', 'commit', '--blueprint', blueprint, '--yes'],
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
// dispatch 때 이미 dirty였던 경로(커밋 전 plan 문서의 seed)는 이 attempt가 만든 변경이 아니다.
|
|
936
|
+
const baseline = new Set(dirtyPaths(item.dispatch.initial_worktree_state));
|
|
937
|
+
const extras = dirtyPaths(porcelainText).filter((rel) => !baseline.has(rel));
|
|
938
|
+
// stamp 규칙은 report·record가 거절에 쓰는 workerCommitEvidence와 같은 함수 하나를 쓴다.
|
|
939
|
+
// 그래야 next가 report를 권한 상태를 CLI가 거절하는 어긋남이 생기지 않는다.
|
|
940
|
+
const taskItem = item;
|
|
941
|
+
const evidence = workerCommitEvidence(exec, cwd, blueprint, taskItem, ledger);
|
|
942
|
+
if (evidence.ok && onlyTaskBriefDirty(extras, blueprint, task)) {
|
|
943
|
+
return ok({
|
|
944
|
+
scope: 'task', action: 'report', cwd,
|
|
945
|
+
argv: coordinateArgv('report', blueprint, ledgerRef, { task, item }, [
|
|
946
|
+
'--attempt', String(item.dispatch.attempt),
|
|
947
|
+
'--task-brief-hash', item.dispatch.task_brief_hash,
|
|
948
|
+
]),
|
|
949
|
+
judge: {
|
|
950
|
+
kind: 'report-outcome',
|
|
951
|
+
fields: ['--outcome', '--summary'],
|
|
952
|
+
allowed: ['accepted', 'rework', 'scope_revision', 'task_change', 'blocked'],
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
if (!evidence.ok) {
|
|
957
|
+
// report·record는 이 두 거절에서 advance의 commit을 안내한다. 커밋할 scope 변경이 실제로
|
|
958
|
+
// 있을 때만 commit을 골라야 한다 — 변경이 없으면 `bouncer commit --yes`가 committed:false로
|
|
959
|
+
// 끝나 stamp를 못 고치므로 advance가 같은 commit 단계를 무한 반복한다.
|
|
960
|
+
const hasCommittable = evidence.reason === 'worker-scope-dirty'
|
|
961
|
+
|| (evidence.reason === 'worker-commit-stamp-mismatch'
|
|
962
|
+
&& workerScopeDirtyPaths(exec, cwd, blueprint, taskItem).length > 0);
|
|
963
|
+
if (hasCommittable) {
|
|
964
|
+
return ok({
|
|
965
|
+
scope: 'task', action: 'commit', cwd,
|
|
966
|
+
argv: ['bouncer', 'commit', '--blueprint', blueprint, '--yes'],
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
// 커밋으로 풀리지 않는 낡은 stamp는 원인 코드를 그대로 드러낸다.
|
|
970
|
+
if (evidence.reason === 'worker-commit-stamp-mismatch') {
|
|
971
|
+
return ok({
|
|
972
|
+
scope: 'task', action: 'blocked', reason: 'worker-commit-stamp-mismatch', cwd,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return ok({
|
|
977
|
+
scope: 'task', action: 'blocked', reason: 'commit-evidence-mismatch', cwd,
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* 원장 decisions의 마지막 kind:report를 복구한다. dispatch 객체는 재디스패치 때
|
|
982
|
+
* attempt·outcome을 덮어쓰므로, 판단 요약은 decisions 쪽만 믿을 수 있다.
|
|
983
|
+
*
|
|
984
|
+
* @param {LedgerTask} item - prepared task
|
|
985
|
+
* @returns {LastReport | undefined} 있으면 payload.report 후보
|
|
986
|
+
*/
|
|
987
|
+
function lastReportedOutcome(item) {
|
|
988
|
+
const decisions = Array.isArray(item.decisions) ? item.decisions : [];
|
|
989
|
+
for (let i = decisions.length - 1; i >= 0; i -= 1) {
|
|
990
|
+
const entry = decisions[i];
|
|
991
|
+
if (entry && entry.kind === 'report' && typeof entry.outcome === 'string'
|
|
992
|
+
&& typeof entry.summary === 'string' && Number.isInteger(entry.attempt)) {
|
|
993
|
+
return { outcome: entry.outcome, summary: entry.summary, attempt: entry.attempt };
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return undefined;
|
|
997
|
+
}
|
|
998
|
+
module.exports = { coordinateNext, NEXT_FAILURE_HINTS, attachJudgeContext };
|