@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,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { execFileSync } = require('node:child_process');
|
|
5
|
+
const current = require("./current");
|
|
6
|
+
const { readCurrent, presentCurrent, resolveEffectiveTask, CurrentSelectionError } = current;
|
|
7
|
+
const runtimeState = require("./runtime-state");
|
|
8
|
+
const { worktreePathFor, coordinatorPathsFor, branchNamesFor, resolveWorktreeBranch } = runtimeState;
|
|
9
|
+
const seedWorktreeMod = require("./seed-worktree");
|
|
10
|
+
const { seedWorktree } = seedWorktreeMod;
|
|
11
|
+
const scopeMod = require("./scope");
|
|
12
|
+
const { readCoordinatorLedger } = scopeMod;
|
|
13
|
+
const paths = require("./paths");
|
|
14
|
+
const { toPosix } = paths;
|
|
15
|
+
const TASK_DOC_RE = /(?:^|\/)tasks\/(\d{3})\/tasks\.md$/;
|
|
16
|
+
function isSelectionError(error) {
|
|
17
|
+
return error instanceof CurrentSelectionError
|
|
18
|
+
|| (typeof error === 'object'
|
|
19
|
+
&& error !== null
|
|
20
|
+
&& 'code' in error
|
|
21
|
+
&& typeof error.code === 'string'
|
|
22
|
+
&& String(error.code).startsWith('CURRENT_'));
|
|
23
|
+
}
|
|
24
|
+
function sameBlueprint(left, right) {
|
|
25
|
+
return toPosix(left).replace(/\/+$/, '') === toPosix(right).replace(/\/+$/, '');
|
|
26
|
+
}
|
|
27
|
+
function taskDigits(task) {
|
|
28
|
+
if (typeof task !== 'string' || !task)
|
|
29
|
+
return null;
|
|
30
|
+
if (/^\d{3}$/.test(task))
|
|
31
|
+
return task;
|
|
32
|
+
const match = TASK_DOC_RE.exec(toPosix(task));
|
|
33
|
+
return match ? match[1] : null;
|
|
34
|
+
}
|
|
35
|
+
function gitStderr(error) {
|
|
36
|
+
const err = error;
|
|
37
|
+
const raw = err.stderr != null ? String(err.stderr) : (err.message || '');
|
|
38
|
+
const first = raw.trim().split('\n').find((line) => line.trim());
|
|
39
|
+
// JSON stdout을 깨지 않으려고 hook·사용법 덤프 전체가 아니라 첫 줄만 싣는다.
|
|
40
|
+
return first || 'git worktree add failed';
|
|
41
|
+
}
|
|
42
|
+
function listWorktrees(repoRoot) {
|
|
43
|
+
const out = execFileSync('git', ['worktree', 'list', '--porcelain'], {
|
|
44
|
+
cwd: repoRoot,
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
});
|
|
47
|
+
const entries = [];
|
|
48
|
+
let current = null;
|
|
49
|
+
const flush = () => {
|
|
50
|
+
if (current)
|
|
51
|
+
entries.push(current);
|
|
52
|
+
current = null;
|
|
53
|
+
};
|
|
54
|
+
for (const line of out.split('\n')) {
|
|
55
|
+
if (line.startsWith('worktree ')) {
|
|
56
|
+
flush();
|
|
57
|
+
current = { path: line.slice('worktree '.length), branch: null };
|
|
58
|
+
}
|
|
59
|
+
else if (current && line.startsWith('branch ')) {
|
|
60
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
flush();
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
function realOf(target) {
|
|
67
|
+
try {
|
|
68
|
+
return fs.realpathSync(target);
|
|
69
|
+
}
|
|
70
|
+
catch (_e) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function isDirectory(target) {
|
|
75
|
+
try {
|
|
76
|
+
return fs.statSync(target).isDirectory();
|
|
77
|
+
}
|
|
78
|
+
catch (_e) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 예상 경로가 Git에 등록된 worktree면 그 항목, 없으면 null, 디렉터리만
|
|
84
|
+
* 있으면 'unregistered'. 등록 여부는 브랜치 이름이 아니라 경로로만 본다 —
|
|
85
|
+
* 기존 체크아웃을 rename·migrate 하지 않기 위함이다.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} repoRoot - 기준 checkout
|
|
88
|
+
* @param {string} expectedPath - worktreePathFor 결과
|
|
89
|
+
* @returns {ListedWorktree | null | 'unregistered'} 재사용 / 신규 / 거절
|
|
90
|
+
*/
|
|
91
|
+
function matchWorktree(repoRoot, expectedPath) {
|
|
92
|
+
const listed = listWorktrees(repoRoot);
|
|
93
|
+
const expectedReal = isDirectory(expectedPath) ? realOf(expectedPath) : null;
|
|
94
|
+
if (expectedReal) {
|
|
95
|
+
const hit = listed.find((entry) => realOf(entry.path) === expectedReal);
|
|
96
|
+
if (hit)
|
|
97
|
+
return hit;
|
|
98
|
+
return 'unregistered';
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
function pointerTask(pointer, repoRoot) {
|
|
103
|
+
const presented = presentCurrent(pointer, { repoRoot });
|
|
104
|
+
if (!presented)
|
|
105
|
+
return { task: null, scale: null };
|
|
106
|
+
// drive·standalone 모두 effective task(path/id)를 보고한다. pointer.task는
|
|
107
|
+
// presentCurrent의 task 키로 남고, 소비자는 lease 우선 값을 써야 한다.
|
|
108
|
+
const eff = presented.effectiveTask;
|
|
109
|
+
if (eff && eff.source && typeof eff.path === 'string') {
|
|
110
|
+
return {
|
|
111
|
+
task: { id: eff.id, path: eff.path },
|
|
112
|
+
scale: presented.scale,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { task: presented.task, scale: presented.scale };
|
|
116
|
+
}
|
|
117
|
+
function assignedWorkerPath(repoRoot, blueprintDir, taskRef, ledger) {
|
|
118
|
+
const taskId = taskDigits(taskRef);
|
|
119
|
+
const computed = coordinatorPathsFor({
|
|
120
|
+
repoRoot, blueprint: blueprintDir, task: taskId || undefined,
|
|
121
|
+
});
|
|
122
|
+
if (ledger && Array.isArray(ledger.tasks) && taskId) {
|
|
123
|
+
const entry = ledger.tasks.find((item) => item && item.id === taskId);
|
|
124
|
+
if (entry && typeof entry.workerPath === 'string' && entry.workerPath) {
|
|
125
|
+
return entry.workerPath;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return computed.workerPath || computed.integrationPath;
|
|
129
|
+
}
|
|
130
|
+
function seedOrConflict(repoRoot, blueprintDir, worktreePath) {
|
|
131
|
+
const seeded = seedWorktree({
|
|
132
|
+
repoRoot,
|
|
133
|
+
blueprintDir,
|
|
134
|
+
worktreePath,
|
|
135
|
+
});
|
|
136
|
+
if (seeded.ok) {
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
moved: seeded.moved,
|
|
140
|
+
restored: seeded.restored,
|
|
141
|
+
config: seeded.config,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// conflict만 계약 코드로 바꾸고, 이미 만든 worktree는 호출자가 지우지 않는다.
|
|
145
|
+
if (seeded.reason === 'conflict') {
|
|
146
|
+
return { ok: false, reason: 'seed-conflict', conflicts: seeded.conflicts || [] };
|
|
147
|
+
}
|
|
148
|
+
return { ok: false, reason: seeded.reason };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* standalone execute의 worktree 경로·브랜치·재사용·seed를 한 명령으로 수행한다.
|
|
152
|
+
* coordinator 원장이 있으면 drive로 보고 생성·seed를 하지 않는다 — 그 쓰기는
|
|
153
|
+
* `coordinate prepare` 소유이고, 여기서 main checkout을 건드리면 경계가 깨진다.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} opts
|
|
156
|
+
* @param {string} opts.repoRoot - plan 문서가 있는 base checkout
|
|
157
|
+
* @param {string} opts.blueprintDir - `--blueprint` 값
|
|
158
|
+
* @returns {PrepareResult} 성공 payload 또는 거절 reason
|
|
159
|
+
*/
|
|
160
|
+
function executePrepare({ repoRoot, blueprintDir }) {
|
|
161
|
+
const blueprint = toPosix(blueprintDir);
|
|
162
|
+
let pointer;
|
|
163
|
+
try {
|
|
164
|
+
pointer = readCurrent({ repoRoot });
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
if (isSelectionError(error)) {
|
|
168
|
+
const fail = { ok: false, reason: error.code };
|
|
169
|
+
if (error.candidates)
|
|
170
|
+
fail.candidates = error.candidates;
|
|
171
|
+
if (error.issues)
|
|
172
|
+
fail.issues = error.issues;
|
|
173
|
+
return fail;
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
if (!pointer)
|
|
178
|
+
return { ok: false, reason: 'no-current' };
|
|
179
|
+
if (!sameBlueprint(pointer.blueprint, blueprint)) {
|
|
180
|
+
return { ok: false, reason: 'blueprint-mismatch' };
|
|
181
|
+
}
|
|
182
|
+
// worker cwd에서 lease를 확인하지 못하면 drive·standalone 모두 거절한다.
|
|
183
|
+
// pointer로 떨어지면 다른 task의 workerPath를 보고하게 된다.
|
|
184
|
+
const effective = resolveEffectiveTask({ repoRoot });
|
|
185
|
+
if (effective && effective.source === null) {
|
|
186
|
+
return { ok: false, reason: effective.reason };
|
|
187
|
+
}
|
|
188
|
+
const { task, scale } = pointerTask(pointer, repoRoot);
|
|
189
|
+
const taskRef = effective && typeof effective.path === 'string'
|
|
190
|
+
? effective.path
|
|
191
|
+
: pointer.task;
|
|
192
|
+
const found = readCoordinatorLedger({ repoRoot, blueprint });
|
|
193
|
+
// 원장 파일이 있으면(읽을 수 없어도) drive다. 부재만 standalone이다.
|
|
194
|
+
if (found.ok || found.reason === 'unreadable-ledger') {
|
|
195
|
+
const ledger = found.ok ? found.ledger : null;
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
drive: true,
|
|
199
|
+
worktreePath: assignedWorkerPath(repoRoot, blueprint, taskRef, ledger),
|
|
200
|
+
created: false,
|
|
201
|
+
task,
|
|
202
|
+
scale,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const worktreePath = worktreePathFor({ repoRoot, blueprint });
|
|
206
|
+
const matched = matchWorktree(repoRoot, worktreePath);
|
|
207
|
+
if (matched === 'unregistered') {
|
|
208
|
+
return { ok: false, reason: 'unregistered-worktree' };
|
|
209
|
+
}
|
|
210
|
+
let branch;
|
|
211
|
+
try {
|
|
212
|
+
// standalone도 coordinator와 같은 이름·충돌 계약을 써야 한다. 그래야 새
|
|
213
|
+
// checkout만 만들고, 예전 checkout은 실제 branch를 보존하는 경계가 한 곳에 남는다.
|
|
214
|
+
const names = branchNamesFor({ repoRoot, blueprint });
|
|
215
|
+
// porcelain의 detached checkout은 helper가 branch 없는 경로를 신규로 보고
|
|
216
|
+
// 기존 ref 충돌을 돌린다. 이 경우에는 새 branch를 만들려는 요청 자체가 없으므로
|
|
217
|
+
// 실제 null을 보존한다; branchNamesFor는 위에서 계속 검증된다.
|
|
218
|
+
const resolved = matched && !matched.branch
|
|
219
|
+
? null
|
|
220
|
+
: resolveWorktreeBranch({ repoRoot, worktreePath, branch: names.standalone });
|
|
221
|
+
branch = resolved ? resolved.branch : null;
|
|
222
|
+
if (!matched) {
|
|
223
|
+
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
|
224
|
+
execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath, pointer.base], {
|
|
225
|
+
cwd: repoRoot,
|
|
226
|
+
encoding: 'utf8',
|
|
227
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
const code = error && typeof error === 'object' && 'code' in error
|
|
233
|
+
? error.code : null;
|
|
234
|
+
if (code === 'invalid-commit-type' || code === 'invalid-branch-name' || code === 'branch-conflict') {
|
|
235
|
+
return { ok: false, reason: code };
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, reason: 'worktree-add-failed', message: gitStderr(error) };
|
|
238
|
+
}
|
|
239
|
+
if (matched) {
|
|
240
|
+
// 등록된 체크아웃의 실제 브랜치를 그대로 돌려준다. 계산된 이름과 달라도
|
|
241
|
+
// 옮기거나 고치지 않는다. porcelain에 branch 줄이 없으면 detached HEAD이므로
|
|
242
|
+
// null을 그대로 둔다 — `<commit_type>/<leaf>`를 합성하면 스킬이 그 이름으로
|
|
243
|
+
// 커밋을 시도하지만 워킹 트리는 그 브랜치에 있지 않다.
|
|
244
|
+
branch = matched.branch;
|
|
245
|
+
}
|
|
246
|
+
const created = !matched;
|
|
247
|
+
const seeded = seedOrConflict(repoRoot, blueprint, worktreePath);
|
|
248
|
+
if (!seeded.ok)
|
|
249
|
+
return seeded;
|
|
250
|
+
return {
|
|
251
|
+
ok: true,
|
|
252
|
+
drive: false,
|
|
253
|
+
worktreePath,
|
|
254
|
+
branch,
|
|
255
|
+
created,
|
|
256
|
+
base: pointer.base,
|
|
257
|
+
task,
|
|
258
|
+
scale,
|
|
259
|
+
seed: { moved: seeded.moved, restored: seeded.restored, config: seeded.config },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
module.exports = { executePrepare };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.validOutsideScope = validOutsideScope;
|
|
4
|
+
const nonEmptyText = (value) => typeof value === 'string' && value.trim() !== '';
|
|
5
|
+
/**
|
|
6
|
+
* terminal verification task가 "성공은 아니지만 범위 밖 실패로 입증돼 완료를 막지 않는" 상태인지
|
|
7
|
+
* 판정한다. next·gate·digest·finalize가 같은 판정을 쓰도록 한 곳에 둔다 — 소비자마다
|
|
8
|
+
* 조건이 다르면 한쪽은 진행시키고 다른 쪽은 막아 drive가 갈라진다.
|
|
9
|
+
*
|
|
10
|
+
* 모든 조건이 맞을 때만 분류를 돌려준다: task가 verification이고 `verifying`이며,
|
|
11
|
+
* 원장 terminalFailure가 그 task의 것이고, disposition이 완전하며(kind·reason·evidence_id·
|
|
12
|
+
* source_digest·paths), 실패 증거의 identity와 일치하고, 현재 integrationHead가 분류 당시의
|
|
13
|
+
* source_digest와 같다(소스가 바뀌었으면 stale). 어느 하나라도 어긋나면 null이라 호출자는
|
|
14
|
+
* 기존 blocked/미완료 의미를 그대로 유지한다.
|
|
15
|
+
*
|
|
16
|
+
* @param {unknown} ledger - 원장 객체(읽기만 한다). 모양이 틀리면 null
|
|
17
|
+
* @param {string} taskId - 세 자리 terminal task id (`TASKS-` 접두는 벗겨 비교한다)
|
|
18
|
+
* @returns {OutsideScopeDisposition | null} 유효한 분류, 아니면 null
|
|
19
|
+
*/
|
|
20
|
+
function validOutsideScope(ledger, taskId) {
|
|
21
|
+
if (!ledger || typeof ledger !== 'object')
|
|
22
|
+
return null;
|
|
23
|
+
const root = ledger;
|
|
24
|
+
const id = String(taskId).replace(/^TASKS-/, '');
|
|
25
|
+
const task = (Array.isArray(root.tasks) ? root.tasks : []).find((entry) => Boolean(entry) && typeof entry === 'object' && entry.id === id);
|
|
26
|
+
if (!task || task.execution_kind !== 'verification' || task.status !== 'verifying')
|
|
27
|
+
return null;
|
|
28
|
+
const failure = root.terminalFailure;
|
|
29
|
+
if (!failure || typeof failure !== 'object' || failure.task !== id)
|
|
30
|
+
return null;
|
|
31
|
+
const disposition = failure.disposition;
|
|
32
|
+
if (!disposition || typeof disposition !== 'object' || disposition.kind !== 'outside_scope')
|
|
33
|
+
return null;
|
|
34
|
+
if (!nonEmptyText(disposition.reason) || !nonEmptyText(disposition.evidence_id)
|
|
35
|
+
|| !nonEmptyText(disposition.source_digest))
|
|
36
|
+
return null;
|
|
37
|
+
if (!Array.isArray(disposition.paths) || disposition.paths.length === 0
|
|
38
|
+
|| !disposition.paths.every(nonEmptyText))
|
|
39
|
+
return null;
|
|
40
|
+
if (disposition.evidence_id !== failure.evidence_id || disposition.source_digest !== failure.source_digest) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (root.integrationHead !== disposition.source_digest)
|
|
44
|
+
return null;
|
|
45
|
+
return disposition;
|
|
46
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// I/O 없는 DAG 규칙: 어떤 task를 지금 열 수 있는지(readyWave)와 상태 전이(transition).
|
|
3
|
+
// coordinator가 명령 처리와 이 규칙을 함께 들고 있으면 current·commit 같은 읽기 쪽 모듈이
|
|
4
|
+
// readyWave 하나 때문에 coordinator 전체를 가져와 verification → current → coordinator →
|
|
5
|
+
// verification 순환이 생긴다. 규칙만 하위 모듈로 내려 그 간선을 끊는다.
|
|
6
|
+
// 이 모듈은 config(DEFAULT_MAX_PARALLEL)와 path-overlap만 런타임에 가져온다 —
|
|
7
|
+
// cli·validate·coordinator·current·verification을 가져오면 경계 테스트가 실패한다.
|
|
8
|
+
const configMod = require("./config");
|
|
9
|
+
const { DEFAULT_MAX_PARALLEL } = configMod;
|
|
10
|
+
const pathOverlapMod = require("./path-overlap");
|
|
11
|
+
// 정규화 정본은 path-overlap이다. 기존 소비자(dag-shape·coordinator-commands)가 이 모듈에서
|
|
12
|
+
// 같은 이름으로 가져가므로 아래 export에서 그대로 다시 내보낸다.
|
|
13
|
+
const { pathsOverlap, normalizeOverlapPath } = pathOverlapMod;
|
|
14
|
+
/**
|
|
15
|
+
* commit은 prepared·recorded, verification은 ready·verifying만 in-flight다.
|
|
16
|
+
* pending·integrated·(commit의) ready는 자리 수를 쓰지 않는다 — prepare가
|
|
17
|
+
* ready를 거쳐 바로 prepared로 올리므로, ready를 세면 한 wave가 자신을 막는다.
|
|
18
|
+
*
|
|
19
|
+
* @param {Task} task - 원장 task
|
|
20
|
+
* @returns {boolean} 동시 실행 한도·충돌 집합에 넣을지
|
|
21
|
+
*/
|
|
22
|
+
function isInFlight(task) {
|
|
23
|
+
const status = task.status || 'pending';
|
|
24
|
+
if ((task.execution_kind || 'commit') === 'verification') {
|
|
25
|
+
return status === 'ready' || status === 'verifying';
|
|
26
|
+
}
|
|
27
|
+
return status === 'prepared' || status === 'recorded';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 충돌 판정용 경로 집합. scope.paths가 있으면 lease 개정 뒤의 실제 범위를
|
|
31
|
+
* 쓰고, 없으면 bootstrap 스냅샷 affected_paths를 쓴다. 둘 다 없으면 legacy —
|
|
32
|
+
* 옛 원장이 경로 없이 열려 병렬로 겹치는 쓰기를 막기 위해 모든 task와 충돌한다.
|
|
33
|
+
*
|
|
34
|
+
* @param {Task} task - 원장 task
|
|
35
|
+
* @returns {string[] | null} 경로 목록, legacy면 null
|
|
36
|
+
*/
|
|
37
|
+
function pathSetOf(task) {
|
|
38
|
+
if (task.scope && Array.isArray(task.scope.paths))
|
|
39
|
+
return task.scope.paths;
|
|
40
|
+
if (Array.isArray(task.affected_paths))
|
|
41
|
+
return task.affected_paths;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 두 task가 같은 시점에 in-flight이면 안 되는지 판정한다. 경로 조상·동일
|
|
46
|
+
* 비교는 path-overlap.pathsOverlap 하나만 쓴다 — 새 규칙을 두면 경로 중첩 규칙과
|
|
47
|
+
* scheduler가 갈라진다. 입력만 normalizeOverlapPath로 맞춘다.
|
|
48
|
+
*
|
|
49
|
+
* @param {Task} left - 한쪽 task
|
|
50
|
+
* @param {Task} right - 다른 쪽 task
|
|
51
|
+
* @returns {boolean} 충돌이면 true
|
|
52
|
+
*/
|
|
53
|
+
function tasksConflict(left, right) {
|
|
54
|
+
return taskConflictDetail(left, right) !== null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 두 task의 경로·자원 충돌 근거를 반환한다. 경로가 없는 legacy task는
|
|
58
|
+
* 모든 상대와 충돌시키되 실제 교집합 경로는 없으므로 빈 목록으로 표시한다.
|
|
59
|
+
*
|
|
60
|
+
* @param {Task} left - 비교할 task
|
|
61
|
+
* @param {Task} right - 비교할 상대 task
|
|
62
|
+
* @returns {{ paths: string[]; resources: string[]; legacy: boolean } | null} 충돌 근거 또는 충돌 없음
|
|
63
|
+
*/
|
|
64
|
+
function taskConflictDetail(left, right) {
|
|
65
|
+
const leftPaths = pathSetOf(left);
|
|
66
|
+
const rightPaths = pathSetOf(right);
|
|
67
|
+
if (leftPaths === null || rightPaths === null)
|
|
68
|
+
return { paths: [], resources: [], legacy: true };
|
|
69
|
+
const paths = new Set();
|
|
70
|
+
const sharedResources = new Set();
|
|
71
|
+
for (const a of leftPaths) {
|
|
72
|
+
for (const b of rightPaths) {
|
|
73
|
+
if (pathsOverlap(normalizeOverlapPath(a), normalizeOverlapPath(b)))
|
|
74
|
+
paths.add(b);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const leftResources = new Set(Array.isArray(left.exclusive_resources) ? left.exclusive_resources : []);
|
|
78
|
+
for (const resource of Array.isArray(right.exclusive_resources) ? right.exclusive_resources : []) {
|
|
79
|
+
if (leftResources.has(resource))
|
|
80
|
+
sharedResources.add(resource);
|
|
81
|
+
}
|
|
82
|
+
const resources = [...sharedResources];
|
|
83
|
+
return paths.size || resources.length ? { paths: [...paths], resources, legacy: false } : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 설정 한도와 경로·exclusive_resources 충돌 안에서 다음에 열 수 있는 task id를
|
|
87
|
+
* 고른다. in-flight에 순차 task가 있거나 후보에 순차 task가 있으면 병렬 wave를
|
|
88
|
+
* 열지 않는다 — parallel_safe는 명시 true만 허용하는 기존 계약을 유지한다.
|
|
89
|
+
*
|
|
90
|
+
* @param {Task[]} tasks - 원장 task 목록
|
|
91
|
+
* @param {{ maxParallel?: number }} [options] - 동시 실행 상한. 부재 시 기본 2
|
|
92
|
+
* @returns {string[]} ID순으로 고른 ready task id
|
|
93
|
+
*/
|
|
94
|
+
function readyWave(tasks, options) {
|
|
95
|
+
const maxParallel = options && typeof options.maxParallel === 'number'
|
|
96
|
+
? options.maxParallel
|
|
97
|
+
: DEFAULT_MAX_PARALLEL;
|
|
98
|
+
const inFlight = tasks.filter(isInFlight);
|
|
99
|
+
// 순차 task가 이미 돌고 있으면 자리를 더 열지 않는다. prepared 옆의 parallel
|
|
100
|
+
// 후보를 예전처럼 바로 열면 한도·충돌 정책과 어긋난다.
|
|
101
|
+
if (inFlight.some((task) => task.parallel_safe !== true))
|
|
102
|
+
return [];
|
|
103
|
+
const candidates = tasks.filter((task) => (task.status || 'pending') === 'pending'
|
|
104
|
+
&& (task.depends_on || []).every((id) => {
|
|
105
|
+
const predecessor = tasks.find((other) => other.id === id);
|
|
106
|
+
// successor가 요구한 gate를 predecessor status와 그대로 비교한다. gate는
|
|
107
|
+
// integrated 하나뿐이므로 종단에 닿지 않은 predecessor는 successor를 열지 않는다.
|
|
108
|
+
return predecessor?.status === (task.dependency_gate || 'integrated');
|
|
109
|
+
})).sort((a, b) => a.id.localeCompare(b.id));
|
|
110
|
+
const sequential = candidates.find((task) => task.parallel_safe !== true);
|
|
111
|
+
if (sequential) {
|
|
112
|
+
// in-flight가 비었을 때만 순차 하나를 연다. 이미 자리가 있으면 [] —
|
|
113
|
+
// 순차와 parallel을 같은 순간에 섞지 않는다.
|
|
114
|
+
return inFlight.length === 0 ? [sequential.id] : [];
|
|
115
|
+
}
|
|
116
|
+
const slots = maxParallel - inFlight.length;
|
|
117
|
+
if (slots <= 0)
|
|
118
|
+
return [];
|
|
119
|
+
const selected = [];
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
if (selected.length >= slots)
|
|
122
|
+
break;
|
|
123
|
+
const blocked = inFlight.some((task) => tasksConflict(candidate, task))
|
|
124
|
+
|| selected.some((task) => tasksConflict(candidate, task));
|
|
125
|
+
if (blocked)
|
|
126
|
+
continue;
|
|
127
|
+
selected.push(candidate);
|
|
128
|
+
}
|
|
129
|
+
return selected.map((task) => task.id);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* 실행 종류별 coordinator 상태 전이를 검증한다. verification은 worker·SHA를
|
|
133
|
+
* 만들지 않으므로 commit 전이와 교차할 수 없다.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} from - 현재 ledger 상태
|
|
136
|
+
* @param {string} to - 요청한 다음 상태
|
|
137
|
+
* @param {'commit' | 'verification'} [executionKind] - 부재면 기존 commit 전이
|
|
138
|
+
* @returns {string} 허용된 다음 상태
|
|
139
|
+
*/
|
|
140
|
+
function transition(from, to, executionKind = 'commit') {
|
|
141
|
+
const allowed = executionKind === 'verification'
|
|
142
|
+
? { pending: ['ready'], ready: ['verifying'], verifying: ['integrated'] }
|
|
143
|
+
: { pending: ['ready'], ready: ['prepared'], prepared: ['recorded'], recorded: ['integrated'] };
|
|
144
|
+
if (!(allowed[from] || []).includes(to))
|
|
145
|
+
throw new Error(`illegal state transition: ${from} -> ${to}`);
|
|
146
|
+
return to;
|
|
147
|
+
}
|
|
148
|
+
module.exports = { readyWave, tasksConflict, taskConflictDetail, transition, normalizeOverlapPath, pathSetOf };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const paths = require("./paths");
|
|
5
|
+
const { toPosix } = paths;
|
|
6
|
+
const frontmatter = require("./frontmatter");
|
|
7
|
+
const { parseFrontmatter } = frontmatter;
|
|
8
|
+
const execution_contracts_1 = require("./execution-contracts");
|
|
9
|
+
function asRecord(value) { return value; }
|
|
10
|
+
function isDeferredMustFix(raw) {
|
|
11
|
+
const finding = asRecord(raw);
|
|
12
|
+
return finding.actionability === 'must_fix'
|
|
13
|
+
&& finding.status === 'deferred'
|
|
14
|
+
&& typeof finding.id === 'string' && finding.id !== '';
|
|
15
|
+
}
|
|
16
|
+
function collectMustFixWarnings(rawFindings) {
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
for (const raw of Array.isArray(rawFindings) ? rawFindings : []) {
|
|
20
|
+
if (!isDeferredMustFix(raw))
|
|
21
|
+
continue;
|
|
22
|
+
const finding = asRecord(raw);
|
|
23
|
+
const id = finding.id;
|
|
24
|
+
if (seen.has(id))
|
|
25
|
+
continue;
|
|
26
|
+
seen.add(id);
|
|
27
|
+
warnings.push({
|
|
28
|
+
kind: 'must-fix-deferred',
|
|
29
|
+
id,
|
|
30
|
+
note: typeof finding.note === 'string' ? finding.note : '',
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return warnings;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* ledger task id를 세 자리 목록용으로 정규화한다.
|
|
37
|
+
* 원장은 `001`과 `TASKS-001`을 섞어 쓸 수 있어, 보고 목록만 세 자리로 맞춘다.
|
|
38
|
+
*
|
|
39
|
+
* @param {unknown} id - 원장 task id
|
|
40
|
+
* @returns {string | null} 세 자리 id. 숫자로 해석되지 않으면 null
|
|
41
|
+
*/
|
|
42
|
+
function threeDigitTaskId(id) {
|
|
43
|
+
if (typeof id === 'number' && Number.isInteger(id) && id >= 0 && id <= 999) {
|
|
44
|
+
return String(id).padStart(3, '0');
|
|
45
|
+
}
|
|
46
|
+
if (typeof id !== 'string' || id === '')
|
|
47
|
+
return null;
|
|
48
|
+
const match = /^(?:TASKS-)?(\d{1,3})$/.exec(id);
|
|
49
|
+
return match ? match[1].padStart(3, '0') : null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* finalize payload의 보고 전용 `integration` 필드를 만든다.
|
|
53
|
+
* 거절 reason은 바꾸지 않는다. 깨진 원장은 기존 `coordinator-ledger`만 쓰고,
|
|
54
|
+
* 미통합 task는 `complete`/`openTasks`로만 알린다.
|
|
55
|
+
*
|
|
56
|
+
* @param {object} read - `readCoordinatorLedger` 결과
|
|
57
|
+
* @returns {IntegrationReport} absent는 비-drive, unreadable은 기존 거절과 함께 실림
|
|
58
|
+
*/
|
|
59
|
+
function buildIntegration(read) {
|
|
60
|
+
// 1. 원장이 없으면 위임 실행이 아니다. required를 켜면 스킬이 비-drive를
|
|
61
|
+
// 미완료로 오인하고 `--yes`를 멈춘다.
|
|
62
|
+
if (!read.ok) {
|
|
63
|
+
if (read.reason !== 'unreadable-ledger') {
|
|
64
|
+
return {
|
|
65
|
+
ledger: 'absent',
|
|
66
|
+
required: false,
|
|
67
|
+
complete: true,
|
|
68
|
+
openTasks: [],
|
|
69
|
+
headVerified: null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
ledger: 'unreadable',
|
|
74
|
+
required: true,
|
|
75
|
+
complete: false,
|
|
76
|
+
openTasks: [],
|
|
77
|
+
headVerified: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const tasks = (Array.isArray(read.ledger.tasks) ? read.ledger.tasks : [])
|
|
81
|
+
.filter((entry) => Boolean(entry) && typeof entry === 'object')
|
|
82
|
+
.map((entry) => asRecord(entry));
|
|
83
|
+
const classified = tasks
|
|
84
|
+
.map((entry) => ({ entry, disposition: (0, execution_contracts_1.validOutsideScope)(read.ledger, String(entry.id)) }))
|
|
85
|
+
.find((row) => row.disposition !== null);
|
|
86
|
+
const open = tasks.filter((entry) => entry.status !== 'integrated'
|
|
87
|
+
&& !(classified && classified.entry === entry));
|
|
88
|
+
const openTasks = open
|
|
89
|
+
.map((entry) => threeDigitTaskId(entry.id))
|
|
90
|
+
.filter((id) => Boolean(id))
|
|
91
|
+
.sort();
|
|
92
|
+
const verification = tasks.filter((entry) => entry.execution_kind === 'verification');
|
|
93
|
+
return {
|
|
94
|
+
ledger: 'ok',
|
|
95
|
+
required: true,
|
|
96
|
+
// open.length: id를 못 읽은 미통합 task도 complete를 false로 남긴다.
|
|
97
|
+
complete: open.length === 0,
|
|
98
|
+
openTasks,
|
|
99
|
+
// verification node가 없으면 head 판정 대상이 없다. null과 false를 구분해
|
|
100
|
+
// 스킬이 "검증 실패"와 "검증 task 없음"을 같은 중지로 접지 않게 한다.
|
|
101
|
+
headVerified: verification.length === 0
|
|
102
|
+
? null
|
|
103
|
+
: verification.every((entry) => entry.status === 'integrated'),
|
|
104
|
+
...(classified && classified.disposition
|
|
105
|
+
? { outsideScope: { task: String(classified.entry.id), disposition: classified.disposition } }
|
|
106
|
+
: {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 원장에서 integrated인 task마다 integration 사본의 tasks.md 상태를 대조한다.
|
|
111
|
+
* commit task는 `verified`, verification task는 `integrated`여야 한다. 원장만 앞서
|
|
112
|
+
* 가고 문서가 따라오지 않으면 G16이 원인 모를 열린 task로 보고하므로, 그 앞에서
|
|
113
|
+
* 어긋난 task를 이름으로 돌려준다. 읽기만 한다.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} read - 읽기에 성공한 `readCoordinatorLedger` 결과
|
|
116
|
+
* @param {string} blueprintDir - blueprint 상대 경로
|
|
117
|
+
* @returns {EvidenceMismatch[]} 어긋난 task. 비어 있으면 일치
|
|
118
|
+
*/
|
|
119
|
+
function coordinatorEvidenceMismatches(read, blueprintDir) {
|
|
120
|
+
const tasks = (Array.isArray(read.ledger.tasks) ? read.ledger.tasks : [])
|
|
121
|
+
.filter((entry) => Boolean(entry) && typeof entry === 'object')
|
|
122
|
+
.map((entry) => asRecord(entry))
|
|
123
|
+
.filter((entry) => entry.status === 'integrated');
|
|
124
|
+
const mismatches = [];
|
|
125
|
+
for (const entry of tasks) {
|
|
126
|
+
const id = threeDigitTaskId(entry.id);
|
|
127
|
+
const expected = entry.execution_kind === 'verification' ? 'integrated' : 'verified';
|
|
128
|
+
let actual = null;
|
|
129
|
+
if (id && read.integrationPath) {
|
|
130
|
+
const file = path.join(read.integrationPath, toPosix(blueprintDir), 'tasks', id, 'tasks.md');
|
|
131
|
+
actual = readTaskStatus(file);
|
|
132
|
+
}
|
|
133
|
+
if (actual !== expected)
|
|
134
|
+
mismatches.push({ id, expected, actual });
|
|
135
|
+
}
|
|
136
|
+
return mismatches;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* tasks.md의 `bouncer.status`를 읽는다. 문서가 없거나 frontmatter를 읽을 수 없으면
|
|
140
|
+
* null — 대조에서는 둘 다 기대 상태와 다른 값이다.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} file - tasks.md 절대 경로
|
|
143
|
+
* @returns {string | null} 상태 문자열 또는 null
|
|
144
|
+
*/
|
|
145
|
+
function readTaskStatus(file) {
|
|
146
|
+
const source = (() => {
|
|
147
|
+
try {
|
|
148
|
+
return fs.readFileSync(file, 'utf8');
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (error.code === 'ENOENT')
|
|
152
|
+
return null;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
if (source === null || !/^---\s*\r?\n/.test(source))
|
|
157
|
+
return null;
|
|
158
|
+
let data;
|
|
159
|
+
try {
|
|
160
|
+
data = parseFrontmatter(source).data;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error instanceof Error && error.name === 'YAMLException')
|
|
164
|
+
return null;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
const root = data && typeof data === 'object' ? asRecord(data) : null;
|
|
168
|
+
const bouncer = root && root.bouncer && typeof root.bouncer === 'object'
|
|
169
|
+
? asRecord(root.bouncer)
|
|
170
|
+
: null;
|
|
171
|
+
const status = bouncer && bouncer.status;
|
|
172
|
+
return typeof status === 'string' ? status : null;
|
|
173
|
+
}
|
|
174
|
+
module.exports = {
|
|
175
|
+
buildIntegration, coordinatorEvidenceMismatches,
|
|
176
|
+
collectMustFixWarnings, isDeferredMustFix,
|
|
177
|
+
};
|