@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,343 @@
|
|
|
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 { readDoc } = frontmatter;
|
|
8
|
+
const tasksDocs = require("./tasks-docs");
|
|
9
|
+
const { listTasksDocs } = tasksDocs;
|
|
10
|
+
const scope = require("./scope");
|
|
11
|
+
const { readCoordinatorLedger } = scope;
|
|
12
|
+
const commitSha = require("./commit-sha");
|
|
13
|
+
const { buildStableProvenance, normalizeCommitSha } = commitSha;
|
|
14
|
+
const contextIds = require("./context-ids");
|
|
15
|
+
const { parseStableId } = contextIds;
|
|
16
|
+
const gitOps = require("./git-ops");
|
|
17
|
+
const { resolveCheckoutBranch } = gitOps;
|
|
18
|
+
function asRecord(value) { return value; }
|
|
19
|
+
// 번호·slug 두 형식의 task stable ID에서 epic·blueprint key와 task 번호를 꺼낸다. task가 없는 intent ID는 null이다.
|
|
20
|
+
// 부모(Explain frontmatter) 비교는 형식별 key 문자열 그대로 한다 — 형식 간 변환을 여기서 하지 않는다.
|
|
21
|
+
function taskStableParts(id) {
|
|
22
|
+
const parsed = parseStableId(id);
|
|
23
|
+
return parsed && parsed.task !== null
|
|
24
|
+
? { epicKey: parsed.epicKey, blueprintKey: parsed.blueprintKey, task: parsed.task }
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
// buildStableProvenance가 던지는 "입력 형식 위반" 메시지만 가린다(번호/slug key, TASKS-NNN).
|
|
28
|
+
const PROVENANCE_INPUT_ERROR_RE = /must be a three-digit id|must be a slug|must be TASKS-NNN/;
|
|
29
|
+
/**
|
|
30
|
+
* tasks.md frontmatter에서 stable Task ID를 만든다.
|
|
31
|
+
* epic_id·blueprint_id·id가 정본이 아니면 null — 잘못된 값을 패딩하지 않는다.
|
|
32
|
+
*
|
|
33
|
+
* @param {unknown} data - tasks.md frontmatter 루트
|
|
34
|
+
* @returns {string | null} `EPIC-ddd/BP-ddd/TASK-ddd` 또는 `<epic>/<bp>/ddd`, 불가 시 null
|
|
35
|
+
*/
|
|
36
|
+
function stableIdFromTaskData(data) {
|
|
37
|
+
// data·bouncer 부재는 legacy `### Task NNN` 폴백이다. asRecord만 쓰면
|
|
38
|
+
// undefined.bouncer에서 throw해 retention·순수 테스트 fixture까지 깨진다.
|
|
39
|
+
if (!data || typeof data !== 'object')
|
|
40
|
+
return null;
|
|
41
|
+
const bouncer = asRecord(asRecord(data).bouncer);
|
|
42
|
+
if (!bouncer || typeof bouncer !== 'object')
|
|
43
|
+
return null;
|
|
44
|
+
try {
|
|
45
|
+
return buildStableProvenance({
|
|
46
|
+
epicId: bouncer.epic_id,
|
|
47
|
+
blueprintId: bouncer.blueprint_id,
|
|
48
|
+
taskId: bouncer.id,
|
|
49
|
+
}).task;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
// 번호·slug key·TASKS-NNN 계약 위반만 흡수한다. 그 외는 호출부에서 본다.
|
|
53
|
+
if (error instanceof Error
|
|
54
|
+
&& PROVENANCE_INPUT_ERROR_RE.test(error.message)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function stringOrNull(value) {
|
|
61
|
+
return typeof value === 'string' && value !== '' ? value : null;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 원장에서 finalize·digest가 실제로 읽는 여섯 필드만 접는다.
|
|
65
|
+
* task SHA·decisions·repair wave는 마감 JSON에 실리면 모델이 drive 실행
|
|
66
|
+
* 기록을 재사용하므로 여기서 버린다. worker별 `git symbolic-ref`도 같은
|
|
67
|
+
* 이유로 호출하지 않는다 — 소비처는 integration branch와 worktree 경로뿐이다.
|
|
68
|
+
* 원장이 없으면 위임 실행이 아니므로 null. 빈 객체를 주면 호출부가
|
|
69
|
+
* "drive인데 기록이 비었다"와 구분하지 못한다.
|
|
70
|
+
*
|
|
71
|
+
* @param {CoordinatorLedgerLike | null | undefined} ledger - coordinator 원장. 없으면 null
|
|
72
|
+
* @param {object} [paths] - integration·원장 경로. 생략 시 둘 다 null
|
|
73
|
+
* @param {string | null} [paths.integrationPath] - integration worktree 절대 경로
|
|
74
|
+
* @param {string | null} [paths.ledgerFile] - 원장 파일 절대 경로
|
|
75
|
+
* @returns {{
|
|
76
|
+
* status: 'ok', ledgerFile: string | null, base: string | null,
|
|
77
|
+
* integrationBranch: string | null, integrationPath: string | null,
|
|
78
|
+
* worktrees: string[]
|
|
79
|
+
* } | null} 원장 없으면 null
|
|
80
|
+
*/
|
|
81
|
+
function buildCoordinatorProvenance(ledger, { integrationPath = null, ledgerFile = null } = {}) {
|
|
82
|
+
if (!ledger)
|
|
83
|
+
return null;
|
|
84
|
+
// 빈 workerPath는 정리 대상이 아니다. 순서는 원장 task 순서를 유지한다.
|
|
85
|
+
const workerPaths = (Array.isArray(ledger.tasks) ? ledger.tasks : [])
|
|
86
|
+
.filter((entry) => Boolean(entry) && typeof entry === 'object')
|
|
87
|
+
.map((entry) => stringOrNull(entry.workerPath))
|
|
88
|
+
.filter((p) => Boolean(p));
|
|
89
|
+
return {
|
|
90
|
+
status: 'ok',
|
|
91
|
+
// 성공 경로도 원장 경로를 싣는다. unreadable 분기에만 채우면 소비자가
|
|
92
|
+
// `ledgerFile: null`을 "원장이 없다"와 "원장은 있는데 알려주지 않았다"로
|
|
93
|
+
// 구분하지 못한다.
|
|
94
|
+
ledgerFile,
|
|
95
|
+
base: stringOrNull(ledger.base),
|
|
96
|
+
integrationBranch: stringOrNull(ledger.integrationBranch)
|
|
97
|
+
|| resolveCheckoutBranch(integrationPath),
|
|
98
|
+
integrationPath,
|
|
99
|
+
// cleanup 목록: integration이 먼저고, 할당된 worker worktree가 뒤따른다.
|
|
100
|
+
// payload의 top-level `worktrees`는 정리 계약(SKILL step 5,
|
|
101
|
+
// cleanup-handoff.md)이 읽는 안정된 자리이다.
|
|
102
|
+
worktrees: [
|
|
103
|
+
...(integrationPath ? [integrationPath] : []),
|
|
104
|
+
...workerPaths,
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 원장 읽기 결과만으로 provenance를 접는다.
|
|
110
|
+
* finalize는 같은 읽기로 `integration`도 만들어 두 필드가 서로 다른 원장을
|
|
111
|
+
* 가리키지 않게 한다. 공개 API는 아래 `collectCoordinatorProvenance`다.
|
|
112
|
+
*
|
|
113
|
+
* @param {ReturnType<typeof readCoordinatorLedger>} read - `readCoordinatorLedger` 결과
|
|
114
|
+
* @returns {CoordinatorProvenance | UnreadableProvenance | null} 원장 없음은 null, 깨진 원장은 unreadable
|
|
115
|
+
*/
|
|
116
|
+
function provenanceFromLedgerRead(read) {
|
|
117
|
+
if (!read.ok) {
|
|
118
|
+
if (read.reason !== 'unreadable-ledger')
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
status: 'unreadable',
|
|
122
|
+
ledgerFile: read.ledgerFile || null,
|
|
123
|
+
integrationPath: read.integrationPath || null,
|
|
124
|
+
base: null,
|
|
125
|
+
integrationBranch: null,
|
|
126
|
+
worktrees: [],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return buildCoordinatorProvenance(read.ledger, {
|
|
130
|
+
integrationPath: read.integrationPath,
|
|
131
|
+
ledgerFile: read.ledgerFile,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 저장소에서 원장을 찾아 provenance로 접는다.
|
|
136
|
+
*
|
|
137
|
+
* 원장이 아예 없으면 위임 실행이 아니므로 null이다. 읽을 수는 있으나 깨진
|
|
138
|
+
* 원장은 null이 아니다 — null로 접으면 top-level `worktrees: []`만 남아
|
|
139
|
+
* 비-drive finalize와 구분되지 않고, 검증되지 않은 fan-in이 완료로
|
|
140
|
+
* 기록된다. `status`와 고칠 파일 경로를 실어 호출부가 멈출 수 있게 한다.
|
|
141
|
+
*
|
|
142
|
+
* @param {object} opts
|
|
143
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
144
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
145
|
+
* @returns {CoordinatorProvenance | UnreadableProvenance | null} 원장 없음은 null
|
|
146
|
+
*/
|
|
147
|
+
function collectCoordinatorProvenance({ repoRoot, blueprintDir }) {
|
|
148
|
+
return provenanceFromLedgerRead(readCoordinatorLedger({ repoRoot, blueprint: blueprintDir }));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* finalize가 closed 전이와 함께 지울 일회성 문서 경로를 모은다.
|
|
152
|
+
* tasks.md·verification.md·review.md와 (있을 때만) context-review.md가 대상이다.
|
|
153
|
+
* explain.md·index.md는 절대 넣지 않는다.
|
|
154
|
+
* light blueprint는 context-review.md가 없으므로 목록에 나타나지 않는다.
|
|
155
|
+
*
|
|
156
|
+
* @param {object} opts
|
|
157
|
+
* @param {string} opts.repoRoot - 저장소 루트
|
|
158
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
159
|
+
* @returns {string[]} 존재하는 일회성 문서의 posix 상대 경로
|
|
160
|
+
*/
|
|
161
|
+
/**
|
|
162
|
+
* closed 전이에 지울 일회성 문서 경로를 모은다.
|
|
163
|
+
* task leaf와 context-review는 항상 대상이다. 루트 review.md는
|
|
164
|
+
* `review_scope: blueprint`일 때만 넣는다 — 구형 blueprint의 루트
|
|
165
|
+
* 파일은 사람이 둔 증적이라 마감이 지우면 안 된다.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} opts - 저장소와 blueprint 경로
|
|
168
|
+
* @param {string} opts.repoRoot - 저장소 루트 절대 경로
|
|
169
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
170
|
+
* @returns {string[]} 존재하는 일회성 문서의 상대 경로
|
|
171
|
+
*/
|
|
172
|
+
function collectTransientRels({ repoRoot, blueprintDir }) {
|
|
173
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir });
|
|
174
|
+
const rels = [];
|
|
175
|
+
for (const entry of listing.entries) {
|
|
176
|
+
// verification.md도 task 실행 증적이라 closed 뒤에는 남기지 않는다.
|
|
177
|
+
for (const leaf of ['tasks', 'verification', 'review']) {
|
|
178
|
+
const ref = entry[leaf];
|
|
179
|
+
// verification node는 review leaf를 만들지 않는다. 없는 leaf는 삭제
|
|
180
|
+
// 대상으로 합성하지 않고, 실제 listing이 제공한 문서만 수집한다.
|
|
181
|
+
if (!ref)
|
|
182
|
+
continue;
|
|
183
|
+
const rel = ref.rel;
|
|
184
|
+
if (fs.existsSync(path.join(repoRoot, rel)))
|
|
185
|
+
rels.push(rel);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const bp = toPosix(blueprintDir);
|
|
189
|
+
const contextReviewRel = `${bp}/context-review.md`;
|
|
190
|
+
if (fs.existsSync(path.join(repoRoot, contextReviewRel))) {
|
|
191
|
+
rels.push(contextReviewRel);
|
|
192
|
+
}
|
|
193
|
+
if (isBlueprintReviewScopeAt(repoRoot, bp)) {
|
|
194
|
+
const rootReviewRel = `${bp}/review.md`;
|
|
195
|
+
if (fs.existsSync(path.join(repoRoot, rootReviewRel))) {
|
|
196
|
+
rels.push(rootReviewRel);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return rels;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* blueprint index의 `review_scope === 'blueprint'`만 모드로 본다.
|
|
203
|
+
* 파일 부재·파싱 실패는 구형 계약(지우지 않음)으로 접는다 — 모드로
|
|
204
|
+
* 오인하면 남겨야 할 루트 리뷰를 지운다.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} repoRoot - 저장소 루트
|
|
207
|
+
* @param {string} blueprintDir - blueprint 상대 경로
|
|
208
|
+
* @returns {boolean} 모드이면 true
|
|
209
|
+
*/
|
|
210
|
+
function isBlueprintReviewScopeAt(repoRoot, blueprintDir) {
|
|
211
|
+
const abs = path.join(repoRoot, `${toPosix(blueprintDir)}/index.md`);
|
|
212
|
+
if (!fs.existsSync(abs))
|
|
213
|
+
return false;
|
|
214
|
+
try {
|
|
215
|
+
const { data } = readDoc(abs);
|
|
216
|
+
const bouncer = asRecord(asRecord(data).bouncer);
|
|
217
|
+
return bouncer.review_scope === 'blueprint';
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
// YAML/frontmatter 파싱 실패만 흡수한다. 권한 오류는 숨기지 않는다.
|
|
221
|
+
if (error instanceof Error && (error.name === 'YAMLException' || /frontmatter|YAML/i.test(error.message))) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* explain.md frontmatter의 Epic·Blueprint 번호를 읽는다.
|
|
229
|
+
* task_commits 행의 stable ref가 이 부모와 같은 번호인지 대조하기 위해서다.
|
|
230
|
+
*
|
|
231
|
+
* @param {object} opts
|
|
232
|
+
* @param {string} opts.repoRoot - 저장소 루트
|
|
233
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
234
|
+
* @returns {{epicId: string, blueprintId: string} | null} 읽을 수 없으면 null
|
|
235
|
+
*/
|
|
236
|
+
function explainParentIds({ repoRoot, blueprintDir }) {
|
|
237
|
+
const abs = path.join(repoRoot, `${toPosix(blueprintDir)}/explain.md`);
|
|
238
|
+
if (!fs.existsSync(abs))
|
|
239
|
+
return null;
|
|
240
|
+
let data;
|
|
241
|
+
try {
|
|
242
|
+
data = readDoc(abs).data;
|
|
243
|
+
}
|
|
244
|
+
catch (_e) {
|
|
245
|
+
// 깨진 YAML·frontmatter만 흡수한다. 부모 ID를 경로에서 추측하면 다른
|
|
246
|
+
// Blueprint의 커밋이 이 Explain에 실릴 수 있다. collectTaskCommits는
|
|
247
|
+
// null이면 행을 만들지 않고, writeExplainTaskCommits도 같은 부모 대조로
|
|
248
|
+
// 직렬화를 거절한다.
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
if (!data || typeof data !== 'object')
|
|
252
|
+
return null;
|
|
253
|
+
const bouncer = asRecord(asRecord(data).bouncer);
|
|
254
|
+
const epicId = bouncer.epic_id;
|
|
255
|
+
const blueprintId = bouncer.blueprint_id;
|
|
256
|
+
if (typeof epicId !== 'string' || typeof blueprintId !== 'string')
|
|
257
|
+
return null;
|
|
258
|
+
return { epicId, blueprintId };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* tasks.md의 commit_sha와 stable Task ID를 모아 explain 보존용 task_commits를 만든다.
|
|
262
|
+
* 새 행은 `{ task, sha, intent_anchor }`만 쓴다. sha가 없거나 짧은 hex가 아니면 건너뛴다.
|
|
263
|
+
* `commits`에 trailer로 찾은 SHA가 있으면 그 sha8을 쓰고, 없을 때만 commit_sha로 폴백한다.
|
|
264
|
+
* stable ID가 정본이 아니거나 Explain 부모 ID를 못 읽거나 Epic·Blueprint가
|
|
265
|
+
* 다르면 행을 만들지 않는다.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} opts
|
|
268
|
+
* @param {string} opts.repoRoot - 저장소 루트
|
|
269
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
270
|
+
* @param {Map<string, TrailerCommit>} [opts.commits] - resolveTaskCommits 결과. 있으면 trailer 우선
|
|
271
|
+
* @returns {TaskCommitEntry[]} 쓸 수 있는 provenance 행. 없으면 빈 배열
|
|
272
|
+
*/
|
|
273
|
+
function collectTaskCommits({ repoRoot, blueprintDir, commits }) {
|
|
274
|
+
const parent = explainParentIds({ repoRoot, blueprintDir });
|
|
275
|
+
// 부모 번호를 못 읽으면 행을 만들지 않는다. 경로에서 채우면 다른
|
|
276
|
+
// Blueprint SHA가 이 Explain에 남는다.
|
|
277
|
+
if (!parent)
|
|
278
|
+
return [];
|
|
279
|
+
const listing = listTasksDocs({ repoRoot, blueprintDir });
|
|
280
|
+
const out = [];
|
|
281
|
+
for (const entry of listing.entries) {
|
|
282
|
+
if (entry.number == null)
|
|
283
|
+
continue;
|
|
284
|
+
const abs = path.join(repoRoot, entry.tasks.rel);
|
|
285
|
+
if (!fs.existsSync(abs))
|
|
286
|
+
continue;
|
|
287
|
+
let data;
|
|
288
|
+
try {
|
|
289
|
+
data = readDoc(abs).data;
|
|
290
|
+
}
|
|
291
|
+
catch (_e) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const bouncer = asRecord(asRecord(data).bouncer);
|
|
295
|
+
let provenance;
|
|
296
|
+
try {
|
|
297
|
+
provenance = buildStableProvenance({
|
|
298
|
+
epicId: bouncer.epic_id,
|
|
299
|
+
blueprintId: bouncer.blueprint_id,
|
|
300
|
+
taskId: bouncer.id,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
// epic_id·blueprint_id가 \d{3}이 아니거나 id가 TASKS-NNN이 아닌 경우만
|
|
305
|
+
// 건너뛴다. 잘못된 값을 패딩하면 다른 Blueprint 커밋과 같은 ref가 된다.
|
|
306
|
+
if (error instanceof Error
|
|
307
|
+
&& PROVENANCE_INPUT_ERROR_RE.test(error.message)) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
const parts = taskStableParts(provenance.task);
|
|
313
|
+
// helper가 만든 문자열만 온다. 매칭 실패는 계약 파손이라 추측해서 채우지 않는다.
|
|
314
|
+
if (!parts)
|
|
315
|
+
continue;
|
|
316
|
+
if (parts.epicKey !== parent.epicId || parts.blueprintKey !== parent.blueprintId) {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// trailer SHA가 있으면 worker worktree의 commit_sha보다 우선한다 —
|
|
320
|
+
// integration에 cherry-pick된 도달 가능 commit을 Explain이 가리키게 하기 위함.
|
|
321
|
+
const trailer = commits && commits.get(provenance.task);
|
|
322
|
+
const sha = trailer
|
|
323
|
+
? normalizeCommitSha(trailer.sha8 || trailer.sha)
|
|
324
|
+
: normalizeCommitSha(bouncer.commit_sha);
|
|
325
|
+
if (!sha)
|
|
326
|
+
continue;
|
|
327
|
+
out.push({
|
|
328
|
+
task: provenance.task,
|
|
329
|
+
sha,
|
|
330
|
+
intent_anchor: `task-${parts.task}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
module.exports = {
|
|
336
|
+
taskStableParts,
|
|
337
|
+
stableIdFromTaskData,
|
|
338
|
+
buildCoordinatorProvenance,
|
|
339
|
+
provenanceFromLedgerRead,
|
|
340
|
+
collectCoordinatorProvenance,
|
|
341
|
+
collectTransientRels,
|
|
342
|
+
collectTaskCommits,
|
|
343
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// 사용자 quiz 확인 이후 explain.md의 `bouncer.comprehension` 단일 entry를 생성하는 소유자.
|
|
3
|
+
// 수기 복사로 range/diff_sha가 어긋나는 일을 막으려고 digest·HEAD·computeDiffSha 세 곳이
|
|
4
|
+
// 모두 같을 때만 쓴다. G16 판정과 computeDiffSha 의미는 건드리지 않는다.
|
|
5
|
+
const nodeFs = require('node:fs');
|
|
6
|
+
const nodePath = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const comprehension = require("./comprehension");
|
|
9
|
+
const frontmatter = require("./frontmatter");
|
|
10
|
+
const render = require("./render");
|
|
11
|
+
const paths = require("./paths");
|
|
12
|
+
const timeLib = require("./time");
|
|
13
|
+
const { computeDiffSha, resolveComprehensionEntry } = comprehension;
|
|
14
|
+
const { parseFrontmatter } = frontmatter;
|
|
15
|
+
const { renderDoc } = render;
|
|
16
|
+
const { toPosix } = paths;
|
|
17
|
+
const { nowIsoKst } = timeLib;
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function nonEmpty(value) {
|
|
22
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 사용자 quiz 확인 뒤 blueprint explain.md에 comprehension entry를 정확히 한 개 기록한다.
|
|
26
|
+
* 모든 거절은 파일 bytes를 바꾸기 전에 일어난다. 쓰기는 같은 디렉터리의 임시 파일에 쓴 뒤
|
|
27
|
+
* rename하므로 쓰기 중 실패해도 기존 explain.md는 온전하다.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.repoRoot - 실행 checkout 루트(explain.md를 쓰는 곳)
|
|
31
|
+
* @param {string} opts.blueprintDir - blueprint 상대 경로
|
|
32
|
+
* @param {unknown} opts.digest - `finalize prepare`가 낸 digest JSON(파싱된 값)
|
|
33
|
+
* @param {boolean} opts.confirmed - 실제 사용자 quiz 답변을 받은 controller의 --confirm-quiz
|
|
34
|
+
* @param {boolean} opts.refresh - 기존 유효 기록을 후속 HEAD로 갱신하는 --refresh
|
|
35
|
+
* @param {object} [opts.deps] - 테스트 seam: exec(git argv), now, fs
|
|
36
|
+
* @returns {RecordResult} 기록한 entry 또는 거절 reason
|
|
37
|
+
* (consent-required: 확인·refresh 둘 다 없음, input-invalid: digest·Explain·기존 기록 손상,
|
|
38
|
+
* target-stale: digest가 현재 HEAD·diff fingerprint·기존 base와 다름)
|
|
39
|
+
*/
|
|
40
|
+
function recordComprehension({ repoRoot, blueprintDir, digest, confirmed, refresh, deps }) {
|
|
41
|
+
const fs = (deps && deps.fs) || nodeFs;
|
|
42
|
+
const exec = (deps && deps.exec) || ((args) => {
|
|
43
|
+
const r = spawnSync('git', args, {
|
|
44
|
+
cwd: repoRoot, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
45
|
+
});
|
|
46
|
+
return { status: typeof r.status === 'number' ? r.status : 1, stdout: r.stdout || '' };
|
|
47
|
+
});
|
|
48
|
+
const now = (deps && deps.now) || (() => new Date());
|
|
49
|
+
const invalid = { ok: false, reason: 'comprehension-input-invalid' };
|
|
50
|
+
const stale = { ok: false, reason: 'comprehension-target-stale' };
|
|
51
|
+
// 1. 동의가 가장 먼저다. 확인도 refresh도 없으면 입력을 읽지도 않아 어떤 검증 결과도 새지 않는다.
|
|
52
|
+
if (!confirmed && !refresh)
|
|
53
|
+
return { ok: false, reason: 'comprehension-consent-required' };
|
|
54
|
+
// 2. digest 형태 검증. 필요한 필드가 문자열이 아니면 손상 입력이다.
|
|
55
|
+
if (!isRecord(digest) || !isRecord(digest.blueprint) || !isRecord(digest.range))
|
|
56
|
+
return invalid;
|
|
57
|
+
const { base, head, diff_sha: digestSha } = digest.range;
|
|
58
|
+
if (!nonEmpty(base) || !nonEmpty(head) || !nonEmpty(digestSha))
|
|
59
|
+
return invalid;
|
|
60
|
+
const bp = toPosix(blueprintDir).replace(/\/+$/, '');
|
|
61
|
+
if (!nonEmpty(digest.blueprint.dir) || toPosix(digest.blueprint.dir).replace(/\/+$/, '') !== bp)
|
|
62
|
+
return invalid;
|
|
63
|
+
// 3. explain.md 읽기·파싱. 없거나 frontmatter가 깨졌으면 쓸 곳이 없다.
|
|
64
|
+
const explainPath = nodePath.join(repoRoot, bp, 'explain.md');
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = parseFrontmatter(fs.readFileSync(explainPath, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
catch (_e) {
|
|
70
|
+
// 파일 부재·읽기 실패·frontmatter 파싱 실패를 모두 "쓸 수 없는 Explain"으로 모은다.
|
|
71
|
+
// 어느 경우든 아직 아무것도 쓰지 않았으므로 하나의 거절로 합쳐도 안전하다.
|
|
72
|
+
return invalid;
|
|
73
|
+
}
|
|
74
|
+
if (!isRecord(parsed.data) || !isRecord(parsed.data.bouncer))
|
|
75
|
+
return invalid;
|
|
76
|
+
const data = parsed.data;
|
|
77
|
+
const bouncer = data.bouncer;
|
|
78
|
+
// 4. 현재 HEAD·diff fingerprint가 digest와 같아야 한다. 후속 커밋 뒤의 낡은 digest를 거른다.
|
|
79
|
+
const headNow = exec(['rev-parse', 'HEAD']);
|
|
80
|
+
if (headNow.status !== 0 || headNow.stdout.trim() !== head)
|
|
81
|
+
return stale;
|
|
82
|
+
const diff = computeDiffSha({
|
|
83
|
+
repoRoot, base, exec: (args) => ({ ...exec(args), stderr: '' }),
|
|
84
|
+
});
|
|
85
|
+
if (!diff.ok || diff.sha !== digestSha)
|
|
86
|
+
return stale;
|
|
87
|
+
// 5. refresh는 기존 유효 기록이 있을 때만, 그리고 base가 같을 때만 range_from을 유지한 채 갱신한다.
|
|
88
|
+
// quiz를 건너뛰는 최초 기록의 대체 수단이 되지 않게 한다.
|
|
89
|
+
let rangeFrom = base;
|
|
90
|
+
if (refresh) {
|
|
91
|
+
const existing = resolveComprehensionEntry(bouncer.comprehension);
|
|
92
|
+
if (!existing.ok)
|
|
93
|
+
return invalid;
|
|
94
|
+
if (existing.entry.range_from !== base)
|
|
95
|
+
return stale;
|
|
96
|
+
rangeFrom = existing.entry.range_from;
|
|
97
|
+
}
|
|
98
|
+
// 6. task 필드 없는 단일 entry로 배열을 통째 교체한다.
|
|
99
|
+
const entry = {
|
|
100
|
+
range_from: rangeFrom, range_to: head, diff_sha: digestSha, recorded_at: nowIsoKst(now()),
|
|
101
|
+
};
|
|
102
|
+
bouncer.comprehension = [entry];
|
|
103
|
+
// 7. 임시 파일 → rename. 쓰기 실패는 호출자에게 전파하되 임시 파일은 남기지 않는다.
|
|
104
|
+
const tmpPath = `${explainPath}.tmp-${process.pid}`;
|
|
105
|
+
try {
|
|
106
|
+
fs.writeFileSync(tmpPath, renderDoc(data, parsed.body));
|
|
107
|
+
fs.renameSync(tmpPath, explainPath);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
// 정리 실패(이미 없음 등)는 원래 오류를 가리면 안 되므로 force로 흡수한다.
|
|
111
|
+
nodeFs.rmSync(tmpPath, { force: true });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, entry };
|
|
115
|
+
}
|
|
116
|
+
module.exports = { recordComprehension };
|