@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,824 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const guards = require("./guards");
|
|
4
|
+
const { isRecord } = guards;
|
|
5
|
+
const frontmatter = require("./frontmatter");
|
|
6
|
+
const { readDoc, parseFrontmatter } = frontmatter;
|
|
7
|
+
const planSnapshot = require("./plan-snapshot");
|
|
8
|
+
const { computePlanSnapshot, computePlanParts } = planSnapshot;
|
|
9
|
+
const scaffold = require("./scaffold");
|
|
10
|
+
const { scaffoldContextReview } = scaffold;
|
|
11
|
+
const validateSections = require("./validate-sections");
|
|
12
|
+
const { REVIEW_SEVERITY, FINDING_ACTIONABILITY, REPORT_FINDING_STATUS: FINDING_STATUS, CONTEXT_ROUND_MODES: ROUND_MODES, CONTEXT_REPORT_STATUS: REPORT_STATUS, normalizeFinding, } = validateSections;
|
|
13
|
+
const transitions = require("./context-review-transitions");
|
|
14
|
+
const schema = require("./schema");
|
|
15
|
+
const { PLAN_FULL_PERSPECTIVES, planDeltaPerspectiveOf } = schema;
|
|
16
|
+
const { inputFindingIds, normalizeProjection, projectClosure, contentOf, openMustFixesFixed, compareShape, sameStructureAndScope, applyDispositions, prepareFindings, recordState, closeState, restartState, restartArchive, } = transitions;
|
|
17
|
+
const projection = require("./context-review-projection");
|
|
18
|
+
const { projectMarker, validateMarker, projectClosureDoc } = projection;
|
|
19
|
+
const store = require("./context-review-store");
|
|
20
|
+
const { fail, sha256, targetInvalid, isFail, corrupt, transitionKey, writeAtomic, writeImmutable, guardWrites, loadDispatch, withLock, openContext, loadState, saveState, cyclePrefix, storeDispatch, readRef, readLegacy, readStoredLatest, readLegacyEvidence, cycleStateExists, readInputFile, projectionDocExists, readProjectionText, } = store;
|
|
21
|
+
// 계획 리뷰 cycle의 단일 writer. 전체 재시작을 누적 1회로 제한하고, 재시작이
|
|
22
|
+
// 이전 finding을 지우지 못하게 archive로 보관한다. cycle을 닫는 전이(close, record --status accepted)만
|
|
23
|
+
// context-review.md에 투영을 기록하고 execute review.md는 쓰지 않는다. 기록 성공이 곧 plan gate
|
|
24
|
+
// 전체 통과는 아니다(다른 문서와 신선도는 게이트가 본다).
|
|
25
|
+
const RESTART_LIMIT = 1;
|
|
26
|
+
/**
|
|
27
|
+
* active 보고들을 읽어 round 객체와 ID별 최신 finding을 합친다. 같은 id는 뒤 보고가
|
|
28
|
+
* 통째로 대체한다(처분 갱신). 입력 객체는 변형하지 않아 archive가 원본을 보존한다.
|
|
29
|
+
* 각 finding은 합치기 전에 정규화한다(fingerprint 공식 덮어쓰기, 생략 id는 `CR-<8hex>`).
|
|
30
|
+
* immutable 보고 원문·sha는 그대로 두고 파생 결과에서만 고쳐, 과거에 틀린 fingerprint로
|
|
31
|
+
* 기록된 보고도 close·projection에서 복구된다.
|
|
32
|
+
*
|
|
33
|
+
* @param {Ctx} ctx - namespace 경로
|
|
34
|
+
* @param {string} repoRoot - 저장소 루트
|
|
35
|
+
* @param {ReportRef[]} reports - active 보고 참조
|
|
36
|
+
* @returns {{ rounds: unknown[], findings: Finding[] } | Fail} 합성 결과 또는 손상 거절
|
|
37
|
+
*/
|
|
38
|
+
function foldReports(ctx, repoRoot, reports) {
|
|
39
|
+
const rounds = [];
|
|
40
|
+
const findings = [];
|
|
41
|
+
for (const ref of reports) {
|
|
42
|
+
const text = readRef(ctx, repoRoot, ref);
|
|
43
|
+
if (isFail(text))
|
|
44
|
+
return text;
|
|
45
|
+
const body = JSON.parse(text);
|
|
46
|
+
rounds.push(body.round);
|
|
47
|
+
for (const raw of body.findings) {
|
|
48
|
+
const f = normalizeFinding(raw, 'context');
|
|
49
|
+
const at = findings.findIndex((x) => x.id === f.id);
|
|
50
|
+
if (at >= 0)
|
|
51
|
+
findings[at] = f;
|
|
52
|
+
else
|
|
53
|
+
findings.push(f);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { rounds, findings };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 기존 표식 문서를 준비하고 순수 변환에 위임한다. scaffold와 읽기는 기존 순서대로 실행한다.
|
|
60
|
+
* @param {string} repoRoot - 저장소 루트
|
|
61
|
+
* @param {string} bp - blueprint 경로
|
|
62
|
+
* @param {number} count - 누적 재시작 하한
|
|
63
|
+
* @returns {{ abs: string; text: string } | Fail} 쓰기 후보 또는 거절; 쓰지는 않는다
|
|
64
|
+
*/
|
|
65
|
+
function prepareMarker(repoRoot, bp, count) {
|
|
66
|
+
const abs = path.join(repoRoot, bp, 'context-review.md');
|
|
67
|
+
const unsupported = (cause) => fail('context-review-legacy-evidence', cause, 'keep context-review.md frontmatter in the standard two-space YAML layout, then retry');
|
|
68
|
+
if (!projectionDocExists(abs)) {
|
|
69
|
+
try {
|
|
70
|
+
scaffoldContextReview({ repoRoot, blueprintDir: bp, timestamp: new Date().toISOString() });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// scaffold가 던지는 Error(닫힌 blueprint·id 도출 실패)만 거절로 바꾼다.
|
|
74
|
+
if (!(error instanceof Error))
|
|
75
|
+
throw error;
|
|
76
|
+
return unsupported(`cannot create context-review.md: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const original = readProjectionText(abs);
|
|
80
|
+
// frontmatter 형식 거절은 순수 투영이 먼저 판정하므로 기존 읽기 우선순위를 보존한다.
|
|
81
|
+
const projected = projectMarker(original, count);
|
|
82
|
+
if (isFail(projected))
|
|
83
|
+
return projected;
|
|
84
|
+
let before;
|
|
85
|
+
let after;
|
|
86
|
+
try {
|
|
87
|
+
before = readDoc(abs).data;
|
|
88
|
+
after = parseFrontmatter(projected.text).data;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return unsupported(error instanceof Error ? error.message : String(error));
|
|
92
|
+
}
|
|
93
|
+
const invalid = validateMarker(before, after, count);
|
|
94
|
+
return invalid ?? { abs, ...projected };
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 기존 문서를 지연 읽어 closure 투영에 전달한다. 읽기 실패와 문서 부재 거절 순서를 보존한다.
|
|
98
|
+
* @param {string} repoRoot - 저장소 루트
|
|
99
|
+
* @param {string} bp - blueprint 경로
|
|
100
|
+
* @param {State['projection']} projection - null이 아닌 닫힘 투영
|
|
101
|
+
* @param {string} [closureTargetDigest] - 인증 digest
|
|
102
|
+
* @returns {{ abs: string; text: string; rel: string } | Fail} 쓰기 후보 또는 거절; 쓰지는 않는다
|
|
103
|
+
*/
|
|
104
|
+
function prepareClosureDoc(repoRoot, bp, projection, closureTargetDigest) {
|
|
105
|
+
const rel = `${bp}/context-review.md`;
|
|
106
|
+
const abs = path.join(repoRoot, bp, 'context-review.md');
|
|
107
|
+
if (!projectionDocExists(abs)) {
|
|
108
|
+
return targetInvalid(`${rel} not found`, `bouncer scaffold context-review --blueprint ${bp}`);
|
|
109
|
+
}
|
|
110
|
+
let parsed;
|
|
111
|
+
try {
|
|
112
|
+
parsed = parseFrontmatter(readProjectionText(abs));
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// 기존 파싱 Error만 legacy-evidence로 변환하며 다른 throw는 전파한다.
|
|
116
|
+
if (!(error instanceof Error))
|
|
117
|
+
throw error;
|
|
118
|
+
return fail('context-review-legacy-evidence', error.message, 'fix context-review.md frontmatter, then retry');
|
|
119
|
+
}
|
|
120
|
+
const projected = projectClosureDoc(parsed, projection, closureTargetDigest);
|
|
121
|
+
return isFail(projected) ? projected : { abs, rel, ...projected };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 표식과 state를 대조한다(잠금 안에서 호출). 표식이 state보다 크면 state가 지워졌거나 낮춰진
|
|
125
|
+
* 것이라 거절하고, state가 표식보다 크면 직전 restart가 표식 쓰기 전에 죽은 것이므로 여기서
|
|
126
|
+
* 표식을 채운다. 표식을 낮추는 경로는 없다.
|
|
127
|
+
*
|
|
128
|
+
* @param {Ctx} ctx - namespace 경로
|
|
129
|
+
* @param {string} repoRoot - 저장소 루트
|
|
130
|
+
* @param {State} state - 검증된 state
|
|
131
|
+
* @param {Legacy} legacy - 같은 시점에 읽은 context-review.md 증거
|
|
132
|
+
* @returns {Fail | null} 불일치 거절, 정상이면 null
|
|
133
|
+
*/
|
|
134
|
+
function reconcileMarker(ctx, repoRoot, state, legacy) {
|
|
135
|
+
if (legacy.marker > state.cycle.restart_count) {
|
|
136
|
+
return corrupt(`restart_count marker ${legacy.marker} exceeds state ${state.cycle.restart_count}`);
|
|
137
|
+
}
|
|
138
|
+
if (legacy.marker < state.cycle.restart_count) {
|
|
139
|
+
const prepared = prepareMarker(repoRoot, ctx.bp, state.cycle.restart_count);
|
|
140
|
+
if (isFail(prepared))
|
|
141
|
+
return prepared;
|
|
142
|
+
const written = guardWrites(() => writeAtomic(prepared.abs, prepared.text));
|
|
143
|
+
if (isFail(written))
|
|
144
|
+
return written;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** state.json이 없는데 표식이 남아 있을 때의 거절. 새 cycle로 횟수를 되돌리지 못하게 한다. */
|
|
149
|
+
function markerWithoutState(marker) {
|
|
150
|
+
return fail('context-review-state-conflict', `restart_count marker ${marker} exists in context-review.md but state.json is missing`, 'restore state.json from backup; counters must not be reset by hand');
|
|
151
|
+
}
|
|
152
|
+
function newCycleId(bp, dispatchSha) {
|
|
153
|
+
return `cr-${sha256(`${bp}\u0000${dispatchSha}`).slice(0, 16)}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* 새 cycle을 시작한다. 이미 state가 있으면 id와 횟수를 그대로 돌려주는 no-op이라
|
|
157
|
+
* start를 반복해도 누적 재시작 횟수를 0으로 되돌릴 수 없다. 기존 context-review
|
|
158
|
+
* 증거만 있고 state가 없으면 거절하고 restart로 가져오게 한다.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} input - 입력
|
|
161
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
162
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
163
|
+
* @param {string} input.dispatchFile - review-dispatch plan JSON 파일
|
|
164
|
+
* @returns {object} 성공 `{ok:true,cycle,noop}`, 실패 `{ok:false,reason,cause,next}`
|
|
165
|
+
*/
|
|
166
|
+
function startCycle(input) {
|
|
167
|
+
const { repoRoot } = input;
|
|
168
|
+
const probe = openContext(repoRoot, input.blueprintDir, false);
|
|
169
|
+
if (isFail(probe))
|
|
170
|
+
return probe;
|
|
171
|
+
// 쓰기 전에 payload를 먼저 검증한다: 거절된 start가 디렉터리를 남기지 않는다.
|
|
172
|
+
const dispatch = loadDispatch(repoRoot, probe.bp, input.dispatchFile);
|
|
173
|
+
if (isFail(dispatch))
|
|
174
|
+
return dispatch;
|
|
175
|
+
const ctx = openContext(repoRoot, input.blueprintDir, true);
|
|
176
|
+
return withLock(ctx, () => {
|
|
177
|
+
const state = loadState(ctx);
|
|
178
|
+
if (isFail(state))
|
|
179
|
+
return state;
|
|
180
|
+
const legacy = readLegacy(repoRoot, ctx.bp);
|
|
181
|
+
if (isFail(legacy))
|
|
182
|
+
return legacy;
|
|
183
|
+
if (state) {
|
|
184
|
+
const mismatch = reconcileMarker(ctx, repoRoot, state, legacy);
|
|
185
|
+
if (mismatch)
|
|
186
|
+
return mismatch;
|
|
187
|
+
return { ok: true, noop: true, cycle: state.cycle };
|
|
188
|
+
}
|
|
189
|
+
// 표식이 있는데 state가 없으면 runtime을 지워 새 cycle로 한도를 되돌리려는 경로다.
|
|
190
|
+
if (legacy.marker >= 1)
|
|
191
|
+
return markerWithoutState(legacy.marker);
|
|
192
|
+
if (legacy.present) {
|
|
193
|
+
return fail('context-review-legacy-evidence', 'context-review.md already holds review evidence', 'run context-review restart with the previous payload to archive it; start cannot import');
|
|
194
|
+
}
|
|
195
|
+
const id = newCycleId(ctx.bp, dispatch.sha256);
|
|
196
|
+
const created = {
|
|
197
|
+
version: 1,
|
|
198
|
+
blueprint: ctx.bp,
|
|
199
|
+
cycle: {
|
|
200
|
+
id,
|
|
201
|
+
restart_count: 0,
|
|
202
|
+
history: [],
|
|
203
|
+
active_reports: [],
|
|
204
|
+
latest_dispatch: { path: '', sha256: '' },
|
|
205
|
+
},
|
|
206
|
+
transitions: [],
|
|
207
|
+
closed: false,
|
|
208
|
+
projection: null,
|
|
209
|
+
};
|
|
210
|
+
const stored = guardWrites(() => {
|
|
211
|
+
created.cycle.latest_dispatch = storeDispatch(repoRoot, ctx, id, dispatch);
|
|
212
|
+
saveState(ctx, created);
|
|
213
|
+
});
|
|
214
|
+
if (isFail(stored))
|
|
215
|
+
return stored;
|
|
216
|
+
return { ok: true, noop: false, cycle: created.cycle };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 보고 입력(`{round,findings}`)을 읽고 finding 모양을 검사한다. 미해결 처분(open)은
|
|
221
|
+
* 진행 중 보고에만 허용되며 canonical 처분 enum은 여기서 늘리지 않는다.
|
|
222
|
+
* `id` 키 생략은 자동 id 대상이라 통과하지만, 명시한 id는 비어 있지 않은 문자열이어야 한다
|
|
223
|
+
* (null·빈 문자열·공백·비문자열은 작성 실수로 보고 거절한다).
|
|
224
|
+
*/
|
|
225
|
+
function readReportInput(roundFile) {
|
|
226
|
+
const next = 'fix --round JSON: object with round and findings array';
|
|
227
|
+
let text;
|
|
228
|
+
try {
|
|
229
|
+
text = readInputFile(roundFile).toString('utf8');
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
return fail('context-review-input-invalid', isRecord(error) ? error.code : String(error), next);
|
|
233
|
+
}
|
|
234
|
+
let parsed;
|
|
235
|
+
try {
|
|
236
|
+
parsed = JSON.parse(text);
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (!(error instanceof SyntaxError))
|
|
240
|
+
throw error;
|
|
241
|
+
return fail('context-review-input-invalid', error.message, next);
|
|
242
|
+
}
|
|
243
|
+
if (!isRecord(parsed) || !isRecord(parsed.round) || !Array.isArray(parsed.findings)) {
|
|
244
|
+
return fail('context-review-input-invalid', 'expected { round: object, findings: array }', next);
|
|
245
|
+
}
|
|
246
|
+
for (const f of parsed.findings) {
|
|
247
|
+
const ok = isRecord(f)
|
|
248
|
+
&& (!Object.prototype.hasOwnProperty.call(f, 'id') || (typeof f.id === 'string' && f.id.trim() !== ''))
|
|
249
|
+
&& REVIEW_SEVERITY.includes(f.severity)
|
|
250
|
+
&& FINDING_STATUS.includes(f.status)
|
|
251
|
+
&& FINDING_ACTIONABILITY.includes(f.actionability);
|
|
252
|
+
if (!ok)
|
|
253
|
+
return fail('context-review-input-invalid', `malformed finding ${JSON.stringify(f)}`, next);
|
|
254
|
+
}
|
|
255
|
+
return { text, round: parsed.round, findings: parsed.findings };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* round 보고의 관점 이름이 dispatch 계약과 맞는지 검사한다.
|
|
259
|
+
* perspectives dispatch면 discovery는 dispatch 관점 집합과 같아야 하고 delta는 full `combined` /
|
|
260
|
+
* light `verifiability` 하나여야 한다. single/clustered dispatch는 새 관점 이름을 받지 않는다 —
|
|
261
|
+
* 표식 없는 blueprint가 새 계약의 round를 몰래 쌓지 못하게 하기 위해서다. 상태를 바꾸지 않는다.
|
|
262
|
+
*
|
|
263
|
+
* @param {unknown} dispatchPayload - loadDispatch가 검증한 dispatch JSON
|
|
264
|
+
* @param {'full' | 'light'} scale - loadDispatch가 검증한 scale. delta 관점 선택의 유일한 기준
|
|
265
|
+
* @param {number} priorRounds - 이미 기록된 round 수(0이면 discovery, 1이면 delta)
|
|
266
|
+
* @param {Record<string, unknown>} round - 보고의 round 객체
|
|
267
|
+
* @returns {Fail | null} 어긋나면 context-review-input-invalid, 맞으면 null
|
|
268
|
+
*/
|
|
269
|
+
function checkRoundPerspectives(dispatchPayload, scale, priorRounds, round) {
|
|
270
|
+
const next = 'set round.perspectives names to the dispatch perspectives contract';
|
|
271
|
+
const raw = round.perspectives;
|
|
272
|
+
const entries = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
|
|
273
|
+
const names = entries.map((entry) => (isRecord(entry) && typeof entry.name === 'string' ? entry.name : ''));
|
|
274
|
+
const payload = isRecord(dispatchPayload) ? dispatchPayload : {};
|
|
275
|
+
if (payload.strategy !== 'perspectives') {
|
|
276
|
+
const stray = names.find((name) => PLAN_FULL_PERSPECTIVES.includes(name));
|
|
277
|
+
return stray === undefined ? null
|
|
278
|
+
: fail('context-review-input-invalid', `perspective ${stray} requires a perspectives dispatch`, next);
|
|
279
|
+
}
|
|
280
|
+
const expected = payload.perspectives;
|
|
281
|
+
if (priorRounds === 0) {
|
|
282
|
+
const same = names.length === expected.length && expected.every((name) => names.includes(name));
|
|
283
|
+
return same ? null : fail('context-review-input-invalid', `round 1 perspectives ${names.join(',')} != dispatch perspectives ${expected.join(',')}`, next);
|
|
284
|
+
}
|
|
285
|
+
// delta는 reviewer 하나: full은 combined, light는 verifiability. 관점 개수가 아니라 검증된 scale로 고른다.
|
|
286
|
+
const want = planDeltaPerspectiveOf(scale);
|
|
287
|
+
return names.length === 1 && names[0] === want ? null
|
|
288
|
+
: fail('context-review-input-invalid', `round ${priorRounds + 1} delta perspective must be ${want}`, next);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* 리뷰어 보고 한 건과 dispatch를 cycle에 기록한다. 보고는 immutable 파일로 먼저 저장하고
|
|
292
|
+
* state 교체(sibling tmp + rename)가 마지막이라, 어느 단계에서 실패해도 기존 state는
|
|
293
|
+
* 유지된다. 같은 전이 키의 재요청은 no-op, 같은 round의 다른 입력은 충돌이다.
|
|
294
|
+
*
|
|
295
|
+
* @param {object} input - 입력
|
|
296
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
297
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
298
|
+
* @param {string} input.dispatchFile - review-dispatch plan JSON 파일
|
|
299
|
+
* @param {string} input.roundFile - `{round,findings}` JSON 파일
|
|
300
|
+
* @param {string} [input.status] - requested|addressed|accepted(기본 requested). accepted는 closure
|
|
301
|
+
* @param {string[]} [input.acceptRisk] - 사용자 동의가 있는 must_fix accepted ID
|
|
302
|
+
* @returns {object} 성공 `{ok:true,noop,cycle,status,closed,projection?}`, 실패 `{ok:false,...}`
|
|
303
|
+
*/
|
|
304
|
+
function recordReport(input) {
|
|
305
|
+
const { repoRoot } = input;
|
|
306
|
+
const status = input.status || 'requested';
|
|
307
|
+
if (!REPORT_STATUS.includes(status)) {
|
|
308
|
+
return fail('context-review-input-invalid', `status ${status}`, 'use requested, addressed or accepted');
|
|
309
|
+
}
|
|
310
|
+
const acceptRisk = [...(input.acceptRisk || [])].sort();
|
|
311
|
+
const ctx = openContext(repoRoot, input.blueprintDir, false);
|
|
312
|
+
if (isFail(ctx))
|
|
313
|
+
return ctx;
|
|
314
|
+
const report = readReportInput(input.roundFile);
|
|
315
|
+
if (isFail(report))
|
|
316
|
+
return report;
|
|
317
|
+
let dispatchBytes;
|
|
318
|
+
try {
|
|
319
|
+
dispatchBytes = readInputFile(input.dispatchFile);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
return fail('context-review-input-invalid', isRecord(error) ? error.code : String(error), 'pass --dispatch <review-dispatch plan JSON file>');
|
|
323
|
+
}
|
|
324
|
+
if (!cycleStateExists(ctx)) {
|
|
325
|
+
const evidence = readLegacy(repoRoot, ctx.bp);
|
|
326
|
+
if (isFail(evidence))
|
|
327
|
+
return evidence;
|
|
328
|
+
if (evidence.marker >= 1)
|
|
329
|
+
return markerWithoutState(evidence.marker);
|
|
330
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
331
|
+
}
|
|
332
|
+
return withLock(ctx, () => {
|
|
333
|
+
const state = loadState(ctx);
|
|
334
|
+
if (isFail(state))
|
|
335
|
+
return state;
|
|
336
|
+
if (!state)
|
|
337
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
338
|
+
const evidence = readLegacy(repoRoot, ctx.bp);
|
|
339
|
+
if (isFail(evidence))
|
|
340
|
+
return evidence;
|
|
341
|
+
const mismatch = reconcileMarker(ctx, repoRoot, state, evidence);
|
|
342
|
+
if (mismatch)
|
|
343
|
+
return mismatch;
|
|
344
|
+
const cycle = state.cycle;
|
|
345
|
+
const round = report.round.round;
|
|
346
|
+
// 1. 전이 키: cycle·restart·round·dispatch·입력 digest(+처분 인자)를 묶는다.
|
|
347
|
+
// 같은 키는 이미 적용된 전이라 no-op이며 dispatch가 그사이 stale이 되어도 거절하지 않는다.
|
|
348
|
+
const key = transitionKey([cycle.id, cycle.restart_count, String(round), sha256(dispatchBytes),
|
|
349
|
+
sha256(report.text), status, acceptRisk.join(',')]);
|
|
350
|
+
if (state.transitions.includes(key)) {
|
|
351
|
+
// 닫는 전이의 재실행은 문서를 투영 상태로 되돌려 쓴다: 첫 성공 뒤 문서가 바뀌었거나
|
|
352
|
+
// state 저장 뒤 문서 쓰기가 실패했어도 같은 입력의 재실행이 복구한다.
|
|
353
|
+
let written;
|
|
354
|
+
const projection = state.projection ? normalizeProjection(state.projection) : null;
|
|
355
|
+
if (status === 'accepted' && projection) {
|
|
356
|
+
const doc = prepareClosureDoc(repoRoot, ctx.bp, projection, state.closure?.closure_target_digest);
|
|
357
|
+
if (isFail(doc))
|
|
358
|
+
return doc;
|
|
359
|
+
const wrote = guardWrites(() => writeAtomic(doc.abs, doc.text));
|
|
360
|
+
if (isFail(wrote))
|
|
361
|
+
return wrote;
|
|
362
|
+
written = doc.rel;
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
ok: true, noop: true, cycle, status, closed: state.closed,
|
|
366
|
+
finding_ids: inputFindingIds(report.findings),
|
|
367
|
+
...(projection ? { projection } : {}),
|
|
368
|
+
...(written ? { written } : {}),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const dispatch = loadDispatch(repoRoot, ctx.bp, input.dispatchFile);
|
|
372
|
+
if (isFail(dispatch))
|
|
373
|
+
return dispatch;
|
|
374
|
+
if (state.closed) {
|
|
375
|
+
return fail('context-review-state-conflict', 'cycle is already closed', 'restart only when the plan changed');
|
|
376
|
+
}
|
|
377
|
+
// 2. round 순서: discovery 또는 discovery→delta. 이미 있는 번호를 다른 입력이 노리면 충돌.
|
|
378
|
+
const n = cycle.active_reports.length;
|
|
379
|
+
if (Number.isInteger(round) && round >= 1 && round <= n) {
|
|
380
|
+
return fail('context-review-state-conflict', `round ${String(round)} already recorded with different input`, 'a transition is applied once; record the next round');
|
|
381
|
+
}
|
|
382
|
+
if (n >= ROUND_MODES.length || round !== n + 1 || report.round.mode !== ROUND_MODES[n]) {
|
|
383
|
+
const want = ROUND_MODES[n] ?? 'none';
|
|
384
|
+
return fail('context-review-round-order', `expected round ${n + 1} mode ${want}, got round ${String(round)} mode ${String(report.round.mode)}`, n >= ROUND_MODES.length
|
|
385
|
+
? 'close with the recorded rounds or restart'
|
|
386
|
+
: `record round ${n + 1} (${want})`);
|
|
387
|
+
}
|
|
388
|
+
const reportTarget = report.round.target;
|
|
389
|
+
if (isRecord(reportTarget) && reportTarget.digest !== undefined && reportTarget.digest !== dispatch.digest) {
|
|
390
|
+
return targetInvalid(`round target.digest ${String(reportTarget.digest)} != dispatch ${dispatch.digest}`, 'set round.target.digest to the dispatch target.digest');
|
|
391
|
+
}
|
|
392
|
+
// 2-1. 관점 계약: 상태를 바꾸기 전에 거절해 round·cycle이 그대로 남게 한다.
|
|
393
|
+
const wrongPerspectives = checkRoundPerspectives(JSON.parse(dispatch.text), dispatch.scale, n, report.round);
|
|
394
|
+
if (wrongPerspectives)
|
|
395
|
+
return wrongPerspectives;
|
|
396
|
+
// 3. 이전 보고와 합쳐 ID별 최신 처분을 만든다. closure면 projection 검사까지.
|
|
397
|
+
const folded = foldReports(ctx, repoRoot, cycle.active_reports);
|
|
398
|
+
if (isFail(folded))
|
|
399
|
+
return folded;
|
|
400
|
+
// 3-1. 입력도 같은 공식으로 정규화한다(fingerprint 덮어쓰기·생략 id 생성). 명시 id 오류와
|
|
401
|
+
// 자동 id 충돌은 merge 전에 거절해 다른 finding을 덮어쓰지 않게 한다. 보고 원문(report.text)과
|
|
402
|
+
// 그 sha·전이 키는 입력 바이트 그대로 두고, 정규화는 fold가 읽을 때마다 다시 적용된다.
|
|
403
|
+
const prepared = prepareFindings(report.findings, folded.findings);
|
|
404
|
+
if (isFail(prepared))
|
|
405
|
+
return prepared;
|
|
406
|
+
const { normalized, incoming, merged } = prepared;
|
|
407
|
+
let projection = null;
|
|
408
|
+
let closureDoc = null;
|
|
409
|
+
if (status === 'accepted') {
|
|
410
|
+
const projected = projectClosure(merged, acceptRisk);
|
|
411
|
+
if (isFail(projected))
|
|
412
|
+
return projected;
|
|
413
|
+
projection = { findings: projected, rounds: [...folded.rounds, report.round] };
|
|
414
|
+
// state를 바꾸기 전에 문서를 렌더·검증한다: 부재·검증 실패 거절이 아무것도 남기지 않는다.
|
|
415
|
+
// accepted record는 round target과 같다고 검증된 dispatch.digest를 인증 대상으로 쓴다.
|
|
416
|
+
closureDoc = prepareClosureDoc(repoRoot, ctx.bp, projection, dispatch.digest);
|
|
417
|
+
if (isFail(closureDoc))
|
|
418
|
+
return closureDoc;
|
|
419
|
+
}
|
|
420
|
+
// 4. 쓰기: immutable 자료 → state 교체 순.
|
|
421
|
+
const reportSha = sha256(report.text);
|
|
422
|
+
const reportRel = `${cyclePrefix(ctx, cycle.id)}/r${cycle.restart_count}`
|
|
423
|
+
+ `/reports/${String(round)}-${reportSha.slice(0, 16)}.json`;
|
|
424
|
+
let dispatchRef = { path: '', sha256: '' };
|
|
425
|
+
const stored = guardWrites(() => {
|
|
426
|
+
dispatchRef = storeDispatch(repoRoot, ctx, cycle.id, dispatch);
|
|
427
|
+
writeImmutable(ctx.nsAbs, path.join(repoRoot, reportRel), report.text);
|
|
428
|
+
});
|
|
429
|
+
if (isFail(stored))
|
|
430
|
+
return stored;
|
|
431
|
+
const next = recordState(state, report, status, acceptRisk, dispatch, dispatchRef, reportRel, reportSha, key, projection);
|
|
432
|
+
const saved = guardWrites(() => saveState(ctx, next));
|
|
433
|
+
if (isFail(saved))
|
|
434
|
+
return saved;
|
|
435
|
+
// 문서는 state 저장 뒤에 쓴다. 쓰기가 실패해도 같은 입력의 재실행(noop 경로)이 복구한다.
|
|
436
|
+
if (closureDoc && !isFail(closureDoc)) {
|
|
437
|
+
const doc = closureDoc;
|
|
438
|
+
const wrote = guardWrites(() => writeAtomic(doc.abs, doc.text));
|
|
439
|
+
if (isFail(wrote))
|
|
440
|
+
return wrote;
|
|
441
|
+
}
|
|
442
|
+
// 병합 후보 경고는 noop이 아닌 이번 기록의 round 입력만 대상으로 한다(추가 I/O 없음).
|
|
443
|
+
const warnings = validateSections.findingMergeCandidates(incoming);
|
|
444
|
+
return {
|
|
445
|
+
ok: true, noop: false, cycle: next.cycle, status, closed: next.closed, finding_ids: normalized.ids,
|
|
446
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
447
|
+
...(projection ? { projection } : {}),
|
|
448
|
+
...(closureDoc && !isFail(closureDoc) ? { written: closureDoc.rel } : {}),
|
|
449
|
+
};
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 마지막 round 뒤 남은 finding 처분을 사용자 동의(--accept-risk·note)와 함께 기록해 cycle을 닫는다.
|
|
454
|
+
* 닫힌 cycle은 restart할 수 있고, accepted-risk must_fix는 미해결로 세지 않는다.
|
|
455
|
+
* dispositions를 immutable 파일로 먼저 저장하고 state 교체가 마지막이라, 어느 단계에서
|
|
456
|
+
* 실패해도 기존 state는 유지된다. 같은 전이 키의 재요청은 no-op이다.
|
|
457
|
+
*
|
|
458
|
+
* @param {object} input - 입력 객체
|
|
459
|
+
* @param {string} input.repoRoot - 저장소 루트 절대 경로
|
|
460
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
461
|
+
* @param {string} input.dispositionsFile - [{ id, status, note }] JSON 파일 경로
|
|
462
|
+
* @param {string[]} [input.acceptRisk] - 사용자 동의가 있는 must_fix accepted ID 목록
|
|
463
|
+
* @returns {Record<string, unknown>} 성공 {ok:true,noop,cycle,status,closed,projection}, 실패 {ok:false,...}
|
|
464
|
+
*/
|
|
465
|
+
function closeCycle(input) {
|
|
466
|
+
const { repoRoot } = input;
|
|
467
|
+
// 중복을 제거하고 정렬해 전이 키와 closure 원장의 결정론적 형식을 보장한다.
|
|
468
|
+
const acceptRisk = Array.from(new Set(input.acceptRisk || [])).sort();
|
|
469
|
+
const ctx = openContext(repoRoot, input.blueprintDir, false);
|
|
470
|
+
if (isFail(ctx))
|
|
471
|
+
return ctx;
|
|
472
|
+
let dispBytes;
|
|
473
|
+
let rawText;
|
|
474
|
+
try {
|
|
475
|
+
dispBytes = readInputFile(input.dispositionsFile);
|
|
476
|
+
rawText = dispBytes.toString('utf8');
|
|
477
|
+
}
|
|
478
|
+
catch (error) {
|
|
479
|
+
return fail('context-review-input-invalid', isRecord(error) ? error.code : String(error), 'pass --dispositions <json-file>');
|
|
480
|
+
}
|
|
481
|
+
let parsed;
|
|
482
|
+
try {
|
|
483
|
+
parsed = JSON.parse(rawText);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
if (!(error instanceof SyntaxError))
|
|
487
|
+
throw error;
|
|
488
|
+
return fail('context-review-input-invalid', error.message, 'pass valid dispositions JSON array');
|
|
489
|
+
}
|
|
490
|
+
if (!Array.isArray(parsed)) {
|
|
491
|
+
return fail('context-review-input-invalid', 'dispositions must be a JSON array', 'pass an array of { id, status, note }');
|
|
492
|
+
}
|
|
493
|
+
if (!cycleStateExists(ctx)) {
|
|
494
|
+
const evidence = readLegacy(repoRoot, ctx.bp);
|
|
495
|
+
if (isFail(evidence))
|
|
496
|
+
return evidence;
|
|
497
|
+
if (evidence.marker >= 1)
|
|
498
|
+
return markerWithoutState(evidence.marker);
|
|
499
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
500
|
+
}
|
|
501
|
+
return withLock(ctx, () => {
|
|
502
|
+
const state = loadState(ctx);
|
|
503
|
+
if (isFail(state))
|
|
504
|
+
return state;
|
|
505
|
+
if (!state)
|
|
506
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
507
|
+
const evidence = readLegacy(repoRoot, ctx.bp);
|
|
508
|
+
if (isFail(evidence))
|
|
509
|
+
return evidence;
|
|
510
|
+
const mismatch = reconcileMarker(ctx, repoRoot, state, evidence);
|
|
511
|
+
if (mismatch)
|
|
512
|
+
return mismatch;
|
|
513
|
+
const cycle = state.cycle;
|
|
514
|
+
// 0. 인증 대상: 지금 디스크의 계획 snapshot. close는 dispatch를 받지 않으므로 현재 내용이 곧 대상이다.
|
|
515
|
+
const snap = computePlanSnapshot({ repoRoot, blueprintDir: ctx.bp });
|
|
516
|
+
if (!snap.ok)
|
|
517
|
+
return targetInvalid(snap.error, 'fix the plan documents, then retry close');
|
|
518
|
+
// 1. 전이 키: cycle id, restart count, 'close', dispositions 원본 바이트 sha256, 정렬된 accept-risk,
|
|
519
|
+
// 인증 대상 digest. digest를 넣어야 닫힌 뒤 본문을 고친 재close가 같은 처분 파일로도
|
|
520
|
+
// no-op으로 삼켜지지 않는다.
|
|
521
|
+
const key = transitionKey([cycle.id, cycle.restart_count, 'close', sha256(dispBytes), acceptRisk.join(','),
|
|
522
|
+
snap.digest]);
|
|
523
|
+
if (state.transitions.includes(key)) {
|
|
524
|
+
// 이미 반영된 close 전이이므로 closure 결과(status: 'accepted')를 돌려주되, 문서는
|
|
525
|
+
// 투영 상태로 다시 쓴다(첫 성공 뒤 변경·쓰기 실패 복구).
|
|
526
|
+
// 저장 projection은 정규화 전 기록일 수 있어 파생 사본을 정규화해 쓴다.
|
|
527
|
+
let written;
|
|
528
|
+
const projection = state.projection ? normalizeProjection(state.projection) : null;
|
|
529
|
+
if (projection) {
|
|
530
|
+
const doc = prepareClosureDoc(repoRoot, ctx.bp, projection, snap.digest);
|
|
531
|
+
if (isFail(doc))
|
|
532
|
+
return doc;
|
|
533
|
+
const wrote = guardWrites(() => writeAtomic(doc.abs, doc.text));
|
|
534
|
+
if (isFail(wrote))
|
|
535
|
+
return wrote;
|
|
536
|
+
written = doc.rel;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
ok: true, noop: true, cycle, status: 'accepted', closed: state.closed,
|
|
540
|
+
...(projection ? { projection } : {}),
|
|
541
|
+
...(written ? { written } : {}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
// 2. 상태 충돌 검사: 아직 기록된 round가 없으면 거절. 이미 닫힌 cycle은 인증한 digest와
|
|
545
|
+
// 현재 snapshot이 같을 때만 거절한다 — 다르면 본문 수정 뒤 재close 후보다(3-1에서 계약 대조).
|
|
546
|
+
const latestText = readRef(ctx, repoRoot, cycle.latest_dispatch);
|
|
547
|
+
if (isFail(latestText))
|
|
548
|
+
return latestText;
|
|
549
|
+
const baseline = JSON.parse(latestText);
|
|
550
|
+
const baselineTarget = isRecord(baseline.target) ? baseline.target : {};
|
|
551
|
+
const certified = state.closure?.closure_target_digest ?? baselineTarget.digest;
|
|
552
|
+
if (state.closed && certified === snap.digest) {
|
|
553
|
+
return fail('context-review-state-conflict', 'cycle is already closed', 'restart only when the plan changed');
|
|
554
|
+
}
|
|
555
|
+
if (cycle.active_reports.length === 0) {
|
|
556
|
+
return fail('context-review-state-conflict', 'no round has been recorded in this cycle', 'record at least one round before close');
|
|
557
|
+
}
|
|
558
|
+
// 2-1. 구조·계약 대조: 리뷰한 baseline과 digest가 다르면 문서 집합·structure_parts·scope_parts가
|
|
559
|
+
// 모두 같은 본문 수정만 인증한다. 다른 변경은 route의 delta/restart 몫이다.
|
|
560
|
+
if (baselineTarget.digest !== snap.digest) {
|
|
561
|
+
const parts = computePlanParts({ repoRoot, blueprintDir: ctx.bp });
|
|
562
|
+
if (!parts.ok)
|
|
563
|
+
return targetInvalid(parts.error, 'fix the plan documents, then retry close');
|
|
564
|
+
if (!sameStructureAndScope(baseline, { documents: snap.documents, ...parts })) {
|
|
565
|
+
return fail('context-review-state-conflict', 'plan structure or contract differs from the reviewed baseline; close cannot certify it', 'run context-review route with a fresh review-dispatch plan: delta for an Interface/Touch change, '
|
|
566
|
+
+ 'restart for a structure change');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// 3. active 보고들을 fold하여 기존 finding 목록을 취합한다
|
|
570
|
+
const folded = foldReports(ctx, repoRoot, cycle.active_reports);
|
|
571
|
+
if (isFail(folded))
|
|
572
|
+
return folded;
|
|
573
|
+
// 4. dispositions 항목 유효성 검사 및 합성. folded는 이미 정규화돼 있어 처분은 정규화 후 id에
|
|
574
|
+
// 적용된다(생략 id로 기록한 finding도 `CR-<8hex>`로 지정한다).
|
|
575
|
+
const merged = applyDispositions(folded.findings, parsed);
|
|
576
|
+
if (isFail(merged))
|
|
577
|
+
return merged;
|
|
578
|
+
// 5. projectClosure 검증: open finding 잔존 여부, note 없는 accepted, --accept-risk 매칭을 확인
|
|
579
|
+
const projected = projectClosure(merged, acceptRisk);
|
|
580
|
+
if (isFail(projected))
|
|
581
|
+
return projected;
|
|
582
|
+
const projection = { findings: projected, rounds: folded.rounds };
|
|
583
|
+
// 5-1. state를 바꾸기 전에 문서를 렌더·검증한다(부재·검증 실패는 무변경 거절).
|
|
584
|
+
const closureDoc = prepareClosureDoc(repoRoot, ctx.bp, projection, snap.digest);
|
|
585
|
+
if (isFail(closureDoc))
|
|
586
|
+
return closureDoc;
|
|
587
|
+
// 6. 쓰기: immutable dispositions 저장 후 state.json 원자적 교체
|
|
588
|
+
const dispSha = sha256(rawText);
|
|
589
|
+
const dispRel = `${cyclePrefix(ctx, cycle.id)}/r${cycle.restart_count}/dispositions/${dispSha.slice(0, 16)}.json`;
|
|
590
|
+
const dispRef = { path: dispRel, sha256: dispSha };
|
|
591
|
+
const stored = guardWrites(() => {
|
|
592
|
+
writeImmutable(ctx.nsAbs, path.join(repoRoot, dispRel), rawText);
|
|
593
|
+
});
|
|
594
|
+
if (isFail(stored))
|
|
595
|
+
return stored;
|
|
596
|
+
const next = closeState(state, key, dispRef, acceptRisk, snap.digest, projection);
|
|
597
|
+
const saved = guardWrites(() => saveState(ctx, next));
|
|
598
|
+
if (isFail(saved))
|
|
599
|
+
return saved;
|
|
600
|
+
// 문서는 state 저장 뒤에 쓴다. 쓰기가 실패해도 같은 입력의 재실행(noop 경로)이 복구한다.
|
|
601
|
+
const wrote = guardWrites(() => writeAtomic(closureDoc.abs, closureDoc.text));
|
|
602
|
+
if (isFail(wrote))
|
|
603
|
+
return wrote;
|
|
604
|
+
// 정상적으로 cycle이 closed/accepted 되었음을 알리는 반환 객체
|
|
605
|
+
return {
|
|
606
|
+
ok: true, noop: false, cycle: next.cycle, status: 'accepted', closed: next.closed,
|
|
607
|
+
projection: next.projection, written: closureDoc.rel,
|
|
608
|
+
};
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* cycle을 전체 재시작한다. 누적 1회만 허용하며 active 증거(rounds·findings·보고·dispatch)는
|
|
613
|
+
* history로 보관한다. 직전 certified target과 현재 snapshot이 같으면 거절하고, delta 뒤
|
|
614
|
+
* 미해결 must_fix가 있으면 restart로 해결을 우회하지 못하게 거절한다. state가 없고
|
|
615
|
+
* context-review.md에 기존 증거가 있으면 그 증거를 archive로 가져오며 재시작 1회를 소비한다.
|
|
616
|
+
*
|
|
617
|
+
* @param {object} input - 입력
|
|
618
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
619
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
620
|
+
* @param {string} input.dispatchFile - 새 snapshot의 review-dispatch plan JSON 파일
|
|
621
|
+
* @param {string} input.reason - 재시작 사유(archive에 보관)
|
|
622
|
+
* @returns {object} 성공 `{ok:true,noop,cycle}`, 실패 `{ok:false,...}`
|
|
623
|
+
*/
|
|
624
|
+
function restartCycle(input) {
|
|
625
|
+
const { repoRoot, reason } = input;
|
|
626
|
+
const probe = openContext(repoRoot, input.blueprintDir, false);
|
|
627
|
+
if (isFail(probe))
|
|
628
|
+
return probe;
|
|
629
|
+
let dispatchBytes;
|
|
630
|
+
try {
|
|
631
|
+
dispatchBytes = readInputFile(input.dispatchFile);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
return fail('context-review-input-invalid', isRecord(error) ? error.code : String(error), 'pass --dispatch <review-dispatch plan JSON file>');
|
|
635
|
+
}
|
|
636
|
+
const legacy = readLegacy(repoRoot, probe.bp);
|
|
637
|
+
if (isFail(legacy))
|
|
638
|
+
return legacy;
|
|
639
|
+
const hasState = cycleStateExists(probe);
|
|
640
|
+
// 표식이 있는데 state가 없으면 한도 소진 기록이 남은 것이다: 증거 import로 우회하지 못하게 거절한다.
|
|
641
|
+
if (!hasState && legacy.marker >= RESTART_LIMIT) {
|
|
642
|
+
return fail('context-review-restart-limit', `restart_count marker ${legacy.marker} of ${RESTART_LIMIT} used and state.json is missing`, 'continue the current cycle: restore state.json from backup; no further automatic restart');
|
|
643
|
+
}
|
|
644
|
+
if (!hasState && !legacy.present) {
|
|
645
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
646
|
+
}
|
|
647
|
+
const ctx = openContext(repoRoot, input.blueprintDir, true);
|
|
648
|
+
return withLock(ctx, () => {
|
|
649
|
+
const state = loadState(ctx);
|
|
650
|
+
if (isFail(state))
|
|
651
|
+
return state;
|
|
652
|
+
if (state) {
|
|
653
|
+
const evidence = readLegacy(repoRoot, ctx.bp);
|
|
654
|
+
if (isFail(evidence))
|
|
655
|
+
return evidence;
|
|
656
|
+
const mismatch = reconcileMarker(ctx, repoRoot, state, evidence);
|
|
657
|
+
if (mismatch)
|
|
658
|
+
return mismatch;
|
|
659
|
+
}
|
|
660
|
+
const dispatchSha = sha256(dispatchBytes);
|
|
661
|
+
const priorId = state ? state.cycle.id : newCycleId(ctx.bp, dispatchSha);
|
|
662
|
+
const key = transitionKey([priorId, 'restart', dispatchSha, reason]);
|
|
663
|
+
if (state && state.transitions.includes(key))
|
|
664
|
+
return { ok: true, noop: true, cycle: state.cycle };
|
|
665
|
+
if (state && state.cycle.restart_count >= RESTART_LIMIT) {
|
|
666
|
+
return fail('context-review-restart-limit', `restart_count ${state.cycle.restart_count} of ${RESTART_LIMIT} used`, 'continue the current cycle: record findings, resolve them, or ask the user; no further automatic restart');
|
|
667
|
+
}
|
|
668
|
+
const dispatch = loadDispatch(repoRoot, ctx.bp, input.dispatchFile);
|
|
669
|
+
if (isFail(dispatch))
|
|
670
|
+
return dispatch;
|
|
671
|
+
let archive;
|
|
672
|
+
let base;
|
|
673
|
+
if (state) {
|
|
674
|
+
const folded = foldReports(ctx, repoRoot, state.cycle.active_reports);
|
|
675
|
+
if (isFail(folded))
|
|
676
|
+
return folded;
|
|
677
|
+
const latest = readRef(ctx, repoRoot, state.cycle.latest_dispatch);
|
|
678
|
+
if (isFail(latest))
|
|
679
|
+
return latest;
|
|
680
|
+
const prepared = restartArchive(state, folded, JSON.parse(latest), JSON.parse(dispatch.text), dispatch.digest, reason);
|
|
681
|
+
if (isFail(prepared))
|
|
682
|
+
return prepared;
|
|
683
|
+
archive = prepared;
|
|
684
|
+
base = state;
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
// 기존 증거 import: 직전 certified digest가 없으면 stale 판정 근거가 없어 거절한다.
|
|
688
|
+
if (legacy.digest === null) {
|
|
689
|
+
return fail('context-review-legacy-evidence', 'context-review.md has no previous target.digest', 'provide the previous review-dispatch payload digest (missing previous target); '
|
|
690
|
+
+ 'restart cannot prove the plan changed');
|
|
691
|
+
}
|
|
692
|
+
if (legacy.digest === dispatch.digest) {
|
|
693
|
+
return fail('context-review-state-conflict', 'plan snapshot is unchanged since the last certified target', 'restart only after the plan documents changed');
|
|
694
|
+
}
|
|
695
|
+
archive = {
|
|
696
|
+
restart_index: 1,
|
|
697
|
+
reason,
|
|
698
|
+
legacy: true,
|
|
699
|
+
rounds: legacy.rounds,
|
|
700
|
+
findings: legacy.findings,
|
|
701
|
+
reports: [],
|
|
702
|
+
dispatches: [],
|
|
703
|
+
};
|
|
704
|
+
base = {
|
|
705
|
+
version: 1, blueprint: ctx.bp, transitions: [], closed: false, projection: null,
|
|
706
|
+
// 아직 저장되지 않는 임시 틀: 아래 next가 restart_count·latest_dispatch를 모두 덮어쓴다.
|
|
707
|
+
cycle: {
|
|
708
|
+
id: priorId, restart_count: 0, history: [], active_reports: [], latest_dispatch: { path: '', sha256: '' },
|
|
709
|
+
},
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
// 표식 본문을 쓰기 전에 먼저 만들어 패치 불가를 state 변경 전에 거절한다.
|
|
713
|
+
const marked = prepareMarker(repoRoot, ctx.bp, 1);
|
|
714
|
+
if (isFail(marked))
|
|
715
|
+
return marked;
|
|
716
|
+
let dispatchRef = { path: '', sha256: '' };
|
|
717
|
+
const stored = guardWrites(() => {
|
|
718
|
+
dispatchRef = storeDispatch(repoRoot, ctx, base.cycle.id, dispatch);
|
|
719
|
+
});
|
|
720
|
+
if (isFail(stored))
|
|
721
|
+
return stored;
|
|
722
|
+
const next = restartState(base, archive, dispatchRef, key);
|
|
723
|
+
// state가 먼저, 표식이 나중이다. 사이에 죽으면 다음 전이의 reconcileMarker가 표식을 채운다.
|
|
724
|
+
const saved = guardWrites(() => {
|
|
725
|
+
saveState(ctx, next);
|
|
726
|
+
writeAtomic(marked.abs, marked.text);
|
|
727
|
+
});
|
|
728
|
+
if (isFail(saved))
|
|
729
|
+
return saved;
|
|
730
|
+
return { ok: true, noop: false, cycle: next.cycle };
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
function route(next_action, reason, cause, next, cycle) {
|
|
734
|
+
return { ok: true, next_action, reason, cause, next, cycle };
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* 새 review-dispatch payload가 기존 cycle에서 어떤 후속 조치를 요구하는지 정한다.
|
|
738
|
+
* 읽기 전용이라 state.json·보고·dispatch 저장본을 쓰지 않는다 — 호출이 round 수나 restart
|
|
739
|
+
* 횟수를 바꾸지 못하므로 "delta 뒤 새 stale"가 세 번째 round나 두 번째 restart로 새지 않는다.
|
|
740
|
+
* 판정 순서: 같은 target(reuse) → round 없음·미해결 must_fix(user) → 구조·계약 동일(close)
|
|
741
|
+
* → 구조 동일 + 계약 변경, discovery 뒤 partial(delta)
|
|
742
|
+
* → 그 밖의 stale(구조 변경): restart_count<1이면 restart, 한도 소진이면 user.
|
|
743
|
+
*
|
|
744
|
+
* @param {object} input - 입력
|
|
745
|
+
* @param {string} input.repoRoot - 저장소 루트
|
|
746
|
+
* @param {string} input.blueprintDir - blueprint 상대 경로
|
|
747
|
+
* @param {string} input.dispatchFile - 새 snapshot의 review-dispatch plan JSON 파일
|
|
748
|
+
* @param {string} [input.followUp] - 선택. 주어지면 payload의 follow_up과 같아야 한다 — 호출자가
|
|
749
|
+
* payload와 다른 판정을 주입해 routing을 바꾸지 못한다. 생략하면 payload에서 파생한다.
|
|
750
|
+
* @returns {object} 성공 `{ok:true,next_action,reason,cause,next,cycle}`
|
|
751
|
+
* (next_action은 reuse|close|delta|restart|user), 실패 `{ok:false,reason,cause,next}`
|
|
752
|
+
*/
|
|
753
|
+
function routeFollowUp(input) {
|
|
754
|
+
const { repoRoot } = input;
|
|
755
|
+
const ctx = openContext(repoRoot, input.blueprintDir, false);
|
|
756
|
+
if (isFail(ctx))
|
|
757
|
+
return ctx;
|
|
758
|
+
const state = loadState(ctx);
|
|
759
|
+
if (isFail(state))
|
|
760
|
+
return state;
|
|
761
|
+
if (!state)
|
|
762
|
+
return fail('context-review-state-conflict', 'no cycle started', 'run context-review start first');
|
|
763
|
+
const dispatch = loadDispatch(repoRoot, ctx.bp, input.dispatchFile);
|
|
764
|
+
if (isFail(dispatch))
|
|
765
|
+
return dispatch;
|
|
766
|
+
// follow_up은 검증을 통과한 payload 자체에서 읽는다(loadDispatch가 text를 JSON으로 확인했다).
|
|
767
|
+
const followUp = JSON.parse(dispatch.text).follow_up;
|
|
768
|
+
if (followUp !== 'partial' && followUp !== 'full') {
|
|
769
|
+
return fail('context-review-input-invalid', `payload follow_up ${String(followUp)}`, 'pass the review-dispatch plan JSON produced with --previous');
|
|
770
|
+
}
|
|
771
|
+
if (input.followUp !== undefined && input.followUp !== followUp) {
|
|
772
|
+
return fail('context-review-input-invalid', `follow_up ${String(input.followUp)} differs from payload follow_up ${followUp}`, 'omit followUp or pass the payload value');
|
|
773
|
+
}
|
|
774
|
+
const { cycle } = state;
|
|
775
|
+
const rounds = cycle.active_reports.length;
|
|
776
|
+
// 1. 아직 기록된 round가 없으면 reviewer 보고를 기다리는 중이다: 자동 전이를 만들지 않는다.
|
|
777
|
+
if (rounds === 0) {
|
|
778
|
+
return route('user', 'context-review-round-pending', 'no round has been recorded in this cycle', 'record the pending discovery round before routing a new target', cycle);
|
|
779
|
+
}
|
|
780
|
+
// 2. 마지막 reviewed target과 같으면 재사용한다: round도 restart도 늘리지 않는다.
|
|
781
|
+
const latest = readRef(ctx, repoRoot, cycle.latest_dispatch);
|
|
782
|
+
if (isFail(latest))
|
|
783
|
+
return latest;
|
|
784
|
+
const baseline = JSON.parse(latest);
|
|
785
|
+
const newPayload = JSON.parse(dispatch.text);
|
|
786
|
+
// close로 재인증한 digest도 이미 검토·인증된 target이다: latest_dispatch는 그대로라 따로 본다.
|
|
787
|
+
if (baseline.target.digest === dispatch.digest
|
|
788
|
+
|| state.closure?.closure_target_digest === dispatch.digest) {
|
|
789
|
+
return route('reuse', 'context-review-target-unchanged', 'plan snapshot equals the last reviewed target', 'reuse the recorded review result; do not add a round', cycle);
|
|
790
|
+
}
|
|
791
|
+
// 3. delta 뒤 미해결 must_fix는 restart·재-delta로 해결을 우회할 수 없다: 사용자 판단으로 멈춘다.
|
|
792
|
+
if (!state.closed && rounds >= ROUND_MODES.length) {
|
|
793
|
+
const folded = foldReports(ctx, repoRoot, cycle.active_reports);
|
|
794
|
+
if (isFail(folded))
|
|
795
|
+
return folded;
|
|
796
|
+
// 대상 섹션을 모두 고쳤다면 멈추지 않고 4(close) 또는 6(restart)으로 내려간다.
|
|
797
|
+
if (folded.findings.some((f) => f.status === 'open' && f.actionability === 'must_fix')
|
|
798
|
+
&& !openMustFixesFixed(folded.findings, contentOf(baseline), contentOf(newPayload))) {
|
|
799
|
+
return route('user', 'context-review-unresolved-findings', 'must_fix remains open after the delta round', 'record explicit user-approved accepted-risk with context-review close, or fix the target section '
|
|
800
|
+
+ 'of every open must_fix: route then guides close or restart', cycle);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
// 4. 구조·계약이 baseline과 같은 본문 수정은 새 round 없이 close로 현재 digest를 인증한다.
|
|
804
|
+
// 닫힌 cycle의 본문 수정도 같은 경로다(닫힌 뒤 stale이 되면 재close).
|
|
805
|
+
const shape = compareShape(baseline, newPayload);
|
|
806
|
+
if (shape.structure && shape.scope) {
|
|
807
|
+
return route('close', 'context-review-close-available', 'body-only change: structure and contract equal the reviewed baseline', 'run context-review close with dispositions; it certifies the current snapshot digest', cycle);
|
|
808
|
+
}
|
|
809
|
+
// 5. 구조가 같고 Interface·Touch만 바뀐 discovery 뒤 닫히지 않은 cycle의 partial만 delta다.
|
|
810
|
+
// 닫힌 cycle은 round 기록이 거절되므로 제외한다.
|
|
811
|
+
if (followUp === 'partial' && shape.structure && !state.closed && rounds === 1) {
|
|
812
|
+
return route('delta', 'context-review-delta-available', 'partial change after discovery with no delta used', 'dispatch one delta round with revision_diff and related_documents', cycle);
|
|
813
|
+
}
|
|
814
|
+
// 6. delta를 이미 썼거나 structural full: 남은 restart가 있을 때만 전체 재시작이다.
|
|
815
|
+
if (cycle.restart_count < RESTART_LIMIT) {
|
|
816
|
+
return route('restart', 'context-review-restart-available', 'delta is used or the change is structural', 'run context-review restart, then dispatch discovery', cycle);
|
|
817
|
+
}
|
|
818
|
+
return route('user', 'context-review-restart-limit', `restart_count ${cycle.restart_count} of ${RESTART_LIMIT} used`, 'continue the current cycle: record findings, resolve them, or ask the user; no further automatic restart', cycle);
|
|
819
|
+
}
|
|
820
|
+
module.exports = {
|
|
821
|
+
startCycle, recordReport, closeCycle, restartCycle, routeFollowUp, readStoredLatest, readLegacyEvidence,
|
|
822
|
+
// 단위 테스트가 symlink·덮어쓰기 거부를 직접 확인하도록 노출한다.
|
|
823
|
+
writeAtomic, writeImmutable,
|
|
824
|
+
};
|