@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.
Files changed (156) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/bouncer +26 -0
  4. package/bin/bouncer-root +25 -0
  5. package/bin/build-stamp.js +78 -0
  6. package/bin/register-path.js +71 -0
  7. package/dist/.build-stamp +1 -0
  8. package/dist/approval-snapshot.js +99 -0
  9. package/dist/bouncer-root.js +80 -0
  10. package/dist/canonical-json.js +37 -0
  11. package/dist/cli-commit-guard-command.js +74 -0
  12. package/dist/cli-config-command.js +47 -0
  13. package/dist/cli-context-review-command.js +273 -0
  14. package/dist/cli-current-command.js +374 -0
  15. package/dist/cli-dispatch-command.js +119 -0
  16. package/dist/cli-doc-commands.js +352 -0
  17. package/dist/cli-epic-command.js +130 -0
  18. package/dist/cli-flags.js +50 -0
  19. package/dist/cli-git-commands.js +931 -0
  20. package/dist/cli-intent-command.js +491 -0
  21. package/dist/cli-metrics-command.js +134 -0
  22. package/dist/cli-ontology-command.js +109 -0
  23. package/dist/cli-project-commands.js +580 -0
  24. package/dist/cli-retro-command.js +138 -0
  25. package/dist/cli-review-command.js +251 -0
  26. package/dist/cli-review-dispatch-command.js +338 -0
  27. package/dist/cli-usage.js +72 -0
  28. package/dist/cli.js +70 -0
  29. package/dist/codex-agents.js +189 -0
  30. package/dist/command-argv.js +125 -0
  31. package/dist/commit-guard.js +72 -0
  32. package/dist/commit-hook.js +470 -0
  33. package/dist/commit-message.js +77 -0
  34. package/dist/commit-sha.js +107 -0
  35. package/dist/commit.js +519 -0
  36. package/dist/composite-gates.js +129 -0
  37. package/dist/comprehension.js +127 -0
  38. package/dist/config.js +298 -0
  39. package/dist/context-ids.js +288 -0
  40. package/dist/context-migration-rewrite.js +421 -0
  41. package/dist/context-ontology.js +746 -0
  42. package/dist/context-review-contracts.js +2 -0
  43. package/dist/context-review-projection.js +147 -0
  44. package/dist/context-review-state.js +824 -0
  45. package/dist/context-review-store.js +602 -0
  46. package/dist/context-review-transitions.js +412 -0
  47. package/dist/coordinate-advance.js +253 -0
  48. package/dist/coordinate-next.js +998 -0
  49. package/dist/coordinate-output.js +57 -0
  50. package/dist/coordinator-command-contracts.js +2 -0
  51. package/dist/coordinator-commands.js +1908 -0
  52. package/dist/coordinator-fanin.js +1021 -0
  53. package/dist/coordinator-git.js +121 -0
  54. package/dist/coordinator-input.js +37 -0
  55. package/dist/coordinator-ledger.js +381 -0
  56. package/dist/coordinator-prepare.js +1047 -0
  57. package/dist/coordinator-repair.js +1416 -0
  58. package/dist/coordinator-verification.js +42 -0
  59. package/dist/coordinator-worker.js +388 -0
  60. package/dist/coordinator.js +7 -0
  61. package/dist/cross-drive.js +85 -0
  62. package/dist/current.js +1148 -0
  63. package/dist/dag-shape.js +93 -0
  64. package/dist/doc-shape-contracts.js +241 -0
  65. package/dist/drive-metrics.js +691 -0
  66. package/dist/epic-index.js +347 -0
  67. package/dist/epic-registry.js +453 -0
  68. package/dist/execute-prepare.js +262 -0
  69. package/dist/execution-contracts.js +46 -0
  70. package/dist/execution-dag.js +148 -0
  71. package/dist/finalize-checks.js +177 -0
  72. package/dist/finalize-collect.js +343 -0
  73. package/dist/finalize-comprehension.js +116 -0
  74. package/dist/finalize-digest-collect.js +388 -0
  75. package/dist/finalize-digest-model.js +595 -0
  76. package/dist/finalize-digest.js +164 -0
  77. package/dist/finalize-explain.js +176 -0
  78. package/dist/finalize-pr.js +610 -0
  79. package/dist/finalize-release-main.js +652 -0
  80. package/dist/finalize.js +714 -0
  81. package/dist/frontmatter.js +22 -0
  82. package/dist/git-ops.js +49 -0
  83. package/dist/graph-exec.js +179 -0
  84. package/dist/graph-scope.js +268 -0
  85. package/dist/graphify.js +995 -0
  86. package/dist/guards.js +82 -0
  87. package/dist/import-git.js +78 -0
  88. package/dist/import-history.js +392 -0
  89. package/dist/import-render.js +59 -0
  90. package/dist/import-types.js +2 -0
  91. package/dist/init.js +571 -0
  92. package/dist/intent-bundle.js +936 -0
  93. package/dist/intent-provenance.js +927 -0
  94. package/dist/layout.js +131 -0
  95. package/dist/lease.js +145 -0
  96. package/dist/legacy-comments.js +71 -0
  97. package/dist/migrate-context-slugs.js +262 -0
  98. package/dist/migrate-merge-legacy-epics.js +392 -0
  99. package/dist/migrate-review-scope.js +144 -0
  100. package/dist/migrate-task-layout.js +194 -0
  101. package/dist/path-overlap.js +38 -0
  102. package/dist/paths.js +45 -0
  103. package/dist/plan-approve.js +218 -0
  104. package/dist/plan-inspect.js +575 -0
  105. package/dist/plan-prerequisites.js +197 -0
  106. package/dist/plan-snapshot-contract.js +98 -0
  107. package/dist/plan-snapshot.js +138 -0
  108. package/dist/plugin-root.js +237 -0
  109. package/dist/posix-path.js +14 -0
  110. package/dist/pre-commit-hook.js +170 -0
  111. package/dist/print-dispatch.js +285 -0
  112. package/dist/render.js +8 -0
  113. package/dist/retention-migration.js +595 -0
  114. package/dist/retro-read.js +259 -0
  115. package/dist/retro-sources.js +318 -0
  116. package/dist/review-dispatch.js +1054 -0
  117. package/dist/review-policy.js +28 -0
  118. package/dist/review-record.js +447 -0
  119. package/dist/review-repair-state.js +52 -0
  120. package/dist/review-snapshot.js +659 -0
  121. package/dist/run-detach.js +165 -0
  122. package/dist/run-preflight.js +368 -0
  123. package/dist/runtime-state.js +1038 -0
  124. package/dist/scaffold.js +533 -0
  125. package/dist/schema.js +294 -0
  126. package/dist/scope.js +1073 -0
  127. package/dist/seed-worktree.js +926 -0
  128. package/dist/session-graph.js +337 -0
  129. package/dist/subagents.js +86 -0
  130. package/dist/symbol-index.js +1390 -0
  131. package/dist/task-brief-hash.js +34 -0
  132. package/dist/task-commits.js +124 -0
  133. package/dist/tasks-docs.js +192 -0
  134. package/dist/templates.js +469 -0
  135. package/dist/time.js +21 -0
  136. package/dist/validate-docs.js +302 -0
  137. package/dist/validate-gates-finalize.js +303 -0
  138. package/dist/validate-gates-plan.js +720 -0
  139. package/dist/validate-gates-run.js +420 -0
  140. package/dist/validate-gates-shared.js +124 -0
  141. package/dist/validate-gates.js +87 -0
  142. package/dist/validate-sections.js +654 -0
  143. package/dist/validate-structural.js +347 -0
  144. package/dist/validate.js +417 -0
  145. package/dist/verification-contracts.js +26 -0
  146. package/dist/verification-evidence.js +233 -0
  147. package/dist/verification-execution.js +163 -0
  148. package/dist/verification-identity.js +413 -0
  149. package/dist/verification-target.js +51 -0
  150. package/dist/verification.js +383 -0
  151. package/dist/windows-npm.js +38 -0
  152. package/package.json +32 -4
  153. package/resources/graphify-compat.json +9 -0
  154. package/vendor/README.md +23 -0
  155. package/vendor/js-yaml.LICENSE +21 -0
  156. package/vendor/js-yaml.js +3022 -0
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ /**
5
+ * @param {Exec} exec - Git 실행기
6
+ * @param {string} cwd - checkout
7
+ * @param {string[]} args - Git 인자
8
+ * @returns {string} trim된 stdout
9
+ */
10
+ function git(exec, cwd, args) {
11
+ return String(exec('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })).trim();
12
+ }
13
+ /**
14
+ * dispatch 직전 porcelain 출력을 원문 그대로 보존한다.
15
+ *
16
+ * @param {Exec} exec - 주입 가능한 Git 실행기
17
+ * @param {string} cwd - worker worktree
18
+ * @returns {string} porcelain=v1 stdout 원문
19
+ */
20
+ function initialWorktreeState(exec, cwd) {
21
+ return String(exec('git', ['status', '--porcelain=v1'], {
22
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
23
+ }));
24
+ }
25
+ /** @param {Exec} exec - Git 실행기 @param {string} cwd - checkout @returns {string} source porcelain */
26
+ function sourceStatus(exec, cwd) {
27
+ // .worktrees는 bootstrap fixture에서 ignore 규칙이 아직 없어도 source 변경으로 세지 않는다.
28
+ return git(exec, cwd, ['status', '--porcelain']).split('\n')
29
+ .filter((line) => !line.slice(3).startsWith('.worktrees/')).join('\n');
30
+ }
31
+ /**
32
+ * worker HEAD가 주어진 SHA를 조상으로 갖는지 확인한다.
33
+ *
34
+ * @param {Exec} exec - Git 실행기
35
+ * @param {string} workerPath - 할당된 worker checkout
36
+ * @param {string} sha - 검사할 commit SHA
37
+ * @returns {boolean} 조상이면 true, Git 조회 실패면 false
38
+ */
39
+ function workerOwnsSha(exec, workerPath, sha) {
40
+ try {
41
+ git(exec, workerPath, ['merge-base', '--is-ancestor', sha, 'HEAD']);
42
+ return true;
43
+ }
44
+ catch (_error) {
45
+ // 얕은 clone이나 삭제된 object도 안전하게 입증할 수 없어 소유하지 않은 것으로 처리한다.
46
+ return false;
47
+ }
48
+ }
49
+ /**
50
+ * SHA의 단일 부모를 구한다. merge 또는 Git 조회 실패는 provenance를 추측하지 않고 거절한다.
51
+ *
52
+ * @param {Exec} exec - Git 실행기
53
+ * @param {string} cwd - 할당된 worker checkout
54
+ * @param {string} sha - 검사할 worker HEAD
55
+ * @returns {string | null} 유일한 부모 SHA 또는 null
56
+ */
57
+ function directParent(exec, cwd, sha) {
58
+ try {
59
+ const fields = git(exec, cwd, ['rev-list', '--parents', '-n', '1', sha]).split(/\s+/);
60
+ return fields.length === 2 ? fields[1] : null;
61
+ }
62
+ catch (_error) {
63
+ // 존재하지 않는 object와 Git 조회 실패는 모두 provenance를 입증하지 못한다.
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * worker 경로가 symlink가 아니며 integration 저장소에 등록된 worktree인지 검사한다.
69
+ *
70
+ * @param {Exec} exec - Git 실행기
71
+ * @param {string} integrationPath - integration checkout
72
+ * @param {string} workerPath - 검사할 worker 경로
73
+ * @returns {boolean} 등록된 동일 checkout이면 true
74
+ */
75
+ function registeredWorker(exec, integrationPath, workerPath) {
76
+ try {
77
+ const canonicalWorker = fs.realpathSync(workerPath);
78
+ if (canonicalWorker !== path.resolve(workerPath))
79
+ return false;
80
+ const workerRoot = fs.realpathSync(path.dirname(workerPath));
81
+ const relative = path.relative(workerRoot, canonicalWorker);
82
+ if (relative === '' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
83
+ return false;
84
+ const registered = git(exec, integrationPath, ['worktree', 'list', '--porcelain'])
85
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => line.slice('worktree '.length));
86
+ return registered.some((entry) => fs.realpathSync(entry) === canonicalWorker);
87
+ }
88
+ catch (_error) {
89
+ // 사라진 경로·Git 조회 실패는 등록을 입증하지 못하므로 false다.
90
+ return false;
91
+ }
92
+ }
93
+ /**
94
+ * integration 경로가 symlink가 아니며 project 저장소에 등록된 worktree인지 검사한다.
95
+ *
96
+ * @param {Exec} exec - Git 실행기
97
+ * @param {string} repoRoot - project checkout
98
+ * @param {string} integrationPath - 검사할 integration 경로
99
+ * @returns {boolean} 등록된 동일 checkout이면 true
100
+ */
101
+ function registeredIntegration(exec, repoRoot, integrationPath) {
102
+ try {
103
+ const canonicalIntegration = fs.realpathSync(integrationPath);
104
+ if (canonicalIntegration !== path.resolve(integrationPath))
105
+ return false;
106
+ const integrationRoot = fs.realpathSync(path.dirname(integrationPath));
107
+ const relative = path.relative(integrationRoot, canonicalIntegration);
108
+ if (relative === '' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
109
+ return false;
110
+ const registered = git(exec, repoRoot, ['worktree', 'list', '--porcelain'])
111
+ .split('\n').filter((line) => line.startsWith('worktree ')).map((line) => line.slice('worktree '.length));
112
+ return registered.some((entry) => fs.realpathSync(entry) === canonicalIntegration);
113
+ }
114
+ catch (_error) {
115
+ // 경로나 Git이 유효하지 않으면 integration checkout 소유권을 입증할 수 없다.
116
+ return false;
117
+ }
118
+ }
119
+ module.exports = {
120
+ git, initialWorktreeState, sourceStatus, workerOwnsSha, directParent, registeredWorker, registeredIntegration,
121
+ };
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+ /**
3
+ * coordinator print dispatch 입력 텍스트를 조립한다. 입력 파일 형식을 세션이
4
+ * 플러그인 소스에서 역산하지 않도록 `coordinate status --write-input`이 이
5
+ * 함수의 결과를 그대로 쓴다. 파일 I/O와 값 수집은 호출자 몫이라 순수 함수다.
6
+ *
7
+ * @param {object} fields - 입력 텍스트에 들어갈 값
8
+ * @param {string} fields.integrationPath - coordinator와 worker의 유일한 write cwd
9
+ * @param {string} fields.blueprint - blueprint 디렉터리
10
+ * @param {string} fields.base - 원장 `base` SHA
11
+ * @param {CoordinatorInputCheckpoint} fields.checkpoint - status checkpoint (ledger ref 포함)
12
+ * @param {string} fields.autonomy - 보고 주기로만 쓰는 autonomy 값
13
+ * @param {string} fields.projectRoot - main worktree. base SHA 출처 표기에만 쓴다
14
+ * @returns {string} 개행으로 끝나는 coordinator 입력 텍스트
15
+ */
16
+ function buildCoordinatorInput(fields) {
17
+ const { checkpoint } = fields;
18
+ // revision은 원장에 없을 수 있다(null). 줄 자체는 형식 고정을 위해 항상 남긴다.
19
+ return [
20
+ 'Coordinator dispatch input',
21
+ `write cwd: ${fields.integrationPath}`,
22
+ `blueprint: ${fields.blueprint}`,
23
+ `base SHA: ${fields.base}`,
24
+ `checkpoint.ledger.path: ${checkpoint.ledger.path}`,
25
+ `checkpoint.ledger.sha256: ${checkpoint.ledger.sha256}`,
26
+ `checkpoint.ledger.revision: ${checkpoint.ledger.revision}`,
27
+ `checkpoint: ${JSON.stringify(checkpoint)}`,
28
+ `autonomy: ${fields.autonomy} (reporting cadence only; open no per-task ACQ)`,
29
+ `read-only provenance: ${fields.projectRoot} (base SHA provenance only; never a write cwd)`,
30
+ // 조기 반환 방지 가드: native/generic/print 모두 이 문자열을 그대로 받는다.
31
+ // 병렬 launch는 막지 않고, 핸들만 받은 상태를 보고로 오인하지 못하게 한다.
32
+ 'Before returning, wait for every worker final report (a handle is not a report) and integrate your whole wave;',
33
+ 'return continue only with at least one newly integrated task id, never an empty list or a partial wave.',
34
+ '',
35
+ ].join('\n');
36
+ }
37
+ module.exports = { buildCoordinatorInput };
@@ -0,0 +1,381 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { createHash } = require('node:crypto');
5
+ const runtime = require("./runtime-state");
6
+ const configMod = require("./config");
7
+ const { readCoordinatorPolicy } = configMod;
8
+ const executionDag = require("./execution-dag");
9
+ const { readyWave } = executionDag;
10
+ const leaseMod = require("./lease");
11
+ const { isValidLease, isValidLeaseSeq } = leaseMod;
12
+ const coordinatorWorker = require("./coordinator-worker");
13
+ const { reportPathFor } = coordinatorWorker;
14
+ const LEDGER_REL = runtime.COORDINATOR_LEDGER_REL;
15
+ function atomicWrite(file, data) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true });
17
+ const temporary = `${file}.${process.pid}.tmp`;
18
+ fs.writeFileSync(temporary, `${JSON.stringify(data, null, 2)}\n`);
19
+ fs.renameSync(temporary, file);
20
+ }
21
+ /**
22
+ * 원장 bytes와 파싱 결과를 같은 snapshot에서 읽어 fence가 TOCTOU로 느슨해지지 않게 한다.
23
+ *
24
+ * @param {string} file - 원장 절대 경로
25
+ * @returns {{ ledger: Ledger, bytes: Buffer } | null} 파일이 없으면 null
26
+ */
27
+ function loadLedgerBytes(file) {
28
+ if (!fs.existsSync(file))
29
+ return null;
30
+ const bytes = fs.readFileSync(file);
31
+ return { ledger: JSON.parse(bytes.toString('utf8')), bytes };
32
+ }
33
+ function loadLedger(file) {
34
+ const loaded = loadLedgerBytes(file);
35
+ return loaded ? loaded.ledger : null;
36
+ }
37
+ /** @param {Buffer} bytes - load 시점의 원장 bytes @returns {string} SHA-256 hex */
38
+ function ledgerBytesHash(bytes) {
39
+ return createHash('sha256').update(bytes).digest('hex');
40
+ }
41
+ /**
42
+ * mutation이 들고 온 원장 경로와 hash를 검사하고 detached ledger를 차단한다.
43
+ *
44
+ * @param {{ ledgerPath?: string, ledgerHash?: string, ledgerBytes: Buffer }} opts - fence 입력
45
+ * @returns {{ok: true} | {ok: false, reason: string}} 기존 거절 reason 또는 통과
46
+ */
47
+ function assertLedgerFence(opts) {
48
+ const { ledgerPath, ledgerHash, ledgerBytes } = opts;
49
+ if (typeof ledgerPath !== 'string' || ledgerPath === ''
50
+ || typeof ledgerHash !== 'string' || ledgerHash === '')
51
+ return { ok: false, reason: 'ledger-checkpoint-invalid' };
52
+ if (path.isAbsolute(ledgerPath))
53
+ return { ok: false, reason: 'ledger-checkpoint-invalid' };
54
+ if (ledgerPath.includes('\\') || ledgerPath.split('/').includes('..')
55
+ || ledgerPath.split('/').includes('') || ledgerPath !== LEDGER_REL) {
56
+ return { ok: false, reason: 'ledger-checkpoint-invalid' };
57
+ }
58
+ if (!/^[a-f0-9]{64}$/.test(ledgerHash))
59
+ return { ok: false, reason: 'ledger-checkpoint-invalid' };
60
+ if (!Buffer.isBuffer(ledgerBytes) || ledgerBytes.length === 0) {
61
+ return { ok: false, reason: 'ledger-checkpoint-invalid' };
62
+ }
63
+ if (ledgerBytesHash(ledgerBytes) !== ledgerHash)
64
+ return { ok: false, reason: 'stale-ledger-checkpoint' };
65
+ // 모든 fenced mutation이 공유하는 끝단 검사라 새 명령도 detached 상태를 빠뜨리지 않는다.
66
+ const parsed = JSON.parse(ledgerBytes.toString('utf8'));
67
+ if (parsed && parsed.status === 'detached')
68
+ return { ok: false, reason: 'run-detached' };
69
+ return { ok: true };
70
+ }
71
+ /**
72
+ * 잠금 소유를 확인한 뒤에만 원장을 쓴다.
73
+ *
74
+ * @param {() => boolean} owns - 현재 잠금 소유 확인
75
+ * @param {(file: string, data: unknown) => void} writeLedger - 원자 쓰기 구현
76
+ * @param {string} file - 원장 경로
77
+ * @param {Ledger} ledger - 기록할 원장
78
+ * @returns {{ok: false, reason: 'ledger-lock-lost'} | null} 소유 상실 시 실패
79
+ */
80
+ function writeOwnedLedger(owns, writeLedger, file, ledger) {
81
+ if (!owns())
82
+ return { ok: false, reason: 'ledger-lock-lost' };
83
+ writeLedger(file, ledger);
84
+ return null;
85
+ }
86
+ /** 성공 mutation 결과에 다음 fencing token이 될 checkpoint를 붙인다. */
87
+ function withCheckpoint(result, ledger, ledgerFile) {
88
+ return { ...result, checkpoint: projectCheckpoint(ledger, ledgerFile) };
89
+ }
90
+ /**
91
+ * 잠금 안에서 원장을 읽고 기존 순서대로 lease·명령별 validation·fence를 검사한다.
92
+ * reason 우선순위가 caller마다 달라지지 않도록 이 공유 helper가 순서를 소유한다.
93
+ *
94
+ * @param {object} opts - 원장 경로와 검증 조건
95
+ * @returns {OpenedLedger | LedgerFail} bytes snapshot 또는 기존 거절 reason
96
+ */
97
+ function openLedgerFenced({ ledgerFile, ledgerPath, ledgerHash, validate }) {
98
+ const loaded = loadLedgerBytes(ledgerFile);
99
+ if (!loaded)
100
+ return { ok: false, reason: 'missing-ledger' };
101
+ const { ledger, bytes } = loaded;
102
+ const leaseShape = assertLeaseShape(ledger);
103
+ if (!leaseShape.ok)
104
+ return leaseShape;
105
+ const rejected = validate ? validate(ledger) : null;
106
+ if (rejected)
107
+ return rejected;
108
+ const fenced = assertLedgerFence({ ledgerPath, ledgerHash, ledgerBytes: bytes });
109
+ if (!fenced.ok)
110
+ return fenced;
111
+ return { ok: true, ledger, bytes };
112
+ }
113
+ /**
114
+ * 잠금 재획득 뒤 원장이 checkpoint 이후 바뀌지 않았는지 확인한다. null hash는 caller fence를 재검사한다.
115
+ *
116
+ * @param {object} opts - 재확인 입력
117
+ * @returns {OpenedLedger | LedgerFail} 현재 snapshot 또는 기존 거절 reason
118
+ */
119
+ function recheckLedgerCheckpoint({ ledgerFile, expectedHash, fence, onStale }) {
120
+ const loaded = loadLedgerBytes(ledgerFile);
121
+ if (!loaded)
122
+ return { ok: false, reason: 'missing-ledger' };
123
+ const { ledger, bytes } = loaded;
124
+ if (expectedHash === null) {
125
+ const fenced = assertLedgerFence({
126
+ ledgerPath: fence?.ledgerPath, ledgerHash: fence?.ledgerHash, ledgerBytes: bytes,
127
+ });
128
+ if (!fenced.ok)
129
+ return fenced;
130
+ }
131
+ else if (ledgerBytesHash(bytes) !== expectedHash) {
132
+ if (onStale)
133
+ onStale(ledger);
134
+ return { ok: false, reason: 'stale-ledger-checkpoint' };
135
+ }
136
+ return { ok: true, ledger, bytes };
137
+ }
138
+ /**
139
+ * fan-in 사전 검사로 fence보다 먼저 원장 전체 불변조건을 검사한다.
140
+ *
141
+ * @param {Ledger} ledger - 방금 연 원장
142
+ * @returns {LedgerFail | null} 거절 reason 또는 통과
143
+ */
144
+ function validateFaninLedger(ledger) {
145
+ const checked = runtime.validateCoordinatorLedger(ledger);
146
+ return checked.ok ? null : { ok: false, reason: checked.reason };
147
+ }
148
+ /**
149
+ * 보고 파일의 존재를 투영 시점에 관측한다. 원장에는 기록하지 않는다.
150
+ * 일반 파일이고 크기가 0보다 클 때만 present다. 파일 없음·0바이트·디렉터리와
151
+ * 그 밖의 모든 stat 오류는 absent로 접는다 — 이 관측은 복구 재개 여부를 정하는
152
+ * 보수적 신호일 뿐이라 오류를 던지면 status 전체가 깨지고, absent는 기존
153
+ * 보존 중단 경로로 이어지므로 안전하다.
154
+ *
155
+ * @param {string} reportPath - 현재 attempt의 보고 파일 절대 경로
156
+ * @returns {'present' | 'absent'} 관측 결과
157
+ */
158
+ function observeReport(reportPath) {
159
+ try {
160
+ const stat = fs.statSync(reportPath);
161
+ return stat.isFile() && stat.size > 0 ? 'present' : 'absent';
162
+ }
163
+ catch (_error) {
164
+ // stat 실패(ENOENT·EACCES 등)는 모두 absent: 위 docstring의 보수적 접힘 근거 참조.
165
+ return 'absent';
166
+ }
167
+ }
168
+ /**
169
+ * 현재 CLI는 호스트 실행기를 추적하지 않으므로 항상 관측 불가를 뜻하는 상수 모양이다.
170
+ * running·terminated는 root가 실제 호스트 핸들로만 확인하며 여기서 만들지 않는다.
171
+ */
172
+ // 실패 사유가 아니므로 `reason: '...'` 리터럴 대신 상수로 두어 COORDINATE_FAILURE_HINTS
173
+ // 전수 검사(실패 reason 리터럴 수집)에 섞이지 않게 한다.
174
+ const EXECUTOR_NOT_TRACKED = 'executor-state-not-tracked';
175
+ /** checkpoint에 싣는 note 개수 상한. 오래된 note는 원장에만 남는다. */
176
+ const CHECKPOINT_NOTES_LIMIT = 5;
177
+ /**
178
+ * 읽기 전용 ready 계산용 한도. 잘못된 config는 1로 접어 병렬 폭을 넓히지 않는다.
179
+ * prepare의 거절 경로와 달리 status·current·commit 안내는 worktree를 만들지 않으므로
180
+ * 여기서는 거절 대신 보수적 폴백만 한다.
181
+ *
182
+ * @param {string} repoRoot - 정책을 읽을 checkout 루트
183
+ * @returns {number} 유효 한도 또는 invalid 시 1
184
+ */
185
+ function maxParallelForRead(repoRoot) {
186
+ const policy = readCoordinatorPolicy(repoRoot);
187
+ return policy.ok ? policy.maxParallel : 1;
188
+ }
189
+ /**
190
+ * 원장 파일 경로에서 integration checkout 루트를 복원한다. checkpoint·bootstrap
191
+ * ready가 prepare와 같은 config를 읽게 하려고 ledger 상대 위치만 쓴다.
192
+ *
193
+ * @param {string} ledgerFile - `.bouncer/runtime/coordinator.json` 절대 경로
194
+ * @returns {string} integration checkout 루트
195
+ */
196
+ function integrationRootFromLedger(ledgerFile) {
197
+ return path.resolve(path.dirname(ledgerFile), '..', '..');
198
+ }
199
+ /**
200
+ * 완료 task를 status에 실을 요약만 고른다. dispatch·decisions 본문은 상세 원장에
201
+ * 남기고, 없는 선택 필드는 키 자체를 생략해 응답이 커지지 않게 한다.
202
+ *
203
+ * @param {Task} task - 원장 task
204
+ * @returns {CompletedTaskSummary} 완료 summary
205
+ */
206
+ function summarizeCompletedTask(task) {
207
+ const summary = { id: task.id, status: task.status || 'integrated' };
208
+ if (task.dispatch && Number.isInteger(task.dispatch.attempt)) {
209
+ summary.attempt = task.dispatch.attempt;
210
+ }
211
+ if (typeof task.sha === 'string' && task.sha !== '')
212
+ summary.commit_sha = task.sha;
213
+ if (task.scope && Array.isArray(task.scope.paths))
214
+ summary.changed_paths = [...task.scope.paths];
215
+ if (task.scope && typeof task.scope.revision === 'string') {
216
+ summary.scope_revision = task.scope.revision;
217
+ }
218
+ if (typeof task.verify_evidence_id === 'string' && task.verify_evidence_id !== '') {
219
+ summary.verify_evidence_id = task.verify_evidence_id;
220
+ }
221
+ if (typeof task.review_evidence_id === 'string' && task.review_evidence_id !== '') {
222
+ summary.review_evidence_id = task.review_evidence_id;
223
+ }
224
+ if (task.advisory !== undefined)
225
+ summary.advisory = task.advisory;
226
+ return summary;
227
+ }
228
+ /**
229
+ * 다음 dispatch/fan-in에 필요한 활성 task 필드만 고른다. Interface allowlist는
230
+ * current scope·dependency·branch/worktree·dispatch metadata뿐이다 —
231
+ * execution_kind/sha/criticalRecovery/dynamic은 상세 원장에 두고 status를 키우지 않는다.
232
+ *
233
+ * @param {Task} task - 아직 integrated가 아닌 task
234
+ * @returns {ActiveTaskProjection} 활성 projection
235
+ */
236
+ function projectActiveTask(task) {
237
+ const projected = {
238
+ id: task.id,
239
+ status: task.status || 'pending',
240
+ };
241
+ if (task.depends_on)
242
+ projected.depends_on = [...task.depends_on];
243
+ if (task.dependency_gate)
244
+ projected.dependency_gate = task.dependency_gate;
245
+ if (task.parallel_safe !== undefined)
246
+ projected.parallel_safe = task.parallel_safe;
247
+ if (task.workerPath)
248
+ projected.workerPath = task.workerPath;
249
+ if (task.branch)
250
+ projected.branch = task.branch;
251
+ if (task.scope)
252
+ projected.scope = { revision: task.scope.revision, paths: [...task.scope.paths] };
253
+ if (task.dispatch)
254
+ projected.dispatch = { ...task.dispatch };
255
+ // active attempt에만 붙인다: reported 보고는 원장 outcome이 이미 정본이다.
256
+ if (task.dispatch?.status === 'active' && task.workerPath) {
257
+ const reportPath = reportPathFor(task.workerPath, task.id, task.dispatch.attempt);
258
+ projected.report = {
259
+ path: reportPath, attempt: task.dispatch.attempt, state: observeReport(reportPath),
260
+ };
261
+ }
262
+ return projected;
263
+ }
264
+ /**
265
+ * 상세 원장을 보존한 채 status/mutation 응답에 실을 bounded checkpoint를 만든다.
266
+ * unresolved decision은 아직 integrated가 아닌 task를 가리키는 항목만 남겨,
267
+ * 완료 task의 과거 decision 본문이 활성 context로 되살아나지 않게 한다.
268
+ * sha256은 호출자가 넘긴 원장 bytes(또는 쓰기 직후 디스크)에 묶는다.
269
+ *
270
+ * @param {Ledger} ledger - 메모리상 원장 정본
271
+ * @param {string} ledgerFile - 쓰기 후 hash용 절대 경로(ledgerBytes 없을 때)
272
+ * @param {Buffer} [ledgerBytes] - load 시 고정한 bytes. 있으면 디스크를 다시 읽지 않는다
273
+ * @returns {CoordinatorCheckpoint} compact checkpoint
274
+ */
275
+ function projectCheckpoint(ledger, ledgerFile, ledgerBytes) {
276
+ const completed_tasks = [];
277
+ const active_tasks = [];
278
+ const integratedIds = new Set();
279
+ for (const task of ledger.tasks) {
280
+ if (task.status === 'integrated') {
281
+ integratedIds.add(task.id);
282
+ completed_tasks.push(summarizeCompletedTask(task));
283
+ }
284
+ else {
285
+ active_tasks.push(projectActiveTask(task));
286
+ }
287
+ }
288
+ const decisions = Array.isArray(ledger.decisions) ? ledger.decisions : [];
289
+ // note는 task를 가리켜도 미해결 판단이 아니라 인계 메모이므로 unresolved_decisions에서 뺀다.
290
+ const isNote = (entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry) && entry.kind === 'note';
291
+ const unresolved_decisions = decisions
292
+ .filter((entry) => {
293
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
294
+ return false;
295
+ if (isNote(entry))
296
+ return false;
297
+ const id = entry.task;
298
+ return typeof id === 'string' && !integratedIds.has(id);
299
+ });
300
+ // task 없는 note와 아직 통합되지 않은 task의 note만 최근 N개 싣는다(통합된 task의 메모는 낡았다).
301
+ const notes = decisions
302
+ .filter(isNote)
303
+ .filter((n) => n.task === undefined || !integratedIds.has(n.task))
304
+ .slice(-CHECKPOINT_NOTES_LIMIT)
305
+ .map((n) => ({
306
+ text: n.text, written_at: n.written_at, ...(n.task !== undefined ? { task: n.task } : {}),
307
+ }));
308
+ // 쓰기 직후는 디스크로 확정된 bytes를 읽고, status·fence 직전에는 load bytes를 재사용한다.
309
+ const sha256 = ledgerBytes
310
+ ? ledgerBytesHash(ledgerBytes)
311
+ : ledgerBytesHash(fs.readFileSync(ledgerFile));
312
+ return {
313
+ ready: readyWaveGated(ledger.tasks, {
314
+ maxParallel: maxParallelForRead(integrationRootFromLedger(ledgerFile)),
315
+ }),
316
+ active_tasks,
317
+ completed_tasks,
318
+ unresolved_decisions,
319
+ notes,
320
+ recent_failure: ledger.terminalFailure || null,
321
+ integration_head: typeof ledger.integrationHead === 'string' ? ledger.integrationHead : null,
322
+ revision: typeof ledger.revision === 'string' ? ledger.revision : null,
323
+ ledger: {
324
+ path: LEDGER_REL,
325
+ sha256,
326
+ revision: typeof ledger.revision === 'string' ? ledger.revision : null,
327
+ },
328
+ // active는 launch 전에 기록되고 stale report도 해시를 바꾸므로, 원장 값은
329
+ // 워커 생존의 증거가 아니다. 프로세스 관측·원장 저장 없이 한계만 명시한다.
330
+ executor_observation: {
331
+ state: 'unknown', source: 'unavailable', reason: EXECUTOR_NOT_TRACKED,
332
+ },
333
+ };
334
+ }
335
+ /**
336
+ * `readyWave`에 "재배정된 task가 재통합되기 전에는 terminal을 열지 않는다"는 규칙을 더한다.
337
+ * `repair --resume-task`는 DAG를 바꾸지 않으므로 terminal의 직접 선행(통합 완료)만 보면 terminal이
338
+ * 재작업과 동시에(또는 id 순서상 먼저) 열릴 수 있다. resumed 표지가 붙은 task가 integrated가 되기
339
+ * 전까지 pending terminal을 후보에서 뺀다. resumed 표지가 없는 원장은 입력 그대로 `readyWave`에 간다.
340
+ *
341
+ * @param {Task[]} tasks - 원장 task 목록(읽기만 한다)
342
+ * @param {{ maxParallel?: number }} [options] - `readyWave`와 같은 동시 실행 상한
343
+ * @returns {string[]} 다음에 열 수 있는 task id
344
+ */
345
+ function readyWaveGated(tasks, options) {
346
+ const reopened = tasks.some((entry) => entry.resumed === true && entry.status !== 'integrated');
347
+ if (!reopened)
348
+ return readyWave(tasks, options);
349
+ return readyWave(tasks.map((entry) => (entry.execution_kind === 'verification' && (entry.status || 'pending') === 'pending'
350
+ ? { ...entry, status: 'waiting-for-resumed' }
351
+ : entry)), options);
352
+ }
353
+ /**
354
+ * 원장 lease 필드만 검사한다. 전체 validateCoordinatorLedger는 partial-close
355
+ * 불변조건까지 보므로, 일반 mutation에서는 lease-invalid만 분리해 거절한다.
356
+ *
357
+ * @param {Ledger} ledger - 로드한 원장
358
+ * @returns {{ok: true} | {ok: false, reason: 'lease-invalid'}}
359
+ */
360
+ function assertLeaseShape(ledger) {
361
+ if (!isValidLeaseSeq(ledger.leaseSeq))
362
+ return { ok: false, reason: 'lease-invalid' };
363
+ for (const task of ledger.tasks) {
364
+ if (task.lease !== undefined && !isValidLease(task.lease)) {
365
+ return { ok: false, reason: 'lease-invalid' };
366
+ }
367
+ }
368
+ return { ok: true };
369
+ }
370
+ /** 원장 mode를 읽는다. 값이 없거나 light가 아니면 legacy full로 해석한다. */
371
+ function ledgerModeOf(ledger) {
372
+ return ledger.mode === 'light' ? 'light' : 'full';
373
+ }
374
+ module.exports = {
375
+ atomicWrite, loadLedgerBytes, loadLedger, ledgerBytesHash, assertLedgerFence, writeOwnedLedger, LEDGER_REL,
376
+ projectCheckpoint, assertLeaseShape,
377
+ // coordinator-commands가 checkpoint와 같은 ready 계산·한도 폴백을 쓰도록 함께 내보낸다.
378
+ readyWaveGated, maxParallelForRead,
379
+ withCheckpoint, openLedgerFenced, recheckLedgerCheckpoint, validateFaninLedger,
380
+ ledgerModeOf,
381
+ };