@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,1038 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { isRecord, catchMessage, isEnoentError } = guards;
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { createHash } = require('node:crypto');
|
|
7
|
+
const { isDeepStrictEqual } = require('node:util');
|
|
8
|
+
const { execFileSync: realExecFileSync } = require('node:child_process');
|
|
9
|
+
const paths = require("./paths");
|
|
10
|
+
const { toPosix, parsePathIds } = paths;
|
|
11
|
+
const frontmatter = require("./frontmatter");
|
|
12
|
+
const { readDoc } = frontmatter;
|
|
13
|
+
const schema = require("./schema");
|
|
14
|
+
const { DEFAULT_COMMIT_TYPE, COMMIT_TYPE_ENUM } = schema;
|
|
15
|
+
const validateSections = require("./validate-sections");
|
|
16
|
+
const { REPAIR_WAVE_LIMIT } = validateSections;
|
|
17
|
+
const contextIds = require("./context-ids");
|
|
18
|
+
const { parseContextPath, parsePointerKey: parseContextPointerKey } = contextIds;
|
|
19
|
+
const time = require("./time");
|
|
20
|
+
const { nowIsoKst } = time;
|
|
21
|
+
const GIT_REQUIRED = 'Bouncer requires a Git repository for an active blueprint';
|
|
22
|
+
class BranchNameError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code) {
|
|
25
|
+
super(code);
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function nonEmptyString(value) {
|
|
30
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* blueprint 경로가 slug 형식(`epics/<slug>/blueprints/<slug>`)인지 본다.
|
|
34
|
+
* 번호 형식·해석 불가 경로는 false라 기존(구형) 이름 규칙이 그대로 적용된다.
|
|
35
|
+
*
|
|
36
|
+
* @param {unknown} blueprint - blueprint 디렉터리 상대 경로
|
|
37
|
+
* @returns {boolean} slug 형식이면 true
|
|
38
|
+
*/
|
|
39
|
+
function isSlugBlueprint(blueprint) {
|
|
40
|
+
const ids = parseContextPath(blueprint);
|
|
41
|
+
return ids !== null && ids.blueprintKey !== null && ids.format === 'slug';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* blueprint 메타데이터와 경로에서 coordinator가 공유할 branch 이름을 만든다.
|
|
45
|
+
* index.md가 없거나 읽히지 않으면 기존 fixture와 이전 blueprint를 보존하기 위해
|
|
46
|
+
* DEFAULT_COMMIT_TYPE으로 계속 진행하지만, 명시된 잘못된 commit_type은 새 worktree
|
|
47
|
+
* 생성 전에 거절한다.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ repoRoot: string, blueprint: string, task?: string }} opts - 저장소와 blueprint 식별자
|
|
50
|
+
* @returns {BranchNames} integration·standalone 및 선택 worker branch 이름
|
|
51
|
+
* @throws {BranchNameError} 명시 commit_type 또는 Git ref 형식이 유효하지 않을 때
|
|
52
|
+
*/
|
|
53
|
+
function branchNamesFor({ repoRoot, blueprint, task }) {
|
|
54
|
+
const { epicId, blueprintId } = parsePathIds(blueprint);
|
|
55
|
+
const slugFormat = isSlugBlueprint(blueprint);
|
|
56
|
+
const slug = path.basename(blueprint).replace(new RegExp(`^${blueprintId}-`), '');
|
|
57
|
+
let commitType = DEFAULT_COMMIT_TYPE;
|
|
58
|
+
try {
|
|
59
|
+
const doc = readDoc(path.join(repoRoot, blueprint, 'index.md'));
|
|
60
|
+
const bouncer = doc.data.bouncer;
|
|
61
|
+
if (bouncer && Object.prototype.hasOwnProperty.call(bouncer, 'commit_type'))
|
|
62
|
+
commitType = bouncer.commit_type;
|
|
63
|
+
}
|
|
64
|
+
catch (_error) {
|
|
65
|
+
// index.md 부재·frontmatter 오류는 기존 bootstrap fixture와 같은 폴백이다.
|
|
66
|
+
}
|
|
67
|
+
if (typeof commitType !== 'string' || !COMMIT_TYPE_ENUM.includes(commitType)) {
|
|
68
|
+
throw new BranchNameError('invalid-commit-type');
|
|
69
|
+
}
|
|
70
|
+
// slug 형식은 구분자 `--`로 epic·bp slug를 잇는다. slug 규칙이 `--`를 막으므로 분해가 모호하지
|
|
71
|
+
// 않고, bp slug가 이미 이름이라 뒤에 다시 붙이지 않는다. 구형은 진행 중 drive가 옛 이름으로
|
|
72
|
+
// 끝나도록 번호 이름을 바이트까지 유지한다.
|
|
73
|
+
const integration = slugFormat
|
|
74
|
+
? `${commitType}/${epicId}--${blueprintId}`
|
|
75
|
+
: `${commitType}/${epicId}-${blueprintId}-${slug}`;
|
|
76
|
+
const result = { integration, standalone: integration };
|
|
77
|
+
if (task !== undefined) {
|
|
78
|
+
result.worker = slugFormat
|
|
79
|
+
? `bouncer/${epicId}--${blueprintId}--${task}`
|
|
80
|
+
: `bouncer/${epicId}-${blueprintId}-${task}`;
|
|
81
|
+
}
|
|
82
|
+
for (const branch of Object.values(result)) {
|
|
83
|
+
if (!branch || branch.includes('..') || /[ ~^:?*\\[\\]/.test(branch)) {
|
|
84
|
+
throw new BranchNameError('invalid-branch-name');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 등록된 checkout은 실제 branch를 유지하고, 새 checkout만 예상 branch를 만든다.
|
|
91
|
+
* 같은 branch를 다른 worktree가 잡고 있으면 suffix로 피해 provenance를 흐리지 않고
|
|
92
|
+
* 명시적으로 충돌을 돌려준다.
|
|
93
|
+
*
|
|
94
|
+
* @param {{ repoRoot: string, worktreePath: string, branch: string, execFileSync?: ExecFileSyncFn }} opts
|
|
95
|
+
* - Git 저장소와 예상 checkout/branch
|
|
96
|
+
* @returns {WorktreeBranch} 기존 branch 재사용 또는 새 branch 생성 지시
|
|
97
|
+
* @throws {BranchNameError} ref 형식 오류나 다른 checkout의 branch 충돌일 때
|
|
98
|
+
*/
|
|
99
|
+
function resolveWorktreeBranch({ repoRoot, worktreePath, branch, execFileSync = realExecFileSync }) {
|
|
100
|
+
let listed;
|
|
101
|
+
try {
|
|
102
|
+
listed = String(execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
103
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
catch (_error) {
|
|
107
|
+
listed = '';
|
|
108
|
+
}
|
|
109
|
+
const entries = listed.trim().split(/\n\n+/).filter(Boolean).map((block) => {
|
|
110
|
+
const lines = block.split('\n');
|
|
111
|
+
return { path: lines.find((line) => line.startsWith('worktree '))?.slice(9),
|
|
112
|
+
branch: lines.find((line) => line.startsWith('branch refs/heads/'))?.slice('branch refs/heads/'.length) };
|
|
113
|
+
});
|
|
114
|
+
const existing = entries.find((entry) => entry.path && path.resolve(entry.path) === path.resolve(worktreePath));
|
|
115
|
+
if (existing?.branch)
|
|
116
|
+
return { action: 'reuse', branch: existing.branch };
|
|
117
|
+
try {
|
|
118
|
+
execFileSync('git', ['check-ref-format', '--branch', branch], {
|
|
119
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
catch (_error) {
|
|
123
|
+
throw new BranchNameError('invalid-branch-name');
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
|
|
127
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
128
|
+
});
|
|
129
|
+
throw new BranchNameError('branch-conflict');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof BranchNameError)
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
return { action: 'create', branch };
|
|
136
|
+
}
|
|
137
|
+
function stringList(value, nonEmpty = false) {
|
|
138
|
+
return Array.isArray(value) && (!nonEmpty || value.length > 0)
|
|
139
|
+
&& value.every((entry) => nonEmptyString(entry));
|
|
140
|
+
}
|
|
141
|
+
function dagDecision(value) {
|
|
142
|
+
return Array.isArray(value) && value.every((entry) => Boolean(entry && typeof entry === 'object'
|
|
143
|
+
&& !Array.isArray(entry) && nonEmptyString(entry.id)
|
|
144
|
+
&& stringList(entry.depends_on)));
|
|
145
|
+
}
|
|
146
|
+
/** coordinator가 기록한 task별 critical recovery 한 번의 shape를 고정한다. */
|
|
147
|
+
function validCriticalRecovery(value) {
|
|
148
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
149
|
+
return false;
|
|
150
|
+
const recovery = value;
|
|
151
|
+
return recovery.used === 1 && stringList(recovery.findings, true)
|
|
152
|
+
&& nonEmptyString(recovery.reason)
|
|
153
|
+
&& (recovery.outcome === null || recovery.outcome === 'resolved' || recovery.outcome === 'blocked');
|
|
154
|
+
}
|
|
155
|
+
const DISPATCH_REPORT_OUTCOMES = new Set([
|
|
156
|
+
'accepted', 'rework', 'scope_revision', 'task_change', 'blocked',
|
|
157
|
+
]);
|
|
158
|
+
/**
|
|
159
|
+
* 최신 dispatch attempt 메타 shape. attempt·hash·status가 깨지면 재개 시
|
|
160
|
+
* stale/accepted 판정이 다른 답을 내므로 critical recovery와 같이 엄격히 막는다.
|
|
161
|
+
*
|
|
162
|
+
* @param {unknown} value - task.dispatch 후보
|
|
163
|
+
* @returns {boolean} 유효하면 true
|
|
164
|
+
*/
|
|
165
|
+
function validDispatch(value) {
|
|
166
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
167
|
+
return false;
|
|
168
|
+
const dispatch = value;
|
|
169
|
+
if (!Number.isInteger(dispatch.attempt) || dispatch.attempt < 1)
|
|
170
|
+
return false;
|
|
171
|
+
if (typeof dispatch.task_brief_hash !== 'string'
|
|
172
|
+
|| !/^[a-f0-9]{64}$/.test(dispatch.task_brief_hash))
|
|
173
|
+
return false;
|
|
174
|
+
if (!nonEmptyString(dispatch.base_head))
|
|
175
|
+
return false;
|
|
176
|
+
if (typeof dispatch.initial_worktree_state !== 'string')
|
|
177
|
+
return false;
|
|
178
|
+
if (dispatch.status !== 'active' && dispatch.status !== 'reported')
|
|
179
|
+
return false;
|
|
180
|
+
if (dispatch.status === 'reported') {
|
|
181
|
+
if (!DISPATCH_REPORT_OUTCOMES.has(dispatch.outcome))
|
|
182
|
+
return false;
|
|
183
|
+
if (!nonEmptyString(dispatch.summary))
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* partial close가 신뢰하는 canonical repair 항목의 전체 shape를 검사한다.
|
|
190
|
+
* task/wave 식별자만 맞춘 복사본은 scope·DAG·실패 근거를 바꿔치기할 수 있으므로,
|
|
191
|
+
* 원장 사본 비교 전에 각 필드와 실제 실패 경로를 먼저 고정한다.
|
|
192
|
+
* `failure.findings`는 리뷰 원인 repair만 갖는다. 키가 없으면 CI 증적으로
|
|
193
|
+
* 읽고, 있으면 비어 있지 않은 문자열 배열이어야 한다 — 문자열 하나로는
|
|
194
|
+
* 반복 `--review-finding` 순서를 보존할 수 없다.
|
|
195
|
+
*
|
|
196
|
+
* @param {unknown} value - repair 결정 후보
|
|
197
|
+
* @param {number} expectedWave - 1부터 세는 기대 wave
|
|
198
|
+
* @returns {boolean} canonical repair 결정이면 true
|
|
199
|
+
*/
|
|
200
|
+
function validRepairDecision(value, expectedWave) {
|
|
201
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
202
|
+
return false;
|
|
203
|
+
const decision = value;
|
|
204
|
+
const failure = decision.failure;
|
|
205
|
+
if (!failure || typeof failure !== 'object' || Array.isArray(failure))
|
|
206
|
+
return false;
|
|
207
|
+
const evidence = failure;
|
|
208
|
+
if (evidence.findings !== undefined && !stringList(evidence.findings, true))
|
|
209
|
+
return false;
|
|
210
|
+
return decision.kind === 'repair' && decision.wave === expectedWave
|
|
211
|
+
&& nonEmptyString(decision.task) && nonEmptyString(decision.reason)
|
|
212
|
+
&& nonEmptyString(evidence.task) && nonEmptyString(evidence.command)
|
|
213
|
+
&& nonEmptyString(evidence.summary) && stringList(evidence.paths, true)
|
|
214
|
+
&& typeof evidence.exitCode === 'number' && evidence.exitCode !== 0
|
|
215
|
+
&& evidence.repairWave === expectedWave - 1
|
|
216
|
+
&& dagDecision(decision.previousDag) && dagDecision(decision.nextDag)
|
|
217
|
+
&& stringList(decision.previousScope) && stringList(decision.nextScope, true)
|
|
218
|
+
&& nonEmptyString(decision.necessity)
|
|
219
|
+
&& nonEmptyString(decision.revision) && /^r[1-9]\d*$/.test(decision.revision);
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* coordinator 원장의 repair/partial-close 불변조건을 검증한다.
|
|
223
|
+
* 활성 원장은 레거시 필드 부재를 허용하지만 partial_closed는 두 wave, 마지막
|
|
224
|
+
* 실패 증적, 명시적 사용자 확인이 모두 있어야만 유효하다.
|
|
225
|
+
*
|
|
226
|
+
* @param {unknown} value - 파싱된 coordinator 원장
|
|
227
|
+
* @returns {{ok: true} | {ok: false, reason: string}} 검증 결과
|
|
228
|
+
*/
|
|
229
|
+
function validateCoordinatorLedger(value, options = {}) {
|
|
230
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
231
|
+
return { ok: false, reason: 'invalid-ledger' };
|
|
232
|
+
}
|
|
233
|
+
const ledger = value;
|
|
234
|
+
if (ledger.seedManifest !== undefined && (!Array.isArray(ledger.seedManifest)
|
|
235
|
+
|| ledger.seedManifest.some((entry) => !entry || typeof entry !== 'object' || Array.isArray(entry)
|
|
236
|
+
|| !nonEmptyString(entry.path)
|
|
237
|
+
|| !/^[a-f0-9]{64}$/.test(entry.sha256)))) {
|
|
238
|
+
return { ok: false, reason: 'invalid-seed-manifest' };
|
|
239
|
+
}
|
|
240
|
+
// partial_closed 여부와 무관하게 먼저 검사한다. 조기 성공 반환 뒤에 두면 active
|
|
241
|
+
// ledger가 used 2를 품은 채 다음 coordinator 명령의 기준점이 될 수 있다.
|
|
242
|
+
const tasksForRecovery = Array.isArray(ledger.tasks) ? ledger.tasks : [];
|
|
243
|
+
if (tasksForRecovery.some((task) => task.criticalRecovery !== undefined
|
|
244
|
+
&& !validCriticalRecovery(task.criticalRecovery))) {
|
|
245
|
+
return { ok: false, reason: 'critical-recovery-invalid' };
|
|
246
|
+
}
|
|
247
|
+
// dispatch는 레거시 원장에 없을 수 있다. 필드가 있을 때만 shape를 강제해
|
|
248
|
+
// 손상 attempt가 재개 기준이 되는 일을 막는다.
|
|
249
|
+
if (tasksForRecovery.some((task) => task.dispatch !== undefined
|
|
250
|
+
&& !validDispatch(task.dispatch))) {
|
|
251
|
+
return { ok: false, reason: 'dispatch-invalid' };
|
|
252
|
+
}
|
|
253
|
+
// lease·leaseSeq도 필드가 있을 때만 검사한다. 손상 lease가 재개 기준이 되면
|
|
254
|
+
// revoke·stale 판정이 갈라지므로 dispatch와 같은 엄격도로 막는다.
|
|
255
|
+
const { isValidLease, isValidLeaseSeq } = require('./lease');
|
|
256
|
+
if (!isValidLeaseSeq(ledger.leaseSeq)) {
|
|
257
|
+
return { ok: false, reason: 'lease-invalid' };
|
|
258
|
+
}
|
|
259
|
+
if (tasksForRecovery.some((task) => task.lease !== undefined && !isValidLease(task.lease))) {
|
|
260
|
+
return { ok: false, reason: 'lease-invalid' };
|
|
261
|
+
}
|
|
262
|
+
// fanin은 부재(legacy)·null(비움)·shape 맞는 객체만 허용한다. 잘못된 status·
|
|
263
|
+
// tasks면 integrate 재개가 잘못된 candidate를 기준으로 삼을 수 있어 여기서 막는다.
|
|
264
|
+
if (ledger.fanin !== undefined && !validCoordinatorFanin(ledger.fanin)) {
|
|
265
|
+
return { ok: false, reason: 'fanin-invalid' };
|
|
266
|
+
}
|
|
267
|
+
// branch 필드는 이전 원장에는 없을 수 있지만, 있으면 이후 재개가 Git의 실제
|
|
268
|
+
// checkout을 신뢰할 수 있도록 문자열이어야 한다. 여기서 느슨하게 받으면
|
|
269
|
+
// prepare가 잘못된 값을 정상 branch 기록으로 덮어쓴 것처럼 보일 수 있다.
|
|
270
|
+
if (ledger.integrationBranch !== undefined && !nonEmptyString(ledger.integrationBranch)) {
|
|
271
|
+
return { ok: false, reason: 'invalid-ledger-branch' };
|
|
272
|
+
}
|
|
273
|
+
if (Array.isArray(ledger.tasks) && ledger.tasks.some((entry) => entry && typeof entry === 'object'
|
|
274
|
+
&& entry.branch !== undefined
|
|
275
|
+
&& !nonEmptyString(entry.branch))) {
|
|
276
|
+
return { ok: false, reason: 'invalid-ledger-branch' };
|
|
277
|
+
}
|
|
278
|
+
if (options.requirePartialClose && ledger.status !== 'awaiting_confirmation') {
|
|
279
|
+
return { ok: false, reason: 'partial-close-awaiting-confirmation-required' };
|
|
280
|
+
}
|
|
281
|
+
const waves = ledger.repairWaves === undefined ? [] : ledger.repairWaves;
|
|
282
|
+
if (!Array.isArray(waves) || waves.length > REPAIR_WAVE_LIMIT)
|
|
283
|
+
return { ok: false, reason: 'repair-wave-limit' };
|
|
284
|
+
if (ledger.status !== 'partial_closed')
|
|
285
|
+
return { ok: true };
|
|
286
|
+
const handoff = ledger.handoff;
|
|
287
|
+
if (!handoff || typeof handoff !== 'object' || Array.isArray(handoff)) {
|
|
288
|
+
return { ok: false, reason: 'partial-close-handoff-required' };
|
|
289
|
+
}
|
|
290
|
+
const handoffRef = handoff;
|
|
291
|
+
if (!nonEmptyString(handoffRef.path) || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+09:00$/.test(handoffRef.timestamp)
|
|
292
|
+
|| !nonEmptyString(handoffRef.source_digest)) {
|
|
293
|
+
return { ok: false, reason: 'partial-close-handoff-invalid' };
|
|
294
|
+
}
|
|
295
|
+
if (waves.length !== REPAIR_WAVE_LIMIT)
|
|
296
|
+
return { ok: false, reason: 'partial-close-repair-waves-required' };
|
|
297
|
+
const repairs = waves;
|
|
298
|
+
if (repairs.some((entry, index) => !validRepairDecision(entry, index + 1))) {
|
|
299
|
+
return { ok: false, reason: 'partial-close-repair-decision-required' };
|
|
300
|
+
}
|
|
301
|
+
const decisions = Array.isArray(ledger.decisions) ? ledger.decisions : [];
|
|
302
|
+
const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
|
|
303
|
+
let previousGlobalIndex = -1;
|
|
304
|
+
for (const repair of repairs) {
|
|
305
|
+
// repairWaves가 canonical이고 task/global 항목은 그 전체 deep copy여야 한다.
|
|
306
|
+
// 부분 key 비교는 서로 다른 scope나 failure evidence를 같은 결정으로 오인한다.
|
|
307
|
+
const matches = (entry) => isDeepStrictEqual(entry, repair);
|
|
308
|
+
const globalIndex = decisions.findIndex(matches);
|
|
309
|
+
if (globalIndex < 0)
|
|
310
|
+
return { ok: false, reason: 'partial-close-global-repair-log-required' };
|
|
311
|
+
if (globalIndex <= previousGlobalIndex) {
|
|
312
|
+
return { ok: false, reason: 'partial-close-repair-waves-ordered-required' };
|
|
313
|
+
}
|
|
314
|
+
previousGlobalIndex = globalIndex;
|
|
315
|
+
const task = tasks.find((entry) => entry && entry.id === repair.task);
|
|
316
|
+
if (!task || !Array.isArray(task.decisions) || !task.decisions.some(matches)) {
|
|
317
|
+
return { ok: false, reason: 'partial-close-task-repair-log-required' };
|
|
318
|
+
}
|
|
319
|
+
if (task.status !== 'integrated') {
|
|
320
|
+
return { ok: false, reason: 'partial-close-repair-wave-not-integrated' };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const failure = ledger.terminalFailure;
|
|
324
|
+
if (!failure || typeof failure !== 'object' || Array.isArray(failure)) {
|
|
325
|
+
return { ok: false, reason: 'partial-close-failure-evidence-required' };
|
|
326
|
+
}
|
|
327
|
+
const evidence = failure;
|
|
328
|
+
if (!nonEmptyString(evidence.task) || !nonEmptyString(evidence.command)
|
|
329
|
+
|| !nonEmptyString(evidence.summary) || !stringList(evidence.paths, true)
|
|
330
|
+
|| typeof evidence.exitCode !== 'number') {
|
|
331
|
+
return { ok: false, reason: 'partial-close-failure-evidence-required' };
|
|
332
|
+
}
|
|
333
|
+
if (evidence.exitCode === 0 || evidence.repairWave !== 2) {
|
|
334
|
+
return { ok: false, reason: 'partial-close-post-wave2-nonzero-failure-required' };
|
|
335
|
+
}
|
|
336
|
+
const terminal = tasks.find((entry) => entry && entry.id === evidence.task);
|
|
337
|
+
if (!terminal || terminal.execution_kind !== 'verification' || terminal.status !== 'verifying') {
|
|
338
|
+
return { ok: false, reason: 'partial-close-terminal-failure-state-required' };
|
|
339
|
+
}
|
|
340
|
+
if (handoffRef.source_digest !== evidence.source_digest) {
|
|
341
|
+
return { ok: false, reason: 'partial-close-handoff-source-mismatch' };
|
|
342
|
+
}
|
|
343
|
+
if (ledger.userConfirmed !== true)
|
|
344
|
+
return { ok: false, reason: 'partial-close-user-confirmation-required' };
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
// coordinator.ts fence·checkpoint와 같은 상대 경로 문자열이어야 path/hash 쌍이
|
|
348
|
+
// 모듈마다 다른 정본을 가리키지 않는다. 한 상수만 export하고 양쪽에서 import한다.
|
|
349
|
+
const COORDINATOR_LEDGER_REL = '.bouncer/runtime/coordinator.json';
|
|
350
|
+
const COMPLETED_SUMMARY_FORBIDDEN = new Set([
|
|
351
|
+
'dispatch', 'decisions', 'workerPath', 'branch', 'criticalRecovery',
|
|
352
|
+
]);
|
|
353
|
+
/**
|
|
354
|
+
* compact coordinator checkpoint와 원장 참조 shape를 검증한다. 완료 summary에
|
|
355
|
+
* dispatch·decisions 본문이 실리면 status compaction이 무의미해지므로 여기서 막는다.
|
|
356
|
+
* `recent_failure`는 null이어도 키가 있어야 status가 실패 증적 유무를 놓치지 않는다.
|
|
357
|
+
*
|
|
358
|
+
* @param {unknown} value - projectCheckpoint 결과 후보
|
|
359
|
+
* @returns {{ok: true} | {ok: false, reason: string}} 검증 결과
|
|
360
|
+
*/
|
|
361
|
+
function validateCoordinatorCheckpoint(value) {
|
|
362
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
363
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
364
|
+
}
|
|
365
|
+
const checkpoint = value;
|
|
366
|
+
if (!Array.isArray(checkpoint.ready) || !checkpoint.ready.every((id) => nonEmptyString(id))) {
|
|
367
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(checkpoint.active_tasks) || !Array.isArray(checkpoint.completed_tasks)
|
|
370
|
+
|| !Array.isArray(checkpoint.unresolved_decisions)) {
|
|
371
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
372
|
+
}
|
|
373
|
+
// notes는 구 checkpoint 호환을 위해 선택이지만, 있으면 각 항목이 비지 않은 text·written_at을 가져야 한다.
|
|
374
|
+
if (Object.prototype.hasOwnProperty.call(checkpoint, 'notes')) {
|
|
375
|
+
if (!Array.isArray(checkpoint.notes) || !checkpoint.notes.every((n) => (!!n && typeof n === 'object' && !Array.isArray(n)
|
|
376
|
+
&& nonEmptyString(n.text)
|
|
377
|
+
&& nonEmptyString(n.written_at)))) {
|
|
378
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// 키 부재와 null은 다르다. null은 "실패 없음"이고 키 누락은 projection 결함이다.
|
|
382
|
+
if (!Object.prototype.hasOwnProperty.call(checkpoint, 'recent_failure')) {
|
|
383
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
384
|
+
}
|
|
385
|
+
if (checkpoint.recent_failure !== null) {
|
|
386
|
+
if (!checkpoint.recent_failure || typeof checkpoint.recent_failure !== 'object'
|
|
387
|
+
|| Array.isArray(checkpoint.recent_failure)) {
|
|
388
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const entry of checkpoint.completed_tasks) {
|
|
392
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
393
|
+
return { ok: false, reason: 'invalid-completed-summary' };
|
|
394
|
+
}
|
|
395
|
+
const summary = entry;
|
|
396
|
+
if (!nonEmptyString(summary.id) || !nonEmptyString(summary.status)) {
|
|
397
|
+
return { ok: false, reason: 'invalid-completed-summary' };
|
|
398
|
+
}
|
|
399
|
+
for (const key of COMPLETED_SUMMARY_FORBIDDEN) {
|
|
400
|
+
if (Object.prototype.hasOwnProperty.call(summary, key)) {
|
|
401
|
+
return { ok: false, reason: 'invalid-completed-summary' };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
for (const entry of checkpoint.active_tasks) {
|
|
406
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
407
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
408
|
+
}
|
|
409
|
+
if (!nonEmptyString(entry.id)
|
|
410
|
+
|| !nonEmptyString(entry.status)) {
|
|
411
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const ledger = checkpoint.ledger;
|
|
415
|
+
if (!ledger || typeof ledger !== 'object' || Array.isArray(ledger)) {
|
|
416
|
+
return { ok: false, reason: 'invalid-ledger-ref' };
|
|
417
|
+
}
|
|
418
|
+
const ref = ledger;
|
|
419
|
+
if (typeof ref.path !== 'string' || path.isAbsolute(ref.path) || ref.path !== COORDINATOR_LEDGER_REL
|
|
420
|
+
|| ref.path.includes('..')) {
|
|
421
|
+
return { ok: false, reason: 'invalid-ledger-ref' };
|
|
422
|
+
}
|
|
423
|
+
if (typeof ref.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(ref.sha256)) {
|
|
424
|
+
return { ok: false, reason: 'invalid-ledger-ref' };
|
|
425
|
+
}
|
|
426
|
+
if (ref.revision !== null && !nonEmptyString(ref.revision)) {
|
|
427
|
+
return { ok: false, reason: 'invalid-ledger-ref' };
|
|
428
|
+
}
|
|
429
|
+
if (checkpoint.integration_head !== null && !nonEmptyString(checkpoint.integration_head)) {
|
|
430
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
431
|
+
}
|
|
432
|
+
if (checkpoint.revision !== null && !nonEmptyString(checkpoint.revision)) {
|
|
433
|
+
return { ok: false, reason: 'invalid-checkpoint' };
|
|
434
|
+
}
|
|
435
|
+
return { ok: true };
|
|
436
|
+
}
|
|
437
|
+
function runtimePaths({ repoRoot, execFileSync = realExecFileSync,
|
|
438
|
+
// env/platform은 호출부 호환용; worktree는 이제 repo 내부.
|
|
439
|
+
platform = process.platform, }) {
|
|
440
|
+
const pathApi = platform === 'win32' ? path.win32 : path;
|
|
441
|
+
let commonDir;
|
|
442
|
+
try {
|
|
443
|
+
// encoding utf8이라 런타임은 문자열이다. Node 오버로드 유니온이 trim을 막아
|
|
444
|
+
// 단언만 한다 — String()으로 감싸면 Buffer 경로의 표현이 달라진다.
|
|
445
|
+
commonDir = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
446
|
+
cwd: repoRoot,
|
|
447
|
+
encoding: 'utf8',
|
|
448
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
449
|
+
}).trim();
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
return { unavailable: true, reason: catchMessage(error) || 'Git common directory unavailable' };
|
|
453
|
+
}
|
|
454
|
+
if (!commonDir) {
|
|
455
|
+
return { unavailable: true, reason: 'Git common directory unavailable' };
|
|
456
|
+
}
|
|
457
|
+
const commonGitDir = pathApi.resolve(repoRoot, commonDir);
|
|
458
|
+
// dirname(.git)은 일반 repo의 main worktree root이므로, linked checkout이
|
|
459
|
+
// 자기 아래에 중첩되지 않고 같은 `.worktrees/`를 공유한다.
|
|
460
|
+
// projectRoot는 그 값을 스킬·훅 소비용으로 노출한다 — Git 계산을
|
|
461
|
+
// 스킬이나 별도 helper에서 복제하지 않기 위한 단일 정본.
|
|
462
|
+
const mainRoot = pathApi.dirname(commonGitDir);
|
|
463
|
+
const bouncerDir = pathApi.join(commonGitDir, 'bouncer');
|
|
464
|
+
return {
|
|
465
|
+
commonGitDir,
|
|
466
|
+
currentFile: pathApi.join(bouncerDir, 'current'),
|
|
467
|
+
// 레거시 단일 파일과 namespace 루트를 같이 노출한다. 이관은 current.ts가
|
|
468
|
+
// 조합하고, 여기서는 경로만 계산한다 — 두 저장소를 한 경로로 합치면
|
|
469
|
+
// 이관 실패 시 어느 쪽이 남았는지 호출부가 구분하지 못한다.
|
|
470
|
+
pointersRoot: pathApi.join(bouncerDir, 'pointers'),
|
|
471
|
+
// pointers와 형제로 둔다. 승인 파일을 포인터 JSON에 섞으면
|
|
472
|
+
// parsePointerBody가 모르는 키를 버려 재승인 증적이 사라진다.
|
|
473
|
+
approvalsRoot: pathApi.join(bouncerDir, 'approvals'),
|
|
474
|
+
worktreeRoot: pathApi.join(mainRoot, '.worktrees'),
|
|
475
|
+
projectRoot: mainRoot,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function resolvedPaths({ repoRoot, deps }) {
|
|
479
|
+
const d = deps || {};
|
|
480
|
+
return runtimePaths({
|
|
481
|
+
repoRoot,
|
|
482
|
+
execFileSync: d.execFileSync,
|
|
483
|
+
env: d.env,
|
|
484
|
+
platform: d.platform,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* 레거시 `<git-common-dir>/bouncer/current`만 읽는다. namespace 전환 뒤에는
|
|
489
|
+
* 이 파일이 없을 수 있다. current.ts의 충돌·이관 판정은 실제 레거시 파일이
|
|
490
|
+
* 필요한데, `readRuntimeCurrent`는 유일한 namespace 키를 돌려주므로 둘을
|
|
491
|
+
* 섞으면 병렬 `--set`을 레거시 충돌로 오인한다.
|
|
492
|
+
*
|
|
493
|
+
* @param {{ repoRoot: string, deps?: RuntimeDeps | null }} opts - 저장소 루트와 주입 의존성
|
|
494
|
+
* @returns {RuntimePointer | null} 파싱된 레거시 포인터. 없거나 깨지면 null
|
|
495
|
+
*/
|
|
496
|
+
function readLegacyRuntimeCurrent({ repoRoot, deps }) {
|
|
497
|
+
const d = { fs, ...(deps || {}) };
|
|
498
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
499
|
+
if (paths.unavailable || !d.fs.existsSync(paths.currentFile))
|
|
500
|
+
return null;
|
|
501
|
+
try {
|
|
502
|
+
const data = JSON.parse(d.fs.readFileSync(paths.currentFile, 'utf8').trim());
|
|
503
|
+
if (!data || typeof data.blueprint !== 'string' || typeof data.base !== 'string')
|
|
504
|
+
return null;
|
|
505
|
+
// task 키 부재·비문자열은 미지정. 마이그레이션 없이 기존 파일을 그대로 읽는다.
|
|
506
|
+
const task = typeof data.task === 'string' ? toPosix(data.task) : null;
|
|
507
|
+
return { blueprint: toPosix(data.blueprint), base: data.base, task };
|
|
508
|
+
}
|
|
509
|
+
catch (_error) {
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* 활성 포인터 primitive. migrate-task-layout·import-history는 namespace
|
|
515
|
+
* 열거를 모르므로, 유일한 namespace 키가 있으면 그것을 돌려 첫 `--set`
|
|
516
|
+
* 이후에도 포인터가 보이게 한다. 키가 없으면 레거시 파일로 폴백한다.
|
|
517
|
+
* 깨진 namespace 파일은 null로 숨기지 않는다 — 다른 키를 고르거나 레거시로
|
|
518
|
+
* 넘어가면 손상된 주기를 삼킨다. 경로와 실패 원인을 담아 throw한다.
|
|
519
|
+
*
|
|
520
|
+
* @param {{ repoRoot: string, deps?: RuntimeDeps | null }} opts - 저장소 루트와 주입 의존성
|
|
521
|
+
* @returns {RuntimePointer | null} 유일한 namespace 포인터, 없으면 레거시, 둘 다 없으면 null
|
|
522
|
+
* @throws {Error} 깨진 namespace 파일 또는 키가 둘 이상일 때. 메시지에 경로를 포함한다
|
|
523
|
+
*/
|
|
524
|
+
function readRuntimeCurrent({ repoRoot, deps }) {
|
|
525
|
+
const listed = listNamespacePointers({ repoRoot, deps });
|
|
526
|
+
const issues = listed.filter((e) => e.issue);
|
|
527
|
+
if (issues.length > 0) {
|
|
528
|
+
const detail = issues
|
|
529
|
+
.map((e) => `${e.issue.path}: ${e.issue.reason}`)
|
|
530
|
+
.join('; ');
|
|
531
|
+
throw new Error(detail);
|
|
532
|
+
}
|
|
533
|
+
const valid = listed.filter((e) => e.pointer != null);
|
|
534
|
+
if (valid.length > 1) {
|
|
535
|
+
throw new Error(`ambiguous namespace pointers: ${valid.map((e) => e.path).join(', ')}`);
|
|
536
|
+
}
|
|
537
|
+
if (valid.length === 1)
|
|
538
|
+
return valid[0].pointer;
|
|
539
|
+
return readLegacyRuntimeCurrent({ repoRoot, deps });
|
|
540
|
+
}
|
|
541
|
+
// pointer가 제거되면 true, 없었으면 false. 호출자는 "이미 없음"을
|
|
542
|
+
// 성공으로 보므로, 파일이 없어도 throw하면 안 된다.
|
|
543
|
+
function clearRuntimeCurrent({ repoRoot, deps }) {
|
|
544
|
+
const d = { fs, ...(deps || {}) };
|
|
545
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
546
|
+
if (paths.unavailable || !d.fs.existsSync(paths.currentFile))
|
|
547
|
+
return false;
|
|
548
|
+
d.fs.rmSync(paths.currentFile);
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* blueprint 경로에서 세 자리 epic·blueprint id로 namespace 키를 만든다.
|
|
553
|
+
* 경로 문자열이나 worktree 위치를 키로 쓰지 않는다 — 같은 문서가 다른
|
|
554
|
+
* checkout에서 다른 파일로 갈라지면 병렬 주기가 서로 덮어쓴다.
|
|
555
|
+
*
|
|
556
|
+
* @param {unknown} blueprint - blueprint 디렉터리 상대 경로
|
|
557
|
+
* @returns {{ epicId: string, blueprintId: string, key: string }} `key`는 `epicId/blueprintId`
|
|
558
|
+
*/
|
|
559
|
+
function pointerKeyFromBlueprint(blueprint) {
|
|
560
|
+
const { epicId, blueprintId } = parsePathIds(blueprint);
|
|
561
|
+
if (!epicId || !blueprintId) {
|
|
562
|
+
throw new Error(`Cannot derive epic/blueprint ids from blueprint path: ${blueprint}`);
|
|
563
|
+
}
|
|
564
|
+
return { epicId, blueprintId, key: `${epicId}/${blueprintId}` };
|
|
565
|
+
}
|
|
566
|
+
function pathApiFor(platform) {
|
|
567
|
+
return platform === 'win32' ? path.win32 : path;
|
|
568
|
+
}
|
|
569
|
+
function namespacePointerPath(pointersRoot, epicId, blueprintId, pathApi) {
|
|
570
|
+
return pathApi.join(pointersRoot, epicId, `${blueprintId}.json`);
|
|
571
|
+
}
|
|
572
|
+
// 형식 판단은 context-ids 한 곳에 위임한다 — 번호 쌍과 slug 쌍만 받고 섞인 쌍은 null이다.
|
|
573
|
+
function parsePointerKey(key) {
|
|
574
|
+
const parsed = parseContextPointerKey(key);
|
|
575
|
+
if (!parsed)
|
|
576
|
+
return null;
|
|
577
|
+
return { epicId: parsed.epicKey, blueprintId: parsed.blueprintKey, key: key };
|
|
578
|
+
}
|
|
579
|
+
function parsePointerBody(raw) {
|
|
580
|
+
try {
|
|
581
|
+
const data = JSON.parse(raw.trim());
|
|
582
|
+
if (!data || typeof data.blueprint !== 'string' || typeof data.base !== 'string') {
|
|
583
|
+
return { reason: 'missing blueprint or base' };
|
|
584
|
+
}
|
|
585
|
+
const task = typeof data.task === 'string' ? toPosix(data.task) : null;
|
|
586
|
+
return { pointer: { blueprint: toPosix(data.blueprint), base: data.base, task } };
|
|
587
|
+
}
|
|
588
|
+
catch (error) {
|
|
589
|
+
return { reason: catchMessage(error) || 'invalid JSON' };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function isDir(d, abs) {
|
|
593
|
+
try {
|
|
594
|
+
return d.fs.statSync(abs).isDirectory();
|
|
595
|
+
}
|
|
596
|
+
catch (_e) {
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* namespace 포인터 파일을 열거한다. 깨진 파일은 건너뛰지 않고 issue로 남긴다 —
|
|
602
|
+
* null로 숨기면 다른 키가 선택된 것처럼 보여 병렬 주기를 덮어쓴다.
|
|
603
|
+
*
|
|
604
|
+
* @param {{ repoRoot: string, deps?: RuntimeDeps | null }} opts - 저장소 루트와 주입 의존성
|
|
605
|
+
* @returns {NamespaceEntry[]} 키(`epic/blueprint`) 사전순
|
|
606
|
+
*/
|
|
607
|
+
function listNamespacePointers({ repoRoot, deps }) {
|
|
608
|
+
const d = { fs, ...(deps || {}) };
|
|
609
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
610
|
+
if (paths.unavailable || !paths.pointersRoot)
|
|
611
|
+
return [];
|
|
612
|
+
const pathApi = pathApiFor(d.platform);
|
|
613
|
+
const root = paths.pointersRoot;
|
|
614
|
+
if (!d.fs.existsSync(root) || !isDir(d, root))
|
|
615
|
+
return [];
|
|
616
|
+
const entries = [];
|
|
617
|
+
let epicNames;
|
|
618
|
+
try {
|
|
619
|
+
epicNames = d.fs.readdirSync(root);
|
|
620
|
+
}
|
|
621
|
+
catch (_e) {
|
|
622
|
+
return [];
|
|
623
|
+
}
|
|
624
|
+
for (const epicName of epicNames) {
|
|
625
|
+
// 번호·slug 어느 epic 디렉터리 이름이든 파일 단계에서 쌍을 검증한다.
|
|
626
|
+
if (!/^[a-z0-9-]+$/.test(epicName))
|
|
627
|
+
continue;
|
|
628
|
+
const epicDir = pathApi.join(root, epicName);
|
|
629
|
+
if (!isDir(d, epicDir))
|
|
630
|
+
continue;
|
|
631
|
+
let files;
|
|
632
|
+
try {
|
|
633
|
+
files = d.fs.readdirSync(epicDir);
|
|
634
|
+
}
|
|
635
|
+
catch (_e) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
for (const file of files) {
|
|
639
|
+
const idMatch = /^([a-z0-9-]+)\.json$/.exec(file);
|
|
640
|
+
if (!idMatch)
|
|
641
|
+
continue;
|
|
642
|
+
const blueprintId = idMatch[1];
|
|
643
|
+
const key = `${epicName}/${blueprintId}`;
|
|
644
|
+
// 두 이름이 같은 형식의 쌍이 아니면(예: `001/registry-gates`) 기존처럼 조용히 건너뛴다.
|
|
645
|
+
if (!parsePointerKey(key))
|
|
646
|
+
continue;
|
|
647
|
+
const filePath = pathApi.join(epicDir, file);
|
|
648
|
+
try {
|
|
649
|
+
const parsed = parsePointerBody(d.fs.readFileSync(filePath, 'utf8'));
|
|
650
|
+
if ('pointer' in parsed) {
|
|
651
|
+
entries.push({ key, path: filePath, pointer: parsed.pointer });
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
entries.push({
|
|
655
|
+
key, path: filePath, pointer: null,
|
|
656
|
+
issue: { path: filePath, reason: parsed.reason },
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
entries.push({
|
|
662
|
+
key, path: filePath, pointer: null,
|
|
663
|
+
issue: { path: filePath, reason: catchMessage(error) || 'unreadable pointer file' },
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
669
|
+
return entries;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* namespace 파일에 포인터를 원자적으로 쓴다. 레거시 파일은 건드리지 않는다 —
|
|
673
|
+
* 이관 삭제는 쓰기가 성공한 뒤에야 current.ts가 수행한다.
|
|
674
|
+
*
|
|
675
|
+
* @param {{ repoRoot: string, blueprint: unknown, base: string, task?: unknown, deps?: RuntimeDeps | null }} opts
|
|
676
|
+
* @returns {string} 쓴 namespace 파일 절대 경로
|
|
677
|
+
*/
|
|
678
|
+
function writeRuntimeCurrent({ repoRoot, blueprint, base, task, deps, }) {
|
|
679
|
+
const d = { fs, ...(deps || {}) };
|
|
680
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
681
|
+
if (paths.unavailable)
|
|
682
|
+
throw new Error(GIT_REQUIRED);
|
|
683
|
+
const { epicId, blueprintId } = pointerKeyFromBlueprint(blueprint);
|
|
684
|
+
const pathApi = pathApiFor(d.platform);
|
|
685
|
+
const target = namespacePointerPath(paths.pointersRoot, epicId, blueprintId, pathApi);
|
|
686
|
+
d.fs.mkdirSync(pathApi.dirname(target), { recursive: true });
|
|
687
|
+
// task는 문자열일 때만 파일에 쓴다 — 없으면 키 자체를 생략해 레거시 형태를 유지.
|
|
688
|
+
const data = {
|
|
689
|
+
blueprint: toPosix(blueprint),
|
|
690
|
+
base,
|
|
691
|
+
};
|
|
692
|
+
if (typeof task === 'string')
|
|
693
|
+
data.task = toPosix(task);
|
|
694
|
+
const payload = `${JSON.stringify(data, null, 2)}\n`;
|
|
695
|
+
// 같은 디렉터리에 tmp를 만들고 rename으로 교체한다. 다른 볼륨의 tmp는
|
|
696
|
+
// rename이 copy+unlink가 되어 이관 전에 레거시를 지운 것과 같은 구멍이 난다.
|
|
697
|
+
const tmp = pathApi.join(pathApi.dirname(target), `.${blueprintId}.${process.pid}.${Date.now()}.tmp`);
|
|
698
|
+
d.fs.writeFileSync(tmp, payload);
|
|
699
|
+
try {
|
|
700
|
+
d.fs.renameSync(tmp, target);
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
try {
|
|
704
|
+
d.fs.rmSync(tmp);
|
|
705
|
+
}
|
|
706
|
+
catch (_cleanup) {
|
|
707
|
+
// tmp 정리는 best-effort. rename 실패 원인을 가리면 호출부가 재시도 지점을 잃는다.
|
|
708
|
+
}
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
return target;
|
|
712
|
+
}
|
|
713
|
+
function approvalSnapshotPath(approvalsRoot, epicId, blueprintId, pathApi) {
|
|
714
|
+
return pathApi.join(approvalsRoot, epicId, `${blueprintId}.json`);
|
|
715
|
+
}
|
|
716
|
+
/**
|
|
717
|
+
* 같은 디렉터리 tmp→rename으로 파일을 교체한다.
|
|
718
|
+
* OS tmp를 쓰면 볼륨이 다를 때 rename이 copy+unlink가 되어 부분 파일이 남는다.
|
|
719
|
+
*
|
|
720
|
+
* @param {{ fs: InjectedFs }} d - 주입 가능 fs
|
|
721
|
+
* @param {{ join: (...parts: string[]) => string, dirname: (p: string) => string }} pathApi - 플랫폼 path
|
|
722
|
+
* @param {string} target - 최종 파일 절대 경로
|
|
723
|
+
* @param {string} payload - 쓸 문자열
|
|
724
|
+
* @param {string} tmpName - 같은 디렉터리에 만들 tmp 파일명
|
|
725
|
+
* @returns {void}
|
|
726
|
+
*/
|
|
727
|
+
function atomicWriteFile(d, pathApi, target, payload, tmpName) {
|
|
728
|
+
d.fs.mkdirSync(pathApi.dirname(target), { recursive: true });
|
|
729
|
+
const tmp = pathApi.join(pathApi.dirname(target), tmpName);
|
|
730
|
+
d.fs.writeFileSync(tmp, payload);
|
|
731
|
+
try {
|
|
732
|
+
d.fs.renameSync(tmp, target);
|
|
733
|
+
}
|
|
734
|
+
catch (error) {
|
|
735
|
+
try {
|
|
736
|
+
d.fs.rmSync(tmp);
|
|
737
|
+
}
|
|
738
|
+
catch (_cleanup) {
|
|
739
|
+
// tmp 정리는 best-effort. rename 실패 원인을 가리면 호출부가 재시도 지점을 잃는다.
|
|
740
|
+
}
|
|
741
|
+
throw error;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function isApprovalParts(value) {
|
|
745
|
+
if (!isRecord(value))
|
|
746
|
+
return false;
|
|
747
|
+
return Object.values(value).every((entry) => typeof entry === 'string');
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* 승인 스냅샷 파일 경로를 계산한다. Git common dir 아래에 두어 linked worktree가
|
|
751
|
+
* 같은 승인 파일을 보고, `.git/` 안이라 staging에 실리지 않게 한다.
|
|
752
|
+
*
|
|
753
|
+
* @param {{ repoRoot: string, blueprintDir: unknown, deps?: RuntimeDeps | null }} opts
|
|
754
|
+
* - `blueprintDir`은 저장소 상대 blueprint 경로
|
|
755
|
+
* @returns {{ unavailable: true, reason?: string } | { path: string }} 비-Git이면 unavailable, 아니면 절대 경로
|
|
756
|
+
*/
|
|
757
|
+
function approvalSnapshotPathFor({ repoRoot, blueprintDir, deps }) {
|
|
758
|
+
const d = { fs, ...(deps || {}) };
|
|
759
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
760
|
+
if (paths.unavailable || !paths.approvalsRoot) {
|
|
761
|
+
return { unavailable: true, reason: paths.reason };
|
|
762
|
+
}
|
|
763
|
+
const { epicId, blueprintId } = pointerKeyFromBlueprint(blueprintDir);
|
|
764
|
+
const pathApi = pathApiFor(d.platform);
|
|
765
|
+
return { path: approvalSnapshotPath(paths.approvalsRoot, epicId, blueprintId, pathApi) };
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* 승인 스냅샷을 읽는다. 파일이 없으면 null — 이 변경 전에 활성화된 blueprint는
|
|
769
|
+
* G24 대조를 건너뛴다. JSON이 깨졌거나 version이 1이 아니면 경로를 담아
|
|
770
|
+
* 호출부가 G24 unreadable로 거절하게 한다. 권한 오류를 부재로 접지 않는다.
|
|
771
|
+
*
|
|
772
|
+
* @param {{ repoRoot: string, blueprintDir: unknown, deps?: RuntimeDeps | null }} opts
|
|
773
|
+
* @returns {ApprovalRead} 없음 null, 파손 `{ ok: false, path }`, 유효 `{ ok: true, snapshot }`
|
|
774
|
+
*/
|
|
775
|
+
function readApprovalSnapshot({ repoRoot, blueprintDir, deps }) {
|
|
776
|
+
const d = { fs, ...(deps || {}) };
|
|
777
|
+
const located = approvalSnapshotPathFor({ repoRoot, blueprintDir, deps: d });
|
|
778
|
+
if (!('path' in located))
|
|
779
|
+
return null;
|
|
780
|
+
const target = located.path;
|
|
781
|
+
if (!d.fs.existsSync(target))
|
|
782
|
+
return null;
|
|
783
|
+
let raw;
|
|
784
|
+
try {
|
|
785
|
+
raw = d.fs.readFileSync(target, 'utf8');
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
// ENOENT는 existsSync 이후 삭제 경합만. 그 외 읽기 오류는 부재가 아니다 —
|
|
789
|
+
// G24 skip으로 접으면 권한 문제가 승인 없음으로 통과한다.
|
|
790
|
+
if (isEnoentError(error))
|
|
791
|
+
return null;
|
|
792
|
+
return { ok: false, path: target };
|
|
793
|
+
}
|
|
794
|
+
try {
|
|
795
|
+
const parsed = JSON.parse(raw.trim());
|
|
796
|
+
if (!isRecord(parsed) || parsed.version !== 1) {
|
|
797
|
+
return { ok: false, path: target };
|
|
798
|
+
}
|
|
799
|
+
if (typeof parsed.digest !== 'string' || !isApprovalParts(parsed.parts)) {
|
|
800
|
+
return { ok: false, path: target };
|
|
801
|
+
}
|
|
802
|
+
return { ok: true, snapshot: { digest: parsed.digest, parts: parsed.parts } };
|
|
803
|
+
}
|
|
804
|
+
catch (error) {
|
|
805
|
+
// SyntaxError만 흡수한다. 그 외 throw를 unreadable로 접으면 호출부가
|
|
806
|
+
// 파일 손상을 디스크 실패와 구분하지 못한다.
|
|
807
|
+
if (error instanceof SyntaxError)
|
|
808
|
+
return { ok: false, path: target };
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* 승인 스냅샷을 원자적으로 쓴다. pointer 파일과 같은 디렉터리 rename을 써서
|
|
814
|
+
* 다른 볼륨 tmp의 copy+unlink 구멍을 피한다. `--clear`/`--replace`는 이 파일을
|
|
815
|
+
* 지우지 않는다 — 다음 최초 `--set`이 덮어쓴다.
|
|
816
|
+
*
|
|
817
|
+
* @param {{
|
|
818
|
+
* repoRoot: string,
|
|
819
|
+
* blueprintDir: unknown,
|
|
820
|
+
* digest: string,
|
|
821
|
+
* parts: Record<string, string>,
|
|
822
|
+
* deps?: RuntimeDeps | null,
|
|
823
|
+
* }} opts
|
|
824
|
+
* @returns {string} 쓴 승인 파일 절대 경로
|
|
825
|
+
*/
|
|
826
|
+
function writeApprovalSnapshot({ repoRoot, blueprintDir, digest, parts, deps, }) {
|
|
827
|
+
const d = { fs, ...(deps || {}) };
|
|
828
|
+
const located = approvalSnapshotPathFor({ repoRoot, blueprintDir, deps: d });
|
|
829
|
+
if (!('path' in located))
|
|
830
|
+
throw new Error(GIT_REQUIRED);
|
|
831
|
+
const { blueprintId } = pointerKeyFromBlueprint(blueprintDir);
|
|
832
|
+
const pathApi = pathApiFor(d.platform);
|
|
833
|
+
const body = {
|
|
834
|
+
version: 1,
|
|
835
|
+
blueprint: toPosix(blueprintDir),
|
|
836
|
+
digest,
|
|
837
|
+
parts,
|
|
838
|
+
recorded_at: nowIsoKst(),
|
|
839
|
+
};
|
|
840
|
+
const payload = `${JSON.stringify(body, null, 2)}\n`;
|
|
841
|
+
atomicWriteFile(d, pathApi, located.path, payload, `.${blueprintId}.${process.pid}.${Date.now()}.tmp`);
|
|
842
|
+
return located.path;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* namespace 키 파일을 지운다. 파일이 없으면 false — 호출자는 이미 없음을 성공으로 본다.
|
|
846
|
+
*
|
|
847
|
+
* @param {{ repoRoot: string, key: string, deps?: RuntimeDeps | null }} opts - `key`는 `epicId/blueprintId`
|
|
848
|
+
* @returns {boolean} 지웠으면 true, 없었으면 false
|
|
849
|
+
*/
|
|
850
|
+
function removeNamespacePointer({ repoRoot, key, deps }) {
|
|
851
|
+
const d = { fs, ...(deps || {}) };
|
|
852
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
853
|
+
if (paths.unavailable || !paths.pointersRoot)
|
|
854
|
+
return false;
|
|
855
|
+
const parsed = parsePointerKey(key);
|
|
856
|
+
if (!parsed)
|
|
857
|
+
return false;
|
|
858
|
+
const pathApi = pathApiFor(d.platform);
|
|
859
|
+
const target = namespacePointerPath(paths.pointersRoot, parsed.epicId, parsed.blueprintId, pathApi);
|
|
860
|
+
if (!d.fs.existsSync(target))
|
|
861
|
+
return false;
|
|
862
|
+
d.fs.rmSync(target);
|
|
863
|
+
return true;
|
|
864
|
+
}
|
|
865
|
+
// blueprint 경로만으로 execute worktree 절대 경로를 고른다.
|
|
866
|
+
// 기본은 `.worktrees/<epic>/<bp>`이고, 중첩이 없는데 평면 `.worktrees/<bp>`만
|
|
867
|
+
// 있으면 그 평면을 재사용한다(옮기지 않음). mkdir은 하지 않는다 —
|
|
868
|
+
// `git worktree add`가 부모 디렉터리까지 만든다.
|
|
869
|
+
function worktreePathFor({ repoRoot, blueprint, deps }) {
|
|
870
|
+
const d = { fs, ...(deps || {}) };
|
|
871
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
872
|
+
if (paths.unavailable)
|
|
873
|
+
throw new Error(GIT_REQUIRED);
|
|
874
|
+
const { epicId, blueprintId } = parsePathIds(blueprint);
|
|
875
|
+
// 구형 EPIC-/BP- 접두는 parsePathIds가 흡수하지 않는다 — 숫자 id 경로만 유효.
|
|
876
|
+
if (!epicId || !blueprintId) {
|
|
877
|
+
throw new Error(`Cannot derive epic/blueprint ids from blueprint path: ${blueprint}`);
|
|
878
|
+
}
|
|
879
|
+
const platform = d.platform || process.platform;
|
|
880
|
+
const pathApi = platform === 'win32' ? path.win32 : path;
|
|
881
|
+
const nested = pathApi.join(paths.worktreeRoot, epicId, blueprintId);
|
|
882
|
+
const flat = pathApi.join(paths.worktreeRoot, blueprintId);
|
|
883
|
+
// 중첩이 디렉터리로 없고 평면만 디렉터리면 레거시 평면. 둘 다 있으면 중첩 우선.
|
|
884
|
+
const nestedIsDir = (() => {
|
|
885
|
+
try {
|
|
886
|
+
return d.fs.statSync(nested).isDirectory();
|
|
887
|
+
}
|
|
888
|
+
catch (_e) {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
})();
|
|
892
|
+
const flatIsDir = (() => {
|
|
893
|
+
try {
|
|
894
|
+
return d.fs.statSync(flat).isDirectory();
|
|
895
|
+
}
|
|
896
|
+
catch (_e) {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
})();
|
|
900
|
+
// 평면 fallback은 구형 번호 경로 전용이다. slug 경로는 bp slug만으로 다른 epic의 같은 이름
|
|
901
|
+
// 디렉터리를 잘못 재사용할 수 있어 항상 중첩 경로를 쓴다.
|
|
902
|
+
if (!nestedIsDir && flatIsDir && !isSlugBlueprint(blueprint))
|
|
903
|
+
return flat;
|
|
904
|
+
return nested;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* coordinator 원장 `fanin` 필드의 shape를 본다. null은 "진행 중 없음"이고,
|
|
908
|
+
* 객체면 base/candidate/tasks/status가 재개·CAS에 쓸 수 있는 형태여야 한다.
|
|
909
|
+
*
|
|
910
|
+
* @param {unknown} value - ledger.fanin 후보
|
|
911
|
+
* @returns {boolean} 유효하면 true
|
|
912
|
+
*/
|
|
913
|
+
function validCoordinatorFanin(value) {
|
|
914
|
+
if (value === null)
|
|
915
|
+
return true;
|
|
916
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
917
|
+
return false;
|
|
918
|
+
const fanin = value;
|
|
919
|
+
if (!nonEmptyString(fanin.base_head))
|
|
920
|
+
return false;
|
|
921
|
+
if (fanin.candidate_head !== null && !nonEmptyString(fanin.candidate_head))
|
|
922
|
+
return false;
|
|
923
|
+
if (!Array.isArray(fanin.tasks)
|
|
924
|
+
|| fanin.tasks.some((entry) => typeof entry !== 'string' || !/^\d{3}$/.test(entry))) {
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
return fanin.status === 'building' || fanin.status === 'verified';
|
|
928
|
+
}
|
|
929
|
+
// coordinator mode는 기존 execute worktree와 구분해 fan-in의 기준 checkout을
|
|
930
|
+
// 하나로 고정한다. 기존 worktreePathFor의 평면 fallback은 건드리지 않는다.
|
|
931
|
+
function coordinatorPathsFor({ repoRoot, blueprint, task, deps }) {
|
|
932
|
+
const d = { fs, ...(deps || {}) };
|
|
933
|
+
const paths = resolvedPaths({ repoRoot, deps: d });
|
|
934
|
+
if (paths.unavailable)
|
|
935
|
+
throw new Error(GIT_REQUIRED);
|
|
936
|
+
const { epicId, blueprintId } = parsePathIds(blueprint);
|
|
937
|
+
if (!epicId || !blueprintId)
|
|
938
|
+
throw new Error(`Cannot derive epic/blueprint ids from blueprint path: ${blueprint}`);
|
|
939
|
+
const pathApi = (d.platform || process.platform) === 'win32' ? path.win32 : path;
|
|
940
|
+
const root = pathApi.join(paths.worktreeRoot, epicId, blueprintId);
|
|
941
|
+
const integrationPath = pathApi.join(root, 'integration');
|
|
942
|
+
const result = {
|
|
943
|
+
integrationPath,
|
|
944
|
+
// COORDINATOR_LEDGER_REL과 같은 세그먼트로 절대 경로를 만들어 fence path 비교와
|
|
945
|
+
// 실제 파일이 어긋나지 않게 한다.
|
|
946
|
+
ledgerFile: pathApi.join(integrationPath, ...COORDINATOR_LEDGER_REL.split('/')),
|
|
947
|
+
// candidate는 integration 형제로 두어 ff 전에 canonical HEAD를 오염시키지 않는다.
|
|
948
|
+
faninPath: pathApi.join(root, 'fanin'),
|
|
949
|
+
};
|
|
950
|
+
if (typeof task === 'string' && /^\d{3}$/.test(task))
|
|
951
|
+
result.workerPath = pathApi.join(root, 'workers', task);
|
|
952
|
+
return result;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Git common directory 아래의 verify 원장 경로를 계산한다.
|
|
956
|
+
* evidenceId가 있으면 v2(내용 주소) 키를 쓰고, 없으면 verificationRel만
|
|
957
|
+
* 해시하는 legacy 경로를 돌려 — 구 원장은 읽을 수 있어도 reuse hit 키가
|
|
958
|
+
* 되지 않게 한다. linked worktree는 같은 common dir을 공유한다.
|
|
959
|
+
*
|
|
960
|
+
* @param {{ repoRoot: string, verificationRel: unknown, evidenceId?: unknown, deps?: RuntimeDeps | null }} opts
|
|
961
|
+
* - `verificationRel`은 저장소 상대 verification.md 경로(legacy 키)
|
|
962
|
+
* - `evidenceId`는 v2 evidence SHA-256. 있으면 legacy 키를 쓰지 않는다
|
|
963
|
+
* @returns {RuntimePaths} 성공 시 `ledgerFile`, 비-Git이면 `unavailable`
|
|
964
|
+
*/
|
|
965
|
+
function verifyLedgerPathFor({ repoRoot, verificationRel, evidenceId, deps }) {
|
|
966
|
+
const paths = resolvedPaths({ repoRoot, deps });
|
|
967
|
+
if (paths.unavailable) {
|
|
968
|
+
return { unavailable: true, reason: paths.reason };
|
|
969
|
+
}
|
|
970
|
+
const d = deps || {};
|
|
971
|
+
const platform = d.platform || process.platform;
|
|
972
|
+
const pathApi = platform === 'win32' ? path.win32 : path;
|
|
973
|
+
// 원장은 current 포인터와 같이 common dir 아래에 둔다. linked worktree가
|
|
974
|
+
// 같은 레코드를 보게 하고, `.git/` 안이라 커밋 스코프에 절대 안 실린다.
|
|
975
|
+
// v2는 evidence_id로 키를 잡아 scope·identity가 다르면 파일을 덮어쓰지
|
|
976
|
+
// 않는다. legacy는 rel만 해시해 구 판독기가 옛 경로를 열 수 있게 남긴다.
|
|
977
|
+
const key = typeof evidenceId === 'string' && evidenceId.trim()
|
|
978
|
+
? `v2:${evidenceId.trim()}`
|
|
979
|
+
: toPosix(verificationRel);
|
|
980
|
+
const digest = createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 16);
|
|
981
|
+
return {
|
|
982
|
+
...paths,
|
|
983
|
+
ledgerFile: pathApi.join(paths.commonGitDir, 'bouncer', 'verify', `${digest}.json`),
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* 실행 Task 문서 경로의 intent bundle record 위치를 계산한다.
|
|
988
|
+
* verify ledger와 같이 Git common directory 아래에 두어 main·linked worktree가
|
|
989
|
+
* 같은 파일을 보고, `.git/` 안이라 staging에 실리지 않게 한다.
|
|
990
|
+
*
|
|
991
|
+
* @param {{ repoRoot: string, taskRel: unknown, deps?: RuntimeDeps | null }} opts
|
|
992
|
+
* - `taskRel`은 저장소 상대 tasks.md 경로
|
|
993
|
+
* @returns {RuntimePaths} 성공 시 `intentFile`, 비-Git이면 `unavailable`
|
|
994
|
+
*/
|
|
995
|
+
function intentBundlePathFor({ repoRoot, taskRel, deps }) {
|
|
996
|
+
const paths = resolvedPaths({ repoRoot, deps });
|
|
997
|
+
if (paths.unavailable) {
|
|
998
|
+
return { unavailable: true, reason: paths.reason };
|
|
999
|
+
}
|
|
1000
|
+
const d = deps || {};
|
|
1001
|
+
const platform = d.platform || process.platform;
|
|
1002
|
+
const pathApi = platform === 'win32' ? path.win32 : path;
|
|
1003
|
+
// verify ledger와 같은 digest 길이·배치를 유지한다. 경로 문자를 파일명에
|
|
1004
|
+
// 넣지 않으면서 worktree마다 다른 checkout 문자열이 섞이지 않게 한다.
|
|
1005
|
+
const digest = createHash('sha256').update(toPosix(taskRel), 'utf8').digest('hex').slice(0, 16);
|
|
1006
|
+
return {
|
|
1007
|
+
...paths,
|
|
1008
|
+
intentFile: pathApi.join(paths.commonGitDir, 'bouncer', 'intent', `${digest}.json`),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* porcelain 출력이 비어 있지 않으면 dirty. git 실패도 dirty로 본다 —
|
|
1013
|
+
* migrate task-layout·import-history가 부분 쓰기를 남기지 않게 apply를 막기 위함.
|
|
1014
|
+
*/
|
|
1015
|
+
function isWorktreeDirty(repoRoot, execFileSync = realExecFileSync) {
|
|
1016
|
+
try {
|
|
1017
|
+
const out = execFileSync('git', ['status', '--porcelain'], {
|
|
1018
|
+
cwd: repoRoot,
|
|
1019
|
+
encoding: 'utf8',
|
|
1020
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1021
|
+
});
|
|
1022
|
+
return String(out).trim().length > 0;
|
|
1023
|
+
}
|
|
1024
|
+
catch (_e) {
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
module.exports = {
|
|
1029
|
+
runtimePaths, readRuntimeCurrent, readLegacyRuntimeCurrent, writeRuntimeCurrent,
|
|
1030
|
+
clearRuntimeCurrent, worktreePathFor, coordinatorPathsFor, verifyLedgerPathFor,
|
|
1031
|
+
intentBundlePathFor, isWorktreeDirty,
|
|
1032
|
+
pointerKeyFromBlueprint, listNamespacePointers, removeNamespacePointer,
|
|
1033
|
+
readApprovalSnapshot, writeApprovalSnapshot, approvalSnapshotPathFor,
|
|
1034
|
+
validateCoordinatorLedger,
|
|
1035
|
+
validateCoordinatorCheckpoint,
|
|
1036
|
+
branchNamesFor, resolveWorktreeBranch, parsePointerKey,
|
|
1037
|
+
COORDINATOR_LEDGER_REL,
|
|
1038
|
+
};
|