@dzhechkov/keysarium 1.7.7 → 1.7.9

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.
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ // Host checkpoint lineage, not a reviewer-supplied prior array, owns historical conditions.
3
+ // This checks consistency/freshness; it does not authenticate models or judge their evidence.
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from 'node:fs'
6
+ import { dirname, relative, resolve, sep } from 'node:path'
7
+ import { execFileSync } from 'node:child_process'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ export const SCHEMA = 'fa-review-convergence-1'
11
+ const phases = ['ideation', 'qe']
12
+ const severities = ['BLOCKER', 'CRITICAL', 'HIGH', 'WARNING', 'SUGGESTION']
13
+ const kinds = ['structural', 'wording']
14
+ const digest = value => createHash('sha256').update(value).digest('hex')
15
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b)
16
+ const text = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 65536
17
+ const hash = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)
18
+ const array = (value, limit = 512) => Array.isArray(value) && value.length <= limit
19
+ function dictionary(value, keys, required = keys) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => keys.includes(key)) && required.every(key => Object.hasOwn(value, key))
21
+ }
22
+ function strings(value, nonempty = false) { return array(value) && (!nonempty || value.length > 0) && value.every(text) && new Set(value).size === value.length }
23
+ function condition(c, owners) {
24
+ return dictionary(c, ['id', 'owner', 'severity', 'classification', 'requirement', 'scope', 'evidence']) && /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,95}$/.test(c.id) && owners.includes(c.owner) && severities.includes(c.severity) && kinds.includes(c.classification) && text(c.requirement) && strings(c.scope, true) && text(c.evidence)
25
+ }
26
+ function risks(r, owners) { return dictionary(r, ['assessment', 'evidence', 'conditions']) && text(r.assessment) && text(r.evidence) && array(r.conditions) && r.conditions.every(c => condition(c, owners)) }
27
+ function checkpointProof(p) {
28
+ return dictionary(p, ['checkpointDigest', 'evidence', 'supported', 'route', 'findings', 'reportDigest']) && hash(p.checkpointDigest) && text(p.evidence) && p.supported === true && ['native', 'mode-b', 'fallback'].includes(p.route) && array(p.findings) && p.findings.every(v => dictionary(v, ['digest', 'conditionId']) && hash(v.digest) && text(v.conditionId)) && (p.reportDigest === null || hash(p.reportDigest))
29
+ }
30
+ function reviewerFamily(stage) { return stage.qeReviewerUsed === 'codex-fallback' ? 'codex' : stage.qeReviewerUsed }
31
+ function reviewerEntry(r, roster) {
32
+ const owners = roster.map(p => p.id)
33
+ return !(!dictionary(r, ['reviewer', 'family', 'independent', 'verdict', 'conditions', 'verifications', 'newRisks', 'authorAssessmentChecked', 'deltaVerification', 'checkpointVerification'], ['reviewer', 'family', 'independent', 'verdict', 'conditions', 'verifications', 'newRisks', 'authorAssessmentChecked', 'deltaVerification']) || !owners.includes(r.reviewer) || roster.find(p => p.id === r.reviewer)?.family !== r.family || r.independent !== true || !['clean', 'conditional', 'no-go'].includes(r.verdict) || !array(r.conditions) || !r.conditions.every(c => condition(c, owners) && c.owner === r.reviewer) || !array(r.verifications) || !risks(r.newRisks, owners) || !r.newRisks.conditions.every(c => c.owner === r.reviewer) || typeof r.authorAssessmentChecked !== 'boolean')
34
+ }
35
+ function result(phase, verdict, revision, reasons, unresolved = []) { return { schema: SCHEMA, phase, verdict, revision, unresolved, reasons } }
36
+
37
+ export function evaluateReviewConvergence(host, receipt, currentManifest) {
38
+ const phase = host?.phase
39
+ const refuse = reason => result(phase || 'qe', 'not-established', host?.snapshot?.revision || null, [reason], (host?.conditions || []).map(c => c.id))
40
+ if (!dictionary(host, ['schema', 'phase', 'reviewers', 'snapshot', 'conditions', 'reviewSeen', 'rework', 'changedPaths']) || host.schema !== SCHEMA || !phases.includes(phase) || !array(host.reviewers, 16) || host.reviewers.length === 0) return refuse('host-lineage-invalid')
41
+ const owners = host.reviewers.map(r => r.id)
42
+ if (new Set(owners).size !== owners.length || !host.reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family))) return refuse('host-reviewers-invalid')
43
+ if (!dictionary(host.snapshot, ['nonce', 'revision', 'manifest']) || !text(host.snapshot.nonce) || !hash(host.snapshot.revision) || !array(host.conditions) || !host.conditions.every(c => condition(c, owners)) || typeof host.reviewSeen !== 'boolean' || typeof host.rework !== 'boolean' || !strings(host.changedPaths)) return refuse('host-snapshot-invalid')
44
+ if (!same(host.snapshot.manifest, currentManifest) || digest(JSON.stringify(currentManifest)) !== host.snapshot.revision) return refuse('artifact-manifest-changed')
45
+ if (!dictionary(receipt, ['schema', 'phase', 'nonce', 'revision', 'reviews', 'author']) || receipt.schema !== SCHEMA || receipt.phase !== phase || receipt.nonce !== host.snapshot.nonce || receipt.revision !== host.snapshot.revision) return refuse('receipt-phase-or-revision-invalid')
46
+ if (!array(receipt.reviews, 16) || receipt.reviews.length !== owners.length || new Set(receipt.reviews.map(r => r?.reviewer)).size !== owners.length) return refuse('reviewer-set-invalid')
47
+ const all = new Map(host.conditions.map(c => [c.id, c]))
48
+ const verified = new Set()
49
+ const reasons = []
50
+ const needsAuthor = host.rework || (host.reviewSeen && host.conditions.length > 0)
51
+ if (needsAuthor && !receipt.author) return refuse('author-delta-missing')
52
+ if (receipt.author !== null) {
53
+ const a = receipt.author
54
+ if (!dictionary(a, ['addressed', 'changedScope', 'classification', 'delta', 'evidence', 'newRisks']) || !strings(a.addressed) || !strings(a.changedScope, true) || !kinds.includes(a.classification) || !text(a.delta) || !text(a.evidence) || !risks(a.newRisks, owners)) return refuse('author-delta-invalid')
55
+ if (host.changedPaths.some(path => !a.changedScope.includes(path))) return refuse('author-delta-omits-measured-changes')
56
+ if (a.addressed.some(id => !all.has(id))) return refuse('author-addressed-unknown-condition')
57
+ for (const c of a.newRisks.conditions) {
58
+ if (all.has(c.id) && !same(all.get(c.id), c)) return refuse('author-risk-condition-redefined')
59
+ all.set(c.id, c)
60
+ }
61
+ }
62
+ for (const r of receipt.reviews) {
63
+ if (!reviewerEntry(r, host.reviewers)) return refuse('reviewer-contract-invalid')
64
+ if (Object.hasOwn(r, 'checkpointVerification') && !checkpointProof(r.checkpointVerification)) return refuse('checkpoint-verification-contract-invalid')
65
+ if (receipt.author && !r.authorAssessmentChecked) return refuse('author-risk-assessment-not-reviewed')
66
+ if (receipt.author) {
67
+ const d = r.deltaVerification
68
+ if (!dictionary(d, ['classification', 'evidence', 'implementationVerified']) || !kinds.includes(d.classification) || !text(d.evidence) || typeof d.implementationVerified !== 'boolean') return refuse('independent-delta-classification-missing')
69
+ if ((receipt.author.classification === 'structural' || d.classification === 'structural') && (!d.implementationVerified || d.classification !== 'structural')) reasons.push('structural-delta-not-verified:' + r.reviewer)
70
+ } else if (r.deltaVerification !== null) return refuse('unexpected-delta-verification')
71
+ const own = r.conditions.concat(r.newRisks.conditions)
72
+ if (new Set(own.map(c => c.id)).size !== own.length) return refuse('duplicate-condition')
73
+ for (const c of host.conditions.filter(c => c.owner === r.reviewer)) if (!own.some(n => same(n, c))) return refuse('prior-condition-omitted-or-redefined:' + c.id)
74
+ for (const c of own) {
75
+ if (c.scope.some(path => !currentManifest.some(p => p.path === path))) return refuse('condition-scope-unmeasured:' + c.id)
76
+ if (all.has(c.id) && !same(all.get(c.id), c)) return refuse('condition-redefined:' + c.id)
77
+ all.set(c.id, c)
78
+ }
79
+ if (r.verdict === 'clean' && own.length > 0 && r.verifications.length === 0) return refuse('clean-verdict-contradicts-conditions')
80
+ if (r.verdict === 'conditional' && own.length === 0) return refuse('conditional-without-conditions')
81
+ if (r.verdict === 'no-go') reasons.push('reviewer-no-go:' + r.reviewer)
82
+ const seen = new Set()
83
+ for (const v of r.verifications) {
84
+ if (!dictionary(v, ['id', 'revision', 'classification', 'evidence', 'implementationVerified']) || seen.has(v.id) || !all.has(v.id) || all.get(v.id).owner !== r.reviewer || v.revision !== host.snapshot.revision || !kinds.includes(v.classification) || !text(v.evidence) || typeof v.implementationVerified !== 'boolean') return refuse('verification-invalid-or-foreign')
85
+ seen.add(v.id)
86
+ // A semantic change cannot become a weaker check by the author's label.
87
+ const structural = all.get(v.id).classification === 'structural' || receipt.author?.classification === 'structural' || v.classification === 'structural'
88
+ if (structural && (!v.implementationVerified || v.classification !== 'structural')) reasons.push('structural-verification-missing:' + v.id)
89
+ else verified.add(v.id)
90
+ }
91
+ }
92
+ const unresolved = [...all.keys()].filter(id => !verified.has(id))
93
+ for (const id of unresolved) reasons.push('unresolved:' + id)
94
+ if (receipt.author?.newRisks.conditions.some(c => !receipt.reviews.some(r => r.conditions.concat(r.newRisks.conditions).some(n => same(n, c))))) reasons.push('author-risk-not-adopted-by-reviewer')
95
+ return result(phase, reasons.length ? 'unresolved' : 'closed', host.snapshot.revision, reasons, unresolved)
96
+ }
97
+
98
+ function safePath(root, rel, allowMissing = true) {
99
+ if (!text(rel) || rel.startsWith('/') || rel.includes('\\') || rel.split('/').some(p => !p || p === '.' || p === '..') || /[\x00-\x1f]/.test(rel)) throw Error('unsafe-artifact-path')
100
+ let path = root
101
+ for (const part of rel.split('/')) {
102
+ path = resolve(path, part)
103
+ if (!existsSync(path)) { if (allowMissing) return resolve(root, rel); throw Error('artifact-missing:' + rel) }
104
+ if (lstatSync(path).isSymbolicLink()) throw Error('symlink-artifact:' + rel)
105
+ }
106
+ return path
107
+ }
108
+ function readJson(path) { return JSON.parse(readFileSync(path, 'utf8')) }
109
+ function writeJson(path, value) {
110
+ if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw Error('unsafe-checkpoint')
111
+ const temporary = path + '.' + randomUUID() + '.tmp'
112
+ writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', { flag: 'wx' })
113
+ renameSync(temporary, path)
114
+ }
115
+ function pathsFor(repo, feature, phase) {
116
+ const prefix = relative(repo, feature).split(sep).join('/')
117
+ if (!prefix || prefix.startsWith('../')) throw Error('feature-must-be-inside-repo')
118
+ safePath(repo, prefix, false)
119
+ const targets = ['00_complexity_assessment.md', '01_requirements.md', '02_research.md', '04_domain_model.md', '05_architecture.md']
120
+ if (phase === 'qe') targets.push('06_implementation_plan.md', '03.5_ideation_report.md')
121
+ const adr = safePath(repo, prefix + '/03_adr')
122
+ if (existsSync(adr) && !lstatSync(adr).isDirectory()) throw Error('adr-not-directory')
123
+ const decisions = existsSync(adr) ? readdirSync(adr).filter(p => /^[0-9]{3}-.+\.md$/.test(p)).sort() : []
124
+ if (phase === 'ideation' && !decisions.length) throw Error('adr-missing')
125
+ const paths = targets.filter(p => existsSync(resolve(feature, p))).concat(decisions.map(p => '03_adr/' + p)).map(p => prefix + '/' + p)
126
+ for (const required of phase === 'ideation' ? ['01_requirements.md', '05_architecture.md'] : ['01_requirements.md', '06_implementation_plan.md']) if (!paths.includes(prefix + '/' + required)) throw Error('design-input-missing:' + required)
127
+ if (phase === 'qe') {
128
+ const plan = readFileSync(safePath(repo, prefix + '/06_implementation_plan.md', false), 'utf8')
129
+ const block = plan.split(/^EXPECTED_CODE_TARGETS:\s*$/m)
130
+ if (block.length !== 2) throw Error('target-block-missing-or-duplicate')
131
+ const planned = block[1].split('\n').filter(line => line.trim()).map(line => {
132
+ const match = /^- (.+)$/.exec(line)
133
+ if (!match) throw Error('target-block-malformed')
134
+ return match[1]
135
+ })
136
+ if (!planned.length) throw Error('target-block-empty')
137
+ if (planned.some(p => /^(features|\.dz|\.agentic-qe|roam)\//.test(p))) throw Error('circular-review-target')
138
+ paths.push(...planned)
139
+ // Additions/deletions are discovered by the host, not the author delta request.
140
+ const baseFile = resolve(feature, '.fa-state/base-ref')
141
+ const base = existsSync(baseFile) ? readFileSync(baseFile, 'utf8').trim() : 'HEAD'
142
+ if (!/^[A-Za-z0-9_./:-]+$/.test(base) || base.startsWith('-')) throw Error('base-ref-invalid')
143
+ for (const command of [['diff', '--name-only', '-z', base, '--'], ['ls-files', '--others', '--exclude-standard', '-z']]) {
144
+ const names = execFileSync('git', command, { cwd: repo, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }).split('\0').filter(Boolean)
145
+ paths.push(...names.filter(p => !p.startsWith('features/') && !p.startsWith('.dz/') && !p.startsWith('.agentic-qe/') && !p.startsWith('roam/') && p !== 'architecture/map.json'))
146
+ }
147
+ }
148
+ return [...new Set(paths)].sort()
149
+ }
150
+ export function measureManifest(repo, feature, phase) {
151
+ return pathsFor(repo, feature, phase).map(path => {
152
+ const absolute = safePath(repo, path)
153
+ if (!existsSync(absolute)) return { path, digest: null }
154
+ if (!lstatSync(absolute).isFile()) throw Error('non-file-artifact:' + path)
155
+ return { path, digest: digest(readFileSync(absolute)) }
156
+ })
157
+ }
158
+ function checkpointPaths(repo, feature, phase) {
159
+ const stateRel = relative(repo, feature).split(sep).join('/') + '/.fa-state'
160
+ const state = safePath(repo, stateRel)
161
+ mkdirSync(state, { recursive: true })
162
+ return { host: safePath(repo, stateRel + '/review-convergence-' + phase + '-host.json'), review: safePath(repo, stateRel + '/review-convergence-' + phase + '-review.json') }
163
+ }
164
+ function qeCheckpoint(repo, feature) {
165
+ const rel = relative(repo, feature).split(sep).join('/') + '/.fa-state/checkpoints.jsonl'
166
+ const file = safePath(repo, rel)
167
+ if (!existsSync(file)) return null
168
+ let checkpoint = null
169
+ for (const line of readFileSync(file, 'utf8').split('\n').filter(Boolean)) {
170
+ const item = JSON.parse(line)
171
+ if (item.stage === 'qe') checkpoint = item.result
172
+ }
173
+ return checkpoint ? { checkpointDigest: digest(JSON.stringify(checkpoint)), checkpoint } : null
174
+ }
175
+ function seriousFindings(source) {
176
+ return (array(source?.gaps) ? source.gaps : []).filter(gap => [gap.sev, gap.severity, gap.priority].some(v => /(?:^|[^A-Z0-9])(BLOCKER|CRITICAL|HIGH|P0|P1)(?:$|[^A-Z0-9])/.test(String(v || '').toUpperCase())))
177
+ }
178
+ function reconcileCheckpoint(host, receipt, measured, repo, feature) {
179
+ if (!measured) return null
180
+ const stage = measured.checkpoint
181
+ for (const [base, source, family, missing] of [
182
+ ['qe-primary', stage.qe, reviewerFamily(stage), !stage.qe || Boolean(stage.qe?.convergenceUnavailable)],
183
+ ['qe-precision', stage.qe2, 'claude', host.reviewers.some(r => r.id.startsWith('qe-precision')) && (!stage.qe2 || !stage.qe2.reportWritten)],
184
+ ]) {
185
+ const findings = seriousFindings(source)
186
+ if (!missing && !findings.length) continue
187
+ const origin = host.reviewers.find(r => (r.id === base || r.id === base + ':' + family) && r.family === family)
188
+ const review = receipt.reviews.find(r => r.reviewer === origin?.id)
189
+ const proof = review?.checkpointVerification
190
+ if (!origin || !checkpointProof(proof) || proof.checkpointDigest !== measured.checkpointDigest || !text(proof.evidence) || proof.supported !== true || !['native', 'mode-b', 'fallback'].includes(proof.route) || !array(proof.findings) || proof.findings.length !== findings.length || !(proof.reportDigest === null || hash(proof.reportDigest))) return null
191
+ const mapped = new Set()
192
+ for (const gap of findings) {
193
+ const item = proof.findings.find(v => v.digest === digest(JSON.stringify(gap)))
194
+ if (!dictionary(item, ['digest', 'conditionId']) || mapped.has(item.conditionId) || !host.conditions.some(c => c.id === item.conditionId && c.owner === origin.id && ['BLOCKER', 'CRITICAL', 'HIGH'].includes(c.severity)) || !review.verifications.some(v => v.id === item.conditionId && v.revision === host.snapshot.revision && v.implementationVerified === true && v.classification === 'structural')) return null
195
+ mapped.add(item.conditionId)
196
+ }
197
+ if (base === 'qe-precision' && missing) {
198
+ const file = safePath(repo, relative(repo, feature).split(sep).join('/') + '/08_qe_report.md', false)
199
+ const report = readFileSync(file)
200
+ if (digest(report) !== proof.reportDigest || !/## Primary QE pass/.test(report.toString()) || !/## Precision QE pass/.test(report.toString()) || !/Combined Step-8 grade:/.test(report.toString())) return null
201
+ }
202
+ }
203
+ return { ...measured, revision: host.snapshot.revision }
204
+ }
205
+ function retainPendingReceipt(host, path) {
206
+ if (!host || !existsSync(path)) return host
207
+ let receipt
208
+ try { receipt = readJson(path) } catch (_) { throw Error('pending-reviewer-receipt-needs-origin-review') }
209
+ if (!dictionary(receipt, ['schema', 'phase', 'nonce', 'revision', 'reviews', 'author']) || receipt.schema !== SCHEMA || receipt.phase !== host.phase || !text(receipt.nonce) || !hash(receipt.revision) || !array(receipt.reviews, 16) || !receipt.reviews.length || new Set(receipt.reviews.map(r => r?.reviewer)).size !== receipt.reviews.length) throw Error('pending-reviewer-receipt-needs-origin-review')
210
+ const owners = host.reviewers.map(r => r.id), preserved = new Map(host.conditions.map(c => [c.id, c]))
211
+ const fresh = receipt.nonce === host.snapshot.nonce && receipt.revision === host.snapshot.revision
212
+ let problem = false, witnessed = false
213
+ for (const r of receipt.reviews) {
214
+ if (!reviewerEntry(r, host.reviewers) || Object.hasOwn(r, 'checkpointVerification') && !checkpointProof(r.checkpointVerification)) { problem = true; continue }
215
+ const own = r.conditions.concat(r.newRisks.conditions)
216
+ if (new Set(own.map(c => c.id)).size !== own.length) { problem = true; continue }
217
+ witnessed = true
218
+ for (const c of own) {
219
+ // Stale evidence may preserve a known condition; it cannot originate new host authority.
220
+ if (preserved.has(c.id)) { if (!same(preserved.get(c.id), c)) problem = true }
221
+ else if (!fresh || c.scope.some(path => !host.snapshot.manifest.some(p => p.path === path))) problem = true
222
+ else if (condition(c, owners) && c.owner === r.reviewer) preserved.set(c.id, c)
223
+ }
224
+ }
225
+ const next = { ...host, conditions: [...preserved.values()], reviewSeen: host.reviewSeen || witnessed }
226
+ // This imports original independent findings only, never closure, author claims or a grade.
227
+ return { next, problem }
228
+ }
229
+ function bindReviewers(host, requested, measured) {
230
+ if (!host) return requested
231
+ const bound = host.reviewers.map(r => ({ ...r }))
232
+ for (const actual of requested) {
233
+ const prior = bound.find(r => r.id === actual.id)
234
+ if (!prior) throw Error('host-lineage-conflict')
235
+ if (prior.family === actual.family) continue
236
+ const originated = host.reviewSeen || host.conditions.some(c => c.owner === prior.id) || measured && (prior.id === 'qe-primary' && reviewerFamily(measured.checkpoint) === prior.family || prior.id === 'qe-precision' && measured.checkpoint.qe2)
237
+ if (!originated) prior.family = actual.family
238
+ else if (!bound.some(r => r.id === actual.id + ':' + actual.family)) bound.push({ id: actual.id + ':' + actual.family, family: actual.family })
239
+ }
240
+ if (host.reviewers.some(r => !r.id.includes(':') && !requested.some(n => n.id === r.id)) || bound.length > 16) throw Error('host-lineage-conflict')
241
+ return bound
242
+ }
243
+ export function runGate({ action, repo, feature, phase, reviewers }) {
244
+ if (!phases.includes(phase) || !['prepare', 'evaluate'].includes(action)) throw Error('invalid-command')
245
+ repo = realpathSync(repo); feature = resolve(repo, feature)
246
+ const current = measureManifest(repo, feature, phase)
247
+ const paths = checkpointPaths(repo, feature, phase)
248
+ let host = existsSync(paths.host) ? readJson(paths.host) : null
249
+ const measuredCheckpoint = phase === 'qe' ? qeCheckpoint(repo, feature) : null
250
+ if (action === 'prepare') {
251
+ if (!array(reviewers, 16) || !reviewers.length || !reviewers.every(r => dictionary(r, ['id', 'family']) && text(r.id) && ['codex', 'claude', 'owner-exception'].includes(r.family)) || new Set(reviewers.map(r => r.id)).size !== reviewers.length) throw Error('reviewers-invalid')
252
+ if (host && (host.phase !== phase || host.schema !== SCHEMA || !array(host.conditions) || typeof host.reviewSeen !== 'boolean')) throw Error('host-lineage-conflict')
253
+ if (host && existsSync(paths.review)) {
254
+ const pending = retainPendingReceipt(host, paths.review)
255
+ host = pending.next; writeJson(paths.host, host)
256
+ if (pending.problem) throw Error('pending-reviewer-receipt-needs-origin-review')
257
+ }
258
+ reviewers = bindReviewers(host, reviewers, measuredCheckpoint)
259
+ const revision = digest(JSON.stringify(current))
260
+ const snapshot = host?.snapshot?.revision === revision ? host.snapshot : { nonce: randomUUID(), revision, manifest: current }
261
+ const priorManifest = new Map((host?.snapshot?.manifest || []).map(p => [p.path, p.digest]))
262
+ const newManifest = new Map(current.map(p => [p.path, p.digest]))
263
+ const changedPaths = host?.snapshot?.revision === revision ? host.changedPaths : host ? [...new Set([...(host.changedPaths || []), ...[...new Set([...priorManifest.keys(), ...newManifest.keys()])].filter(path => priorManifest.get(path) !== newManifest.get(path))])].sort() : []
264
+ const next = { schema: SCHEMA, phase, reviewers, snapshot, conditions: host?.conditions || [], reviewSeen: host?.reviewSeen || false, rework: Boolean(host && (host.rework || host.reviewSeen && (host.snapshot.revision !== revision || host.conditions.length > 0))), changedPaths }
265
+ writeJson(paths.host, next)
266
+ return { ...result(phase, 'prepared', revision, []), nonce: snapshot.nonce, manifest: current, conditions: next.conditions, reviewers, checkpointDigest: measuredCheckpoint?.checkpointDigest || null }
267
+ }
268
+ if (!host) return result(phase, 'not-established', null, ['host-lineage-missing'])
269
+ if (!existsSync(paths.review)) return result(phase, 'not-established', host.snapshot.revision, ['reviewer-receipt-missing'], host.conditions.map(c => c.id))
270
+ const receipt = readJson(paths.review)
271
+ let verdict = evaluateReviewConvergence(host, receipt, current)
272
+ let closure = null
273
+ if (phase === 'qe' && verdict.verdict === 'closed' && measuredCheckpoint) {
274
+ closure = reconcileCheckpoint(host, receipt, measuredCheckpoint, repo, feature)
275
+ if (!closure) verdict = result(phase, 'not-established', host.snapshot.revision, ['historical-checkpoint-own-verification-missing'])
276
+ }
277
+ // Preserve independently owned findings even on refusal. Never import author/scribe claims.
278
+ // Invalid ownership/meaning refuses without overwriting the prior host authority.
279
+ const owners = host.reviewers.map(r => r.id)
280
+ const preserved = new Map(host.conditions.map(c => [c.id, c]))
281
+ if (receipt?.schema === SCHEMA && receipt.phase === phase && array(receipt.reviews)) {
282
+ for (const r of receipt.reviews) if (owners.includes(r?.reviewer) && host.reviewers.find(p => p.id === r.reviewer)?.family === r.family && r.independent === true) {
283
+ for (const c of [...(array(r.conditions) ? r.conditions : []), ...(array(r.newRisks?.conditions) ? r.newRisks.conditions : [])]) {
284
+ if (condition(c, owners) && c.owner === r.reviewer && !preserved.has(c.id)) preserved.set(c.id, c)
285
+ }
286
+ }
287
+ }
288
+ if (receipt?.phase === phase && receipt.nonce === host.snapshot.nonce && receipt.revision === host.snapshot.revision && risks(receipt.author?.newRisks, owners)) {
289
+ for (const c of receipt.author.newRisks.conditions) if (!preserved.has(c.id)) preserved.set(c.id, c)
290
+ }
291
+ writeJson(paths.host, { ...host, conditions: [...preserved.values()], reviewSeen: true, changedPaths: verdict.verdict === 'closed' ? [] : host.changedPaths })
292
+ if (closure && verdict.verdict === 'closed') return { ...verdict, checkpointClosure: closure }
293
+ return verdict
294
+ }
295
+
296
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
297
+ let phase = 'qe'
298
+ try {
299
+ const args = process.argv.slice(2)
300
+ const action = args.shift()
301
+ const options = {}
302
+ while (args.length) {
303
+ const key = args.shift()
304
+ if (!['--repo', '--feature', '--phase', '--reviewers'].includes(key) || !args.length || Object.hasOwn(options, key)) throw Error('invalid-arguments')
305
+ options[key] = args.shift()
306
+ }
307
+ phase = options['--phase']
308
+ if (!options['--repo'] || !options['--feature'] || !phases.includes(phase)) throw Error('invalid-arguments')
309
+ const output = runGate({ action, repo: options['--repo'], feature: options['--feature'], phase, reviewers: options['--reviewers'] ? JSON.parse(options['--reviewers']) : undefined })
310
+ process.stdout.write(JSON.stringify(output) + '\n')
311
+ process.exitCode = ['closed', 'prepared'].includes(output.verdict) ? 0 : output.verdict === 'unresolved' ? 1 : 3
312
+ } catch (error) {
313
+ process.stdout.write(JSON.stringify(result(phases.includes(phase) ? phase : 'qe', 'not-established', null, [String(error.message)])) + '\n')
314
+ process.exitCode = 3
315
+ }
316
+ }