@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,165 @@
|
|
|
1
|
+
// scripts/lib/run-detach.js
|
|
2
|
+
'use strict';
|
|
3
|
+
const { execFileSync: realExecFileSync } = require('node:child_process');
|
|
4
|
+
const scope = require("./scope");
|
|
5
|
+
const { readCoordinatorLedger, withLedgerLock, writeLedgerFile } = scope;
|
|
6
|
+
const runtimeState = require("./runtime-state");
|
|
7
|
+
const { pointerKeyFromBlueprint, removeNamespacePointer } = runtimeState;
|
|
8
|
+
// 모든 detach 거절이 `{ reason, cause, next }`로 답하도록 하는 표. coordinator 모듈을
|
|
9
|
+
// import하지 않고(의존 경계) COORDINATE_FAILURE_HINTS와 같은 관례로 여기에 둔다.
|
|
10
|
+
const DETACH_FAILURE_HINTS = {
|
|
11
|
+
'detach-confirmation-required': {
|
|
12
|
+
cause: 'Detach changes the ledger and pointer, so it needs explicit user approval.',
|
|
13
|
+
next: 'After the user approves, rerun `bouncer run detach --blueprint <dir> --yes`.',
|
|
14
|
+
},
|
|
15
|
+
'detach-awaiting-confirmation': {
|
|
16
|
+
cause: 'The ledger is waiting for a user decision after the repair wave limit, and detach must not bypass it.',
|
|
17
|
+
next: 'Resolve the pending decision with `bouncer coordinate next`, then rerun `bouncer run detach ' +
|
|
18
|
+
'--blueprint <dir> --yes`.',
|
|
19
|
+
},
|
|
20
|
+
'detach-worker-in-flight': {
|
|
21
|
+
cause: 'A task that is not integrated still has an active or reported dispatch, or is prepared or recorded.',
|
|
22
|
+
next: 'Finish or revoke the listed tasks with `bouncer coordinate next`, then rerun `bouncer run detach ' +
|
|
23
|
+
'--blueprint <dir> --yes`.',
|
|
24
|
+
},
|
|
25
|
+
'no-ledger': {
|
|
26
|
+
cause: 'No coordinator ledger exists for this blueprint, so there is no drive to detach.',
|
|
27
|
+
next: 'Check the blueprint path with `bouncer current`, then rerun `bouncer run detach --blueprint <dir> --yes`.',
|
|
28
|
+
},
|
|
29
|
+
'unreadable-ledger': {
|
|
30
|
+
cause: 'The coordinator ledger could not be read as valid JSON.',
|
|
31
|
+
next: 'Inspect the ledger file named in `ledgerFile`, restore it, then rerun `bouncer run detach ' +
|
|
32
|
+
'--blueprint <dir> --yes`.',
|
|
33
|
+
},
|
|
34
|
+
'ledger-locked': {
|
|
35
|
+
cause: 'Another writer holds the ledger lock.',
|
|
36
|
+
next: 'Wait for the other command to finish, then rerun `bouncer run detach --blueprint <dir> --yes`.',
|
|
37
|
+
},
|
|
38
|
+
'ledger-lock-lost': {
|
|
39
|
+
cause: 'The ledger lock was lost during detach, so the ledger and pointer may already have been written.',
|
|
40
|
+
next: 'Rerun `bouncer run detach --blueprint <dir> --yes` and confirm it reports `already: true`.',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* 실패 body에 hint 표의 cause·next를 합친다. 표에 없는 reason도 빈 응답이 되지 않도록
|
|
45
|
+
* reason을 cause로 쓰는 폴백을 둔다(표 전수 보장은 테스트가 한다).
|
|
46
|
+
*
|
|
47
|
+
* @param {{ reason: string; tasks?: string[]; ledgerFile?: string }} body - hint가 없는 실패 body
|
|
48
|
+
* @returns {DetachFail} cause·next가 채워진 실패 결과
|
|
49
|
+
*/
|
|
50
|
+
function withHint(body) {
|
|
51
|
+
const hint = DETACH_FAILURE_HINTS[body.reason] || {
|
|
52
|
+
cause: body.reason,
|
|
53
|
+
next: 'Rerun `bouncer run detach --blueprint <dir> --yes` after fixing the reported reason.',
|
|
54
|
+
};
|
|
55
|
+
return { ok: false, ...body, cause: hint.cause, next: hint.next };
|
|
56
|
+
}
|
|
57
|
+
const defaultGit = (cwd, args) => String(realExecFileSync('git', args, {
|
|
58
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
59
|
+
})).trim();
|
|
60
|
+
/**
|
|
61
|
+
* 관찰용 HEAD를 읽는다. detach의 성공 조건이 아니라 기록용 값이므로(병합 여부를
|
|
62
|
+
* 증거로 쓰지 않는다) 읽지 못하면 null로 남긴다.
|
|
63
|
+
*
|
|
64
|
+
* @param {GitFn} git - git 실행 함수
|
|
65
|
+
* @param {string | undefined} cwd - HEAD를 읽을 worktree
|
|
66
|
+
* @returns {string | null} HEAD sha. cwd가 없거나 git이 실패하면 null
|
|
67
|
+
*/
|
|
68
|
+
function observedHead(git, cwd) {
|
|
69
|
+
if (!cwd)
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
return git(cwd, ['rev-parse', 'HEAD']);
|
|
73
|
+
}
|
|
74
|
+
catch (_error) {
|
|
75
|
+
// git 실행 실패(worktree가 이미 정리됨, git 부재 등)만 흡수한다. 이 값은 사람이
|
|
76
|
+
// 읽는 관찰 기록일 뿐 분리 판정에 쓰이지 않으므로 null이 안전한 폴백이다.
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Git 병합이 끝난 뒤에도 남은 활성 실행 상태를 기록을 보존한 채 분리한다.
|
|
82
|
+
*
|
|
83
|
+
* 사용자가 승인(`yes`)한 경우에만 동작한다. ledger 파일·worktree·branch는 지우지 않고,
|
|
84
|
+
* ledger `status`를 `detached`로 두면서 `detached` decision을 append하며, 해당 blueprint의
|
|
85
|
+
* 포인터 key 하나만 지운다. 검증 성공이나 finalize로 기록하지 않는다.
|
|
86
|
+
* 이미 분리된 ledger는 아무것도 바꾸지 않고 `already: true`로 성공한다(분리 뒤 사용자가
|
|
87
|
+
* finalize를 위해 포인터를 다시 세웠을 수 있어, 재실행이 그것을 지우면 안 된다).
|
|
88
|
+
*
|
|
89
|
+
* @param {object} opts
|
|
90
|
+
* @param {string} opts.repoRoot - 메인 checkout 루트
|
|
91
|
+
* @param {string} opts.blueprint - blueprint 디렉터리 상대 경로
|
|
92
|
+
* @param {string} [opts.reason] - 분리 사유(선택, decision에 기록)
|
|
93
|
+
* @param {boolean} opts.yes - 사용자 승인. true가 아니면 어떤 것도 바꾸지 않는다
|
|
94
|
+
* @param {DetachDeps} [opts.deps] - git·시계 주입
|
|
95
|
+
* @returns {DetachOk | DetachFail} 성공 시 ledger 경로·포인터 삭제 여부·다음 안내,
|
|
96
|
+
* 실패 시 reason(`detach-confirmation-required`, `no-ledger`, `unreadable-ledger`,
|
|
97
|
+
* `detach-awaiting-confirmation`, `detach-worker-in-flight`, `ledger-locked` 등).
|
|
98
|
+
* 모든 실패에서 ledger와 포인터는 바뀌지 않는다.
|
|
99
|
+
*/
|
|
100
|
+
function detachRun({ repoRoot, blueprint, reason, yes, deps }) {
|
|
101
|
+
const git = (deps && deps.git) || defaultGit;
|
|
102
|
+
const now = (deps && deps.now) || (() => new Date().toISOString());
|
|
103
|
+
const withLock = (deps && deps.withLock) || withLedgerLock;
|
|
104
|
+
// 1. 승인 없는 경로를 만들지 않는다: ledger를 읽기 전에 먼저 막는다.
|
|
105
|
+
if (yes !== true)
|
|
106
|
+
return withHint({ reason: 'detach-confirmation-required' });
|
|
107
|
+
// 2. 포인터 key는 쓰기 전에 계산한다. 경로에서 id를 못 뽑으면 throw하므로
|
|
108
|
+
// ledger를 건드리기 전에 실패시켜 부분 변경을 막는다.
|
|
109
|
+
const { key } = pointerKeyFromBlueprint(blueprint);
|
|
110
|
+
const first = readCoordinatorLedger({ repoRoot, blueprint });
|
|
111
|
+
if (!first.ok) {
|
|
112
|
+
return withHint({ reason: first.reason, ledgerFile: first.ledgerFile });
|
|
113
|
+
}
|
|
114
|
+
const ledgerFile = first.ledgerFile;
|
|
115
|
+
const nextHint = `To finalize, run \`bouncer current --set ${blueprint}\` and then /bouncer-finalize.`;
|
|
116
|
+
const outcome = withLock(ledgerFile, (owns) => {
|
|
117
|
+
// 3. 잠금 안에서 다시 읽는다 — 위 읽기 뒤에 다른 writer가 상태를 바꿨을 수 있다.
|
|
118
|
+
const read = readCoordinatorLedger({ repoRoot, blueprint });
|
|
119
|
+
if (!read.ok)
|
|
120
|
+
return withHint({ reason: read.reason, ledgerFile });
|
|
121
|
+
const ledger = read.ledger;
|
|
122
|
+
const previous = ledger.status || 'active';
|
|
123
|
+
if (previous === 'detached') {
|
|
124
|
+
return { ok: true, ledger: ledgerFile, pointer_removed: false, already: true, next: nextHint };
|
|
125
|
+
}
|
|
126
|
+
// 사용자 확인 흐름(repair wave 한도 뒤 결정)을 detach로 우회하지 않는다.
|
|
127
|
+
if (previous === 'awaiting_confirmation') {
|
|
128
|
+
return withHint({ reason: 'detach-awaiting-confirmation' });
|
|
129
|
+
}
|
|
130
|
+
const tasks = Array.isArray(ledger.tasks) ? ledger.tasks : [];
|
|
131
|
+
// integrated task는 정상 완료 뒤에도 dispatch.status 'reported'가 남는다. 그 잔여
|
|
132
|
+
// 상태를 진행 중으로 읽으면 안내된 복구 명령이 스스로 막히므로 먼저 제외한다.
|
|
133
|
+
const inFlight = tasks.filter((item) => item && item.status !== 'integrated' && ((item.dispatch && (item.dispatch.status === 'active' || item.dispatch.status === 'reported'))
|
|
134
|
+
|| item.status === 'prepared' || item.status === 'recorded')).map((item) => item.id);
|
|
135
|
+
if (inFlight.length > 0) {
|
|
136
|
+
return withHint({ reason: 'detach-worker-in-flight', tasks: inFlight });
|
|
137
|
+
}
|
|
138
|
+
// 4. 기존 decision은 그대로 두고 append만 한다. 관찰 HEAD는 기록용이다.
|
|
139
|
+
const decisions = Array.isArray(ledger.decisions) ? ledger.decisions : [];
|
|
140
|
+
decisions.push({
|
|
141
|
+
kind: 'detached',
|
|
142
|
+
at: now(),
|
|
143
|
+
reason: typeof reason === 'string' && reason !== '' ? reason : null,
|
|
144
|
+
integration_head: observedHead(git, read.integrationPath),
|
|
145
|
+
main_head: observedHead(git, repoRoot),
|
|
146
|
+
previous_status: previous,
|
|
147
|
+
});
|
|
148
|
+
ledger.decisions = decisions;
|
|
149
|
+
ledger.status = 'detached';
|
|
150
|
+
// 잠금을 잃었다면 다른 writer의 상태를 덮어쓸 수 있으므로 쓰기 직전에 소유를 확인하고
|
|
151
|
+
// 물러난다. 이 시점까지 ledger와 포인터는 바뀐 적이 없다.
|
|
152
|
+
if (!owns())
|
|
153
|
+
return withHint({ reason: 'ledger-lock-lost' });
|
|
154
|
+
writeLedgerFile(ledgerFile, ledger);
|
|
155
|
+
// 5. 이 blueprint의 key 하나만 지운다. 다른 key와 ledger·worktree·branch는 그대로다.
|
|
156
|
+
const removed = removeNamespacePointer({ repoRoot, key });
|
|
157
|
+
return { ok: true, ledger: ledgerFile, pointer_removed: removed, next: nextHint };
|
|
158
|
+
});
|
|
159
|
+
// withLock이 직접 돌려준 실패(ledger-locked, 쓰기 뒤 해제 실패의 ledger-lock-lost)에도
|
|
160
|
+
// hint를 붙인다. 해제 실패는 이미 쓴 뒤일 수 있어 hint의 next가 재실행 확인을 안내한다.
|
|
161
|
+
if (outcome.ok === false && !('cause' in outcome))
|
|
162
|
+
return withHint(outcome);
|
|
163
|
+
return outcome;
|
|
164
|
+
}
|
|
165
|
+
module.exports = { detachRun, DETACH_FAILURE_HINTS };
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const guards = require("./guards");
|
|
3
|
+
const { isRecord } = guards;
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const current = require("./current");
|
|
7
|
+
const { readCurrent, CurrentSelectionError } = current;
|
|
8
|
+
const tasksDocs = require("./tasks-docs");
|
|
9
|
+
const { listTasksDocs } = tasksDocs;
|
|
10
|
+
const frontmatter = require("./frontmatter");
|
|
11
|
+
const { readDoc } = frontmatter;
|
|
12
|
+
const paths = require("./paths");
|
|
13
|
+
const { toPosix } = paths;
|
|
14
|
+
const schema = require("./schema");
|
|
15
|
+
const { AUTONOMY_ENUM, DEFAULT_AUTONOMY, DEFAULT_SCALE, DEFAULT_DEPENDS_ON, DEFAULT_PARALLEL_SAFE, DEFAULT_DEPENDENCY_GATE, DEFAULT_EXCLUSIVE_RESOURCES, executionKindOf, } = schema;
|
|
16
|
+
const coordinatorLedger = require("./coordinator-ledger");
|
|
17
|
+
const { loadLedger } = coordinatorLedger;
|
|
18
|
+
const executionDag = require("./execution-dag");
|
|
19
|
+
const { readyWave } = executionDag;
|
|
20
|
+
const runtimeState = require("./runtime-state");
|
|
21
|
+
const { coordinatorPathsFor } = runtimeState;
|
|
22
|
+
const configMod = require("./config");
|
|
23
|
+
const { readConfig, readCoordinatorPolicy, readDirectMaxCommitTasks } = configMod;
|
|
24
|
+
const dagShape = require("./dag-shape");
|
|
25
|
+
const { analyzeDriveShape } = dagShape;
|
|
26
|
+
const OPEN_STATUS = ['ready', 'in_progress'];
|
|
27
|
+
const TASK_ID_RE = /^TASKS-(\d{3})$/;
|
|
28
|
+
const TASK_DIR_RE = /(?:^|\/)tasks\/(\d{3})\/tasks\.md$/;
|
|
29
|
+
function isSelectionError(error) {
|
|
30
|
+
return error instanceof CurrentSelectionError
|
|
31
|
+
|| (typeof error === 'object'
|
|
32
|
+
&& error !== null
|
|
33
|
+
&& 'code' in error
|
|
34
|
+
&& typeof error.code === 'string'
|
|
35
|
+
&& String(error.code).startsWith('CURRENT_'));
|
|
36
|
+
}
|
|
37
|
+
function bouncerOf(data) {
|
|
38
|
+
if (!isRecord(data) || !isRecord(data.bouncer))
|
|
39
|
+
return null;
|
|
40
|
+
return data.bouncer;
|
|
41
|
+
}
|
|
42
|
+
function stringList(value) {
|
|
43
|
+
if (!Array.isArray(value))
|
|
44
|
+
return [];
|
|
45
|
+
return value.filter((entry) => typeof entry === 'string');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* frontmatter `TASKS-NNN`과 문서 경로에서 원장과 같은 세 자리 id를 고른다.
|
|
49
|
+
* readyWave는 `other.id === depends_on 항목`으로 선행을 찾으므로, 문서 값
|
|
50
|
+
* `TASKS-001`을 그대로 넘기면 파가 항상 비어 버린다.
|
|
51
|
+
*
|
|
52
|
+
* @param {string | null} taskId - frontmatter `bouncer.id`
|
|
53
|
+
* @param {string} rel - tasks.md 상대 경로
|
|
54
|
+
* @returns {string | null} 세 자리 번호, 둘 다 없으면 null
|
|
55
|
+
*/
|
|
56
|
+
function ledgerId(taskId, rel) {
|
|
57
|
+
if (taskId) {
|
|
58
|
+
const fromId = TASK_ID_RE.exec(taskId);
|
|
59
|
+
if (fromId)
|
|
60
|
+
return fromId[1];
|
|
61
|
+
}
|
|
62
|
+
const fromPath = TASK_DIR_RE.exec(toPosix(rel));
|
|
63
|
+
return fromPath ? fromPath[1] : null;
|
|
64
|
+
}
|
|
65
|
+
function ledgerDependsOn(values) {
|
|
66
|
+
return values.map((entry) => {
|
|
67
|
+
const match = TASK_ID_RE.exec(entry);
|
|
68
|
+
return match ? match[1] : entry;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* 문서의 열린 상태만 원장 `pending`으로 옮긴다. readyWave는 pending만
|
|
73
|
+
* 후보로 보고, `integrated` 같은 종단 상태는 선행 gate 비교에 그대로 쓴다.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} status - tasks.md `bouncer.status`
|
|
76
|
+
* @returns {string} readyWave에 넣을 상태
|
|
77
|
+
*/
|
|
78
|
+
function ledgerStatus(status) {
|
|
79
|
+
return OPEN_STATUS.includes(status) ? 'pending' : status;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* main worktree 설정에서 autonomy를 읽는다. 누락·잘못된 값은 기본값으로 접는다.
|
|
83
|
+
* coordinate status --write-input도 같은 규칙을 쓰도록 export한다.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} repoRoot - `.bouncer/config.json`이 있는 main checkout
|
|
86
|
+
* @returns {Autonomy} 값과 기본값 폴백 여부
|
|
87
|
+
*/
|
|
88
|
+
function readAutonomy(repoRoot) {
|
|
89
|
+
const parsed = readConfig(repoRoot);
|
|
90
|
+
if (!isRecord(parsed) || !Object.prototype.hasOwnProperty.call(parsed, 'autonomy')) {
|
|
91
|
+
return { value: DEFAULT_AUTONOMY, fallback: true, reason: 'missing' };
|
|
92
|
+
}
|
|
93
|
+
const raw = parsed.autonomy;
|
|
94
|
+
if (typeof raw === 'string' && AUTONOMY_ENUM.includes(raw)) {
|
|
95
|
+
return { value: raw, fallback: false, reason: null };
|
|
96
|
+
}
|
|
97
|
+
return { value: DEFAULT_AUTONOMY, fallback: true, reason: 'invalid' };
|
|
98
|
+
}
|
|
99
|
+
function readBlueprintMeta(repoRoot, blueprintDir) {
|
|
100
|
+
try {
|
|
101
|
+
const doc = readDoc(path.join(repoRoot, blueprintDir, 'index.md'));
|
|
102
|
+
const bouncer = bouncerOf(doc.data);
|
|
103
|
+
const status = bouncer && typeof bouncer.status === 'string' ? bouncer.status : '';
|
|
104
|
+
const scale = bouncer && typeof bouncer.scale === 'string' ? bouncer.scale : DEFAULT_SCALE;
|
|
105
|
+
// bouncer 블록이 있는 읽힌 index만 legacy 판정 대상이다. 읽지 못한 문서는
|
|
106
|
+
// status ''로 접혀 blueprint-status-unknown이 되므로 여기서 이중 판정하지 않는다.
|
|
107
|
+
const legacyReviewScope = bouncer !== null && bouncer.review_scope !== 'blueprint';
|
|
108
|
+
return {
|
|
109
|
+
status, scale, legacyReviewScope,
|
|
110
|
+
verificationPolicy: schema.verificationPolicyOf(bouncer) ?? 'invalid',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (_error) {
|
|
114
|
+
// index 부재·파싱 실패는 status를 ''로 접는다. 호출부(docStatusReason)가 ''를
|
|
115
|
+
// blueprint-status-unknown으로 거절하므로 읽지 못한 blueprint는 위임되지 않는다.
|
|
116
|
+
return { status: '', scale: DEFAULT_SCALE, verificationPolicy: 'legacy', legacyReviewScope: false };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// approved가 아닌 문서 status → 위임 거절 reason. 목록에 없는 값(빈 값 포함)은
|
|
120
|
+
// 모두 status-unknown으로 접어, 새 status가 생겨도 기본이 "위임 거절"이 되게 한다.
|
|
121
|
+
const STATUS_REASON = {
|
|
122
|
+
closed: 'blueprint-closed',
|
|
123
|
+
partial_closed: 'blueprint-partial-closed',
|
|
124
|
+
draft: 'blueprint-draft',
|
|
125
|
+
superseded: 'blueprint-superseded',
|
|
126
|
+
imported: 'blueprint-imported',
|
|
127
|
+
};
|
|
128
|
+
/**
|
|
129
|
+
* blueprint 문서 status를 위임 거절 reason으로 바꾼다.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} status - index.md `bouncer.status` (읽지 못하면 '')
|
|
132
|
+
* @returns {PreflightOk['reason']} approved면 null, 아니면 상태별 reason
|
|
133
|
+
*/
|
|
134
|
+
function docStatusReason(status) {
|
|
135
|
+
if (status === 'approved')
|
|
136
|
+
return null;
|
|
137
|
+
return Object.prototype.hasOwnProperty.call(STATUS_REASON, status)
|
|
138
|
+
? STATUS_REASON[status]
|
|
139
|
+
: 'blueprint-status-unknown';
|
|
140
|
+
}
|
|
141
|
+
// 원장 status → 위임 거절 reason. active는 막지 않으므로 목록에 없다.
|
|
142
|
+
const LEDGER_REASON = {
|
|
143
|
+
promotion_stopped: 'promotion-stopped',
|
|
144
|
+
awaiting_confirmation: 'awaiting-confirmation',
|
|
145
|
+
partial_closed: 'partial-closed',
|
|
146
|
+
detached: 'run-detached',
|
|
147
|
+
};
|
|
148
|
+
function collectTasks(repoRoot, blueprintDir) {
|
|
149
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir });
|
|
150
|
+
const openTasks = [];
|
|
151
|
+
const waveTasks = [];
|
|
152
|
+
const shapeTasks = [];
|
|
153
|
+
for (const entry of listing.entries) {
|
|
154
|
+
let data;
|
|
155
|
+
try {
|
|
156
|
+
data = readDoc(path.join(repoRoot, entry.rel)).data;
|
|
157
|
+
}
|
|
158
|
+
catch (_error) {
|
|
159
|
+
// 깨진 문서 하나가 나머지 열린 task를 지우지 않게 이 항목만 건너뛴다.
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const bouncer = bouncerOf(data);
|
|
163
|
+
const status = bouncer && typeof bouncer.status === 'string' ? bouncer.status : '';
|
|
164
|
+
const taskId = bouncer && typeof bouncer.id === 'string' && bouncer.id
|
|
165
|
+
? bouncer.id
|
|
166
|
+
: (typeof entry.id === 'string' ? entry.id : null);
|
|
167
|
+
const id = ledgerId(taskId, entry.rel);
|
|
168
|
+
if (!id)
|
|
169
|
+
continue;
|
|
170
|
+
const dependsOn = bouncer && Object.prototype.hasOwnProperty.call(bouncer, 'depends_on')
|
|
171
|
+
? stringList(bouncer.depends_on)
|
|
172
|
+
: [...DEFAULT_DEPENDS_ON];
|
|
173
|
+
const parallelSafe = bouncer && typeof bouncer.parallel_safe === 'boolean'
|
|
174
|
+
? bouncer.parallel_safe
|
|
175
|
+
: DEFAULT_PARALLEL_SAFE;
|
|
176
|
+
const dependencyGate = bouncer && typeof bouncer.dependency_gate === 'string'
|
|
177
|
+
? bouncer.dependency_gate
|
|
178
|
+
: DEFAULT_DEPENDENCY_GATE;
|
|
179
|
+
const executionKind = executionKindOf(bouncer) || 'commit';
|
|
180
|
+
const affected = bouncer ? stringList(bouncer.affected_paths) : [];
|
|
181
|
+
const exclusive = bouncer && Object.prototype.hasOwnProperty.call(bouncer, 'exclusive_resources')
|
|
182
|
+
? stringList(bouncer.exclusive_resources)
|
|
183
|
+
: [...DEFAULT_EXCLUSIVE_RESOURCES];
|
|
184
|
+
const waveTask = {
|
|
185
|
+
id,
|
|
186
|
+
depends_on: ledgerDependsOn(dependsOn),
|
|
187
|
+
dependency_gate: dependencyGate,
|
|
188
|
+
parallel_safe: parallelSafe,
|
|
189
|
+
status: ledgerStatus(status),
|
|
190
|
+
// 문서에 키가 없어도 []를 넣어 legacy(필드 부재)와 구분한다. preflight가
|
|
191
|
+
// bootstrap 스냅샷과 같은 충돌 집합을 보게 하려는 목적이다.
|
|
192
|
+
affected_paths: affected,
|
|
193
|
+
exclusive_resources: exclusive,
|
|
194
|
+
};
|
|
195
|
+
waveTasks.push(waveTask);
|
|
196
|
+
// review_risk 형식 거절은 S30 소관이므로 배열이 아니면 빈 위험으로 읽는다.
|
|
197
|
+
shapeTasks.push({
|
|
198
|
+
...waveTask,
|
|
199
|
+
execution_kind: executionKind,
|
|
200
|
+
review_risk: stringList(bouncer ? bouncer.review_risk : undefined),
|
|
201
|
+
open: OPEN_STATUS.includes(status),
|
|
202
|
+
});
|
|
203
|
+
if (!OPEN_STATUS.includes(status))
|
|
204
|
+
continue;
|
|
205
|
+
openTasks.push({
|
|
206
|
+
id,
|
|
207
|
+
taskId: taskId || `TASKS-${id}`,
|
|
208
|
+
path: entry.rel,
|
|
209
|
+
status,
|
|
210
|
+
execution_kind: executionKind,
|
|
211
|
+
affected_paths: affected,
|
|
212
|
+
depends_on: dependsOn,
|
|
213
|
+
parallel_safe: parallelSafe,
|
|
214
|
+
dependency_gate: dependencyGate,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return { openTasks, waveTasks, shapeTasks };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* blueprint 경로를 repo 상대 POSIX 문자열로 정규화한다. 절대·상대 입력 모두
|
|
221
|
+
* repoRoot 기준으로 풀고 `./`·`..` 세그먼트와 끝 슬래시를 제거해, 같은 blueprint는
|
|
222
|
+
* 표기와 무관하게 같은 문자열이 되게 한다. repo 밖이면 `..` 접두 또는 절대 경로가 남는다.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} repoRoot - 기준 checkout
|
|
225
|
+
* @param {string} input - repo 상대 또는 절대 경로
|
|
226
|
+
* @returns {string} 정규화한 repo 상대 POSIX 경로
|
|
227
|
+
*/
|
|
228
|
+
function normalizeBlueprintPath(repoRoot, input) {
|
|
229
|
+
const relative = toPosix(path.relative(repoRoot, path.resolve(repoRoot, toPosix(input))));
|
|
230
|
+
return path.posix.normalize(relative).replace(/\/$/, '');
|
|
231
|
+
}
|
|
232
|
+
/** 정규화한 repo 상대 경로가 repo 밖을 가리키는지 본다. */
|
|
233
|
+
function isOutsideRepo(relative) {
|
|
234
|
+
return relative === '..' || relative.startsWith('../') || path.isAbsolute(relative);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 요청 경로가 실재할 때 symlink를 풀어 repo 밖으로 나가는지 본다. 문자열 정규화만으로는
|
|
238
|
+
* repo 안 symlink가 밖의 같은 id blueprint를 가리켜도 통과하기 때문이다. 경로가
|
|
239
|
+
* 실재하지 않으면(ENOENT·ENOTDIR) 풀 대상이 없으므로 문자열 판정에 맡기고 false를 돌려준다.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} repoRoot - 기준 checkout
|
|
242
|
+
* @param {string} requested - `--blueprint` 값
|
|
243
|
+
* @returns {boolean} 실재하는 요청 경로의 realpath가 repo realpath 밖이면 true
|
|
244
|
+
*/
|
|
245
|
+
function resolvesOutsideRepo(repoRoot, requested) {
|
|
246
|
+
let real;
|
|
247
|
+
try {
|
|
248
|
+
real = fs.realpathSync(path.resolve(repoRoot, toPosix(requested)));
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
const code = error.code;
|
|
252
|
+
if (code === 'ENOENT' || code === 'ENOTDIR')
|
|
253
|
+
return false;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
return isOutsideRepo(toPosix(path.relative(fs.realpathSync(repoRoot), real)));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* drive 시작에 필요한 pointer·blueprint·열린 task·DAG·autonomy를 한 JSON으로
|
|
260
|
+
* 정규화한다. 파일을 쓰지 않으며, 위임 가능 여부는 상태만 보고 결정한다.
|
|
261
|
+
* 요청 blueprint가 포인터와 다르면 `pointer-mismatch`(ok:false)로 거절한다.
|
|
262
|
+
* 위임은 원장이 active이거나 없고, 문서 status가 approved이며, 열린 task가
|
|
263
|
+
* 있을 때만 열린다. 그 밖에는 상태별 reason과 함께 delegable:false이다.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} opts
|
|
266
|
+
* @param {string} opts.repoRoot - 포인터와 plan 문서가 있는 checkout
|
|
267
|
+
* @param {string} opts.blueprintDir - `--blueprint` 값
|
|
268
|
+
* @returns {PreflightResult} 성공 payload 또는 거절 reason
|
|
269
|
+
*/
|
|
270
|
+
function runPreflight({ repoRoot, blueprintDir }) {
|
|
271
|
+
const requested = toPosix(blueprintDir);
|
|
272
|
+
let pointer;
|
|
273
|
+
try {
|
|
274
|
+
pointer = readCurrent({ repoRoot });
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (isSelectionError(error)) {
|
|
278
|
+
const fail = { ok: false, reason: error.code };
|
|
279
|
+
if (error.candidates)
|
|
280
|
+
fail.candidates = error.candidates;
|
|
281
|
+
if (error.issues)
|
|
282
|
+
fail.issues = error.issues;
|
|
283
|
+
return fail;
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
if (!pointer)
|
|
288
|
+
return { ok: false, reason: 'no-current' };
|
|
289
|
+
// 포인터와 다른 blueprint를 요청하면 그 task에 포인터의 base를 섞어 위임 가능으로
|
|
290
|
+
// 판정하게 된다. 원본 문자열에서 id를 뽑으면 `<bp>/../<다른 bp>`가 id 비교를 통과하므로,
|
|
291
|
+
// 요청과 포인터를 같은 규칙으로 정규화한 문자열을 비교한다. 끝 슬래시·`./`·repo 안
|
|
292
|
+
// 절대 경로 표기 차이는 정규화로 같아지고, id를 못 뽑는 입력은 포인터와 달라 거절된다.
|
|
293
|
+
const blueprint = normalizeBlueprintPath(repoRoot, requested);
|
|
294
|
+
if (blueprint !== normalizeBlueprintPath(repoRoot, pointer.blueprint)
|
|
295
|
+
// 정규화 결과가 repo 밖(다른 경로의 절대 경로, 루트를 벗어나는 `../`)이면 그 바깥
|
|
296
|
+
// 문서를 읽게 되므로 같은 pointer-mismatch로 거절한다. actual은 요청 원문 그대로 둔다.
|
|
297
|
+
|| isOutsideRepo(blueprint)
|
|
298
|
+
|| resolvesOutsideRepo(repoRoot, requested)) {
|
|
299
|
+
return {
|
|
300
|
+
ok: false, reason: 'pointer-mismatch', expected: pointer.blueprint, actual: requested,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const meta = readBlueprintMeta(repoRoot, blueprint);
|
|
304
|
+
// review_scope 없는 blueprint는 위임·원장 재개 판정보다 먼저 막는다. 실행 중에 최종
|
|
305
|
+
// 리뷰 범위가 바뀌면 안 되므로 사용자가 명시적으로 전환해야 한다. flat layout은
|
|
306
|
+
// review-scope 전환이 거절하므로 task-layout 전환을 먼저 안내한다.
|
|
307
|
+
if (meta.legacyReviewScope) {
|
|
308
|
+
const flat = listTasksDocs({ repoRoot, blueprintDir: blueprint }).legacy;
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
reason: 'legacy-review-scope',
|
|
312
|
+
next: flat
|
|
313
|
+
? 'bouncer migrate task-layout'
|
|
314
|
+
: `bouncer migrate review-scope --blueprint ${blueprint}`,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
const { openTasks, waveTasks, shapeTasks } = collectTasks(repoRoot, blueprint);
|
|
318
|
+
// 우선순위: 원장 상태 → 문서 status → no-open-task. 원장이 진행 중 정지·확인 대기·
|
|
319
|
+
// 부분 종료이면 문서가 approved여도 새 drive를 시작하면 안 된다. 원장 부재·손상·
|
|
320
|
+
// integration 경로 해석 실패는 정지 여부를 알 수 없으므로 miss로 두고 문서만 본다.
|
|
321
|
+
let reason = null;
|
|
322
|
+
try {
|
|
323
|
+
const paths = coordinatorPathsFor({ repoRoot, blueprint });
|
|
324
|
+
const ledger = loadLedger(paths.ledgerFile);
|
|
325
|
+
const ledgerStatus = ledger ? ledger.status : undefined;
|
|
326
|
+
if (ledgerStatus && Object.prototype.hasOwnProperty.call(LEDGER_REASON, ledgerStatus)) {
|
|
327
|
+
reason = LEDGER_REASON[ledgerStatus];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch (_error) {
|
|
331
|
+
// 원장 부재·손상·git 실패는 miss로 흡수한다. 위임을 열어 주는 방향이지만
|
|
332
|
+
// 이어지는 문서 status 검사가 승인 전·종료 blueprint를 여전히 막는다.
|
|
333
|
+
}
|
|
334
|
+
if (reason === null)
|
|
335
|
+
reason = docStatusReason(meta.status);
|
|
336
|
+
if (reason === null && openTasks.length === 0)
|
|
337
|
+
reason = 'no-open-task';
|
|
338
|
+
// preflight는 repoRoot config를 본다. invalid면 1로 접어 ACQ 미리보기가
|
|
339
|
+
// prepare보다 넓은 wave를 약속하지 않게 한다.
|
|
340
|
+
const policy = readCoordinatorPolicy(repoRoot);
|
|
341
|
+
const maxParallel = policy.ok ? policy.maxParallel : 1;
|
|
342
|
+
const shape = analyzeDriveShape(shapeTasks, { maxParallel });
|
|
343
|
+
// 직접 실행은 위임 가능한 full 직렬 체인이 설정 상한 이하일 때만 연다. 상한 0(꺼짐·invalid
|
|
344
|
+
// 폴백)은 commit_tasks >= 1 조건과 겹쳐 항상 false가 된다.
|
|
345
|
+
const directMax = readDirectMaxCommitTasks(repoRoot);
|
|
346
|
+
const directEligible = reason === null
|
|
347
|
+
&& meta.scale === 'full'
|
|
348
|
+
&& shape.drive_shape === 'serial'
|
|
349
|
+
&& shape.commit_tasks >= 1
|
|
350
|
+
&& shape.commit_tasks <= directMax;
|
|
351
|
+
return {
|
|
352
|
+
ok: true,
|
|
353
|
+
blueprint: {
|
|
354
|
+
dir: blueprint, status: meta.status, scale: meta.scale,
|
|
355
|
+
verification_policy: meta.verificationPolicy,
|
|
356
|
+
},
|
|
357
|
+
base: pointer.base,
|
|
358
|
+
openTasks,
|
|
359
|
+
readyWave: readyWave(waveTasks, { maxParallel }),
|
|
360
|
+
autonomy: readAutonomy(repoRoot),
|
|
361
|
+
delegable: reason === null,
|
|
362
|
+
reason,
|
|
363
|
+
// readyWave와 같은 maxParallel(invalid는 1)로 돌려 두 값이 어긋나지 않게 한다.
|
|
364
|
+
shape,
|
|
365
|
+
direct_eligible: directEligible,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
module.exports = { runPreflight, readAutonomy };
|